Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

integrations

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.

The pattern

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.


Mounting integrations

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.

Auth

The router owns the OAuth gate — one login covers every integration.

auth valueWho gets in
omitted (default)Any logged-in Val Town account
{ users: "alice" }Only @alice
{ users: ["alice", "bob"] }Only @alice or @bob
falseNo 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.

Reserved paths

The router keeps / (the directory page) and /auth/* (the OAuth routes) for itself. Integration slugs are validated against these — see reserved slugs.


Writing an integration

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.

The Integration contract

defineIntegration takes one object:

FieldTypeRequiredMeaning
namestringyesHuman-readable label shown on the directory page.
slugstringyesURL-safe id; the integration is served at /<slug>/.
descriptionstringnoShown under the name on the directory page.
requiredEnvstring[]noEnv vars this integration reads; unset ones badge the page.
handlerIntegrationHandleryesThe fetch handler: (req, ctx?) => Response | Promise<…>.

The factory convention

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.

The handler and its context

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.

Dual-mode: one factory, two mounts

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 Location headers, prepending your prefix — so the redirect-after-POST idiom c.redirect("/?ok=1") becomes /<slug>/?ok=1 automatically. 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.prefix or 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.

requiredEnv

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).

Reserved slugs

createRouter and defineIntegration reject invalid or reserved slugs by throwing at construction (fail fast, not at request time):

  • Must match ^[a-z0-9-]+$.
  • auth is reserved (std/oauth owns /auth/*).
  • The _-prefixed namespace is reserved forever for router-owned surface.
  • Slugs must be unique within a router.

Permission composition

The router authenticates; your integration authorizes. Read ctx.user and decide what that user may do:

  • ctx.user is a username when the router's gate let them in.
  • ctx.user is null when 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.)

Testing without OAuth

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.


API reference

All exports come from mod.ts:

ExportKindPurpose
createRouterfunctionBuild the HTTP handler that mounts integrations (users).
defineIntegrationfunctionWrap an Integration into a dual-mode callable (authors).
createRouteHandlerfunctionOAuth-free router core with injected identity resolver (test seam).
IntegrationtypeThe contract object: name, slug, handler, …
CallableIntegrationtypeIntegration & IntegrationHandler — the dual-mode value.
IntegrationHandlertype(req, ctx?) => Response | Promise<Response>.
IntegrationContexttype{ user: string | null; prefix: string }.
RouterOptionstypeArgument to createRouter.
RouterAuthOptionstypeThe auth field: { users?: string | string[] } | false.

Publishing and versioning

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.

Development

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).