@arcote.tech/arc-cli 0.7.2 → 0.7.4

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
@@ -26625,7 +26625,7 @@ ${colors3.yellow}Type declaration errors:${colors3.reset}`);
26625
26625
 
26626
26626
  // src/platform/shared.ts
26627
26627
  import { copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync9, readdirSync as readdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync9 } from "fs";
26628
- import { dirname as dirname6, join as join11 } from "path";
26628
+ import { dirname as dirname7, join as join11 } from "path";
26629
26629
 
26630
26630
  // src/builder/module-builder.ts
26631
26631
  import { execSync } from "child_process";
@@ -26637,17 +26637,28 @@ import {
26637
26637
  rmSync,
26638
26638
  writeFileSync as writeFileSync6
26639
26639
  } from "fs";
26640
- import { basename as basename2, dirname as dirname5, join as join8, relative as relative3 } from "path";
26640
+ import { basename as basename2, dirname as dirname6, join as join8, relative as relative3 } from "path";
26641
26641
  init_i18n();
26642
26642
  init_compile();
26643
26643
 
26644
26644
  // src/builder/build-cache.ts
26645
26645
  import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
26646
- import { join as join6 } from "path";
26647
- var CACHE_VERSION = 2;
26646
+ import { join as join6, dirname as dirname5 } from "path";
26647
+ import { fileURLToPath as fileURLToPath5 } from "url";
26648
+ var CACHE_SCHEMA_VERSION = 3;
26648
26649
  var CACHE_FILE = ".build-cache.json";
26650
+ function readCliVersion() {
26651
+ try {
26652
+ const here = dirname5(fileURLToPath5(import.meta.url));
26653
+ const pkg = JSON.parse(readFileSync5(join6(here, "..", "package.json"), "utf-8"));
26654
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
26655
+ } catch {
26656
+ return "unknown";
26657
+ }
26658
+ }
26659
+ var CACHE_FINGERPRINT = `${CACHE_SCHEMA_VERSION}:${readCliVersion()}`;
26649
26660
  function emptyCache() {
26650
- return { version: CACHE_VERSION, units: {} };
26661
+ return { version: CACHE_FINGERPRINT, units: {} };
26651
26662
  }
26652
26663
  function loadBuildCache(arcDir) {
26653
26664
  const path4 = join6(arcDir, CACHE_FILE);
@@ -26655,7 +26666,7 @@ function loadBuildCache(arcDir) {
26655
26666
  return emptyCache();
26656
26667
  try {
26657
26668
  const raw = JSON.parse(readFileSync5(path4, "utf-8"));
26658
- if (raw?.version !== CACHE_VERSION || typeof raw.units !== "object") {
26669
+ if (raw?.version !== CACHE_FINGERPRINT || typeof raw.units !== "object") {
26659
26670
  return emptyCache();
26660
26671
  }
26661
26672
  return raw;
@@ -26963,7 +26974,7 @@ async function buildContextClient(pkg, rootDir, client, cache, noCache) {
26963
26974
  }
26964
26975
  const globalsContent = Object.entries(client.defines).map(([k, v]) => `declare const ${k}: ${v};`).join(`
26965
26976
  `);
26966
- const declResult = await buildTypeDeclarations([pkg.entrypoint], outDir, dirname5(pkg.entrypoint), globalsContent);
26977
+ const declResult = await buildTypeDeclarations([pkg.entrypoint], outDir, dirname6(pkg.entrypoint), globalsContent);
26967
26978
  const declarationErrors = !declResult.success && declResult.errors.length > 0 ? declResult.errors.map((e) => `[${pkg.name}/${client.name}] ${e}`) : [];
26968
26979
  const outputHash = sha256OfDir(outDir);
26969
26980
  updateCache(cache, unitId, inputHash, { outputHash });
@@ -27527,7 +27538,7 @@ function resolveWorkspace() {
27527
27538
  err("No package.json found");
27528
27539
  process.exit(1);
27529
27540
  }
27530
- const rootDir = dirname6(packageJsonPath);
27541
+ const rootDir = dirname7(packageJsonPath);
27531
27542
  const rootPkg = JSON.parse(readFileSync10(packageJsonPath, "utf-8"));
27532
27543
  const appName = rootPkg.name ?? "Arc App";
27533
27544
  const arcDir = join11(rootDir, ".arc", "platform");
@@ -27704,7 +27715,7 @@ async function copyBrowserAssets(ws, cache, noCache) {
27704
27715
  const outputHashes = {};
27705
27716
  for (const asset of assets) {
27706
27717
  const dest = join11(ws.assetsDir, asset.to);
27707
- mkdirSync9(dirname6(dest), { recursive: true });
27718
+ mkdirSync9(dirname7(dest), { recursive: true });
27708
27719
  copyFileSync(asset.src, dest);
27709
27720
  outputHashes[asset.to] = sha256Hex(readFileSync10(dest));
27710
27721
  }
@@ -27763,8 +27774,8 @@ async function platformBuild(opts = {}) {
27763
27774
 
27764
27775
  // src/commands/platform-deploy.ts
27765
27776
  import { existsSync as existsSync17, readFileSync as readFileSync14 } from "fs";
27766
- import { dirname as dirname8, join as join19 } from "path";
27767
- import { fileURLToPath as fileURLToPath6 } from "url";
27777
+ import { dirname as dirname9, join as join19 } from "path";
27778
+ import { fileURLToPath as fileURLToPath7 } from "url";
27768
27779
 
27769
27780
  // src/deploy/bootstrap.ts
27770
27781
  var {spawn: spawn4 } = globalThis.Bun;
@@ -28968,8 +28979,8 @@ import {
28968
28979
  writeFileSync as writeFileSync14
28969
28980
  } from "fs";
28970
28981
  import { tmpdir as tmpdir3 } from "os";
28971
- import { dirname as dirname7, join as join18 } from "path";
28972
- import { fileURLToPath as fileURLToPath5 } from "url";
28982
+ import { dirname as dirname8, join as join18 } from "path";
28983
+ import { fileURLToPath as fileURLToPath6 } from "url";
28973
28984
 
28974
28985
  // src/deploy/image-template.ts
28975
28986
  function generateDockerfile(inputs) {
@@ -29061,8 +29072,8 @@ function embedCliBundle(ws) {
29061
29072
  copyFileSync2(source, target);
29062
29073
  }
29063
29074
  function locateCliBundle() {
29064
- const here = fileURLToPath5(import.meta.url);
29065
- let cur = dirname7(here);
29075
+ const here = fileURLToPath6(import.meta.url);
29076
+ let cur = dirname8(here);
29066
29077
  while (cur !== "/" && cur !== "") {
29067
29078
  const candidate = join18(cur, "package.json");
29068
29079
  if (existsSync16(candidate)) {
@@ -29080,7 +29091,7 @@ function locateCliBundle() {
29080
29091
  throw e;
29081
29092
  }
29082
29093
  }
29083
- const parent = dirname7(cur);
29094
+ const parent = dirname8(cur);
29084
29095
  if (parent === cur)
29085
29096
  break;
29086
29097
  cur = parent;
@@ -29969,7 +29980,7 @@ async function platformDeploy(envArg, options = {}) {
29969
29980
  log2("Inspecting remote server...");
29970
29981
  const state = await detectRemoteState(cfg);
29971
29982
  log2(`Remote state: ${state.kind}`);
29972
- const cliVersion = readCliVersion();
29983
+ const cliVersion = readCliVersion2();
29973
29984
  const configHash = await hashDeployConfig(ws.rootDir);
29974
29985
  await bootstrap({
29975
29986
  cfg,
@@ -30001,10 +30012,10 @@ async function platformDeploy(envArg, options = {}) {
30001
30012
  }
30002
30013
  }
30003
30014
  }
30004
- function readCliVersion() {
30015
+ function readCliVersion2() {
30005
30016
  try {
30006
- let cur = dirname8(fileURLToPath6(import.meta.url));
30007
- const root = dirname8(cur).startsWith("/") ? "/" : ".";
30017
+ let cur = dirname9(fileURLToPath7(import.meta.url));
30018
+ const root = dirname9(cur).startsWith("/") ? "/" : ".";
30008
30019
  while (cur !== root && cur !== "") {
30009
30020
  const candidate = join19(cur, "package.json");
30010
30021
  if (existsSync17(candidate)) {
@@ -30013,7 +30024,7 @@ function readCliVersion() {
30013
30024
  return pkg.version ?? "unknown";
30014
30025
  }
30015
30026
  }
30016
- const parent = dirname8(cur);
30027
+ const parent = dirname9(cur);
30017
30028
  if (parent === cur)
30018
30029
  break;
30019
30030
  cur = parent;
@@ -31486,11 +31497,12 @@ async function createArcServer(config) {
31486
31497
  init_i18n();
31487
31498
  import { existsSync as existsSync18, mkdirSync as mkdirSync14 } from "fs";
31488
31499
  import { join as join20 } from "path";
31489
- function generateShellHtml(appName, manifest, initial) {
31500
+ function generateShellHtml(appName, manifest, initial, stylesHash) {
31490
31501
  const initialUrl = initial ? `/browser/${initial.file}` : null;
31491
31502
  if (!initialUrl) {
31492
31503
  throw new Error("generateShellHtml: initial bundle missing from manifest");
31493
31504
  }
31505
+ const stylesQs = stylesHash ? `?v=${stylesHash.slice(0, 16)}` : "";
31494
31506
  return `<!doctype html>
31495
31507
  <html lang="en">
31496
31508
  <head>
@@ -31499,8 +31511,8 @@ function generateShellHtml(appName, manifest, initial) {
31499
31511
  <title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `
31500
31512
  <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `
31501
31513
  <link rel="manifest" href="/manifest.json">` : ""}
31502
- <link rel="stylesheet" href="/styles.css" />
31503
- <link rel="stylesheet" href="/theme.css" />
31514
+ <link rel="stylesheet" href="/styles.css${stylesQs}" />
31515
+ <link rel="stylesheet" href="/theme.css${stylesQs}" />
31504
31516
  <link rel="modulepreload" href="${initialUrl}" />
31505
31517
  </head>
31506
31518
  <body>
@@ -31539,7 +31551,6 @@ function serveFile(filePath, headers = {}) {
31539
31551
  });
31540
31552
  }
31541
31553
  var MODULE_SIG_SECRET = process.env.ARC_MODULE_SECRET ?? "";
31542
- var MODULE_SIG_TTL = 3600;
31543
31554
  function ensureModuleSigSecret(ws, devMode) {
31544
31555
  if (MODULE_SIG_SECRET)
31545
31556
  return;
@@ -31552,19 +31563,16 @@ function ensureModuleSigSecret(ws, devMode) {
31552
31563
  }
31553
31564
  }
31554
31565
  function signGroupUrl(file) {
31555
- const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
31556
31566
  const hasher = new Bun.CryptoHasher("sha256");
31557
- hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
31567
+ hasher.update(`${file}:${MODULE_SIG_SECRET}`);
31558
31568
  const sig = hasher.digest("hex").slice(0, 16);
31559
- return `/browser/${file}?sig=${sig}&exp=${exp}`;
31569
+ return `/browser/${file}?sig=${sig}`;
31560
31570
  }
31561
- function verifyGroupSignature(file, sig, exp) {
31562
- if (!sig || !exp)
31563
- return false;
31564
- if (Number(exp) < Date.now() / 1000)
31571
+ function verifyGroupSignature(file, sig) {
31572
+ if (!sig)
31565
31573
  return false;
31566
31574
  const hasher = new Bun.CryptoHasher("sha256");
31567
- hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
31575
+ hasher.update(`${file}:${MODULE_SIG_SECRET}`);
31568
31576
  return hasher.digest("hex").slice(0, 16) === sig;
31569
31577
  }
31570
31578
  function decodeTokenPayload(jwt2) {
@@ -31654,8 +31662,7 @@ function staticFilesHandler(ws, devMode, getManifest) {
31654
31662
  const isGroupEntry = Object.values(manifest.groups).some((g3) => g3.file === file);
31655
31663
  if (isGroupEntry) {
31656
31664
  const sig = url.searchParams.get("sig");
31657
- const exp = url.searchParams.get("exp");
31658
- if (!verifyGroupSignature(file, sig, exp)) {
31665
+ if (!verifyGroupSignature(file, sig)) {
31659
31666
  return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
31660
31667
  }
31661
31668
  }
@@ -31665,13 +31672,25 @@ function staticFilesHandler(ws, devMode, getManifest) {
31665
31672
  });
31666
31673
  }
31667
31674
  if (path4.startsWith("/locales/"))
31668
- return serveFile(join20(ws.arcDir, path4.slice(1)), ctx.corsHeaders);
31675
+ return serveFile(join20(ws.arcDir, path4.slice(1)), {
31676
+ ...ctx.corsHeaders,
31677
+ "Cache-Control": devMode ? "no-cache" : "max-age=300,stale-while-revalidate=3600"
31678
+ });
31669
31679
  if (path4.startsWith("/assets/"))
31670
- return serveFile(join20(ws.assetsDir, path4.slice(8)), ctx.corsHeaders);
31680
+ return serveFile(join20(ws.assetsDir, path4.slice(8)), {
31681
+ ...ctx.corsHeaders,
31682
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable"
31683
+ });
31671
31684
  if (path4 === "/styles.css")
31672
- return serveFile(join20(ws.arcDir, "styles.css"), ctx.corsHeaders);
31685
+ return serveFile(join20(ws.arcDir, "styles.css"), {
31686
+ ...ctx.corsHeaders,
31687
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable"
31688
+ });
31673
31689
  if (path4 === "/theme.css")
31674
- return serveFile(join20(ws.arcDir, "theme.css"), ctx.corsHeaders);
31690
+ return serveFile(join20(ws.arcDir, "theme.css"), {
31691
+ ...ctx.corsHeaders,
31692
+ "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable"
31693
+ });
31675
31694
  if ((path4 === "/manifest.json" || path4 === "/manifest.webmanifest") && ws.manifest) {
31676
31695
  return serveFile(ws.manifest.path, ctx.corsHeaders);
31677
31696
  }
@@ -31750,7 +31769,7 @@ async function startPlatformServer(opts) {
31750
31769
  const setManifest = (m4) => {
31751
31770
  manifest = m4;
31752
31771
  };
31753
- const getShellHtml = () => generateShellHtml(ws.appName, ws.manifest, manifest?.initial);
31772
+ const getShellHtml = () => generateShellHtml(ws.appName, ws.manifest, manifest?.initial, manifest?.stylesHash);
31754
31773
  const sseClients = new Set;
31755
31774
  const notifyReload = (m4) => {
31756
31775
  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.2",
3
+ "version": "0.7.4",
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.2",
16
- "@arcote.tech/arc-ds": "^0.7.2",
17
- "@arcote.tech/arc-react": "^0.7.2",
18
- "@arcote.tech/arc-host": "^0.7.2",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.2",
20
- "@arcote.tech/platform": "^0.7.2",
15
+ "@arcote.tech/arc": "^0.7.4",
16
+ "@arcote.tech/arc-ds": "^0.7.4",
17
+ "@arcote.tech/arc-react": "^0.7.4",
18
+ "@arcote.tech/arc-host": "^0.7.4",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.4",
20
+ "@arcote.tech/platform": "^0.7.4",
21
21
  "@clack/prompts": "^0.9.0",
22
22
  "commander": "^11.1.0",
23
23
  "chokidar": "^3.5.3",
@@ -1,11 +1,30 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { join } from "path";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
3
4
 
4
- // v2: switched from `modules-bundle` (one unit) to `modules-chunk:<name>`
5
- // (one unit per chunk group). Old v1 entries are irrelevant.
6
- const CACHE_VERSION = 2;
5
+ // Schema version bump if cache entry shape changes.
6
+ const CACHE_SCHEMA_VERSION = 3;
7
7
  const CACHE_FILE = ".build-cache.json";
8
8
 
9
+ /**
10
+ * Combined cache fingerprint: schema version + CLI version. Lets a newer CLI
11
+ * automatically invalidate dist/ produced by an older CLI even when source
12
+ * hashes match. Without this, a build-logic change in the CLI (e.g.
13
+ * externalization rules in buildContextClient) silently kept stale dist/
14
+ * around — see the v0.7.3 "creatorWorkspaces not found in context" incident.
15
+ */
16
+ function readCliVersion(): string {
17
+ try {
18
+ // Bundled CLI lives at `<pkg>/dist/index.js`; package.json is one level up.
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
21
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
22
+ } catch {
23
+ return "unknown";
24
+ }
25
+ }
26
+ const CACHE_FINGERPRINT = `${CACHE_SCHEMA_VERSION}:${readCliVersion()}`;
27
+
9
28
  export interface CacheEntry {
10
29
  inputHash: string;
11
30
  outputHash?: string;
@@ -13,22 +32,22 @@ export interface CacheEntry {
13
32
  }
14
33
 
15
34
  export interface BuildCache {
16
- version: typeof CACHE_VERSION;
35
+ version: string;
17
36
  units: Record<string, CacheEntry>;
18
37
  }
19
38
 
20
39
  function emptyCache(): BuildCache {
21
- return { version: CACHE_VERSION, units: {} };
40
+ return { version: CACHE_FINGERPRINT, units: {} };
22
41
  }
23
42
 
24
43
  /** Loads cache from .arc/platform/.build-cache.json. Returns empty cache on
25
- * missing file, parse error, or version mismatch. */
44
+ * missing file, parse error, or fingerprint mismatch (schema OR CLI version). */
26
45
  export function loadBuildCache(arcDir: string): BuildCache {
27
46
  const path = join(arcDir, CACHE_FILE);
28
47
  if (!existsSync(path)) return emptyCache();
29
48
  try {
30
49
  const raw = JSON.parse(readFileSync(path, "utf-8"));
31
- if (raw?.version !== CACHE_VERSION || typeof raw.units !== "object") {
50
+ if (raw?.version !== CACHE_FINGERPRINT || typeof raw.units !== "object") {
32
51
  return emptyCache();
33
52
  }
34
53
  return raw as BuildCache;
@@ -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) => {