@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.
Files changed (90) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11426 -11802
  3. package/dist/server.meta.json +2 -2
  4. package/package.json +25 -18
  5. package/server.ts +3 -2175
  6. package/src/agent/cli.ts +193 -0
  7. package/src/agent/guide.ts +355 -0
  8. package/src/agent/instruction.ts +59 -0
  9. package/src/agent/seed/seed.ts +69 -0
  10. package/{theme.ts → src/agent/seed/theme-css.ts} +4 -11
  11. package/src/agent/starter-hub.ts +217 -0
  12. package/src/bb/activity.ts +59 -0
  13. package/src/bb/bb-host.ts +280 -0
  14. package/src/bb/public-origin.ts +45 -0
  15. package/src/config/settings.ts +82 -0
  16. package/src/domain/capabilities/contract.ts +48 -0
  17. package/src/domain/capabilities/index.ts +10 -0
  18. package/src/domain/capabilities/protocol.ts +112 -0
  19. package/src/domain/capabilities/registry.ts +48 -0
  20. package/src/domain/capabilities/schema.ts +198 -0
  21. package/src/domain/capabilities/specs.ts +479 -0
  22. package/src/domain/eligibility.ts +43 -0
  23. package/src/domain/errors.ts +116 -0
  24. package/src/domain/html/document.ts +109 -0
  25. package/src/domain/html/escape.ts +16 -0
  26. package/src/domain/ids.ts +37 -0
  27. package/src/domain/json/canonical.ts +19 -0
  28. package/src/domain/json/strict-json.ts +139 -0
  29. package/src/domain/limits.ts +88 -0
  30. package/src/domain/rate-limit.ts +64 -0
  31. package/src/domain/revision.ts +27 -0
  32. package/src/domain/submissions/idempotency.ts +59 -0
  33. package/src/domain/submissions/message.ts +42 -0
  34. package/src/domain/submissions/parse.ts +105 -0
  35. package/src/domain/tokens/action-token.ts +52 -0
  36. package/src/domain/tokens/confirmation.ts +99 -0
  37. package/src/domain/tokens/mac.ts +50 -0
  38. package/src/generated/kernel-runtime.ts +3 -0
  39. package/src/generated/shell-runtime.ts +3 -0
  40. package/src/host/contract.ts +65 -0
  41. package/src/host/types.ts +89 -0
  42. package/src/pages/layout.ts +65 -0
  43. package/src/pages/page-store.ts +136 -0
  44. package/src/pages/site.ts +36 -0
  45. package/src/plugin.ts +71 -0
  46. package/src/runtime/kernel/anchors.ts +45 -0
  47. package/src/runtime/kernel/api.ts +15 -0
  48. package/src/runtime/kernel/bridge-client.ts +148 -0
  49. package/src/runtime/kernel/dirty.ts +51 -0
  50. package/src/runtime/kernel/forms.ts +114 -0
  51. package/src/runtime/kernel/install.ts +156 -0
  52. package/src/runtime/kernel/labels.ts +98 -0
  53. package/src/runtime/kernel/main.ts +6 -0
  54. package/src/runtime/kernel/readonly.ts +75 -0
  55. package/src/runtime/shared/protocol.ts +125 -0
  56. package/src/runtime/shell/confirm.ts +70 -0
  57. package/src/runtime/shell/install.ts +79 -0
  58. package/src/runtime/shell/main.ts +12 -0
  59. package/src/runtime/shell/navigate.ts +64 -0
  60. package/src/runtime/shell/poll.ts +125 -0
  61. package/src/runtime/shell/relay.ts +185 -0
  62. package/src/serving/action-request.ts +32 -0
  63. package/src/serving/bridge/dispatcher.ts +112 -0
  64. package/src/serving/bridge/handler.ts +37 -0
  65. package/src/serving/bridge/handlers/index.ts +26 -0
  66. package/src/serving/bridge/handlers/navigation.ts +43 -0
  67. package/src/serving/bridge/handlers/reads.ts +186 -0
  68. package/src/serving/bridge/handlers/writes.ts +175 -0
  69. package/src/serving/bridge/selection-store.ts +58 -0
  70. package/src/serving/bridge-route.ts +23 -0
  71. package/src/serving/context.ts +34 -0
  72. package/src/serving/document-route.ts +37 -0
  73. package/src/serving/home-route.ts +23 -0
  74. package/src/serving/responses.ts +81 -0
  75. package/src/serving/routes.ts +26 -0
  76. package/src/serving/session-access.ts +22 -0
  77. package/src/serving/shell-html.ts +77 -0
  78. package/src/serving/shell-route.ts +51 -0
  79. package/src/serving/signing-key.ts +25 -0
  80. package/src/serving/submit-route.ts +47 -0
  81. package/src/serving/upload-route.ts +46 -0
  82. package/tsconfig.json +10 -6
  83. package/ARCHITECTURE.md +0 -230
  84. package/PLUGIN_OVERVIEW.md +0 -83
  85. package/authoring.ts +0 -368
  86. package/bridge.ts +0 -1721
  87. package/docs/MODEL.md +0 -211
  88. package/docs/ROADMAP.md +0 -96
  89. package/home.ts +0 -419
  90. package/page.ts +0 -782
@@ -0,0 +1,136 @@
1
+ import { PageError, PUBLIC_MESSAGES, errorText } from "../domain/errors.ts";
2
+ import { isRevision } from "../domain/ids.ts";
3
+ import { LIMITS } from "../domain/limits.ts";
4
+ import { revisionOf } from "../domain/revision.ts";
5
+ import type { SessionHost } from "../host/contract.ts";
6
+ import { ENTRY_FILE } from "./layout.ts";
7
+
8
+ /**
9
+ * Loads a page's entry document, bounds it, computes its revision, and keeps
10
+ * a last-known-good copy so the page still opens read-only when its source
11
+ * host is unreachable. spec R1.7, R2.11, R2.27–R2.30
12
+ */
13
+ export interface LoadedPage {
14
+ readonly html: string;
15
+ readonly revision: string;
16
+ readonly updatedAtMs: number;
17
+ /** True when served from the offline copy. */
18
+ readonly stale: boolean;
19
+ }
20
+
21
+ interface CachedPage {
22
+ readonly html: string;
23
+ readonly revision: string;
24
+ readonly updatedAtMs: number;
25
+ }
26
+
27
+ export interface PageStore {
28
+ load(session: string): Promise<LoadedPage>;
29
+ /** Records a document the plugin just wrote, so the next load is warm. */
30
+ remember(session: string, html: string): Promise<CachedPage>;
31
+ /** The revision last seen for a session, without touching the host. */
32
+ knownRevision(session: string): string | null;
33
+ }
34
+
35
+ const KV_PREFIX = "cache:";
36
+
37
+ export function createPageStore(host: SessionHost): PageStore {
38
+ const memory = new Map<string, CachedPage>();
39
+ let memoryBytes = 0;
40
+
41
+ function cost(page: CachedPage): number {
42
+ return Buffer.byteLength(page.html, "utf8") + 128;
43
+ }
44
+
45
+ function retain(session: string, page: CachedPage): void {
46
+ const previous = memory.get(session);
47
+ if (previous) {
48
+ memoryBytes -= cost(previous);
49
+ memory.delete(session);
50
+ }
51
+ memory.set(session, page);
52
+ memoryBytes += cost(page);
53
+ while (memory.size > LIMITS.offlineCacheEntries || memoryBytes > LIMITS.offlineCacheBytes) {
54
+ const oldest = memory.keys().next().value;
55
+ if (oldest === undefined) break;
56
+ const evicted = memory.get(oldest);
57
+ memory.delete(oldest);
58
+ if (evicted) memoryBytes -= cost(evicted);
59
+ }
60
+ }
61
+
62
+ async function persist(session: string, page: CachedPage, previousRevision: string | undefined): Promise<void> {
63
+ if (previousRevision === page.revision) return;
64
+ const key = KV_PREFIX + session;
65
+ if (Buffer.byteLength(page.html, "utf8") > LIMITS.offlineCopyBytes) {
66
+ await host.kv.delete(key).catch((error: unknown) => host.log.warn(`offline copy: could not clear ${session}: ${errorText(error)}`));
67
+ return;
68
+ }
69
+ await host.kv.set(key, { html: page.html, revision: page.revision, updatedAtMs: page.updatedAtMs }).catch((error: unknown) => {
70
+ host.log.warn(`offline copy: could not store ${session}: ${errorText(error)}`);
71
+ });
72
+ }
73
+
74
+ async function cached(session: string): Promise<CachedPage | null> {
75
+ const resident = memory.get(session);
76
+ if (resident) return resident;
77
+ try {
78
+ const stored = await host.kv.get(KV_PREFIX + session);
79
+ if (!isCachedPage(stored)) return null;
80
+ retain(session, stored);
81
+ return stored;
82
+ } catch (error) {
83
+ host.log.warn(`offline copy: could not read ${session}: ${errorText(error)}`);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ async function remember(session: string, html: string, updatedAtMs = Date.now()): Promise<CachedPage> {
89
+ const page: CachedPage = { html, revision: revisionOf(html), updatedAtMs };
90
+ const previous = memory.get(session)?.revision;
91
+ retain(session, page);
92
+ await persist(session, page, previous);
93
+ return page;
94
+ }
95
+
96
+ return {
97
+ async load(session) {
98
+ let content;
99
+ try {
100
+ const location = await host.sessions.storage(session);
101
+ content = await host.files.read(location, ENTRY_FILE);
102
+ } catch (error) {
103
+ const fallback = await cached(session);
104
+ if (fallback) return { ...fallback, stale: true };
105
+ throw PageError.is(error) ? error : new PageError("unavailable", PUBLIC_MESSAGES.unavailable, { cause: error });
106
+ }
107
+ if (!content) throw new PageError("no_page", PUBLIC_MESSAGES.noPage);
108
+ if (content.bytes.byteLength > LIMITS.entryDocumentBytes) {
109
+ throw new PageError("page_too_large", PUBLIC_MESSAGES.pageTooLarge);
110
+ }
111
+ const html = Buffer.from(content.bytes).toString("utf8");
112
+ const page: CachedPage = { html, revision: revisionOf(content.bytes), updatedAtMs: content.modifiedAtMs ?? Date.now() };
113
+ const previous = memory.get(session)?.revision;
114
+ retain(session, page);
115
+ await persist(session, page, previous);
116
+ return { ...page, stale: false };
117
+ },
118
+ remember,
119
+ knownRevision(session) {
120
+ return memory.get(session)?.revision ?? null;
121
+ },
122
+ };
123
+ }
124
+
125
+ function isCachedPage(value: unknown): value is CachedPage {
126
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
127
+ const entry = value as Record<string, unknown>;
128
+ return (
129
+ typeof entry.html === "string" &&
130
+ Buffer.byteLength(entry.html, "utf8") <= LIMITS.offlineCopyBytes &&
131
+ isRevision(entry.revision) &&
132
+ revisionOf(entry.html) === entry.revision &&
133
+ typeof entry.updatedAtMs === "number" &&
134
+ Number.isFinite(entry.updatedAtMs)
135
+ );
136
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How a page's site reaches the reader. spec R1.2, R1.3; rewrite RW-1
3
+ *
4
+ * The document is always served by this plugin (it needs the kernel). Where
5
+ * its files come from depends on what the host's router can do:
6
+ *
7
+ * - `core-storage`: the host already serves the session's storage as a site
8
+ * at a stable path; the document gets one same-origin `<base>` so relative
9
+ * references resolve there. This is what bb 0.42.1 allows.
10
+ * - `plugin-prefix`: the plugin serves `/page/<id>/…` itself; the document
11
+ * URL is path-shaped and no base is needed. Available once the host's
12
+ * plugin router matches prefixes.
13
+ */
14
+ export interface SiteStrategy {
15
+ readonly name: "core-storage" | "plugin-prefix";
16
+ /** Origin-relative URL the shell loads into the iframe. */
17
+ documentUrl(session: string): string;
18
+ /** Same-origin `<base href>` to inject, or null when the document URL is path-shaped. */
19
+ baseHref(session: string): string | null;
20
+ }
21
+
22
+ export function createCoreStorageSite(routeBase: string, storageFilesBase: (session: string) => string): SiteStrategy {
23
+ return {
24
+ name: "core-storage",
25
+ documentUrl: (session) => `${routeBase}/document?session=${encodeURIComponent(session)}`,
26
+ baseHref: (session) => storageFilesBase(session),
27
+ };
28
+ }
29
+
30
+ export function createPluginPrefixSite(routeBase: string): SiteStrategy {
31
+ return {
32
+ name: "plugin-prefix",
33
+ documentUrl: (session) => `${routeBase}/page/${encodeURIComponent(session)}/`,
34
+ baseHref: () => null,
35
+ };
36
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { BbPluginApi } from "@get-bb/plugin-sdk";
2
+ import { registerCli } from "./agent/cli.ts";
3
+ import { buildGuide } from "./agent/guide.ts";
4
+ import { createBbHost } from "./bb/bb-host.ts";
5
+ import { defineSettings } from "./config/settings.ts";
6
+ import { capabilityRegistry } from "./domain/capabilities/index.ts";
7
+ import { createRateLimiter } from "./domain/rate-limit.ts";
8
+ import { createOutcomeMemory } from "./domain/submissions/idempotency.ts";
9
+ import type { SessionHost } from "./host/contract.ts";
10
+ import { createPageStore } from "./pages/page-store.ts";
11
+ import { createCoreStorageSite, type SiteStrategy } from "./pages/site.ts";
12
+ import { createSelectionStore } from "./serving/bridge/selection-store.ts";
13
+ import type { ServingContext } from "./serving/context.ts";
14
+ import { registerRoutes } from "./serving/routes.ts";
15
+ import { loadSigningKey } from "./serving/signing-key.ts";
16
+
17
+ /**
18
+ * The composition root: the only file that knows every package. Builds the
19
+ * host adapter, the stores and the serving context, then registers routes,
20
+ * the CLI and the agent-instruction hook.
21
+ */
22
+ export interface PluginOptions {
23
+ /** Override the host (tests). */
24
+ host?: SessionHost;
25
+ /** Override the site strategy (tests, or a host with prefix routes). */
26
+ site?: (routeBase: string) => SiteStrategy;
27
+ now?: () => number;
28
+ }
29
+
30
+ export async function createPlugin(bb: BbPluginApi, options: PluginOptions = {}): Promise<ServingContext> {
31
+ const settings = await defineSettings(bb);
32
+ const host = options.host ?? createBbHost(bb);
33
+ const signingKey = await loadSigningKey(host);
34
+ const routeBase = `/api/v1/plugins/${bb.pluginId}/http`;
35
+ const site = options.site
36
+ ? options.site(routeBase)
37
+ : createCoreStorageSite(routeBase, (session) => `/api/v1/threads/${encodeURIComponent(session)}/thread-storage/files/`);
38
+
39
+ const serving: ServingContext = {
40
+ host,
41
+ pages: createPageStore(host),
42
+ settings,
43
+ signingKey,
44
+ site,
45
+ routeBase,
46
+ registry: capabilityRegistry,
47
+ rate: createRateLimiter(),
48
+ submissions: createOutcomeMemory(),
49
+ replies: createOutcomeMemory(),
50
+ selections: createSelectionStore(),
51
+ hostSessionUrl: (session) => `/threads/${encodeURIComponent(session)}`,
52
+ now: options.now ?? (() => Date.now()),
53
+ };
54
+
55
+ const effectiveInstruction = (): string | null => {
56
+ const current = settings.current();
57
+ return current.agentInstructions && current.agentInstructionText.trim() ? current.agentInstructionText : null;
58
+ };
59
+
60
+ // The standing instruction: only eligible sessions, only when enabled.
61
+ // Visibility is not known here, so `init` rechecks eligibility at call time. spec R6.14
62
+ bb.agents.configure((context) => {
63
+ const instruction = effectiveInstruction();
64
+ const root = context.thread.parentThreadId === null && context.thread.sourceThreadId === null && context.origin.kind === null;
65
+ return instruction && root ? { tools: [], skills: [], instructions: instruction } : { tools: [], skills: [] };
66
+ });
67
+
68
+ registerRoutes(bb, serving);
69
+ registerCli(bb, { serving, guide: buildGuide(capabilityRegistry, site), effectiveInstruction });
70
+ return serving;
71
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Authored links work. spec R4.15
3
+ *
4
+ * The sandbox has no top-level navigation, so an `<a href>` to another site
5
+ * would silently do nothing (or replace the page inside the frame). The
6
+ * kernel routes http(s) destinations through `navigation.openExternal`,
7
+ * leaves same-document fragments and the page's own files to the browser,
8
+ * and swallows schemes the sandbox cannot honour.
9
+ */
10
+ export type AnchorDecision = { kind: "default" } | { kind: "external"; url: string; label: string } | { kind: "block" };
11
+
12
+ export function decideAnchor(anchor: HTMLAnchorElement, documentUrl: string, siteBase: string | null): AnchorDecision {
13
+ const raw = anchor.getAttribute("href");
14
+ if (raw === null) return { kind: "default" };
15
+ if (raw.startsWith("#")) return { kind: "default" };
16
+ let target: URL;
17
+ try {
18
+ target = new URL(raw, siteBase ?? documentUrl);
19
+ } catch {
20
+ return { kind: "block" };
21
+ }
22
+ if (target.protocol !== "http:" && target.protocol !== "https:") return { kind: "block" };
23
+ if (siteBase && target.href.startsWith(siteBase)) return { kind: "default" };
24
+ if (anchor.hasAttribute("download")) return { kind: "default" };
25
+ return { kind: "external", url: target.href, label: (anchor.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160) };
26
+ }
27
+
28
+ export function installAnchorInterception(doc: Document, open: (url: string, label: string) => void): void {
29
+ doc.addEventListener(
30
+ "click",
31
+ (event) => {
32
+ if (event.defaultPrevented || event.button !== 0) return;
33
+ const target = event.target as Element | null;
34
+ const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
35
+ if (!anchor) return;
36
+ const base = doc.querySelector("base")?.getAttribute("href") ?? null;
37
+ const siteBase = base ? new URL(base, doc.baseURI).href : null;
38
+ const decision = decideAnchor(anchor, doc.baseURI, siteBase);
39
+ if (decision.kind === "default") return;
40
+ event.preventDefault();
41
+ if (decision.kind === "external") open(decision.url, decision.label);
42
+ },
43
+ true,
44
+ );
45
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `window.threadPage`: the complete page-facing API, frozen, non-writable and
3
+ * non-configurable so one script cannot shim it for another. spec R4.3, R4.28–R4.33
4
+ */
5
+ export interface ThreadPageApi {
6
+ readonly version: 1;
7
+ invoke(method: string, params?: unknown): Promise<unknown>;
8
+ watch(method: string, params: unknown, listener: (value: unknown, error: unknown) => void, options?: { intervalMs?: number }): () => void;
9
+ setDirty(dirty: boolean): void;
10
+ }
11
+
12
+ export function installApi(target: Window, api: ThreadPageApi): void {
13
+ const frozen = Object.freeze({ version: 1 as const, invoke: api.invoke, watch: api.watch, setDirty: api.setDirty });
14
+ Object.defineProperty(target, "threadPage", { value: frozen, writable: false, configurable: false, enumerable: true });
15
+ }
@@ -0,0 +1,148 @@
1
+ import { LIMITS } from "../../domain/limits.ts";
2
+ import { BRIDGE_VERSION, isBridgeResponse, type BridgeRequestMessage, type BridgeResponseMessage } from "../shared/protocol.ts";
3
+ import type { BridgeErrorCode } from "../../domain/errors.ts";
4
+
5
+ /**
6
+ * `invoke` and `watch` as a page sees them. Calls made before the port is
7
+ * ready are queued; every response is checked before it is trusted; a
8
+ * failure rejects with an Error carrying a `code`. spec R4.28–R4.32
9
+ */
10
+ export interface BridgeClient {
11
+ invoke(method: string, params?: unknown): Promise<unknown>;
12
+ watch(method: string, params: unknown, listener: (value: unknown, error: unknown) => void, options?: { intervalMs?: number }): () => void;
13
+ /** Called by the kernel once the shell hands over the port. */
14
+ attach(post: (message: BridgeRequestMessage) => void): void;
15
+ /** Called by the kernel for every port message; returns true when consumed. */
16
+ receive(message: unknown): boolean;
17
+ }
18
+
19
+ export class ThreadPageError extends Error {
20
+ readonly code: BridgeErrorCode;
21
+ constructor(code: BridgeErrorCode, message: string) {
22
+ super(message);
23
+ this.name = "ThreadPageError";
24
+ this.code = code;
25
+ Object.defineProperty(this, "code", { value: code, enumerable: true, writable: false });
26
+ }
27
+ }
28
+
29
+ interface Pending {
30
+ request: BridgeRequestMessage;
31
+ resolve: (value: unknown) => void;
32
+ reject: (error: Error) => void;
33
+ }
34
+
35
+ export function createBridgeClient(pageRevision: string, doc: Document): BridgeClient {
36
+ const pending = new Map<string, Pending>();
37
+ const queued: string[] = [];
38
+ let post: ((message: BridgeRequestMessage) => void) | null = null;
39
+ let sequence = 0;
40
+
41
+ function nextId(): string {
42
+ sequence += 1;
43
+ const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${sequence}`;
44
+ return `tp-${random}`;
45
+ }
46
+
47
+ function send(id: string): void {
48
+ const entry = pending.get(id);
49
+ if (!entry || !post) return;
50
+ try {
51
+ post(entry.request);
52
+ } catch (error) {
53
+ pending.delete(id);
54
+ entry.reject(new ThreadPageError("invalid_request", error instanceof Error ? error.message : "The request could not be sent"));
55
+ }
56
+ }
57
+
58
+ function invoke(method: string, params?: unknown): Promise<unknown> {
59
+ return new Promise((resolve, reject) => {
60
+ if (typeof method !== "string") {
61
+ reject(new ThreadPageError("invalid_request", "A method name is required"));
62
+ return;
63
+ }
64
+ const id = nextId();
65
+ const request: BridgeRequestMessage = { v: BRIDGE_VERSION, id, method, params: params === undefined ? null : params, pageRevision };
66
+ pending.set(id, { request, resolve, reject });
67
+ if (post) send(id);
68
+ else queued.push(id);
69
+ });
70
+ }
71
+
72
+ function watch(method: string, params: unknown, listener: (value: unknown, error: unknown) => void, options?: { intervalMs?: number }): () => void {
73
+ if (typeof listener !== "function") throw new TypeError("Thread Page watch needs a listener");
74
+ const requested = options?.intervalMs;
75
+ const interval = typeof requested === "number" && Number.isFinite(requested)
76
+ ? Math.max(LIMITS.watchMinMs, Math.min(LIMITS.watchMaxMs, Math.round(requested)))
77
+ : LIMITS.watchDefaultMs;
78
+ let stopped = false;
79
+ let running = false;
80
+ let timer: ReturnType<typeof setTimeout> | null = null;
81
+
82
+ function schedule(delay: number): void {
83
+ if (stopped) return;
84
+ if (timer !== null) clearTimeout(timer);
85
+ timer = setTimeout(tick, delay);
86
+ }
87
+ async function tick(): Promise<void> {
88
+ timer = null;
89
+ if (stopped || running || doc.visibilityState === "hidden") return;
90
+ running = true;
91
+ try {
92
+ const value = await invoke(method, params);
93
+ if (!stopped) listener(value, null);
94
+ } catch (error) {
95
+ if (!stopped) listener(undefined, error);
96
+ } finally {
97
+ running = false;
98
+ if (!stopped) schedule(interval);
99
+ }
100
+ }
101
+ function onVisibility(): void {
102
+ if (stopped) return;
103
+ if (doc.visibilityState === "hidden") {
104
+ if (timer !== null) clearTimeout(timer);
105
+ timer = null;
106
+ } else {
107
+ schedule(0);
108
+ }
109
+ }
110
+ doc.addEventListener("visibilitychange", onVisibility);
111
+ schedule(0);
112
+ return () => {
113
+ if (stopped) return;
114
+ stopped = true;
115
+ if (timer !== null) clearTimeout(timer);
116
+ timer = null;
117
+ doc.removeEventListener("visibilitychange", onVisibility);
118
+ };
119
+ }
120
+
121
+ return {
122
+ invoke,
123
+ watch,
124
+ attach(poster) {
125
+ post = poster;
126
+ while (queued.length > 0) {
127
+ const id = queued.shift();
128
+ if (id) send(id);
129
+ }
130
+ },
131
+ receive(message) {
132
+ if (typeof message !== "object" || message === null) return false;
133
+ const id = (message as { id?: unknown }).id;
134
+ if (typeof id !== "string") return false;
135
+ const entry = pending.get(id);
136
+ if (!entry) return false;
137
+ pending.delete(id);
138
+ if (!isBridgeResponse(message, id)) {
139
+ entry.reject(new ThreadPageError("invalid_response", "The Thread Page bridge returned an invalid response"));
140
+ return true;
141
+ }
142
+ const response = message as BridgeResponseMessage;
143
+ if (response.ok) entry.resolve(response.result);
144
+ else entry.reject(new ThreadPageError(response.error.code, response.error.message));
145
+ return true;
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The dirty flag: typing into a captured form, or a page's explicit
3
+ * `setDirty(true)`, tells the shell not to reload under the reader.
4
+ * spec R2.21–R2.23
5
+ */
6
+ export interface DirtyTracker {
7
+ /** Current combined state. */
8
+ isDirty(): boolean;
9
+ /** Marks a form dirty; returns the version recorded for it. */
10
+ markForm(form: HTMLFormElement): number;
11
+ /** The version a form was last marked with, if any. */
12
+ versionOf(form: HTMLFormElement): number | undefined;
13
+ /** Clears a form's dirt only if nothing touched it since `version`. */
14
+ clearForm(form: HTMLFormElement, version: number | undefined): void;
15
+ setCustom(dirty: boolean): void;
16
+ }
17
+
18
+ export function createDirtyTracker(onChange: (dirty: boolean) => void): DirtyTracker {
19
+ const versions = new Map<HTMLFormElement, number>();
20
+ let sequence = 0;
21
+ let custom = false;
22
+ let last = false;
23
+
24
+ function sync(): void {
25
+ const next = custom || versions.size > 0;
26
+ if (next === last) return;
27
+ last = next;
28
+ onChange(next);
29
+ }
30
+
31
+ return {
32
+ isDirty: () => last,
33
+ markForm(form) {
34
+ sequence += 1;
35
+ versions.set(form, sequence);
36
+ sync();
37
+ return sequence;
38
+ },
39
+ versionOf: (form) => versions.get(form),
40
+ clearForm(form, version) {
41
+ if (version !== undefined && versions.get(form) === version) {
42
+ versions.delete(form);
43
+ sync();
44
+ }
45
+ },
46
+ setCustom(dirty) {
47
+ custom = dirty === true;
48
+ sync();
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,114 @@
1
+ import { LIMITS } from "../../domain/limits.ts";
2
+ import type { SubmitFile } from "../shared/protocol.ts";
3
+ import { collectAnswers } from "./labels.ts";
4
+
5
+ /**
6
+ * Automatic form capture. spec R4.5–R4.9
7
+ *
8
+ * Every `<form>` without `data-thread-page-manual` is captured: native
9
+ * validation is suppressed (blank is an answer), a status line is kept per
10
+ * form, ranges get a live readout, and while a submission is in flight the
11
+ * form's controls are disabled and restored afterwards.
12
+ */
13
+ export const MANUAL_ATTRIBUTE = "data-thread-page-manual";
14
+ export const STATUS_ATTRIBUTE = "data-thread-page-status";
15
+ const RANGE_ATTRIBUTE = "data-thread-page-range";
16
+
17
+ export interface SubmitIntent {
18
+ submissionId: string;
19
+ form: HTMLFormElement;
20
+ title: string;
21
+ answers: ReturnType<typeof collectAnswers>;
22
+ files: SubmitFile[];
23
+ }
24
+
25
+ export function isManualForm(form: Element): boolean {
26
+ return form.hasAttribute(MANUAL_ATTRIBUTE);
27
+ }
28
+
29
+ export function capturedForms(root: ParentNode | Element): HTMLFormElement[] {
30
+ const forms: HTMLFormElement[] = [];
31
+ if ("tagName" in root && (root as Element).tagName.toLowerCase() === "form") forms.push(root as HTMLFormElement);
32
+ if ("querySelectorAll" in root) forms.push(...Array.from(root.querySelectorAll("form")));
33
+ return forms.filter((form) => !isManualForm(form));
34
+ }
35
+
36
+ export function statusLine(form: HTMLFormElement): HTMLElement {
37
+ let node = form.querySelector<HTMLElement>(`[${STATUS_ATTRIBUTE}]`);
38
+ if (!node) {
39
+ node = form.ownerDocument.createElement("p");
40
+ node.setAttribute(STATUS_ATTRIBUTE, "");
41
+ node.setAttribute("role", "status");
42
+ form.appendChild(node);
43
+ }
44
+ return node;
45
+ }
46
+
47
+ const preparedRanges = new WeakSet<HTMLInputElement>();
48
+
49
+ export function prepareForm(form: HTMLFormElement): void {
50
+ form.noValidate = true;
51
+ for (const input of Array.from(form.querySelectorAll<HTMLInputElement>('input[type="range"]'))) {
52
+ if (preparedRanges.has(input)) continue;
53
+ preparedRanges.add(input);
54
+ const output = form.ownerDocument.createElement("output");
55
+ output.setAttribute(RANGE_ATTRIBUTE, "");
56
+ const sync = () => {
57
+ output.textContent = String(input.value);
58
+ };
59
+ input.addEventListener("input", sync);
60
+ sync();
61
+ input.insertAdjacentElement("afterend", output);
62
+ }
63
+ }
64
+
65
+ export type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement | HTMLFieldSetElement;
66
+
67
+ export function controlsOf(form: HTMLFormElement): FormControl[] {
68
+ return Array.from(form.querySelectorAll<FormControl>("input,textarea,select,button,fieldset"));
69
+ }
70
+
71
+ export function filesOf(form: HTMLFormElement): SubmitFile[] {
72
+ const out: SubmitFile[] = [];
73
+ for (const input of Array.from(form.querySelectorAll<HTMLInputElement>('input[type="file"]'))) {
74
+ if (input.disabled) continue;
75
+ for (const file of Array.from(input.files ?? [])) {
76
+ if (out.length >= LIMITS.uploadsPerForm) return out;
77
+ out.push({ field: input.name || "file", file });
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export interface PendingForm {
84
+ form: HTMLFormElement;
85
+ disabled: FormControl[];
86
+ dirtyVersion: number | undefined;
87
+ }
88
+
89
+ /** Disables the controls that were enabled, remembering them for restore. spec R4.8 */
90
+ export function lockForm(form: HTMLFormElement): FormControl[] {
91
+ const disabled: FormControl[] = [];
92
+ for (const control of controlsOf(form)) {
93
+ if (!control.disabled) {
94
+ control.disabled = true;
95
+ disabled.push(control);
96
+ }
97
+ }
98
+ return disabled;
99
+ }
100
+
101
+ export function unlockForm(disabled: FormControl[]): void {
102
+ for (const control of disabled) control.disabled = false;
103
+ }
104
+
105
+ export function titleOf(form: HTMLFormElement): string {
106
+ const explicit = form.getAttribute("data-title");
107
+ if (explicit && explicit.trim()) return explicit.trim().slice(0, 300);
108
+ const heading = form.ownerDocument.querySelector("h1");
109
+ return (heading?.textContent || "").trim().slice(0, 300) || "Thread Page";
110
+ }
111
+
112
+ export function buildIntent(form: HTMLFormElement, submitter: HTMLElement | null, submissionId: string): SubmitIntent {
113
+ return { submissionId, form, title: titleOf(form), answers: collectAnswers(form, submitter), files: filesOf(form) };
114
+ }