@nifrajs/web 2.14.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/boundary.d.ts +158 -0
  2. package/dist/boundary.d.ts.map +1 -0
  3. package/dist/boundary.js +237 -0
  4. package/dist/boundary.js.map +1 -0
  5. package/dist/build-vite.d.ts.map +1 -1
  6. package/dist/build-vite.js +5 -3
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +31 -13
  10. package/dist/build.js.map +1 -1
  11. package/dist/index.d.ts +30 -9
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +163 -51
  14. package/dist/index.js.map +1 -1
  15. package/dist/internal/fetcher-runtime.d.ts +19 -0
  16. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  17. package/dist/internal/fetcher-runtime.js +26 -0
  18. package/dist/internal/fetcher-runtime.js.map +1 -0
  19. package/dist/internal/identity-policy.d.ts +51 -0
  20. package/dist/internal/identity-policy.d.ts.map +1 -0
  21. package/dist/internal/identity-policy.js +67 -0
  22. package/dist/internal/identity-policy.js.map +1 -0
  23. package/dist/internal/parity.d.ts +97 -8
  24. package/dist/internal/parity.d.ts.map +1 -1
  25. package/dist/internal/parity.js +281 -47
  26. package/dist/internal/parity.js.map +1 -1
  27. package/dist/internal/query-runtime.d.ts +8 -0
  28. package/dist/internal/query-runtime.d.ts.map +1 -0
  29. package/dist/internal/query-runtime.js +29 -0
  30. package/dist/internal/query-runtime.js.map +1 -0
  31. package/dist/islands.d.ts +2 -2
  32. package/dist/islands.d.ts.map +1 -1
  33. package/dist/islands.js +17 -36
  34. package/dist/islands.js.map +1 -1
  35. package/dist/manifest.d.ts +41 -6
  36. package/dist/manifest.d.ts.map +1 -1
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/render-seam.d.ts +5 -0
  39. package/dist/render-seam.d.ts.map +1 -1
  40. package/dist/render-seam.js +10 -0
  41. package/dist/render-seam.js.map +1 -1
  42. package/dist/router.d.ts +10 -0
  43. package/dist/router.d.ts.map +1 -1
  44. package/dist/router.js +233 -26
  45. package/dist/router.js.map +1 -1
  46. package/dist/vite.d.ts.map +1 -1
  47. package/dist/vite.js +8 -6
  48. package/dist/vite.js.map +1 -1
  49. package/package.json +13 -2
  50. package/src/boundary.ts +407 -0
  51. package/src/build-vite.ts +5 -3
  52. package/src/build.ts +36 -14
  53. package/src/index.ts +235 -49
  54. package/src/internal/fetcher-runtime.ts +36 -0
  55. package/src/internal/identity-policy.ts +94 -0
  56. package/src/internal/parity.ts +409 -57
  57. package/src/internal/query-runtime.ts +31 -0
  58. package/src/islands.ts +22 -41
  59. package/src/manifest.ts +50 -3
  60. package/src/render-seam.ts +6 -0
  61. package/src/router.ts +271 -29
  62. package/src/vite.ts +8 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web",
3
- "version": "2.14.1",
3
+ "version": "3.1.0",
4
4
  "description": "Framework-agnostic SSR core for nifra - the render seam + HTML document orchestration.",
5
5
  "keywords": [
6
6
  "bun",
@@ -159,6 +159,16 @@
159
159
  "bun": "./src/internal/parity.ts",
160
160
  "types": "./dist/internal/parity.d.ts",
161
161
  "default": "./dist/internal/parity.js"
162
+ },
163
+ "./internal/query-runtime": {
164
+ "bun": "./src/internal/query-runtime.ts",
165
+ "types": "./dist/internal/query-runtime.d.ts",
166
+ "default": "./dist/internal/query-runtime.js"
167
+ },
168
+ "./internal/fetcher-runtime": {
169
+ "bun": "./src/internal/fetcher-runtime.ts",
170
+ "types": "./dist/internal/fetcher-runtime.d.ts",
171
+ "default": "./dist/internal/fetcher-runtime.js"
162
172
  }
163
173
  },
164
174
  "files": [
@@ -176,7 +186,8 @@
176
186
  "access": "public"
177
187
  },
178
188
  "dependencies": {
179
- "@nifrajs/core": "2.14.1"
189
+ "@nifrajs/core": "3.1.0",
190
+ "@nifrajs/island-trigger": "3.1.0"
180
191
  },
181
192
  "peerDependencies": {
182
193
  "sass": "^1.0.0",
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Framework-neutral async boundaries. A boundary owns one named piece of UI data; the web core owns
3
+ * lifecycle, concurrency, and isolation while each adapter decides how its `render` result is mounted.
4
+ * The contract deliberately contains no React/Svelte/Vue/Preact types.
5
+ */
6
+
7
+ /** Request-scoped inputs available to dynamic and intercepting boundaries. */
8
+ export interface BoundaryRequestCtx {
9
+ readonly request: Request
10
+ readonly params: Readonly<Record<string, string>>
11
+ readonly api: unknown
12
+ readonly env: unknown
13
+ readonly draft: boolean
14
+ readonly search: Readonly<Record<string, unknown>>
15
+ readonly signal: AbortSignal
16
+ }
17
+
18
+ /** Build-safe inputs available to a static boundary. It intentionally has no request/session/params API. */
19
+ export interface StaticCtx {
20
+ readonly phase: "build"
21
+ readonly origin?: string
22
+ }
23
+
24
+ export type BoundaryMode = "static" | "dynamic" | { readonly intercept: string }
25
+
26
+ interface BoundaryBase<Data, UI> {
27
+ readonly name: string
28
+ readonly render: (data: Data) => UI
29
+ readonly fallback?: UI
30
+ /** Adapter-provided error slot name. The neutral core never imports a UI component. */
31
+ readonly errorId?: string
32
+ }
33
+
34
+ export type StaticBoundary<Data, UI> = BoundaryBase<Data, UI> & {
35
+ readonly mode: "static"
36
+ readonly load?: (ctx: StaticCtx) => Data | Promise<Data>
37
+ }
38
+
39
+ export type DynamicBoundary<Data, UI> = BoundaryBase<Data, UI> & {
40
+ readonly mode: "dynamic"
41
+ readonly load?: (ctx: BoundaryRequestCtx) => Data | Promise<Data>
42
+ }
43
+
44
+ export type InterceptBoundary<Data, UI> = BoundaryBase<Data, UI> & {
45
+ readonly mode: { readonly intercept: string }
46
+ readonly load?: (ctx: BoundaryRequestCtx) => Data | Promise<Data>
47
+ }
48
+
49
+ /** A discriminated union: the `mode` selects the context type accepted by `load`. */
50
+ export type Boundary<Data, UI> =
51
+ | StaticBoundary<Data, UI>
52
+ | DynamicBoundary<Data, UI>
53
+ | InterceptBoundary<Data, UI>
54
+
55
+ /**
56
+ * Erased runtime registration shape used by a route/layout manifest. This is intentionally a
57
+ * structural union rather than `Boundary<never, unknown>`: a registration keeps its loader's
58
+ * concrete data type at the declaration site, and function-parameter variance must not turn that
59
+ * loader into `Promise<never>` when the manifest erases it.
60
+ */
61
+ type ErasedBoundaryBase = {
62
+ readonly name: string
63
+ readonly render: (data: never) => unknown
64
+ readonly fallback?: unknown
65
+ readonly errorId?: string
66
+ }
67
+
68
+ export type BoundaryRegistration =
69
+ | (ErasedBoundaryBase & {
70
+ readonly mode: "static"
71
+ readonly load?: (ctx: StaticCtx) => unknown | Promise<unknown>
72
+ })
73
+ | (ErasedBoundaryBase & {
74
+ readonly mode: "dynamic"
75
+ readonly load?: (ctx: BoundaryRequestCtx) => unknown | Promise<unknown>
76
+ })
77
+ | (ErasedBoundaryBase & {
78
+ readonly mode: { readonly intercept: string }
79
+ readonly load?: (ctx: BoundaryRequestCtx) => unknown | Promise<unknown>
80
+ })
81
+
82
+ /** Neutral manifest descriptor: no framework component crosses the web core boundary. */
83
+ export interface BoundaryDescriptor {
84
+ readonly name: string
85
+ readonly mode: BoundaryMode
86
+ readonly hasLoad: boolean
87
+ readonly errorId?: string
88
+ }
89
+
90
+ export type BoundaryStatus = "unresolved" | "pending" | "ready" | "error"
91
+
92
+ export interface BoundaryError {
93
+ readonly name: string
94
+ readonly message: string
95
+ }
96
+
97
+ /** Serializable boundary state passed to the adapter's render seam. */
98
+ export interface BoundaryState {
99
+ readonly name: string
100
+ readonly mode: BoundaryMode
101
+ readonly status: BoundaryStatus
102
+ readonly data?: unknown
103
+ readonly error?: BoundaryError
104
+ readonly errorId?: string
105
+ }
106
+
107
+ export type BoundaryStates = Readonly<Record<string, BoundaryState>>
108
+
109
+ /** A dynamic load that has started but is not part of the initial render barrier. */
110
+ export interface PendingBoundary {
111
+ readonly name: string
112
+ readonly mode: BoundaryMode
113
+ readonly errorId?: string
114
+ readonly promise: Promise<unknown>
115
+ }
116
+
117
+ /** Runtime handles for a concurrent boundary batch. `initial` is renderable immediately. */
118
+ export interface DynamicBoundaryBatch {
119
+ readonly initial: BoundaryStates
120
+ readonly pending: ReadonlyArray<PendingBoundary>
121
+ /** Resolves to final per-boundary states for non-streaming consumers and tests. */
122
+ readonly complete: Promise<BoundaryStates>
123
+ }
124
+
125
+ /** Public in-memory reference cache for build-safe static boundary values. It holds no payload outside
126
+ * the process and is intentionally not a durable or tenant-aware cache implementation. */
127
+ export interface StaticBoundaryCache {
128
+ get(boundary: BoundaryRegistration): Promise<unknown> | undefined
129
+ set(boundary: BoundaryRegistration, value: Promise<unknown>): void
130
+ }
131
+
132
+ export class MemoryStaticBoundaryCache implements StaticBoundaryCache {
133
+ readonly #values = new WeakMap<object, Promise<unknown>>()
134
+
135
+ get(boundary: BoundaryRegistration): Promise<unknown> | undefined {
136
+ return this.#values.get(boundary as object)
137
+ }
138
+
139
+ set(boundary: BoundaryRegistration, value: Promise<unknown>): void {
140
+ this.#values.set(boundary as object, value)
141
+ }
142
+ }
143
+
144
+ export interface StaticBoundaryImportEdge {
145
+ readonly from: string
146
+ readonly to: string
147
+ }
148
+
149
+ export interface StaticBoundaryRoot {
150
+ readonly name: string
151
+ readonly module: string
152
+ }
153
+
154
+ /**
155
+ * Enforce the second half of the static-boundary safety boundary. A build adapter supplies the
156
+ * transitive module graph and the modules it has classified as request-scoped; a static root that
157
+ * reaches one fails before any shared shell is emitted. Keeping this check graph-shaped makes it
158
+ * usable by Bun/Vite without making the neutral web runtime depend on either bundler.
159
+ */
160
+ export function assertStaticBoundaryImports(
161
+ roots: readonly StaticBoundaryRoot[],
162
+ edges: readonly StaticBoundaryImportEdge[],
163
+ requestScopedModules: ReadonlySet<string>,
164
+ ): void {
165
+ const next = new Map<string, string[]>()
166
+ for (const edge of edges) {
167
+ const children = next.get(edge.from)
168
+ if (children === undefined) next.set(edge.from, [edge.to])
169
+ else children.push(edge.to)
170
+ }
171
+ for (const root of roots) {
172
+ const seen = new Set<string>([root.module])
173
+ const queue = [root.module]
174
+ for (let cursor = 0; cursor < queue.length; cursor++) {
175
+ const module = queue[cursor]
176
+ if (module === undefined) continue
177
+ if (requestScopedModules.has(module)) {
178
+ throw new TypeError(
179
+ `[nifra/web] static boundary "${root.name}" reaches request-scoped module "${module}"`,
180
+ )
181
+ }
182
+ for (const child of next.get(module) ?? []) {
183
+ if (!seen.has(child)) {
184
+ seen.add(child)
185
+ queue.push(child)
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ const BOUNDARY_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/
193
+ const MAX_INTERCEPT_PATH = 2048
194
+
195
+ const modeKey = (mode: BoundaryMode): string =>
196
+ typeof mode === "string" ? mode : `intercept:${mode.intercept}`
197
+
198
+ const assertMode = (mode: BoundaryMode): void => {
199
+ if (mode === "static" || mode === "dynamic") return
200
+ if (
201
+ typeof mode !== "object" ||
202
+ mode === null ||
203
+ typeof mode.intercept !== "string" ||
204
+ mode.intercept.length === 0 ||
205
+ mode.intercept.length > MAX_INTERCEPT_PATH ||
206
+ !mode.intercept.startsWith("/") ||
207
+ mode.intercept.startsWith("//")
208
+ ) {
209
+ throw new TypeError("[nifra/web] boundary intercept must be a same-origin path")
210
+ }
211
+ }
212
+
213
+ /** Validate and serialize the neutral boundary manifest. Duplicate names fail closed at startup. */
214
+ export function boundaryDescriptors(
215
+ boundaries: readonly BoundaryRegistration[],
216
+ ): ReadonlyArray<BoundaryDescriptor> {
217
+ const seen = new Set<string>()
218
+ return boundaries.map((boundary) => {
219
+ if (!BOUNDARY_NAME.test(boundary.name) || seen.has(boundary.name)) {
220
+ throw new TypeError(`[nifra/web] invalid or duplicate boundary name "${boundary.name}"`)
221
+ }
222
+ assertMode(boundary.mode)
223
+ seen.add(boundary.name)
224
+ return {
225
+ name: boundary.name,
226
+ mode: boundary.mode,
227
+ hasLoad: boundary.load !== undefined,
228
+ ...(boundary.errorId !== undefined ? { errorId: boundary.errorId } : {}),
229
+ }
230
+ })
231
+ }
232
+
233
+ const scopedContext = (context: BoundaryRequestCtx): BoundaryRequestCtx =>
234
+ Object.freeze({
235
+ request: context.request,
236
+ params: Object.freeze({ ...context.params }),
237
+ api: context.api,
238
+ env: context.env,
239
+ draft: context.draft,
240
+ search: Object.freeze({ ...context.search }),
241
+ signal: context.signal,
242
+ })
243
+
244
+ /**
245
+ * Redact a failed load into the client-visible slot. Boundary states are serialized into the document
246
+ * (`window.__NIFRA_BOUNDARIES__`), so the thrown error's own message never crosses: a driver or fetch
247
+ * failure carries hosts, credentials, and query text that a page is not allowed to publish. An `Error`
248
+ * subclass name is withheld for the same reason - it names the internal library. The real error goes to
249
+ * a generic server-console event, the same split `deferred.ts` uses for a rejected deferred value. The
250
+ * log deliberately carries only the failure kind: exception messages can contain credentials, query
251
+ * text, or user data and must not be copied into an operational sink by default.
252
+ *
253
+ * A boundary that wants to show the user something specific catches its own failure inside `load` and
254
+ * returns that as data; the framework never guesses which parts of an exception are publishable.
255
+ */
256
+ const boundaryError = (error: unknown): BoundaryError => {
257
+ if (error !== undefined) {
258
+ console.error("[nifra/web] boundary load failed", {
259
+ kind: error instanceof Error ? "error" : typeof error,
260
+ })
261
+ }
262
+ return { name: "Error", message: "Boundary failed" }
263
+ }
264
+
265
+ /**
266
+ * Resolve all dynamic boundaries concurrently. Each receives a fresh frozen context object; one
267
+ * failure becomes that boundary's error state and never rejects a sibling or exposes sibling data.
268
+ * Static/intercepting modes remain `unresolved` until their phase-specific runtime owns them.
269
+ */
270
+ export async function resolveDynamicBoundaries(
271
+ boundaries: readonly BoundaryRegistration[],
272
+ context: BoundaryRequestCtx,
273
+ ): Promise<BoundaryStates> {
274
+ return startDynamicBoundaries(boundaries, context).complete
275
+ }
276
+
277
+ /**
278
+ * Start all dynamic boundary loads at once without making the page wait for the slowest sibling.
279
+ * The initial states contain `status: "pending"`; callers that support deferred values can attach
280
+ * each `pending.promise` to its own slot. `complete` still provides settled, isolated states to
281
+ * non-streaming callers, preserving the simple reference API.
282
+ */
283
+ export function startDynamicBoundaries(
284
+ boundaries: readonly BoundaryRegistration[],
285
+ context: BoundaryRequestCtx,
286
+ ): DynamicBoundaryBatch {
287
+ // Validation only: a bad name, a duplicate, or a malformed intercept path throws here rather than
288
+ // producing a half-registered batch. It cannot return holes, so no slot below tests for one.
289
+ boundaryDescriptors(boundaries)
290
+ const initial: Record<string, BoundaryState> = {}
291
+ const pending: PendingBoundary[] = []
292
+
293
+ for (const boundary of boundaries) {
294
+ const base = {
295
+ name: boundary.name,
296
+ mode: boundary.mode,
297
+ ...(boundary.errorId !== undefined ? { errorId: boundary.errorId } : {}),
298
+ }
299
+ if (boundary.mode !== "dynamic") {
300
+ initial[boundary.name] = { ...base, status: "unresolved" }
301
+ continue
302
+ }
303
+ if (boundary.load === undefined) {
304
+ initial[boundary.name] = { ...base, status: "ready" }
305
+ continue
306
+ }
307
+ // Promise.resolve().then() converts a synchronous throw into this boundary's rejection, so it
308
+ // cannot abort siblings or escape before the caller attaches the deferred consumer.
309
+ const promise = Promise.resolve().then(() => boundary.load?.(scopedContext(context)))
310
+ pending.push({
311
+ name: boundary.name,
312
+ mode: boundary.mode,
313
+ ...(boundary.errorId !== undefined ? { errorId: boundary.errorId } : {}),
314
+ promise,
315
+ })
316
+ initial[boundary.name] = { ...base, status: "pending" }
317
+ }
318
+
319
+ const complete = Promise.all(
320
+ boundaries.map(async (boundary) => {
321
+ const base = {
322
+ name: boundary.name,
323
+ mode: boundary.mode,
324
+ ...(boundary.errorId !== undefined ? { errorId: boundary.errorId } : {}),
325
+ }
326
+ if (boundary.mode !== "dynamic") return { ...base, status: "unresolved" as const }
327
+ if (boundary.load === undefined) return { ...base, status: "ready" as const }
328
+ const load = pending.find((entry) => entry.name === boundary.name)
329
+ if (load === undefined)
330
+ return { ...base, status: "error" as const, error: boundaryError(undefined) }
331
+ try {
332
+ const data = await load.promise
333
+ return { ...base, status: "ready" as const, ...(data !== undefined ? { data } : {}) }
334
+ } catch (error) {
335
+ return { ...base, status: "error" as const, error: boundaryError(error) }
336
+ }
337
+ }),
338
+ ).then((states) => Object.fromEntries(states.map((state) => [state.name, state])))
339
+
340
+ return { initial, pending, complete }
341
+ }
342
+
343
+ /**
344
+ * Resolve only explicitly annotated static boundaries with a request-free build context. Dynamic
345
+ * and intercepting boundaries remain unresolved. Values are cached by boundary object identity in
346
+ * the supplied in-memory cache, so a worker instance does not repeat a build-safe computation per
347
+ * request. A rejected static load becomes that slot's error state; it never fabricates shared data.
348
+ */
349
+ export async function resolveStaticBoundaries(
350
+ boundaries: readonly BoundaryRegistration[],
351
+ context: StaticCtx,
352
+ cache: StaticBoundaryCache = DEFAULT_STATIC_CACHE,
353
+ ): Promise<BoundaryStates> {
354
+ // Validation only - see `startDynamicBoundaries`; the descriptor list has no holes to test for.
355
+ boundaryDescriptors(boundaries)
356
+ const states = await Promise.all(
357
+ boundaries.map(async (boundary) => {
358
+ if (boundary.mode !== "static") {
359
+ return {
360
+ name: boundary.name,
361
+ mode: boundary.mode,
362
+ status: "unresolved" as const,
363
+ ...(boundary.errorId !== undefined ? { errorId: boundary.errorId } : {}),
364
+ }
365
+ }
366
+ if (boundary.load === undefined) {
367
+ return {
368
+ name: boundary.name,
369
+ mode: boundary.mode,
370
+ status: "ready" as const,
371
+ ...(boundary.errorId !== undefined ? { errorId: boundary.errorId } : {}),
372
+ }
373
+ }
374
+ let value = cache.get(boundary)
375
+ if (value === undefined) {
376
+ value = Promise.resolve().then(() => boundary.load?.(context))
377
+ cache.set(boundary, value)
378
+ }
379
+ try {
380
+ const data = await value
381
+ return {
382
+ name: boundary.name,
383
+ mode: boundary.mode,
384
+ status: "ready" as const,
385
+ ...(data !== undefined ? { data } : {}),
386
+ ...(boundary.errorId !== undefined ? { errorId: boundary.errorId } : {}),
387
+ }
388
+ } catch (error) {
389
+ return {
390
+ name: boundary.name,
391
+ mode: boundary.mode,
392
+ status: "error" as const,
393
+ error: boundaryError(error),
394
+ ...(boundary.errorId !== undefined ? { errorId: boundary.errorId } : {}),
395
+ }
396
+ }
397
+ }),
398
+ )
399
+ return Object.fromEntries(states.map((state) => [state.name, state]))
400
+ }
401
+
402
+ const DEFAULT_STATIC_CACHE = new MemoryStaticBoundaryCache()
403
+
404
+ /** Stable mode label for adapter registries and diagnostics. */
405
+ export function boundaryModeKey(mode: BoundaryMode): string {
406
+ return modeKey(mode)
407
+ }
package/src/build-vite.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  } from "./build.ts"
34
34
  import { discoverRoutes } from "./fs.ts"
35
35
  import { generateClientEntry, generateServerManifest } from "./index.ts"
36
+ import { viteDedupePackages } from "./internal/identity-policy.ts"
36
37
  import {
37
38
  assertDevelopmentProductionParity,
38
39
  assertIdentityParity,
@@ -201,9 +202,10 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
201
202
  ...(options.define ?? {}),
202
203
  },
203
204
  resolve: {
204
- // Mirror the Bun build's reactDedupePlugin: one physical react/react-dom, or a hook-using route
205
- // gets a second dispatcher. No-op when the app isn't React.
206
- dedupe: ["react", "react-dom"],
205
+ // Same framework coverage as the Bun build's dedupe plugins, from the one policy table: one
206
+ // physical copy of react/react-dom, preact, and svelte, or a hook-using route gets a second
207
+ // dispatcher/options global. No-op per framework the app does not use.
208
+ dedupe: [...viteDedupePackages()],
207
209
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
208
210
  },
209
211
  css: {
package/src/build.ts CHANGED
@@ -8,10 +8,15 @@
8
8
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
9
9
  import { cp, lstat, mkdir, realpath } from "node:fs/promises"
10
10
  import { dirname, join, relative, resolve as resolvePath, sep } from "node:path"
11
+ import {
12
+ singleCopyPlugin as declaredSingleCopyPlugin,
13
+ readSingleCopyDeclaration,
14
+ } from "@nifrajs/core/single-copy"
11
15
  import { type BunPlugin, Glob } from "bun"
12
16
  import { sanitizeOutputNames } from "./chunk-names.ts"
13
17
  import { discoverRoutes } from "./fs.ts"
14
18
  import { generateClientEntry, generateServerManifest } from "./index.ts"
19
+ import { dedupePolicyFor } from "./internal/identity-policy.ts"
15
20
  import {
16
21
  assertDevelopmentProductionParity,
17
22
  assertIdentityParity,
@@ -1015,6 +1020,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1015
1020
  ...buildExtras,
1016
1021
  minify: options.minify ?? true,
1017
1022
  plugins: [
1023
+ ...declaredSingleCopyPlugins(root),
1018
1024
  reactDedupePlugin(routesDir),
1019
1025
  preactDedupePlugin(routesDir),
1020
1026
  svelteDedupePlugin(routesDir),
@@ -1105,12 +1111,17 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1105
1111
  const base = basename(path)
1106
1112
  return base.slice(0, base.lastIndexOf("-")) // strip `-${hash}.css`
1107
1113
  }
1114
+ // `css` is the union of every emitted stylesheet, matching the Vite pipeline (build-vite.ts) so the
1115
+ // field means the same thing on both bundlers. The bootstrap aggregate lists first, preserving the
1116
+ // fallback link order `createWebApp` uses (whole-app stylesheet before any route-scoped one); the
1117
+ // per-route `cssBundle` outputs follow. Dropping those per-route outputs (the old `aggregate`-only
1118
+ // shape) left them in `manifest.assets` but not `css`, so a route-scoped stylesheet normalized to
1119
+ // `asset:css` and tripped the module-graph parity contract - a bundler-dependent divergence.
1108
1120
  const cssAssets = result.outputs.filter((o) => o.kind === "asset" && o.path.endsWith(".css"))
1109
- const aggregate = cssAssets
1110
- .filter((o) => cssNameOf(o.path) === bootstrapName)
1111
- .map((o) => toUrl(o.path))
1112
- const css: readonly string[] =
1113
- aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path))
1121
+ const css: readonly string[] = [
1122
+ ...cssAssets.filter((o) => cssNameOf(o.path) === bootstrapName),
1123
+ ...cssAssets.filter((o) => cssNameOf(o.path) !== bootstrapName),
1124
+ ].map((o) => toUrl(o.path))
1114
1125
 
1115
1126
  // CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
1116
1127
  // `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
@@ -1244,7 +1255,7 @@ const reactDomEdgePlugin = (from: string): BunPlugin => ({
1244
1255
  * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
1245
1256
  * react-dom's build.
1246
1257
  */
1247
- const REACT_DEDUPE_SPECS = ["react", "react/jsx-runtime", "react/jsx-dev-runtime"] as const
1258
+ const REACT_DEDUPE_SPECS = dedupePolicyFor("react").bunSpecs ?? []
1248
1259
  export const reactDedupePlugin = (from: string): BunPlugin => ({
1249
1260
  name: "nifra-react-dedupe",
1250
1261
  setup(build) {
@@ -1272,13 +1283,7 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
1272
1283
  * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
1273
1284
  * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
1274
1285
  */
1275
- const PREACT_DEDUPE_SPECS = [
1276
- "preact",
1277
- "preact/hooks",
1278
- "preact/compat",
1279
- "preact/jsx-runtime",
1280
- "preact/jsx-dev-runtime",
1281
- ] as const
1286
+ const PREACT_DEDUPE_SPECS = dedupePolicyFor("preact").bunSpecs ?? []
1282
1287
  export const preactDedupePlugin = (from: string): BunPlugin => ({
1283
1288
  name: "nifra-preact-dedupe",
1284
1289
  setup(build) {
@@ -1295,6 +1300,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1295
1300
  },
1296
1301
  })
1297
1302
 
1303
+ const SVELTE_DEDUPE_PATTERN = dedupePolicyFor("svelte").bunPattern ?? /^svelte($|\/internal\/)/
1298
1304
  /**
1299
1305
  * Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
1300
1306
  * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
@@ -1313,7 +1319,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1313
1319
  export const svelteDedupePlugin = (from: string): BunPlugin => ({
1314
1320
  name: "nifra-svelte-dedupe",
1315
1321
  setup(build) {
1316
- build.onResolve({ filter: /^svelte($|\/internal\/)/ }, (args) => {
1322
+ build.onResolve({ filter: SVELTE_DEDUPE_PATTERN }, (args) => {
1317
1323
  try {
1318
1324
  return { path: Bun.resolveSync(args.path, from) }
1319
1325
  } catch {
@@ -1323,6 +1329,21 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
1323
1329
  },
1324
1330
  })
1325
1331
 
1332
+ /**
1333
+ * The app's declared single-copy rule, applied to the bundle.
1334
+ *
1335
+ * The three plugins above cover the framework runtimes by name, which is the case that bites hardest
1336
+ * and needs no configuration. This one covers what a name list cannot know: `@nifrajs/*` (two copies
1337
+ * of `@nifrajs/core` are two `Server` classes, and `Server` has private members, so `.merge()` stops
1338
+ * accepting the other's app) and whatever else the app named in
1339
+ * `"nifra": { "singleCopy": [...] }`.
1340
+ *
1341
+ * Nothing declared means no plugin at all - the plan does filesystem work, so it is not built for the
1342
+ * majority of apps that have a single copy of everything and never think about this.
1343
+ */
1344
+ const declaredSingleCopyPlugins = (root: string): readonly BunPlugin[] =>
1345
+ readSingleCopyDeclaration(root) === undefined ? [] : [declaredSingleCopyPlugin({ cwd: root })]
1346
+
1326
1347
  /**
1327
1348
  * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
1328
1349
  * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
@@ -1439,6 +1460,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1439
1460
  // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
1440
1461
  splitting: lazy,
1441
1462
  plugins: [
1463
+ ...declaredSingleCopyPlugins(resolvePath(dirname(routesDir))),
1442
1464
  ...(edge ? [reactDomEdgePlugin(entryDir), solidWebServerPlugin(entryDir)] : []),
1443
1465
  reactDedupePlugin(entryDir),
1444
1466
  preactDedupePlugin(entryDir),