@botiverse/porthole 0.2.0 → 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.
@@ -58,20 +58,20 @@ export interface ServedConformanceHost {
58
58
  /** How many requests reached `canary(name)`. */
59
59
  canaryHits(name: string): Promise<number>;
60
60
  /** A served shell (domain isolation) for `scope`. */
61
- shell(scope: string): InstanceRef;
61
+ shell(scope: string): InstanceRef | Promise<InstanceRef>;
62
62
  /**
63
63
  * A url-mode app for `scope`: its page loads a script from its own origin that sets
64
64
  * `document.documentElement.dataset.app = "ready"`.
65
65
  */
66
- app(scope: string): InstanceRef;
66
+ app(scope: string): InstanceRef | Promise<InstanceRef>;
67
67
  /**
68
68
  * A stored artifact page (domain isolation) for `scope`: version `"1"` and `"2"` of one page,
69
69
  * each with `<main id="artifact">version N</main>` and a script that records
70
70
  * `document.documentElement.dataset.rtc = typeof RTCPeerConnection`.
71
71
  */
72
- artifact(scope: string, version: "1" | "2"): InstanceRef;
72
+ artifact(scope: string, version: "1" | "2"): InstanceRef | Promise<InstanceRef>;
73
73
  /** The MCP Apps sandbox proxy (domain isolation) for `scope`, opening the view's `egress`. */
74
- proxy(scope: string, egress?: EgressConfig): InstanceRef;
74
+ proxy(scope: string, egress?: EgressConfig): InstanceRef | Promise<InstanceRef>;
75
75
  }
76
76
 
77
77
  export interface ServedSuiteOptions {
@@ -198,8 +198,11 @@ export function servedTests(
198
198
  ["url", (scope: string) => host.app(scope)],
199
199
  ] as const;
200
200
  // A served shell draws (and has a height to click in) once its host gives it content.
201
- const showMode = (page: Page, mode: "domain" | "url", instance: InstanceRef) =>
202
- host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
201
+ const showMode = async (
202
+ page: Page,
203
+ mode: "domain" | "url",
204
+ instance: InstanceRef | Promise<InstanceRef>,
205
+ ) => host.show(page, await instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
203
206
 
204
207
  for (const [mode, instance] of modes) {
205
208
  define(
@@ -298,7 +301,9 @@ export function servedTests(
298
301
  "served.domain.address",
299
302
  "a served frame's address is its own, carrying at most Porthole's ticket: never the app's",
300
303
  async ({ page }) => {
301
- const display = await host.show(page, host.shell("address"), { content: SHELL_CONTENT });
304
+ const display = await host.show(page, await host.shell("address"), {
305
+ content: SHELL_CONTENT,
306
+ });
302
307
  const frame = await display.frame();
303
308
  const seen = await frame.evaluate(() => ({
304
309
  origin: location.origin,
@@ -316,13 +321,19 @@ export function servedTests(
316
321
  "served.domain.storage-scope",
317
322
  "storage persists within a scope and is never shared across scopes",
318
323
  async ({ page }) => {
319
- const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
324
+ const first = await host.show(page, await host.shell("scope-a"), {
325
+ content: SHELL_CONTENT,
326
+ });
320
327
  await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
321
328
  await first.destroy();
322
- const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
329
+ const again = await host.show(page, await host.shell("scope-a"), {
330
+ content: SHELL_CONTENT,
331
+ });
323
332
  expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
324
333
  expect(again.origin).toBe(first.origin);
325
- const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
334
+ const other = await host.show(page, await host.shell("scope-b"), {
335
+ content: SHELL_CONTENT,
336
+ });
326
337
  expect(other.origin).not.toBe(first.origin);
327
338
  expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
328
339
  },
@@ -332,8 +343,8 @@ export function servedTests(
332
343
  "served.artifact.versions",
333
344
  "each version of a page shows as stored, on its scope's origin, hardened before it runs",
334
345
  async ({ page }) => {
335
- const one = await host.show(page, host.artifact("artifact-scope", "1"));
336
- const two = await host.show(page, host.artifact("artifact-scope", "2"));
346
+ const one = await host.show(page, await host.artifact("artifact-scope", "1"));
347
+ const two = await host.show(page, await host.artifact("artifact-scope", "2"));
337
348
  expect(two.origin).toBe(one.origin);
338
349
  for (const [display, version] of [
339
350
  [one, "1"],
@@ -352,7 +363,7 @@ export function servedTests(
352
363
  "served.domain.ticket-once",
353
364
  "a ticket opens its content once; without it there is nothing",
354
365
  async ({ page, browserName }) => {
355
- const location = await host.locate(host.shell("ticket-once"));
366
+ const location = await host.locate(await host.shell("ticket-once"));
356
367
  const first = await page.goto(location.url);
357
368
  expect(first?.status()).toBe(200);
358
369
  // A fresh browser state: no cookie, only the used ticket.
@@ -372,7 +383,7 @@ export function servedTests(
372
383
  "served.domain.frame-ancestors",
373
384
  "only the host's origins may frame content",
374
385
  async ({ page }) => {
375
- const location = await host.locate(host.shell("foreign"));
386
+ const location = await host.locate(await host.shell("foreign"));
376
387
  await host.frameFromForeignOrigin(page, location.url);
377
388
  await page.waitForTimeout(SETTLE_MS);
378
389
  for (const frame of page.frames().slice(1)) {
@@ -400,7 +411,9 @@ export function servedTests(
400
411
  "served.domain.egress",
401
412
  "the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it",
402
413
  async ({ page }) => {
403
- const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
414
+ const display = await host.show(page, await host.shell("egress"), {
415
+ content: SHELL_CONTENT,
416
+ });
404
417
  const frame = await display.frame();
405
418
  const kinds = ["fetch", "img", "media", "script", "style", "frame"] as const;
406
419
  const urls = Object.fromEntries(
@@ -494,7 +507,7 @@ export function servedTests(
494
507
  "served.proxy.view",
495
508
  "the sandbox proxy becomes the view, hardened, talking to its host directly",
496
509
  async ({ page }) => {
497
- const display = await host.show(page, host.proxy("proxy-view"));
510
+ const display = await host.show(page, await host.proxy("proxy-view"));
498
511
  await proxyReady(display);
499
512
  const canary = await host.canary("proxy-undeclared");
500
513
  await display.post(resourceReady(PROBE_VIEW(canary)));
@@ -515,7 +528,7 @@ export function servedTests(
515
528
  "served.proxy.parent-only",
516
529
  "the sandbox proxy takes a view only from its host",
517
530
  async ({ page }) => {
518
- const display = await host.show(page, host.proxy("proxy-parent"));
531
+ const display = await host.show(page, await host.proxy("proxy-parent"));
519
532
  await proxyReady(display);
520
533
  const frame = await display.frame();
521
534
  await frame.evaluate(
@@ -534,7 +547,7 @@ export function servedTests(
534
547
  const canary = await host.canary("proxy-declared");
535
548
  const display = await host.show(
536
549
  page,
537
- host.proxy("proxy-egress", { connect: [new URL(canary).origin] }),
550
+ await host.proxy("proxy-egress", { connect: [new URL(canary).origin] }),
538
551
  );
539
552
  await proxyReady(display);
540
553
  await display.post(resourceReady(PROBE_VIEW(canary)));
@@ -548,7 +561,7 @@ export function servedTests(
548
561
  "served.url.ticket",
549
562
  "an app shows nothing to a browser without its ticket's cookie",
550
563
  async ({ page }) => {
551
- const location = await host.locate(host.app("url-ticket"));
564
+ const location = await host.locate(await host.app("url-ticket"));
552
565
  // A fresh browser: no cookie of the content's, none of the app's. Porthole refuses it, or
553
566
  // (redirect entry, P068) hands it to the app's authorize route, which asks it to sign in.
554
567
  const fresh = await page.context().browser()!.newContext();
@@ -570,7 +583,7 @@ export function servedTests(
570
583
  "served.url.tab",
571
584
  "an app opened on a tab of its own runs, its requests riding on its cookie",
572
585
  async ({ page }) => {
573
- const location = await host.locate(host.app("url-tab"));
586
+ const location = await host.locate(await host.app("url-tab"));
574
587
  await page.goto(location.url);
575
588
  await expect(page.locator("html")).toHaveAttribute("data-app", "ready");
576
589
  },
@@ -583,7 +596,7 @@ export function servedTests(
583
596
  // Measured (P025): WebKit keeps no cookie in a cross-site frame, partitioned or not, so a
584
597
  // framed app's subresources are refused there; such hosts open the app on a tab.
585
598
  typed.fail(browserName === "webkit", "WebKit keeps no cookie in a cross-site frame");
586
- const display = await host.show(page, host.app("url-framed"));
599
+ const display = await host.show(page, await host.app("url-framed"));
587
600
  const frame = await display.frame();
588
601
  await expect
589
602
  .poll(() => frame.evaluate(() => document.documentElement.dataset.app ?? null), {
@@ -157,7 +157,7 @@ function widget(c: Context): string {
157
157
  "",
158
158
  what,
159
159
  "",
160
- `Where it runs: ${fills ? `in a sandboxed frame that fills the viewer, about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and as wide as the window on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "window's innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
160
+ `Where it runs: ${fills ? `in a sandboxed frame that fills the app's viewer (often smaller than the window: toolbars and side panels take room), about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and wider on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "frame's own innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
161
161
  "",
162
162
  "Sizes that read on a phone:",
163
163
  c.when(
@@ -179,7 +179,7 @@ function widget(c: Context): string {
179
179
  : null,
180
180
  "",
181
181
  themeless
182
- ? `Colors: these variables hold the app's light palette; use them, or colors of your own. Give the ${noun} its own background.`
182
+ ? `Colors: these variables hold the app's palette; use them, or colors of your own. Give the ${noun} its own background.`
183
183
  : optsIn(config)
184
184
  ? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
185
185
  : `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`,
@@ -204,7 +204,7 @@ function widget(c: Context): string {
204
204
  otherVariables(c) === "" ? null : `- Also: ${otherVariables(c)}`,
205
205
  "",
206
206
  exampleFits(c, GUIDE_EXAMPLES.stats)
207
- ? `Cards that read in both themes, and wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
207
+ ? `Cards that ${themeless ? "" : "read in both themes, and "}wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
208
208
  : null,
209
209
  c.when(
210
210
  `Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`,
@@ -445,7 +445,7 @@ export function generateToolDescription(config: PortholeConfig): string {
445
445
  const read =
446
446
  config.guide.guideToolName === undefined
447
447
  ? ""
448
- : ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
448
+ : ` Call ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
449
449
  const where =
450
450
  layout.kind === "fill"
451
451
  ? `It fills the viewer in a sandbox about ${layout.narrowWidthPx} px wide on a phone, from the same code on every screen`
@@ -95,7 +95,7 @@ export function manualLimits(config: PortholeConfig, network: string): ManualLim
95
95
  add(
96
96
  "layout",
97
97
  config.layout.kind === "fill"
98
- ? "It is as tall as the window: the page scrolls itself, and position: fixed and sticky work."
98
+ ? "It is as tall as the viewer, not the window: the page scrolls itself, and position: fixed and sticky work."
99
99
  : declared.has("resize")
100
100
  ? `Its height follows its content, at most ${config.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
101
101
  : "Its height is fixed by the app: keep it short, or let it scroll inside.",
package/src/node/index.ts CHANGED
@@ -40,8 +40,15 @@ export interface NodeHost {
40
40
  readonly upgrade: UpgradeListener;
41
41
  }
42
42
 
43
- /** The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). */
44
- export function toRequest(request: IncomingMessage, scheme: "https" | "http"): Request {
43
+ /**
44
+ * The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). `signal`
45
+ * (`nodeHost` gives one, aborted when the client goes away) lets an upstream stop its work.
46
+ */
47
+ export function toRequest(
48
+ request: IncomingMessage,
49
+ scheme: "https" | "http",
50
+ signal?: AbortSignal,
51
+ ): Request {
45
52
  const headers = new Headers();
46
53
  for (const [name, value] of Object.entries(request.headers)) {
47
54
  for (const each of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
@@ -55,10 +62,21 @@ export function toRequest(request: IncomingMessage, scheme: "https" | "http"): R
55
62
  const method = request.method ?? "GET";
56
63
  const body =
57
64
  method === "GET" || method === "HEAD" ? null : (Readable.toWeb(request) as ReadableStream);
58
- return new Request(url, { method, headers, body, duplex: "half" } as RequestInit);
65
+ return new Request(url, {
66
+ method,
67
+ headers,
68
+ body,
69
+ duplex: "half",
70
+ ...(signal === undefined ? {} : { signal }),
71
+ } as RequestInit);
59
72
  }
60
73
 
61
- /** Writes a fetch `Response` to a Node response, cookies one header each. */
74
+ /**
75
+ * Writes a fetch `Response` to a Node response, cookies one header each (P070): it waits for the
76
+ * socket to drain before reading on (a slow client never makes it buffer the body), cancels the
77
+ * body when the client goes away (an event stream ends with the page that asked for it), and on
78
+ * an error mid-body destroys the response rather than end it looking complete.
79
+ */
62
80
  export async function sendResponse(response: ServerResponse, answer: Response): Promise<void> {
63
81
  const headers: Record<string, string | string[]> = {};
64
82
  for (const [name, value] of answer.headers) {
@@ -75,10 +93,45 @@ export async function sendResponse(response: ServerResponse, answer: Response):
75
93
  response.end();
76
94
  return;
77
95
  }
78
- for await (const chunk of answer.body as unknown as AsyncIterable<Uint8Array>) {
79
- response.write(chunk);
96
+ const reader = answer.body.getReader();
97
+ let gone = false;
98
+ const leave = () => {
99
+ gone = true;
100
+ reader.cancel().catch(() => undefined);
101
+ };
102
+ // The client went away before the end: stop reading, and let the body's source know.
103
+ response.once("close", () => {
104
+ if (!response.writableFinished) {
105
+ leave();
106
+ }
107
+ });
108
+ try {
109
+ for (;;) {
110
+ const { done, value } = await reader.read();
111
+ if (done || gone) {
112
+ break;
113
+ }
114
+ if (!response.write(value)) {
115
+ await new Promise<void>((resolve) => {
116
+ const on = () => {
117
+ response.off("drain", on);
118
+ response.off("close", on);
119
+ resolve();
120
+ };
121
+ response.on("drain", on);
122
+ response.on("close", on);
123
+ });
124
+ }
125
+ }
126
+ } catch (error) {
127
+ // The head is sent: a body cut short must not look whole.
128
+ leave();
129
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
130
+ return;
131
+ }
132
+ if (!gone) {
133
+ response.end();
80
134
  }
81
- response.end();
82
135
  }
83
136
 
84
137
  /** Whether a Node request's Host (or absolute-form target) is under the content domain. */
@@ -110,7 +163,15 @@ export function nodeHost<V>(content: ContentHost<V>, app: NodeHostOptions): Node
110
163
  app.request(request, response);
111
164
  return;
112
165
  }
113
- content.fetch(toRequest(request, scheme())).then(
166
+ // Aborted when the client goes away first, so an upstream (a proxied app's event stream)
167
+ // stops with it.
168
+ const gone = new AbortController();
169
+ response.once("close", () => {
170
+ if (!response.writableFinished) {
171
+ gone.abort();
172
+ }
173
+ });
174
+ content.fetch(toRequest(request, scheme(), gone.signal)).then(
114
175
  (answer) => {
115
176
  if (answer === null) {
116
177
  // A label the app routes itself.
@@ -120,9 +181,11 @@ export function nodeHost<V>(content: ContentHost<V>, app: NodeHostOptions): Node
120
181
  return sendResponse(response, answer);
121
182
  },
122
183
  () => {
123
- if (!response.headersSent) {
124
- response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
184
+ if (response.headersSent) {
185
+ response.destroy();
186
+ return;
125
187
  }
188
+ response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
126
189
  response.end("Content couldn't be served.");
127
190
  },
128
191
  );
@@ -47,6 +47,12 @@ export interface HealthOptions {
47
47
  /** How the check is fetched (default: `fetch`; on Node, `nodeProbe()`). */
48
48
  readonly probe?: HealthProbe;
49
49
  readonly timeoutMs?: number;
50
+ /**
51
+ * Told each time the gate's state changes (P070): checking to healthy or unhealthy, and every
52
+ * flip between them, with the new view. `meta().served` changes with it, so an app that tells
53
+ * its clients whether frames are served pushes it from here.
54
+ */
55
+ readonly onChange?: (view: ContentHealthView) => void;
50
56
  }
51
57
 
52
58
  /** Where content stands, for the app's own meta and logs. */
@@ -95,7 +101,13 @@ export interface ContentHostOptions<V> {
95
101
  * that says which scope a label names (`prefixedName` does).
96
102
  */
97
103
  readonly name?: Naming;
98
- /** Labels the app routes itself (`p-*` previews): `fetch` hands them back. */
104
+ /**
105
+ * Labels the app itself serves on the content domain, which `fetch` hands back (null) so the
106
+ * app's own routes answer there: Ferry's `p-*` previews, another server's namespace it routes
107
+ * on. Not for labels nothing should answer: a label the naming never makes, the bare domain
108
+ * and an address `entry.at` says names nothing all get Porthole's 404, with nothing to list
109
+ * (P070). The health label is reserved by the gate itself.
110
+ */
99
111
  readonly reserved?: readonly string[] | ((label: string) => boolean);
100
112
  /**
101
113
  * Policy 1, mapping: the instance a request proposes (`{ instance }`), as the app means it,
@@ -145,22 +157,49 @@ export interface EntryOptions<V> {
145
157
  /** The app's authorize route (`ContentHost.authorize`), an absolute URL on an app origin. */
146
158
  readonly authorizeUrl: string;
147
159
  /**
148
- * Policy 1 for a clean address: the instance a scope's path means, or null (404). The path is
149
- * the address's pathname; its query rides along but never chooses the instance.
160
+ * Policy 1 for a clean address: the instance a scope's path means, or null/undefined: nothing
161
+ * (404). The path is the address's pathname; its query rides along but never chooses the
162
+ * instance. Asked twice: on the content origin, with no viewer, whether the address names
163
+ * anything at all (nothing: Porthole's 404 there, never the app's sign-in, P070); then at the
164
+ * authorize route with the viewer. Say what exists the same way for both; `access` says who
165
+ * may see it.
150
166
  */
151
167
  readonly at: (
152
168
  scope: string,
153
169
  path: string,
154
170
  viewer: V | null,
155
171
  request: Request,
156
- ) => Awaitable<InstanceRef | null>;
172
+ ) => Awaitable<InstanceRef | null | undefined>;
157
173
  /**
158
174
  * The app's own sign-in, which comes back to `returnTo` (the authorize URL) once signed in.
159
- * Only ever on a tab: in a frame, the person is asked to open a new tab instead.
175
+ * Only ever on a tab: in a frame, the person is asked to open a new tab instead. `entry` is the
176
+ * address being opened, decoded (P070).
160
177
  */
161
- readonly signIn: (returnTo: string, request: Request) => Awaitable<Response>;
162
- /** The app's refusal page on a tab (a frame gets Porthole's plain one). Default: Porthole's. */
163
- readonly refusal?: (status: 403 | 404, request: Request) => Awaitable<Response>;
178
+ readonly signIn: (returnTo: string, request: Request, entry: EntryContext) => Awaitable<Response>;
179
+ /**
180
+ * The app's refusal page on a tab (a frame gets Porthole's plain one), for the address in
181
+ * `entry`. Nothing (null, undefined): Porthole's own (P070).
182
+ */
183
+ readonly refusal?: (
184
+ status: 403 | 404,
185
+ request: Request,
186
+ entry: EntryContext,
187
+ ) => Awaitable<Response | null | undefined>;
188
+ }
189
+
190
+ /** The clean address a redirect entry is opening, as its authorize route decoded it (P070). */
191
+ export interface EntryContext {
192
+ /** The address's label, and the scope it names (null: one the naming never makes). */
193
+ readonly label: string;
194
+ readonly scope: string | null;
195
+ /** The path (and query) asked for. */
196
+ readonly path: string;
197
+ /** The clean address itself: where the person comes back to. */
198
+ readonly url: string;
199
+ /** The instance the path names for this viewer (`entry.at`), or null. */
200
+ readonly instance: InstanceRef | null;
201
+ /** Whether it was asked from a frame. */
202
+ readonly framed: boolean;
164
203
  }
165
204
 
166
205
  export interface ContentHost<V> {
@@ -294,7 +333,21 @@ export function createContentHost<V>(options: ContentHostOptions<V>): ContentHos
294
333
  ...(options.upstream === undefined ? {} : { upstream: options.upstream }),
295
334
  ...(options.entry === undefined
296
335
  ? {}
297
- : { entry: { authorizeUrl: options.entry.authorizeUrl } }),
336
+ : {
337
+ entry: {
338
+ authorizeUrl: options.entry.authorizeUrl,
339
+ // Whether a clean address names anything, before a browser is sent away (P070).
340
+ named: async (label: string, path: string, request: Request) => {
341
+ const scope = options.name?.scopeOf?.(label) ?? null;
342
+ const at = options.entry?.at;
343
+ return (
344
+ scope !== null &&
345
+ at !== undefined &&
346
+ (await at(scope, path, null, request)) != null
347
+ );
348
+ },
349
+ },
350
+ }),
298
351
  ...(options.authorize === undefined
299
352
  ? {}
300
353
  : {
@@ -386,6 +439,13 @@ export function createContentHost<V>(options: ContentHostOptions<V>): ContentHos
386
439
  : { evt: "porthole.content.unhealthy", reason },
387
440
  );
388
441
  }
442
+ if (before.state !== view.state) {
443
+ try {
444
+ gate.onChange?.(view);
445
+ } catch {
446
+ // The app's listener failing changes nothing here.
447
+ }
448
+ }
389
449
  running = null;
390
450
  schedule();
391
451
  return view;
@@ -622,13 +682,22 @@ export function createContentHost<V>(options: ContentHostOptions<V>): ContentHos
622
682
  const instance =
623
683
  scope === null
624
684
  ? null
625
- : await entry.at(
685
+ : ((await entry.at(
626
686
  scope,
627
687
  new URL(state.path, "https://x.invalid").pathname,
628
688
  viewer,
629
689
  request,
630
- );
690
+ )) ?? null);
631
691
  const grant = instance === null ? null : await options.access(instance, viewer, request);
692
+ // The address being opened, decoded, for the app's sign-in and refusal pages.
693
+ const context: EntryContext = {
694
+ label: state.label,
695
+ scope,
696
+ path: state.path,
697
+ url: clean,
698
+ instance,
699
+ framed,
700
+ };
632
701
  if (instance === null || grant === null) {
633
702
  if (viewer === null) {
634
703
  // The app's sign-in never shows in a frame: the person opens a tab for it.
@@ -640,11 +709,12 @@ export function createContentHost<V>(options: ContentHostOptions<V>): ContentHos
640
709
  "sign-in",
641
710
  method,
642
711
  )
643
- : await entry.signIn(request.url, request);
712
+ : await entry.signIn(request.url, request, context);
644
713
  }
645
714
  const status = instance === null ? 404 : 403;
646
- if (!framed && entry.refusal !== undefined) {
647
- return await entry.refusal(status, request);
715
+ const own = framed ? null : await entry.refusal?.(status, request, context);
716
+ if (own !== null && own !== undefined) {
717
+ return own;
648
718
  }
649
719
  return status === 404
650
720
  ? appPage(404, "Not found", "There is nothing here.", "not-found", method)
@@ -40,6 +40,7 @@ export {
40
40
  type ContentHost,
41
41
  type ContentHostEvent,
42
42
  type ContentHostOptions,
43
+ type EntryContext,
43
44
  type EntryOptions,
44
45
  type ContentMeta,
45
46
  type HealthOptions,
@@ -62,6 +63,9 @@ export {
62
63
  } from "./tickets.ts";
63
64
  export { FRAME_STATUS_METHOD } from "../isolation/types.ts";
64
65
  export { isHostLabel, prefixedName, type Naming } from "./naming.ts";
66
+ // The types an app's callbacks take and give (P070).
67
+ export type { PortholeConfig } from "../config/schema.ts";
68
+ export type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
65
69
  export { proxyTo, type ProxyOptions } from "./proxy.ts";
66
70
  export type {
67
71
  Access,
@@ -22,8 +22,22 @@ export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
22
22
  /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
23
23
  export const SESSION_PATH = "/__porthole/session";
24
24
 
25
+ /**
26
+ * Tells the page that frames this one how loading went, at each web host origin. A desktop app's
27
+ * own scheme (`ferry://app`) is no target origin a browser takes (postMessage throws), so where
28
+ * the host origins hold one, and the parent isn't known to be a web host origin, the word goes to
29
+ * the parent as `*`: these pages' `frame-ancestors` already hold their parent to the host origins,
30
+ * and what they say is no secret (P070). One origin failing never stops the page.
31
+ */
25
32
  const say = (): string =>
26
- `const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
33
+ `const say = (params) => {
34
+ if (window.parent === window) return;
35
+ const message = { jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params };
36
+ const web = hosts.filter((origin) => origin.startsWith("https://") || origin.startsWith("http://"));
37
+ for (const origin of web) { try { window.parent.postMessage(message, origin); } catch (_) {} }
38
+ const parent = location.ancestorOrigins && location.ancestorOrigins.length > 0 ? location.ancestorOrigins[0] : null;
39
+ if (web.length < hosts.length && (parent === null || !web.includes(parent))) { try { window.parent.postMessage(message, "*"); } catch (_) {} }
40
+ };`;
27
41
 
28
42
  /** Tells the host the page is blocked, and why (from the page's data attributes). */
29
43
  const STATUS_SCRIPT = `(() => {
@@ -120,6 +120,8 @@ export function proxyTo(options: ProxyOptions = {}): Upstream {
120
120
  method,
121
121
  headers: upstreamRequestHeaders(request.headers, access.origin, target),
122
122
  redirect: "manual",
123
+ // The browser went away (`nodeHost` aborts it): so does the relay (P070).
124
+ signal: request.signal,
123
125
  ...(hasBody ? { body: request.body, duplex: "half" } : {}),
124
126
  });
125
127
  return new Response(method === "HEAD" ? null : answer.body, {
@@ -682,8 +682,17 @@ export function createServer(options: ServerOptions): PortholeServer {
682
682
  * signed state, the browser holding the state's nonce in a cookie of this origin. A path just
683
683
  * entered whose cookie still didn't come back is a browser that keeps none here: no loop.
684
684
  */
685
- function beginEntry(request: Request, url: URL, label: string, to: URL): Response {
685
+ async function beginEntry(request: Request, url: URL, label: string, to: URL): Promise<Response> {
686
686
  const method = request.method.toUpperCase();
687
+ // An address that names nothing is nothing here (P070): never a trip to the app's sign-in.
688
+ const scopeOf = content.name?.scopeOf;
689
+ const named = options.entry?.named;
690
+ if (
691
+ (scopeOf !== undefined && scopeOf(label) === null) ||
692
+ (named !== undefined && !(await named(label, url.pathname, request)))
693
+ ) {
694
+ return page(404, "Nothing here", "This address names no content.", method);
695
+ }
687
696
  if (method !== "GET" && method !== "HEAD") {
688
697
  return page(
689
698
  401,
@@ -1148,7 +1157,7 @@ export function createServer(options: ServerOptions): PortholeServer {
1148
1157
  return await serveAccess(request, entered, null);
1149
1158
  }
1150
1159
  if (options.upstream === undefined || accessOf(request, label, null) === null) {
1151
- return beginEntry(request, url, label, authorizeUrl);
1160
+ return await beginEntry(request, url, label, authorizeUrl);
1152
1161
  }
1153
1162
  }
1154
1163
  // Without an upstream, only Porthole's own paths are served here: any other is nothing.
@@ -1,5 +1,6 @@
1
1
  import type { PortholeConfig } from "../config/schema.ts";
2
2
  import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
3
+ import type { Naming } from "./naming.ts";
3
4
 
4
5
  export interface ContentDomain {
5
6
  /**
@@ -17,13 +18,14 @@ export interface ContentDomain {
17
18
  * names like `a-<id>`) must return one lowercase DNS label, the same for the same scope, a
18
19
  * different one for a different scope, and never a `reserved` one.
19
20
  */
20
- readonly name?: (scope: string) => string;
21
+ readonly name?: Naming;
21
22
  /**
22
- * Labels this server never serves or names: another server's namespace under the same domain
23
- * (`f` for `<instance>.f.<domain>`, `www`), or the host's own (`p-*`: every label starting
24
- * `p-`). Each entry is a whole label, or a prefix ending in `*`; or a function that says
25
- * whether a label is reserved. Requests for them, and for the names under them, get null (the
26
- * host's routes), so a host routes them without wrapping `handle`.
23
+ * Labels something else serves on this domain, so this server never names them and hands their
24
+ * requests back: another server's namespace (`f` for `<instance>.f.<domain>`, where that server
25
+ * answers), or the host's own (`p-*`: its previews). Each entry is a whole label, or a prefix
26
+ * ending in `*`; or a function that says whether a label is reserved. Requests for them, and for
27
+ * the names under them, get null (the host's routes), so a host routes them without wrapping
28
+ * `handle`. Labels nothing serves need no entry: they get this server's 404 (P070).
27
29
  */
28
30
  readonly reserved?: readonly string[] | ((label: string) => boolean);
29
31
  }
@@ -168,7 +170,16 @@ export interface ServerOptions {
168
170
  * to the host's authorize route, on a host origin, which decides and sends it back with a
169
171
  * one-time code (`enterBy`). Absent: a clean address without a cookie is refused, as before.
170
172
  */
171
- readonly entry?: { readonly authorizeUrl: string };
173
+ readonly entry?: {
174
+ readonly authorizeUrl: string;
175
+ /**
176
+ * Whether a clean address names anything (P070): asked before sending a browser away, with
177
+ * the address's label and pathname. False: this server's 404, never the authorize route. A
178
+ * naming that says a label's scope (`Naming.scopeOf`) already answers 404 for a label it never
179
+ * makes.
180
+ */
181
+ readonly named?: (label: string, path: string, request: Request) => boolean | Promise<boolean>;
182
+ };
172
183
  }
173
184
 
174
185
  /** A redirect entry's state, as the host's authorize route reads it (`PortholeServer.entryState`). */