@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.
- package/dist/boundary.d.ts +158 -0
- package/dist/boundary.d.ts.map +1 -0
- package/dist/boundary.js +237 -0
- package/dist/boundary.js.map +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +5 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +31 -13
- package/dist/build.js.map +1 -1
- package/dist/index.d.ts +30 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +163 -51
- package/dist/index.js.map +1 -1
- package/dist/internal/fetcher-runtime.d.ts +19 -0
- package/dist/internal/fetcher-runtime.d.ts.map +1 -0
- package/dist/internal/fetcher-runtime.js +26 -0
- package/dist/internal/fetcher-runtime.js.map +1 -0
- package/dist/internal/identity-policy.d.ts +51 -0
- package/dist/internal/identity-policy.d.ts.map +1 -0
- package/dist/internal/identity-policy.js +67 -0
- package/dist/internal/identity-policy.js.map +1 -0
- package/dist/internal/parity.d.ts +97 -8
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +281 -47
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/query-runtime.d.ts +8 -0
- package/dist/internal/query-runtime.d.ts.map +1 -0
- package/dist/internal/query-runtime.js +29 -0
- package/dist/internal/query-runtime.js.map +1 -0
- package/dist/islands.d.ts +2 -2
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +17 -36
- package/dist/islands.js.map +1 -1
- package/dist/manifest.d.ts +41 -6
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/render-seam.d.ts +5 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +10 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +10 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +233 -26
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +8 -6
- package/dist/vite.js.map +1 -1
- package/package.json +13 -2
- package/src/boundary.ts +407 -0
- package/src/build-vite.ts +5 -3
- package/src/build.ts +36 -14
- package/src/index.ts +235 -49
- package/src/internal/fetcher-runtime.ts +36 -0
- package/src/internal/identity-policy.ts +94 -0
- package/src/internal/parity.ts +409 -57
- package/src/internal/query-runtime.ts +31 -0
- package/src/islands.ts +22 -41
- package/src/manifest.ts +50 -3
- package/src/render-seam.ts +6 -0
- package/src/router.ts +271 -29
- package/src/vite.ts +8 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/web",
|
|
3
|
-
"version": "
|
|
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": "
|
|
189
|
+
"@nifrajs/core": "3.1.0",
|
|
190
|
+
"@nifrajs/island-trigger": "3.1.0"
|
|
180
191
|
},
|
|
181
192
|
"peerDependencies": {
|
|
182
193
|
"sass": "^1.0.0",
|
package/src/boundary.ts
ADDED
|
@@ -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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
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
|
|
1110
|
-
.filter((o) => cssNameOf(o.path) === bootstrapName)
|
|
1111
|
-
.
|
|
1112
|
-
|
|
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 =
|
|
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:
|
|
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),
|