@unifedev/thread-pages 0.3.2 → 1.1.0

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.
Files changed (95) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11779 -11801
  3. package/dist/server.meta.json +2 -2
  4. package/docs/B1-OWN-FILES.md +133 -0
  5. package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
  6. package/docs/UPGRADING.md +53 -0
  7. package/package.json +26 -18
  8. package/server.ts +3 -2175
  9. package/src/agent/cli.ts +193 -0
  10. package/src/agent/guide.ts +450 -0
  11. package/src/agent/instruction.ts +59 -0
  12. package/src/agent/seed/seed.ts +73 -0
  13. package/{theme.ts → src/agent/seed/theme-css.ts} +9 -11
  14. package/src/agent/starter-hub.ts +217 -0
  15. package/src/bb/activity.ts +59 -0
  16. package/src/bb/bb-host.ts +280 -0
  17. package/src/bb/public-origin.ts +45 -0
  18. package/src/config/settings.ts +82 -0
  19. package/src/domain/capabilities/contract.ts +48 -0
  20. package/src/domain/capabilities/index.ts +10 -0
  21. package/src/domain/capabilities/protocol.ts +113 -0
  22. package/src/domain/capabilities/registry.ts +48 -0
  23. package/src/domain/capabilities/renamed.ts +34 -0
  24. package/src/domain/capabilities/schema.ts +198 -0
  25. package/src/domain/capabilities/specs.ts +479 -0
  26. package/src/domain/eligibility.ts +43 -0
  27. package/src/domain/errors.ts +116 -0
  28. package/src/domain/html/document.ts +109 -0
  29. package/src/domain/html/escape.ts +16 -0
  30. package/src/domain/ids.ts +37 -0
  31. package/src/domain/json/canonical.ts +19 -0
  32. package/src/domain/json/strict-json.ts +139 -0
  33. package/src/domain/limits.ts +98 -0
  34. package/src/domain/rate-limit.ts +64 -0
  35. package/src/domain/revision.ts +27 -0
  36. package/src/domain/submissions/idempotency.ts +59 -0
  37. package/src/domain/submissions/message.ts +42 -0
  38. package/src/domain/submissions/parse.ts +105 -0
  39. package/src/domain/tokens/action-token.ts +52 -0
  40. package/src/domain/tokens/confirmation.ts +99 -0
  41. package/src/domain/tokens/mac.ts +50 -0
  42. package/src/generated/kernel-runtime.ts +3 -0
  43. package/src/generated/shell-runtime.ts +3 -0
  44. package/src/host/contract.ts +65 -0
  45. package/src/host/types.ts +89 -0
  46. package/src/pages/inline.ts +277 -0
  47. package/src/pages/layout.ts +65 -0
  48. package/src/pages/page-store.ts +170 -0
  49. package/src/pages/site.ts +36 -0
  50. package/src/plugin.ts +81 -0
  51. package/src/runtime/kernel/anchors.ts +45 -0
  52. package/src/runtime/kernel/api.ts +15 -0
  53. package/src/runtime/kernel/bridge-client.ts +148 -0
  54. package/src/runtime/kernel/dirty.ts +51 -0
  55. package/src/runtime/kernel/forms.ts +114 -0
  56. package/src/runtime/kernel/install.ts +156 -0
  57. package/src/runtime/kernel/labels.ts +98 -0
  58. package/src/runtime/kernel/main.ts +6 -0
  59. package/src/runtime/kernel/readonly.ts +75 -0
  60. package/src/runtime/shared/protocol.ts +125 -0
  61. package/src/runtime/shell/confirm.ts +70 -0
  62. package/src/runtime/shell/install.ts +79 -0
  63. package/src/runtime/shell/main.ts +12 -0
  64. package/src/runtime/shell/navigate.ts +64 -0
  65. package/src/runtime/shell/poll.ts +125 -0
  66. package/src/runtime/shell/relay.ts +185 -0
  67. package/src/serving/action-request.ts +32 -0
  68. package/src/serving/bridge/dispatcher.ts +112 -0
  69. package/src/serving/bridge/handler.ts +37 -0
  70. package/src/serving/bridge/handlers/index.ts +26 -0
  71. package/src/serving/bridge/handlers/navigation.ts +43 -0
  72. package/src/serving/bridge/handlers/reads.ts +186 -0
  73. package/src/serving/bridge/handlers/writes.ts +175 -0
  74. package/src/serving/bridge/selection-store.ts +58 -0
  75. package/src/serving/bridge-route.ts +23 -0
  76. package/src/serving/context.ts +34 -0
  77. package/src/serving/document-route.ts +37 -0
  78. package/src/serving/home-route.ts +23 -0
  79. package/src/serving/responses.ts +81 -0
  80. package/src/serving/routes.ts +26 -0
  81. package/src/serving/session-access.ts +22 -0
  82. package/src/serving/shell-html.ts +77 -0
  83. package/src/serving/shell-route.ts +51 -0
  84. package/src/serving/signing-key.ts +25 -0
  85. package/src/serving/submit-route.ts +47 -0
  86. package/src/serving/upload-route.ts +46 -0
  87. package/tsconfig.json +10 -6
  88. package/ARCHITECTURE.md +0 -230
  89. package/PLUGIN_OVERVIEW.md +0 -83
  90. package/authoring.ts +0 -368
  91. package/bridge.ts +0 -1721
  92. package/docs/MODEL.md +0 -211
  93. package/docs/ROADMAP.md +0 -96
  94. package/home.ts +0 -419
  95. package/page.ts +0 -782
@@ -0,0 +1,125 @@
1
+ /**
2
+ * What crosses the one MessagePort between the kernel (inside the sandboxed
3
+ * document) and the shell (trusted chrome), and the configuration each side
4
+ * receives from the server. Shared so neither side can drift. spec R2.3, R3.8
5
+ */
6
+ import { BRIDGE_ERROR_CODES, type BridgeErrorCode } from "../../domain/errors.ts";
7
+
8
+ export const HANDSHAKE_VERSION = 1 as const;
9
+ export const BRIDGE_VERSION = 1 as const;
10
+
11
+ export interface BridgeRequestMessage {
12
+ v: 1;
13
+ id: string;
14
+ method: string;
15
+ params: unknown;
16
+ pageRevision: string;
17
+ }
18
+
19
+ export type BridgeResponseMessage =
20
+ | { v: 1; id: string; ok: true; result: unknown }
21
+ | { v: 1; id: string; ok: false; error: { code: BridgeErrorCode; message: string } };
22
+
23
+ export interface SubmitFile {
24
+ field: string;
25
+ file: File;
26
+ }
27
+
28
+ export interface SubmitAnswer {
29
+ name: string;
30
+ label: string;
31
+ value: string | string[] | boolean;
32
+ }
33
+
34
+ export type KernelMessage =
35
+ | { kind: "thread-page:dirty" }
36
+ | { kind: "thread-page:clean" }
37
+ | { kind: "thread-page:submit"; submissionId: string; title: string; answers: SubmitAnswer[]; files: SubmitFile[] }
38
+ | BridgeRequestMessage;
39
+
40
+ export type ShellMessage =
41
+ | { kind: "thread-page:source-state"; stale: boolean }
42
+ | { kind: "thread-page:submit-progress"; submissionId: string; message: string }
43
+ | { kind: "thread-page:submit-result"; submissionId: string; ok: boolean; message?: string; error?: string }
44
+ | BridgeResponseMessage;
45
+
46
+ /** Carried in the kernel script's `data-config` attribute. */
47
+ export interface KernelConfig {
48
+ pageRevision: string;
49
+ stale: boolean;
50
+ }
51
+
52
+ /** Carried in the shell script's `data-config` attribute. */
53
+ export interface ShellConfig {
54
+ actionToken: string;
55
+ pageRevision: string;
56
+ expiresAt: number;
57
+ documentUrl: string;
58
+ submitUrl: string;
59
+ uploadUrl: string;
60
+ bridgeUrl: string;
61
+ workingLabel: string;
62
+ stale: boolean;
63
+ pollMs: number;
64
+ maxUploadBytes: number;
65
+ maxUploads: number;
66
+ }
67
+
68
+ const ERROR_CODES: ReadonlySet<string> = new Set(BRIDGE_ERROR_CODES);
69
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/;
70
+ const METHOD_PATTERN = /^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*)+$/;
71
+
72
+ export function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ export function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
77
+ const own = Object.keys(value);
78
+ return own.length === keys.length && keys.every((key) => Object.prototype.hasOwnProperty.call(value, key));
79
+ }
80
+
81
+ export function isValidRequestId(value: unknown): value is string {
82
+ return typeof value === "string" && ID_PATTERN.test(value);
83
+ }
84
+
85
+ /** The shell checks every request before it leaves the reader's browser. */
86
+ export function isBridgeRequest(value: unknown, pageRevision: string): value is BridgeRequestMessage {
87
+ return (
88
+ isRecord(value) &&
89
+ hasExactKeys(value, ["v", "id", "method", "params", "pageRevision"]) &&
90
+ value.v === BRIDGE_VERSION &&
91
+ isValidRequestId(value.id) &&
92
+ typeof value.method === "string" &&
93
+ value.method.length >= 3 &&
94
+ value.method.length <= 96 &&
95
+ METHOD_PATTERN.test(value.method) &&
96
+ value.pageRevision === pageRevision
97
+ );
98
+ }
99
+
100
+ /** Both sides check every response; an unexpected shape is `invalid_response`. spec R4.32 */
101
+ export function isBridgeResponse(value: unknown, expectedId?: string): value is BridgeResponseMessage {
102
+ if (!isRecord(value) || value.v !== BRIDGE_VERSION || typeof value.id !== "string" || typeof value.ok !== "boolean") return false;
103
+ if (expectedId !== undefined && value.id !== expectedId) return false;
104
+ if (value.ok === true) return hasExactKeys(value, ["v", "id", "ok", "result"]);
105
+ if (!hasExactKeys(value, ["v", "id", "ok", "error"]) || !isRecord(value.error)) return false;
106
+ const error = value.error;
107
+ return (
108
+ hasExactKeys(error, ["code", "message"]) &&
109
+ typeof error.code === "string" &&
110
+ ERROR_CODES.has(error.code) &&
111
+ typeof error.message === "string" &&
112
+ error.message.length > 0 &&
113
+ error.message.length <= 512
114
+ );
115
+ }
116
+
117
+ export function makeFailure(id: unknown, code: BridgeErrorCode, message: string): BridgeResponseMessage {
118
+ return { v: 1, id: isValidRequestId(id) ? id : "invalid", ok: false, error: { code, message: message.slice(0, 512) || "Request failed" } };
119
+ }
120
+
121
+ export function readConfig<T>(script: Element | null): T {
122
+ const raw = script?.getAttribute("data-config");
123
+ if (!raw) throw new Error("Thread Page runtime: configuration is missing");
124
+ return JSON.parse(raw) as T;
125
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The confirmation dialog: rendered in trusted chrome the sandboxed page
3
+ * cannot draw over, click or reword. The summary comes from the host, never
4
+ * from the page. spec R2.19, R3.22
5
+ *
6
+ * Resolution is driven by button clicks rather than the dialog's `close`
7
+ * event, which never fires in headless browsers (spec 09 §Testing note).
8
+ */
9
+ export interface Confirmer {
10
+ confirm(summary: string, onConfirmGesture?: () => void): Promise<boolean>;
11
+ }
12
+
13
+ export function createConfirmer(dialog: HTMLDialogElement): Confirmer {
14
+ const text = dialog.querySelector("p");
15
+ const cancel = dialog.querySelector<HTMLButtonElement>('button[value="cancel"]');
16
+ const confirm = dialog.querySelector<HTMLButtonElement>('button[value="confirm"]');
17
+ let active: ((approved: boolean) => void) | null = null;
18
+ let gesture: (() => void) | undefined;
19
+
20
+ function settle(approved: boolean): void {
21
+ const current = active;
22
+ active = null;
23
+ if (approved && gesture) {
24
+ try {
25
+ gesture();
26
+ } catch {
27
+ // A refused popup is handled by the caller's fallback.
28
+ }
29
+ }
30
+ gesture = undefined;
31
+ if (dialog.open) dialog.close();
32
+ current?.(approved);
33
+ }
34
+
35
+ cancel?.addEventListener("click", (event) => {
36
+ event.preventDefault();
37
+ settle(false);
38
+ });
39
+ confirm?.addEventListener("click", (event) => {
40
+ event.preventDefault();
41
+ settle(true);
42
+ });
43
+ dialog.addEventListener("cancel", (event) => {
44
+ event.preventDefault();
45
+ settle(false);
46
+ });
47
+ dialog.addEventListener("close", () => {
48
+ if (active) settle(false);
49
+ });
50
+
51
+ return {
52
+ confirm(summary, onConfirmGesture) {
53
+ return new Promise((resolve) => {
54
+ if (active) settle(false);
55
+ if (text) text.textContent = summary;
56
+ active = resolve;
57
+ gesture = onConfirmGesture;
58
+ if (typeof dialog.showModal === "function") {
59
+ try {
60
+ dialog.showModal();
61
+ } catch {
62
+ settle(false);
63
+ }
64
+ } else {
65
+ settle(false);
66
+ }
67
+ });
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,79 @@
1
+ import { HANDSHAKE_VERSION, isRecord, type ShellConfig } from "../shared/protocol.ts";
2
+ import { createConfirmer } from "./confirm.ts";
3
+ import { createNavigator } from "./navigate.ts";
4
+ import { createPoller, type Poller } from "./poll.ts";
5
+ import { createRelay } from "./relay.ts";
6
+
7
+ /**
8
+ * Wires the shell: loads the document into the sandboxed frame, hands it one
9
+ * MessagePort once it reports ready, relays its messages, polls for a new
10
+ * revision, and owns the chrome. spec 02 §The shell
11
+ */
12
+ export interface ShellElements {
13
+ frame: HTMLIFrameElement;
14
+ status: HTMLElement;
15
+ work: HTMLElement;
16
+ reload: HTMLButtonElement;
17
+ dialog: HTMLDialogElement;
18
+ }
19
+
20
+ export interface ShellHandle {
21
+ poller: Poller;
22
+ }
23
+
24
+ export function installShell(win: Window & typeof globalThis, config: ShellConfig, elements: ShellElements, fetchImpl?: typeof fetch): ShellHandle {
25
+ const { frame, status, work, reload, dialog } = elements;
26
+ let framePort: MessagePort | null = null;
27
+ let awaitingReady = true;
28
+ let lastStale = config.stale;
29
+
30
+ const view = {
31
+ setStatus(text: string, warn: boolean) {
32
+ status.textContent = text;
33
+ status.dataset.tone = warn ? "warn" : "";
34
+ },
35
+ setWorking(working: boolean) {
36
+ work.dataset.visible = working && config.workingLabel ? "true" : "false";
37
+ },
38
+ showReload(visible: boolean) {
39
+ reload.dataset.visible = visible ? "true" : "false";
40
+ },
41
+ onStaleChanged(stale: boolean) {
42
+ lastStale = stale;
43
+ framePort?.postMessage({ kind: "thread-page:source-state", stale });
44
+ },
45
+ reloadView() {
46
+ win.location.reload();
47
+ },
48
+ };
49
+
50
+ const poller = createPoller(win, config, view, fetchImpl);
51
+ const navigator = createNavigator(win);
52
+ const confirmer = createConfirmer(dialog);
53
+ const relay = createRelay({ config, confirmer, navigator, onDirty: (dirty) => poller.setDirty(dirty), ...(fetchImpl ? { fetchImpl } : {}) });
54
+
55
+ function connectFrame(): void {
56
+ const channel = new win.MessageChannel();
57
+ const port = channel.port1;
58
+ framePort = port;
59
+ port.onmessage = (event) => relay.handle(port, event.data);
60
+ port.start?.();
61
+ frame.contentWindow?.postMessage({ kind: "thread-page:connect", version: HANDSHAKE_VERSION }, "*", [channel.port2]);
62
+ port.postMessage({ kind: "thread-page:source-state", stale: lastStale });
63
+ }
64
+
65
+ win.addEventListener("message", (event) => {
66
+ // Only the frame's opaque origin, only once, only the handshake.
67
+ if (!awaitingReady || event.origin !== "null" || event.source !== frame.contentWindow) return;
68
+ const data = event.data as unknown;
69
+ if (!isRecord(data) || data.kind !== "thread-page:ready" || data.version !== HANDSHAKE_VERSION) return;
70
+ awaitingReady = false;
71
+ connectFrame();
72
+ });
73
+
74
+ reload.addEventListener("click", () => win.location.reload());
75
+
76
+ frame.src = config.documentUrl;
77
+ poller.start();
78
+ return { poller };
79
+ }
@@ -0,0 +1,12 @@
1
+ import { readConfig, type ShellConfig } from "../shared/protocol.ts";
2
+ import { installShell } from "./install.ts";
3
+
4
+ // Entry for the bundled shell runtime.
5
+ const config = readConfig<ShellConfig>(document.currentScript);
6
+ const frame = document.querySelector("iframe");
7
+ const status = document.querySelector<HTMLElement>("[data-shell-status]");
8
+ const work = document.querySelector<HTMLElement>("[data-shell-working]");
9
+ const reload = document.querySelector<HTMLButtonElement>("[data-shell-reload]");
10
+ const dialog = document.querySelector("dialog");
11
+ if (!frame || !status || !work || !reload || !dialog) throw new Error("Thread Page shell: chrome is incomplete");
12
+ installShell(window, config, { frame, status, work, reload, dialog });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Navigation belongs to the trusted shell. Destinations arrive from the host,
3
+ * already validated; the shell never builds one from page-supplied text.
4
+ * spec R5.29–R5.34
5
+ */
6
+ export interface Navigator {
7
+ /** Same-origin destinations navigate the reader's view in place. */
8
+ inPlace(url: string): void;
9
+ /** Claims a window during a user gesture so a later navigation is not a blocked popup. */
10
+ reserveWindow(): void;
11
+ /** Sends the reserved window (or, failing that, this view) to an external URL. */
12
+ external(url: string): void;
13
+ /** Releases a reserved window that will not be used. */
14
+ release(): void;
15
+ }
16
+
17
+ export function createNavigator(win: Window): Navigator {
18
+ let reserved: Window | null = null;
19
+ return {
20
+ inPlace(url) {
21
+ win.location.assign(url);
22
+ },
23
+ reserveWindow() {
24
+ try {
25
+ reserved = win.open("", "_blank");
26
+ if (reserved) {
27
+ try {
28
+ reserved.opener = null;
29
+ } catch {
30
+ // Some browsers refuse; the navigation below still uses noreferrer semantics.
31
+ }
32
+ }
33
+ } catch {
34
+ reserved = null;
35
+ }
36
+ },
37
+ external(url) {
38
+ const target = reserved;
39
+ reserved = null;
40
+ if (target && !target.closed) {
41
+ try {
42
+ target.location.href = url;
43
+ return;
44
+ } catch {
45
+ try {
46
+ target.close();
47
+ } catch {
48
+ // ignore
49
+ }
50
+ }
51
+ }
52
+ win.location.assign(url);
53
+ },
54
+ release() {
55
+ const target = reserved;
56
+ reserved = null;
57
+ try {
58
+ target?.close();
59
+ } catch {
60
+ // ignore
61
+ }
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,125 @@
1
+ import type { ShellConfig } from "../shared/protocol.ts";
2
+
3
+ /**
4
+ * The revision poll: a conditional GET of the document every few seconds
5
+ * while the tab is visible. It carries the working indicator and the
6
+ * source state, so no second channel exists. spec R2.17–R2.26
7
+ */
8
+ export interface PollView {
9
+ setStatus(text: string, warn: boolean): void;
10
+ setWorking(working: boolean): void;
11
+ showReload(visible: boolean): void;
12
+ onStaleChanged(stale: boolean): void;
13
+ reloadView(): void;
14
+ }
15
+
16
+ export interface Poller {
17
+ start(): void;
18
+ setDirty(dirty: boolean): void;
19
+ /** For tests: run one poll now. */
20
+ pollNow(): Promise<void>;
21
+ isStopped(): boolean;
22
+ }
23
+
24
+ export function createPoller(win: Window, config: ShellConfig, view: PollView, fetchImpl: typeof fetch = win.fetch.bind(win)): Poller {
25
+ let etag = `"${config.pageRevision}"`;
26
+ let dirty = false;
27
+ let stopped = false;
28
+ let polling = false;
29
+ let lastStale = config.stale;
30
+ let timer: ReturnType<typeof setTimeout> | null = null;
31
+ let controller: AbortController | null = null;
32
+
33
+ function schedule(delay: number): void {
34
+ if (timer !== null) clearTimeout(timer);
35
+ timer = null;
36
+ if (stopped || win.document.visibilityState !== "visible") return;
37
+ timer = setTimeout(() => {
38
+ timer = null;
39
+ void poll();
40
+ }, delay);
41
+ }
42
+
43
+ function pause(): void {
44
+ if (timer !== null) clearTimeout(timer);
45
+ timer = null;
46
+ controller?.abort();
47
+ controller = null;
48
+ }
49
+
50
+ function newVersion(): void {
51
+ if (dirty) {
52
+ view.setStatus("Page changed — reload when ready", true);
53
+ view.showReload(true);
54
+ } else {
55
+ view.reloadView();
56
+ }
57
+ }
58
+
59
+ async function poll(): Promise<void> {
60
+ if (stopped || polling || win.document.visibilityState !== "visible") return;
61
+ if (Date.now() >= config.expiresAt - 30_000) {
62
+ stopped = true;
63
+ if (dirty) {
64
+ view.setStatus("Session expiring — reload when ready", true);
65
+ view.showReload(true);
66
+ } else {
67
+ view.reloadView();
68
+ }
69
+ return;
70
+ }
71
+ polling = true;
72
+ controller = new AbortController();
73
+ try {
74
+ const response = await fetchImpl(config.documentUrl, {
75
+ method: "GET",
76
+ credentials: "same-origin",
77
+ cache: "no-store",
78
+ headers: { "if-none-match": etag },
79
+ signal: controller.signal,
80
+ });
81
+ if (response.status === 401 || response.status === 403) {
82
+ stopped = true;
83
+ view.setStatus("Session expired — reload this page", true);
84
+ view.showReload(true);
85
+ return;
86
+ }
87
+ if (!response.ok && response.status !== 304) {
88
+ view.setStatus("Page unavailable", true);
89
+ return;
90
+ }
91
+ const stale = response.headers.get("x-thread-page-stale") === "true";
92
+ view.setWorking(response.headers.get("x-thread-page-activity") === "working");
93
+ if (stale !== lastStale) {
94
+ lastStale = stale;
95
+ view.onStaleChanged(stale);
96
+ }
97
+ view.setStatus(stale ? "Offline copy — read-only" : "", stale);
98
+ const next = response.headers.get("etag");
99
+ if (next && next !== etag) {
100
+ etag = next;
101
+ newVersion();
102
+ }
103
+ } catch (error) {
104
+ if (!(error instanceof DOMException && error.name === "AbortError")) view.setStatus("Cannot check for updates", true);
105
+ } finally {
106
+ controller = null;
107
+ polling = false;
108
+ schedule(config.pollMs);
109
+ }
110
+ }
111
+
112
+ win.document.addEventListener("visibilitychange", () => {
113
+ if (win.document.visibilityState === "visible") schedule(0);
114
+ else pause();
115
+ });
116
+
117
+ return {
118
+ start: () => schedule(config.pollMs),
119
+ setDirty: (next) => {
120
+ dirty = next;
121
+ },
122
+ pollNow: () => poll(),
123
+ isStopped: () => stopped,
124
+ };
125
+ }
@@ -0,0 +1,185 @@
1
+ import { isBridgeRequest, isBridgeResponse, isRecord, makeFailure, type BridgeRequestMessage, type ShellConfig, type ShellMessage, type SubmitFile } from "../shared/protocol.ts";
2
+ import type { Confirmer } from "./confirm.ts";
3
+ import type { Navigator } from "./navigate.ts";
4
+
5
+ /**
6
+ * The shell's side of the port: it validates every message from the frame,
7
+ * carries bridge calls and submissions to the host with the action token,
8
+ * shows host-authored confirmations, and executes host-validated navigation.
9
+ * spec R3.5–R3.7, R3.17
10
+ */
11
+ export interface RelayDeps {
12
+ config: ShellConfig;
13
+ confirmer: Confirmer;
14
+ navigator: Navigator;
15
+ onDirty(dirty: boolean): void;
16
+ fetchImpl?: typeof fetch;
17
+ }
18
+
19
+ export interface Relay {
20
+ handle(port: MessagePort, data: unknown): void;
21
+ }
22
+
23
+ type Directive = { kind: "page" | "host" | "external"; url: string };
24
+
25
+ export function createRelay(deps: RelayDeps): Relay {
26
+ const { config, confirmer, navigator } = deps;
27
+ const fetchImpl = deps.fetchImpl ?? fetch;
28
+
29
+ function reply(port: MessagePort, message: ShellMessage): void {
30
+ port.postMessage(message);
31
+ }
32
+
33
+ async function postBridge(body: unknown): Promise<unknown> {
34
+ const response = await fetchImpl(config.bridgeUrl, {
35
+ method: "POST",
36
+ credentials: "same-origin",
37
+ cache: "no-store",
38
+ headers: { "content-type": "application/json" },
39
+ body: JSON.stringify(body),
40
+ });
41
+ return response.json().catch(() => null);
42
+ }
43
+
44
+ function directiveOf(value: unknown): Directive | null {
45
+ if (!isRecord(value)) return null;
46
+ if ((value.kind !== "page" && value.kind !== "host" && value.kind !== "external") || typeof value.url !== "string") return null;
47
+ if (value.kind === "external" && !/^https?:\/\//i.test(value.url)) return null;
48
+ if (value.kind !== "external" && !value.url.startsWith("/")) return null;
49
+ return { kind: value.kind, url: value.url };
50
+ }
51
+
52
+ function deliver(port: MessagePort, request: BridgeRequestMessage, body: unknown): void {
53
+ if (!isRecord(body) || !isBridgeResponse(body.response, request.id)) {
54
+ reply(port, makeFailure(request.id, "invalid_response", "The Thread Page bridge returned an invalid response"));
55
+ return;
56
+ }
57
+ const directive = body.navigate === undefined ? null : directiveOf(body.navigate);
58
+ if (body.response.ok && directive) {
59
+ reply(port, body.response);
60
+ if (directive.kind === "external") navigator.external(directive.url);
61
+ else navigator.inPlace(directive.url);
62
+ return;
63
+ }
64
+ navigator.release();
65
+ reply(port, body.response);
66
+ }
67
+
68
+ async function relayBridge(port: MessagePort, request: BridgeRequestMessage): Promise<void> {
69
+ try {
70
+ const first = await postBridge({ actionToken: config.actionToken, request });
71
+ if (isRecord(first) && isRecord(first.confirm)) {
72
+ const confirm = first.confirm;
73
+ if (typeof confirm.challenge !== "string" || typeof confirm.summary !== "string" || confirm.requestId !== request.id) {
74
+ reply(port, makeFailure(request.id, "invalid_response", "The Thread Page bridge returned an invalid confirmation"));
75
+ return;
76
+ }
77
+ const external = request.method === "navigation.openExternal";
78
+ const approved = await confirmer.confirm(confirm.summary, external ? () => navigator.reserveWindow() : undefined);
79
+ if (!approved) {
80
+ reply(port, makeFailure(request.id, "cancelled", "You declined this action"));
81
+ return;
82
+ }
83
+ const second = await postBridge({ actionToken: config.actionToken, request, confirmation: confirm.challenge });
84
+ deliver(port, request, second);
85
+ return;
86
+ }
87
+ deliver(port, request, first);
88
+ } catch (error) {
89
+ navigator.release();
90
+ reply(port, makeFailure(request.id, "unavailable", error instanceof Error ? error.message : "The Thread Page bridge is unavailable"));
91
+ }
92
+ }
93
+
94
+ async function uploadOne(entry: SubmitFile): Promise<{ field: string; name: string; path: string; sizeBytes: number }> {
95
+ const file = entry.file;
96
+ if (!file || typeof file.size !== "number") throw new Error("Attachment is not a file");
97
+ const label = file.name || "file";
98
+ if (file.size <= 0) throw new Error(`Attachment ${label} is empty`);
99
+ if (file.size > config.maxUploadBytes) throw new Error(`Attachment ${label} is larger than ${Math.round(config.maxUploadBytes / (1024 * 1024))} MiB`);
100
+ const content = await encodeBase64(file);
101
+ const response = await fetchImpl(config.uploadUrl, {
102
+ method: "POST",
103
+ credentials: "same-origin",
104
+ cache: "no-store",
105
+ headers: { "content-type": "application/json" },
106
+ body: JSON.stringify({ actionToken: config.actionToken, pageRevision: config.pageRevision, name: label, content }),
107
+ });
108
+ const body = (await response.json().catch(() => null)) as Record<string, unknown> | null;
109
+ if (!response.ok || !body || body.ok !== true || typeof body.name !== "string" || typeof body.path !== "string" || typeof body.sizeBytes !== "number") {
110
+ throw new Error((body && typeof body.message === "string" && body.message) || `Upload failed (${response.status})`);
111
+ }
112
+ return { field: String(entry.field || "file").slice(0, 128), name: body.name, path: body.path, sizeBytes: body.sizeBytes };
113
+ }
114
+
115
+ async function relaySubmit(port: MessagePort, data: Record<string, unknown>): Promise<void> {
116
+ const submissionId = typeof data.submissionId === "string" ? data.submissionId : "";
117
+ try {
118
+ const entries = (Array.isArray(data.files) ? data.files : []).slice(0, config.maxUploads) as SubmitFile[];
119
+ const files = [];
120
+ for (let index = 0; index < entries.length; index += 1) {
121
+ reply(port, { kind: "thread-page:submit-progress", submissionId, message: `Uploading ${index + 1} of ${entries.length}…` });
122
+ files.push(await uploadOne(entries[index] as SubmitFile));
123
+ }
124
+ if (files.length > 0) reply(port, { kind: "thread-page:submit-progress", submissionId, message: "Sending…" });
125
+ const response = await fetchImpl(config.submitUrl, {
126
+ method: "POST",
127
+ credentials: "same-origin",
128
+ cache: "no-store",
129
+ headers: { "content-type": "application/json" },
130
+ body: JSON.stringify({
131
+ actionToken: config.actionToken,
132
+ submissionId,
133
+ pageRevision: config.pageRevision,
134
+ title: data.title,
135
+ answers: data.answers,
136
+ files,
137
+ }),
138
+ });
139
+ const body = (await response.json().catch(() => ({ ok: false, message: "Invalid server response" }))) as Record<string, unknown>;
140
+ const ok = response.ok && body.ok === true;
141
+ reply(port, {
142
+ kind: "thread-page:submit-result",
143
+ submissionId,
144
+ ok,
145
+ message: typeof body.delivery === "string" ? `Sent (${body.delivery})` : "Sent",
146
+ error: typeof body.message === "string" ? body.message : `Request failed (${response.status})`,
147
+ });
148
+ } catch (error) {
149
+ reply(port, { kind: "thread-page:submit-result", submissionId, ok: false, error: error instanceof Error ? error.message : "Request failed" });
150
+ }
151
+ }
152
+
153
+ return {
154
+ handle(port, data) {
155
+ if (!isRecord(data)) return;
156
+ if (data.kind === "thread-page:dirty") {
157
+ deps.onDirty(true);
158
+ return;
159
+ }
160
+ if (data.kind === "thread-page:clean") {
161
+ deps.onDirty(false);
162
+ return;
163
+ }
164
+ if (data.kind === "thread-page:submit") {
165
+ void relaySubmit(port, data);
166
+ return;
167
+ }
168
+ if (!isBridgeRequest(data, config.pageRevision)) {
169
+ reply(port, makeFailure(data.id, "invalid_request", "Invalid Thread Page bridge request"));
170
+ return;
171
+ }
172
+ void relayBridge(port, data);
173
+ },
174
+ };
175
+ }
176
+
177
+ async function encodeBase64(file: Blob): Promise<string> {
178
+ const bytes = new Uint8Array(await file.arrayBuffer());
179
+ let binary = "";
180
+ const chunk = 0x8000;
181
+ for (let index = 0; index < bytes.length; index += chunk) {
182
+ binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(index, index + chunk)));
183
+ }
184
+ return btoa(binary);
185
+ }