@arcote.tech/arc-cli 0.7.2 → 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
@@ -31486,11 +31486,12 @@ async function createArcServer(config) {
31486
31486
  init_i18n();
31487
31487
  import { existsSync as existsSync18, mkdirSync as mkdirSync14 } from "fs";
31488
31488
  import { join as join20 } from "path";
31489
- function generateShellHtml(appName, manifest, initial) {
31489
+ function generateShellHtml(appName, manifest, initial, stylesHash) {
31490
31490
  const initialUrl = initial ? `/browser/${initial.file}` : null;
31491
31491
  if (!initialUrl) {
31492
31492
  throw new Error("generateShellHtml: initial bundle missing from manifest");
31493
31493
  }
31494
+ const stylesQs = stylesHash ? `?v=${stylesHash.slice(0, 16)}` : "";
31494
31495
  return `<!doctype html>
31495
31496
  <html lang="en">
31496
31497
  <head>
@@ -31499,8 +31500,8 @@ function generateShellHtml(appName, manifest, initial) {
31499
31500
  <title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `
31500
31501
  <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `
31501
31502
  <link rel="manifest" href="/manifest.json">` : ""}
31502
- <link rel="stylesheet" href="/styles.css" />
31503
- <link rel="stylesheet" href="/theme.css" />
31503
+ <link rel="stylesheet" href="/styles.css${stylesQs}" />
31504
+ <link rel="stylesheet" href="/theme.css${stylesQs}" />
31504
31505
  <link rel="modulepreload" href="${initialUrl}" />
31505
31506
  </head>
31506
31507
  <body>
@@ -31539,7 +31540,6 @@ function serveFile(filePath, headers = {}) {
31539
31540
  });
31540
31541
  }
31541
31542
  var MODULE_SIG_SECRET = process.env.ARC_MODULE_SECRET ?? "";
31542
- var MODULE_SIG_TTL = 3600;
31543
31543
  function ensureModuleSigSecret(ws, devMode) {
31544
31544
  if (MODULE_SIG_SECRET)
31545
31545
  return;
@@ -31552,19 +31552,16 @@ function ensureModuleSigSecret(ws, devMode) {
31552
31552
  }
31553
31553
  }
31554
31554
  function signGroupUrl(file) {
31555
- const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
31556
31555
  const hasher = new Bun.CryptoHasher("sha256");
31557
- hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
31556
+ hasher.update(`${file}:${MODULE_SIG_SECRET}`);
31558
31557
  const sig = hasher.digest("hex").slice(0, 16);
31559
- return `/browser/${file}?sig=${sig}&exp=${exp}`;
31558
+ return `/browser/${file}?sig=${sig}`;
31560
31559
  }
31561
- function verifyGroupSignature(file, sig, exp) {
31562
- if (!sig || !exp)
31563
- return false;
31564
- if (Number(exp) < Date.now() / 1000)
31560
+ function verifyGroupSignature(file, sig) {
31561
+ if (!sig)
31565
31562
  return false;
31566
31563
  const hasher = new Bun.CryptoHasher("sha256");
31567
- hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
31564
+ hasher.update(`${file}:${MODULE_SIG_SECRET}`);
31568
31565
  return hasher.digest("hex").slice(0, 16) === sig;
31569
31566
  }
31570
31567
  function decodeTokenPayload(jwt2) {
@@ -31654,8 +31651,7 @@ function staticFilesHandler(ws, devMode, getManifest) {
31654
31651
  const isGroupEntry = Object.values(manifest.groups).some((g3) => g3.file === file);
31655
31652
  if (isGroupEntry) {
31656
31653
  const sig = url.searchParams.get("sig");
31657
- const exp = url.searchParams.get("exp");
31658
- if (!verifyGroupSignature(file, sig, exp)) {
31654
+ if (!verifyGroupSignature(file, sig)) {
31659
31655
  return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
31660
31656
  }
31661
31657
  }
@@ -31665,13 +31661,25 @@ function staticFilesHandler(ws, devMode, getManifest) {
31665
31661
  });
31666
31662
  }
31667
31663
  if (path4.startsWith("/locales/"))
31668
- 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
+ });
31669
31668
  if (path4.startsWith("/assets/"))
31670
- 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
+ });
31671
31673
  if (path4 === "/styles.css")
31672
- 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
+ });
31673
31678
  if (path4 === "/theme.css")
31674
- 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
+ });
31675
31683
  if ((path4 === "/manifest.json" || path4 === "/manifest.webmanifest") && ws.manifest) {
31676
31684
  return serveFile(ws.manifest.path, ctx.corsHeaders);
31677
31685
  }
@@ -31750,7 +31758,7 @@ async function startPlatformServer(opts) {
31750
31758
  const setManifest = (m4) => {
31751
31759
  manifest = m4;
31752
31760
  };
31753
- const getShellHtml = () => generateShellHtml(ws.appName, ws.manifest, manifest?.initial);
31761
+ const getShellHtml = () => generateShellHtml(ws.appName, ws.manifest, manifest?.initial, manifest?.stylesHash);
31754
31762
  const sseClients = new Set;
31755
31763
  const notifyReload = (m4) => {
31756
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.2",
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.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.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",
@@ -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) => {