@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.
- package/README.md +3 -3
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +433 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +174 -65
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +376 -135
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +209 -27
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +62 -5
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +44 -7
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +92 -9
- package/dist/draft.js.map +1 -1
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +15 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +8 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +266 -74
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +869 -179
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +8 -21
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +121 -40
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +136 -26
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +94 -22
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +170 -58
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +25 -17
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -33
- package/dist/vite.js.map +1 -1
- package/package.json +48 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +553 -0
- package/src/build.ts +563 -172
- package/src/bun-dev-entry.ts +184 -0
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +221 -30
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +468 -66
- package/src/draft.ts +124 -12
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +24 -6
- package/src/index.ts +1174 -225
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +18 -30
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +264 -48
- package/src/module-graph.ts +146 -0
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/public-dir.ts +108 -0
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +288 -74
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- 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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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()`
|
|
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
|
|
255
|
-
// listener must not synchronously subscribe/unsubscribe during notification
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|