@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.
- package/dist/conformance/entry.d.ts +8 -2
- package/dist/conformance/entry.js +23 -8
- package/dist/conformance/harness.js +4 -4
- package/dist/conformance/host-kit.d.ts +8 -3
- package/dist/conformance/host-kit.js +58 -23
- package/dist/conformance/limits.js +6 -0
- package/dist/conformance/served.d.ts +4 -4
- package/dist/conformance/served.js +26 -16
- package/dist/guide/guide.js +4 -4
- package/dist/guide/limits.js +1 -1
- package/dist/node/index.d.ts +11 -3
- package/dist/node/index.js +70 -10
- package/dist/server/host.d.ts +42 -8
- package/dist/server/host.js +35 -5
- package/dist/server/index.d.ts +3 -1
- package/dist/server/pages.js +15 -1
- package/dist/server/proxy.js +2 -0
- package/dist/server/server.js +9 -2
- package/dist/server/types.d.ts +15 -6
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/DECISIONS.md +1774 -0
- package/docs/DESIGN.md +1067 -0
- package/docs/OBJECTIVES.md +116 -0
- package/docs/PRINCIPLES.md +155 -0
- package/package.json +4 -3
- package/src/conformance/entry.ts +39 -10
- package/src/conformance/host-kit.ts +71 -28
- package/src/conformance/limits.ts +8 -0
- package/src/conformance/served.ts +34 -21
- package/src/guide/guide.ts +4 -4
- package/src/guide/limits.ts +1 -1
- package/src/node/index.ts +73 -10
- package/src/server/host.ts +84 -14
- package/src/server/index.ts +4 -0
- package/src/server/pages.ts +15 -1
- package/src/server/proxy.ts +2 -0
- package/src/server/server.ts +11 -2
- package/src/server/types.ts +18 -7
|
@@ -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 = (
|
|
202
|
-
|
|
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"), {
|
|
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"), {
|
|
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"), {
|
|
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"), {
|
|
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"), {
|
|
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), {
|
package/src/guide/guide.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
: `
|
|
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`
|
package/src/guide/limits.ts
CHANGED
|
@@ -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
|
-
/**
|
|
44
|
-
|
|
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, {
|
|
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
|
-
/**
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 (
|
|
124
|
-
response.
|
|
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
|
);
|
package/src/server/host.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
149
|
-
* the address's pathname; its query rides along but never chooses the
|
|
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
|
-
/**
|
|
163
|
-
|
|
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
|
-
: {
|
|
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
|
-
|
|
647
|
-
|
|
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)
|
package/src/server/index.ts
CHANGED
|
@@ -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,
|
package/src/server/pages.ts
CHANGED
|
@@ -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) => {
|
|
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 = `(() => {
|
package/src/server/proxy.ts
CHANGED
|
@@ -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, {
|
package/src/server/server.ts
CHANGED
|
@@ -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.
|
package/src/server/types.ts
CHANGED
|
@@ -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?:
|
|
21
|
+
readonly name?: Naming;
|
|
21
22
|
/**
|
|
22
|
-
* Labels
|
|
23
|
-
* (`f` for `<instance>.f.<domain>`,
|
|
24
|
-
* `p
|
|
25
|
-
* whether a label is reserved. Requests for them, and for
|
|
26
|
-
* host's routes), so a host routes them without wrapping
|
|
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?: {
|
|
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`). */
|