@nifrajs/web 2.9.1 → 2.11.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/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +16 -0
- package/dist/client.js.map +1 -1
- package/dist/dev-ssr-graph.d.ts +33 -0
- package/dist/dev-ssr-graph.d.ts.map +1 -0
- package/dist/dev-ssr-graph.js +285 -0
- package/dist/dev-ssr-graph.js.map +1 -0
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +76 -18
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +28 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +53 -79
- package/dist/index.js.map +1 -1
- package/dist/internal/head-merge.d.ts +27 -0
- package/dist/internal/head-merge.d.ts.map +1 -0
- package/dist/internal/head-merge.js +65 -0
- package/dist/internal/head-merge.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +1 -2
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +1 -2
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/isr.d.ts +32 -3
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +181 -16
- package/dist/isr.js.map +1 -1
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +12 -0
- 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 +54 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +66 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +71 -4
- package/dist/public-dir.js.map +1 -1
- package/dist/route-manifest.d.ts +3 -1
- package/dist/route-manifest.d.ts.map +1 -1
- package/dist/route-manifest.js +3 -0
- package/dist/route-manifest.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +27 -1
- package/dist/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/client.ts +28 -0
- package/src/dev-ssr-graph.ts +311 -0
- package/src/dev.ts +81 -20
- package/src/index.ts +63 -71
- package/src/internal/head-merge.ts +80 -0
- package/src/internal/server-boundary.ts +1 -2
- package/src/isr.ts +216 -19
- package/src/manifest.ts +2 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +71 -0
- package/src/public-dir.ts +74 -4
- package/src/route-manifest.ts +10 -1
- package/src/vite.ts +29 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-module SSR invalidation for the **Bun pipeline's** dev server.
|
|
3
|
+
*
|
|
4
|
+
* Bun's runtime caches every module it has evaluated, and nothing invalidates that cache on a file
|
|
5
|
+
* change. The dev server's answer used to stop at the route module: `discoverRoutes({ importQuery })`
|
|
6
|
+
* appends a changing query to each route's own dynamic import, so the route re-evaluates. Its imports
|
|
7
|
+
* do not - a component, a helper, a `*.server` module all keep resolving to the same cached specifier,
|
|
8
|
+
* so SSR kept rendering the code that was on disk when the server started while the client, which Bun
|
|
9
|
+
* rebuilds properly, rendered the edit. Every framework on this pipeline had it; the Vite pipeline does
|
|
10
|
+
* not, because Vite owns its own SSR module graph.
|
|
11
|
+
*
|
|
12
|
+
* The fix is that module graph, in the small: watch what the server actually imported, notice which
|
|
13
|
+
* files changed, and give exactly those - and everything that imports them - a fresh cache key.
|
|
14
|
+
*
|
|
15
|
+
* ## Why a version per module, and why version 0 means no query
|
|
16
|
+
*
|
|
17
|
+
* A cache key is a `?v=<n>` query on the resolved absolute path. Bun treats a distinct query as a
|
|
18
|
+
* distinct registry entry and re-reads the file from disk (nifra's own Bun plugins already tolerate the
|
|
19
|
+
* suffix - see the `(\?|$)` in their filters), so a bump is a reload of that module and nothing else.
|
|
20
|
+
*
|
|
21
|
+
* That "nothing else" is the reason versions start at 0 and 0 rewrites nothing at all. A module that has
|
|
22
|
+
* never been edited keeps its bare path, which is the same registry entry the CLI already loaded when it
|
|
23
|
+
* read the app's config and backend - so a database client, a queue handle, any module-scope singleton
|
|
24
|
+
* shared between the backend and a route stays ONE instance. Versioning everything up front would have
|
|
25
|
+
* forked those on the first request. Editing such a module does fork it, deliberately: the routes get
|
|
26
|
+
* the new code and the already-running backend keeps the old, which is the same deal every dev server
|
|
27
|
+
* makes, and the same one a restart resolves.
|
|
28
|
+
*
|
|
29
|
+
* ## Why importers have to be bumped too
|
|
30
|
+
*
|
|
31
|
+
* Re-evaluating a module re-runs its imports through resolution, but a module that is NOT re-evaluated
|
|
32
|
+
* never re-resolves anything. With `route -> Layout -> Button` and an edit to `Button`, the route
|
|
33
|
+
* re-evaluates (its own query moved), asks for `Layout` under the key it always used, and gets the
|
|
34
|
+
* cached `Layout` back - which never mentions `Button` again. So a change propagates UP: the edited file
|
|
35
|
+
* and every transitive importer of it are bumped together, and the chain re-evaluates from the route
|
|
36
|
+
* down.
|
|
37
|
+
*
|
|
38
|
+
* ## Why the key is written into the source, not returned from `onResolve`
|
|
39
|
+
*
|
|
40
|
+
* Rewriting a resolution is the obvious mechanism and it only covers half of the imports people write.
|
|
41
|
+
* Bun's runtime consults resolver plugins for a specifier that carries a file extension
|
|
42
|
+
* (`"./Counter.tsx"`) and not for one that does not (`"./Counter"`, `"./components"`) - the extensionless
|
|
43
|
+
* form is resolved on an internal fast path that no plugin, in any namespace, is offered. Measured on
|
|
44
|
+
* Bun 1.3.14; the bundler has no such gap. Since extensionless is how most of this ecosystem's code is
|
|
45
|
+
* written, an `onResolve`-only fix would have silently covered a minority of real apps.
|
|
46
|
+
*
|
|
47
|
+
* So the version is applied one level earlier, in the importer's own source: on load, each app-owned
|
|
48
|
+
* module has its relative specifiers rewritten to absolute paths carrying the current version of the
|
|
49
|
+
* file they point at. `onResolve` stays as well, because it is the only hook that sees modules loaded by
|
|
50
|
+
* a third-party plugin this one does not wrap.
|
|
51
|
+
*
|
|
52
|
+
* Old versions stay in Bun's registry - a bounded dev-only leak, one entry per edit, which is what the
|
|
53
|
+
* route-level query already did.
|
|
54
|
+
*
|
|
55
|
+
* Bun-only + dev-only; never imported by the edge runtime or by a production build.
|
|
56
|
+
*/
|
|
57
|
+
import { statSync } from "node:fs"
|
|
58
|
+
import { dirname } from "node:path"
|
|
59
|
+
import type { BunPlugin } from "bun"
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extensions worth a version. Source and data only: an asset (`.css`, `.svg`, an image) either has no
|
|
63
|
+
* server-side meaning or, like CSS Modules, compiles to a pure function of its path, so re-reading it
|
|
64
|
+
* would change nothing about the rendered HTML.
|
|
65
|
+
*/
|
|
66
|
+
const VERSIONED = /\.(?:mts|cts|mjs|cjs|tsx|jsx|ts|js|svelte|vue|mdx|md|json)$/
|
|
67
|
+
|
|
68
|
+
/** Source this module can load and rewrite itself, when no framework plugin claims the file first. */
|
|
69
|
+
const REWRITABLE = "mts|cts|mjs|cjs|tsx|jsx|ts|js"
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `onLoad` filter, which has to do the whole job of selecting app source: a **runtime** `onLoad`
|
|
73
|
+
* handler may not decline a file it was offered (Bun raises `onLoad() expects an object returned`), so
|
|
74
|
+
* anything this matches is loaded here - and a dependency's `.js` loaded as ESM when the package meant
|
|
75
|
+
* CJS would break the app. Hence the shape: under the app root, no `node_modules` segment at any depth,
|
|
76
|
+
* no dot-directory (`.nifra-bun/`, `.git/`), and a source extension, with an optional cache-busting
|
|
77
|
+
* query. It mirrors {@link SsrGraph}'s ownership test; keep the two in step.
|
|
78
|
+
*/
|
|
79
|
+
const appSourceFilter = (root: string): RegExp =>
|
|
80
|
+
new RegExp(
|
|
81
|
+
`^${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/` +
|
|
82
|
+
`(?!.*/node_modules/)(?!\\.)(?!.*/\\.)[^?]*\\.(?:${REWRITABLE})(?:\\?|$)`,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `from "x"`, `import "x"`, `import("x")`, `require("x")` - every form that carries a static specifier,
|
|
87
|
+
* including `export ... from`. What it matches is only a candidate: a hit counts as an import only if the
|
|
88
|
+
* transpiler's own scan reported the same specifier, which is what keeps a lookalike string literal out.
|
|
89
|
+
*/
|
|
90
|
+
const SPECIFIER = /\b(from|import|require)(\s*\(?\s*)(["'])([^"'\n]+)\3/g
|
|
91
|
+
|
|
92
|
+
export interface SsrGraphOptions {
|
|
93
|
+
/** The app root. Only files under it (and outside `node_modules`) are tracked. */
|
|
94
|
+
readonly root: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SsrGraph {
|
|
98
|
+
/**
|
|
99
|
+
* The Bun **runtime** plugin that records the graph and re-keys imports. Register it before the first
|
|
100
|
+
* route module is imported; a runtime plugin only affects modules loaded after it.
|
|
101
|
+
*/
|
|
102
|
+
readonly plugin: BunPlugin
|
|
103
|
+
/**
|
|
104
|
+
* Re-stat every tracked module and bump the versions of the changed ones and their importers.
|
|
105
|
+
* Returns `true` when anything moved, i.e. when the app has to be rebuilt.
|
|
106
|
+
*/
|
|
107
|
+
sweep(): boolean
|
|
108
|
+
/** Bumped by each {@link sweep} that found a change. Belongs in the key the built app is cached under. */
|
|
109
|
+
generation(): number
|
|
110
|
+
/** Release the process-wide hook {@link rewriteSsrImports} reads. */
|
|
111
|
+
dispose(): void
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `/abs/path.ts?v=2` and `file:///abs/path.ts` both name `/abs/path.ts`. */
|
|
115
|
+
function plainPath(path: string): string {
|
|
116
|
+
const withoutQuery = path.split("?")[0] ?? path
|
|
117
|
+
return withoutQuery.startsWith("file://")
|
|
118
|
+
? decodeURIComponent(withoutQuery.slice("file://".length))
|
|
119
|
+
: withoutQuery
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const mtimeOf = (path: string): number =>
|
|
123
|
+
statSync(path, { throwIfNoEntry: false })?.mtimeMs ?? Number.NaN
|
|
124
|
+
|
|
125
|
+
/** What Bun should compile a file this module loaded itself as - the file's own extension decides. */
|
|
126
|
+
const loaderFor = (path: string): "tsx" | "ts" | "jsx" | "js" =>
|
|
127
|
+
path.endsWith(".tsx")
|
|
128
|
+
? "tsx"
|
|
129
|
+
: path.endsWith(".jsx")
|
|
130
|
+
? "jsx"
|
|
131
|
+
: /\.[cm]?ts$/.test(path)
|
|
132
|
+
? "ts"
|
|
133
|
+
: "js"
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What to PARSE a module as when scanning it for imports. `ts` is the safe default even for plain JS and
|
|
137
|
+
* for a plugin's compiled output (TS ⊃ JS), and the one thing it cannot also parse is JSX - which is why
|
|
138
|
+
* the JSX extensions are called out. Nothing here is emitted; the scan only reads specifiers.
|
|
139
|
+
*/
|
|
140
|
+
const scanLoaderFor = (path: string): "tsx" | "ts" =>
|
|
141
|
+
path.endsWith(".tsx") || path.endsWith(".jsx") ? "tsx" : "ts"
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The active graph, for the framework plugins that own a file extension this module cannot load itself
|
|
145
|
+
* (`.vue`, `.svelte`, `.tsx` under Solid, `.mdx`). Only one dev server runs per process.
|
|
146
|
+
*/
|
|
147
|
+
let active: { rewrite(contents: string, path: string): string } | undefined
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Re-key the app-owned imports of a module a **server-side** plugin just compiled. A plugin that claims a
|
|
151
|
+
* file extension is the only code that sees that file's source, so it is the only place its imports can
|
|
152
|
+
* be versioned; without this call every component in that language stays on the code it had at startup.
|
|
153
|
+
*
|
|
154
|
+
* A no-op outside a Bun dev server, and on the client half of a plugin - the client is bundled, and a
|
|
155
|
+
* bundle has no import cache to bust.
|
|
156
|
+
*/
|
|
157
|
+
export function rewriteSsrImports(contents: string, path: string, generate: "dom" | "ssr"): string {
|
|
158
|
+
if (generate !== "ssr" || active === undefined) return contents
|
|
159
|
+
return active.rewrite(contents, plainPath(path))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Track and version the app's own server-side module graph. See the module doc for the whole design. */
|
|
163
|
+
export function createSsrGraph(options: SsrGraphOptions): SsrGraph {
|
|
164
|
+
const root = options.root.endsWith("/") ? options.root.slice(0, -1) : options.root
|
|
165
|
+
const prefix = `${root}/`
|
|
166
|
+
/** Every tracked module, by absolute path: its current cache version and the mtime it was read at. */
|
|
167
|
+
const nodes = new Map<string, { version: number; mtime: number }>()
|
|
168
|
+
/** Reverse edges - who imports this file - which is the direction invalidation travels. */
|
|
169
|
+
const importers = new Map<string, Set<string>>()
|
|
170
|
+
let generation = 0
|
|
171
|
+
|
|
172
|
+
/** Inside the app, outside `node_modules`, outside dot-directories, and a file worth re-reading. */
|
|
173
|
+
const owned = (abs: string): boolean => {
|
|
174
|
+
if (!abs.startsWith(prefix)) return false
|
|
175
|
+
if (abs.includes("/node_modules/")) return false
|
|
176
|
+
// Relative to the app, so a project that itself lives under a dotted directory is not excluded
|
|
177
|
+
// wholesale - this is about the app's own generated trees (`.nifra/`, `.nifra-bun/`, `.git/`).
|
|
178
|
+
const rel = abs.slice(prefix.length)
|
|
179
|
+
if (rel.startsWith(".") || rel.includes("/.")) return false
|
|
180
|
+
return VERSIONED.test(abs)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const track = (abs: string, importer: string): void => {
|
|
184
|
+
if (!nodes.has(abs)) nodes.set(abs, { version: 0, mtime: mtimeOf(abs) })
|
|
185
|
+
const parents = importers.get(abs)
|
|
186
|
+
if (parents === undefined) importers.set(abs, new Set([importer]))
|
|
187
|
+
else parents.add(importer)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `Bun.resolveSync` re-enters this plugin's own `onResolve`, which is an unbounded recursion rather
|
|
191
|
+
// than a slow path. The flag is safe because the handler and `resolveSync` are both synchronous.
|
|
192
|
+
let resolving = false
|
|
193
|
+
|
|
194
|
+
const resolveFrom = (specifier: string, dir: string): string | undefined => {
|
|
195
|
+
resolving = true
|
|
196
|
+
try {
|
|
197
|
+
return Bun.resolveSync(specifier, dir)
|
|
198
|
+
} catch {
|
|
199
|
+
// Unresolvable here is not this module's error to report - leave it to Bun, which raises the
|
|
200
|
+
// diagnostic the app author can act on.
|
|
201
|
+
return undefined
|
|
202
|
+
} finally {
|
|
203
|
+
resolving = false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Record `specifier` as an edge out of `importer` and return what it should be imported as, or
|
|
209
|
+
* `undefined` to leave the specifier exactly as the author wrote it.
|
|
210
|
+
*/
|
|
211
|
+
const keyFor = (specifier: string, importer: string): string | undefined => {
|
|
212
|
+
const abs = resolveFrom(specifier, dirname(importer))
|
|
213
|
+
if (abs === undefined || !owned(abs)) return undefined
|
|
214
|
+
track(abs, importer)
|
|
215
|
+
const version = nodes.get(abs)?.version ?? 0
|
|
216
|
+
// Version 0 is the shared, unversioned instance - see the module doc.
|
|
217
|
+
return version === 0 ? undefined : `${abs}?v=${version}`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const rewrite = (contents: string, importer: string): string => {
|
|
221
|
+
if (!importer.startsWith(prefix) || importer.includes("/node_modules/")) return contents
|
|
222
|
+
let scanned: readonly { readonly path: string }[]
|
|
223
|
+
try {
|
|
224
|
+
scanned = new Bun.Transpiler({ loader: scanLoaderFor(importer) }).scanImports(contents)
|
|
225
|
+
} catch {
|
|
226
|
+
// Source Bun cannot parse is source Bun is about to reject with a real error message. Hand it
|
|
227
|
+
// through untouched rather than adding a second, worse one.
|
|
228
|
+
return contents
|
|
229
|
+
}
|
|
230
|
+
const candidates = new Set(scanned.map((entry) => entry.path).filter((p) => p.startsWith(".")))
|
|
231
|
+
if (candidates.size === 0) return contents
|
|
232
|
+
const keys = new Map<string, string>()
|
|
233
|
+
for (const specifier of candidates) {
|
|
234
|
+
const key = keyFor(specifier, importer)
|
|
235
|
+
if (key !== undefined) keys.set(specifier, key)
|
|
236
|
+
}
|
|
237
|
+
if (keys.size === 0) return contents
|
|
238
|
+
// Same length in lines and columns up to the specifier itself, so a stack trace still points at the
|
|
239
|
+
// line the author wrote.
|
|
240
|
+
return contents.replace(SPECIFIER, (whole, keyword, gap, quote, specifier) =>
|
|
241
|
+
keys.has(specifier) ? `${keyword}${gap}${quote}${keys.get(specifier)}${quote}` : whole,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const plugin: BunPlugin = {
|
|
246
|
+
name: "nifra-dev-ssr-graph",
|
|
247
|
+
setup(build) {
|
|
248
|
+
// Every app-owned module that no other plugin claims: read it, re-key its imports, hand it back.
|
|
249
|
+
// Loading it here is what makes the extensionless specifiers reachable at all (module doc).
|
|
250
|
+
build.onLoad({ filter: appSourceFilter(root) }, async (args) => {
|
|
251
|
+
const path = plainPath(args.path)
|
|
252
|
+
const contents = await Bun.file(path).text()
|
|
253
|
+
return { contents: rewrite(contents, path), loader: loaderFor(path) }
|
|
254
|
+
})
|
|
255
|
+
// The resolver half covers what the loader half cannot see: a module compiled by a plugin that
|
|
256
|
+
// does not call `rewriteSsrImports`. Extension-bearing specifiers only, which is all Bun offers.
|
|
257
|
+
build.onResolve({ filter: /^[./]/ }, (args) => {
|
|
258
|
+
if (resolving) return undefined
|
|
259
|
+
// A specifier that already carries a query is either this module's own rewrite or a plugin's
|
|
260
|
+
// virtual sub-request (`?vue-css` and friends) - both are already keyed.
|
|
261
|
+
if (args.path.includes("?")) return undefined
|
|
262
|
+
const importer = plainPath(args.importer)
|
|
263
|
+
if (!importer.startsWith(prefix) || importer.includes("/node_modules/")) return undefined
|
|
264
|
+
const key = keyFor(args.path, importer)
|
|
265
|
+
return key === undefined ? undefined : { path: key }
|
|
266
|
+
})
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const sweep = (): boolean => {
|
|
271
|
+
const dirty = new Set<string>()
|
|
272
|
+
for (const [abs, node] of nodes) {
|
|
273
|
+
const mtime = mtimeOf(abs)
|
|
274
|
+
// NaN (deleted, or unreadable mid-save) never equals the recorded value, so it counts as changed
|
|
275
|
+
// and the next successful stat settles it.
|
|
276
|
+
if (mtime === node.mtime) continue
|
|
277
|
+
node.mtime = mtime
|
|
278
|
+
dirty.add(abs)
|
|
279
|
+
}
|
|
280
|
+
if (dirty.size === 0) return false
|
|
281
|
+
// Breadth-first up the reverse edges: an importer of a changed module is itself stale, because it
|
|
282
|
+
// holds the old binding and would not re-resolve on its own.
|
|
283
|
+
const queue = [...dirty]
|
|
284
|
+
for (let i = 0; i < queue.length; i++) {
|
|
285
|
+
const child = queue[i]
|
|
286
|
+
if (child === undefined) continue
|
|
287
|
+
for (const parent of importers.get(child) ?? []) {
|
|
288
|
+
if (dirty.has(parent)) continue
|
|
289
|
+
dirty.add(parent)
|
|
290
|
+
queue.push(parent)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const abs of dirty) {
|
|
294
|
+
const node = nodes.get(abs)
|
|
295
|
+
if (node !== undefined) node.version += 1
|
|
296
|
+
}
|
|
297
|
+
generation += 1
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const hook = { rewrite }
|
|
302
|
+
active = hook
|
|
303
|
+
return {
|
|
304
|
+
plugin,
|
|
305
|
+
sweep,
|
|
306
|
+
generation: () => generation,
|
|
307
|
+
dispose: () => {
|
|
308
|
+
if (active === hook) active = undefined
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
}
|
package/src/dev.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* `@nifrajs/web/dev` - the **Bun pipeline's** dev server: one toolchain, no Vite in the process.
|
|
3
3
|
*
|
|
4
4
|
* nifra ships two dev pipelines and the rule between them is that a pipeline owns a whole phase. The
|
|
5
|
-
* Vite server (`@nifrajs/web/vite`) is the
|
|
6
|
-
* well as the client so both halves agree on every specifier. This is the other one:
|
|
7
|
-
* and hot-reloads the client while Bun's runtime resolves SSR. Only one toolchain is present, so the two
|
|
5
|
+
* Vite server (`@nifrajs/web/vite`) is the Vite-only escape hatch - mature framework plugins, and it
|
|
6
|
+
* resolves SSR as well as the client so both halves agree on every specifier. This is the other one:
|
|
7
|
+
* `Bun.serve` bundles and hot-reloads the client while Bun's runtime resolves SSR. Only one toolchain is present, so the two
|
|
8
8
|
* cannot disagree.
|
|
9
9
|
*
|
|
10
10
|
* What you get: React Fast Refresh WITH state preserved - Bun's dev server applies it natively, no plugin
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
* not, and saving it does a clean full reload. Plus no Vite dependency and ONE bundler across dev and
|
|
15
15
|
* production, which is the real prize - the dev/prod seam disappears.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* CSS Modules, server functions, and `*.server` modules use the same production transforms through the
|
|
18
|
+
* generated Bun config. The CLI owns that config because Bun's HTML dev server accepts plugins only from
|
|
19
|
+
* `[serve.static] plugins`; direct callers should pass the equivalent plugin through their Bun config.
|
|
20
20
|
*
|
|
21
21
|
* ## How the two halves meet
|
|
22
22
|
*
|
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
* production, which reads CSS from the build manifest, is perfectly fine.
|
|
39
39
|
*
|
|
40
40
|
* SSR invalidation is Bun's import cache rather than Vite's module graph, so route modules are re-imported
|
|
41
|
-
* under a changing query on each change - which is what `discoverRoutes({ importQuery })` exists for.
|
|
41
|
+
* under a changing query on each change - which is what `discoverRoutes({ importQuery })` exists for. That
|
|
42
|
+
* query stops at the route file, so everything BELOW it - components, helpers, `*.server` modules - is
|
|
43
|
+
* tracked and re-keyed per module by `./dev-ssr-graph.ts`, or SSR would render the code that was on disk
|
|
44
|
+
* when the server started.
|
|
42
45
|
*
|
|
43
46
|
* Bun-only + build-time; never imported by the edge runtime.
|
|
44
47
|
*/
|
|
@@ -56,8 +59,10 @@ import { type BuildClientOptions, buildClient } from "./build.ts"
|
|
|
56
59
|
import { type DevEntryMatch, resolveDevEntry } from "./bun-dev-entry.ts"
|
|
57
60
|
import { createDevDiagnostics } from "./dev-diagnostics.ts"
|
|
58
61
|
import { explainBindFailure } from "./dev-port.ts"
|
|
62
|
+
import { createSsrGraph, type SsrGraph } from "./dev-ssr-graph.ts"
|
|
59
63
|
import { discoverRoutes } from "./fs.ts"
|
|
60
64
|
import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
|
|
65
|
+
import { DEV_HMR_ENV, DEV_ROOT_ENV, DEV_ROUTES_ENV } from "./plugins/kit.ts"
|
|
61
66
|
import { servePublicDir } from "./public-dir.ts"
|
|
62
67
|
|
|
63
68
|
export { LAST_ERROR_PATH } from "./diagnostic.ts"
|
|
@@ -244,13 +249,54 @@ export function injectStyles(html: string, styles: readonly string[]): string {
|
|
|
244
249
|
export async function createDevServer(options: DevServerOptions): Promise<DevServer> {
|
|
245
250
|
const { createApp, port = DEFAULT_DEV_PORT, routesDir, clientModule } = options
|
|
246
251
|
const serve = bunServe()
|
|
252
|
+
// Tell the framework compiler plugins they are compiling for a DEV SERVER, so they may emit HMR
|
|
253
|
+
// wiring. It cannot be a plugin constructor argument: an app builds its `clientPlugins` once in
|
|
254
|
+
// `nifra.config.ts` and the same objects serve dev and `nifra build`. Guarding the emitted code with
|
|
255
|
+
// `if (import.meta.hot)` is not sufficient either - `Bun.build` keeps the branch, so the HMR calls
|
|
256
|
+
// ship to production. This flag is the phase signal; `nifra build` never sets it.
|
|
257
|
+
process.env[DEV_HMR_ENV] = "1"
|
|
258
|
+
// Dev-shaped runtimes for SSR, matching what the client bundle gets: a library ships its dev build
|
|
259
|
+
// behind the `development` export condition with a `NODE_ENV` fallback for resolvers that set no
|
|
260
|
+
// conditions, and Bun's runtime (where SSR runs) is one of those. `nifra dev` sets this on the server
|
|
261
|
+
// process before it reads the app config, which is earlier than this and is the point that matters -
|
|
262
|
+
// a framework runtime imported through the config has already read the flag by the time this line
|
|
263
|
+
// runs. This is the floor for a dev server started programmatically. `??=`, so a pinned value stands.
|
|
264
|
+
process.env.NODE_ENV ??= "development"
|
|
265
|
+
// `conditions` governs the production client bundle but CANNOT govern the one served here: Bun's
|
|
266
|
+
// dev-server bundler takes no resolve conditions, from `bunfig.toml` or anywhere else, so a package
|
|
267
|
+
// with an `exports` map can resolve to a different file in dev than in `nifra build`. SSR is fine -
|
|
268
|
+
// `nifra dev` passes these to the runtime as `--conditions` when it re-execs - which is exactly why
|
|
269
|
+
// this is worth a line: the halves of the dev process no longer agree, and the drift is invisible
|
|
270
|
+
// until production picks the other file. Said once at startup rather than swallowed.
|
|
271
|
+
if (options.conditions !== undefined && options.conditions.length > 0) {
|
|
272
|
+
console.warn(
|
|
273
|
+
`\n[nifra/web/dev] \`conditions: [${options.conditions.map((c) => `"${c}"`).join(", ")}]\` ` +
|
|
274
|
+
"does not reach the client bundle on the Bun dev pipeline.\n" +
|
|
275
|
+
" Bun's dev-server bundler accepts no resolve conditions, so a package with an `exports` map\n" +
|
|
276
|
+
" may resolve differently here than in `nifra build`. SSR does honour them.\n" +
|
|
277
|
+
" Run `nifra dev --vite` if this app needs exact dev/prod client resolution.\n",
|
|
278
|
+
)
|
|
279
|
+
}
|
|
247
280
|
// The app root. `routesDir` is `<app>/routes` by convention, so its parent is the project - the one
|
|
248
281
|
// place a generated entry can sit and still resolve the app's imports.
|
|
249
282
|
const root = resolve(routesDir, "..")
|
|
283
|
+
// Which files are the app's own components, for the plugins that hot-patch at component granularity
|
|
284
|
+
// (`devHotComponent`). Announced here rather than passed, for the same reason the flag above is.
|
|
285
|
+
process.env[DEV_ROOT_ENV] = root
|
|
286
|
+
process.env[DEV_ROUTES_ENV] = resolve(routesDir)
|
|
250
287
|
// The most recent SSR failure as a structured Diagnostic, scoped to THIS server. Served at
|
|
251
288
|
// LAST_ERROR_PATH so an agent driving the dev server reads the exact failure (code, codeframe, fix) as
|
|
252
289
|
// JSON instead of scraping the overlay. Shared with the Vite adapter so the endpoint can't drift.
|
|
253
290
|
const devDiagnostics = createDevDiagnostics(root)
|
|
291
|
+
// SSR freshness BELOW the route module. The route-level `importQuery` only ever reloaded the route
|
|
292
|
+
// itself; everything it imports is tracked here and re-keyed when it changes. Registered now because a
|
|
293
|
+
// Bun runtime plugin only affects modules loaded after it, and the first route import is `appFor`
|
|
294
|
+
// below - the CLI's own plugins (CSS Modules, the app's `serverPlugins`) register earlier still, and
|
|
295
|
+
// being earlier is what gives them first refusal on the specifiers and file types they own. That
|
|
296
|
+
// ordering is deliberate: a framework plugin compiling `.vue`/`.svelte`/`.tsx` keeps its loader, and
|
|
297
|
+
// re-keys the imports of what it compiled by handing its output through `rewriteSsrImports`.
|
|
298
|
+
const ssrGraph = createSsrGraph({ root })
|
|
299
|
+
;(await import("bun")).plugin(ssrGraph.plugin)
|
|
254
300
|
const devDir = resolve(root, DEV_DIR)
|
|
255
301
|
const entryPath = resolve(devDir, "entry.tsx")
|
|
256
302
|
const htmlPath = resolve(devDir, "entry.html")
|
|
@@ -271,9 +317,9 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
271
317
|
let server: BunServerHandle
|
|
272
318
|
// The last resolved entry, with the time it was resolved. Deliberately short-lived - see `currentEntry`.
|
|
273
319
|
let cache: { readonly entry: DevEntryMatch; readonly at: number } | undefined
|
|
274
|
-
// The app, tagged with the
|
|
275
|
-
let built: { readonly
|
|
276
|
-
let building: { readonly
|
|
320
|
+
// The app, tagged with the build it was made for - see `appFor`.
|
|
321
|
+
let built: { readonly key: string; readonly app: FetchApp } | undefined
|
|
322
|
+
let building: { readonly key: string; readonly promise: Promise<FetchApp> } | undefined
|
|
277
323
|
let version = 0
|
|
278
324
|
|
|
279
325
|
/**
|
|
@@ -321,27 +367,38 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
321
367
|
*
|
|
322
368
|
* The watcher still runs: it regenerates the entry when routes are added or removed, and re-checks for
|
|
323
369
|
* client leaks. It is no longer what keeps SSR correct.
|
|
370
|
+
*
|
|
371
|
+
* The entry hash is not the whole marker, though, because it only covers the CLIENT graph. A module
|
|
372
|
+
* the browser never receives - a `*.server` file, a loader's helper - can change without moving it, so
|
|
373
|
+
* the key also carries {@link SsrGraph} generation, which counts changes on the SERVER side. Either
|
|
374
|
+
* one moving rebuilds the app, which is what re-imports the route modules under a fresh query.
|
|
324
375
|
*/
|
|
325
|
-
const appFor = (
|
|
326
|
-
if (built?.
|
|
327
|
-
if (building?.
|
|
376
|
+
const appFor = (key: string): Promise<FetchApp> => {
|
|
377
|
+
if (built?.key === key) return Promise.resolve(built.app)
|
|
378
|
+
if (building?.key === key) return building.promise
|
|
328
379
|
version += 1
|
|
329
380
|
const promise = Promise.resolve(createApp(CLIENT_ENTRY_PATH, `v=${version}`))
|
|
330
|
-
building = {
|
|
381
|
+
building = { key, promise }
|
|
331
382
|
void promise
|
|
332
383
|
.then((next) => {
|
|
333
|
-
if (building?.
|
|
334
|
-
built = {
|
|
384
|
+
if (building?.key === key) {
|
|
385
|
+
built = { key, app: next }
|
|
335
386
|
building = undefined
|
|
336
387
|
}
|
|
337
388
|
})
|
|
338
389
|
.catch(() => {
|
|
339
390
|
// Clear the in-flight marker so the next request retries instead of re-awaiting a failed build.
|
|
340
|
-
if (building?.
|
|
391
|
+
if (building?.key === key) building = undefined
|
|
341
392
|
})
|
|
342
393
|
return promise
|
|
343
394
|
}
|
|
344
395
|
|
|
396
|
+
/** Check the server-side graph for changes, then hand back the app for what is on disk right now. */
|
|
397
|
+
const appForRequest = (entrySrc: string): Promise<FetchApp> => {
|
|
398
|
+
ssrGraph.sweep()
|
|
399
|
+
return appFor(`${entrySrc}#${ssrGraph.generation()}`)
|
|
400
|
+
}
|
|
401
|
+
|
|
345
402
|
try {
|
|
346
403
|
server = serve({
|
|
347
404
|
port,
|
|
@@ -367,14 +424,17 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
367
424
|
return new Response(body, { headers })
|
|
368
425
|
}
|
|
369
426
|
// Static probe before routing; a miss returns undefined and falls through, so no route is
|
|
370
|
-
// shadowed
|
|
427
|
+
// shadowed. Every GET/HEAD pays the probe, including page renders and API routes: the handler
|
|
428
|
+
// serves extension-less files too (an ACME challenge token is the reason), so there is no
|
|
429
|
+
// cheap prefilter to skip. Dev only - production serves `public/` from the CDN, and a caller
|
|
430
|
+
// mounting this handler itself can pass `files` to answer a miss without touching the disk.
|
|
371
431
|
const staticFile = await servePublic(req)
|
|
372
432
|
if (staticFile !== undefined) return staticFile
|
|
373
433
|
try {
|
|
374
434
|
// One fresh probe per request: it is both the freshness check for SSR and the stylesheet list,
|
|
375
435
|
// so the page cannot be rendered against a build the browser is not about to load.
|
|
376
436
|
const entry = await currentEntry(true)
|
|
377
|
-
const res = await (await
|
|
437
|
+
const res = await (await appForRequest(entry.src)).fetch(req)
|
|
378
438
|
if (!(res.headers.get("content-type") ?? "").includes("text/html")) return res
|
|
379
439
|
if (entry.styles.length === 0) return res
|
|
380
440
|
const headers = new Headers(res.headers)
|
|
@@ -405,7 +465,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
405
465
|
try {
|
|
406
466
|
// Build once up front so a dev server that cannot find the entry, or whose app fails to construct,
|
|
407
467
|
// fails at startup with a real diagnosis instead of on the first page request.
|
|
408
|
-
await
|
|
468
|
+
await appForRequest((await currentEntry()).src)
|
|
409
469
|
} catch (err) {
|
|
410
470
|
// Leaving the server up would answer 500s forever, which presents as a running server rather than as
|
|
411
471
|
// the startup failure it is.
|
|
@@ -481,6 +541,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
481
541
|
if (timer) clearTimeout(timer)
|
|
482
542
|
clearInterval(topologyTimer)
|
|
483
543
|
for (const file of watched) unwatchFile(file)
|
|
544
|
+
ssrGraph.dispose()
|
|
484
545
|
server.stop(true)
|
|
485
546
|
rmSync(devDir, { recursive: true, force: true })
|
|
486
547
|
},
|