/** * The patterns route: the server half of a `system:` pattern origin. * * A piece that records such an origin follows it by fetching a path under * {@link PATTERNS_ROUTE_PREFIX} from the host serving its space — the source * itself, or, with `?identity`, the content identity of the program that * source is the entry of: its authored import closure, that of each source * root the request names, and the data files those closures read — with, where * the deployment states one, the time it released what it serves, which orders * two such identities (`pattern-release-time.ts`). This is what answers. * * Both halves of that exchange belong to the runner. The route prefix is part * of a pattern's content identity, because a compiled module is named by its * URL pathname; and the identity a request asks for is the value a worker * stores as `patternIdentity.identity` when it compiles the same source over * HTTP. A host serving this route has to reproduce both exactly, and an answer * that reproduces them imperfectly is one no runtime can adopt. So the route * is defined once here, and each host — the toolshed, a test harness hosting * storage in its own process — supplies its directories and composes the * answer with whatever else it serves. * * Deno-only: it reads files. Single-file reads only, never a directory * listing, so it behaves the same in a compiled binary's embedded file system * as it does on a source tree. */ import { toFileUrl } from "@std/path/to-file-url"; import { compareETags, createCacheHeaders, generateETag, } from "@commonfabric/static/etag"; import { LRUCache } from "@commonfabric/utils/cache"; import { decode, decodeDataFile } from "@commonfabric/utils/encoding"; import { stringTupleKey } from "@commonfabric/utils/string-tuple-key"; import { formatReleaseTime, RELEASED_AT_HEADER, } from "../pattern-release-time.ts"; import { PATTERNS_ROUTE_PREFIX } from "../pattern-source-scheme.ts"; import { resolveEntryIdentity } from "./entry-identity.ts"; /** * A directory of pattern source files reached under a route path prefix. The * prefix is stripped before the rest of the path is resolved inside the * directory, so a connector's patterns answer at a stable route of their own * whatever their location on disk. */ export interface PatternSourceDirectory { /** The route path prefix, ending in `/`, e.g. `github-activity/`. */ readonly routePrefix: string; /** The directory on disk holding the files that prefix names. */ readonly directory: string; } /** What a single-file request asks for beyond the file itself. */ export interface PatternFileRequest { /** Answer with the entry closure's identity rather than the source. */ identity?: boolean; /** Retained source entries, named by their full patterns-route pathname. */ sourceRoots?: readonly string[]; /** The request's `If-None-Match`, which a matching ETag answers 304 to. */ ifNoneMatch?: string | null; } /** What a deployment says about the patterns it serves as a whole. */ export interface PatternsRouteOptions { /** * When the deployment released what it serves, in milliseconds since the * epoch (`pattern-release-time.ts`). Sent beside every identity the route * advertises; absent, the route advertises identities alone. */ readonly releasedAt?: number; } /** The route over one set of directories, and everything it answers. */ export class PatternsRoute { #baseUrl: URL; #extraSources: Array<{ routePrefix: string; baseUrl: URL }>; #releasedAt: number | undefined; /** * Each requested program's content identity. Pattern files are fixed for * the process's lifetime; request-selected root combinations have bounded * retention. A rejected computation is evicted so a transient * failure (e.g. an incomplete closure during a partial deploy) can be * retried. */ #identityCache = new LRUCache>({ capacity: 256 }); /** * `root` holds the patterns every route path reaches by default. * `extraSources` name directories answering for a prefix of their own, and * are consulted before `root`. */ constructor( root: string, extraSources: readonly PatternSourceDirectory[] = [], options: PatternsRouteOptions = {}, ) { this.#baseUrl = directoryUrl(root); this.#extraSources = extraSources.map((source) => ({ routePrefix: source.routePrefix, baseUrl: directoryUrl(source.directory), })); this.#releasedAt = options.releasedAt; } /** * A pattern file's content as bytes. * * A path that names no file is not found, whichever of the three ways it * fails to name one: nothing is there, it names a directory, or it * continues below a file. The read reports each differently, and a caller * asking for a pattern has the same answer for all three. */ async get(filename: string): Promise { const url = this.#resolve(filename); try { return await Deno.readFile(url); } catch (error) { if ( error instanceof Deno.errors.NotFound || error instanceof Deno.errors.IsADirectory || error instanceof Deno.errors.NotADirectory ) { throw new Error(`Pattern file not found: ${filename}`); } throw error; } } /** A pattern file's content as text. */ async getText(filename: string): Promise { const buffer = await this.get(filename); return decode(buffer); } /** * The content-addressed identity of a pattern entry — the value advertised * to runtimes through `?identity`. Reads the authored import closures of the * entry and of each source root, and each data file those closures read with * `dataFile()`, and hashes the pristine bytes; no compiler, runtime, or * storage is involved. An updater * independently compiles the downloaded program and requires its entry ref * to have this identity before replacing a root. * * `filename` is the same root-relative path `getText` accepts, e.g. * `system/default-app.tsx`. `sourceRoots` adds up to 32 distinct, canonical * `/api/patterns/` pathnames to the closure. The bounded cache keys entries * by filename and sorted roots. Rejects invalid roots, an incomplete closure, * a data file this route cannot serve, or a `cf:` fabric import, which the * light path does not model. */ identity( filename: string, sourceRoots: readonly string[] = [], ): Promise { const roots = [...new Set(sourceRoots)].sort(); if (roots.length > 32 || !roots.every(validSourceRoot)) { throw new Error("Invalid source root path"); } const key = stringTupleKey([filename, ...roots]); let cached = this.#identityCache.get(key); if (!cached) { // Name modules by their URL pathname so the identity equals the one the // worker computes when it compiles the same source over HTTP. Each file // is read as a `GET` of that pathname would serve it, and a data file is // decoded as a program stores one, byte order mark included, rather than // as source text. cached = resolveEntryIdentity( `${PATTERNS_ROUTE_PREFIX}${filename}`, async (name) => decode(await this.#readServed(name)), { sourceRoots: roots, readDataFile: async (name) => decodeDataFile(await this.#readServed(name), name), }, ); this.#identityCache.put(key, cached); const pending = cached; cached.catch(() => { if (this.#identityCache.get(key) === pending) { this.#identityCache.delete(key); } }); } return cached; } /** * This route's answer to `request`, or `undefined` when the request does not * address the route — a path outside the prefix, or a method the route does * not serve. A host composes that answer with whatever else it serves, and * answers the leftovers itself. */ async serve(request: Request): Promise { if (request.method !== "GET" && request.method !== "HEAD") return undefined; const url = new URL(request.url); if (!url.pathname.startsWith(PATTERNS_ROUTE_PREFIX)) return undefined; const encoded = url.pathname.slice(PATTERNS_ROUTE_PREFIX.length); if (encoded.length === 0) return undefined; // The route's own router decodes the path once, so `serveFile` receives // the same shape from either entry point. let filename: string; try { filename = decodeURIComponent(encoded); } catch { return invalidPatternPath(); } return await this.serveFile(filename, { identity: url.searchParams.has("identity"), sourceRoots: url.searchParams.getAll("sourceRoot"), ifNoneMatch: request.headers.get("If-None-Match"), }); } /** * The route's answer for one file, named by its path under the route prefix. * A host whose own router has already picked the path apart calls this * instead of {@link serve}. * * The path is checked before it is resolved, by {@link servesFilename}. */ async serveFile( filename: string, options: PatternFileRequest = {}, ): Promise { const ifNoneMatch = options.ifNoneMatch ?? null; try { if (!servesFilename(filename)) return invalidPatternPath(); if (options.identity) { const identity = await this.identity(filename, options.sourceRoots); const releasedAt = this.#releasedAt; // The release time is part of the answer, so it is part of the // validator: revalidating after a redeploy of unchanged source gets a // 200 carrying the new time rather than a 304 over a cached one. return patternResponse( identity, "text/plain; charset=utf-8", releasedAt === undefined ? `"${identity}"` : `"${identity}.${releasedAt}"`, ifNoneMatch, releasedAt, ); } // Hash and serve the exact bytes so the ETag is a strong validator for // the representation the client caches. const content = await this.get(filename); return patternResponse( content as BodyInit, "text/typescript-jsx; charset=utf-8", await generateETag(content), ifNoneMatch, ); } catch (error) { const { status, body } = classifyPatternError(error); if (status === 500) console.error("Error serving pattern file:", error); return Response.json(body, { status }); } } /** * Helper for `identity()`, which reads one file of a program by the URL * pathname the program names it by, exactly as a `GET` of that pathname is * served. A file the route would refuse to serve is refused here too: no * worker can fetch it, so no worker could compile the program it belongs to. */ async #readServed(pathname: string): Promise { const filename = servedFilename(pathname); if (filename === undefined) throw new UnservedPatternFileError(pathname); return await this.get(filename); } #resolve(filename: string): URL { const source = this.#extraSources.find(({ routePrefix }) => filename.startsWith(routePrefix) ); const baseUrl = source?.baseUrl ?? this.#baseUrl; const relative = source === undefined ? filename : filename.slice(source.routePrefix.length); const url = new URL(relative, baseUrl); if (!url.href.startsWith(baseUrl.href)) { throw new Error("Path traversal detected"); } return url; } } /** * Headers shared by source and `?identity` responses, with the deployment's * release time when `releasedAt` is given. Both validator and release time are * exposed, so a runtime served from another origin can read them. */ export function patternResponseHeaders( contentType: string, etag: string, releasedAt?: number, ): Record { return { "Content-Type": contentType, ...createCacheHeaders(etag), ...(releasedAt === undefined ? {} : { [RELEASED_AT_HEADER]: formatReleaseTime(releasedAt) }), "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Expose-Headers": `ETag, ${RELEASED_AT_HEADER}`, }; } /** * Map a pattern-serving error to an HTTP status and body: a missing file → * 404; a structurally invalid entry (incomplete import closure, a `cf:` * fabric import the light `?identity` path does not model, a file the route * would not serve, or a data file that is not valid UTF-8) → 400 with the * reason; an invalid retained root path or excessive root count → 400; * anything else → 500. */ export function classifyPatternError( error: unknown, ): { status: 404 | 400 | 500; body: { error: string } } { if (error instanceof Error && error.message.includes("not found")) { return { status: 404, body: { error: "File not found" } }; } if ( error instanceof UnservedPatternFileError || error instanceof Error && (error.message.includes("Invalid source root path") || error.message.includes("incomplete closure") || error.message.includes("fabric import") || error.message.includes("is not valid UTF-8 text")) ) { return { status: 400, body: { error: error.message } }; } return { status: 500, body: { error: "Internal server error" } }; } function patternResponse( body: BodyInit, contentType: string, etag: string, ifNoneMatch: string | null, releasedAt?: number, ): Response { const headers = patternResponseHeaders(contentType, etag, releasedAt); if (compareETags(etag, ifNoneMatch)) { return new Response(null, { status: 304, headers }); } return new Response(body, { status: 200, headers }); } function invalidPatternPath(): Response { return Response.json({ error: "Invalid file path" }, { status: 400 }); } /** * Whether the route serves a file under `filename`, its path below the route * prefix as a request names it once the router has decoded it. A `..` * sequence would escape the directory, a leading `/` would make the name * absolute in URL resolution, and a `:` would introduce a URL scheme * (`file:///etc/passwd`). An internal `/` is allowed, because patterns are * served from subdirectories. The check decodes first, so an encoded sequence * is caught as the sequence it denotes, and a name that does not decode is * refused. */ function servesFilename(filename: string): boolean { let decoded: string; try { decoded = decodeURIComponent(filename); } catch { return false; } return !decoded.includes("..") && !decoded.startsWith("/") && !decoded.includes(":"); } /** * The path below the route prefix that a `GET` of the URL pathname `pathname` * reads, or `undefined` when the route would not serve it: a pathname outside * the prefix, one that is not valid percent-encoding, or one * {@link servesFilename} refuses. Decodes once, as {@link PatternsRoute.serve} * does. */ function servedFilename(pathname: string): string | undefined { if (!pathname.startsWith(PATTERNS_ROUTE_PREFIX)) return undefined; let filename: string; try { filename = decodeURIComponent(pathname.slice(PATTERNS_ROUTE_PREFIX.length)); } catch { return undefined; } return servesFilename(filename) ? filename : undefined; } /** * A file a program names that the route would not serve, by the URL pathname * the program names it by. A program reaching one is one no worker can fetch, * so its identity is refused rather than computed. */ class UnservedPatternFileError extends Error { /** Constructs an instance naming the pathname that was refused. */ constructor(pathname: string) { super(`the patterns route does not serve \`${pathname}\``); this.name = "UnservedPatternFileError"; } } function directoryUrl(directory: string): URL { const url = toFileUrl(directory); if (!url.href.endsWith("/")) url.href += "/"; return url; } /** Whether an attached entry stays within the public patterns namespace. */ function validSourceRoot(name: string): boolean { if (!name.startsWith(PATTERNS_ROUTE_PREFIX)) return false; const path = name.slice(PATTERNS_ROUTE_PREFIX.length); if (path.length === 0 || path.length > 2048 || /%2f/i.test(path)) { return false; } let decoded: string; try { decoded = decodeURIComponent(path); } catch (error) { if (error instanceof URIError) return false; throw error; } // Check traversal after decoding, but retain the encoded name in the // program: the HTTP compiler hashes URL pathnames. A second escape layer // or URL delimiter would be interpreted differently by the file route. for (const character of decoded) { const code = character.charCodeAt(0); if (code < 32 || code === 127) return false; } return !/[\\:%?#]/.test(decoded) && !decoded.startsWith("/") && !decoded.split("/").some((part) => part === "." || part === ".."); }