a2a-exposed 0.7.10 → 0.7.11

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/lib/main.mjs CHANGED
@@ -126,7 +126,11 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
126
126
  peer then swaps the old token for the new one under the same label (older peers
127
127
  keep the old token until their owner revokes it). --card-url: the card to name in
128
128
  the request (default: this deployment's); a *.ts.net / LAN card is sent as
129
- informational (flagged "not publicly reachable"), a non-https one is left out
129
+ informational (flagged "not publicly reachable"), a non-https one is left out.
130
+ The name sent is the card named in the request (--card-url, otherwise this
131
+ deployment's). --name overrides it. If that card can't be read, the name is the
132
+ configured agent name, the worker name, or the public host's first label. The
133
+ other inbox labels the token from that name and adds -2, -3, … when it is taken
130
134
  pair set-password --web [--ttl MIN] [--json]
131
135
  one-time link (default 15 min, single use) where your human sets or changes the
132
136
  approval password on a web page. Send it to them privately; never open or fill it
package/lib/pair.mjs CHANGED
@@ -193,6 +193,39 @@ export function deviceEndpointsFromCard(card) {
193
193
  const slug = (s) => String(s || "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/, "").toLowerCase();
194
194
  const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000 * Number(process.env.A2A_POLL_SCALE || 1)));
195
195
 
196
+ /** First DNS label of this deployment's public URL, or "" for loopback and an unusable host. */
197
+ export function hostAlias(ownUrl) {
198
+ if (!ownUrl) return "";
199
+ try {
200
+ const host = new URL(ownUrl).hostname;
201
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return "";
202
+ return slug(host.split(".")[0]);
203
+ } catch { return ""; }
204
+ }
205
+
206
+ /**
207
+ * client_name for a pairing request. An explicit `--name` wins, then the card named in the request
208
+ * (`--card-url`, otherwise this deployment's card), then the configured agent name, then the local
209
+ * alias (worker name, else the public host's first label).
210
+ */
211
+ export function pairingClientName({ explicit = "", cardName = "", agentName = "", workerName = "", ownUrl = "" } = {}) {
212
+ const text = (v) => String(v || "").replace(/\s+/g, " ").trim().slice(0, 64);
213
+ for (const v of [explicit, cardName, agentName, workerName, hostAlias(ownUrl)]) {
214
+ const t = text(v);
215
+ if (t) return t;
216
+ }
217
+ return "a2a-exposed agent";
218
+ }
219
+
220
+ /** Name on a card URL, or "" when the card can't be read. */
221
+ async function readCardName(url) {
222
+ if (!url) return "";
223
+ try {
224
+ const { status, data } = await httpJson(url, { timeout: 3000 });
225
+ return status === 200 && data && typeof data.name === "string" ? data.name : "";
226
+ } catch { return ""; }
227
+ }
228
+
196
229
  async function postForm(url, params, bearer = null) {
197
230
  let r;
198
231
  const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
@@ -315,16 +348,19 @@ A different agent at the same URL? Pass another --alias.`);
315
348
  `Resuming the pairing request to ${peerName} (code ${st.userCode}, link ${st.verificationUriComplete}).`);
316
349
  else {
317
350
  const own = baseUrl();
318
- const params = { client_name: o.name || C.get("A2A_AGENT_NAME") || C.get("A2A_WORKER_NAME") || "a2a-exposed agent", client_id: C.get("A2A_WORKER_NAME") || alias };
319
- const card = cardUrlToSend(o["card-url"], own, o.json);
320
- if (card) params.agent_card_url = card;
351
+ const sentCard = cardUrlToSend(o["card-url"], own, o.json);
352
+ // The name is the card this request actually names. --card-url (a Tailnet card, before a public
353
+ // deployment exists) wins. With no override, read this deployment's card, including an http one.
354
+ const nameUrl = sentCard || (!o["card-url"] && /^https?:\/\//.test(own || "") ? `${own.replace(/\/$/, "")}/.well-known/agent-card.json` : "");
355
+ const params = { client_name: pairingClientName({ explicit: o.name, cardName: await readCardName(nameUrl), agentName: C.get("A2A_AGENT_NAME"), workerName: C.get("A2A_WORKER_NAME"), ownUrl: own }), client_id: C.get("A2A_WORKER_NAME") || alias };
356
+ if (sentCard) params.agent_card_url = sentCard;
321
357
  // replacing: present the old token, so a peer that supports it swaps the token under the same label (no orphan)
322
358
  const r = await postForm(ep.device, params, replace && oldToken ? oldToken : null);
323
359
  if (r.status !== 200 || !r.data.device_code || !r.data.user_code) {
324
360
  const why = r.data.error_description || r.data.error || `HTTP ${r.status}`;
325
- const privateCard = !!card && (() => { try { return isPrivateHost(new URL(card).hostname); } catch { return false; } })();
361
+ const privateCard = !!sentCard && (() => { try { return isPrivateHost(new URL(sentCard).hostname); } catch { return false; } })();
326
362
  die(r.status === 429 ? `${peerName} is not accepting more pairing requests right now: ${why}` : r.status === 404 ? `${peerName} has device-flow pairing disabled (${why}); ask its owner for a token`
327
- : `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(card).host}) is not publicly reachable, and inboxes may accept only public https cards: retry with --card-url set to a public card, or expose your agent through a public façade first (setup skill: "Already have A2A on a Tailnet or LAN")` : ""}`);
363
+ : `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(sentCard).host}) is not publicly reachable, and inboxes may accept only public https cards: retry with --card-url set to a public card, or expose your agent through a public façade first (setup skill: "Already have A2A on a Tailnet or LAN")` : ""}`);
328
364
  }
329
365
  st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
330
366
  verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  // Device-flow pairing (OAuth 2.0 Device Authorization Grant, RFC 8628): pure helpers (Web Crypto only), so they run
2
2
  // in Workers and in Node's test runner. The HTTP handlers live in index.ts.
3
3
  import { isPrivateHost } from "./a2a.ts";
4
+ import { pageStyle } from "./theme.ts";
4
5
 
5
6
  /** How every hint tells an agent to run the CLI: npx, latest release, no global install (agent sandboxes block global
6
7
  * installs as untrusted code). WAKE_CLI_COMMAND overrides it for the owner's own wake hints only. */
@@ -223,17 +224,11 @@ export function devicePage(m: PageModel, nonce: string): string {
223
224
  return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${oidcForm}${ask}`);
224
225
  }
225
226
 
226
- const STYLE = `body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fafafa}
227
- h1{font-size:1.3rem}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #ddd;vertical-align:top;word-break:break-all}th{width:40%;font-weight:600;word-break:normal}
228
- label{display:block;font-weight:600;margin:.8rem 0 .3rem}input{font:inherit;width:100%;box-sizing:border-box;padding:.5rem;border:1px solid #888;border-radius:4px}
229
- .b{display:flex;gap:.6rem;margin-top:1rem}button{font:inherit;padding:.5rem 1.2rem;border:0;border-radius:4px;background:#1a5fb4;color:#fff;cursor:pointer}button.d{background:#a51d2d}
230
- h2{font-size:1.05rem;margin-top:1.5rem}ul{padding-left:1.2rem}a{color:#1a5fb4}.n{padding:.6rem .8rem;border-radius:4px}.ok{background:#e6f4ea}.error{background:#fce8e6}.info{background:#e8f0fe}.w{font-size:.9rem;color:#444}code{background:#eee;padding:0 .2rem}`;
231
-
232
227
  /** The shared page frame: no scripts, no external assets, one nonce'd style block. `title` is escaped here. */
233
228
  export function pageShell(title: string, nonce: string, body: string): string {
234
229
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer">
235
230
  <title>${esc(title)}</title><style nonce="${nonce}">
236
- ${STYLE}
231
+ ${pageStyle()}
237
232
  </style></head><body><h1>${esc(title)}</h1>${body}</body></html>`;
238
233
  }
239
234
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Neutral CSS for the inbox HTML pages (`/`, `/device`, `/device/setup`, MCP consent).
3
+ * It is inlined in one nonce'd `<style>` block. There is no external stylesheet.
4
+ * `WORKER_THEME` (a CSS file path) replaces this module at `cf build`. See `theme-build.mjs`.
5
+ */
6
+ export const PAGE_STYLE = `body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fafafa}
7
+ h1{font-size:1.3rem}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #ddd;vertical-align:top;word-break:break-all}th{width:40%;font-weight:600;word-break:normal}
8
+ label{display:block;font-weight:600;margin:.8rem 0 .3rem}input{font:inherit;width:100%;box-sizing:border-box;padding:.5rem;border:1px solid #888;border-radius:4px}
9
+ .b{display:flex;gap:.6rem;margin-top:1rem}button{font:inherit;padding:.5rem 1.2rem;border:0;border-radius:4px;background:#1a5fb4;color:#fff;cursor:pointer}button.d{background:#a51d2d}
10
+ h2{font-size:1.05rem;margin-top:1.5rem}ul{padding-left:1.2rem}a{color:#1a5fb4}.n{padding:.6rem .8rem;border-radius:4px}.ok{background:#e6f4ea}.error{background:#fce8e6}.info{background:#e8f0fe}.w{font-size:.9rem;color:#444}code{background:#eee;padding:0 .2rem}`;
11
+
12
+ export function pageStyle(): string {
13
+ return PAGE_STYLE;
14
+ }
@@ -288,6 +288,25 @@ test("human approval: pending, slow_down, page approve with the password, single
288
288
  assert.equal((await s.call("POST", "/", { json: { jsonrpc: "2.0", id: 1, method: "GetTask", params: { id: "x" } }, headers: { authorization: `Bearer ${p.data.access_token}` } })).status, 401);
289
289
  });
290
290
 
291
+ test("a pairing label comes from the client name, a second one is suffixed, and inbox from uses it", async (t) => {
292
+ const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
293
+ const first = (await s.start({ client_name: "tester-alpha", client_id: "other-alias" }, "203.0.113.40")).data;
294
+ const approved = await s.owner("POST", `/owner/pairing/${first.user_code}/approve`);
295
+ assert.equal(approved.data.label, "tester-alpha");
296
+ const token = (await s.poll(first.device_code)).data.access_token;
297
+ const sent = await s.call("POST", "/", {
298
+ json: { jsonrpc: "2.0", id: 1, method: "SendMessage", params: { message: { role: "ROLE_USER", parts: [{ text: "hello" }] } } },
299
+ headers: { authorization: `Bearer ${token}`, "a2a-version": "1.0" },
300
+ });
301
+ assert.equal(sent.status, 200, JSON.stringify(sent.data));
302
+ const inbox = await s.owner("GET", "/owner/inbox");
303
+ assert.equal(inbox.data.length, 1);
304
+ assert.equal(inbox.data[0].from, "tester-alpha");
305
+ assert.equal(inbox.data[0].text, "hello");
306
+ const second = (await s.start({ client_name: "tester-alpha", client_id: "another" }, "203.0.113.41")).data;
307
+ assert.equal((await s.owner("POST", `/owner/pairing/${second.user_code}/approve`)).data.label, "tester-alpha-2");
308
+ });
309
+
291
310
  test("labels are deduplicated; denied and expired requests; JSON bodies; bad requests", async (t) => {
292
311
  const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
293
312
  await s.owner("POST", "/owner/peers", { label: "barry" });
@@ -0,0 +1,85 @@
1
+ // Neutral inbox CSS, and the build-time WORKER_THEME check. Run: npm test
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { PAGE_STYLE, pageStyle } from "../src/theme.ts";
8
+ import { pageShell } from "../src/pairing.ts";
9
+ import { applyWorkerTheme, assertInlineCss, cssForChecks } from "../theme-build.mjs";
10
+
11
+ test("the neutral theme is inlined once and loads nothing", () => {
12
+ assert.equal(pageStyle(), PAGE_STYLE);
13
+ assert.equal(PAGE_STYLE.includes("background:#fafafa"), true);
14
+ assert.equal(PAGE_STYLE.includes("background:#1a5fb4"), true);
15
+ assert.doesNotThrow(() => assertInlineCss(PAGE_STYLE));
16
+ const html = pageShell("Inbox", "nonce-1", "<p>Hello</p>");
17
+ assert.equal(html.includes(`<style nonce="nonce-1">\n${PAGE_STYLE}\n</style>`), true);
18
+ assert.equal((html.match(/<style/g) || []).length, 1);
19
+ assert.equal(/<link|<script|<img|url\s*\(|@import/i.test(html), false);
20
+ });
21
+
22
+ test("WORKER_THEME replaces the module only when the CSS stays inline", () => {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-theme-"));
24
+ const file = path.join(dir, "theme.css");
25
+ const css = "@media (max-width: 40rem) { body { background: #fff; color: \\23 222; } }";
26
+ fs.writeFileSync(file, css);
27
+ const next = applyWorkerTheme("/work/src/theme.ts", file);
28
+ assert.match(next || "", /export const PAGE_STYLE = /);
29
+ assert.match(next || "", /background: #fff/);
30
+ assert.match(next || "", /\\\\23 222/);
31
+ assert.equal(applyWorkerTheme("/work/src/theme.ts", undefined), null);
32
+ assert.equal(applyWorkerTheme("/work/src/index.ts", file), null);
33
+
34
+ for (const bad of [
35
+ "",
36
+ "body { background: url(https://example.com/a.png); }",
37
+ "@import 'other.css';",
38
+ "@font-face { font-family: x; }",
39
+ "body { background: red; } </style><script>",
40
+ "body { background: expression(alert(1)); }",
41
+ "a { color: javascript:alert(1); }",
42
+ "body{background:u\\72l(https://example.com/a.png)}",
43
+ "body{background:\\75\\72\\6c(https://example.com/a.png)}",
44
+ "body{background:u\\000072l(https://example.com/a.png)}",
45
+ "@\\69mport \"https://example.com/x.css\"",
46
+ "@\\69 mport \"https://example.com/x.css\"",
47
+ "@\\69\\6d\\70\\6f\\72\\74 \"https://example.com/x.css\"",
48
+ "body{background:url/*x*/(https://example.com/a.png)}",
49
+ "@font\\2d face { font-family: x; }",
50
+ "a{\"/*\";background:url(https://example.com/a.png);content:\"*/\"}",
51
+ "a{content:\"foo\\\"bar\";background:url(https://example.com/a.png)}",
52
+ "a{content:\"foo\\\nbar\";background:url(https://example.com/a.png)}",
53
+ "a{content:\"url(https://example.com/a.png)\nbackground:url(https://example.com/b.png)}",
54
+ "body{background-image:image-set(\"https://example.com/p.png\" 1x)}",
55
+ "body{background-image:image(\"https://example.com/p.png\")}",
56
+ "body{background-image:-webkit-image-set(\"https://example.com/p.png\" 1x)}",
57
+ "body{background-image:image-\\73 et(\"https://example.com/p.png\" 1x)}",
58
+ "body{background-image:\\69mage(\"https://example.com/p.png\")}",
59
+ "body{background-image:image-set/*x*/(\"https://example.com/p.png\" 1x)}",
60
+ ]) {
61
+ fs.writeFileSync(file, bad);
62
+ assert.throws(() => applyWorkerTheme("/work/src/theme.ts", file), /WORKER_THEME was refused/);
63
+ }
64
+ for (const ok of [
65
+ "/* url(https://example.com/a.png) */ body { color: red }",
66
+ "a { content: \"url(https://example.com/a.png)\" }",
67
+ "a { content: \"foo\\\nurl(https://example.com/a.png)\" }",
68
+ "body { color: \\3c }",
69
+ "body { background-image: linear-gradient(#fff, #000); }",
70
+ "a { content: \"image-set(https://example.com/p.png)\" }",
71
+ ]) {
72
+ fs.writeFileSync(file, ok);
73
+ assert.doesNotThrow(() => applyWorkerTheme("/work/src/theme.ts", file));
74
+ }
75
+ assert.throws(() => applyWorkerTheme("/work/src/theme.ts", path.join(dir, "missing.css")), /could not be read/);
76
+ });
77
+
78
+ test("the resource check decodes escapes and keeps strings and comments closed", () => {
79
+ assert.match(cssForChecks("u\\72l(https://example.com/a.png)"), /url\(https:\/\/example.com\/a.png\)/);
80
+ assert.match(cssForChecks("@\\69 mport \"x.css\""), /@import ""/);
81
+ assert.equal(/url\s*\(/i.test(cssForChecks("/* url(https://example.com/a.png) */ body{color:red}")), false);
82
+ assert.equal(/url\s*\(/i.test(cssForChecks("a{content:\"url(https://example.com/a.png)\"}")), false);
83
+ assert.match(cssForChecks("a{\"/*\";background:url(https://example.com/a.png)}"), /url\(https:\/\/example.com\/a.png\)/);
84
+ assert.match(cssForChecks("color:\\23 222"), /color:#222/);
85
+ });
@@ -0,0 +1,162 @@
1
+ // Build-time check for WORKER_THEME. The Worker inlines the result in a nonce'd style block and
2
+ // does not load a stylesheet, font, or image. Unsafe CSS is refused so the build stops.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const MAX = 16384;
7
+
8
+ function isHex(char) {
9
+ return char !== undefined && ((char >= "0" && char <= "9") || (char >= "a" && char <= "f") || (char >= "A" && char <= "F"));
10
+ }
11
+
12
+ function isCssSpace(char) {
13
+ return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f";
14
+ }
15
+
16
+ function isNewline(char) {
17
+ return char === "\n" || char === "\r" || char === "\f";
18
+ }
19
+
20
+ function consumeNewline(css, i) {
21
+ if (css[i] === "\r" && css[i + 1] === "\n") return i + 2;
22
+ return i + 1;
23
+ }
24
+
25
+ /**
26
+ * One CSS escape at a backslash. A backslash before a newline is not an escape (`newline`);
27
+ * the caller decides whether that pair continues a string or stays in the sheet.
28
+ * @param {string} css
29
+ * @param {number} i index of the backslash
30
+ */
31
+ function consumeEscape(css, i) {
32
+ i++;
33
+ if (i >= css.length) return { type: "char", char: "\uFFFD", next: i };
34
+ if (isNewline(css[i])) return { type: "newline", next: i };
35
+ if (isHex(css[i])) {
36
+ let hex = "";
37
+ while (hex.length < 6 && i < css.length && isHex(css[i])) hex += css[i++];
38
+ if (i < css.length && isCssSpace(css[i])) i = isNewline(css[i]) ? consumeNewline(css, i) : i + 1;
39
+ const codePoint = Number.parseInt(hex, 16);
40
+ const surrogate = codePoint >= 0xd800 && codePoint <= 0xdfff;
41
+ const char = codePoint === 0 || surrogate || codePoint > 0x10ffff ? "\uFFFD" : String.fromCodePoint(codePoint);
42
+ return { type: "char", char, next: i };
43
+ }
44
+ return { type: "char", char: css[i], next: i + 1 };
45
+ }
46
+
47
+ /**
48
+ * Stylesheet text with escapes decoded, comments removed, and string contents removed.
49
+ * Markers are recognized on the raw text, so a string cannot pretend to be a comment,
50
+ * and a decoded character is not read again as a quote or a comment.
51
+ * @param {string} css
52
+ */
53
+ export function cssForChecks(css) {
54
+ let out = "";
55
+ let i = 0;
56
+ /** @type {"normal" | "comment" | "string"} */
57
+ let mode = "normal";
58
+ let quote = "";
59
+ while (i < css.length) {
60
+ if (mode === "comment") {
61
+ if (css[i] === "*" && css[i + 1] === "/") {
62
+ mode = "normal";
63
+ i += 2;
64
+ } else i++;
65
+ continue;
66
+ }
67
+ if (mode === "string") {
68
+ if (css[i] === "\\") {
69
+ const esc = consumeEscape(css, i);
70
+ i = esc.type === "newline" ? consumeNewline(css, esc.next) : esc.next;
71
+ continue;
72
+ }
73
+ if (isNewline(css[i])) {
74
+ mode = "normal";
75
+ out += "\n";
76
+ i = consumeNewline(css, i);
77
+ continue;
78
+ }
79
+ if (css[i] === quote) {
80
+ mode = "normal";
81
+ out += quote;
82
+ i++;
83
+ continue;
84
+ }
85
+ i++;
86
+ continue;
87
+ }
88
+ if (css[i] === "/" && css[i + 1] === "*") {
89
+ out += " ";
90
+ mode = "comment";
91
+ i += 2;
92
+ continue;
93
+ }
94
+ if (css[i] === '"' || css[i] === "'") {
95
+ quote = css[i];
96
+ out += quote;
97
+ mode = "string";
98
+ i++;
99
+ continue;
100
+ }
101
+ if (css[i] === "\\") {
102
+ const esc = consumeEscape(css, i);
103
+ if (esc.type === "newline") {
104
+ out += "\\";
105
+ i = esc.next;
106
+ continue;
107
+ }
108
+ out += esc.char;
109
+ i = esc.next;
110
+ continue;
111
+ }
112
+ out += css[i++];
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** @param {string} css */
118
+ export function assertInlineCss(css) {
119
+ if (typeof css !== "string") throw new Error("the theme is not text");
120
+ const text = css.replace(/^\uFEFF/, "");
121
+ if (!text.trim()) throw new Error("the theme is empty");
122
+ if (text.length > MAX) throw new Error("the theme is too long");
123
+ if (/[<>]/.test(text)) throw new Error("the theme has a tag");
124
+ const parsed = cssForChecks(text);
125
+ if (/@import\b/i.test(parsed)) throw new Error("the theme imports a stylesheet");
126
+ if (/@font-face\b/i.test(parsed)) throw new Error("the theme loads a font");
127
+ if (/url\s*\(/i.test(parsed)) throw new Error("the theme loads a url");
128
+ // image() and image-set() take a string as an image source, so the address never appears as url().
129
+ if (/\bimage-set\s*\(/i.test(parsed) || /\bimage\s*\(/i.test(parsed)) throw new Error("the theme loads an image");
130
+ if (/expression\s*\(/i.test(parsed) || /javascript\s*:/i.test(parsed)) throw new Error("the theme has a script");
131
+ if (/-moz-binding\s*:/i.test(parsed) || /behavior\s*:/i.test(parsed)) throw new Error("the theme has a binding");
132
+ return text;
133
+ }
134
+
135
+ /** Module source with the same exports as `src/theme.ts`. */
136
+ export function themeModule(css) {
137
+ const safe = assertInlineCss(css);
138
+ return `export const PAGE_STYLE = ${JSON.stringify(safe)};\nexport function pageStyle(): string {\n\treturn PAGE_STYLE;\n}\n`;
139
+ }
140
+
141
+ /**
142
+ * Replacement for `src/theme.ts` when `file` is set. Null leaves the neutral module in place.
143
+ * @param {string} id
144
+ * @param {string | undefined} file
145
+ */
146
+ export function applyWorkerTheme(id, file) {
147
+ if (!file) return null;
148
+ const clean = id.split("?")[0].replaceAll("\\", "/");
149
+ if (!clean.endsWith("/src/theme.ts")) return null;
150
+ let css;
151
+ try {
152
+ css = fs.readFileSync(path.resolve(file), "utf8");
153
+ } catch {
154
+ throw new Error("WORKER_THEME could not be read");
155
+ }
156
+ try {
157
+ return themeModule(css);
158
+ } catch (error) {
159
+ const reason = error instanceof Error ? error.message : "the theme was refused";
160
+ throw new Error(`WORKER_THEME was refused (${reason})`);
161
+ }
162
+ }
@@ -1,6 +1,17 @@
1
1
  import { cloudflare } from "@cloudflare/vite-plugin";
2
2
  import { defineConfig } from "vite";
3
+ import { applyWorkerTheme } from "./theme-build.mjs";
3
4
 
4
5
  export default defineConfig({
5
- plugins: [cloudflare()],
6
+ plugins: [
7
+ {
8
+ name: "inbox-theme",
9
+ enforce: "pre",
10
+ transform(_code, id) {
11
+ const next = applyWorkerTheme(id, process.env.WORKER_THEME);
12
+ return next ? { code: next, map: null } : null;
13
+ },
14
+ },
15
+ cloudflare(),
16
+ ],
6
17
  });