@botiverse/porthole 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,9 +15,15 @@ export interface EntryConformanceHost {
15
15
  * The clean address of a stored page the signed-in viewer may open (`ContentHost.urlOf`), for
16
16
  * `scope`: it draws `<main id="artifact">version 1</main>`.
17
17
  */
18
- url(scope: string): string;
18
+ url(scope: string): string | Promise<string>;
19
19
  /** A clean address the signed-in viewer may not open. */
20
- denied(scope: string): string;
20
+ denied(scope: string): string | Promise<string>;
21
+ /**
22
+ * Addresses on the content domain that name nothing (a label the app never assigns, the bare
23
+ * domain, a path no instance has): each must be Porthole's 404 there, never the app's sign-in
24
+ * (P070). Absent: the case is skipped.
25
+ */
26
+ unknown?(): readonly string[] | Promise<readonly string[]>;
21
27
  /** Whether `page` shows the app's own sign-in. */
22
28
  atSignIn(page: Page): Promise<boolean>;
23
29
  /** On the app's sign-in page: signs in, as a person does (and the app goes back). */
@@ -31,7 +31,7 @@ export function entryTests(host, options = {}) {
31
31
  };
32
32
  define("served.entry.direct", "a clean address opens directly for a signed-in viewer, and stays clean; a reload rides on its cookie", async ({ page }) => {
33
33
  await host.signIn(page);
34
- const url = host.url("entry-direct");
34
+ const url = await host.url("entry-direct");
35
35
  await page.goto(url);
36
36
  await expect(page.locator("#artifact")).toHaveText("version 1");
37
37
  expect(page.url(), "the address bar shows the clean address, no code").toBe(url);
@@ -46,7 +46,7 @@ export function entryTests(host, options = {}) {
46
46
  expect(entries, "a reload needs no entry").toEqual([]);
47
47
  });
48
48
  define("served.entry.signed-out", "signed out, a clean address goes to the app's own sign-in, then back to the content", async ({ page }) => {
49
- const url = host.url("entry-signed-out");
49
+ const url = await host.url("entry-signed-out");
50
50
  await page.goto(url);
51
51
  expect(await host.atSignIn(page), "the app's sign-in").toBe(true);
52
52
  expect(await shows(page)).toBe(false);
@@ -56,15 +56,30 @@ export function entryTests(host, options = {}) {
56
56
  });
57
57
  define("served.entry.denied", "a viewer without access gets the app's refusal, never the content", async ({ page }) => {
58
58
  await host.signIn(page);
59
- const url = host.denied("entry-denied");
59
+ const url = await host.denied("entry-denied");
60
60
  const answer = await page.goto(url);
61
61
  expect(answer?.status()).toBe(403);
62
62
  expect(await shows(page)).toBe(false);
63
63
  expect(new URL(page.url()).origin, "the refusal is the app's").not.toBe(new URL(url).origin);
64
64
  });
65
+ define("served.entry.unknown", "an address that names nothing is a 404 on the content domain: never a trip to the app's sign-in", async ({ page }, info) => {
66
+ info.skip(host.unknown === undefined, "the host gave no addresses that name nothing (unknown)");
67
+ const request = page.context().request;
68
+ for (const url of (await host.unknown?.()) ?? []) {
69
+ const answer = await request.get(url, {
70
+ maxRedirects: 0,
71
+ headers: { "sec-fetch-dest": "document" },
72
+ });
73
+ expect(answer.status(), url).toBe(404);
74
+ expect(answer.headers().location, url).toBeUndefined();
75
+ const opened = await page.goto(url);
76
+ expect(opened?.status(), url).toBe(404);
77
+ expect(new URL(page.url()).origin, url).toBe(new URL(url).origin);
78
+ }
79
+ });
65
80
  define("served.entry.code-once", "an entry code works once: replayed, it opens nothing", async ({ page }) => {
66
81
  await host.signIn(page);
67
- const code = await codeFor(page, host.url("entry-once"));
82
+ const code = await codeFor(page, await host.url("entry-once"));
68
83
  const request = page.context().request;
69
84
  const top = { "sec-fetch-dest": "document" };
70
85
  const first = await request.get(code, { maxRedirects: 0, headers: top });
@@ -75,8 +90,8 @@ export function entryTests(host, options = {}) {
75
90
  });
76
91
  define("served.entry.code-elsewhere", "an entry code taken to another instance's origin opens nothing there", async ({ page }) => {
77
92
  await host.signIn(page);
78
- const code = new URL(await codeFor(page, host.url("entry-mine")));
79
- const other = new URL(host.url("entry-theirs"));
93
+ const code = new URL(await codeFor(page, await host.url("entry-mine")));
94
+ const other = new URL(await host.url("entry-theirs"));
80
95
  const request = page.context().request;
81
96
  const answer = await request.get(`${other.origin}${code.pathname}${code.search}`, {
82
97
  maxRedirects: 0,
@@ -87,7 +102,7 @@ export function entryTests(host, options = {}) {
87
102
  });
88
103
  define("served.entry.framed", "a clean address in a frame shows the content where the browser keeps the cookies, else asks for a tab: never the app's sign-in", async ({ page, browserName }, info) => {
89
104
  await host.signIn(page);
90
- const shown = await host.frame(page, host.url("entry-framed"));
105
+ const shown = await host.frame(page, await host.url("entry-framed"));
91
106
  const outcome = await shown.outcome();
92
107
  const frame = await shown.frame();
93
108
  expect(await host.atSignIn(page)).toBe(false);
@@ -110,7 +125,7 @@ export function entryTests(host, options = {}) {
110
125
  await host.signIn(page);
111
126
  // The app's page stays; the browser forgets every sign-in.
112
127
  await page.context().clearCookies();
113
- const shown = await host.frame(page, host.url("entry-framed-out"));
128
+ const shown = await host.frame(page, await host.url("entry-framed-out"));
114
129
  expect(await shown.outcome()).toMatchObject({ state: "blocked", reason: "sign-in" });
115
130
  const frame = await shown.frame();
116
131
  await expect(frame.locator("h1")).toHaveText("Open this in a new tab to sign in");
@@ -50,14 +50,19 @@ export interface HostConformanceOptions {
50
50
  * signed in and out, and in a frame.
51
51
  */
52
52
  readonly entry?: {
53
- /** The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`). */
54
- readonly url: (scope: string) => string;
53
+ /**
54
+ * The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`); may publish it
55
+ * on demand.
56
+ */
57
+ readonly url: (scope: string) => string | Promise<string>;
55
58
  /** A clean address the signed-in viewer may not open. */
56
- readonly denied: (scope: string) => string;
59
+ readonly denied: (scope: string) => string | Promise<string>;
57
60
  /** Whether `page` shows the app's own sign-in. */
58
61
  readonly atSignIn: (page: Page) => Promise<boolean>;
59
62
  /** On the app's sign-in page: signs in, as a person does. */
60
63
  readonly completeSignIn: (page: Page) => Promise<void>;
64
+ /** Addresses on the content domain that name nothing: each a 404 there (P070). */
65
+ readonly unknown?: () => readonly string[] | Promise<readonly string[]>;
61
66
  };
62
67
  /** Case ids to skip, each with its reason (shown in the report). */
63
68
  readonly skip?: Readonly<Record<string, string>>;
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { fileURLToPath } from "node:url";
3
+ import { runInThisContext } from "node:vm";
3
4
  import { expect } from "@playwright/test";
4
5
  import { entryTests } from "./entry.js";
5
6
  import { leakTests } from "./probes.js";
@@ -224,9 +225,10 @@ export function hostConformance(options) {
224
225
  denied: given.denied,
225
226
  atSignIn: given.atSignIn,
226
227
  completeSignIn: given.completeSignIn,
228
+ ...(given.unknown === undefined ? {} : { unknown: given.unknown }),
227
229
  async frame(page, url) {
228
230
  await ready(page);
229
- const instance = served.artifact("entry", "1");
231
+ const instance = await served.artifact("entry", "1");
230
232
  const id = await invoke(page, "show", options.config(instance), instance, {
231
233
  location: { url, origin: new URL(url).origin },
232
234
  });
@@ -310,32 +312,65 @@ export function defineHostConformance(test, options) {
310
312
  if (options.instances.proxy === undefined) {
311
313
  absent(PROXY_CASES, "the app serves no MCP Apps views");
312
314
  }
313
- typed.describe(`Porthole leak suite (${options.name})`, () => {
314
- for (const each of leakTests(hosts.leak, { skip })) {
315
- // Playwright reads the fixtures a test uses from its parameter's destructuring.
316
- typed(each.title, async ({ page, browserName }, info) => {
317
- await hosts.begin(page);
318
- await each.run({ page, browserName }, info);
319
- });
320
- }
321
- });
322
- typed.describe(`Porthole served modes (${options.name})`, () => {
323
- for (const each of servedTests(hosts.served, { skip })) {
324
- typed(each.title, async ({ page, browserName }, info) => {
325
- await hosts.begin(page);
326
- await each.run({ page, browserName }, info);
327
- });
328
- }
329
- });
330
- const entry = hosts.entry;
331
- if (entry !== null) {
332
- typed.describe(`Porthole redirect entry (${options.name})`, () => {
333
- for (const each of entryTests(entry, { skip })) {
334
- typed(each.title, async ({ page, browserName }, info) => {
315
+ // Registered as the app's own spec file's (P070): Playwright locates a test where `test()` is
316
+ // called, so `playwright test <spec>` and `<spec>:<line>` select these.
317
+ const at = callerOf(defineHostConformance);
318
+ const describeOf = Reflect.get(test, "describe");
319
+ const describe = calledFrom(describeOf, at);
320
+ const register = calledFrom(typed, at);
321
+ const suite = (title, tests) => {
322
+ describe(title, () => {
323
+ for (const each of tests) {
324
+ // Playwright reads the fixtures a test uses from its parameter's destructuring.
325
+ register(each.title, async ({ page, browserName }, info) => {
335
326
  await hosts.begin(page);
336
327
  await each.run({ page, browserName }, info);
337
328
  });
338
329
  }
339
330
  });
331
+ };
332
+ suite(`Porthole leak suite (${options.name})`, leakTests(hosts.leak, { skip }));
333
+ suite(`Porthole served modes (${options.name})`, servedTests(hosts.served, { skip }));
334
+ if (hosts.entry !== null) {
335
+ suite(`Porthole redirect entry (${options.name})`, entryTests(hosts.entry, { skip }));
336
+ }
337
+ }
338
+ /** Where a function was called from: the caller's file and line, or null. */
339
+ function callerOf(callee) {
340
+ const holder = {};
341
+ Error.captureStackTrace(holder, callee);
342
+ // The first frame is the caller's: " at file:///…/app.spec.ts:16:1" or " at fn (/…/app.spec.ts:16:1)".
343
+ const frame = (holder.stack ?? "")
344
+ .split("\n")
345
+ .map((line) => line.trim())
346
+ .find((line) => line.startsWith("at "));
347
+ const where = (frame ?? "").slice(3).replace(/^.*\((.*)\)$/u, "$1");
348
+ const found = /^(.+):(\d+):\d+$/u.exec(where);
349
+ if (found?.[1] === undefined || found[2] === undefined) {
350
+ return null;
340
351
  }
352
+ try {
353
+ return {
354
+ file: found[1].startsWith("file://") ? fileURLToPath(found[1]) : found[1],
355
+ line: Number(found[2]),
356
+ };
357
+ }
358
+ catch {
359
+ return null;
360
+ }
361
+ }
362
+ /**
363
+ * `register` (Playwright's `test` or `test.describe`), called from a function compiled as the
364
+ * caller's file and line (`vm`'s `filename` and `lineOffset`), so the tests it makes are located
365
+ * there. Without a caller, `register` itself.
366
+ */
367
+ function calledFrom(register, at) {
368
+ if (at === null) {
369
+ return (title, body) => register(title, body);
370
+ }
371
+ const call = runInThisContext("(register, title, body) => register(title, body)", {
372
+ filename: at.file,
373
+ lineOffset: Math.max(0, at.line - 1),
374
+ });
375
+ return (title, body) => call(register, title, body);
341
376
  }
@@ -53,20 +53,20 @@ export interface ServedConformanceHost {
53
53
  /** How many requests reached `canary(name)`. */
54
54
  canaryHits(name: string): Promise<number>;
55
55
  /** A served shell (domain isolation) for `scope`. */
56
- shell(scope: string): InstanceRef;
56
+ shell(scope: string): InstanceRef | Promise<InstanceRef>;
57
57
  /**
58
58
  * A url-mode app for `scope`: its page loads a script from its own origin that sets
59
59
  * `document.documentElement.dataset.app = "ready"`.
60
60
  */
61
- app(scope: string): InstanceRef;
61
+ app(scope: string): InstanceRef | Promise<InstanceRef>;
62
62
  /**
63
63
  * A stored artifact page (domain isolation) for `scope`: version `"1"` and `"2"` of one page,
64
64
  * each with `<main id="artifact">version N</main>` and a script that records
65
65
  * `document.documentElement.dataset.rtc = typeof RTCPeerConnection`.
66
66
  */
67
- artifact(scope: string, version: "1" | "2"): InstanceRef;
67
+ artifact(scope: string, version: "1" | "2"): InstanceRef | Promise<InstanceRef>;
68
68
  /** The MCP Apps sandbox proxy (domain isolation) for `scope`, opening the view's `egress`. */
69
- proxy(scope: string, egress?: EgressConfig): InstanceRef;
69
+ proxy(scope: string, egress?: EgressConfig): InstanceRef | Promise<InstanceRef>;
70
70
  }
71
71
  export interface ServedSuiteOptions {
72
72
  /** Case ids to skip, each with the reason (shown in the report). */
@@ -112,7 +112,7 @@ export function servedTests(host, options = {}) {
112
112
  ["url", (scope) => host.app(scope)],
113
113
  ];
114
114
  // A served shell draws (and has a height to click in) once its host gives it content.
115
- const showMode = (page, mode, instance) => host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
115
+ const showMode = async (page, mode, instance) => host.show(page, await instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
116
116
  for (const [mode, instance] of modes) {
117
117
  define(`served.${mode}.origin`, "content runs on its own origin, never the host's", async ({ page }) => {
118
118
  const display = await showMode(page, mode, instance(`${mode}-origin`));
@@ -190,7 +190,9 @@ export function servedTests(host, options = {}) {
190
190
  });
191
191
  }
192
192
  define("served.domain.address", "a served frame's address is its own, carrying at most Porthole's ticket: never the app's", async ({ page }) => {
193
- const display = await host.show(page, host.shell("address"), { content: SHELL_CONTENT });
193
+ const display = await host.show(page, await host.shell("address"), {
194
+ content: SHELL_CONTENT,
195
+ });
194
196
  const frame = await display.frame();
195
197
  const seen = await frame.evaluate(() => ({
196
198
  origin: location.origin,
@@ -203,19 +205,25 @@ export function servedTests(host, options = {}) {
203
205
  expect(seen.origin).not.toBe(new URL(page.url()).origin);
204
206
  });
205
207
  define("served.domain.storage-scope", "storage persists within a scope and is never shared across scopes", async ({ page }) => {
206
- const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
208
+ const first = await host.show(page, await host.shell("scope-a"), {
209
+ content: SHELL_CONTENT,
210
+ });
207
211
  await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
208
212
  await first.destroy();
209
- const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
213
+ const again = await host.show(page, await host.shell("scope-a"), {
214
+ content: SHELL_CONTENT,
215
+ });
210
216
  expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
211
217
  expect(again.origin).toBe(first.origin);
212
- const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
218
+ const other = await host.show(page, await host.shell("scope-b"), {
219
+ content: SHELL_CONTENT,
220
+ });
213
221
  expect(other.origin).not.toBe(first.origin);
214
222
  expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
215
223
  });
216
224
  define("served.artifact.versions", "each version of a page shows as stored, on its scope's origin, hardened before it runs", async ({ page }) => {
217
- const one = await host.show(page, host.artifact("artifact-scope", "1"));
218
- const two = await host.show(page, host.artifact("artifact-scope", "2"));
225
+ const one = await host.show(page, await host.artifact("artifact-scope", "1"));
226
+ const two = await host.show(page, await host.artifact("artifact-scope", "2"));
219
227
  expect(two.origin).toBe(one.origin);
220
228
  for (const [display, version] of [
221
229
  [one, "1"],
@@ -227,7 +235,7 @@ export function servedTests(host, options = {}) {
227
235
  }
228
236
  });
229
237
  define("served.domain.ticket-once", "a ticket opens its content once; without it there is nothing", async ({ page, browserName }) => {
230
- const location = await host.locate(host.shell("ticket-once"));
238
+ const location = await host.locate(await host.shell("ticket-once"));
231
239
  const first = await page.goto(location.url);
232
240
  expect(first?.status()).toBe(200);
233
241
  // A fresh browser state: no cookie, only the used ticket.
@@ -243,7 +251,7 @@ export function servedTests(host, options = {}) {
243
251
  }
244
252
  });
245
253
  define("served.domain.frame-ancestors", "only the host's origins may frame content", async ({ page }) => {
246
- const location = await host.locate(host.shell("foreign"));
254
+ const location = await host.locate(await host.shell("foreign"));
247
255
  await host.frameFromForeignOrigin(page, location.url);
248
256
  await page.waitForTimeout(SETTLE_MS);
249
257
  for (const frame of page.frames().slice(1)) {
@@ -264,7 +272,9 @@ export function servedTests(host, options = {}) {
264
272
  }
265
273
  });
266
274
  define("served.domain.egress", "the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it", async ({ page }) => {
267
- const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
275
+ const display = await host.show(page, await host.shell("egress"), {
276
+ content: SHELL_CONTENT,
277
+ });
268
278
  const frame = await display.frame();
269
279
  const kinds = ["fetch", "img", "media", "script", "style", "frame"];
270
280
  const urls = Object.fromEntries(await Promise.all(kinds.map(async (kind) => [kind, await host.canary(`egress-${kind}`)])));
@@ -343,7 +353,7 @@ export function servedTests(host, options = {}) {
343
353
  .poll(async () => methodsOf(await display.received()))
344
354
  .toContain(MCP_APPS_METHODS.sandboxProxyReady);
345
355
  define("served.proxy.view", "the sandbox proxy becomes the view, hardened, talking to its host directly", async ({ page }) => {
346
- const display = await host.show(page, host.proxy("proxy-view"));
356
+ const display = await host.show(page, await host.proxy("proxy-view"));
347
357
  await proxyReady(display);
348
358
  const canary = await host.canary("proxy-undeclared");
349
359
  await display.post(resourceReady(PROBE_VIEW(canary)));
@@ -357,7 +367,7 @@ export function servedTests(host, options = {}) {
357
367
  expect(await host.canaryHits("proxy-undeclared")).toBe(0);
358
368
  });
359
369
  define("served.proxy.parent-only", "the sandbox proxy takes a view only from its host", async ({ page }) => {
360
- const display = await host.show(page, host.proxy("proxy-parent"));
370
+ const display = await host.show(page, await host.proxy("proxy-parent"));
361
371
  await proxyReady(display);
362
372
  const frame = await display.frame();
363
373
  await frame.evaluate((message) => window.postMessage(message, "*"), resourceReady('<p id="view">Not from the host.</p>'));
@@ -366,7 +376,7 @@ export function servedTests(host, options = {}) {
366
376
  });
367
377
  define("served.proxy.egress", "the sandbox proxy opens what the view declares, and nothing else", async ({ page }) => {
368
378
  const canary = await host.canary("proxy-declared");
369
- const display = await host.show(page, host.proxy("proxy-egress", { connect: [new URL(canary).origin] }));
379
+ const display = await host.show(page, await host.proxy("proxy-egress", { connect: [new URL(canary).origin] }));
370
380
  await proxyReady(display);
371
381
  await display.post(resourceReady(PROBE_VIEW(canary)));
372
382
  const frame = await display.frame();
@@ -374,7 +384,7 @@ export function servedTests(host, options = {}) {
374
384
  expect(await host.canaryHits("proxy-declared")).toBe(1);
375
385
  });
376
386
  define("served.url.ticket", "an app shows nothing to a browser without its ticket's cookie", async ({ page }) => {
377
- const location = await host.locate(host.app("url-ticket"));
387
+ const location = await host.locate(await host.app("url-ticket"));
378
388
  // A fresh browser: no cookie of the content's, none of the app's. Porthole refuses it, or
379
389
  // (redirect entry, P068) hands it to the app's authorize route, which asks it to sign in.
380
390
  const fresh = await page.context().browser().newContext();
@@ -390,7 +400,7 @@ export function servedTests(host, options = {}) {
390
400
  }
391
401
  });
392
402
  define("served.url.tab", "an app opened on a tab of its own runs, its requests riding on its cookie", async ({ page }) => {
393
- const location = await host.locate(host.app("url-tab"));
403
+ const location = await host.locate(await host.app("url-tab"));
394
404
  await page.goto(location.url);
395
405
  await expect(page.locator("html")).toHaveAttribute("data-app", "ready");
396
406
  });
@@ -398,7 +408,7 @@ export function servedTests(host, options = {}) {
398
408
  // Measured (P025): WebKit keeps no cookie in a cross-site frame, partitioned or not, so a
399
409
  // framed app's subresources are refused there; such hosts open the app on a tab.
400
410
  typed.fail(browserName === "webkit", "WebKit keeps no cookie in a cross-site frame");
401
- const display = await host.show(page, host.app("url-framed"));
411
+ const display = await host.show(page, await host.app("url-framed"));
402
412
  const frame = await display.frame();
403
413
  await expect
404
414
  .poll(() => frame.evaluate(() => document.documentElement.dataset.app ?? null), {
@@ -27,9 +27,17 @@ export interface NodeHost {
27
27
  readonly request: RequestListener;
28
28
  readonly upgrade: UpgradeListener;
29
29
  }
30
- /** The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). */
31
- export declare function toRequest(request: IncomingMessage, scheme: "https" | "http"): Request;
32
- /** Writes a fetch `Response` to a Node response, cookies one header each. */
30
+ /**
31
+ * The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). `signal`
32
+ * (`nodeHost` gives one, aborted when the client goes away) lets an upstream stop its work.
33
+ */
34
+ export declare function toRequest(request: IncomingMessage, scheme: "https" | "http", signal?: AbortSignal): Request;
35
+ /**
36
+ * Writes a fetch `Response` to a Node response, cookies one header each (P070): it waits for the
37
+ * socket to drain before reading on (a slow client never makes it buffer the body), cancels the
38
+ * body when the client goes away (an event stream ends with the page that asked for it), and on
39
+ * an error mid-body destroys the response rather than end it looking complete.
40
+ */
33
41
  export declare function sendResponse(response: ServerResponse, answer: Response): Promise<void>;
34
42
  export declare function nodeHost<V>(content: ContentHost<V>, app: NodeHostOptions): NodeHost;
35
43
  /**
@@ -1,8 +1,11 @@
1
1
  import { request as httpRequest } from "node:http";
2
2
  import { request as httpsRequest } from "node:https";
3
3
  import { Readable } from "node:stream";
4
- /** The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). */
5
- export function toRequest(request, scheme) {
4
+ /**
5
+ * The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). `signal`
6
+ * (`nodeHost` gives one, aborted when the client goes away) lets an upstream stop its work.
7
+ */
8
+ export function toRequest(request, scheme, signal) {
6
9
  const headers = new Headers();
7
10
  for (const [name, value] of Object.entries(request.headers)) {
8
11
  for (const each of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
@@ -15,9 +18,20 @@ export function toRequest(request, scheme) {
15
18
  : target;
16
19
  const method = request.method ?? "GET";
17
20
  const body = method === "GET" || method === "HEAD" ? null : Readable.toWeb(request);
18
- return new Request(url, { method, headers, body, duplex: "half" });
21
+ return new Request(url, {
22
+ method,
23
+ headers,
24
+ body,
25
+ duplex: "half",
26
+ ...(signal === undefined ? {} : { signal }),
27
+ });
19
28
  }
20
- /** Writes a fetch `Response` to a Node response, cookies one header each. */
29
+ /**
30
+ * Writes a fetch `Response` to a Node response, cookies one header each (P070): it waits for the
31
+ * socket to drain before reading on (a slow client never makes it buffer the body), cancels the
32
+ * body when the client goes away (an event stream ends with the page that asked for it), and on
33
+ * an error mid-body destroys the response rather than end it looking complete.
34
+ */
21
35
  export async function sendResponse(response, answer) {
22
36
  const headers = {};
23
37
  for (const [name, value] of answer.headers) {
@@ -34,10 +48,46 @@ export async function sendResponse(response, answer) {
34
48
  response.end();
35
49
  return;
36
50
  }
37
- for await (const chunk of answer.body) {
38
- response.write(chunk);
51
+ const reader = answer.body.getReader();
52
+ let gone = false;
53
+ const leave = () => {
54
+ gone = true;
55
+ reader.cancel().catch(() => undefined);
56
+ };
57
+ // The client went away before the end: stop reading, and let the body's source know.
58
+ response.once("close", () => {
59
+ if (!response.writableFinished) {
60
+ leave();
61
+ }
62
+ });
63
+ try {
64
+ for (;;) {
65
+ const { done, value } = await reader.read();
66
+ if (done || gone) {
67
+ break;
68
+ }
69
+ if (!response.write(value)) {
70
+ await new Promise((resolve) => {
71
+ const on = () => {
72
+ response.off("drain", on);
73
+ response.off("close", on);
74
+ resolve();
75
+ };
76
+ response.on("drain", on);
77
+ response.on("close", on);
78
+ });
79
+ }
80
+ }
81
+ }
82
+ catch (error) {
83
+ // The head is sent: a body cut short must not look whole.
84
+ leave();
85
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
86
+ return;
87
+ }
88
+ if (!gone) {
89
+ response.end();
39
90
  }
40
- response.end();
41
91
  }
42
92
  /** Whether a Node request's Host (or absolute-form target) is under the content domain. */
43
93
  function forContent(content, request) {
@@ -67,7 +117,15 @@ export function nodeHost(content, app) {
67
117
  app.request(request, response);
68
118
  return;
69
119
  }
70
- content.fetch(toRequest(request, scheme())).then((answer) => {
120
+ // Aborted when the client goes away first, so an upstream (a proxied app's event stream)
121
+ // stops with it.
122
+ const gone = new AbortController();
123
+ response.once("close", () => {
124
+ if (!response.writableFinished) {
125
+ gone.abort();
126
+ }
127
+ });
128
+ content.fetch(toRequest(request, scheme(), gone.signal)).then((answer) => {
71
129
  if (answer === null) {
72
130
  // A label the app routes itself.
73
131
  app.request(request, response);
@@ -75,9 +133,11 @@ export function nodeHost(content, app) {
75
133
  }
76
134
  return sendResponse(response, answer);
77
135
  }, () => {
78
- if (!response.headersSent) {
79
- response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
136
+ if (response.headersSent) {
137
+ response.destroy();
138
+ return;
80
139
  }
140
+ response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
81
141
  response.end("Content couldn't be served.");
82
142
  });
83
143
  },
@@ -21,6 +21,12 @@ export interface HealthOptions {
21
21
  /** How the check is fetched (default: `fetch`; on Node, `nodeProbe()`). */
22
22
  readonly probe?: HealthProbe;
23
23
  readonly timeoutMs?: number;
24
+ /**
25
+ * Told each time the gate's state changes (P070): checking to healthy or unhealthy, and every
26
+ * flip between them, with the new view. `meta().served` changes with it, so an app that tells
27
+ * its clients whether frames are served pushes it from here.
28
+ */
29
+ readonly onChange?: (view: ContentHealthView) => void;
24
30
  }
25
31
  /** Where content stands, for the app's own meta and logs. */
26
32
  export interface ContentHealthView {
@@ -64,7 +70,13 @@ export interface ContentHostOptions<V> {
64
70
  * that says which scope a label names (`prefixedName` does).
65
71
  */
66
72
  readonly name?: Naming;
67
- /** Labels the app routes itself (`p-*` previews): `fetch` hands them back. */
73
+ /**
74
+ * Labels the app itself serves on the content domain, which `fetch` hands back (null) so the
75
+ * app's own routes answer there: Ferry's `p-*` previews, another server's namespace it routes
76
+ * on. Not for labels nothing should answer: a label the naming never makes, the bare domain
77
+ * and an address `entry.at` says names nothing all get Porthole's 404, with nothing to list
78
+ * (P070). The health label is reserved by the gate itself.
79
+ */
68
80
  readonly reserved?: readonly string[] | ((label: string) => boolean);
69
81
  /**
70
82
  * Policy 1, mapping: the instance a request proposes (`{ instance }`), as the app means it,
@@ -105,17 +117,39 @@ export interface EntryOptions<V> {
105
117
  /** The app's authorize route (`ContentHost.authorize`), an absolute URL on an app origin. */
106
118
  readonly authorizeUrl: string;
107
119
  /**
108
- * Policy 1 for a clean address: the instance a scope's path means, or null (404). The path is
109
- * the address's pathname; its query rides along but never chooses the instance.
120
+ * Policy 1 for a clean address: the instance a scope's path means, or null/undefined: nothing
121
+ * (404). The path is the address's pathname; its query rides along but never chooses the
122
+ * instance. Asked twice: on the content origin, with no viewer, whether the address names
123
+ * anything at all (nothing: Porthole's 404 there, never the app's sign-in, P070); then at the
124
+ * authorize route with the viewer. Say what exists the same way for both; `access` says who
125
+ * may see it.
110
126
  */
111
- readonly at: (scope: string, path: string, viewer: V | null, request: Request) => Awaitable<InstanceRef | null>;
127
+ readonly at: (scope: string, path: string, viewer: V | null, request: Request) => Awaitable<InstanceRef | null | undefined>;
112
128
  /**
113
129
  * The app's own sign-in, which comes back to `returnTo` (the authorize URL) once signed in.
114
- * Only ever on a tab: in a frame, the person is asked to open a new tab instead.
130
+ * Only ever on a tab: in a frame, the person is asked to open a new tab instead. `entry` is the
131
+ * address being opened, decoded (P070).
115
132
  */
116
- readonly signIn: (returnTo: string, request: Request) => Awaitable<Response>;
117
- /** The app's refusal page on a tab (a frame gets Porthole's plain one). Default: Porthole's. */
118
- readonly refusal?: (status: 403 | 404, request: Request) => Awaitable<Response>;
133
+ readonly signIn: (returnTo: string, request: Request, entry: EntryContext) => Awaitable<Response>;
134
+ /**
135
+ * The app's refusal page on a tab (a frame gets Porthole's plain one), for the address in
136
+ * `entry`. Nothing (null, undefined): Porthole's own (P070).
137
+ */
138
+ readonly refusal?: (status: 403 | 404, request: Request, entry: EntryContext) => Awaitable<Response | null | undefined>;
139
+ }
140
+ /** The clean address a redirect entry is opening, as its authorize route decoded it (P070). */
141
+ export interface EntryContext {
142
+ /** The address's label, and the scope it names (null: one the naming never makes). */
143
+ readonly label: string;
144
+ readonly scope: string | null;
145
+ /** The path (and query) asked for. */
146
+ readonly path: string;
147
+ /** The clean address itself: where the person comes back to. */
148
+ readonly url: string;
149
+ /** The instance the path names for this viewer (`entry.at`), or null. */
150
+ readonly instance: InstanceRef | null;
151
+ /** Whether it was asked from a frame. */
152
+ readonly framed: boolean;
119
153
  }
120
154
  export interface ContentHost<V> {
121
155
  /** The parsed content domain, or null (off, or unusable settings under the fallback). */