@unifedev/thread-pages 0.3.2 → 1.0.3
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/README.md +77 -129
- package/dist/server.js +11426 -11802
- package/dist/server.meta.json +2 -2
- package/package.json +25 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +355 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +69 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +4 -11
- package/src/agent/starter-hub.ts +217 -0
- package/src/bb/activity.ts +59 -0
- package/src/bb/bb-host.ts +280 -0
- package/src/bb/public-origin.ts +45 -0
- package/src/config/settings.ts +82 -0
- package/src/domain/capabilities/contract.ts +48 -0
- package/src/domain/capabilities/index.ts +10 -0
- package/src/domain/capabilities/protocol.ts +112 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/schema.ts +198 -0
- package/src/domain/capabilities/specs.ts +479 -0
- package/src/domain/eligibility.ts +43 -0
- package/src/domain/errors.ts +116 -0
- package/src/domain/html/document.ts +109 -0
- package/src/domain/html/escape.ts +16 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/json/canonical.ts +19 -0
- package/src/domain/json/strict-json.ts +139 -0
- package/src/domain/limits.ts +88 -0
- package/src/domain/rate-limit.ts +64 -0
- package/src/domain/revision.ts +27 -0
- package/src/domain/submissions/idempotency.ts +59 -0
- package/src/domain/submissions/message.ts +42 -0
- package/src/domain/submissions/parse.ts +105 -0
- package/src/domain/tokens/action-token.ts +52 -0
- package/src/domain/tokens/confirmation.ts +99 -0
- package/src/domain/tokens/mac.ts +50 -0
- package/src/generated/kernel-runtime.ts +3 -0
- package/src/generated/shell-runtime.ts +3 -0
- package/src/host/contract.ts +65 -0
- package/src/host/types.ts +89 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +136 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +71 -0
- package/src/runtime/kernel/anchors.ts +45 -0
- package/src/runtime/kernel/api.ts +15 -0
- package/src/runtime/kernel/bridge-client.ts +148 -0
- package/src/runtime/kernel/dirty.ts +51 -0
- package/src/runtime/kernel/forms.ts +114 -0
- package/src/runtime/kernel/install.ts +156 -0
- package/src/runtime/kernel/labels.ts +98 -0
- package/src/runtime/kernel/main.ts +6 -0
- package/src/runtime/kernel/readonly.ts +75 -0
- package/src/runtime/shared/protocol.ts +125 -0
- package/src/runtime/shell/confirm.ts +70 -0
- package/src/runtime/shell/install.ts +79 -0
- package/src/runtime/shell/main.ts +12 -0
- package/src/runtime/shell/navigate.ts +64 -0
- package/src/runtime/shell/poll.ts +125 -0
- package/src/runtime/shell/relay.ts +185 -0
- package/src/serving/action-request.ts +32 -0
- package/src/serving/bridge/dispatcher.ts +112 -0
- package/src/serving/bridge/handler.ts +37 -0
- package/src/serving/bridge/handlers/index.ts +26 -0
- package/src/serving/bridge/handlers/navigation.ts +43 -0
- package/src/serving/bridge/handlers/reads.ts +186 -0
- package/src/serving/bridge/handlers/writes.ts +175 -0
- package/src/serving/bridge/selection-store.ts +58 -0
- package/src/serving/bridge-route.ts +23 -0
- package/src/serving/context.ts +34 -0
- package/src/serving/document-route.ts +37 -0
- package/src/serving/home-route.ts +23 -0
- package/src/serving/responses.ts +81 -0
- package/src/serving/routes.ts +26 -0
- package/src/serving/session-access.ts +22 -0
- package/src/serving/shell-html.ts +77 -0
- package/src/serving/shell-route.ts +51 -0
- package/src/serving/signing-key.ts +25 -0
- package/src/serving/submit-route.ts +47 -0
- package/src/serving/upload-route.ts +46 -0
- package/tsconfig.json +10 -6
- package/ARCHITECTURE.md +0 -230
- package/PLUGIN_OVERVIEW.md +0 -83
- package/authoring.ts +0 -368
- package/bridge.ts +0 -1721
- package/docs/MODEL.md +0 -211
- package/docs/ROADMAP.md +0 -96
- package/home.ts +0 -419
- package/page.ts +0 -782
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { HANDSHAKE_VERSION, isRecord, type KernelConfig, type KernelMessage, type ShellMessage } from "../shared/protocol.ts";
|
|
2
|
+
import { installAnchorInterception } from "./anchors.ts";
|
|
3
|
+
import { installApi } from "./api.ts";
|
|
4
|
+
import { createBridgeClient } from "./bridge-client.ts";
|
|
5
|
+
import { createDirtyTracker } from "./dirty.ts";
|
|
6
|
+
import { buildIntent, capturedForms, isManualForm, lockForm, prepareForm, statusLine, unlockForm, type PendingForm } from "./forms.ts";
|
|
7
|
+
import { createReadOnlyController } from "./readonly.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wires the kernel into a document. Exported separately from `main.ts` so
|
|
11
|
+
* tests can install it into a jsdom window with a fake port.
|
|
12
|
+
*/
|
|
13
|
+
export interface KernelHandle {
|
|
14
|
+
/** For tests: deliver a shell message as if it arrived on the port. */
|
|
15
|
+
deliver(message: ShellMessage): void;
|
|
16
|
+
/** For tests: connect a port-like object the way the handshake would. */
|
|
17
|
+
connect(port: Pick<MessagePort, "postMessage"> & Partial<Pick<MessagePort, "start" | "onmessage">>): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function installKernel(win: Window & typeof globalThis, config: KernelConfig): KernelHandle {
|
|
21
|
+
const doc = win.document;
|
|
22
|
+
let port: MessagePort | null = null;
|
|
23
|
+
const pendingForms = new Map<string, PendingForm>();
|
|
24
|
+
const pendingByForm = new WeakSet<HTMLFormElement>();
|
|
25
|
+
|
|
26
|
+
function post(message: KernelMessage): boolean {
|
|
27
|
+
if (!port) return false;
|
|
28
|
+
try {
|
|
29
|
+
port.postMessage(message);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dirty = createDirtyTracker((isDirty) => {
|
|
37
|
+
post({ kind: isDirty ? "thread-page:dirty" : "thread-page:clean" });
|
|
38
|
+
});
|
|
39
|
+
const bridge = createBridgeClient(config.pageRevision, doc);
|
|
40
|
+
const readOnly = createReadOnlyController(doc, config.stale);
|
|
41
|
+
|
|
42
|
+
installApi(win, {
|
|
43
|
+
version: 1,
|
|
44
|
+
invoke: (method, params) => bridge.invoke(method, params),
|
|
45
|
+
watch: (method, params, listener, options) => bridge.watch(method, params, listener, options),
|
|
46
|
+
setDirty: (value) => dirty.setCustom(value !== false),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function prepare(root: ParentNode): void {
|
|
50
|
+
for (const form of capturedForms(root)) prepareForm(form);
|
|
51
|
+
readOnly.prepare(root);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
prepare(doc);
|
|
55
|
+
if (doc.readyState === "loading") doc.addEventListener("DOMContentLoaded", () => prepare(doc), { once: true });
|
|
56
|
+
if (typeof win.MutationObserver === "function" && doc.documentElement) {
|
|
57
|
+
const observer = new win.MutationObserver((records) => {
|
|
58
|
+
for (const record of records) {
|
|
59
|
+
for (const node of Array.from(record.addedNodes)) {
|
|
60
|
+
if (node.nodeType === 1) prepare(node as Element);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
observer.observe(doc.documentElement, { childList: true, subtree: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function markDirty(event: Event): void {
|
|
68
|
+
const target = event.target as Element | null;
|
|
69
|
+
const form = target?.closest?.("form");
|
|
70
|
+
if (!form || isManualForm(form)) return;
|
|
71
|
+
dirty.markForm(form);
|
|
72
|
+
}
|
|
73
|
+
doc.addEventListener("input", markDirty, true);
|
|
74
|
+
doc.addEventListener("change", markDirty, true);
|
|
75
|
+
|
|
76
|
+
doc.addEventListener(
|
|
77
|
+
"submit",
|
|
78
|
+
(event) => {
|
|
79
|
+
const form = event.target as Element | null;
|
|
80
|
+
if (!form || form.tagName?.toLowerCase() !== "form" || isManualForm(form)) return;
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
if (readOnly.isReadOnly() || pendingByForm.has(form as HTMLFormElement)) return;
|
|
83
|
+
const submissionId = `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
84
|
+
const target = form as HTMLFormElement;
|
|
85
|
+
const intent = buildIntent(target, (event as SubmitEvent).submitter ?? null, submissionId);
|
|
86
|
+
const pending: PendingForm = { form: target, disabled: [], dirtyVersion: dirty.versionOf(target) };
|
|
87
|
+
pendingForms.set(submissionId, pending);
|
|
88
|
+
pendingByForm.add(target);
|
|
89
|
+
statusLine(target).textContent = intent.files.length > 0 ? "Uploading…" : "Sending…";
|
|
90
|
+
pending.disabled = lockForm(target);
|
|
91
|
+
const sent = post({ kind: "thread-page:submit", submissionId, title: intent.title, answers: intent.answers, files: intent.files });
|
|
92
|
+
if (!sent) {
|
|
93
|
+
pendingForms.delete(submissionId);
|
|
94
|
+
pendingByForm.delete(target);
|
|
95
|
+
unlockForm(pending.disabled);
|
|
96
|
+
statusLine(target).textContent = "Page connection is not ready; try again in a moment.";
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
true,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
installAnchorInterception(doc, (url, label) => {
|
|
103
|
+
void bridge.invoke("navigation.openExternal", label ? { url, label } : { url }).catch(() => undefined);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function onShellMessage(data: unknown): void {
|
|
107
|
+
if (!isRecord(data)) return;
|
|
108
|
+
if (data.kind === "thread-page:source-state") {
|
|
109
|
+
readOnly.apply(data.stale === true);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (data.kind === "thread-page:submit-progress") {
|
|
113
|
+
const pending = typeof data.submissionId === "string" ? pendingForms.get(data.submissionId) : undefined;
|
|
114
|
+
if (pending) statusLine(pending.form).textContent = String(data.message ?? "Working…").slice(0, 160);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (data.kind === "thread-page:submit-result") {
|
|
118
|
+
const pending = typeof data.submissionId === "string" ? pendingForms.get(data.submissionId) : undefined;
|
|
119
|
+
if (!pending) return;
|
|
120
|
+
pendingForms.delete(data.submissionId as string);
|
|
121
|
+
pendingByForm.delete(pending.form);
|
|
122
|
+
const ok = data.ok === true;
|
|
123
|
+
statusLine(pending.form).textContent = ok ? String(data.message ?? "Sent").slice(0, 160) : String(data.error ?? "Could not send").slice(0, 160);
|
|
124
|
+
unlockForm(pending.disabled);
|
|
125
|
+
if (readOnly.isReadOnly()) readOnly.apply(true);
|
|
126
|
+
if (ok) dirty.clearForm(pending.form, pending.dirtyVersion);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
bridge.receive(data);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function connect(next: MessagePort): void {
|
|
133
|
+
port = next;
|
|
134
|
+
next.onmessage = (message) => onShellMessage(message.data);
|
|
135
|
+
next.start?.();
|
|
136
|
+
bridge.attach((request) => {
|
|
137
|
+
next.postMessage(request);
|
|
138
|
+
});
|
|
139
|
+
if (dirty.isDirty()) post({ kind: "thread-page:dirty" });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function acceptPort(event: MessageEvent): void {
|
|
143
|
+
if (port || event.source !== win.parent) return;
|
|
144
|
+
const data = event.data as unknown;
|
|
145
|
+
if (!isRecord(data) || data.kind !== "thread-page:connect" || data.version !== HANDSHAKE_VERSION || !event.ports || event.ports.length !== 1) return;
|
|
146
|
+
event.stopImmediatePropagation();
|
|
147
|
+
const next = event.ports[0];
|
|
148
|
+
if (next) connect(next);
|
|
149
|
+
}
|
|
150
|
+
win.addEventListener("message", acceptPort, true);
|
|
151
|
+
|
|
152
|
+
if (config.stale) readOnly.apply(true);
|
|
153
|
+
win.parent.postMessage({ kind: "thread-page:ready", version: HANDSHAKE_VERSION }, "*");
|
|
154
|
+
|
|
155
|
+
return { deliver: (message) => onShellMessage(message), connect: (fake) => connect(fake as MessagePort) };
|
|
156
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { SubmitAnswer } from "../shared/protocol.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Answer naming and group collapsing. spec R4.10–R4.12
|
|
5
|
+
*
|
|
6
|
+
* The reader sees labels, not field names, so a delivered answer carries the
|
|
7
|
+
* label. Order: explicit `data-label`; the nearest fieldset's legend; the
|
|
8
|
+
* control's `aria-label`; the text of a wrapping `<label>`; the text of a
|
|
9
|
+
* `<label for>`; the raw name. Derived text excludes nested controls, option
|
|
10
|
+
* text, hints and host-injected nodes.
|
|
11
|
+
*/
|
|
12
|
+
const EXCLUDED_FROM_TEXT = "input,textarea,select,button,option,small,output,[data-thread-page-range],[data-thread-page-status]";
|
|
13
|
+
|
|
14
|
+
export type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;
|
|
15
|
+
|
|
16
|
+
export function labelText(node: Element | null): string {
|
|
17
|
+
if (!node) return "";
|
|
18
|
+
const clone = node.cloneNode(true) as Element;
|
|
19
|
+
for (const child of Array.from(clone.querySelectorAll(EXCLUDED_FROM_TEXT))) child.remove();
|
|
20
|
+
return (clone.textContent || "").replace(/\s+/g, " ").trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function labelFor(form: HTMLFormElement, control: FormControl): string {
|
|
24
|
+
const explicit = control.getAttribute("data-label");
|
|
25
|
+
if (explicit && explicit.trim()) return explicit.trim();
|
|
26
|
+
const fieldset = control.closest("fieldset");
|
|
27
|
+
if (fieldset) {
|
|
28
|
+
const legend = labelText(fieldset.querySelector("legend"));
|
|
29
|
+
if (legend) return legend;
|
|
30
|
+
}
|
|
31
|
+
const aria = control.getAttribute("aria-label");
|
|
32
|
+
if (aria && aria.trim()) return aria.trim();
|
|
33
|
+
const wrapping = control.closest("label");
|
|
34
|
+
if (wrapping) {
|
|
35
|
+
const text = labelText(wrapping);
|
|
36
|
+
if (text) return text;
|
|
37
|
+
}
|
|
38
|
+
if (control.id) {
|
|
39
|
+
const doc = form.ownerDocument;
|
|
40
|
+
const referenced = Array.from(doc.querySelectorAll("label[for]")).find((label) => (label as HTMLLabelElement).htmlFor === control.id);
|
|
41
|
+
const text = labelText(referenced ?? null);
|
|
42
|
+
if (text) return text;
|
|
43
|
+
}
|
|
44
|
+
return control.name;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SKIPPED_TYPES = new Set(["button", "submit", "reset", "image", "file"]);
|
|
48
|
+
|
|
49
|
+
export function namedControls(form: HTMLFormElement): FormControl[] {
|
|
50
|
+
return Array.from(form.elements).filter((element): element is FormControl => {
|
|
51
|
+
const control = element as FormControl;
|
|
52
|
+
return typeof control.name === "string" && control.name.length > 0 && !control.disabled && "type" in control;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Collects answers in document order; the submitter leads as the chosen action. spec R2.36 */
|
|
57
|
+
export function collectAnswers(form: HTMLFormElement, submitter: HTMLElement | null): SubmitAnswer[] {
|
|
58
|
+
const controls = namedControls(form);
|
|
59
|
+
const answers: SubmitAnswer[] = [];
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
if (submitter && (tagOf(submitter) === "button" || tagOf(submitter) === "input")) {
|
|
62
|
+
const control = submitter as HTMLButtonElement | HTMLInputElement;
|
|
63
|
+
const value = control.value || (control.textContent || "").trim();
|
|
64
|
+
answers.push({ name: control.name || "action", label: "Action", value });
|
|
65
|
+
if (control.name) seen.add(control.name);
|
|
66
|
+
}
|
|
67
|
+
for (const control of controls) {
|
|
68
|
+
const name = control.name;
|
|
69
|
+
const type = String(control.type || "").toLowerCase();
|
|
70
|
+
if (seen.has(name) || SKIPPED_TYPES.has(type)) continue;
|
|
71
|
+
seen.add(name);
|
|
72
|
+
const group = controls.filter((item) => item.name === name);
|
|
73
|
+
answers.push({ name, label: labelFor(form, control), value: collapse(control, group, type) });
|
|
74
|
+
}
|
|
75
|
+
return answers;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Tag checks rather than `instanceof`, so the kernel works across realms and with custom elements. */
|
|
79
|
+
export function tagOf(element: Element): string {
|
|
80
|
+
return element.tagName.toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function collapse(control: FormControl, group: FormControl[], type: string): string | string[] | boolean {
|
|
84
|
+
if (type === "checkbox") {
|
|
85
|
+
const boxes = group.filter((item): item is HTMLInputElement => tagOf(item) === "input");
|
|
86
|
+
if (boxes.length === 1) return boxes[0]?.checked === true;
|
|
87
|
+
return boxes.filter((item) => item.checked).map((item) => item.value);
|
|
88
|
+
}
|
|
89
|
+
if (type === "radio") {
|
|
90
|
+
const checked = group.find((item) => tagOf(item) === "input" && (item as HTMLInputElement).checked) as HTMLInputElement | undefined;
|
|
91
|
+
return checked ? checked.value : "";
|
|
92
|
+
}
|
|
93
|
+
if (tagOf(control) === "select" && (control as HTMLSelectElement).multiple) {
|
|
94
|
+
return Array.from((control as HTMLSelectElement).selectedOptions).map((option) => option.value);
|
|
95
|
+
}
|
|
96
|
+
if (group.length > 1) return group.map((item) => String((item as HTMLInputElement).value ?? ""));
|
|
97
|
+
return String((control as HTMLInputElement).value ?? "");
|
|
98
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { readConfig, type KernelConfig } from "../shared/protocol.ts";
|
|
2
|
+
import { installKernel } from "./install.ts";
|
|
3
|
+
|
|
4
|
+
// Entry for the bundled kernel: runs before any authored script (spec R4.1)
|
|
5
|
+
// and reads its configuration from its own script element.
|
|
6
|
+
installKernel(window, readConfig<KernelConfig>(document.currentScript));
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { capturedForms, controlsOf, statusLine, type FormControl } from "./forms.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-only mode for an offline copy: disable the captured-form controls the
|
|
5
|
+
* host finds enabled, show a status line per form and a banner the page
|
|
6
|
+
* cannot suppress; on reconnection restore only what was disabled here.
|
|
7
|
+
* spec R2.28, R4.34, R4.35
|
|
8
|
+
*/
|
|
9
|
+
export const OFFLINE_ATTRIBUTE = "data-thread-page-offline";
|
|
10
|
+
export const OFFLINE_STATUS = "Offline copy — responses are disabled until the source host reconnects.";
|
|
11
|
+
|
|
12
|
+
export interface ReadOnlyController {
|
|
13
|
+
isReadOnly(): boolean;
|
|
14
|
+
apply(readOnly: boolean): void;
|
|
15
|
+
/** Applies the current state to newly added content. */
|
|
16
|
+
prepare(root: ParentNode): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createReadOnlyController(doc: Document, initial: boolean): ReadOnlyController {
|
|
20
|
+
const disabledByHost = new Set<FormControl>();
|
|
21
|
+
let readOnly = initial;
|
|
22
|
+
|
|
23
|
+
function banner(): void {
|
|
24
|
+
if (!doc.body) return;
|
|
25
|
+
let node = doc.querySelector<HTMLElement>(`[${OFFLINE_ATTRIBUTE}="host"]`);
|
|
26
|
+
if (readOnly && !node) {
|
|
27
|
+
node = doc.createElement("aside");
|
|
28
|
+
node.setAttribute(OFFLINE_ATTRIBUTE, "host");
|
|
29
|
+
node.setAttribute("role", "status");
|
|
30
|
+
node.setAttribute(
|
|
31
|
+
"style",
|
|
32
|
+
"position:relative;z-index:2147483647;margin:0;padding:.75rem 1rem;border-bottom:1px solid currentColor;font:600 14px/1.4 system-ui,sans-serif;background:Canvas;color:CanvasText",
|
|
33
|
+
);
|
|
34
|
+
node.textContent = OFFLINE_STATUS;
|
|
35
|
+
doc.body.insertBefore(node, doc.body.firstChild);
|
|
36
|
+
} else if (!readOnly && node) {
|
|
37
|
+
node.remove();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function lock(root: ParentNode): void {
|
|
42
|
+
for (const form of capturedForms(root)) {
|
|
43
|
+
for (const control of controlsOf(form)) {
|
|
44
|
+
if (!control.disabled) {
|
|
45
|
+
control.disabled = true;
|
|
46
|
+
disabledByHost.add(control);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
statusLine(form).textContent = OFFLINE_STATUS;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function unlock(): void {
|
|
54
|
+
for (const control of disabledByHost) control.disabled = false;
|
|
55
|
+
disabledByHost.clear();
|
|
56
|
+
for (const form of capturedForms(doc)) {
|
|
57
|
+
const status = statusLine(form);
|
|
58
|
+
if (status.textContent === OFFLINE_STATUS) status.textContent = "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
isReadOnly: () => readOnly,
|
|
64
|
+
apply(next) {
|
|
65
|
+
readOnly = next;
|
|
66
|
+
if (next) lock(doc);
|
|
67
|
+
else unlock();
|
|
68
|
+
banner();
|
|
69
|
+
},
|
|
70
|
+
prepare(root) {
|
|
71
|
+
if (readOnly) lock(root);
|
|
72
|
+
banner();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -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 });
|