@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
package/page.ts DELETED
@@ -1,782 +0,0 @@
1
- import {
2
- createHash,
3
- createHmac,
4
- timingSafeEqual,
5
- } from "node:crypto";
6
- import {
7
- defaultTreeAdapter,
8
- parse as parseHtml,
9
- serialize as serializeHtml,
10
- type DefaultTreeAdapterTypes,
11
- } from "parse5";
12
-
13
- export const PAGE_FILENAME = "thread-page.html";
14
- export const MAX_PAGE_BYTES = 5 * 1024 * 1024;
15
- export const VIEWER_TOKEN_TTL_MS = 2 * 60 * 60 * 1000;
16
-
17
- export type PageTokenScope = "render" | "action";
18
-
19
- export interface PageTokenPayload {
20
- v: 2;
21
- scope: PageTokenScope;
22
- threadId: string;
23
- pageHash: string;
24
- iat: number;
25
- exp: number;
26
- }
27
-
28
- export type FormAnswerValue = string | string[] | boolean;
29
-
30
- export interface FormAnswer {
31
- name: string;
32
- label: string;
33
- value: FormAnswerValue;
34
- }
35
-
36
- export interface SubmissionFile {
37
- field: string;
38
- name: string;
39
- path: string;
40
- sizeBytes: number;
41
- }
42
-
43
- export interface Submission {
44
- actionToken: string;
45
- submissionId: string;
46
- pageHash: string;
47
- title: string;
48
- answers: FormAnswer[];
49
- files: SubmissionFile[];
50
- }
51
-
52
- /** Upload names are generated by the plugin, never trusted from the page. */
53
- export const UPLOAD_DIRNAME = "thread-page-uploads";
54
- export const MAX_UPLOAD_BYTES = 24 * 1024 * 1024;
55
-
56
- export function safeUploadName(raw: string): string {
57
- let decoded = raw;
58
- try {
59
- decoded = decodeURIComponent(raw);
60
- } catch {
61
- decoded = raw;
62
- }
63
- const base = decoded.split(/[\\/]/).pop() ?? "upload";
64
- const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
65
- return cleaned.slice(0, 80) || "upload";
66
- }
67
-
68
- export function escapeHtml(value: string): string {
69
- return value
70
- .replaceAll("&", "&")
71
- .replaceAll("<", "&lt;")
72
- .replaceAll(">", "&gt;")
73
- .replaceAll('"', "&quot;")
74
- .replaceAll("'", "&#39;");
75
- }
76
-
77
- export function sha256Text(value: string): string {
78
- return createHash("sha256").update(value, "utf8").digest("hex");
79
- }
80
-
81
- export const CONFIRMATION_TTL_MS = 2 * 60 * 1000;
82
-
83
- /**
84
- * A signed challenge for one confirmed capability call.
85
- *
86
- * The server mints it, the trusted outer shell shows its summary and mints
87
- * nothing itself, and the server re-checks the signature before acting. The
88
- * untrusted page never authors the summary and cannot forge or replay a
89
- * challenge: it is bound to one request id, method, parameter fingerprint,
90
- * page revision, and thread, and it expires quickly.
91
- */
92
- export interface ConfirmationChallengePayload {
93
- v: 2;
94
- scope: "confirm";
95
- threadId: string;
96
- pageHash: string;
97
- requestId: string;
98
- method: string;
99
- paramsHash: string;
100
- summary: string;
101
- iat: number;
102
- exp: number;
103
- }
104
-
105
- export function signConfirmationChallenge(
106
- payload: ConfirmationChallengePayload,
107
- key: Uint8Array,
108
- ): string {
109
- const encoded = encodeJson(payload);
110
- const signature = createHmac("sha256", key)
111
- .update(encoded, "ascii")
112
- .digest("base64url");
113
- return `${encoded}.${signature}`;
114
- }
115
-
116
- function isConfirmationChallenge(
117
- value: unknown,
118
- now: number,
119
- ): value is ConfirmationChallengePayload {
120
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
121
- const payload = value as Record<string, unknown>;
122
- return (
123
- payload.v === 2 &&
124
- payload.scope === "confirm" &&
125
- typeof payload.threadId === "string" &&
126
- /^[A-Za-z0-9_-]{3,128}$/.test(payload.threadId) &&
127
- typeof payload.pageHash === "string" &&
128
- /^[a-f0-9]{64}$/.test(payload.pageHash) &&
129
- typeof payload.requestId === "string" &&
130
- payload.requestId.length > 0 &&
131
- payload.requestId.length <= 96 &&
132
- typeof payload.method === "string" &&
133
- payload.method.length > 0 &&
134
- payload.method.length <= 96 &&
135
- typeof payload.paramsHash === "string" &&
136
- /^[a-f0-9]{64}$/.test(payload.paramsHash) &&
137
- typeof payload.summary === "string" &&
138
- payload.summary.length > 0 &&
139
- payload.summary.length <= 512 &&
140
- typeof payload.iat === "number" &&
141
- Number.isSafeInteger(payload.iat) &&
142
- typeof payload.exp === "number" &&
143
- Number.isSafeInteger(payload.exp) &&
144
- payload.iat <= now + 30_000 &&
145
- payload.exp > now &&
146
- payload.exp > payload.iat &&
147
- payload.exp - payload.iat <= CONFIRMATION_TTL_MS
148
- );
149
- }
150
-
151
- export function verifyConfirmationChallenge(
152
- token: string,
153
- key: Uint8Array,
154
- now = Date.now(),
155
- ): ConfirmationChallengePayload | null {
156
- const parts = token.split(".");
157
- if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
158
- const [encoded, suppliedSignature] = parts;
159
- try {
160
- const expected = createHmac("sha256", key)
161
- .update(encoded, "ascii")
162
- .digest();
163
- const supplied = Buffer.from(suppliedSignature, "base64url");
164
- if (supplied.byteLength !== expected.byteLength) return null;
165
- if (supplied.toString("base64url") !== suppliedSignature) return null;
166
- if (!timingSafeEqual(supplied, expected)) return null;
167
- const decoded = JSON.parse(
168
- Buffer.from(encoded, "base64url").toString("utf8"),
169
- ) as unknown;
170
- return isConfirmationChallenge(decoded, now) ? decoded : null;
171
- } catch {
172
- return null;
173
- }
174
- }
175
-
176
- export function etagForHash(hash: string): string {
177
- return `"${hash}"`;
178
- }
179
-
180
- export function makeSeedFragment(title: string): string {
181
- return `<header>
182
- <h1>${escapeHtml(title)}</h1>
183
- <p>This page belongs to this bb thread.</p>
184
- </header>
185
- <main>
186
- <p>Edit this file directly to keep the page current.</p>
187
- <form data-title="${escapeHtml(title)}">
188
- <label>Reply
189
- <textarea name="reply" rows="4" placeholder="Write a response"></textarea>
190
- </label>
191
- <button name="action" value="Reply">Reply</button>
192
- </form>
193
- </main>
194
- `;
195
- }
196
-
197
- function encodeJson(value: unknown): string {
198
- return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
199
- }
200
-
201
- export function signPageToken(
202
- payload: PageTokenPayload,
203
- key: Uint8Array,
204
- ): string {
205
- const encoded = encodeJson(payload);
206
- const signature = createHmac("sha256", key)
207
- .update(encoded, "ascii")
208
- .digest("base64url");
209
- return `${encoded}.${signature}`;
210
- }
211
-
212
- function isPageTokenPayload(
213
- value: unknown,
214
- expectedScope: PageTokenScope,
215
- now: number,
216
- ): value is PageTokenPayload {
217
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
218
- const payload = value as Record<string, unknown>;
219
- return (
220
- payload.v === 2 &&
221
- payload.scope === expectedScope &&
222
- typeof payload.threadId === "string" &&
223
- /^[A-Za-z0-9_-]{3,128}$/.test(payload.threadId) &&
224
- typeof payload.pageHash === "string" &&
225
- /^[a-f0-9]{64}$/.test(payload.pageHash) &&
226
- typeof payload.iat === "number" &&
227
- Number.isSafeInteger(payload.iat) &&
228
- typeof payload.exp === "number" &&
229
- Number.isSafeInteger(payload.exp) &&
230
- payload.iat <= now + 30_000 &&
231
- payload.exp > now &&
232
- payload.exp > payload.iat &&
233
- payload.exp - payload.iat <= VIEWER_TOKEN_TTL_MS
234
- );
235
- }
236
-
237
- export function verifyPageToken(
238
- token: string,
239
- key: Uint8Array,
240
- expectedScope: PageTokenScope,
241
- now = Date.now(),
242
- ): PageTokenPayload | null {
243
- const parts = token.split(".");
244
- if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
245
- const [encoded, suppliedSignature] = parts;
246
-
247
- try {
248
- const expected = createHmac("sha256", key)
249
- .update(encoded, "ascii")
250
- .digest();
251
- const supplied = Buffer.from(suppliedSignature, "base64url");
252
- if (supplied.byteLength !== expected.byteLength) return null;
253
- if (supplied.toString("base64url") !== suppliedSignature) return null;
254
- if (!timingSafeEqual(supplied, expected)) return null;
255
-
256
- const decoded = JSON.parse(
257
- Buffer.from(encoded, "base64url").toString("utf8"),
258
- ) as unknown;
259
- return isPageTokenPayload(decoded, expectedScope, now) ? decoded : null;
260
- } catch {
261
- return null;
262
- }
263
- }
264
-
265
- function jsonForInlineScript(value: unknown): string {
266
- return JSON.stringify(value)
267
- .replaceAll("<", "\\u003c")
268
- .replaceAll("\u2028", "\\u2028")
269
- .replaceAll("\u2029", "\\u2029");
270
- }
271
-
272
- const DOCUMENT_CSS = String.raw`
273
- :root{color-scheme:light dark;--bg:#f7f7f5;--surface:#fff;--ink:#191a1d;--muted:#656a73;--line:#dedfe2;--accent:#315fc5;--warn:#a54312;font:16px/1.55 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
274
- @media(prefers-color-scheme:dark){:root{--bg:#111216;--surface:#191b20;--ink:#ececef;--muted:#a7abb3;--line:#30333a;--accent:#91aff1;--warn:#efa879}}
275
- *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--ink)}.thread-page{width:min(100% - 2rem,46rem);margin:0 auto;padding:clamp(2rem,7vw,5rem) 0 7rem}header{padding-bottom:1.5rem;margin-bottom:2rem;border-bottom:1px solid var(--line)}h1,h2,h3{line-height:1.2;text-wrap:balance}h1{margin:0;font-size:clamp(1.8rem,6vw,2.6rem);letter-spacing:-.025em}h2{margin-top:2.5rem}p,li{color:var(--muted)}a{color:var(--accent)}img,video,canvas,svg{max-width:100%;height:auto}pre{overflow:auto;padding:1rem;border:1px solid var(--line);border-radius:.65rem;background:var(--surface)}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}blockquote{margin-left:0;padding-left:1rem;border-left:3px solid var(--line)}.card,form{margin-top:1.5rem;padding:1.15rem;border:1px solid var(--line);border-radius:.75rem;background:var(--surface)}.needs-you{border-left:3px solid var(--warn);padding-left:1rem}.stale{margin:0 0 1.25rem;padding:.8rem 1rem;border:1px solid color-mix(in srgb,var(--warn) 45%,var(--line));border-radius:.65rem;color:var(--warn);background:color-mix(in srgb,var(--warn) 8%,var(--surface))}form>*+*{margin-top:1rem}fieldset{padding:0;border:0}legend,label{display:block;font-weight:600}label+label{margin-top:.65rem}label:has(>input[type=radio]),label:has(>input[type=checkbox]){display:flex;gap:.55rem;font-weight:400;color:var(--muted)}input,textarea,select,button{font:inherit}input[type=text],input[type=email],input[type=url],input[type=number],textarea,select{display:block;width:100%;margin-top:.4rem;padding:.55rem .65rem;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:.45rem}textarea{resize:vertical}button{padding:.55rem .85rem;border:1px solid var(--line);border-radius:.45rem;color:white;background:var(--accent);cursor:pointer}button+button{margin-left:.4rem}button:disabled,input:disabled,textarea:disabled,select:disabled{opacity:.55;cursor:not-allowed}[data-thread-page-status]{min-height:1.4em;margin:.75rem 0 0;font-size:.9rem;color:var(--muted)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
276
- [data-thread-page-range]{display:inline-block;min-width:2.5rem;margin-left:.6rem;color:var(--ink);font-variant-numeric:tabular-nums}
277
- .thread-page{width:min(calc(100% - 2rem),46rem);overflow-wrap:anywhere}
278
- `;
279
-
280
- const DOCUMENT_RUNTIME = String.raw`
281
- const config=__THREAD_PAGE_CONFIG__;
282
- const bridgeErrorCodes=new Set(["invalid_json","request_too_large","response_too_large","invalid_request","invalid_response","unsupported_version","unknown_method","invalid_params","stale_page","confirmation_required","confirmation_invalid","not_found","conflict","unavailable","cancelled","rate_limited","handler_error","invalid_result"]);
283
- let dirty=false;
284
- let customDirty=false;
285
- let dirtySequence=0;
286
- const dirtyFormVersions=new Map();
287
- let bridgePort=null;
288
- let requestSequence=0;
289
- const pendingInvocations=new Map();
290
- const queuedInvocationIds=[];
291
- const pendingFormsById=new Map();
292
- const pendingForms=new WeakSet();
293
- const preparedRanges=new WeakSet();
294
- let readOnly=Boolean(config.stale);
295
- const sourceDisabled=new WeakSet();
296
- function makeBridgeError(code,message){const error=new Error(message);error.name="ThreadPageBridgeError";Object.defineProperty(error,"code",{value:code,enumerable:true});return error}
297
- function requestId(){requestSequence+=1;const random=globalThis.crypto&&typeof globalThis.crypto.randomUUID==="function"?globalThis.crypto.randomUUID():String(Date.now())+"-"+requestSequence;return "tp-"+random}
298
- function exactKeys(value,wanted){if(!value||typeof value!=="object"||Array.isArray(value))return false;const keys=Object.keys(value);return keys.length===wanted.length&&wanted.every(function(key){return Object.prototype.hasOwnProperty.call(value,key)})}
299
- function strictBridgeResponse(value){if(!value||typeof value!=="object"||Array.isArray(value)||value.v!==1||typeof value.id!=="string"||typeof value.ok!=="boolean")return false;if(value.ok===true)return exactKeys(value,["v","id","ok","result"]);return exactKeys(value,["v","id","ok","error"])&&exactKeys(value.error,["code","message"])&&bridgeErrorCodes.has(value.error.code)&&typeof value.error.message==="string"&&value.error.message.length>0&&value.error.message.length<=512}
300
- function postPort(value){if(!bridgePort)return false;bridgePort.postMessage(value);return true}
301
- function sendInvocation(id){const pending=pendingInvocations.get(id);if(!pending)return;try{postPort(pending.request)}catch(error){pendingInvocations.delete(id);pending.reject(makeBridgeError("invalid_request",error instanceof Error?error.message:"The request could not be sent"))}}
302
- function flushInvocations(){while(queuedInvocationIds.length){const id=queuedInvocationIds.shift();if(id)sendInvocation(id)}}
303
- function invoke(method,params){return new Promise(function(resolve,reject){const id=requestId();const request={v:1,id:id,method:method,params:params===undefined?null:params,pageRevision:config.pageRevision};pendingInvocations.set(id,{request:request,resolve:resolve,reject:reject});if(bridgePort)sendInvocation(id);else queuedInvocationIds.push(id)})}
304
- function watch(method,params,listener,options){if(typeof listener!=="function")throw new TypeError("Thread Page watch needs a listener");const requested=options&&options.intervalMs;const interval=Number.isFinite(requested)?Math.max(2000,Math.min(300000,Math.round(requested))):8000;let stopped=false;let timer=null;let running=false;function schedule(delay){if(stopped)return;if(timer!==null)clearTimeout(timer);timer=setTimeout(tick,delay)}async function tick(){timer=null;if(stopped||running||document.visibilityState==="hidden")return;running=true;try{const value=await invoke(method,params);if(!stopped)listener(value,null)}catch(error){if(!stopped)listener(undefined,error)}finally{running=false;if(!stopped)schedule(interval)}}function visible(){if(stopped)return;if(document.visibilityState==="hidden"){if(timer!==null)clearTimeout(timer);timer=null}else schedule(0)}document.addEventListener("visibilitychange",visible);schedule(0);return function(){if(stopped)return;stopped=true;if(timer!==null)clearTimeout(timer);timer=null;document.removeEventListener("visibilitychange",visible)}}
305
- function sendControl(kind,extra){if(!bridgePort)return false;try{bridgePort.postMessage(Object.assign({kind:kind},extra||{}));return true}catch{return false}}
306
- function syncDirty(){const next=customDirty||dirtyFormVersions.size>0;if(next===dirty)return;dirty=next;sendControl(next?"thread-page:dirty":"thread-page:clean")}
307
- function setDirty(next){customDirty=next!==false;syncDirty()}
308
- function assetUrl(name){if(!config.assetBase)throw new Error("Thread Page assets are unavailable");const safe=String(name==null?"":name).replace(/^\.\//,"");if(!/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(safe)||safe.includes(".."))throw new TypeError("Thread Page asset names may only use letters, digits, dot, dash, and underscore, with no path segments");return config.assetBase+encodeURIComponent(safe)}
309
- const threadPage=Object.freeze({version:1,invoke:invoke,watch:watch,setDirty:setDirty,assetUrl:assetUrl});
310
- Object.defineProperty(window,"threadPage",{value:threadPage,writable:false,configurable:false,enumerable:true});
311
- function statusFor(form){let node=form.querySelector("[data-thread-page-status]");if(!node){node=document.createElement("p");node.setAttribute("data-thread-page-status","");node.setAttribute("role","status");form.appendChild(node)}return node}
312
- // The text of an element minus anything nested that is not part of the question:
313
- // controls it wraps, hints, and plugin-injected nodes. A label that wraps its own
314
- // input ("How deep? <input>") must read as just the question, and a <select>'s
315
- // option text must never become part of its name.
316
- function labelText(node){if(!node||typeof node.cloneNode!=="function")return "";const clone=node.cloneNode(true);if(typeof clone.querySelectorAll==="function"){Array.prototype.forEach.call(clone.querySelectorAll("input,textarea,select,button,option,small,output,[data-thread-page-range],[data-thread-page-status]"),function(element){element.remove()})}return String(clone.textContent||"").replace(/\s+/g," ").trim()}
317
- function labelFor(form,name,group){const control=group&&group[0]||Array.from(form.elements).find(function(item){return item&&item.name===name});if(!control)return name;const explicitLabel=control.dataset&&control.dataset.label;if(explicitLabel)return String(explicitLabel).trim();const fieldset=control.closest&&control.closest("fieldset");if(fieldset){const legend=fieldset.querySelector("legend");const legendText=labelText(legend);if(legendText)return legendText}const aria=control.getAttribute&&control.getAttribute("aria-label");if(aria)return aria.trim();const wrapped=control.closest&&control.closest("label");if(wrapped){const wrappedText=labelText(wrapped);if(wrappedText)return wrappedText}if(control.id){const explicit=Array.from(document.querySelectorAll("label[for]")).find(function(label){return label.htmlFor===control.id});const forText=labelText(explicit);if(forText)return forText}return name}
318
- function collect(form,submitter){const controls=Array.from(form.elements).filter(function(item){return item&&typeof item.name==="string"&&item.name&&!item.disabled});const answers=[];const seen=new Set();if(submitter){const actionValue=submitter.value||submitter.textContent.trim();answers.push({name:submitter.name||"action",label:"Action",value:actionValue});if(submitter.name)seen.add(submitter.name)}for(const control of controls){const name=control.name;const type=String(control.type||"").toLowerCase();if(seen.has(name)||["button","submit","reset","image","file"].includes(type))continue;seen.add(name);const group=controls.filter(function(item){return item.name===name});let value;if(type==="checkbox"){value=group.length===1?Boolean(control.checked):group.filter(function(item){return item.checked}).map(function(item){return item.value})}else if(type==="radio"){const checked=group.find(function(item){return item.checked});value=checked?checked.value:""}else if(control instanceof HTMLSelectElement&&control.multiple){value=Array.from(control.selectedOptions).map(function(option){return option.value})}else if(group.length>1){value=group.map(function(item){return String(item.value||"")})}else{value=String(control.value||"")}answers.push({name:name,label:labelFor(form,name,group),value:value})}return answers}
319
- function syncOfflineBanner(){if(!document.body)return;let banner=document.querySelector("[data-thread-page-offline=plugin]");if(readOnly&&!banner){banner=document.createElement("aside");banner.setAttribute("data-thread-page-offline","plugin");banner.setAttribute("role","status");banner.setAttribute("style","position:relative;z-index:2147483647;margin:0;padding:.75rem 1rem;border-bottom:1px solid currentColor;font:600 14px/1.4 system-ui;background:Canvas;color:CanvasText");banner.textContent="Offline copy — responses are disabled until the source host reconnects.";document.body.insertBefore(banner,document.body.firstChild)}else if(!readOnly&&banner){banner.remove()}}
320
- function matches(root,selector){return root&&typeof root.matches==="function"&&root.matches(selector)}
321
- function descendants(root,selector){return root&&typeof root.querySelectorAll==="function"?Array.from(root.querySelectorAll(selector)):[]}
322
- function formsIn(root){const forms=descendants(root,"form");if(matches(root,"form"))forms.unshift(root);return forms}
323
- function isManualForm(form){return Boolean(form&&typeof form.hasAttribute==="function"&&form.hasAttribute("data-thread-page-manual"))}
324
- function autoFormsIn(root){return formsIn(root).filter(function(form){return !isManualForm(form)})}
325
- function controlsIn(root){const selector="form input,form textarea,form select,form button";const controls=descendants(root,selector);if(matches(root,selector))controls.unshift(root);return controls}
326
- function prepareRanges(form){descendants(form,'input[type="range"]').forEach(function(input){if(preparedRanges.has(input))return;preparedRanges.add(input);const output=document.createElement("output");output.setAttribute("data-thread-page-range","");const sync=function(){output.textContent=String(input.value)};input.addEventListener("input",sync);sync();input.insertAdjacentElement("afterend",output)})}
327
- function prepareRoot(root){const forms=autoFormsIn(root);forms.forEach(function(form){form.noValidate=true;prepareRanges(form)});if(!readOnly)return;controlsIn(root).forEach(function(control){if(!control.disabled){sourceDisabled.add(control);control.disabled=true}});forms.forEach(function(form){statusFor(form).textContent="Offline copy — responses are disabled"})}
328
- function applyReadOnly(next){readOnly=next;const forms=autoFormsIn(document);forms.forEach(function(form){form.noValidate=true});controlsIn(document).forEach(function(control){if(next){if(!control.disabled){sourceDisabled.add(control);control.disabled=true}}else if(sourceDisabled.has(control)){sourceDisabled.delete(control);control.disabled=false}});forms.forEach(function(form){const status=statusFor(form);if(next)status.textContent="Offline copy — responses are disabled";else if(status.textContent==="Offline copy — responses are disabled")status.textContent=""});syncOfflineBanner()}
329
- function initialize(){prepareRoot(document);syncOfflineBanner()}
330
- initialize();
331
- if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",initialize,{once:true});
332
- if(typeof MutationObserver==="function"&&document.documentElement){const observer=new MutationObserver(function(records){records.forEach(function(record){Array.from(record.addedNodes||[]).forEach(prepareRoot)});syncOfflineBanner()});observer.observe(document.documentElement,{childList:true,subtree:true})}
333
- function markFormDirty(event){const target=event&&event.target;const form=target&&typeof target.closest==="function"?target.closest("form"):null;if(!form||isManualForm(form))return;dirtySequence+=1;dirtyFormVersions.set(form,dirtySequence);syncDirty()}
334
- document.addEventListener("input",markFormDirty,true);
335
- document.addEventListener("change",markFormDirty,true);
336
- function filesIn(form){const out=[];descendants(form,'input[type="file"]').forEach(function(input){if(input.disabled)return;Array.prototype.forEach.call(input.files||[],function(file){if(out.length<8)out.push({field:input.name||"file",file:file})})});return out}
337
- document.addEventListener("submit",function(event){const form=event.target;if(!(form instanceof HTMLFormElement)||isManualForm(form))return;event.preventDefault();if(readOnly||pendingForms.has(form))return;const submissionId=requestId();const buttons=Array.from(form.querySelectorAll("button")).filter(function(button){return !button.disabled});const formVersion=dirtyFormVersions.get(form);pendingFormsById.set(submissionId,{form:form,buttons:buttons,formVersion:formVersion});pendingForms.add(form);const answers=collect(form,event.submitter);const files=filesIn(form);buttons.forEach(function(button){button.disabled=true});statusFor(form).textContent=files.length?"Uploading…":"Sending…";const heading=document.querySelector("h1");if(!sendControl("thread-page:submit",{submissionId:submissionId,pageHash:config.pageRevision,title:form.dataset.title||(heading?heading.textContent.trim():"Thread Page"),answers:answers,files:files})){pendingFormsById.delete(submissionId);pendingForms.delete(form);statusFor(form).textContent="Page connection is not ready";buttons.forEach(function(button){button.disabled=false})}},true);
338
- function onPortMessage(event){const data=event&&event.data;if(!data||typeof data!=="object")return;if(data.kind==="thread-page:source-state"){applyReadOnly(Boolean(data.stale));return}if(data.kind==="thread-page:submit-progress"){const progressForm=pendingFormsById.get(data.submissionId);if(progressForm)statusFor(progressForm.form).textContent=String(data.message||"Working…").slice(0,120);return}if(data.kind==="thread-page:submit-result"){const pendingForm=pendingFormsById.get(data.submissionId);if(!pendingForm)return;pendingFormsById.delete(data.submissionId);pendingForms.delete(pendingForm.form);statusFor(pendingForm.form).textContent=data.ok?(data.message||"Sent"):(data.error||"Could not send");pendingForm.buttons.forEach(function(button){if(readOnly)sourceDisabled.add(button);else button.disabled=false});if(data.ok&&dirtyFormVersions.get(pendingForm.form)===pendingForm.formVersion){dirtyFormVersions.delete(pendingForm.form);syncDirty()}return}const possibleId=typeof data.id==="string"?data.id:"";const pending=pendingInvocations.get(possibleId);if(!pending)return;pendingInvocations.delete(possibleId);if(!strictBridgeResponse(data)){pending.reject(makeBridgeError("invalid_response","The Thread Page bridge returned an invalid response"));return}if(data.ok)pending.resolve(data.result);else pending.reject(makeBridgeError(data.error.code,data.error.message))}
339
- function acceptPort(event){if(bridgePort||event.source!==parent||!event.data||event.data.kind!=="thread-page:connect"||event.data.version!==1||!event.ports||event.ports.length!==1)return;if(typeof event.stopImmediatePropagation==="function")event.stopImmediatePropagation();bridgePort=event.ports[0];bridgePort.onmessage=onPortMessage;if(typeof bridgePort.start==="function")bridgePort.start();flushInvocations();if(dirty)sendControl("thread-page:dirty")}
340
- window.addEventListener("message",acceptPort,true);
341
- if(readOnly)applyReadOnly(true);
342
- parent.postMessage({kind:"thread-page:ready",version:1},"*");
343
- `;
344
-
345
- type HtmlDocument = DefaultTreeAdapterTypes.Document;
346
- type HtmlElement = DefaultTreeAdapterTypes.Element;
347
-
348
- interface AuthoredDocument {
349
- document: HtmlDocument;
350
- injectionParent: HtmlElement;
351
- }
352
-
353
- function directHtmlChild(
354
- parent: HtmlDocument | HtmlElement,
355
- tagName: string,
356
- ): HtmlElement | null {
357
- for (const child of parent.childNodes) {
358
- if (
359
- defaultTreeAdapter.isElementNode(child) &&
360
- child.tagName === tagName &&
361
- child.namespaceURI === "http://www.w3.org/1999/xhtml"
362
- ) {
363
- return child;
364
- }
365
- }
366
- return null;
367
- }
368
-
369
- function parseAuthoredDocument(source: string): AuthoredDocument | null {
370
- // A BOM at the beginning of an HTML byte stream is an encoding signature,
371
- // not a text node. parse5 accepts strings, so remove it before tokenization.
372
- const browserSource = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
373
- const document = parseHtml(browserSource, {
374
- scriptingEnabled: true,
375
- sourceCodeLocationInfo: true,
376
- });
377
- const html = directHtmlChild(document, "html");
378
- if (!html) return null;
379
-
380
- const head = directHtmlChild(html, "head");
381
- const body = directHtmlChild(html, "body");
382
- const frameset = directHtmlChild(html, "frameset");
383
- const hasHtmlDoctype = document.childNodes.some(
384
- (child) =>
385
- defaultTreeAdapter.isDocumentTypeNode(child) &&
386
- child.name.toLowerCase() === "html",
387
- );
388
- const hasAuthoredShell = [html, head, body, frameset].some(
389
- (element) => element?.sourceCodeLocation != null,
390
- );
391
-
392
- if (!hasHtmlDoctype && !hasAuthoredShell) return null;
393
- return { document, injectionParent: head ?? body ?? frameset ?? html };
394
- }
395
-
396
- function injectKernel(
397
- authored: AuthoredDocument,
398
- runtime: string,
399
- nonce: string,
400
- assetBase?: string | null,
401
- ): string {
402
- const script = defaultTreeAdapter.createElement(
403
- "script",
404
- authored.injectionParent.namespaceURI,
405
- [
406
- { name: "data-thread-page-kernel", value: "" },
407
- { name: "nonce", value: nonce },
408
- ],
409
- );
410
- defaultTreeAdapter.insertText(script, runtime);
411
-
412
- // The first <base> in a document wins, so the confined asset preview is
413
- // injected ahead of any authored one. Relative page resources therefore
414
- // resolve inside the thread's asset directory and nowhere else.
415
- const nodes = assetBase
416
- ? [
417
- defaultTreeAdapter.createElement(
418
- "base",
419
- authored.injectionParent.namespaceURI,
420
- [{ name: "href", value: assetBase }],
421
- ),
422
- script,
423
- ]
424
- : [script];
425
-
426
- // Insert in order at the very front of the injection parent.
427
- const anchor = defaultTreeAdapter.getFirstChild(authored.injectionParent);
428
- for (const node of nodes) {
429
- if (anchor) {
430
- defaultTreeAdapter.insertBefore(authored.injectionParent, node, anchor);
431
- } else {
432
- defaultTreeAdapter.appendChild(authored.injectionParent, node);
433
- }
434
- }
435
-
436
- return serializeHtml(authored.document);
437
- }
438
-
439
- export function renderDocument(options: {
440
- fragment: string;
441
- nonce: string;
442
- pageHash: string;
443
- stale: boolean;
444
- assetBase?: string | null;
445
- }): string {
446
- const config = jsonForInlineScript({
447
- pageRevision: options.pageHash,
448
- stale: options.stale,
449
- assetBase: options.assetBase ?? null,
450
- });
451
- const runtime = DOCUMENT_RUNTIME.replace("__THREAD_PAGE_CONFIG__", config);
452
- const kernel = `<script data-thread-page-kernel nonce="${escapeHtml(options.nonce)}">${runtime}</script>`;
453
-
454
- const authoredDocument = parseAuthoredDocument(options.fragment);
455
- if (authoredDocument) {
456
- return injectKernel(
457
- authoredDocument,
458
- runtime,
459
- options.nonce,
460
- options.assetBase,
461
- );
462
- }
463
-
464
- const staleBanner = options.stale
465
- ? '<aside class="stale" data-thread-page-offline="plugin" role="status"><strong>Offline copy.</strong> The source host is unavailable. This cached page is read-only until it reconnects.</aside>'
466
- : "";
467
-
468
- const base = options.assetBase
469
- ? `<base href="${escapeHtml(options.assetBase)}">\n`
470
- : "";
471
-
472
- return `<!doctype html>
473
- <html lang="en">
474
- <head>
475
- <meta charset="utf-8">
476
- <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
477
- <meta name="color-scheme" content="light dark">
478
- <title>Thread Page</title>
479
- ${base}<style nonce="${escapeHtml(options.nonce)}">${DOCUMENT_CSS}</style>
480
- ${kernel}
481
- </head>
482
- <body>
483
- <div class="thread-page">${staleBanner}${options.fragment}</div>
484
- </body>
485
- </html>`;
486
- }
487
-
488
- const OUTER_CSS = String.raw`
489
- :root{color-scheme:light dark;font:14px/1.4 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--bg:#f7f7f5;--surface:#fff;--ink:#17181b;--muted:#676c75;--line:#dfe0e3;--accent:#315fc5;--warn:#a54312} @media(prefers-color-scheme:dark){:root{--bg:#111216;--surface:#191b20;--ink:#eeeef0;--muted:#a5a9b1;--line:#30333a;--accent:#91aff1;--warn:#efa879}}*{box-sizing:border-box}html,body{height:100%;margin:0;background:var(--bg);color:var(--ink)}.shell{display:grid;grid-template-rows:auto 1fr;height:100%;min-height:100dvh}.bar{display:flex;align-items:center;gap:.75rem;min-height:2.5rem;padding:.45rem max(.7rem,env(safe-area-inset-right)) .45rem max(.7rem,env(safe-area-inset-left));border-bottom:1px solid var(--line);background:var(--surface)}.home{flex:none;color:var(--muted);text-decoration:none;font-weight:600;white-space:nowrap}.home:hover{color:var(--ink)}.title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}.status{margin-left:auto;color:var(--muted);text-align:right}.status[data-tone=warn]{color:var(--warn)}
490
- .work{flex:none;display:none;align-items:center;gap:.4rem;color:var(--muted)}.work[data-visible=true]{display:inline-flex}.work .dot{width:.5rem;height:.5rem;border-radius:50%;background:var(--accent)}@media(prefers-reduced-motion:no-preference){.work[data-visible=true] .dot{animation:tp-pulse 1.4s ease-in-out infinite}}@keyframes tp-pulse{0%,100%{opacity:1}50%{opacity:.25}}button{display:none;padding:.25rem .55rem;border:1px solid var(--line);border-radius:.4rem;color:var(--ink);background:var(--bg);cursor:pointer}button[data-visible=true]{display:inline-block}iframe{display:block;width:100%;height:100%;border:0;background:var(--bg)}
491
- dialog{margin:auto;max-width:min(30rem,calc(100vw - 2rem));padding:1.15rem 1.25rem;border:1px solid var(--line);border-radius:.75rem;color:var(--ink);background:var(--surface)}dialog::backdrop{background:rgb(0 0 0 / .45)}dialog h2{margin:0 0 .5rem;font-size:1rem}dialog p{margin:0 0 1rem;color:var(--muted);overflow-wrap:anywhere}dialog .row{display:flex;gap:.5rem;justify-content:flex-end}dialog button{display:inline-block}dialog button[value=confirm]{color:#fff;background:var(--accent);border-color:var(--accent)}
492
- `;
493
-
494
- const OUTER_RUNTIME = String.raw`
495
- const config=__THREAD_PAGE_CONFIG__;
496
- const frame=document.querySelector("iframe");
497
- const status=document.querySelector(".status");
498
- const reloadButton=document.querySelector("button");
499
- const bridgeErrorCodes=new Set(["invalid_json","request_too_large","response_too_large","invalid_request","invalid_response","unsupported_version","unknown_method","invalid_params","stale_page","confirmation_required","confirmation_invalid","not_found","conflict","unavailable","cancelled","rate_limited","handler_error","invalid_result"]);
500
- let dirty=false;
501
- let etag='"'+config.pageRevision+'"';
502
- let polling=false;
503
- let stopped=false;
504
- let lastStale=Boolean(config.stale);
505
- let timer=null;
506
- let controller=null;
507
- let framePort=null;
508
- let awaitingReady=true;
509
- function setStatus(text,warn){status.textContent=text||"";status.dataset.tone=warn?"warn":""}
510
- /* The page you are reading is whatever was last saved, so the useful sentence is
511
- not "loading" but that another version is coming. Driven by a header on the
512
- revision poll the shell already makes, so no page and no agent implements it. */
513
- function setWorking(state){const strip=document.querySelector(".work");if(!strip)return;const label=strip.querySelector(".what");const working=state==="working"&&Boolean(config.workingLabel);strip.dataset.visible=working?"true":"false";if(label)label.textContent=config.workingLabel||""}
514
- function exactKeys(value,wanted){if(!value||typeof value!=="object"||Array.isArray(value))return false;const keys=Object.keys(value);return keys.length===wanted.length&&wanted.every(function(key){return Object.prototype.hasOwnProperty.call(value,key)})}
515
- function validId(value){return typeof value==="string"&&value.length>=1&&value.length<=96&&/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)}
516
- function safeId(value){return validId(value)?value:"invalid"}
517
- function failure(id,code,message){return {v:1,id:safeId(id),ok:false,error:{code:code,message:message}}}
518
- function strictBridgeRequest(value){return exactKeys(value,["v","id","method","params","pageRevision"])&&value.v===1&&validId(value.id)&&typeof value.method==="string"&&value.method.length>=3&&value.method.length<=96&&/^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*)+$/.test(value.method)&&typeof value.pageRevision==="string"&&value.pageRevision===config.pageRevision}
519
- function strictBridgeResponse(value,id){if(!value||typeof value!=="object"||Array.isArray(value)||value.v!==1||value.id!==id||typeof value.ok!=="boolean")return false;if(value.ok===true)return exactKeys(value,["v","id","ok","result"]);return exactKeys(value,["v","id","ok","error"])&&exactKeys(value.error,["code","message"])&&bridgeErrorCodes.has(value.error.code)&&typeof value.error.message==="string"&&value.error.message.length>0&&value.error.message.length<=512}
520
- function closeFramePort(){if(framePort&&typeof framePort.close==="function")framePort.close();framePort=null}
521
- function navigateFrame(url){closeFramePort();awaitingReady=true;frame.src=url}
522
- function reloadFrame(){dirty=false;reloadButton.dataset.visible="false";navigateFrame(config.documentUrl+(config.documentUrl.includes("?")?"&":"?")+"reload="+Date.now())}
523
- function schedule(delay){if(timer!==null)clearTimeout(timer);timer=null;if(!stopped&&document.visibilityState==="visible")timer=setTimeout(function(){timer=null;void poll()},delay)}
524
- function pause(){if(timer!==null)clearTimeout(timer);timer=null;if(controller){controller.abort();controller=null}}
525
- reloadButton.addEventListener("click",function(){location.reload()});
526
- /* Navigation belongs to the trusted shell: the sandboxed frame cannot reach
527
- top-level context, and these destinations are built here from an id the
528
- server has already validated, never from page-supplied markup or a URL. */
529
- function navigateTo(port,request){const params=request.params;const id=params&&typeof params.threadId==="string"?params.threadId:"";if(!/^[A-Za-z0-9_-]{3,128}$/.test(id)){port.postMessage(failure(request.id,"invalid_params","A valid threadId is required"));return}const url=request.method==="threads.openPage"?config.pageUrlTemplate.replace("__THREAD__",encodeURIComponent(id)):config.bbThreadUrlTemplate.replace("__THREAD__",encodeURIComponent(id));try{window.open(url,"_blank","noopener");port.postMessage({v:1,id:request.id,ok:true,result:{opened:true}})}catch(error){port.postMessage(failure(request.id,"unavailable",error instanceof Error?error.message:"Could not open that destination"))}}
530
- /* Leaving bb entirely needs a confirmation the page cannot word, and the URL is
531
- re-parsed here rather than trusted as a string. */
532
- async function openExternal(port,request){const params=request.params;const raw=params&&typeof params.url==="string"?params.url:"";let target=null;try{target=new URL(raw)}catch{target=null}if(!target||(target.protocol!=="http:"&&target.protocol!=="https:")){port.postMessage(failure(request.id,"invalid_params","Only http and https destinations can be opened"));return}const label=params&&typeof params.label==="string"?params.label:"";const summary="Leave bb and open "+(label?'\u201c'+label.slice(0,80)+'\u201d at ':"")+target.origin;if(!await confirmInChrome(summary)){port.postMessage(failure(request.id,"cancelled","You declined this action"));return}try{window.open(target.href,"_blank","noopener,noreferrer");port.postMessage({v:1,id:request.id,ok:true,result:{opened:true}})}catch(error){port.postMessage(failure(request.id,"unavailable",error instanceof Error?error.message:"Could not open that destination"))}}
533
- async function relayBridge(port,request){if(!strictBridgeRequest(request)){port.postMessage(failure(request&&request.id,"invalid_request","Invalid Thread Page bridge request"));return}if(request.method==="threads.openPage"||request.method==="threads.openBb"){navigateTo(port,request);return}if(request.method==="navigation.openExternal"){void openExternal(port,request);return}try{const response=await fetch(config.bridgeUrl,{method:"POST",credentials:"same-origin",cache:"no-store",headers:{"content-type":"application/json"},body:JSON.stringify({actionToken:config.actionToken,request:request})});const body=await response.json().catch(function(){return null});
534
- /* The server answers a confirmed method with a signed challenge and its own
535
- summary. We show that summary, never the page's. */
536
- if(response.status===401&&body&&body.confirm&&typeof body.confirm.challenge==="string"&&typeof body.confirm.summary==="string"&&body.confirm.requestId===request.id){await relayConfirmedBridge(port,request,body.confirm.challenge,body.confirm.summary);return}
537
- port.postMessage(strictBridgeResponse(body,request.id)?body:failure(request.id,"invalid_response","The Thread Page bridge returned an invalid response"))}catch(error){port.postMessage(failure(request.id,"unavailable",error instanceof Error?error.message:"The Thread Page bridge is unavailable"))}}
538
- const MAX_UPLOAD_BYTES=24*1024*1024;
539
- // bb's local auth requires an application/json body on non-GET requests, which
540
- // is what forces the CORS preflight. The bytes therefore travel base64-encoded
541
- // inside a JSON envelope rather than as a raw body.
542
- function encodeBase64(buffer){const bytes=new Uint8Array(buffer);let binary="";const chunk=0x8000;for(let index=0;index<bytes.length;index+=chunk)binary+=String.fromCharCode.apply(null,bytes.subarray(index,index+chunk));return btoa(binary)}
543
- async function uploadOne(entry){if(!(entry&&entry.file&&typeof entry.file.size==="number"))throw new Error("Attachment is not a file");if(entry.file.size<=0)throw new Error("Attachment "+(entry.file.name||"file")+" is empty");if(entry.file.size>MAX_UPLOAD_BYTES)throw new Error("Attachment "+(entry.file.name||"file")+" is larger than 24 MiB");const content=encodeBase64(await entry.file.arrayBuffer());const response=await fetch(config.uploadUrl,{method:"POST",credentials:"same-origin",cache:"no-store",headers:{"content-type":"application/json"},body:JSON.stringify({actionToken:config.actionToken,name:String(entry.file.name||"upload"),content:content})});const body=await response.json().catch(function(){return null});if(!response.ok||!body||body.ok!==true)throw new Error((body&&body.error)||"Upload failed ("+response.status+")");return {field:String(entry.field||"file").slice(0,128),name:body.name,path:body.path,sizeBytes:body.sizeBytes}}
544
- async function uploadAll(port,data){const entries=Array.isArray(data.files)?data.files.slice(0,8):[];const files=[];for(let index=0;index<entries.length;index+=1){port.postMessage({kind:"thread-page:submit-progress",submissionId:data.submissionId,message:"Uploading "+(index+1)+" of "+entries.length+"\u2026"});files.push(await uploadOne(entries[index]))}return files}
545
- async function relaySubmit(port,data){try{const files=await uploadAll(port,data);if(files.length)port.postMessage({kind:"thread-page:submit-progress",submissionId:data.submissionId,message:"Sending\u2026"});const response=await fetch(config.submitUrl,{method:"POST",credentials:"same-origin",cache:"no-store",headers:{"content-type":"application/json"},body:JSON.stringify({actionToken:config.actionToken,submissionId:data.submissionId,pageHash:config.pageRevision,title:data.title,answers:data.answers,files:files})});const body=await response.json().catch(function(){return {ok:false,error:"Invalid server response"}});port.postMessage({kind:"thread-page:submit-result",submissionId:data.submissionId,ok:response.ok&&body.ok===true,message:body.delivery?"Sent ("+body.delivery+")":"Sent",error:body.error||("Request failed ("+response.status+")")})}catch(error){port.postMessage({kind:"thread-page:submit-result",submissionId:data.submissionId,ok:false,error:error instanceof Error?error.message:"Request failed"})}}
546
- /* A confirmed action is described by the server and shown here, in trusted
547
- chrome the sandboxed page cannot draw over, click, or reword. The page never
548
- supplies this text and never sees the challenge. */
549
- function confirmInChrome(summary){return new Promise(function(resolve){const dialog=document.querySelector("dialog");const text=dialog.querySelector("p");text.textContent=summary;let settled=false;function finish(value){if(settled)return;settled=true;dialog.removeEventListener("close",onClose);resolve(value)}function onClose(){finish(dialog.returnValue==="confirm")}dialog.addEventListener("close",onClose);dialog.returnValue="";if(typeof dialog.showModal==="function")dialog.showModal();else finish(false)})}
550
- async function relayConfirmedBridge(port,request,challenge,summary){const approved=await confirmInChrome(summary);if(!approved){port.postMessage(failure(request.id,"cancelled","You declined this action"));return}try{const response=await fetch(config.bridgeUrl,{method:"POST",credentials:"same-origin",cache:"no-store",headers:{"content-type":"application/json"},body:JSON.stringify({actionToken:config.actionToken,request:request,confirmation:challenge})});const body=await response.json().catch(function(){return null});port.postMessage(strictBridgeResponse(body,request.id)?body:failure(request.id,"invalid_response","The Thread Page bridge returned an invalid response"))}catch(error){port.postMessage(failure(request.id,"unavailable",error instanceof Error?error.message:"The Thread Page bridge is unavailable"))}}
551
- function onFramePortMessage(port,event){const data=event&&event.data;if(!data||typeof data!=="object")return;if(data.kind==="thread-page:dirty"){dirty=true;return}if(data.kind==="thread-page:clean"){dirty=false;return}if(data.kind==="thread-page:submit"){void relaySubmit(port,data);return}void relayBridge(port,data)}
552
- function connectFrame(){const channel=new MessageChannel();const port=channel.port1;framePort=port;port.onmessage=function(event){onFramePortMessage(port,event)};if(typeof port.start==="function")port.start();frame.contentWindow.postMessage({kind:"thread-page:connect",version:1},"*",[channel.port2]);port.postMessage({kind:"thread-page:source-state",stale:lastStale})}
553
- window.addEventListener("message",function(event){if(!awaitingReady||event.origin!=="null"||event.source!==frame.contentWindow||!event.data||event.data.kind!=="thread-page:ready"||event.data.version!==1)return;awaitingReady=false;connectFrame()});
554
- async function poll(){if(stopped||polling||document.visibilityState!=="visible")return;if(Date.now()>=config.expiresAt-30000){if(dirty){stopped=true;setStatus("Session expiring — reload when ready",true);reloadButton.dataset.visible="true"}else location.reload();return}polling=true;controller=new AbortController();try{const response=await fetch(config.documentUrl,{method:"GET",credentials:"same-origin",cache:"no-store",headers:{"if-none-match":etag},signal:controller.signal});if(response.status===401){stopped=true;setStatus("Session expired — reload this page",true);reloadButton.dataset.visible="true";return}if(!response.ok&&response.status!==304){setStatus("Page unavailable",true);return}const stale=response.headers.get("x-thread-page-stale")==="true";setWorking(response.headers.get("x-thread-page-activity"));const next=response.headers.get("etag");if(stale!==lastStale){lastStale=stale;if(framePort)framePort.postMessage({kind:"thread-page:source-state",stale:stale});if(!dirty)reloadFrame()}if(stale)setStatus("Offline copy — read-only",true);else setStatus("",false);if(next&&next!==etag){if(dirty){setStatus("Page changed — reload when ready",true);reloadButton.dataset.visible="true"}else location.reload()}etag=next||etag}catch(error){if(!(error instanceof DOMException&&error.name==="AbortError"))setStatus("Cannot check for updates",true)}finally{controller=null;polling=false;schedule(10000)}}
555
- document.addEventListener("visibilitychange",function(){if(document.visibilityState==="visible")schedule(0);else pause()});
556
- navigateFrame(config.documentUrl);
557
- schedule(10000);
558
- `;
559
-
560
- export function renderOuterPage(options: {
561
- nonce: string;
562
- title: string;
563
- actionToken: string;
564
- pageHash: string;
565
- expiresAt: number;
566
- documentUrl: string;
567
- submitUrl: string;
568
- uploadUrl: string;
569
- bridgeUrl: string;
570
- pageUrlTemplate: string;
571
- bbThreadUrlTemplate: string;
572
- /** Omitted when this page is itself home, or when no home is set. */
573
- homeUrl: string | null;
574
- /** Whether the owning thread is mid-turn right now. */
575
- working: boolean;
576
- /** User-adjustable wording for the working strip. */
577
- workingLabel: string;
578
- stale: boolean;
579
- }): string {
580
- const config = jsonForInlineScript({
581
- actionToken: options.actionToken,
582
- pageRevision: options.pageHash,
583
- expiresAt: options.expiresAt,
584
- documentUrl: options.documentUrl,
585
- submitUrl: options.submitUrl,
586
- uploadUrl: options.uploadUrl,
587
- bridgeUrl: options.bridgeUrl,
588
- pageUrlTemplate: options.pageUrlTemplate,
589
- bbThreadUrlTemplate: options.bbThreadUrlTemplate,
590
- workingLabel: options.workingLabel,
591
- stale: options.stale,
592
- });
593
- const runtime = OUTER_RUNTIME.replace("__THREAD_PAGE_CONFIG__", config);
594
- return `<!doctype html>
595
- <html lang="en">
596
- <head>
597
- <meta charset="utf-8">
598
- <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
599
- <meta name="color-scheme" content="light dark">
600
- <title>${escapeHtml(options.title)}</title>
601
- <style nonce="${escapeHtml(options.nonce)}">${OUTER_CSS}</style>
602
- </head>
603
- <body>
604
- <div class="shell">
605
- <header class="bar">
606
- ${
607
- options.homeUrl
608
- ? `<a class="home" href="${escapeHtml(options.homeUrl)}" title="All sessions">← Sessions</a>`
609
- : ""
610
- }
611
- <span class="title">${escapeHtml(options.title)}</span>
612
- <span class="work" role="status" data-visible="${options.working && options.workingLabel ? "true" : "false"}"><span class="dot" aria-hidden="true"></span><span class="what">${escapeHtml(options.workingLabel)}</span></span>
613
- <span class="status" role="status"${options.stale ? ' data-tone="warn"' : ""}>${options.stale ? "Offline copy — read-only" : ""}</span>
614
- <button type="button" aria-label="Reload updated page">Reload</button>
615
- </header>
616
- <iframe title="${escapeHtml(options.title)}" sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer"></iframe>
617
- </div>
618
- <dialog aria-labelledby="tp-confirm-title">
619
- <form method="dialog">
620
- <h2 id="tp-confirm-title">Confirm this action</h2>
621
- <p></p>
622
- <div class="row">
623
- <button value="cancel">Cancel</button>
624
- <button value="confirm">Confirm</button>
625
- </div>
626
- </form>
627
- </dialog>
628
- <script nonce="${escapeHtml(options.nonce)}">${runtime}</script>
629
- </body>
630
- </html>`;
631
- }
632
-
633
- export function formatSubmissionMessage(submission: Submission): string {
634
- const heading = submission.title.trim() || "Thread Page";
635
- const fields = submission.answers.map((answer) => {
636
- const label = answer.label.trim() || answer.name;
637
- const value = Array.isArray(answer.value)
638
- ? answer.value.length > 0
639
- ? answer.value.join(", ")
640
- : "(left blank)"
641
- : typeof answer.value === "boolean"
642
- ? answer.value
643
- ? "Yes"
644
- : "No"
645
- : answer.value.length > 0
646
- ? answer.value
647
- : "(left blank)";
648
- return `**${label}**\n${value}`;
649
- });
650
- const attachments = submission.files.length
651
- ? [
652
- [
653
- "**Attached files**",
654
- ...submission.files.map(
655
- (file) =>
656
- `- \`$BB_THREAD_STORAGE/${file.path}\` (${file.name}, ${file.sizeBytes} bytes)`,
657
- ),
658
- "Read them from thread storage with your normal tools.",
659
- ].join("\n"),
660
- ]
661
- : [];
662
- return [
663
- `The user answered the form on your Thread Page — ${heading}.`,
664
- ...fields,
665
- ...attachments,
666
- ].join("\n\n");
667
- }
668
-
669
- export function formatThreadReplyMessage(
670
- title: string | undefined,
671
- result: unknown,
672
- ): string {
673
- const heading = title?.trim() || "Interactive response";
674
- const serialized = JSON.stringify(result, null, 2) ?? "null";
675
- let longestBacktickRun = 0;
676
- for (const match of serialized.matchAll(/`+/g)) {
677
- longestBacktickRun = Math.max(longestBacktickRun, match[0].length);
678
- }
679
- const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
680
- return [
681
- `The user sent an interactive response from your Thread Page — ${heading}.`,
682
- `**Result**\n\n${fence}json\n${serialized}\n${fence}`,
683
- ].join("\n\n");
684
- }
685
-
686
- export function parseSubmission(value: unknown): Submission | null {
687
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
688
- const input = value as Record<string, unknown>;
689
- if (
690
- typeof input.actionToken !== "string" ||
691
- input.actionToken.length > 4096 ||
692
- typeof input.submissionId !== "string" ||
693
- !/^[A-Za-z0-9._-]{1,128}$/.test(input.submissionId) ||
694
- typeof input.pageHash !== "string" ||
695
- !/^[a-f0-9]{64}$/.test(input.pageHash) ||
696
- typeof input.title !== "string" ||
697
- input.title.length > 300 ||
698
- !Array.isArray(input.answers) ||
699
- input.answers.length > 64
700
- ) {
701
- return null;
702
- }
703
-
704
- let total = input.title.length;
705
- const answers: FormAnswer[] = [];
706
- for (const raw of input.answers) {
707
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
708
- const answer = raw as Record<string, unknown>;
709
- if (
710
- typeof answer.name !== "string" ||
711
- answer.name.length > 128 ||
712
- typeof answer.label !== "string" ||
713
- answer.label.length > 300 ||
714
- !isAnswerValue(answer.value)
715
- ) {
716
- return null;
717
- }
718
- const valueLength = Array.isArray(answer.value)
719
- ? answer.value.reduce((sum, item) => sum + item.length, 0)
720
- : typeof answer.value === "string"
721
- ? answer.value.length
722
- : 1;
723
- total += answer.name.length + answer.label.length + valueLength;
724
- if (total > 32_000) return null;
725
- answers.push({
726
- name: answer.name,
727
- label: answer.label,
728
- value: answer.value,
729
- });
730
- }
731
-
732
- const files: SubmissionFile[] = [];
733
- if (input.files !== undefined) {
734
- if (!Array.isArray(input.files) || input.files.length > 8) return null;
735
- for (const raw of input.files) {
736
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
737
- const file = raw as Record<string, unknown>;
738
- if (
739
- typeof file.field !== "string" ||
740
- file.field.length > 128 ||
741
- typeof file.name !== "string" ||
742
- file.name.length > 80 ||
743
- file.name !== safeUploadName(file.name) ||
744
- typeof file.path !== "string" ||
745
- file.path.length > 300 ||
746
- !file.path.startsWith(`${UPLOAD_DIRNAME}/`) ||
747
- file.path.includes("..") ||
748
- typeof file.sizeBytes !== "number" ||
749
- !Number.isSafeInteger(file.sizeBytes) ||
750
- file.sizeBytes < 0 ||
751
- file.sizeBytes > MAX_UPLOAD_BYTES
752
- ) {
753
- return null;
754
- }
755
- files.push({
756
- field: file.field,
757
- name: file.name,
758
- path: file.path,
759
- sizeBytes: file.sizeBytes,
760
- });
761
- }
762
- }
763
-
764
- return {
765
- actionToken: input.actionToken,
766
- submissionId: input.submissionId,
767
- pageHash: input.pageHash,
768
- title: input.title,
769
- answers,
770
- files,
771
- };
772
- }
773
-
774
- function isAnswerValue(value: unknown): value is FormAnswerValue {
775
- if (typeof value === "boolean") return true;
776
- if (typeof value === "string") return value.length <= 8_000;
777
- return (
778
- Array.isArray(value) &&
779
- value.length <= 64 &&
780
- value.every((item) => typeof item === "string" && item.length <= 2_000)
781
- );
782
- }