@antasphere/chassis-server 1.2.0 → 1.3.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.
package/dist/app.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAIrD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAKpD,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,YAAY,CAAC;IACpB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,kGAAkG;IAClG,WAAW,EAAE,MAAM,CAAC;IACpB,6CAA6C;IAC7C,GAAG,EAAE,WAAW,CAAC;IACjB,sDAAsD;IACtD,GAAG,CAAC,EAAE,IAAI,CAAC;IACX,iEAAiE;IACjE,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;IAChC,2DAA2D;IAC3D,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IACtC,aAAa,CAAC,EAAE,IAAI,CAAC;IACrB,cAAc,CAAC,EAAE,iBAAiB,CAAC;IACnC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAAC;IAC/C,qGAAqG;IACrG,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IAC5C,sHAAsH;IACtH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;CAC3C;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAIpE;AAED;;;;;GAKG;AACH,wBAAsB,SAAS,CAAC,EAC9B,MAAM,EACN,KAAK,EACL,SAAS,EACT,WAAW,EACX,GAAG,EACH,GAAG,EACH,SAAS,EACT,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,IAAI,EACJ,cAAc,EACd,WAAW,EACX,SAAS,EACV,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAqIzB"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGrD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAYpD,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,YAAY,CAAC;IACpB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,kGAAkG;IAClG,WAAW,EAAE,MAAM,CAAC;IACpB,6CAA6C;IAC7C,GAAG,EAAE,WAAW,CAAC;IACjB,sDAAsD;IACtD,GAAG,CAAC,EAAE,IAAI,CAAC;IACX,iEAAiE;IACjE,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;IAChC,2DAA2D;IAC3D,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IACtC,aAAa,CAAC,EAAE,IAAI,CAAC;IACrB,cAAc,CAAC,EAAE,iBAAiB,CAAC;IACnC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAAC;IAC/C,qGAAqG;IACrG,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IAC5C,sHAAsH;IACtH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;CAC3C;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAIpE;AAED;;;;;GAKG;AACH,wBAAsB,SAAS,CAAC,EAC9B,MAAM,EACN,KAAK,EACL,SAAS,EACT,WAAW,EACX,GAAG,EACH,GAAG,EACH,SAAS,EACT,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,IAAI,EACJ,cAAc,EACd,WAAW,EACX,SAAS,EACV,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA4IzB"}
package/dist/app.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import { Hono } from 'hono';
2
2
  import { HTTPException } from 'hono/http-exception';
3
- import { serveStatic } from '@hono/node-server/serve-static';
4
3
  import { readFile } from 'node:fs/promises';
5
4
  import { join } from 'node:path';
6
5
  import { requestId } from './middleware/index.js';
7
- import { buildCsp, inlineScriptHashes, securityHeaders } from './middleware/index.js';
6
+ import { REVALIDATE_CACHE_CONTROL, buildCsp, inlineScriptHashes, loadSpaFallback, securityHeaders, staticAssets } from './middleware/index.js';
8
7
  import { healthRoutes } from './routes/index.js';
9
8
  /**
10
9
  * The SQLSTATE of a thrown Postgres error, when it is one. node-postgres puts
@@ -130,10 +129,17 @@ export async function createApp({ logger, state, publicDir, displayName, api, mc
130
129
  // static assets and the SPA fallback, after every credentialed surface.
131
130
  if (publicRoutes)
132
131
  app.route('/', publicRoutes);
133
- app.use('*', serveStatic({ root: publicDir }));
132
+ // The dashboard build: hashed assets cached for a year, everything else
133
+ // revalidated, precompressed siblings served when accepted (PRDCT-3584).
134
+ app.use('*', staticAssets({ publicDir }));
135
+ // The SPA fallback is the application's HTML under any route: kept by the
136
+ // browser but revalidated on every use (a strong ETag, a 304 when it still
137
+ // holds), precompressed when the build wrote the sibling.
138
+ const fallback = indexHtml ? await loadSpaFallback(publicDir, indexHtml) : null;
134
139
  app.get('*', (c) => {
135
- if (indexHtml)
136
- return c.html(indexHtml);
140
+ if (fallback)
141
+ return fallback.respond(c);
142
+ c.header('Cache-Control', REVALIDATE_CACHE_CONTROL);
137
143
  return c.html(`<!doctype html><title>${displayName}</title><h1>${displayName} API is running</h1>` +
138
144
  '<p>No dashboard build found. API health: <a href="/healthz">/healthz</a></p>');
139
145
  });
package/dist/app.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
@@ -9,4 +9,5 @@ export { MAX_JSON_DEPTH, exceedsJsonDepth, jsonDepthLimit } from './json-depth.j
9
9
  export { noStoreAuthenticated } from './no-store.js';
10
10
  export { isPublicOauthPath, oauthPublicEndpoints } from './oauth-public.js';
11
11
  export { buildCsp, hstsValue, inlineScriptHashes, securityHeaders } from './security-headers.js';
12
+ export { IMMUTABLE_ASSET_PREFIX, IMMUTABLE_CACHE_CONTROL, REVALIDATE_CACHE_CONTROL, type SpaFallback, type StaticAssetsOptions, acceptedEncodings, loadSpaFallback, staticAssets, staticCacheControl } from './static-assets.js';
12
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,cAAc,EACd,WAAW,EACX,WAAW,EACX,eAAe,EACf,WAAW,EACX,uBAAuB,EACxB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,kBAAkB,EAClB,kBAAkB,EAClB,SAAS,EACV,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,KAAK,qBAAqB,EAAE,KAAK,SAAS,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC7G,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,cAAc,EACd,WAAW,EACX,WAAW,EACX,eAAe,EACf,WAAW,EACX,uBAAuB,EACxB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,kBAAkB,EAClB,kBAAkB,EAClB,SAAS,EACV,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,KAAK,qBAAqB,EAAE,KAAK,SAAS,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC7G,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACjG,OAAO,EACL,sBAAsB,EACtB,uBAAuB,EACvB,wBAAwB,EACxB,KAAK,WAAW,EAChB,KAAK,mBAAmB,EACxB,iBAAiB,EACjB,eAAe,EACf,YAAY,EACZ,kBAAkB,EACnB,MAAM,oBAAoB,CAAC"}
@@ -9,4 +9,5 @@ export { MAX_JSON_DEPTH, exceedsJsonDepth, jsonDepthLimit } from './json-depth.j
9
9
  export { noStoreAuthenticated } from './no-store.js';
10
10
  export { isPublicOauthPath, oauthPublicEndpoints } from './oauth-public.js';
11
11
  export { buildCsp, hstsValue, inlineScriptHashes, securityHeaders } from './security-headers.js';
12
+ export { IMMUTABLE_ASSET_PREFIX, IMMUTABLE_CACHE_CONTROL, REVALIDATE_CACHE_CONTROL, acceptedEncodings, loadSpaFallback, staticAssets, staticCacheControl } from './static-assets.js';
12
13
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,cAAc,EACd,WAAW,EACX,WAAW,EACX,eAAe,EACf,WAAW,EACX,uBAAuB,EACxB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAML,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,kBAAkB,EAClB,kBAAkB,EAClB,SAAS,EACV,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAA8C,oBAAoB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC7G,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,cAAc,EACd,WAAW,EACX,WAAW,EACX,eAAe,EACf,WAAW,EACX,uBAAuB,EACxB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAML,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,kBAAkB,EAClB,kBAAkB,EAClB,SAAS,EACV,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAA8C,oBAAoB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC7G,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACjG,OAAO,EACL,sBAAsB,EACtB,uBAAuB,EACvB,wBAAwB,EAGxB,iBAAiB,EACjB,eAAe,EACf,YAAY,EACZ,kBAAkB,EACnB,MAAM,oBAAoB,CAAC"}
@@ -0,0 +1,63 @@
1
+ import type { Context, MiddlewareHandler } from 'hono';
2
+ /**
3
+ * The dashboard build, served so the browser keeps it (PRDCT-3584).
4
+ *
5
+ * Before this middleware the SPA's files went out with no `Cache-Control`,
6
+ * no validator and no compression: every visit to a tool re-downloaded the
7
+ * whole dashboard (about 1.2 MB of JavaScript in a hundred files on the
8
+ * team stack, measured 2026-10-17), and the splash the person stared at
9
+ * was that download. Two rules fix it, both on the build's own shape:
10
+ *
11
+ * - `/_app/immutable/*` is CONTENT-HASHED by SvelteKit: a changed file has a
12
+ * new name, so the cached copy can never be stale. It is served with
13
+ * `public, max-age=31536000, immutable`: a second visit transfers none of
14
+ * it. Every other static path (the HTML, the icons, the manifest) is
15
+ * `no-cache`: kept, but revalidated on every use, so a release reaches
16
+ * the browser on its next load.
17
+ * - A `.br`, `.zst` or `.gz` sibling written at build time (the dashboard's
18
+ * adapter has `precompress: true`) is served in place of the file when
19
+ * the request accepts that encoding, with `Content-Encoding` and `Vary:
20
+ * Accept-Encoding`. The server compresses nothing at runtime, and the
21
+ * API is untouched: this middleware runs on the static files alone,
22
+ * after every credentialed route has answered.
23
+ *
24
+ * Nothing here is a security input. `immutable` never reaches a response
25
+ * that is not a hashed asset, so no user-bearing answer can be cached; the
26
+ * authenticated JSON keeps its `no-store` (middleware/no-store.ts).
27
+ */
28
+ /** The content-hashed prefix of a SvelteKit build. */
29
+ export declare const IMMUTABLE_ASSET_PREFIX = "/_app/immutable/";
30
+ export declare const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
31
+ export declare const REVALIDATE_CACHE_CONTROL = "no-cache";
32
+ /** The `Cache-Control` a static path of the build is served with. */
33
+ export declare function staticCacheControl(requestPath: string): string;
34
+ export interface StaticAssetsOptions {
35
+ /** The directory holding the built dashboard (`index.html`, `_app/`). */
36
+ publicDir: string;
37
+ }
38
+ /**
39
+ * Serve `publicDir` with the cache policy above and the precompressed
40
+ * siblings. Falls through (`next()`) on a path that is not a file there, so
41
+ * the SPA fallback behind it answers the application routes.
42
+ */
43
+ export declare function staticAssets(options: StaticAssetsOptions): MiddlewareHandler;
44
+ /** The encodings a request accepts, read from `Accept-Encoding` with any `;q=` weight ignored. */
45
+ export declare function acceptedEncodings(header: string | undefined): Set<string>;
46
+ export interface SpaFallback {
47
+ /** The strong ETag of the plain HTML (the variants derive theirs from it). */
48
+ readonly etag: string;
49
+ /** Answer the application's HTML for a route the build has no file for. */
50
+ respond(c: Context): Response;
51
+ }
52
+ /**
53
+ * The application's HTML under any route the build has no file for, served
54
+ * with the two things the bare `c.html()` lacked (the tier 1 verifier's two
55
+ * caveats, PRDCT-3584): a strong `ETag` so `no-cache` means "revalidate"
56
+ * (a `304` costs no bytes), and the `.br` / `.gz` sibling of `index.html`
57
+ * when the build wrote one and the request accepts it, so a deep link costs
58
+ * the same as the root on a front that compresses nothing (Cloud Run, a
59
+ * self-hosted instance with no proxy). The HTML is read once at boot, like
60
+ * `indexHtml` itself.
61
+ */
62
+ export declare function loadSpaFallback(publicDir: string, html: string): Promise<SpaFallback>;
63
+ //# sourceMappingURL=static-assets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"static-assets.d.ts","sourceRoot":"","sources":["../../src/middleware/static-assets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAMvD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,sDAAsD;AACtD,eAAO,MAAM,sBAAsB,qBAAqB,CAAC;AACzD,eAAO,MAAM,uBAAuB,wCAAwC,CAAC;AAC7E,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD,qEAAqE;AACrE,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,iBAAiB,CAgB5E;AAWD,kGAAkG;AAClG,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,GAAG,CAAC,MAAM,CAAC,CAOzE;AAED,MAAM,WAAW,WAAW;IAC1B,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,OAAO,CAAC,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC/B;AAED;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAoC3F"}
@@ -0,0 +1,125 @@
1
+ import { serveStatic } from '@hono/node-server/serve-static';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ /**
6
+ * The dashboard build, served so the browser keeps it (PRDCT-3584).
7
+ *
8
+ * Before this middleware the SPA's files went out with no `Cache-Control`,
9
+ * no validator and no compression: every visit to a tool re-downloaded the
10
+ * whole dashboard (about 1.2 MB of JavaScript in a hundred files on the
11
+ * team stack, measured 2026-10-17), and the splash the person stared at
12
+ * was that download. Two rules fix it, both on the build's own shape:
13
+ *
14
+ * - `/_app/immutable/*` is CONTENT-HASHED by SvelteKit: a changed file has a
15
+ * new name, so the cached copy can never be stale. It is served with
16
+ * `public, max-age=31536000, immutable`: a second visit transfers none of
17
+ * it. Every other static path (the HTML, the icons, the manifest) is
18
+ * `no-cache`: kept, but revalidated on every use, so a release reaches
19
+ * the browser on its next load.
20
+ * - A `.br`, `.zst` or `.gz` sibling written at build time (the dashboard's
21
+ * adapter has `precompress: true`) is served in place of the file when
22
+ * the request accepts that encoding, with `Content-Encoding` and `Vary:
23
+ * Accept-Encoding`. The server compresses nothing at runtime, and the
24
+ * API is untouched: this middleware runs on the static files alone,
25
+ * after every credentialed route has answered.
26
+ *
27
+ * Nothing here is a security input. `immutable` never reaches a response
28
+ * that is not a hashed asset, so no user-bearing answer can be cached; the
29
+ * authenticated JSON keeps its `no-store` (middleware/no-store.ts).
30
+ */
31
+ /** The content-hashed prefix of a SvelteKit build. */
32
+ export const IMMUTABLE_ASSET_PREFIX = '/_app/immutable/';
33
+ export const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
34
+ export const REVALIDATE_CACHE_CONTROL = 'no-cache';
35
+ /** The `Cache-Control` a static path of the build is served with. */
36
+ export function staticCacheControl(requestPath) {
37
+ return requestPath.startsWith(IMMUTABLE_ASSET_PREFIX) ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL;
38
+ }
39
+ /**
40
+ * Serve `publicDir` with the cache policy above and the precompressed
41
+ * siblings. Falls through (`next()`) on a path that is not a file there, so
42
+ * the SPA fallback behind it answers the application routes.
43
+ */
44
+ export function staticAssets(options) {
45
+ const serve = serveStatic({ root: options.publicDir, precompressed: true });
46
+ return async (c, next) => {
47
+ // The application's HTML itself (`/`, `/index.html`) is the SPA
48
+ // fallback's to answer, with its validator and its sibling; the static
49
+ // server would hand it out with neither (verifier round 2).
50
+ if (c.req.path === '/' || c.req.path === '/index.html')
51
+ return next();
52
+ const res = await serve(c, next);
53
+ // serveStatic answers a Response for a file it found and the result of
54
+ // `next()` otherwise; only the file gets the policy.
55
+ if (res instanceof Response) {
56
+ res.headers.set('Cache-Control', staticCacheControl(c.req.path));
57
+ if (!res.headers.has('Vary'))
58
+ res.headers.set('Vary', 'Accept-Encoding');
59
+ }
60
+ return res;
61
+ };
62
+ }
63
+ // ── The SPA fallback ────────────────────────────────────────────────────────
64
+ /** The encodings a precompressed sibling may carry, in the order they are preferred. */
65
+ const FALLBACK_ENCODINGS = [
66
+ { name: 'br', ext: '.br' },
67
+ { name: 'gzip', ext: '.gz' }
68
+ ];
69
+ /** The encodings a request accepts, read from `Accept-Encoding` with any `;q=` weight ignored. */
70
+ export function acceptedEncodings(header) {
71
+ return new Set((header ?? '')
72
+ .split(',')
73
+ .map((part) => part.split(';')[0].trim().toLowerCase())
74
+ .filter(Boolean));
75
+ }
76
+ /**
77
+ * The application's HTML under any route the build has no file for, served
78
+ * with the two things the bare `c.html()` lacked (the tier 1 verifier's two
79
+ * caveats, PRDCT-3584): a strong `ETag` so `no-cache` means "revalidate"
80
+ * (a `304` costs no bytes), and the `.br` / `.gz` sibling of `index.html`
81
+ * when the build wrote one and the request accepts it, so a deep link costs
82
+ * the same as the root on a front that compresses nothing (Cloud Run, a
83
+ * self-hosted instance with no proxy). The HTML is read once at boot, like
84
+ * `indexHtml` itself.
85
+ */
86
+ export async function loadSpaFallback(publicDir, html) {
87
+ const plain = Buffer.from(html, 'utf8');
88
+ const etag = `"${createHash('sha256').update(plain).digest('base64url').slice(0, 27)}"`;
89
+ const variants = new Map();
90
+ for (const { name, ext } of FALLBACK_ENCODINGS) {
91
+ try {
92
+ variants.set(name, await readFile(join(publicDir, `index.html${ext}`)));
93
+ }
94
+ catch {
95
+ // no sibling of that encoding: the plain HTML answers those requests
96
+ }
97
+ }
98
+ return {
99
+ etag,
100
+ respond(c) {
101
+ const accepted = acceptedEncodings(c.req.header('accept-encoding'));
102
+ const encoding = FALLBACK_ENCODINGS.find((e) => accepted.has(e.name) && variants.has(e.name))?.name;
103
+ // One validator per representation: a cached brotli body is never
104
+ // confirmed against a plain one.
105
+ const variantEtag = encoding ? etag.replace(/"$/, `-${encoding}"`) : etag;
106
+ // The type rides the 304 too: the node server would otherwise stamp a
107
+ // text/plain default on it (verifier round 2), and RFC 9110 allows the
108
+ // 304 to repeat what the 200 carries.
109
+ const headers = {
110
+ 'Cache-Control': REVALIDATE_CACHE_CONTROL,
111
+ 'Content-Type': 'text/html; charset=utf-8',
112
+ ETag: variantEtag,
113
+ Vary: 'Accept-Encoding'
114
+ };
115
+ if (c.req.header('if-none-match') === variantEtag) {
116
+ return c.body(null, 304, headers);
117
+ }
118
+ if (encoding)
119
+ headers['Content-Encoding'] = encoding;
120
+ const body = encoding ? variants.get(encoding) : plain;
121
+ return c.body(new Uint8Array(body), 200, headers);
122
+ }
123
+ };
124
+ }
125
+ //# sourceMappingURL=static-assets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"static-assets.js","sourceRoot":"","sources":["../../src/middleware/static-assets.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antasphere/chassis-server",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "repository": {
6
6
  "type": "git",
@@ -139,8 +139,8 @@
139
139
  "ulid": "^3.0.2",
140
140
  "yazl": "^3.3.1",
141
141
  "zod": "^4.1.0",
142
- "@antasphere/chassis-contract": "1.2.0",
143
- "@antasphere/chassis-db": "1.2.0"
142
+ "@antasphere/chassis-contract": "1.3.0",
143
+ "@antasphere/chassis-db": "1.3.0"
144
144
  },
145
145
  "devDependencies": {
146
146
  "@better-auth/core": "1.6.22",
package/src/app.ts CHANGED
@@ -2,13 +2,19 @@ import { Hono } from 'hono';
2
2
  import type { MiddlewareHandler } from 'hono';
3
3
  import { HTTPException } from 'hono/http-exception';
4
4
  import type { OpenAPIHono } from '@hono/zod-openapi';
5
- import { serveStatic } from '@hono/node-server/serve-static';
6
5
  import { readFile } from 'node:fs/promises';
7
6
  import { join } from 'node:path';
8
7
  import type { Logger } from './logger.js';
9
8
  import type { RuntimeState } from './util/index.js';
10
9
  import { requestId } from './middleware/index.js';
11
- import { buildCsp, inlineScriptHashes, securityHeaders } from './middleware/index.js';
10
+ import {
11
+ REVALIDATE_CACHE_CONTROL,
12
+ buildCsp,
13
+ inlineScriptHashes,
14
+ loadSpaFallback,
15
+ securityHeaders,
16
+ staticAssets
17
+ } from './middleware/index.js';
12
18
  import { healthRoutes } from './routes/index.js';
13
19
 
14
20
  export interface AppDeps {
@@ -214,10 +220,17 @@ export async function createApp({
214
220
  // static assets and the SPA fallback, after every credentialed surface.
215
221
  if (publicRoutes) app.route('/', publicRoutes);
216
222
 
217
- app.use('*', serveStatic({ root: publicDir }));
223
+ // The dashboard build: hashed assets cached for a year, everything else
224
+ // revalidated, precompressed siblings served when accepted (PRDCT-3584).
225
+ app.use('*', staticAssets({ publicDir }));
218
226
 
227
+ // The SPA fallback is the application's HTML under any route: kept by the
228
+ // browser but revalidated on every use (a strong ETag, a 304 when it still
229
+ // holds), precompressed when the build wrote the sibling.
230
+ const fallback = indexHtml ? await loadSpaFallback(publicDir, indexHtml) : null;
219
231
  app.get('*', (c) => {
220
- if (indexHtml) return c.html(indexHtml);
232
+ if (fallback) return fallback.respond(c);
233
+ c.header('Cache-Control', REVALIDATE_CACHE_CONTROL);
221
234
  return c.html(
222
235
  `<!doctype html><title>${displayName}</title><h1>${displayName} API is running</h1>` +
223
236
  '<p>No dashboard build found. API health: <a href="/healthz">/healthz</a></p>'
@@ -31,3 +31,14 @@ export { MAX_JSON_DEPTH, exceedsJsonDepth, jsonDepthLimit } from './json-depth.j
31
31
  export { noStoreAuthenticated } from './no-store.js';
32
32
  export { isPublicOauthPath, oauthPublicEndpoints } from './oauth-public.js';
33
33
  export { buildCsp, hstsValue, inlineScriptHashes, securityHeaders } from './security-headers.js';
34
+ export {
35
+ IMMUTABLE_ASSET_PREFIX,
36
+ IMMUTABLE_CACHE_CONTROL,
37
+ REVALIDATE_CACHE_CONTROL,
38
+ type SpaFallback,
39
+ type StaticAssetsOptions,
40
+ acceptedEncodings,
41
+ loadSpaFallback,
42
+ staticAssets,
43
+ staticCacheControl
44
+ } from './static-assets.js';
@@ -0,0 +1,144 @@
1
+ import type { Context, MiddlewareHandler } from 'hono';
2
+ import { serveStatic } from '@hono/node-server/serve-static';
3
+ import { createHash } from 'node:crypto';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+
7
+ /**
8
+ * The dashboard build, served so the browser keeps it (PRDCT-3584).
9
+ *
10
+ * Before this middleware the SPA's files went out with no `Cache-Control`,
11
+ * no validator and no compression: every visit to a tool re-downloaded the
12
+ * whole dashboard (about 1.2 MB of JavaScript in a hundred files on the
13
+ * team stack, measured 2026-10-17), and the splash the person stared at
14
+ * was that download. Two rules fix it, both on the build's own shape:
15
+ *
16
+ * - `/_app/immutable/*` is CONTENT-HASHED by SvelteKit: a changed file has a
17
+ * new name, so the cached copy can never be stale. It is served with
18
+ * `public, max-age=31536000, immutable`: a second visit transfers none of
19
+ * it. Every other static path (the HTML, the icons, the manifest) is
20
+ * `no-cache`: kept, but revalidated on every use, so a release reaches
21
+ * the browser on its next load.
22
+ * - A `.br`, `.zst` or `.gz` sibling written at build time (the dashboard's
23
+ * adapter has `precompress: true`) is served in place of the file when
24
+ * the request accepts that encoding, with `Content-Encoding` and `Vary:
25
+ * Accept-Encoding`. The server compresses nothing at runtime, and the
26
+ * API is untouched: this middleware runs on the static files alone,
27
+ * after every credentialed route has answered.
28
+ *
29
+ * Nothing here is a security input. `immutable` never reaches a response
30
+ * that is not a hashed asset, so no user-bearing answer can be cached; the
31
+ * authenticated JSON keeps its `no-store` (middleware/no-store.ts).
32
+ */
33
+
34
+ /** The content-hashed prefix of a SvelteKit build. */
35
+ export const IMMUTABLE_ASSET_PREFIX = '/_app/immutable/';
36
+ export const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
37
+ export const REVALIDATE_CACHE_CONTROL = 'no-cache';
38
+
39
+ /** The `Cache-Control` a static path of the build is served with. */
40
+ export function staticCacheControl(requestPath: string): string {
41
+ return requestPath.startsWith(IMMUTABLE_ASSET_PREFIX) ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL;
42
+ }
43
+
44
+ export interface StaticAssetsOptions {
45
+ /** The directory holding the built dashboard (`index.html`, `_app/`). */
46
+ publicDir: string;
47
+ }
48
+
49
+ /**
50
+ * Serve `publicDir` with the cache policy above and the precompressed
51
+ * siblings. Falls through (`next()`) on a path that is not a file there, so
52
+ * the SPA fallback behind it answers the application routes.
53
+ */
54
+ export function staticAssets(options: StaticAssetsOptions): MiddlewareHandler {
55
+ const serve = serveStatic({ root: options.publicDir, precompressed: true });
56
+ return async (c, next) => {
57
+ // The application's HTML itself (`/`, `/index.html`) is the SPA
58
+ // fallback's to answer, with its validator and its sibling; the static
59
+ // server would hand it out with neither (verifier round 2).
60
+ if (c.req.path === '/' || c.req.path === '/index.html') return next();
61
+ const res = await serve(c, next);
62
+ // serveStatic answers a Response for a file it found and the result of
63
+ // `next()` otherwise; only the file gets the policy.
64
+ if (res instanceof Response) {
65
+ res.headers.set('Cache-Control', staticCacheControl(c.req.path));
66
+ if (!res.headers.has('Vary')) res.headers.set('Vary', 'Accept-Encoding');
67
+ }
68
+ return res;
69
+ };
70
+ }
71
+
72
+ // ── The SPA fallback ────────────────────────────────────────────────────────
73
+
74
+ /** The encodings a precompressed sibling may carry, in the order they are preferred. */
75
+ const FALLBACK_ENCODINGS = [
76
+ { name: 'br', ext: '.br' },
77
+ { name: 'gzip', ext: '.gz' }
78
+ ] as const;
79
+ type FallbackEncoding = (typeof FALLBACK_ENCODINGS)[number]['name'];
80
+
81
+ /** The encodings a request accepts, read from `Accept-Encoding` with any `;q=` weight ignored. */
82
+ export function acceptedEncodings(header: string | undefined): Set<string> {
83
+ return new Set(
84
+ (header ?? '')
85
+ .split(',')
86
+ .map((part) => part.split(';')[0]!.trim().toLowerCase())
87
+ .filter(Boolean)
88
+ );
89
+ }
90
+
91
+ export interface SpaFallback {
92
+ /** The strong ETag of the plain HTML (the variants derive theirs from it). */
93
+ readonly etag: string;
94
+ /** Answer the application's HTML for a route the build has no file for. */
95
+ respond(c: Context): Response;
96
+ }
97
+
98
+ /**
99
+ * The application's HTML under any route the build has no file for, served
100
+ * with the two things the bare `c.html()` lacked (the tier 1 verifier's two
101
+ * caveats, PRDCT-3584): a strong `ETag` so `no-cache` means "revalidate"
102
+ * (a `304` costs no bytes), and the `.br` / `.gz` sibling of `index.html`
103
+ * when the build wrote one and the request accepts it, so a deep link costs
104
+ * the same as the root on a front that compresses nothing (Cloud Run, a
105
+ * self-hosted instance with no proxy). The HTML is read once at boot, like
106
+ * `indexHtml` itself.
107
+ */
108
+ export async function loadSpaFallback(publicDir: string, html: string): Promise<SpaFallback> {
109
+ const plain = Buffer.from(html, 'utf8');
110
+ const etag = `"${createHash('sha256').update(plain).digest('base64url').slice(0, 27)}"`;
111
+ const variants = new Map<FallbackEncoding, Buffer>();
112
+ for (const { name, ext } of FALLBACK_ENCODINGS) {
113
+ try {
114
+ variants.set(name, await readFile(join(publicDir, `index.html${ext}`)));
115
+ } catch {
116
+ // no sibling of that encoding: the plain HTML answers those requests
117
+ }
118
+ }
119
+ return {
120
+ etag,
121
+ respond(c) {
122
+ const accepted = acceptedEncodings(c.req.header('accept-encoding'));
123
+ const encoding = FALLBACK_ENCODINGS.find((e) => accepted.has(e.name) && variants.has(e.name))?.name;
124
+ // One validator per representation: a cached brotli body is never
125
+ // confirmed against a plain one.
126
+ const variantEtag = encoding ? etag.replace(/"$/, `-${encoding}"`) : etag;
127
+ // The type rides the 304 too: the node server would otherwise stamp a
128
+ // text/plain default on it (verifier round 2), and RFC 9110 allows the
129
+ // 304 to repeat what the 200 carries.
130
+ const headers: Record<string, string> = {
131
+ 'Cache-Control': REVALIDATE_CACHE_CONTROL,
132
+ 'Content-Type': 'text/html; charset=utf-8',
133
+ ETag: variantEtag,
134
+ Vary: 'Accept-Encoding'
135
+ };
136
+ if (c.req.header('if-none-match') === variantEtag) {
137
+ return c.body(null, 304, headers);
138
+ }
139
+ if (encoding) headers['Content-Encoding'] = encoding;
140
+ const body = encoding ? variants.get(encoding)! : plain;
141
+ return c.body(new Uint8Array(body), 200, headers);
142
+ }
143
+ };
144
+ }
@@ -0,0 +1,227 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { Hono } from 'hono';
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { brotliCompressSync, gzipSync } from 'node:zlib';
7
+ import {
8
+ IMMUTABLE_CACHE_CONTROL,
9
+ REVALIDATE_CACHE_CONTROL,
10
+ acceptedEncodings,
11
+ loadSpaFallback,
12
+ staticAssets,
13
+ staticCacheControl
14
+ } from '@antasphere/chassis-server/middleware';
15
+
16
+ /**
17
+ * PRDCT-3584. The dashboard build went out with no cache policy and no
18
+ * compression, so every visit re-downloaded it. Three properties:
19
+ *
20
+ * 1. a content-hashed asset (`/_app/immutable/*`) is cached for a year and
21
+ * marked immutable; nothing else ever is;
22
+ * 2. a precompressed sibling is served when the request accepts its
23
+ * encoding, with `Content-Encoding` and `Vary`, and the plain file when
24
+ * it does not;
25
+ * 3. a path that is not a file falls through to the SPA fallback, which is
26
+ * revalidated on every use.
27
+ */
28
+
29
+ const CHUNK = 'export const answer = 42; '.repeat(40);
30
+ let publicDir: string;
31
+ let app: Hono;
32
+
33
+ beforeAll(() => {
34
+ publicDir = mkdtempSync(join(tmpdir(), 'chassis-static-'));
35
+ mkdirSync(join(publicDir, '_app', 'immutable', 'chunks'), { recursive: true });
36
+ const chunk = join(publicDir, '_app', 'immutable', 'chunks', 'abc123.js');
37
+ writeFileSync(chunk, CHUNK);
38
+ writeFileSync(`${chunk}.br`, brotliCompressSync(Buffer.from(CHUNK)));
39
+ writeFileSync(`${chunk}.gz`, gzipSync(Buffer.from(CHUNK)));
40
+ // a hashed asset with no compressed sibling
41
+ writeFileSync(join(publicDir, '_app', 'immutable', 'chunks', 'plain.js'), CHUNK);
42
+ writeFileSync(join(publicDir, 'index.html'), '<!doctype html><title>t</title>');
43
+ writeFileSync(join(publicDir, 'favicon.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
44
+
45
+ app = new Hono();
46
+ app.use('*', staticAssets({ publicDir }));
47
+ app.get('*', (c) => {
48
+ c.header('Cache-Control', REVALIDATE_CACHE_CONTROL);
49
+ return c.html('<!doctype html><title>fallback</title>');
50
+ });
51
+ });
52
+
53
+ afterAll(() => {
54
+ rmSync(publicDir, { recursive: true, force: true });
55
+ });
56
+
57
+ describe('staticCacheControl', () => {
58
+ it('marks only the content-hashed prefix immutable', () => {
59
+ expect(staticCacheControl('/_app/immutable/chunks/abc123.js')).toBe(IMMUTABLE_CACHE_CONTROL);
60
+ expect(staticCacheControl('/_app/immutable/entry/start.js')).toBe(IMMUTABLE_CACHE_CONTROL);
61
+ expect(staticCacheControl('/_app/version.json')).toBe(REVALIDATE_CACHE_CONTROL);
62
+ expect(staticCacheControl('/index.html')).toBe(REVALIDATE_CACHE_CONTROL);
63
+ expect(staticCacheControl('/favicon.png')).toBe(REVALIDATE_CACHE_CONTROL);
64
+ expect(staticCacheControl('/')).toBe(REVALIDATE_CACHE_CONTROL);
65
+ });
66
+ });
67
+
68
+ describe('staticAssets', () => {
69
+ it('serves a hashed asset immutable for a year', async () => {
70
+ const res = await app.request('/_app/immutable/chunks/plain.js');
71
+ expect(res.status).toBe(200);
72
+ expect(res.headers.get('cache-control')).toBe(IMMUTABLE_CACHE_CONTROL);
73
+ expect(res.headers.get('content-encoding')).toBeNull();
74
+ expect(res.headers.get('vary')).toBe('Accept-Encoding');
75
+ expect(await res.text()).toBe(CHUNK);
76
+ });
77
+
78
+ it('serves the brotli sibling when the request accepts br', async () => {
79
+ const res = await app.request('/_app/immutable/chunks/abc123.js', {
80
+ headers: { 'accept-encoding': 'gzip, deflate, br, zstd' }
81
+ });
82
+ expect(res.status).toBe(200);
83
+ expect(res.headers.get('content-encoding')).toBe('br');
84
+ expect(res.headers.get('vary')).toContain('Accept-Encoding');
85
+ expect(res.headers.get('cache-control')).toBe(IMMUTABLE_CACHE_CONTROL);
86
+ expect(res.headers.get('content-type')).toContain('javascript');
87
+ const body = Buffer.from(await res.arrayBuffer());
88
+ expect(body.equals(brotliCompressSync(Buffer.from(CHUNK)))).toBe(true);
89
+ });
90
+
91
+ it('serves the gzip sibling when only gzip is accepted', async () => {
92
+ const res = await app.request('/_app/immutable/chunks/abc123.js', {
93
+ headers: { 'accept-encoding': 'gzip' }
94
+ });
95
+ expect(res.headers.get('content-encoding')).toBe('gzip');
96
+ const body = Buffer.from(await res.arrayBuffer());
97
+ expect(body.equals(gzipSync(Buffer.from(CHUNK)))).toBe(true);
98
+ });
99
+
100
+ it('serves the plain file when no encoding is accepted', async () => {
101
+ const res = await app.request('/_app/immutable/chunks/abc123.js');
102
+ expect(res.headers.get('content-encoding')).toBeNull();
103
+ expect(await res.text()).toBe(CHUNK);
104
+ });
105
+
106
+ it('revalidates every static file outside the hashed prefix', async () => {
107
+ const icon = await app.request('/favicon.png');
108
+ expect(icon.status).toBe(200);
109
+ expect(icon.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
110
+ });
111
+
112
+ it('leaves the application HTML itself to the fallback', async () => {
113
+ for (const path of ['/', '/index.html']) {
114
+ const res = await app.request(path);
115
+ expect(res.status, path).toBe(200);
116
+ expect(res.headers.get('cache-control'), path).toBe(REVALIDATE_CACHE_CONTROL);
117
+ expect(await res.text(), path).toContain('fallback');
118
+ }
119
+ });
120
+
121
+ it('falls through to the SPA fallback on an application route, revalidated', async () => {
122
+ const res = await app.request('/login');
123
+ expect(res.status).toBe(200);
124
+ expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
125
+ expect(await res.text()).toContain('fallback');
126
+ });
127
+
128
+ it('never marks a non-hashed response immutable', async () => {
129
+ for (const path of ['/', '/index.html', '/favicon.png', '/login', '/_app/version.json']) {
130
+ const res = await app.request(path);
131
+ expect(res.headers.get('cache-control'), path).not.toContain('immutable');
132
+ }
133
+ });
134
+ });
135
+
136
+ describe('the SPA fallback', () => {
137
+ const html = '<!doctype html><title>app</title><script>1</script>';
138
+ let dir: string;
139
+ let fallbackApp: Hono;
140
+ let etag: string;
141
+
142
+ beforeAll(async () => {
143
+ dir = mkdtempSync(join(tmpdir(), 'chassis-fallback-'));
144
+ writeFileSync(join(dir, 'index.html'), html);
145
+ writeFileSync(join(dir, 'index.html.br'), brotliCompressSync(Buffer.from(html)));
146
+ // no .gz sibling on purpose: a gzip-only client gets the plain HTML
147
+ const fallback = await loadSpaFallback(dir, html);
148
+ etag = fallback.etag;
149
+ fallbackApp = new Hono();
150
+ fallbackApp.use('*', staticAssets({ publicDir: dir }));
151
+ fallbackApp.get('*', (c) => fallback.respond(c));
152
+ });
153
+
154
+ afterAll(() => {
155
+ rmSync(dir, { recursive: true, force: true });
156
+ });
157
+
158
+ it('answers a deep link with the plain HTML, a strong ETag and no-cache', async () => {
159
+ const res = await fallbackApp.request('/settings/members');
160
+ expect(res.status).toBe(200);
161
+ expect(res.headers.get('content-type')).toContain('text/html');
162
+ expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
163
+ expect(res.headers.get('etag')).toBe(etag);
164
+ expect(res.headers.get('content-encoding')).toBeNull();
165
+ expect(res.headers.get('vary')).toBe('Accept-Encoding');
166
+ expect(await res.text()).toBe(html);
167
+ });
168
+
169
+ it('answers 304 to a matching If-None-Match, with no body and the HTML type', async () => {
170
+ const res = await fallbackApp.request('/settings/members', { headers: { 'if-none-match': etag } });
171
+ expect(res.status).toBe(304);
172
+ expect(await res.text()).toBe('');
173
+ expect(res.headers.get('etag')).toBe(etag);
174
+ expect(res.headers.get('cache-control')).toBe(REVALIDATE_CACHE_CONTROL);
175
+ expect(res.headers.get('content-type')).toContain('text/html');
176
+ });
177
+
178
+ it('answers the root and /index.html with the validator too', async () => {
179
+ for (const path of ['/', '/index.html']) {
180
+ const res = await fallbackApp.request(path);
181
+ expect(res.status, path).toBe(200);
182
+ expect(res.headers.get('etag'), path).toBe(etag);
183
+ const again = await fallbackApp.request(path, { headers: { 'if-none-match': etag } });
184
+ expect(again.status, path).toBe(304);
185
+ }
186
+ });
187
+
188
+ it('serves the brotli sibling to a client that accepts br, under its own validator', async () => {
189
+ const res = await fallbackApp.request('/login', {
190
+ headers: { 'accept-encoding': 'gzip, deflate, br;q=1.0' }
191
+ });
192
+ expect(res.status).toBe(200);
193
+ expect(res.headers.get('content-encoding')).toBe('br');
194
+ const variant = res.headers.get('etag')!;
195
+ expect(variant).not.toBe(etag);
196
+ expect(variant.endsWith('-br"')).toBe(true);
197
+ const body = Buffer.from(await res.arrayBuffer());
198
+ expect(body.equals(brotliCompressSync(Buffer.from(html)))).toBe(true);
199
+ const again = await fallbackApp.request('/login', {
200
+ headers: { 'accept-encoding': 'br', 'if-none-match': variant }
201
+ });
202
+ expect(again.status).toBe(304);
203
+ // the plain validator never confirms the compressed body
204
+ const cross = await fallbackApp.request('/login', {
205
+ headers: { 'accept-encoding': 'br', 'if-none-match': etag }
206
+ });
207
+ expect(cross.status).toBe(200);
208
+ });
209
+
210
+ it('falls back to the plain HTML when the accepted encoding has no sibling', async () => {
211
+ const res = await fallbackApp.request('/login', { headers: { 'accept-encoding': 'gzip' } });
212
+ expect(res.status).toBe(200);
213
+ expect(res.headers.get('content-encoding')).toBeNull();
214
+ expect(await res.text()).toBe(html);
215
+ });
216
+
217
+ it('reads Accept-Encoding tokens with their weights stripped', () => {
218
+ expect([...acceptedEncodings('gzip, deflate, br;q=1.0, zstd')]).toEqual([
219
+ 'gzip',
220
+ 'deflate',
221
+ 'br',
222
+ 'zstd'
223
+ ]);
224
+ expect([...acceptedEncodings(undefined)]).toEqual([]);
225
+ expect(acceptedEncodings(' BR ').has('br')).toBe(true);
226
+ });
227
+ });