@arcote.tech/arc-cli 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26942,7 +26942,9 @@ async function buildContextClient(pkg, rootDir, client, cache, noCache) {
26942
26942
  console.log(` building: ${pkg.name} (${client.name})`);
26943
26943
  const peerDeps = Object.keys(pkg.packageJson.peerDependencies ?? {});
26944
26944
  const allDeps = pkg.packageJson.dependencies ?? {};
26945
- const externals = [...peerDeps, ...Object.keys(allDeps)];
26945
+ const isBrowser2 = client.name === "browser";
26946
+ const workspaceDeps = isBrowser2 ? Object.keys(allDeps) : Object.entries(allDeps).filter(([, spec]) => !spec.startsWith("workspace:")).map(([name]) => name);
26947
+ const externals = [...peerDeps, ...workspaceDeps];
26946
26948
  const result = await Bun.build({
26947
26949
  entrypoints: [pkg.entrypoint],
26948
26950
  outdir: join8(outDir, "main"),
@@ -26971,9 +26973,39 @@ async function buildContextPackages(rootDir, packages, cache, noCache) {
26971
26973
  const contexts = packages.filter((p) => isContextPackage(p.packageJson));
26972
26974
  if (contexts.length === 0)
26973
26975
  return { declarationErrors: [] };
26974
- const tasks = contexts.flatMap((pkg) => CONTEXT_CLIENTS.map((client) => () => buildContextClient(pkg, rootDir, client, cache, noCache)));
26975
- const results = await pAll(tasks);
26976
- const declarationErrors = results.flatMap((r) => r.declarationErrors);
26976
+ const byName = new Map(contexts.map((p) => [p.name, p]));
26977
+ const remaining = new Set(contexts.map((p) => p.name));
26978
+ const done = new Set;
26979
+ const ordered = [];
26980
+ const workspaceDepsOf = (pkg) => {
26981
+ const deps = pkg.packageJson.dependencies ?? {};
26982
+ return Object.entries(deps).filter(([name, spec]) => spec.startsWith("workspace:") && byName.has(name)).map(([name]) => name);
26983
+ };
26984
+ while (remaining.size > 0) {
26985
+ const layer = [];
26986
+ for (const name of remaining) {
26987
+ const pkg = byName.get(name);
26988
+ const unmetDeps = workspaceDepsOf(pkg).filter((d) => !done.has(d));
26989
+ if (unmetDeps.length === 0)
26990
+ layer.push(pkg);
26991
+ }
26992
+ if (layer.length === 0) {
26993
+ const cycle = [...remaining].join(", ");
26994
+ throw new Error(`Workspace dependency cycle detected: ${cycle}`);
26995
+ }
26996
+ ordered.push(layer);
26997
+ for (const pkg of layer) {
26998
+ done.add(pkg.name);
26999
+ remaining.delete(pkg.name);
27000
+ }
27001
+ }
27002
+ const declarationErrors = [];
27003
+ for (const layer of ordered) {
27004
+ const tasks = layer.flatMap((pkg) => CONTEXT_CLIENTS.map((client) => () => buildContextClient(pkg, rootDir, client, cache, noCache)));
27005
+ const results = await pAll(tasks);
27006
+ for (const r of results)
27007
+ declarationErrors.push(...r.declarationErrors);
27008
+ }
26977
27009
  if (declarationErrors.length > 0) {
26978
27010
  console.warn(`
26979
27011
  \x1B[33mType declaration errors:\x1B[0m`);
@@ -31454,11 +31486,12 @@ async function createArcServer(config) {
31454
31486
  init_i18n();
31455
31487
  import { existsSync as existsSync18, mkdirSync as mkdirSync14 } from "fs";
31456
31488
  import { join as join20 } from "path";
31457
- function generateShellHtml(appName, manifest, initial) {
31489
+ function generateShellHtml(appName, manifest, initial, stylesHash) {
31458
31490
  const initialUrl = initial ? `/browser/${initial.file}` : null;
31459
31491
  if (!initialUrl) {
31460
31492
  throw new Error("generateShellHtml: initial bundle missing from manifest");
31461
31493
  }
31494
+ const stylesQs = stylesHash ? `?v=${stylesHash.slice(0, 16)}` : "";
31462
31495
  return `<!doctype html>
31463
31496
  <html lang="en">
31464
31497
  <head>
@@ -31467,8 +31500,8 @@ function generateShellHtml(appName, manifest, initial) {
31467
31500
  <title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `
31468
31501
  <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `
31469
31502
  <link rel="manifest" href="/manifest.json">` : ""}
31470
- <link rel="stylesheet" href="/styles.css" />
31471
- <link rel="stylesheet" href="/theme.css" />
31503
+ <link rel="stylesheet" href="/styles.css${stylesQs}" />
31504
+ <link rel="stylesheet" href="/theme.css${stylesQs}" />
31472
31505
  <link rel="modulepreload" href="${initialUrl}" />
31473
31506
  </head>
31474
31507
  <body>
@@ -31507,7 +31540,6 @@ function serveFile(filePath, headers = {}) {
31507
31540
  });
31508
31541
  }
31509
31542
  var MODULE_SIG_SECRET = process.env.ARC_MODULE_SECRET ?? "";
31510
- var MODULE_SIG_TTL = 3600;
31511
31543
  function ensureModuleSigSecret(ws, devMode) {
31512
31544
  if (MODULE_SIG_SECRET)
31513
31545
  return;
@@ -31520,19 +31552,16 @@ function ensureModuleSigSecret(ws, devMode) {
31520
31552
  }
31521
31553
  }
31522
31554
  function signGroupUrl(file) {
31523
- const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
31524
31555
  const hasher = new Bun.CryptoHasher("sha256");
31525
- hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
31556
+ hasher.update(`${file}:${MODULE_SIG_SECRET}`);
31526
31557
  const sig = hasher.digest("hex").slice(0, 16);
31527
- return `/browser/${file}?sig=${sig}&exp=${exp}`;
31558
+ return `/browser/${file}?sig=${sig}`;
31528
31559
  }
31529
- function verifyGroupSignature(file, sig, exp) {
31530
- if (!sig || !exp)
31531
- return false;
31532
- if (Number(exp) < Date.now() / 1000)
31560
+ function verifyGroupSignature(file, sig) {
31561
+ if (!sig)
31533
31562
  return false;
31534
31563
  const hasher = new Bun.CryptoHasher("sha256");
31535
- hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
31564
+ hasher.update(`${file}:${MODULE_SIG_SECRET}`);
31536
31565
  return hasher.digest("hex").slice(0, 16) === sig;
31537
31566
  }
31538
31567
  function decodeTokenPayload(jwt2) {
@@ -31622,8 +31651,7 @@ function staticFilesHandler(ws, devMode, getManifest) {
31622
31651
  const isGroupEntry = Object.values(manifest.groups).some((g3) => g3.file === file);
31623
31652
  if (isGroupEntry) {
31624
31653
  const sig = url.searchParams.get("sig");
31625
- const exp = url.searchParams.get("exp");
31626
- if (!verifyGroupSignature(file, sig, exp)) {
31654
+ if (!verifyGroupSignature(file, sig)) {
31627
31655
  return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
31628
31656
  }
31629
31657
  }
@@ -31633,13 +31661,25 @@ function staticFilesHandler(ws, devMode, getManifest) {
31633
31661
  });
31634
31662
  }
31635
31663
  if (path4.startsWith("/locales/"))
31636
- return serveFile(join20(ws.arcDir, path4.slice(1)), ctx.corsHeaders);
31664
+ return serveFile(join20(ws.arcDir, path4.slice(1)), {
31665
+ ...ctx.corsHeaders,
31666
+ "Cache-Control": devMode ? "no-cache" : "max-age=300,stale-while-revalidate=3600"
31667
+ });
31637
31668
  if (path4.startsWith("/assets/"))
31638
- return serveFile(join20(ws.assetsDir, path4.slice(8)), ctx.corsHeaders);
31669
+ return serveFile(join20(ws.assetsDir, path4.slice(8)), {
31670
+ ...ctx.corsHeaders,
31671
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable"
31672
+ });
31639
31673
  if (path4 === "/styles.css")
31640
- return serveFile(join20(ws.arcDir, "styles.css"), ctx.corsHeaders);
31674
+ return serveFile(join20(ws.arcDir, "styles.css"), {
31675
+ ...ctx.corsHeaders,
31676
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable"
31677
+ });
31641
31678
  if (path4 === "/theme.css")
31642
- return serveFile(join20(ws.arcDir, "theme.css"), ctx.corsHeaders);
31679
+ return serveFile(join20(ws.arcDir, "theme.css"), {
31680
+ ...ctx.corsHeaders,
31681
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable"
31682
+ });
31643
31683
  if ((path4 === "/manifest.json" || path4 === "/manifest.webmanifest") && ws.manifest) {
31644
31684
  return serveFile(ws.manifest.path, ctx.corsHeaders);
31645
31685
  }
@@ -31718,7 +31758,7 @@ async function startPlatformServer(opts) {
31718
31758
  const setManifest = (m4) => {
31719
31759
  manifest = m4;
31720
31760
  };
31721
- const getShellHtml = () => generateShellHtml(ws.appName, ws.manifest, manifest?.initial);
31761
+ const getShellHtml = () => generateShellHtml(ws.appName, ws.manifest, manifest?.initial, manifest?.stylesHash);
31722
31762
  const sseClients = new Set;
31723
31763
  const notifyReload = (m4) => {
31724
31764
  const data = JSON.stringify(m4);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,12 +12,12 @@
12
12
  "build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.1",
16
- "@arcote.tech/arc-ds": "^0.7.1",
17
- "@arcote.tech/arc-react": "^0.7.1",
18
- "@arcote.tech/arc-host": "^0.7.1",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.1",
20
- "@arcote.tech/platform": "^0.7.1",
15
+ "@arcote.tech/arc": "^0.7.3",
16
+ "@arcote.tech/arc-ds": "^0.7.3",
17
+ "@arcote.tech/arc-react": "^0.7.3",
18
+ "@arcote.tech/arc-host": "^0.7.3",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.3",
20
+ "@arcote.tech/platform": "^0.7.3",
21
21
  "@clack/prompts": "^0.9.0",
22
22
  "commander": "^11.1.0",
23
23
  "chokidar": "^3.5.3",
@@ -277,26 +277,32 @@ async function buildContextClient(
277
277
 
278
278
  console.log(` building: ${pkg.name} (${client.name})`);
279
279
 
280
- // Externals: framework peers + npm dependencies + workspace deps.
280
+ // Externals depend on the target client:
281
281
  //
282
- // Workspace deps used to be bundled inline (because the deploy image has
283
- // no workspace symlinks). The consequence: every context package's dist
284
- // carried its own copy of every workspace dep — `@ndt/strategy/dist`
285
- // shipped `@ndt/workspace` inlined, `@ndt/content/dist` shipped another
286
- // copy, etc. At deploy-build time the top-level Bun.build saw N
287
- // pre-inlined copies and could no longer dedupe them — context-package
288
- // singletons (e.g. `WorkspaceContext = createContext`) duplicated per
289
- // entry, breaking `useWorkspace()` provider lookup.
282
+ // - BROWSER client: workspace deps MUST be external. Inlining them per
283
+ // package would make every context package's dist ship its own copy
284
+ // of every workspace dep — the top-level browser Bun.build would see
285
+ // N pre-inlined copies of context singletons (`WorkspaceContext =
286
+ // createContext`) and could not dedupe them, breaking provider lookup.
290
287
  //
291
- // Treating workspace deps as `external` makes per-package dist emit bare
292
- // specifiers (`import { workspace } from "@ndt/workspace"`), which the
293
- // browser-side Bun.build then resolves ONCE across all entries → single
294
- // module instance, splitting hoists into a shared chunk. The deploy
295
- // image is unaffected: the browser bundle is self-contained at that
296
- // layer (we only ship the final chunks, not per-package dist/browser).
288
+ // - SERVER client: workspace deps MUST be bundled inline. The deploy
289
+ // image flattens each package's server bundle to
290
+ // `.arc/platform/server/<pkg>.js` and runs them via a single
291
+ // `loadServerContext()` import loop. There is no `node_modules/@ndt/*`
292
+ // tree inside the image, so bare `@ndt/workspace` specifiers would
293
+ // fail to resolve at startup. Inline duplication is harmless on the
294
+ // server: it's a single Node/Bun process and Arc modules register via
295
+ // a shared platform registry singleton (registry.ts), so two physical
296
+ // copies of the workspace module still merge into one context.
297
297
  const peerDeps = Object.keys(pkg.packageJson.peerDependencies ?? {});
298
298
  const allDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
299
- const externals = [...peerDeps, ...Object.keys(allDeps)];
299
+ const isBrowser = client.name === "browser";
300
+ const workspaceDeps = isBrowser
301
+ ? Object.keys(allDeps)
302
+ : Object.entries(allDeps)
303
+ .filter(([, spec]) => !spec.startsWith("workspace:"))
304
+ .map(([name]) => name);
305
+ const externals = [...peerDeps, ...workspaceDeps];
300
306
 
301
307
  const result = await Bun.build({
302
308
  entrypoints: [pkg.entrypoint],
@@ -349,14 +355,51 @@ export async function buildContextPackages(
349
355
  const contexts = packages.filter((p) => isContextPackage(p.packageJson));
350
356
  if (contexts.length === 0) return { declarationErrors: [] };
351
357
 
352
- const tasks = contexts.flatMap((pkg) =>
353
- CONTEXT_CLIENTS.map((client) => () =>
354
- buildContextClient(pkg, rootDir, client, cache, noCache),
355
- ),
356
- );
358
+ // Topological order each package's server bundle inlines its workspace
359
+ // deps, so those deps must have their `dist/` ready before Bun.build tries
360
+ // to resolve them. Without this, a fresh checkout (no dist/ yet) fails the
361
+ // first build because content's resolve of @ndt/strategy hits a missing
362
+ // file. Inside a topological level, packages are built in parallel.
363
+ const byName = new Map(contexts.map((p) => [p.name, p]));
364
+ const remaining = new Set(contexts.map((p) => p.name));
365
+ const done = new Set<string>();
366
+ const ordered: WorkspacePackage[][] = [];
367
+
368
+ const workspaceDepsOf = (pkg: WorkspacePackage): string[] => {
369
+ const deps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
370
+ return Object.entries(deps)
371
+ .filter(([name, spec]) => spec.startsWith("workspace:") && byName.has(name))
372
+ .map(([name]) => name);
373
+ };
374
+
375
+ while (remaining.size > 0) {
376
+ const layer: WorkspacePackage[] = [];
377
+ for (const name of remaining) {
378
+ const pkg = byName.get(name)!;
379
+ const unmetDeps = workspaceDepsOf(pkg).filter((d) => !done.has(d));
380
+ if (unmetDeps.length === 0) layer.push(pkg);
381
+ }
382
+ if (layer.length === 0) {
383
+ const cycle = [...remaining].join(", ");
384
+ throw new Error(`Workspace dependency cycle detected: ${cycle}`);
385
+ }
386
+ ordered.push(layer);
387
+ for (const pkg of layer) {
388
+ done.add(pkg.name);
389
+ remaining.delete(pkg.name);
390
+ }
391
+ }
357
392
 
358
- const results = await pAll(tasks);
359
- const declarationErrors = results.flatMap((r) => r.declarationErrors);
393
+ const declarationErrors: string[] = [];
394
+ for (const layer of ordered) {
395
+ const tasks = layer.flatMap((pkg) =>
396
+ CONTEXT_CLIENTS.map((client) => () =>
397
+ buildContextClient(pkg, rootDir, client, cache, noCache),
398
+ ),
399
+ );
400
+ const results = await pAll(tasks);
401
+ for (const r of results) declarationErrors.push(...r.declarationErrors);
402
+ }
360
403
 
361
404
  if (declarationErrors.length > 0) {
362
405
  console.warn("\n\x1b[33mType declaration errors:\x1b[0m");
@@ -73,6 +73,7 @@ export function generateShellHtml(
73
73
  appName: string,
74
74
  manifest?: { title: string; favicon?: string },
75
75
  initial?: { file: string; hash: string },
76
+ stylesHash?: string,
76
77
  ): string {
77
78
  // Initial bundle carries framework, public modules, and PlatformApp re-export.
78
79
  // No importmap — single Bun.build with splitting:true inlines + dedups everything
@@ -81,14 +82,19 @@ export function generateShellHtml(
81
82
  if (!initialUrl) {
82
83
  throw new Error("generateShellHtml: initial bundle missing from manifest");
83
84
  }
85
+ // Append the styles content hash as a query string. Filenames are stable
86
+ // (/styles.css, /theme.css) but their content changes between builds; the
87
+ // hash invalidates the browser cache exactly when the content changes,
88
+ // letting us serve them with `Cache-Control: immutable`.
89
+ const stylesQs = stylesHash ? `?v=${stylesHash.slice(0, 16)}` : "";
84
90
  return `<!doctype html>
85
91
  <html lang="en">
86
92
  <head>
87
93
  <meta charset="UTF-8" />
88
94
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
89
95
  <title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json">` : ""}
90
- <link rel="stylesheet" href="/styles.css" />
91
- <link rel="stylesheet" href="/theme.css" />
96
+ <link rel="stylesheet" href="/styles.css${stylesQs}" />
97
+ <link rel="stylesheet" href="/theme.css${stylesQs}" />
92
98
  <link rel="modulepreload" href="${initialUrl}" />
93
99
  </head>
94
100
  <body>
@@ -148,7 +154,6 @@ function serveFile(
148
154
  // without this, every restart invalidates every signed URL in the open page).
149
155
  // Prod uses ARC_MODULE_SECRET if set, otherwise a random UUID per process.
150
156
  let MODULE_SIG_SECRET: string = process.env.ARC_MODULE_SECRET ?? "";
151
- const MODULE_SIG_TTL = 3600; // 1 hour
152
157
 
153
158
  function ensureModuleSigSecret(ws: WorkspaceInfo, devMode: boolean): void {
154
159
  if (MODULE_SIG_SECRET) return;
@@ -163,29 +168,31 @@ function ensureModuleSigSecret(ws: WorkspaceInfo, devMode: boolean): void {
163
168
  }
164
169
 
165
170
  /**
166
- * Signed URL for a token-group bundle. HMAC payload binds the filename so
167
- * a sig minted for `/browser/admin.<hash>.js` cannot be replayed for any
168
- * other file. Shared chunks (chunk-<hash>.js) are NEVER signed — their
169
- * filenames are content-hashed and they don't carry private code on their
170
- * own (group entries side-effect-register the modules).
171
+ * Signed URL for a token-group bundle. HMAC binds the filename so a sig minted
172
+ * for `/browser/admin.<hash>.js` cannot be replayed for any other file. Shared
173
+ * chunks (`chunk-<hash>.js`) are NEVER signed — their names are content-hashed
174
+ * and they don't carry private code on their own (group entries side-effect-
175
+ * register the modules).
176
+ *
177
+ * The signature is intentionally NOT time-bound. A per-request TTL would
178
+ * mutate the URL on every `/api/modules` poll, blowing the browser cache for
179
+ * the (potentially multi-megabyte) protected bundle. Without `exp`:
180
+ * - Content hash in the filename invalidates URLs on deploy.
181
+ * - In-process secret rotation (new UUID on restart) rotates all sigs.
182
+ * - Threat model: a stolen sig only buys access to the JS code itself —
183
+ * all runtime API calls re-validate the JWT independently.
171
184
  */
172
185
  function signGroupUrl(file: string): string {
173
- const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
174
186
  const hasher = new Bun.CryptoHasher("sha256");
175
- hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
187
+ hasher.update(`${file}:${MODULE_SIG_SECRET}`);
176
188
  const sig = hasher.digest("hex").slice(0, 16);
177
- return `/browser/${file}?sig=${sig}&exp=${exp}`;
189
+ return `/browser/${file}?sig=${sig}`;
178
190
  }
179
191
 
180
- function verifyGroupSignature(
181
- file: string,
182
- sig: string | null,
183
- exp: string | null,
184
- ): boolean {
185
- if (!sig || !exp) return false;
186
- if (Number(exp) < Date.now() / 1000) return false;
192
+ function verifyGroupSignature(file: string, sig: string | null): boolean {
193
+ if (!sig) return false;
187
194
  const hasher = new Bun.CryptoHasher("sha256");
188
- hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
195
+ hasher.update(`${file}:${MODULE_SIG_SECRET}`);
189
196
  return hasher.digest("hex").slice(0, 16) === sig;
190
197
  }
191
198
 
@@ -311,8 +318,7 @@ function staticFilesHandler(
311
318
  );
312
319
  if (isGroupEntry) {
313
320
  const sig = url.searchParams.get("sig");
314
- const exp = url.searchParams.get("exp");
315
- if (!verifyGroupSignature(file, sig, exp)) {
321
+ if (!verifyGroupSignature(file, sig)) {
316
322
  return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
317
323
  }
318
324
  }
@@ -322,14 +328,34 @@ function staticFilesHandler(
322
328
  "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
323
329
  });
324
330
  }
331
+ // Locales (compiled .po → .json) — short TTL with SWR; catalogs change
332
+ // build-to-build but rarely within a session, and they're tiny.
325
333
  if (path.startsWith("/locales/"))
326
- return serveFile(join(ws.arcDir, path.slice(1)), ctx.corsHeaders);
334
+ return serveFile(join(ws.arcDir, path.slice(1)), {
335
+ ...ctx.corsHeaders,
336
+ "Cache-Control": devMode ? "no-cache" : "max-age=300,stale-while-revalidate=3600",
337
+ });
338
+ // Browser assets (SQLite WASM worker, etc.) — content-addressed in their
339
+ // source paths, safe to cache forever in prod.
327
340
  if (path.startsWith("/assets/"))
328
- return serveFile(join(ws.assetsDir, path.slice(8)), ctx.corsHeaders);
341
+ return serveFile(join(ws.assetsDir, path.slice(8)), {
342
+ ...ctx.corsHeaders,
343
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
344
+ });
345
+ // /styles.css and /theme.css have stable URLs but their CONTENT changes
346
+ // between builds. HTML references them with `?v=<stylesHash>` (see
347
+ // generateShellHtml), so the URL changes on rebuild and immutable caching
348
+ // is safe — the handler ignores the query string itself.
329
349
  if (path === "/styles.css")
330
- return serveFile(join(ws.arcDir, "styles.css"), ctx.corsHeaders);
350
+ return serveFile(join(ws.arcDir, "styles.css"), {
351
+ ...ctx.corsHeaders,
352
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
353
+ });
331
354
  if (path === "/theme.css")
332
- return serveFile(join(ws.arcDir, "theme.css"), ctx.corsHeaders);
355
+ return serveFile(join(ws.arcDir, "theme.css"), {
356
+ ...ctx.corsHeaders,
357
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
358
+ });
333
359
 
334
360
  // Serve manifest.json from root dir
335
361
  if ((path === "/manifest.json" || path === "/manifest.webmanifest") && ws.manifest) {
@@ -442,7 +468,7 @@ export async function startPlatformServer(
442
468
  // Recompute on every request — manifest.initial.hash changes when public
443
469
  // modules are rebuilt in dev, and we want the new URL in the HTML.
444
470
  const getShellHtml = (): string =>
445
- generateShellHtml(ws.appName, ws.manifest, manifest?.initial);
471
+ generateShellHtml(ws.appName, ws.manifest, manifest?.initial, manifest?.stylesHash);
446
472
  const sseClients = new Set<ReadableStreamDefaultController>();
447
473
 
448
474
  const notifyReload = (m: BuildManifest) => {