/** * `createWorkerEntry` — the shared worker `fetch` plumbing every Astrale domain * worker (and the cloudflare adapter's codegen) needs, so it lives in ONE place * instead of being copy-pasted per worker: * * • resolve the serving URL (== `iss`), canonicalize it (so the value matches * what `createRemoteServer` signs with and the kernel pins), and cache the * built app per distinct URL; * • optional subrequest routing: a `globalThis.fetch` override (installed ONLY * when `selfBinding` and/or `routeSubrequest` is configured) redirects * certain outbound fetches through a caller-supplied binding — `selfBinding` * for same-host fetches (a Worker can't fetch its own hostname), and the * vendor-neutral `routeSubrequest` for any caller policy (e.g. instance * hostnames a same-zone Worker→Worker fetch would 522 on). The SDK names no * backend or topology; the caller owns the predicate + the fetcher; * • optional SPA hook (e.g. `/ui/*` served from an `ASSETS` binding). * * The worker's OWN JWKS (`/.well-known/jwks.json`) is served as a normal * route by `createRemoteServer`; the verifier resolves a self-issued credential * from the in-memory key (see `auth/verify.ts`), so no self-fetch shim is needed. * * The worker file is then just schema + methods + a `build(url, env)` callback. */ import type { ExecutionContext } from 'hono' import type { RemoteServerConfig } from './config.js' import { createRemoteServer } from './create.js' import { requireEnv } from './require-env.js' import { canonicalizeServingUrl } from './serving-url.js' export type Fetcher = { fetch(request: Request): Response | Promise } export type WorkerApp = { fetch( request: Request, env?: unknown, executionCtx?: ExecutionContext, ): Response | Promise } /** A built app cached by its serving URL: `origin` for same-origin matching, * `app` for in-process self-dispatch. */ type CachedApp = { origin: string; app: WorkerApp } /** * Choose where an OUTBOUND subrequest to `u` is routed — or `null` to let the * real `fetch` handle it. Order matters and same-origin wins FIRST: a call to * this worker's OWN serving origin is a self-dispatch, not an edge fetch — and * the caller's `routeSubrequest` policy may itself match our own host (e.g. an * `isInstanceHost` predicate matches every `*.svc.astrale.ai`, ours included), * so it must never get first look at a same-origin call. * * • same-origin + SELF binding present → the SELF fetcher: a fresh same-script * invocation. The normal Cloudflare path — a Worker can't fetch its own * hostname over the edge, so it re-enters itself through the binding. * • same-origin + NO SELF binding → the cached app, dispatched IN-PROCESS. A * Workers-for-Platforms dispatch-namespace tenant can't service-bind to * itself (its script name is platform-renamed), so it has no SELF binding; an * in-process dispatch reaches the same script with no edge hop and no binding. * `App` and the SELF `Fetcher` share the `{ fetch(request) }` shape, so the * caller drives both identically. * • else, the caller's `routeSubrequest` policy matched → its fetcher. * • else → `null`: passthrough to the real network fetch. * * Pure (no closure over instance state) so the routing decision is unit-testable * without standing up a real app. */ export function selectSubrequestTarget( u: URL, ctx: { self: Fetcher | null apps: Iterable routeEnv: TDeps | null routeSubrequest?: (url: URL, env: TDeps) => Fetcher | null | undefined }, ): { fetch(request: Request): Response | Promise } | null { // Same-origin self-subrequest → the same script (SELF binding, else in-process). for (const cached of ctx.apps) { if (cached.origin === u.origin) return ctx.self ?? cached.app } // Caller policy (e.g. a platform router on the same zone) → its fetcher. if (ctx.routeSubrequest && ctx.routeEnv) { const via = ctx.routeSubrequest(u, ctx.routeEnv) if (via) return via } return null } export interface WorkerEntryConfig { /** * Build the `createRemoteServer` config for the resolved serving `url`. Called * once per distinct URL (the resulting app is cached), with the same `env` the * request carries — so it can read additional bindings (e.g. a base domain). */ build: (url: string, env: TDeps) => RemoteServerConfig /** * Resolve the raw serving URL from `env` (+ the per-request origin, for workers * that fall back to the request host). Defaults to the `WORKER_URL` env var. * The result is always canonicalized before use. * * The `requestOrigin` honors an `X-Forwarded-Proto: https` upgrade (see * `clientOrigin`), so a dev worker behind a TLS-terminating proxy (cloudflared * tunnel, reverse proxy) resolves its public `https://` origin, not the raw * `http://` one workerd sees. */ resolveUrl?: (env: TDeps, requestOrigin: string) => string /** Optional: the `SELF` service binding used to route same-host subrequests. */ selfBinding?: (env: TDeps) => Fetcher | null | undefined /** * Optional, vendor-neutral: route an OUTBOUND subrequest through a * caller-supplied fetcher instead of the network — for hosts a Worker can't * reach directly over the edge (e.g. a platform router on the SAME zone: * Cloudflare 522s a same-zone Worker→Worker public `fetch`). Return a `Fetcher` * to route the request through, or null/undefined to fall through to the normal * fetch. The SDK names no backend or topology — the CALLER owns BOTH the * predicate (which hosts) and the fetcher (the binding). This generalizes * `selfBinding` (same-origin → SELF) to any caller policy; both are honored. */ routeSubrequest?: (url: URL, env: TDeps) => Fetcher | null | undefined /** * Optional: handle a request before it reaches the kernel app — e.g. serve a * SPA under `/ui/*` or a same-origin `/api/*` endpoint the view calls. Return * a `Response` to short-circuit, or `undefined` to fall through to the domain * dispatch. */ before?: ( env: TDeps, url: URL, request: Request, ) => Response | undefined | Promise /** * Optional: transform the request on the fall-through path, just before it * reaches the kernel app (e.g. rewrite the hostname for wildcard-subdomain * routing). Not applied when `before` short-circuits. */ rewriteRequest?: (env: TDeps, request: Request) => Request } export interface AppWorkerEntryConfig extends Omit, 'build'> { buildApp: (url: string, env: TDeps) => WorkerApp } export interface WorkerEntry { fetch(request: Request, env: TDeps, executionCtx?: ExecutionContext): Response | Promise } /** * The request origin as the CLIENT reached it — i.e. the origin a fallback * serving URL (and therefore the `iss`) may be derived from. Behind a * TLS-terminating proxy (a cloudflared tunnel in front of `wrangler dev`, any * reverse proxy) the worker sees plain HTTP, so `request.url` says `http://…` * while the public URL is `https://…`; the proxy advertises the original * scheme via `X-Forwarded-Proto`. Honoring it is restricted to the http→https * UPGRADE of the SAME host (never a downgrade, never a host change), so a * spoofed header can at worst derive an `iss` the kernel's JWKS check then * fails — it can never make this worker speak for another origin. */ export function clientOrigin(url: URL, request: Request): string { if (url.protocol !== 'http:') return url.origin const forwarded = request.headers.get('x-forwarded-proto') // Multiple proxies append: "https, http" — the first hop is the client-facing one. const scheme = forwarded?.split(',')[0]?.trim().toLowerCase() return scheme === 'https' ? `https://${url.host}` : url.origin } /** * Build a `before` hook that serves a static-asset `binding` (e.g. a Workers * Assets binding) mounted under `base` (default `/ui`) — the runtime half of an * adapter env's client-asset config. Returns `undefined` for non-matching paths * (and when no binding is present) so the request falls through to domain dispatch. * * Asset URLs are rooted at `base` (a client bundler sets `base: '/'`); * this hook strips that prefix before delegating to the binding, so * `/x.js` resolves from the binding's root. When `devProxy` yields a URL * (local dev), requests are proxied there instead — the seam for a bundler's * HMR dev server. Whether unknown sub-paths fall back to `index.html` is the * binding's own concern (e.g. wrangler's `not_found_handling`), not baked in * here. * * Lives here, type-checked and testable, instead of being emitted as a string * by every adapter's worker codegen. */ export function assets(opts: { base?: string binding: (env: TDeps) => Fetcher | null | undefined devProxy?: (env: TDeps) => string | null | undefined }): (env: TDeps, url: URL, request: Request) => Response | Promise | undefined { const base = (opts.base ?? '/ui').replace(/\/+$/, '') const prefix = `${base}/` return (env, url, request) => { const binding = opts.binding(env) if (!binding) return undefined if (url.pathname !== base && !url.pathname.startsWith(prefix)) return undefined const devUrl = opts.devProxy?.(env) if (devUrl) { const devBase = devUrl.replace(/\/+$/, '') return fetch(new Request(`${devBase}${url.pathname}${url.search}`, request)) } // `` → `/`, `/x` → `/x`: serve from the binding's root. const stripped = url.pathname.slice(base.length) || '/' const rewritten = new URL(stripped + url.search, url.origin) return binding.fetch(new Request(rewritten, request)) } } export function createWorkerEntry(config: WorkerEntryConfig): WorkerEntry { return createAppWorkerEntry({ ...config, buildApp: (url, env) => createRemoteServer(config.build(url, env)).app, }) } export function createAppWorkerEntry( config: AppWorkerEntryConfig, ): WorkerEntry { // Cache the built app per distinct resolved URL — plural and bounded. On the // request-origin fallback the URL legitimately alternates for one worker // (direct http hits vs https-upgraded tunnel hits, workers.dev + custom // domain), and a single slot would tear down and rebuild the whole app on // every alternation. The bound caps abuse via attacker-minted Host / // X-Forwarded-Proto values on that same fallback path. const MAX_CACHED_APPS = 4 const apps = new Map() let self: Fetcher | null = null let routeEnv: TDeps | null = null function getApp(url: string, env: TDeps): WorkerApp { const cached = apps.get(url) if (cached) return cached.app const app = config.buildApp(url, env) if (apps.size >= MAX_CACHED_APPS) { const oldest = apps.keys().next().value if (oldest !== undefined) apps.delete(oldest) } apps.set(url, { origin: new URL(url).origin, app }) return app } // A Worker can't fetch its own hostname over the edge, nor — on Cloudflare — a // same-zone hostname routed to another Worker (the `routeSubrequest` case). // When either is configured, override `globalThis.fetch` to redirect those // subrequests through the right target (see `selectSubrequestTarget`). Workers // with neither selfBinding nor routeSubrequest get no global mutation. (A // self-issued credential's JWKS is resolved in-memory by the verifier, not // fetched — see `auth/verify.ts` — so no self-JWKS shim is needed.) if (config.selfBinding || config.routeSubrequest) { const originalFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url try { const target = selectSubrequestTarget(new URL(href), { self, apps: apps.values(), routeEnv, routeSubrequest: config.routeSubrequest, }) if (target) return target.fetch(new Request(input, init)) } catch { // non-absolute URL — fall through to the original fetch } return originalFetch(input, init) }) as typeof fetch } return { async fetch(request: Request, env: TDeps, executionCtx?: ExecutionContext): Promise { if (config.selfBinding) self ??= config.selfBinding(env) ?? null if (config.routeSubrequest) routeEnv = env // Only parse the request URL when a hook actually needs it. const requestUrl = config.before || config.resolveUrl ? new URL(request.url) : null if (config.before && requestUrl) { // Await so an async `before` that resolves to `undefined` falls through // (a returned Promise would otherwise be sent as the response). const handled = await config.before(env, requestUrl, request) if (handled !== undefined) return handled } const raw = config.resolveUrl ? config.resolveUrl(env, clientOrigin(requestUrl!, request)) : requireEnv(env, 'WORKER_URL', "the worker's public serving URL (its iss identity)") const url = canonicalizeServingUrl(raw) const dispatched = config.rewriteRequest ? config.rewriteRequest(env, request) : request return getApp(url, env).fetch(dispatched, env, executionCtx) }, } }