A contract and router for mounting reusable web apps — integrations — into a
single Val Town val. One val, one login, one URL; many project-level tools
served underneath it.
https://you-integrations.web.val.run/ → directory page
/issues/ → the issue tracker
/blobs/ → the blob viewer
/hello/ → your integration
The router mounts each integration in-process and dispatches by path prefix
(no per-integration redirects, no separate logins). It owns authentication,
serves a directory page at /, and passes each integration a small context —
the logged-in user and its mount prefix. See docs/adr/ for the
architecture decisions behind this.
An integration is a mountable sub-app over a Val Town scoped resource: a
self-contained web app — UI, routes, and its own authorization — wrapped around
one val-scoped thing (a SQLite-backed domain, the blob store). The contract here
is that shape extracted from two apps that already had it
(val-issue-tracker and
scoped-blob-viewer).
What makes it more than a mounted sub-router is Val Town's scoping: because scoped SQLite/blob bind to whichever val's runtime is executing the code, an integration operates on its host val's data and identity just by being imported — no tokens, no config. Three properties fall out of that:
- Host-scoped, not self-scoped — drop it into any val and it points at that val's storage automatically.
- Portable / dual-mode — the same value runs standalone (a val's HTTP
export) or routed (mounted at
/slug/), thanks to relative-only links and a callable contract. - Composable — it keeps its own authorization but lifts authentication up to the host or router, so many integrations stack behind one origin and one login.
The closest analogies are Rails engines or Hono/Express sub-apps mounted at a prefix — but host-scoped, so mounting also inherits the host's data and identity for free.
There are two audiences below: mounting integrations if you want to run a router, and writing an integration if you want to build one.
Import the integrations you want and hand them to createRouter. Its return
value is a plain HTTP handler — make it your val's default export.
import { createRouter } from "https://esm.town/v/nbbaier/integrations/mod.ts";
import { issueTracker } from "https://esm.town/v/nbbaier/val-issue-tracker/mod.ts";
import { blobAdmin } from "https://esm.town/v/nbbaier/scoped-blob-viewer/mod.ts";
export default createRouter({
title: "My Tools",
auth: { users: "nbbaier" },
integrations: [issueTracker({}), blobAdmin({})],
});
Each integration is served at /<slug>/, listed on the directory page at /,
and shares the host val's scoped SQLite and blob storage. See
INTEGRATIONS.md for the registry of known integrations and
their import paths.
The router owns the OAuth gate — one login covers every integration.
auth value | Who gets in |
|---|---|
| omitted (default) | Any logged-in Val Town account |
{ users: "alice" } | Only @alice |
{ users: ["alice", "bob"] } | Only @alice or @bob |
false | No gate — the router is open, user is null |
When the gate is on, set an OAUTH_STATE_ENCRYPTION_KEY env var (any random
32-byte hex string) or login will fail. The login page warns you if it's
missing, and the directory page badges any integration whose requiredEnv vars
are unset.
The router keeps / (the directory page) and /auth/* (the OAuth routes) for
itself. Integration slugs are validated against these — see
reserved slugs.
An integration is metadata plus a fetch handler. You ship an
(options) => Integration factory; users register the factory's return
value with a router, or mount it standalone as a val's HTTP export. The same
value serves both modes.
Here is the entire worked example (example/hello.ts) —
copy it to start your own. Every line is contract surface, not app logic. A
line-by-line walkthrough lives in example/README.md.
import { defineIntegration } from "https://esm.town/v/nbbaier/integrations/mod.ts";
export interface HelloWorldOptions {
subject?: string;
}
export function helloWorld(options: HelloWorldOptions = {}) {
const subject = options.subject ?? "world";
return defineIntegration({
name: "Hello World",
slug: "hello",
description: "The smallest possible integration — the worked example.",
requiredEnv: ["HELLO_GREETING"],
handler: (req, ctx) => {
const greeting = Deno.env.get("HELLO_GREETING") ?? "Hello";
const who = ctx?.user ? `@${ctx.user}` : subject;
const body = new URL(req.url).pathname === "/about"
? `<p>A toy integration.</p><p><a href="./">back</a></p>`
: `<h1>${greeting}, ${who}!</h1><p><a href="about">about</a></p>`;
return new Response(`<!doctype html><body>${body}</body>`, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
},
});
}
export default helloWorld();
The comments those lines used to carry now live in the walkthrough; the prose below covers the same ground for the contract as a whole.
defineIntegration takes one object:
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Human-readable label shown on the directory page. |
slug | string | yes | URL-safe id; the integration is served at /<slug>/. |
description | string | no | Shown under the name on the directory page. |
requiredEnv | string[] | no | Env vars this integration reads; unset ones badge the page. |
handler | IntegrationHandler | yes | The fetch handler: (req, ctx?) => Response | Promise<…>. |
Export a function, not a bare Integration. Configuration goes in the closure,
never on the object — that keeps the Integration type free of app-specific
options and lets users write myFactory({ ...config }). If your integration
needs no config, take an options bag anyway (options = {}) so the calling
convention stays uniform.
type IntegrationHandler = (
req: Request,
ctx?: IntegrationContext,
) => Response | Promise<Response>;
interface IntegrationContext {
user: string | null; // Val Town username from the router's OAuth; null if the gate is off.
prefix: string; // Mount prefix, e.g. "/hello".
}
ctx is present when routed, absent when standalone — its absence is how
your handler knows no router is in front of it. ctx?.user is the dual-mode
idiom. If you use Hono, ctx arrives as c.env.
defineIntegration returns a callable integration — the Integration
object made directly callable as an HTTP handler
(CallableIntegration = Integration & IntegrationHandler). So the same return
value works both ways with no branching:
export default helloWorld(); // standalone HTTP val
createRouter({ integrations: [helloWorld()] }); // routed
The platform calls the standalone export with no ctx, which is exactly the "no
router" signal the contract already defines. See
ADR 0003.
The router mounts you under /<slug>/. To survive that, follow one rule:
- Use relative URLs (
href="about",href="./") — never root-absolute (href="/about"). Relative links resolve inside the mount prefix when routed and at the root when standalone, with zero branching. The router canonicalizes/<slug>→/<slug>/so relative resolution always has a trailing-slash base. - Redirects are handled for you. The router rewrites root-absolute
Locationheaders, prepending your prefix — so the redirect-after-POST idiomc.redirect("/?ok=1")becomes/<slug>/?ok=1automatically. HTML bodies are never rewritten; that stays your job (relative links). See ADR 0002. - To reach outside your mount (the router root, a sibling), use
ctx.prefixor a full absolute URL — a bare/...won't get there by design.
Two real integrations (val-issue-tracker, scoped-blob-viewer) migrated onto
this with zero contract fights. If a rule ever needs a wall of caveats in your
app, that's a bug in the contract — open an issue rather than working around it.
List the env vars you read. The router checks them at request time and badges
the directory page for any that are unset — it never blocks the integration.
Read the vars with Deno.env.get() at request time, never at import time
(import-time reads run before the val's env is available and break standalone
mounting).
createRouter and defineIntegration reject invalid or reserved slugs by
throwing at construction (fail fast, not at request time):
- Must match
^[a-z0-9-]+$. authis reserved (std/oauth owns/auth/*).- The
_-prefixed namespace is reserved forever for router-owned surface. - Slugs must be unique within a router.
The router authenticates; your integration authorizes. Read ctx.user
and decide what that user may do:
ctx.useris a username when the router's gate let them in.ctx.userisnullwhen the router is ungated (auth: false) — meaning no login exists at all, so don't redirect to one; degrade to a read-only or public view.- If your integration has its own credential check (e.g. a bearer-token API), it stacks behind the router's gate when the router has one. A headless client that can't hold an OAuth session should target your standalone or an ungated mount. (v1 accepts this stacking deliberately — see the map's out-of-scope notes.)
createRouteHandler is the public test seam: the router core with identity
resolution injected, so you can exercise routed mode without a real session. The
resolver returns { user, owner } — who is logged in and who owns the host val.
import { createRouteHandler } from "https://esm.town/v/nbbaier/integrations/mod.ts";
const handler = createRouteHandler(
{ integrations: [helloWorld()], auth: false },
// inject the "logged-in" user (and, if it matters, the host owner)
() => Promise.resolve({ user: "alice", owner: null }),
);
const res = await handler(new Request("https://example.com/hello/about"));
You can also call your integration's .handler(req, ctx) directly with a
hand-built ctx to test dispatch in isolation. See
example/hello_test.ts.
All exports come from mod.ts:
| Export | Kind | Purpose |
|---|---|---|
createRouter | function | Build the HTTP handler that mounts integrations (users). |
defineIntegration | function | Wrap an Integration into a dual-mode callable (authors). |
createRouteHandler | function | OAuth-free router core with injected identity resolver (test seam). |
Integration | type | The contract object: name, slug, handler, … |
CallableIntegration | type | Integration & IntegrationHandler — the dual-mode value. |
IntegrationHandler | type | (req, ctx?) => Response | Promise<Response>. |
IntegrationContext | type | { user: string | null; prefix: string }. |
RouterOptions | type | Argument to createRouter. |
RouterAuthOptions | type | The auth field: { users?: string | string[] } | false. |
Import the contract from mod.ts unpinned for the latest v1:
import { createRouter } from "https://esm.town/v/nbbaier/integrations/mod.ts";
Pin a version with Val Town's ?v= escape hatch if you need to
(…/mod.ts?v=42). mod.ts is the stable v1 entrypoint — breaking changes ship
as a new entrypoint, never on mod.ts. Post-v1, main is the release
channel: changes land through a branch with a green deno task check.
deno task check # fmt + lint + typecheck + test
Biome (tab-indented) is the canonical formatter. Tests live alongside the code
(router_test.ts, example/hello_test.ts).