Public: anyone can read this; the crew can change it.
A child forge in a cell serves its apps: their hosts reach the cell (client forge Starck answered 404 while saying running) #7956
Check · Review · Land · Live done — Landed and live in production.
steward/cell-app-hosts → main 2 commits 1 thread · 1 unresolved
4 files
changed +91
−23
src/http/server.ts +23 −20
| @@ -2348,14 +2348,28 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2348 | 2348 | } |
| 2349 | 2349 | // A hosted child's app (`<app>--<child>--<parentHost>`) is the child's |
| 2350 | 2350 | // own runtime's, as the parent's apps are the parent's. |
| 2351 | if (runtime === undefined || ownUnknown) { | |
| 2352 | const hit = await resolveHostedChildApp(app, request); | |
| 2353 | if (hit === "unknown") | |
| 2354 | return securityFloor(new Response("not found", { status: 404 })); | |
| 2355 | if (hit !== undefined) { | |
| 2356 | notePeer(request, server.requestIP(request)?.address, trust); | |
| 2357 | return proxyApp(hit.runtime, request, hit.target, server); | |
| 2358 | } | |
| 2351 | // A child in its own cell answers through this one door, for its pages and its apps. | |
| 2352 | const fromCell = async (name?: string): Promise<Response> => { | |
| 2353 | // The child sees only this forge's relay, so it is told who the client is. | |
| 2354 | notePeer(request, server.requestIP(request)?.address, trust); | |
| 2355 | armClose(request, () => server.timeout(request, 1)); | |
| 2356 | const response = await answered(app, request, () => answerFromCell(app, request, name)); | |
| 2357 | counted(app, request, response, started); | |
| 2358 | logged(app.config.dataDir, request, response, started); | |
| 2359 | await settleBody(request); | |
| 2360 | if (!mustClose(request)) return response; | |
| 2361 | server.timeout(request, 1); | |
| 2362 | return withHeader(response, "connection", "close"); | |
| 2363 | }; | |
| 2364 | // Asked whatever the parent's own apps said: their domain need not be the | |
| 2365 | // one children hang from, and then it does not claim this host at all. | |
| 2366 | const hit = await resolveHostedChildApp(app, request); | |
| 2367 | if (hit === "unknown") | |
| 2368 | return securityFloor(new Response("not found", { status: 404 })); | |
| 2369 | if (hit !== undefined) { | |
| 2370 | if ("cell" in hit) return fromCell(hit.cell); | |
| 2371 | notePeer(request, server.requestIP(request)?.address, trust); | |
| 2372 | return proxyApp(hit.runtime, request, hit.target, server); | |
| 2359 | 2373 | } |
| 2360 | 2374 | // A hosted child's Host (`<name>--<parentHost>`) is answered by that |
| 2361 | 2375 | // child's own App before the parent's pages. Ingress routes the label |
| @@ -2364,18 +2378,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2364 | 2378 | const child = await resolveHostedChild(app, request); |
| 2365 | 2379 | if (child === "unknown") |
| 2366 | 2380 | return securityFloor(new Response("not found", { status: 404 })); |
| 2367 | if (child === "cell") { | |
| 2368 | // The child sees only this forge's relay, so it is told who the client is. | |
| 2369 | notePeer(request, server.requestIP(request)?.address, trust); | |
| 2370 | armClose(request, () => server.timeout(request, 1)); | |
| 2371 | const response = await answered(app, request, () => answerFromCell(app, request)); | |
| 2372 | counted(app, request, response, started); | |
| 2373 | logged(app.config.dataDir, request, response, started); | |
| 2374 | await settleBody(request); | |
| 2375 | if (!mustClose(request)) return response; | |
| 2376 | server.timeout(request, 1); | |
| 2377 | return withHeader(response, "connection", "close"); | |
| 2378 | } | |
| 2381 | if (child === "cell") return fromCell(); | |
| 2379 | 2382 | if (child !== undefined) { |
| 2380 | 2383 | const key = child.config.dataDir; |
| 2381 | 2384 | let http = childHttps.get(key); |
src/spawn/host.ts +9 −3
| @@ -233,6 +233,11 @@ export interface ChildAppHit { | ||
| 233 | 233 | readonly target: AppTarget; |
| 234 | 234 | } |
| 235 | 235 | |
| 236 | /** A child app in a child's own cell: its runtime is in that process, so the request is carried there. */ | |
| 237 | export interface CellAppHit { | |
| 238 | readonly cell: string; | |
| 239 | } | |
| 240 | ||
| 236 | 241 | /** |
| 237 | 242 | * The child app a Host names, `"unknown"` when it is shaped like one of a |
| 238 | 243 | * child that exists but names no app of it, undefined when it is no child |
| @@ -241,7 +246,7 @@ export interface ChildAppHit { | ||
| 241 | 246 | export async function resolveHostedChildApp( |
| 242 | 247 | parent: App, |
| 243 | 248 | request: Request, |
| 244 | ): Promise<ChildAppHit | "unknown" | undefined> { | |
| 249 | ): Promise<ChildAppHit | CellAppHit | "unknown" | undefined> { | |
| 245 | 250 | const host = request.headers.get("host"); |
| 246 | 251 | if (host === null) return undefined; |
| 247 | 252 | const label = parseHostedChildAppLabel(host, childHost(parent)); |
| @@ -257,6 +262,7 @@ export async function resolveHostedChildApp( | ||
| 257 | 262 | if (!CHILD_NAME.test(name)) continue; |
| 258 | 263 | const ledger = readLedgerSync(parent, name); |
| 259 | 264 | if (ledger === undefined || !ledger.done) continue; |
| 265 | if (ledger.placement === "cell") return { cell: name }; | |
| 260 | 266 | const child = await openOnce(parent, ledger); |
| 261 | 267 | const runtime = appsRuntime(child); |
| 262 | 268 | if (runtime === undefined) return "unknown"; |
| @@ -275,8 +281,8 @@ export const hostedChildApps = childAppsSummary; | ||
| 275 | 281 | * is up, and when it is not (just spawned, a parent that restarted, a |
| 276 | 282 | * child that stopped) start it and answer 503 at once. |
| 277 | 283 | */ |
| 278 | export async function answerFromCell(parent: App, request: Request): Promise<Response> { | |
| 279 | const name = parseHostedChildName(request.headers.get("host") ?? "", childHost(parent)); | |
| 284 | export async function answerFromCell(parent: App, request: Request, named?: string): Promise<Response> { | |
| 285 | const name = named ?? parseHostedChildName(request.headers.get("host") ?? "", childHost(parent)); | |
| 280 | 286 | const ledger = name === undefined ? undefined : readLedgerSync(parent, name); |
| 281 | 287 | if (ledger === undefined || ledger.placement !== "cell") return new Response("not found", { status: 404 }); |
| 282 | 288 | const child = cellChildren(parent).get(ledger.bodyId); |
test/spawn-cell-app-hosts.test.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * A child forge in a cell said its apps were running while every address of them answered 404 (Starck, | |
| 3 | * 2026-11-01): an app host of a child was only looked up when the parent's own app domain claimed the name, | |
| 4 | * and the lookup opened the child in-process, where a cell's child has no apps. The host reaches the cell now. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { cellChildren } from "../src/spawn/cell.ts"; | |
| 14 | import { resolveHostedChildApp } from "../src/spawn/host.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let parent: App; | |
| 20 | let servers: Servers; | |
| 21 | let cell: ReturnType<typeof Bun.serve>; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | const dataDir = mkdtempSync(join(tmpdir(), "cell-app-hosts-")); | |
| 25 | parent = createApp({ dataDir, url: "https://forge.test", port: 0 }); | |
| 26 | // As in production: the parent's own apps hang from another domain than its children do. | |
| 27 | await new AppsRuntime(parent, { domain: "--apps.test", scheme: "https", forgeUrl: "https://forge.test", concurrency: 1 }).start(); | |
| 28 | mkdirSync(join(dataDir, "spawn"), { recursive: true }); | |
| 29 | writeFileSync(join(dataDir, "spawn", "kid.json"), JSON.stringify({ name: "kid", bodyId: "cell-k", done: true, placement: "cell", size: "small" })); | |
| 30 | cell = Bun.serve({ port: 0, fetch: (r) => new Response(`cell saw ${r.headers.get("host")}`) }); | |
| 31 | const port = cell.port; | |
| 32 | (cellChildren(parent) as { get: unknown }).get = (id: string) => (id === "cell-k" ? { port } : undefined); | |
| 33 | servers = await serve(parent); | |
| 34 | }); | |
| 35 | ||
| 36 | afterAll(() => { | |
| 37 | servers.stop(); | |
| 38 | void cell.stop(true); | |
| 39 | parent.close(); | |
| 40 | }); | |
| 41 | ||
| 42 | const ask = (host: string) => fetch(`${servers.url}/`, { headers: { host }, redirect: "manual" }); | |
| 43 | ||
| 44 | test("a cell child's app host names the cell, and opens nothing here", async () => { | |
| 45 | const hit = await resolveHostedChildApp(parent, new Request("http://x/", { headers: { host: "deal--kid--forge.test" } })); | |
| 46 | expect(hit).toEqual({ cell: "kid" }); | |
| 47 | }); | |
| 48 | ||
| 49 | test("through the server, the app's request reaches the cell with its own Host", async () => { | |
| 50 | const res = await ask("deal--kid--forge.test"); | |
| 51 | expect(res.status).toBe(200); | |
| 52 | expect(await res.text()).toBe("cell saw deal--kid--forge.test"); | |
| 53 | }); | |
| 54 | ||
| 55 | test("the child's own host still reaches the cell, and a child that does not exist is not found", async () => { | |
| 56 | expect(await (await ask("kid--forge.test")).text()).toBe("cell saw kid--forge.test"); | |
| 57 | expect((await ask("deal--nobody--forge.test")).status).toBe(404); | |
| 58 | }); |
test/spawn-child-runs-apps.test.ts +1
| @@ -43,6 +43,7 @@ describe("a hosted child's apps run in the parent", () => { | ||
| 43 | 43 | const hit = await resolveHostedChildApp(parent, request); |
| 44 | 44 | if (hit === undefined) return new Response("not an app host", { status: 418 }); |
| 45 | 45 | if (hit === "unknown") return new Response("not found", { status: 404 }); |
| 46 | if ("cell" in hit) throw new Error("this child is hosted"); | |
| 46 | 47 | // The server is only for a websocket upgrade, which these requests are not. |
| 47 | 48 | return (await proxyApp(hit.runtime, request, hit.target, {} as never))!; |
| 48 | 49 | }; |
new above
offline
This block duplicates the
child === "cell"branch a few lines below (armClose/answered/counted/logged/settleBody/close). Extract one local helper likecarryToCell(request, server, started, name?)and use it in both, so keeping the two in step is not a manual job.