@nifrajs/web 2.1.0 → 2.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.
Files changed (194) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +68 -0
  3. package/dist/build-vite.d.ts.map +1 -0
  4. package/dist/build-vite.js +433 -0
  5. package/dist/build-vite.js.map +1 -0
  6. package/dist/build.d.ts +174 -65
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +376 -135
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +83 -0
  11. package/dist/bun-dev-entry.d.ts.map +1 -0
  12. package/dist/bun-dev-entry.js +136 -0
  13. package/dist/bun-dev-entry.js.map +1 -0
  14. package/dist/chunk-names.js +1 -1
  15. package/dist/client.d.ts +3 -9
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +209 -27
  18. package/dist/client.js.map +1 -1
  19. package/dist/conformance.d.ts +2 -2
  20. package/dist/conformance.js +3 -3
  21. package/dist/deferred.d.ts +11 -11
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +16 -16
  24. package/dist/dev-error.d.ts +2 -2
  25. package/dist/dev-error.js +3 -3
  26. package/dist/dev-port.d.ts +36 -0
  27. package/dist/dev-port.d.ts.map +1 -0
  28. package/dist/dev-port.js +48 -0
  29. package/dist/dev-port.js.map +1 -0
  30. package/dist/dev.d.ts +62 -5
  31. package/dist/dev.d.ts.map +1 -1
  32. package/dist/dev.js +419 -60
  33. package/dist/dev.js.map +1 -1
  34. package/dist/draft.d.ts +44 -7
  35. package/dist/draft.d.ts.map +1 -1
  36. package/dist/draft.js +92 -9
  37. package/dist/draft.js.map +1 -1
  38. package/dist/fn-state.d.ts +56 -0
  39. package/dist/fn-state.d.ts.map +1 -0
  40. package/dist/fn-state.js +77 -0
  41. package/dist/fn-state.js.map +1 -0
  42. package/dist/fn.d.ts +111 -0
  43. package/dist/fn.d.ts.map +1 -0
  44. package/dist/fn.js +125 -0
  45. package/dist/fn.js.map +1 -0
  46. package/dist/fonts-google.d.ts +9 -9
  47. package/dist/fonts-google.js +10 -10
  48. package/dist/fonts.d.ts +7 -7
  49. package/dist/fonts.js +1 -1
  50. package/dist/forms.d.ts +8 -8
  51. package/dist/fs.d.ts +15 -2
  52. package/dist/fs.d.ts.map +1 -1
  53. package/dist/fs.js +8 -2
  54. package/dist/fs.js.map +1 -1
  55. package/dist/index.d.ts +266 -74
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +869 -179
  58. package/dist/index.js.map +1 -1
  59. package/dist/internal/head-attributes.d.ts +15 -0
  60. package/dist/internal/head-attributes.d.ts.map +1 -0
  61. package/dist/internal/head-attributes.js +99 -0
  62. package/dist/internal/head-attributes.js.map +1 -0
  63. package/dist/internal/script-types.d.ts +14 -0
  64. package/dist/internal/script-types.d.ts.map +1 -0
  65. package/dist/internal/script-types.js +17 -0
  66. package/dist/internal/script-types.js.map +1 -0
  67. package/dist/internal/server-boundary.d.ts +60 -0
  68. package/dist/internal/server-boundary.d.ts.map +1 -0
  69. package/dist/internal/server-boundary.js +174 -0
  70. package/dist/internal/server-boundary.js.map +1 -0
  71. package/dist/internal/timing-safe-equal.d.ts +16 -0
  72. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  73. package/dist/internal/timing-safe-equal.js +26 -0
  74. package/dist/internal/timing-safe-equal.js.map +1 -0
  75. package/dist/internal/vite-import.d.ts +63 -0
  76. package/dist/internal/vite-import.d.ts.map +1 -0
  77. package/dist/internal/vite-import.js +94 -0
  78. package/dist/internal/vite-import.js.map +1 -0
  79. package/dist/islands.d.ts +5 -5
  80. package/dist/islands.js +5 -5
  81. package/dist/isr.d.ts +15 -15
  82. package/dist/isr.d.ts.map +1 -1
  83. package/dist/isr.js +8 -21
  84. package/dist/isr.js.map +1 -1
  85. package/dist/llms-txt.d.ts.map +1 -1
  86. package/dist/llms-txt.js +12 -6
  87. package/dist/llms-txt.js.map +1 -1
  88. package/dist/manifest.d.ts +121 -40
  89. package/dist/manifest.d.ts.map +1 -1
  90. package/dist/manifest.js +136 -26
  91. package/dist/manifest.js.map +1 -1
  92. package/dist/module-graph.d.ts +91 -0
  93. package/dist/module-graph.d.ts.map +1 -0
  94. package/dist/module-graph.js +92 -0
  95. package/dist/module-graph.js.map +1 -0
  96. package/dist/navigation.d.ts +120 -5
  97. package/dist/navigation.d.ts.map +1 -1
  98. package/dist/navigation.js +37 -5
  99. package/dist/navigation.js.map +1 -1
  100. package/dist/plugins/css-modules.d.ts +3 -3
  101. package/dist/plugins/css-modules.d.ts.map +1 -1
  102. package/dist/plugins/css-modules.js +0 -0
  103. package/dist/plugins/css-modules.js.map +1 -1
  104. package/dist/plugins/kit.d.ts +8 -8
  105. package/dist/plugins/kit.js +9 -9
  106. package/dist/plugins/scss.js +3 -3
  107. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  108. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  109. package/dist/plugins/vite-leak-guard.js +67 -0
  110. package/dist/plugins/vite-leak-guard.js.map +1 -0
  111. package/dist/plugins/vite-server-fn.d.ts +23 -0
  112. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  113. package/dist/plugins/vite-server-fn.js +51 -0
  114. package/dist/plugins/vite-server-fn.js.map +1 -0
  115. package/dist/plugins/vite-server-only.d.ts +49 -0
  116. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  117. package/dist/plugins/vite-server-only.js +57 -0
  118. package/dist/plugins/vite-server-only.js.map +1 -0
  119. package/dist/prerender.d.ts +5 -5
  120. package/dist/prerender.js +7 -7
  121. package/dist/public-dir.d.ts +36 -0
  122. package/dist/public-dir.d.ts.map +1 -0
  123. package/dist/public-dir.js +89 -0
  124. package/dist/public-dir.js.map +1 -0
  125. package/dist/query.d.ts +10 -10
  126. package/dist/query.js +15 -15
  127. package/dist/route-manifest.d.ts +91 -0
  128. package/dist/route-manifest.d.ts.map +1 -0
  129. package/dist/route-manifest.js +128 -0
  130. package/dist/route-manifest.js.map +1 -0
  131. package/dist/router.d.ts +94 -22
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +170 -58
  134. package/dist/router.js.map +1 -1
  135. package/dist/search.d.ts +94 -0
  136. package/dist/search.d.ts.map +1 -0
  137. package/dist/search.js +232 -0
  138. package/dist/search.js.map +1 -0
  139. package/dist/server-only.d.ts +6 -6
  140. package/dist/server-only.js +7 -7
  141. package/dist/service-worker.d.ts +56 -0
  142. package/dist/service-worker.d.ts.map +1 -0
  143. package/dist/service-worker.js +127 -0
  144. package/dist/service-worker.js.map +1 -0
  145. package/dist/vite.d.ts +25 -17
  146. package/dist/vite.d.ts.map +1 -1
  147. package/dist/vite.js +75 -33
  148. package/dist/vite.js.map +1 -1
  149. package/package.json +48 -4
  150. package/server-manifest.d.ts +31 -0
  151. package/src/build-vite.ts +553 -0
  152. package/src/build.ts +563 -172
  153. package/src/bun-dev-entry.ts +184 -0
  154. package/src/chunk-names.ts +1 -1
  155. package/src/client.ts +221 -30
  156. package/src/conformance.ts +3 -3
  157. package/src/deferred.ts +17 -17
  158. package/src/dev-error.ts +3 -3
  159. package/src/dev-port.ts +63 -0
  160. package/src/dev.ts +468 -66
  161. package/src/draft.ts +124 -12
  162. package/src/fn-state.ts +107 -0
  163. package/src/fn.ts +199 -0
  164. package/src/fonts-google.ts +13 -13
  165. package/src/fonts.ts +7 -7
  166. package/src/forms.ts +8 -8
  167. package/src/fs.ts +24 -6
  168. package/src/index.ts +1174 -225
  169. package/src/internal/head-attributes.ts +109 -0
  170. package/src/internal/script-types.ts +18 -0
  171. package/src/internal/server-boundary.ts +187 -0
  172. package/src/internal/timing-safe-equal.ts +24 -0
  173. package/src/internal/vite-import.ts +92 -0
  174. package/src/islands.ts +7 -7
  175. package/src/isr.ts +18 -30
  176. package/src/llms-txt.ts +13 -5
  177. package/src/manifest.ts +264 -48
  178. package/src/module-graph.ts +146 -0
  179. package/src/navigation.ts +151 -5
  180. package/src/plugins/css-modules.ts +0 -0
  181. package/src/plugins/kit.ts +11 -11
  182. package/src/plugins/scss.ts +3 -3
  183. package/src/plugins/vite-leak-guard.ts +110 -0
  184. package/src/plugins/vite-server-fn.ts +67 -0
  185. package/src/plugins/vite-server-only.ts +75 -0
  186. package/src/prerender.ts +10 -10
  187. package/src/public-dir.ts +108 -0
  188. package/src/query.ts +22 -22
  189. package/src/route-manifest.ts +240 -0
  190. package/src/router.ts +288 -74
  191. package/src/search.ts +297 -0
  192. package/src/server-only.ts +7 -7
  193. package/src/service-worker.ts +165 -0
  194. package/src/vite.ts +103 -49
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `@nifrajs/web/plugins/vite-server-only` - the Vite half of the `.server` convention.
3
+ *
4
+ * A module named `*.server.ts(x)` is server-only: the Bun client build empties it so its import
5
+ * subtree - `node:` builtins, native modules, secrets - never reaches a browser. This does the same
6
+ * for Vite, in both the dev server and the production build.
7
+ *
8
+ * ## Why this exists separately
9
+ *
10
+ * The convention was implemented once, as a `Bun.build` plugin, which meant it held in exactly one of
11
+ * the four pipelines. `nifra build` emptied the module; `nifra dev` (Vite), a Vite production build,
12
+ * and `nifra dev --bun` all shipped it whole. A guard that holds in one pipeline and not the others is
13
+ * worse than none, because the file name reads as protection everywhere.
14
+ *
15
+ * `*.fn` already had this shape - a Bun plugin, a Vite plugin, and a refusal in the one pipeline that
16
+ * cannot transform. `.server` now matches it.
17
+ *
18
+ * ## Why `transform` and not `load`
19
+ *
20
+ * Vite resolves and reads the file itself; taking `load` would mean re-implementing its resolution.
21
+ * `transform` receives the source Vite already read, and returning replacement code discards the
22
+ * original along with its imports - so the server subtree is never followed. `enforce: "pre"` puts
23
+ * this ahead of framework transforms, which have no business seeing server code either.
24
+ */
25
+
26
+ import {
27
+ SERVER_ONLY_MODULE,
28
+ SERVER_ONLY_REPLACEMENT,
29
+ viteServerOnlyReplacement,
30
+ } from "../internal/server-boundary.ts"
31
+
32
+ // Re-exported so a caller wiring its own bundler gets the matcher from the same owner the pipelines
33
+ // use, rather than writing a fifth copy of the regex.
34
+ export { SERVER_ONLY_MODULE, SERVER_ONLY_REPLACEMENT, viteServerOnlyReplacement }
35
+
36
+ /** The slice of a Vite/Rollup plugin this returns. Structural, so `vite` stays an optional peer. */
37
+ export interface ServerOnlyEmptyPlugin {
38
+ readonly name: string
39
+ readonly enforce: "pre"
40
+ /** Applied only to the CLIENT build; the server build keeps the real module. */
41
+ readonly applyToEnvironment?: (environment: { readonly name: string }) => boolean
42
+ transform(
43
+ code: string,
44
+ id: string,
45
+ options?: { readonly ssr?: boolean },
46
+ ): { code: string; map: null } | null
47
+ }
48
+
49
+ /** Strips a Vite id's `?query` and `#hash` so the suffix test sees a plain path. */
50
+ const bare = (id: string): string => id.split("?")[0]?.split("#")[0] ?? id
51
+
52
+ /**
53
+ * Empty every `*.server` module in the client build.
54
+ *
55
+ * Client-only by construction: nifra passes this to the client build and the dev server, never to the
56
+ * SSR build, where the real module is what runs.
57
+ */
58
+ export function viteServerOnlyEmpty(): ServerOnlyEmptyPlugin {
59
+ return {
60
+ name: "nifra:server-only-empty",
61
+ enforce: "pre",
62
+ // Vite 6+ environments: the SSR environment must keep the real module. Older Vite ignores this and
63
+ // relies on nifra only registering the plugin on the client side.
64
+ applyToEnvironment: (environment) => environment.name === "client",
65
+ transform(code, id, options) {
66
+ // Vite 5 ignores `applyToEnvironment` but supplies this transform context for SSR.
67
+ if (options?.ssr === true) return null
68
+ const path = bare(id)
69
+ if (!SERVER_ONLY_MODULE.test(path)) return null
70
+ // `map: null` is honest rather than lazy: the replacement shares no lines with the source, so any
71
+ // mapping would point a debugger at unrelated code.
72
+ return { code: viteServerOnlyReplacement(code), map: null }
73
+ },
74
+ }
75
+ }
package/src/prerender.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Build-time SSG driver. Prerendering is framework-agnostic by construction: a nifra SSR page is
3
3
  * "just `app.fetch(GET)` returning a streamed `Response`", so prerendering = drive that same handler
4
4
  * at build with a synthetic request, drain the body to bytes, and write the HTML to disk. No adapter
5
- * (React/Solid/Vue/Preact/Svelte) is touched — this sits entirely above the render seam.
5
+ * (React/Solid/Vue/Preact/Svelte) is touched - this sits entirely above the render seam.
6
6
  *
7
7
  * Handles **static** routes (opt in via `export const prerender = true`) and **dynamic** `:param`
8
8
  * routes (enumerate concrete params via `export const getStaticPaths`). Catch-all/wildcard (`*`)
@@ -13,7 +13,7 @@ import { dirname, resolve, sep } from "node:path"
13
13
  import { fillRoutePattern, type RouteEntry } from "./manifest.ts"
14
14
  import { DATA_HEADER } from "./router.ts"
15
15
 
16
- /** Minimal app surface the driver needs — just a fetch handler (a built `createWebApp`). */
16
+ /** Minimal app surface the driver needs - just a fetch handler (a built `createWebApp`). */
17
17
  export interface PrerenderApp {
18
18
  fetch(req: Request): Response | Promise<Response>
19
19
  }
@@ -47,7 +47,7 @@ export interface PrerenderEntry {
47
47
  export interface PrerenderResult {
48
48
  readonly prerendered: readonly PrerenderEntry[]
49
49
  readonly skipped: readonly { readonly path: string; readonly reason: string }[]
50
- /** Per dynamic route pattern, its `getStaticPaths` `fallback` (`"ssr"` default) — so the deploy
50
+ /** Per dynamic route pattern, its `getStaticPaths` `fallback` (`"ssr"` default) - so the deploy
51
51
  * layer knows whether unlisted paths should hit the worker (`"ssr"`) or 404 (`"404"`). */
52
52
  readonly fallbacks: Readonly<Record<string, "ssr" | "404">>
53
53
  }
@@ -71,7 +71,7 @@ const outputPath = (root: string, file: string): string | undefined => {
71
71
 
72
72
  /**
73
73
  * Render every opted-in static route to a static `index.html` under `outDir`. Run AFTER `buildClient`
74
- * (so the app references the hashed client entry). Returns a report of what was emitted vs skipped —
74
+ * (so the app references the hashed client entry). Returns a report of what was emitted vs skipped -
75
75
  * the caller can use `prerendered` to wire a hybrid deploy (e.g. exclude those paths from the SSR
76
76
  * worker so the CDN serves the static file). Never throws on a per-route miss: a non-OK response or a
77
77
  * non-opted/dynamic route is recorded in `skipped` and the build continues.
@@ -88,7 +88,7 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
88
88
  const renderPath = async (path: string): Promise<void> => {
89
89
  const res = await options.app.fetch(new Request(`${origin}${path}`))
90
90
  if (!res.ok) {
91
- // A failed render is a per-route skip, not a build failure — surfaced for the caller to log.
91
+ // A failed render is a per-route skip, not a build failure - surfaced for the caller to log.
92
92
  skipped.push({ path, reason: `render returned HTTP ${res.status}` })
93
93
  return
94
94
  }
@@ -104,7 +104,7 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
104
104
 
105
105
  // Also emit the loader data as a static `_data.json` next to it, so a client soft-nav into this
106
106
  // route fetches a file instead of hitting the worker. Only when the data-mode response is plain
107
- // JSON — a deferred loader answers with NDJSON, which the static fast-path doesn't cover (the
107
+ // JSON - a deferred loader answers with NDJSON, which the static fast-path doesn't cover (the
108
108
  // client falls back to the worker for those).
109
109
  let dataFile: string | undefined
110
110
  const dataRes = await options.app.fetch(
@@ -123,7 +123,7 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
123
123
  writeFileSync(dataAbs, await dataRes.text())
124
124
  }
125
125
  } else {
126
- await dataRes.body?.cancel() // not emitting (non-OK or NDJSON) — release the stream
126
+ await dataRes.body?.cancel() // not emitting (non-OK or NDJSON) - release the stream
127
127
  }
128
128
 
129
129
  prerendered.push({ path, file, bytes: html.length, ...(dataFile ? { dataFile } : {}) })
@@ -131,7 +131,7 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
131
131
 
132
132
  for (const route of options.routes) {
133
133
  if (route.pattern.includes("*")) {
134
- skipped.push({ path: route.pattern, reason: "catch-all/wildcard route — not supported" })
134
+ skipped.push({ path: route.pattern, reason: "catch-all/wildcard route - not supported" })
135
135
  continue
136
136
  }
137
137
  const mod = await route.load()
@@ -182,7 +182,7 @@ export interface CloudflarePagesRoutes {
182
182
 
183
183
  export interface CloudflarePagesRoutesOptions {
184
184
  /** Prerendered request paths (e.g. `prerenderRoutes(...).prerendered.map(p => p.path)`, or the
185
- * build's `prerendered.json`). Each is excluded from the worker — plus its static `_data.json`. */
185
+ * build's `prerendered.json`). Each is excluded from the worker - plus its static `_data.json`. */
186
186
  readonly prerendered: readonly string[]
187
187
  /** Extra globs to keep OFF the worker (CDN-served). Default `["/assets/*"]` (the hashed bundle). */
188
188
  readonly staticGlobs?: readonly string[]
@@ -195,7 +195,7 @@ export interface CloudflarePagesRoutesOptions {
195
195
  *
196
196
  * ⚠️ Cloudflare caps `_routes.json` at **100** include+exclude rules total, and each prerendered path
197
197
  * costs 2 (the doc + its `_data.json`). For a large SSG site, exclude by **prefix glob** (e.g.
198
- * `/blog/*`) instead of listing every path — pass those via `staticGlobs` and a smaller `prerendered`.
198
+ * `/blog/*`) instead of listing every path - pass those via `staticGlobs` and a smaller `prerendered`.
199
199
  */
200
200
  export function cloudflarePagesRoutes(
201
201
  options: CloudflarePagesRoutesOptions,
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `public/` - user-authored static files, served identically in dev and in production.
3
+ *
4
+ * The asymmetry this closes: `nifra dev` served `public/` for free because the HMR path runs on Vite
5
+ * and Vite serves `public/` by default, while production had no `publicDir` concept at all. So a file
6
+ * worked all the way through development and 404'd the moment it was deployed. The failure is
7
+ * inverted - it appears only in production, and only for the assets nobody smoke-tests - and it has
8
+ * already shipped once as a self-hosted webfont that silently fell back to a system font in prod.
9
+ *
10
+ * One owner for both sides is the fix, not a second implementation that happens to agree today.
11
+ *
12
+ * Distinct from `publicPath` in `build.ts`, which is the URL prefix for content-hashed bundle chunks.
13
+ * The names collide and the concepts do not: `publicPath` never covers user-authored files.
14
+ */
15
+ import { realpath } from "node:fs/promises"
16
+ import { normalize, resolve, sep } from "node:path"
17
+
18
+ /** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
19
+ * user-authored file keeps its name across deploys, so it gets a day and a revalidation. */
20
+ export interface PublicDirCache {
21
+ /** `cache-control` for content-hashed assets (default immutable, one year). */
22
+ readonly hashed?: string
23
+ /** `cache-control` for everything else under `public/` (default one day). */
24
+ readonly assets?: string
25
+ }
26
+
27
+ export interface ServePublicDirOptions {
28
+ /** Absolute path of the directory to serve. */
29
+ readonly dir: string
30
+ /** URL prefix whose files are content-hashed and may be cached immutably (default `"/assets/"`). */
31
+ readonly hashedPrefix?: string
32
+ readonly cache?: PublicDirCache
33
+ /** Optional encoded URL-path allowlist. When present, route misses avoid a filesystem probe. */
34
+ readonly files?: ReadonlySet<string>
35
+ }
36
+
37
+ const IMMUTABLE = "public, max-age=31536000, immutable"
38
+ const ONE_DAY = "public, max-age=86400"
39
+
40
+ /**
41
+ * Resolve a URL pathname to an absolute path **confined** to `root`, or `undefined` if it escapes.
42
+ *
43
+ * This is a user-path-to-filesystem sink, which makes it the one part of this feature with a security
44
+ * consequence. Decode first (`%2e%2e%2f` is `../`), then normalize, then verify the result is still
45
+ * under `root` by prefix - checking the *resolved* path rather than scanning the input for `..`,
46
+ * because a blocklist over encodings is exactly the kind of check that gets bypassed.
47
+ */
48
+ export function resolvePublicPath(root: string, pathname: string): string | undefined {
49
+ let decoded: string
50
+ try {
51
+ decoded = decodeURIComponent(pathname)
52
+ } catch {
53
+ return undefined // malformed percent-encoding: not a path we will guess at
54
+ }
55
+ // A NUL can truncate a path in a downstream syscall; refuse rather than normalize it away.
56
+ if (decoded.includes("\0")) return undefined
57
+ const rootResolved = resolve(root)
58
+ const candidate = resolve(rootResolved, `.${normalize(decoded)}`)
59
+ if (candidate !== rootResolved && !candidate.startsWith(rootResolved + sep)) return undefined
60
+ return candidate
61
+ }
62
+
63
+ /**
64
+ * Build a static-file handler for `dir`.
65
+ *
66
+ * Returns `undefined` on any miss so the caller **falls through to routing** - a static probe must
67
+ * never shadow a route. That ordering is also why a `routes/robots.txt.tsx` beats a
68
+ * `public/robots.txt` only if the caller checks routes first; the documented precedence is that the
69
+ * static probe runs first, so `public/` wins, and an app wanting the route should not ship both.
70
+ */
71
+ export function servePublicDir(
72
+ options: ServePublicDirOptions,
73
+ ): (request: Request) => Promise<Response | undefined> {
74
+ const root = resolve(options.dir)
75
+ const rootReal = realpath(root).catch(() => undefined)
76
+ const hashedPrefix = options.hashedPrefix ?? "/assets/"
77
+ const hashed = options.cache?.hashed ?? IMMUTABLE
78
+ const assets = options.cache?.assets ?? ONE_DAY
79
+
80
+ return async (request: Request): Promise<Response | undefined> => {
81
+ if (request.method !== "GET" && request.method !== "HEAD") return undefined
82
+ const { pathname } = new URL(request.url)
83
+ // A production manifest can reject page routes without touching the filesystem.
84
+ if (options.files !== undefined && !options.files.has(pathname)) return undefined
85
+ const abs = resolvePublicPath(root, pathname)
86
+ if (abs === undefined) return undefined
87
+ const [resolvedRoot, resolvedFile] = await Promise.all([
88
+ rootReal,
89
+ realpath(abs).catch(() => undefined),
90
+ ])
91
+ if (
92
+ resolvedRoot === undefined ||
93
+ resolvedFile === undefined ||
94
+ (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep))
95
+ ) {
96
+ return undefined
97
+ }
98
+ const file = Bun.file(resolvedFile)
99
+ if (!(await file.exists())) return undefined
100
+ const headers = new Headers({
101
+ "cache-control": pathname.startsWith(hashedPrefix) ? hashed : assets,
102
+ })
103
+ // HEAD must not carry a body, but must otherwise match GET's headers.
104
+ return request.method === "HEAD"
105
+ ? new Response(null, { headers })
106
+ : new Response(file, { headers })
107
+ }
108
+ }
package/src/query.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The agnostic keyed query-cache — a `query(key, fn)` primitive with in-flight dedup, `staleTime`
2
+ * The agnostic keyed query-cache - a `query(key, fn)` primitive with in-flight dedup, `staleTime`
3
3
  * freshness, prefix `invalidateQueries`, imperative cache read/write, prefetch, SSR dehydrate/hydrate,
4
4
  * infinite (paged) queries, and standalone mutations. Pure logic, no `window`, no framework, no
5
- * `Date.now`/timers (the clock is injected) — so it unit-tests deterministically and is safe to import
5
+ * `Date.now`/timers (the clock is injected) - so it unit-tests deterministically and is safe to import
6
6
  * from the SSR core's main entry. A per-adapter binding (`useQuery`/`useMutation`/`createQuery`)
7
7
  * subscribes to a query/mutation's `subscribe`/`snapshot` store (the same shape the router + fetchers
8
8
  * use).
@@ -15,13 +15,13 @@
15
15
  /** A query's lifecycle status. `pending` = no data yet; `success`/`error` once it has settled once. */
16
16
  export type QueryStatus = "pending" | "success" | "error"
17
17
 
18
- /** A query's observable state — what a binding renders. A new (frozen) object per transition, so a
18
+ /** A query's observable state - what a binding renders. A new (frozen) object per transition, so a
19
19
  * `useSyncExternalStore`/signal binding can compare by reference. */
20
20
  export interface QueryState<T = unknown> {
21
21
  readonly status: QueryStatus
22
22
  readonly data: T | undefined
23
23
  readonly error: unknown
24
- /** True while a fetch is in flight — the initial load OR a background refetch (data may still show). */
24
+ /** True while a fetch is in flight - the initial load OR a background refetch (data may still show). */
25
25
  readonly isFetching: boolean
26
26
  /** When `data` was last set (via the injected clock); drives `staleTime`. `-Infinity` until first set. */
27
27
  readonly updatedAt: number
@@ -46,14 +46,14 @@ export interface QueryOptions {
46
46
  }
47
47
 
48
48
  export interface QueryClientOptions {
49
- /** Monotonic clock in ms — injected so the core stays deterministic + testable; the adapter binding
49
+ /** Monotonic clock in ms - injected so the core stays deterministic + testable; the adapter binding
50
50
  * passes `() => Date.now()`. */
51
51
  readonly now: () => number
52
52
  /** Data stays fresh this long after a fetch; older ⇒ refetch on the next `fetch()` (default `0`). */
53
53
  readonly staleTime?: number
54
54
  /** Evict an entry this long (ms) after its last subscriber leaves (default 5 min). */
55
55
  readonly gcTime?: number
56
- /** Hard cap on cached entries — LRU-evict the oldest *unsubscribed* entry past it (default 1000). */
56
+ /** Hard cap on cached entries - LRU-evict the oldest *unsubscribed* entry past it (default 1000). */
57
57
  readonly max?: number
58
58
  }
59
59
 
@@ -65,7 +65,7 @@ export interface InfiniteData<T, P> {
65
65
  }
66
66
 
67
67
  /** Options for an {@link InfiniteQueryHandle}. `getNextPageParam` (required) derives the param for the
68
- * next page from the last page — return `undefined`/`null` to signal there is no next page. */
68
+ * next page from the last page - return `undefined`/`null` to signal there is no next page. */
69
69
  export interface InfiniteQueryOptions<T, P> extends QueryOptions {
70
70
  /** The param the FIRST page is fetched with. */
71
71
  readonly initialPageParam: P
@@ -104,7 +104,7 @@ export interface InfiniteQueryHandle<T, P> {
104
104
  hasPreviousPage: () => boolean
105
105
  }
106
106
 
107
- /** A serializable snapshot of the cache's successful queries — the SSR→client bridge payload. */
107
+ /** A serializable snapshot of the cache's successful queries - the SSR→client bridge payload. */
108
108
  export interface DehydratedState {
109
109
  readonly queries: ReadonlyArray<{
110
110
  readonly key: unknown
@@ -144,7 +144,7 @@ export interface QueryClient {
144
144
 
145
145
  /**
146
146
  * Hash a query key to a stable cache string. Object keys are sorted (so `{a,b}` ≡ `{b,a}`); arrays
147
- * keep order. Keys must be serializable — a function/symbol in the key throws (it can't be a stable
147
+ * keep order. Keys must be serializable - a function/symbol in the key throws (it can't be a stable
148
148
  * identity). Mirrors TanStack Query's structural hashing.
149
149
  */
150
150
  export function hashQueryKey(key: unknown): string {
@@ -179,7 +179,7 @@ interface Entry {
179
179
  staleTime: number
180
180
  state: QueryState
181
181
  promise: Promise<unknown> | undefined
182
- /** The {@link generation} the in-flight {@link promise} started at — so a fetch begun *before* an
182
+ /** The {@link generation} the in-flight {@link promise} started at - so a fetch begun *before* an
183
183
  * `invalidateQueries` (which bumps `generation`) is recognized as superseded, not joined. */
184
184
  promiseGen: number
185
185
  /** Bumped by `invalidateQueries`/`refetch` to supersede any in-flight fetch begun before it. */
@@ -217,7 +217,7 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
217
217
  const cache = new Map<string, Entry>()
218
218
 
219
219
  // Lazily evict entries whose GC timer has elapsed (no subscribers), then enforce the hard cap by
220
- // dropping the oldest-inserted unsubscribed entries. Called on every `query()` — no background timer.
220
+ // dropping the oldest-inserted unsubscribed entries. Called on every `query()` - no background timer.
221
221
  const sweep = (): void => {
222
222
  const t = now()
223
223
  for (const [h, e] of cache) {
@@ -251,8 +251,8 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
251
251
  handle: undefined as unknown as QueryHandle,
252
252
  }
253
253
  const emit = (): void => {
254
- // Iterate the live set — no per-emit copy (the common case is 0–1 subscribers). Contract: a
255
- // listener must not synchronously subscribe/unsubscribe during notification — `useSyncExternalStore`/
254
+ // Iterate the live set - no per-emit copy (the common case is 0-1 subscribers). Contract: a
255
+ // listener must not synchronously subscribe/unsubscribe during notification - `useSyncExternalStore`/
256
256
  // signal/store bindings don't (un/subscribe happens in a later effect/cleanup tick).
257
257
  if (entry.listeners.size === 0) return
258
258
  for (const l of entry.listeners) l()
@@ -265,19 +265,19 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
265
265
  entry.invalidated || now() - entry.state.updatedAt >= entry.staleTime
266
266
 
267
267
  entry.run = (force: boolean): Promise<unknown> => {
268
- // Dedup — but join the in-flight fetch ONLY when it belongs to the current generation. An
268
+ // Dedup - but join the in-flight fetch ONLY when it belongs to the current generation. An
269
269
  // `invalidateQueries` bumps `generation`, so a fetch that started *before* the invalidation is
270
270
  // superseded and must NOT satisfy this call (its data is pre-mutation); fall through and kick a
271
271
  // fresh fetch instead of returning the stale in-flight one.
272
272
  if (entry.promise !== undefined && entry.promiseGen === entry.generation) return entry.promise
273
273
  if (!force && entry.state.status === "success" && !isStale())
274
- return Promise.resolve(entry.state.data) // fresh cache hit — no fn call
274
+ return Promise.resolve(entry.state.data) // fresh cache hit - no fn call
275
275
  const gen = entry.generation
276
276
  entry.setState({ ...entry.state, isFetching: true }) // show the background/initial fetch
277
277
  const promise = (async () => {
278
278
  try {
279
279
  const data = await entry.fn()
280
- // A newer generation (a later invalidate/refetch) owns the entry now — discard this result
280
+ // A newer generation (a later invalidate/refetch) owns the entry now - discard this result
281
281
  // rather than publishing pre-mutation data as fresh or clearing `invalidated`. The fetch
282
282
  // that superseded this one publishes the post-mutation data.
283
283
  if (gen !== entry.generation) return data
@@ -304,7 +304,7 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
304
304
  snapshot: () => entry.state,
305
305
  subscribe: (listener: () => void) => {
306
306
  entry.subscribers++
307
- entry.gcAt = undefined // in use again — cancel any pending eviction
307
+ entry.gcAt = undefined // in use again - cancel any pending eviction
308
308
  entry.listeners.add(listener)
309
309
  return () => {
310
310
  entry.listeners.delete(listener)
@@ -342,7 +342,7 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
342
342
  for (const entry of cache.values()) {
343
343
  if (!keyMatchesPrefix(entry.key, keyOrPrefix)) continue
344
344
  entry.invalidated = true // next fetch refetches even within staleTime
345
- // Supersede any in-flight fetch begun before this point — its result is pre-mutation and must
345
+ // Supersede any in-flight fetch begun before this point - its result is pre-mutation and must
346
346
  // not resolve as "fresh" or clear `invalidated`. The refetch below (or the next `fetch()`)
347
347
  // starts a new generation that publishes the post-mutation data.
348
348
  entry.generation++
@@ -358,7 +358,7 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
358
358
  }
359
359
 
360
360
  const setQueryData = <T>(key: unknown, updater: T | ((prev: T | undefined) => T)): void => {
361
- // Create a placeholder entry if none exists — its `fn` throws until a real `query(key, fn)` re-binds
361
+ // Create a placeholder entry if none exists - its `fn` throws until a real `query(key, fn)` re-binds
362
362
  // it, so a `setQueryData` for a key nobody queries yet never triggers a fetch on its own.
363
363
  const entry = upsert(
364
364
  key,
@@ -370,7 +370,7 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
370
370
  const prev = entry.state.status === "success" ? (entry.state.data as T) : undefined
371
371
  const data =
372
372
  typeof updater === "function" ? (updater as (p: T | undefined) => T)(prev) : updater
373
- // A direct write is the new truth — bump the generation so any in-flight fetch can't overwrite it,
373
+ // A direct write is the new truth - bump the generation so any in-flight fetch can't overwrite it,
374
374
  // and clear the invalidated flag (the caller just supplied fresh data).
375
375
  entry.generation++
376
376
  entry.invalidated = false
@@ -629,7 +629,7 @@ const IDLE_MUTATION: MutationState<unknown, unknown> = Object.freeze({
629
629
  })
630
630
 
631
631
  /**
632
- * Create a standalone mutation state machine — framework-agnostic, so a per-adapter `useMutation`
632
+ * Create a standalone mutation state machine - framework-agnostic, so a per-adapter `useMutation`
633
633
  * binding just subscribes to it. Single-flight by a monotonic token: overlapping `mutate` calls each
634
634
  * run their `fn`, but only the latest publishes state (an older, slower response can't clobber a newer
635
635
  * one). The callbacks fire in TanStack order: `onMutate` (before), then `onSuccess`/`onError`, then
@@ -667,7 +667,7 @@ export function createMutation<TData, TVariables>(
667
667
  mutate: async (variables) => {
668
668
  const mine = ++token
669
669
  setState({ status: "pending", data: undefined, error: undefined, variables })
670
- // Only await a callback when one is actually supplied — an `await undefined` would insert a
670
+ // Only await a callback when one is actually supplied - an `await undefined` would insert a
671
671
  // needless microtask before the mutationFn runs (and complicates single-flight timing).
672
672
  if (cbs.onMutate !== undefined) await cbs.onMutate(variables)
673
673
  try {