@nifrajs/web 2.10.0 → 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 +72 -17
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +25 -24
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -76
- 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/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/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 +77 -19
- package/src/index.ts +55 -70
- package/src/internal/head-merge.ts +80 -0
- package/src/internal/server-boundary.ts +1 -2
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +71 -0
- package/src/vite.ts +29 -1
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,
|
|
@@ -377,7 +434,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
377
434
|
// One fresh probe per request: it is both the freshness check for SSR and the stylesheet list,
|
|
378
435
|
// so the page cannot be rendered against a build the browser is not about to load.
|
|
379
436
|
const entry = await currentEntry(true)
|
|
380
|
-
const res = await (await
|
|
437
|
+
const res = await (await appForRequest(entry.src)).fetch(req)
|
|
381
438
|
if (!(res.headers.get("content-type") ?? "").includes("text/html")) return res
|
|
382
439
|
if (entry.styles.length === 0) return res
|
|
383
440
|
const headers = new Headers(res.headers)
|
|
@@ -408,7 +465,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
408
465
|
try {
|
|
409
466
|
// Build once up front so a dev server that cannot find the entry, or whose app fails to construct,
|
|
410
467
|
// fails at startup with a real diagnosis instead of on the first page request.
|
|
411
|
-
await
|
|
468
|
+
await appForRequest((await currentEntry()).src)
|
|
412
469
|
} catch (err) {
|
|
413
470
|
// Leaving the server up would answer 500s forever, which presents as a running server rather than as
|
|
414
471
|
// the startup failure it is.
|
|
@@ -484,6 +541,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
484
541
|
if (timer) clearTimeout(timer)
|
|
485
542
|
clearInterval(topologyTimer)
|
|
486
543
|
for (const file of watched) unwatchFile(file)
|
|
544
|
+
ssrGraph.dispose()
|
|
487
545
|
server.stop(true)
|
|
488
546
|
rmSync(devDir, { recursive: true, force: true })
|
|
489
547
|
},
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,10 @@ import {
|
|
|
25
25
|
} from "./deferred.ts"
|
|
26
26
|
import { isDraftEnabled } from "./draft.ts"
|
|
27
27
|
import { trustedHeadAttributes } from "./internal/head-attributes.ts"
|
|
28
|
+
// Head resolution/merging. Defined in an internal module so `@nifrajs/web/client` can export the same
|
|
29
|
+
// two functions to the generated client entry without dragging THIS module (and its server code) into
|
|
30
|
+
// the browser graph - see internal/head-merge.ts. Re-exported here so the public API is unchanged.
|
|
31
|
+
import { mergeHeads, resolveMeta } from "./internal/head-merge.ts"
|
|
28
32
|
import { jsStringLiteral } from "./internal/js-string.ts"
|
|
29
33
|
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
|
|
30
34
|
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.ts"
|
|
@@ -195,7 +199,7 @@ export {
|
|
|
195
199
|
type SubmitOptions,
|
|
196
200
|
} from "./router.ts"
|
|
197
201
|
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
198
|
-
export { type Deferred, defer }
|
|
202
|
+
export { type Deferred, defer, mergeHeads, resolveMeta }
|
|
199
203
|
|
|
200
204
|
/** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
|
|
201
205
|
* object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
|
|
@@ -311,6 +315,43 @@ export interface RenderAdapter {
|
|
|
311
315
|
hydrationHead(nonce?: string): string
|
|
312
316
|
}
|
|
313
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Loads a module through the DEV SERVER'S module graph rather than the runtime's. Takes an absolute
|
|
320
|
+
* file path or a bare specifier; resolution, compilation and caching are the dev server's.
|
|
321
|
+
*/
|
|
322
|
+
export type SsrModuleLoader = (id: string) => Promise<unknown>
|
|
323
|
+
|
|
324
|
+
/* A `globalThis` singleton for the same reason `@nifrajs/web-react`'s router context is one: in dev
|
|
325
|
+
* this module is evaluated twice in one process (the CLI's copy under Bun, and the app's copy), and a
|
|
326
|
+
* module-level `let` would leave the adapter reading a slot the dev server set on the other copy. */
|
|
327
|
+
const SSR_MODULE_LOADER_SLOT = Symbol.for("nifra.web.ssr-module-loader")
|
|
328
|
+
/* Explicitly `| undefined`: clearing the slot is part of the contract (a dev server clears it on
|
|
329
|
+
* stop), and under `exactOptionalPropertyTypes` "absent" and "set to undefined" are separate types. */
|
|
330
|
+
const loaderSlot = globalThis as { [SSR_MODULE_LOADER_SLOT]?: SsrModuleLoader | undefined }
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Publishes the dev server's SSR module loader. Called by a dev server that owns SSR resolution
|
|
334
|
+
* itself; pass `undefined` to clear it on shutdown.
|
|
335
|
+
*/
|
|
336
|
+
export function setSsrModuleLoader(load: SsrModuleLoader | undefined): void {
|
|
337
|
+
loaderSlot[SSR_MODULE_LOADER_SLOT] = load
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The dev server's SSR module loader, or `undefined` when nothing owns SSR resolution but the runtime
|
|
342
|
+
* - the Bun dev pipeline, and every production build, where the adapter's assets are compiled ahead
|
|
343
|
+
* of time and a plain `import` is already the right thing.
|
|
344
|
+
*
|
|
345
|
+
* An adapter that must load a COMPILED asset at runtime (a `.svelte` component, say) has to prefer
|
|
346
|
+
* this over a bare `import`, and has to take the framework's SERVER RENDERER from it as well, not
|
|
347
|
+
* just the component. A component compiled by the dev server renders through the renderer the dev
|
|
348
|
+
* server resolved; mixing the two puts two copies of the framework runtime in one component tree,
|
|
349
|
+
* where context written by one half is invisible to the other.
|
|
350
|
+
*/
|
|
351
|
+
export function ssrModuleLoader(): SsrModuleLoader | undefined {
|
|
352
|
+
return loaderSlot[SSR_MODULE_LOADER_SLOT]
|
|
353
|
+
}
|
|
354
|
+
|
|
314
355
|
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
315
356
|
export const DATA_GLOBAL = "__NIFRA_DATA__"
|
|
316
357
|
/** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
|
|
@@ -1287,12 +1328,6 @@ function headTags(head: Meta | undefined): string {
|
|
|
1287
1328
|
return out
|
|
1288
1329
|
}
|
|
1289
1330
|
|
|
1290
|
-
/** Resolve a route's `meta` (static or a function of the loader data + params) to a {@link Meta}. */
|
|
1291
|
-
export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
|
|
1292
|
-
if (meta === undefined) return {}
|
|
1293
|
-
return typeof meta === "function" ? meta(args) : meta
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
1331
|
/**
|
|
1297
1332
|
* A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
|
|
1298
1333
|
* engines which URL is authoritative for a page (deduping query-string / tracking variants).
|
|
@@ -1379,68 +1414,6 @@ export function unsafeInlineScript(
|
|
|
1379
1414
|
}
|
|
1380
1415
|
}
|
|
1381
1416
|
|
|
1382
|
-
/**
|
|
1383
|
-
* Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
|
|
1384
|
-
*
|
|
1385
|
-
* The head contract (see {@link CreateWebAppOptions} and `LayoutEntry`): a `_layout.tsx` may export
|
|
1386
|
-
* `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
|
|
1387
|
-
* it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
|
|
1388
|
-
* render chain), and merges:
|
|
1389
|
-
* - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
|
|
1390
|
-
* inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
|
|
1391
|
-
* page `title` keeps it.
|
|
1392
|
-
* - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
|
|
1393
|
-
* de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
|
|
1394
|
-
* can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
|
|
1395
|
-
*
|
|
1396
|
-
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
1397
|
-
* static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
|
|
1398
|
-
* fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
1399
|
-
* content can vary with loader data anyway).
|
|
1400
|
-
*/
|
|
1401
|
-
export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
1402
|
-
// Single-head fast path (a route with no layout `meta`, by far the common case) - return the
|
|
1403
|
-
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
1404
|
-
if (heads.length === 1) return heads[0] as Meta
|
|
1405
|
-
let title: string | undefined
|
|
1406
|
-
let lang: string | undefined
|
|
1407
|
-
let dir: Meta["dir"]
|
|
1408
|
-
const meta: MetaDescriptor[] = []
|
|
1409
|
-
const link: LinkDescriptor[] = []
|
|
1410
|
-
const script: ScriptDescriptor[] = []
|
|
1411
|
-
const unsafeScript: UnsafeScriptDescriptor[] = []
|
|
1412
|
-
for (const h of heads) {
|
|
1413
|
-
if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
|
|
1414
|
-
if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
|
|
1415
|
-
if (h.dir !== undefined) dir = h.dir
|
|
1416
|
-
if (h.meta !== undefined) meta.push(...h.meta)
|
|
1417
|
-
if (h.link !== undefined) link.push(...h.link)
|
|
1418
|
-
if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
|
|
1419
|
-
if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
|
|
1420
|
-
}
|
|
1421
|
-
// Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
|
|
1422
|
-
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
1423
|
-
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
1424
|
-
// `undefined`).
|
|
1425
|
-
const merged: {
|
|
1426
|
-
title?: string
|
|
1427
|
-
meta?: Meta["meta"]
|
|
1428
|
-
link?: Meta["link"]
|
|
1429
|
-
script?: Meta["script"]
|
|
1430
|
-
unsafeScript?: Meta["unsafeScript"]
|
|
1431
|
-
lang?: string
|
|
1432
|
-
dir?: Meta["dir"]
|
|
1433
|
-
} = {}
|
|
1434
|
-
if (title !== undefined) merged.title = title
|
|
1435
|
-
if (meta.length > 0) merged.meta = meta
|
|
1436
|
-
if (link.length > 0) merged.link = link
|
|
1437
|
-
if (script.length > 0) merged.script = script
|
|
1438
|
-
if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
|
|
1439
|
-
if (lang !== undefined) merged.lang = lang
|
|
1440
|
-
if (dir !== undefined) merged.dir = dir
|
|
1441
|
-
return merged as Meta
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
1417
|
export interface CreateWebAppOptions {
|
|
1445
1418
|
readonly adapter: RenderAdapter
|
|
1446
1419
|
readonly manifest: Manifest
|
|
@@ -2447,7 +2420,9 @@ export function generateClientEntry(
|
|
|
2447
2420
|
}
|
|
2448
2421
|
|
|
2449
2422
|
return `${[
|
|
2450
|
-
|
|
2423
|
+
// `/client`, never the root: the root's graph carries the server (renderPage, the static-file
|
|
2424
|
+
// server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
|
|
2425
|
+
'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
|
|
2451
2426
|
'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
|
|
2452
2427
|
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
2453
2428
|
// access yields `undefined` if absent - unlike a named import, which would be a link error.
|
|
@@ -2464,6 +2439,16 @@ export function generateClientEntry(
|
|
|
2464
2439
|
'`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
|
|
2465
2440
|
)})`,
|
|
2466
2441
|
"const errorBoundary = __adapter.errorBoundary",
|
|
2442
|
+
// A hot update that the framework cannot actually apply must end in a reload, not in nothing.
|
|
2443
|
+
//
|
|
2444
|
+
// Bun's dev server applies React Fast Refresh to JSX modules. For React that patches the component
|
|
2445
|
+
// and preserves state. For Preact it does not - the module still LOOKS Fast-Refresh-able, so the
|
|
2446
|
+
// update is accepted and then swallowed: `bun:beforeUpdate`/`bun:afterUpdate` both fire, the server
|
|
2447
|
+
// logs a rebuild, and the page silently keeps rendering the old code. An edit that appears to do
|
|
2448
|
+
// nothing is the worst failure mode in a dev loop - worse than a reload, because there is no signal
|
|
2449
|
+
// to act on. An adapter that knows its framework is in that position says so, and the entry turns
|
|
2450
|
+
// the swallowed update into an honest full reload.
|
|
2451
|
+
'if (import.meta.hot && __adapter.hotUpdateNeedsReload) import.meta.hot.on("bun:afterUpdate", () => location.reload())',
|
|
2467
2452
|
`const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
|
|
2468
2453
|
// Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
|
|
2469
2454
|
// route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Head resolution + merging, in its own module so the CLIENT can have it without the server.
|
|
3
|
+
*
|
|
4
|
+
* These two are pure functions over `Meta`, but they used to live in `index.ts` - the module that also
|
|
5
|
+
* carries `renderPage`, `createWebApp` and the static-file server. The generated client entry imported
|
|
6
|
+
* them from `@nifrajs/web`, which under a bundler was harmless (server code tree-shakes away) and under
|
|
7
|
+
* Vite's dev server was fatal: it serves each module as-is, so pulling `index.ts` into the browser graph
|
|
8
|
+
* executed `public-dir.ts` and the page died on `Module "node:fs/promises" has been externalized`
|
|
9
|
+
* before hydrating. Splitting them out means the client entry never names the server module at all,
|
|
10
|
+
* rather than relying on a bundler to remove it afterwards.
|
|
11
|
+
*
|
|
12
|
+
* `index.ts` and `client.ts` both re-export from here, so the public API is unchanged in both places.
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
LinkDescriptor,
|
|
16
|
+
Meta,
|
|
17
|
+
MetaArgs,
|
|
18
|
+
MetaDescriptor,
|
|
19
|
+
MetaInput,
|
|
20
|
+
ScriptDescriptor,
|
|
21
|
+
UnsafeScriptDescriptor,
|
|
22
|
+
} from "../manifest.ts"
|
|
23
|
+
|
|
24
|
+
/** Resolve a route's `meta` export - a static object, or a function of the route's data/params. */
|
|
25
|
+
export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
|
|
26
|
+
if (meta === undefined) return {}
|
|
27
|
+
return typeof meta === "function" ? meta(args) : meta
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Merge a layout chain's heads, outermost first: `title`/`lang`/`dir` are nearest-wins, and
|
|
32
|
+
* `meta`/`link`/`script` concatenate in chain order.
|
|
33
|
+
*
|
|
34
|
+
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
35
|
+
* static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
|
|
36
|
+
* fine: `headTags`'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
37
|
+
* content can vary with loader data anyway).
|
|
38
|
+
*/
|
|
39
|
+
export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
40
|
+
// Single-head fast path (a route with no layout `meta`, by far the common case) - return the
|
|
41
|
+
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
42
|
+
if (heads.length === 1) return heads[0] as Meta
|
|
43
|
+
let title: string | undefined
|
|
44
|
+
let lang: string | undefined
|
|
45
|
+
let dir: Meta["dir"]
|
|
46
|
+
const meta: MetaDescriptor[] = []
|
|
47
|
+
const link: LinkDescriptor[] = []
|
|
48
|
+
const script: ScriptDescriptor[] = []
|
|
49
|
+
const unsafeScript: UnsafeScriptDescriptor[] = []
|
|
50
|
+
for (const h of heads) {
|
|
51
|
+
if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
|
|
52
|
+
if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
|
|
53
|
+
if (h.dir !== undefined) dir = h.dir
|
|
54
|
+
if (h.meta !== undefined) meta.push(...h.meta)
|
|
55
|
+
if (h.link !== undefined) link.push(...h.link)
|
|
56
|
+
if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
|
|
57
|
+
if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
|
|
58
|
+
}
|
|
59
|
+
// Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
|
|
60
|
+
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
61
|
+
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
62
|
+
// `undefined`).
|
|
63
|
+
const merged: {
|
|
64
|
+
title?: string
|
|
65
|
+
meta?: Meta["meta"]
|
|
66
|
+
link?: Meta["link"]
|
|
67
|
+
script?: Meta["script"]
|
|
68
|
+
unsafeScript?: Meta["unsafeScript"]
|
|
69
|
+
lang?: string
|
|
70
|
+
dir?: Meta["dir"]
|
|
71
|
+
} = {}
|
|
72
|
+
if (title !== undefined) merged.title = title
|
|
73
|
+
if (meta.length > 0) merged.meta = meta
|
|
74
|
+
if (link.length > 0) merged.link = link
|
|
75
|
+
if (script.length > 0) merged.script = script
|
|
76
|
+
if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
|
|
77
|
+
if (lang !== undefined) merged.lang = lang
|
|
78
|
+
if (dir !== undefined) merged.dir = dir
|
|
79
|
+
return merged as Meta
|
|
80
|
+
}
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The client build must never ship server-only code, or server-only config, to a browser. Three
|
|
5
5
|
* conventions enforce that, and each has to mean the SAME thing in every pipeline that builds the client
|
|
6
|
-
* - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun
|
|
7
|
-
* (which cannot transform, so it refuses to start instead):
|
|
6
|
+
* - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun`:
|
|
8
7
|
*
|
|
9
8
|
* - `*.server` modules - emptied, so a secret / `node:` / native import subtree never reaches the client.
|
|
10
9
|
* - `*.fn` modules - replaced with HTTP stubs, so the function bodies (and their imports) stay server-side.
|
|
Binary file
|
package/src/plugins/kit.ts
CHANGED
|
@@ -11,6 +11,16 @@ import type { BunPlugin } from "bun"
|
|
|
11
11
|
/** The argument Bun passes to a plugin's `setup` - Bun doesn't export the type, so derive it. */
|
|
12
12
|
export type PluginBuilder = Parameters<BunPlugin["setup"]>[0]
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Re-keys the app-owned imports of a module the SERVER half of a plugin just compiled, so an edit below
|
|
16
|
+
* a route reaches SSR instead of being served from Bun's import cache. A plugin that claims a file
|
|
17
|
+
* extension is the only code that ever sees that file's source, so it is the only place its imports can
|
|
18
|
+
* be versioned - every `generate: "ssr"` loader should return its output through this.
|
|
19
|
+
*
|
|
20
|
+
* A no-op on the client half and outside a dev server. See `../dev-ssr-graph.ts` for the whole design.
|
|
21
|
+
*/
|
|
22
|
+
export { rewriteSsrImports } from "../dev-ssr-graph.ts"
|
|
23
|
+
|
|
14
24
|
const packageRootCache = new Map<string, string>()
|
|
15
25
|
|
|
16
26
|
/** The nearest ancestor directory of `startDir` that holds a `package.json` (the file's package root),
|
|
@@ -57,6 +67,67 @@ export function hash8(input: string): string {
|
|
|
57
67
|
return h.toString(16).padStart(8, "0")
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Env flag a nifra dev server sets for its own lifetime, so a framework compiler plugin can tell a dev
|
|
72
|
+
* compile from a `nifra build` one. Exported for the dev server that sets it; plugins ask
|
|
73
|
+
* {@link devServerCompile} instead of reading it.
|
|
74
|
+
*/
|
|
75
|
+
export const DEV_HMR_ENV = "NIFRA_DEV_HMR"
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* True while a nifra dev server is running, i.e. the compile may emit HMR wiring (and, where the
|
|
79
|
+
* framework couples the two, dev-mode runtime output).
|
|
80
|
+
*
|
|
81
|
+
* The phase cannot be a plugin constructor argument: an app builds its `clientPlugins` once in
|
|
82
|
+
* `nifra.config.ts` and the same plugin objects serve both `nifra dev` and `nifra build`. Guarding the
|
|
83
|
+
* emitted code with `if (import.meta.hot)` is not sufficient either - `Bun.build` keeps the branch, so
|
|
84
|
+
* the HMR calls were measured shipping in production client chunks. The dev server announces itself in
|
|
85
|
+
* the environment instead, and `nifra build` never sets it. Read per compile (not captured at
|
|
86
|
+
* registration) so the flag is honoured however late the server sets it.
|
|
87
|
+
*/
|
|
88
|
+
export function devServerCompile(): boolean {
|
|
89
|
+
return process.env[DEV_HMR_ENV] === "1"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The app root and the routes directory of the running dev server, for plugins that need to tell one of
|
|
94
|
+
* the app's own components from a dependency or a route module. Set alongside {@link DEV_HMR_ENV}; read
|
|
95
|
+
* through {@link devHotComponent}.
|
|
96
|
+
*/
|
|
97
|
+
export const DEV_ROOT_ENV = "NIFRA_DEV_ROOT"
|
|
98
|
+
export const DEV_ROUTES_ENV = "NIFRA_DEV_ROUTES"
|
|
99
|
+
|
|
100
|
+
const under = (path: string, dir: string | undefined): boolean =>
|
|
101
|
+
dir !== undefined &&
|
|
102
|
+
dir !== "" &&
|
|
103
|
+
(path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`))
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a dev compile of `path` may wrap the module in component-level hot-patching - i.e. whether it
|
|
107
|
+
* is one of the app's OWN components rather than a dependency or a route module.
|
|
108
|
+
*
|
|
109
|
+
* The exclusions are not caution, they are two things such a wrapper cannot survive:
|
|
110
|
+
*
|
|
111
|
+
* - a **dependency**, whose components are the framework's own composition machinery. An adapter folds
|
|
112
|
+
* a layout chain through them, so they are entered from a snippet or as a dynamic component - and a
|
|
113
|
+
* wrapper there sits between the SSR markup and the component that is supposed to claim it.
|
|
114
|
+
* - a **route module**, for the same reason one level down: a route or layout IS a chain member, so it
|
|
115
|
+
* is always the dynamic child. It is also the wrong boundary to patch - a route module carries the
|
|
116
|
+
* loader and meta the server ran, and re-running those is a navigation, not a patch. Editing one
|
|
117
|
+
* finds no accepting module and reloads the page, which is the correct outcome.
|
|
118
|
+
*
|
|
119
|
+
* Both cost nothing in practice: the components an edit loop actually touches are the views, and a view
|
|
120
|
+
* that wants to hot-patch lives outside the routes directory. Where the split is unknown (no dev server,
|
|
121
|
+
* or a root that was never announced), the answer is `false` - a full reload is always correct, a
|
|
122
|
+
* mispatched tree is not.
|
|
123
|
+
*/
|
|
124
|
+
export function devHotComponent(path: string): boolean {
|
|
125
|
+
const root = process.env[DEV_ROOT_ENV]
|
|
126
|
+
if (!under(path, root)) return false
|
|
127
|
+
if (path.includes("/node_modules/")) return false
|
|
128
|
+
return !under(path, process.env[DEV_ROUTES_ENV])
|
|
129
|
+
}
|
|
130
|
+
|
|
60
131
|
/**
|
|
61
132
|
* Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module -
|
|
62
133
|
* the idiom the Vue plugin established (`?vue-css`). Register one per plugin `setup`; call `emit` per
|
package/src/vite.ts
CHANGED
|
@@ -20,9 +20,11 @@ import { relative, resolve as resolvePath } from "node:path"
|
|
|
20
20
|
import { createDevDiagnostics } from "./dev-diagnostics.ts"
|
|
21
21
|
import { listenOrExplain } from "./dev-port.ts"
|
|
22
22
|
import { discoverRoutes } from "./fs.ts"
|
|
23
|
-
import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
|
|
23
|
+
import { DEFAULT_DEV_PORT, generateClientEntry, setSsrModuleLoader } from "./index.ts"
|
|
24
24
|
import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
|
|
25
25
|
import { importVite } from "./internal/vite-import.ts"
|
|
26
|
+
import { scopedName } from "./plugins/css-modules.ts"
|
|
27
|
+
import { DEV_ROOT_ENV, DEV_ROUTES_ENV, reproduciblePath } from "./plugins/kit.ts"
|
|
26
28
|
import { viteServerFnStub } from "./plugins/vite-server-fn.ts"
|
|
27
29
|
import { viteServerOnlyEmpty } from "./plugins/vite-server-only.ts"
|
|
28
30
|
|
|
@@ -296,6 +298,12 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
296
298
|
const routesDir = resolvePath(options.routesDir)
|
|
297
299
|
const port = options.port ?? DEFAULT_DEV_PORT
|
|
298
300
|
|
|
301
|
+
// Which files are the app's own components, for framework plugins that hot-patch at component
|
|
302
|
+
// granularity (`devHotComponent`). Announced in the environment rather than passed, because the
|
|
303
|
+
// plugin that reads it is constructed by the app, in its own config, before this server exists.
|
|
304
|
+
process.env[DEV_ROOT_ENV] = root
|
|
305
|
+
process.env[DEV_ROUTES_ENV] = routesDir
|
|
306
|
+
|
|
299
307
|
// Codegen the client entry with Vite-servable, root-relative specifiers (e.g. `/routes/index.tsx`).
|
|
300
308
|
const toUrl = (file: string): string => `/${relative(root, `${routesDir}/${file}`)}`
|
|
301
309
|
const writeClientEntry = (): void => {
|
|
@@ -442,10 +450,26 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
442
450
|
externalConditions: ssrConditions,
|
|
443
451
|
},
|
|
444
452
|
},
|
|
453
|
+
css: {
|
|
454
|
+
modules: {
|
|
455
|
+
// Vite ships its own CSS-Modules naming (`_name_<base64>`); the Bun pipeline and every
|
|
456
|
+
// `nifra build` use `scopedName`. Left alone, one class is called two different things
|
|
457
|
+
// depending on which pipeline served it, so a selector written against a generated name works
|
|
458
|
+
// in dev and vanishes in production - and the two dev pipelines disagree with each other. The
|
|
459
|
+
// name is defined once, in the plugin, and Vite is handed that definition.
|
|
460
|
+
generateScopedName: (name: string, filename: string): string =>
|
|
461
|
+
scopedName(reproduciblePath(filename), name),
|
|
462
|
+
},
|
|
463
|
+
},
|
|
445
464
|
...(options.define ? { define: options.define } : {}),
|
|
446
465
|
})
|
|
447
466
|
|
|
448
467
|
const ssrLoad = (absolutePath: string): Promise<unknown> => vite.ssrLoadModule(absolutePath)
|
|
468
|
+
// Published so an ADAPTER can reach this graph too. Route modules already load through it; an
|
|
469
|
+
// adapter that has to load a compiled asset of its own on the server (`@nifrajs/web-svelte` and its
|
|
470
|
+
// `Chain.svelte`) would otherwise take it from the runtime, which has no compiler for it and no
|
|
471
|
+
// reason to agree with Vite about which copy of the framework runtime it renders through.
|
|
472
|
+
setSsrModuleLoader(ssrLoad)
|
|
449
473
|
app = await options.createApp(entryUrl, ssrLoad)
|
|
450
474
|
|
|
451
475
|
// Re-create the app on change so a hard reload picks up a route ADD/REMOVE (the manifest comes
|
|
@@ -486,6 +510,10 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
486
510
|
return {
|
|
487
511
|
port: typeof address === "object" && address !== null ? address.port : port,
|
|
488
512
|
stop: async () => {
|
|
513
|
+
// Cleared with the server that owns it: the slot is process-global, so a stopped server leaving
|
|
514
|
+
// its loader behind would hand the next one's adapter a graph that is closed (tests start and
|
|
515
|
+
// stop several dev servers in one process).
|
|
516
|
+
setSsrModuleLoader(undefined)
|
|
489
517
|
server.close()
|
|
490
518
|
await vite.close()
|
|
491
519
|
},
|