@nifrajs/web 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +433 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +174 -65
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +376 -135
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +209 -27
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +62 -5
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +44 -7
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +92 -9
- package/dist/draft.js.map +1 -1
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +15 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +8 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +266 -74
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +869 -179
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +8 -21
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +121 -40
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +136 -26
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +94 -22
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +170 -58
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +25 -17
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -33
- package/dist/vite.js.map +1 -1
- package/package.json +48 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +553 -0
- package/src/build.ts +563 -172
- package/src/bun-dev-entry.ts +184 -0
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +221 -30
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +468 -66
- package/src/draft.ts +124 -12
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +24 -6
- package/src/index.ts +1174 -225
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +18 -30
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +264 -48
- package/src/module-graph.ts +146 -0
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/public-dir.ts +108 -0
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +288 -74
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +103 -49
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bundler-neutral view of a client build, for the guards that must run on every pipeline.
|
|
3
|
+
*
|
|
4
|
+
* nifra's two client-leak guards - server-only code reaching the browser, and `node:` builtins in
|
|
5
|
+
* client code - are the reason a pipeline choice is safe. They are security guards, not lints: one
|
|
6
|
+
* stops secrets and database access shipping to a browser. Today both read Bun's metafile directly,
|
|
7
|
+
* so a second production pipeline would either arrive without them or need them hastily ported, and
|
|
8
|
+
* "mostly ported" is the wrong outcome for a guard of that kind.
|
|
9
|
+
*
|
|
10
|
+
* This is the seam that prevents that. It is introduced while Bun is still the ONLY producer, so the
|
|
11
|
+
* adapter can be verified against the existing behaviour rather than written under pressure beside a
|
|
12
|
+
* new bundler. Adding Rollup later becomes one more `from…` function rather than a second copy of the
|
|
13
|
+
* detection logic.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately minimal - exactly what the guards read, nothing more:
|
|
16
|
+
* - which modules exist and what each imports (to walk the import chain to a sink)
|
|
17
|
+
* - which output chunk each module landed in (to name the chunk a leak reached)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One import edge, as the bundler recorded it. */
|
|
21
|
+
export interface GraphImport {
|
|
22
|
+
/** The resolved path, when the bundler resolved it. */
|
|
23
|
+
readonly path?: string
|
|
24
|
+
/** The specifier as written in source - the only form that survives an unresolved import. */
|
|
25
|
+
readonly original?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GraphModule {
|
|
29
|
+
readonly imports: readonly GraphImport[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GraphChunk {
|
|
33
|
+
/** Source module this chunk is the entry for, if it is one. */
|
|
34
|
+
readonly entryPoint?: string
|
|
35
|
+
/** Module ids that landed in this chunk. */
|
|
36
|
+
readonly modules: readonly string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a client build looks like to the guards, whichever bundler produced it. */
|
|
40
|
+
export interface ClientModuleGraph {
|
|
41
|
+
/** Module id → its imports. Ids are bundler-native (Bun graph keys, Rollup module ids). */
|
|
42
|
+
readonly modules: Readonly<Record<string, GraphModule>>
|
|
43
|
+
/** Output path → what it contains. */
|
|
44
|
+
readonly chunks: Readonly<Record<string, GraphChunk>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The slice of Bun's metafile this seam consumes. Not yet in `@types/bun`; shape per the docs. */
|
|
48
|
+
export interface BunMetafileLike {
|
|
49
|
+
readonly inputs?: Readonly<
|
|
50
|
+
Record<string, { readonly imports?: ReadonlyArray<{ path?: string; original?: string }> }>
|
|
51
|
+
>
|
|
52
|
+
readonly outputs?: Readonly<
|
|
53
|
+
Record<
|
|
54
|
+
string,
|
|
55
|
+
{ readonly entryPoint?: string; readonly inputs?: Readonly<Record<string, unknown>> }
|
|
56
|
+
>
|
|
57
|
+
>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Adapt a `Bun.build` metafile to the neutral graph.
|
|
62
|
+
*
|
|
63
|
+
* A total function: an absent or partial metafile yields an empty graph rather than throwing, because
|
|
64
|
+
* a guard that crashes on an unexpected build shape fails the build for the wrong reason. An empty
|
|
65
|
+
* graph reports no findings, which matches the existing behaviour when the metafile is missing.
|
|
66
|
+
*/
|
|
67
|
+
export function fromBunMetafile(meta: BunMetafileLike | undefined): ClientModuleGraph {
|
|
68
|
+
const modules: Record<string, GraphModule> = {}
|
|
69
|
+
for (const [id, input] of Object.entries(meta?.inputs ?? {})) {
|
|
70
|
+
modules[id] = { imports: input.imports ?? [] }
|
|
71
|
+
}
|
|
72
|
+
const chunks: Record<string, GraphChunk> = {}
|
|
73
|
+
for (const [path, output] of Object.entries(meta?.outputs ?? {})) {
|
|
74
|
+
chunks[path] = {
|
|
75
|
+
...(output.entryPoint !== undefined ? { entryPoint: output.entryPoint } : {}),
|
|
76
|
+
modules: Object.keys(output.inputs ?? {}),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { modules, chunks }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The slice of a Rollup/Vite output bundle this seam consumes. `OutputBundle` is
|
|
84
|
+
* `Record<fileName, OutputChunk | OutputAsset>`; only chunks carry a module graph. Typed structurally
|
|
85
|
+
* (no `rollup`/`vite` type dependency) - the fields are stable Rollup output API.
|
|
86
|
+
*/
|
|
87
|
+
export interface RollupChunkLike {
|
|
88
|
+
/** `"chunk"` for JS output, `"asset"` for CSS/static - assets have no module graph. */
|
|
89
|
+
readonly type?: string
|
|
90
|
+
/** The entry module id this chunk was built for, if it is an entry. Rollup's `facadeModuleId`. */
|
|
91
|
+
readonly facadeModuleId?: string | null
|
|
92
|
+
/** Every module id that landed in this chunk. Rollup's `moduleIds`. */
|
|
93
|
+
readonly moduleIds?: readonly string[]
|
|
94
|
+
}
|
|
95
|
+
export type RollupBundleLike = Readonly<Record<string, RollupChunkLike>>
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Adapt a Rollup/Vite output bundle to the neutral graph - the second producer the seam was built for.
|
|
99
|
+
*
|
|
100
|
+
* The bundle records which modules landed in which chunk (`moduleIds`) but NOT each module's import
|
|
101
|
+
* edges, so those come from `importsOf`, which the caller backs with `this.getModuleInfo(id).importedIds`
|
|
102
|
+
* inside a plugin hook (a test backs it with a plain map). Edges carry only the RESOLVED id - Rollup does
|
|
103
|
+
* not keep the as-written specifier per edge - so `path` is set and `original` is left undefined. Both
|
|
104
|
+
* guards read the resolved `path` as their fallback (a `node:` prefix, the `server-only` basename), so
|
|
105
|
+
* detection is unaffected; only the human-readable chain shows resolved paths instead of as-written ones.
|
|
106
|
+
*
|
|
107
|
+
* Total, like {@link fromBunMetafile}: an empty bundle yields an empty graph (no findings), never a throw
|
|
108
|
+
* that would fail a build for the wrong reason.
|
|
109
|
+
*/
|
|
110
|
+
export function fromRollupBundle(
|
|
111
|
+
bundle: RollupBundleLike,
|
|
112
|
+
importsOf: (moduleId: string) => readonly string[],
|
|
113
|
+
): ClientModuleGraph {
|
|
114
|
+
const modules: Record<string, GraphModule> = {}
|
|
115
|
+
const chunks: Record<string, GraphChunk> = {}
|
|
116
|
+
for (const [fileName, output] of Object.entries(bundle)) {
|
|
117
|
+
// Assets (CSS, copied files) have no module graph; `type` is absent only on hand-built test inputs,
|
|
118
|
+
// which are always chunks, so treat "not explicitly an asset" as a chunk.
|
|
119
|
+
if (output.type === "asset") continue
|
|
120
|
+
const moduleIds = output.moduleIds ?? []
|
|
121
|
+
// Start from the real modules; `node:` builtins get appended below.
|
|
122
|
+
const chunkModules: string[] = [...moduleIds]
|
|
123
|
+
for (const id of moduleIds) {
|
|
124
|
+
const imports = importsOf(id).map((path) => ({ path }))
|
|
125
|
+
if (modules[id] === undefined) modules[id] = { imports }
|
|
126
|
+
// The guards locate a leak by finding the builtin IN a chunk's module list - Bun BUNDLES the `node:`
|
|
127
|
+
// polyfill, so the builtin appears there as an input. Rollup EXTERNALIZES `node:`, so it never lands
|
|
128
|
+
// in `moduleIds` and the guard would see nothing. Synthesize the Bun shape: register each imported
|
|
129
|
+
// builtin as a member of THIS chunk (where the leak is) and as a leaf module, so the neutral graph
|
|
130
|
+
// is identical whichever bundler produced it. Only `node:` needs this; a server-only-marked module
|
|
131
|
+
// is a real user module and is already a chunk member.
|
|
132
|
+
for (const im of imports) {
|
|
133
|
+
if (!im.path.startsWith("node:")) continue
|
|
134
|
+
if (!chunkModules.includes(im.path)) chunkModules.push(im.path)
|
|
135
|
+
if (modules[im.path] === undefined) modules[im.path] = { imports: [] }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
chunks[fileName] = {
|
|
139
|
+
// `facadeModuleId` is null for a shared/common chunk (no single entry) - omit it then, matching
|
|
140
|
+
// how a non-entry Bun output omits `entryPoint`, so the guards' entry set stays the real entries.
|
|
141
|
+
...(output.facadeModuleId ? { entryPoint: output.facadeModuleId } : {}),
|
|
142
|
+
modules: chunkModules,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { modules, chunks }
|
|
146
|
+
}
|
package/src/navigation.ts
CHANGED
|
@@ -1,23 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web` navigation bridge
|
|
3
|
-
* (a route component, which must import only from the agnostic `@nifrajs/web` main entry
|
|
2
|
+
* `@nifrajs/web` navigation bridge - a tiny, DOM-free seam that lets an adapter's `useNavigate`
|
|
3
|
+
* (a route component, which must import only from the agnostic `@nifrajs/web` main entry - never the
|
|
4
4
|
* DOM-only `/client`) reach the browser's history-aware navigate, WITHOUT the route component pulling
|
|
5
5
|
* `@nifrajs/web/client` (and its `document`/`history` access) into a bundle that also renders on the
|
|
6
6
|
* server.
|
|
7
7
|
*
|
|
8
8
|
* The browser layer (`installHistory`, in `./client.ts`) populates this on setup and clears it on
|
|
9
|
-
* teardown; a framework binding reads it via {@link getBrowserNavigate}. Module-level singleton
|
|
9
|
+
* teardown; a framework binding reads it via {@link getBrowserNavigate}. Module-level singleton - the
|
|
10
10
|
* browser mounts exactly one app per page (the same convention the adapters' `setMountedRouter` uses).
|
|
11
11
|
* On the server (and before hydration) the getter returns `undefined`, so a binding degrades to the
|
|
12
|
-
* native `<a href>` full-page navigation
|
|
12
|
+
* native `<a href>` full-page navigation - progressive enhancement, no throw.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { serializeSearch } from "./search.ts"
|
|
16
|
+
|
|
15
17
|
/** Options for a programmatic navigation. */
|
|
16
18
|
export interface NavigateOptions {
|
|
17
19
|
/** Replace the current history entry instead of pushing a new one (like `history.replaceState`). */
|
|
18
20
|
readonly replace?: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The augmentable route -> search-type map for typed cross-route navigation. Empty by default (so an
|
|
25
|
+
* object-form navigate to any path is allowed with a loose `search`). A build step (`nifra sync-routes`)
|
|
26
|
+
* OR the app declares one entry per route path against its `searchSchema` output:
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* declare module "@nifrajs/web" {
|
|
30
|
+
* interface RouteSearch {
|
|
31
|
+
* "/reports": { page: number; q: string }
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* With that, `navigate({ to: "/reports", search: { page: 2 } })` type-checks `search` against
|
|
37
|
+
* `/reports`'s schema, and a wrong shape is a compile error. See {@link NavigateTarget}.
|
|
38
|
+
*/
|
|
39
|
+
// biome-ignore lint/suspicious/noEmptyInterface: augmentation target - empty by design, apps/codegen fill it.
|
|
40
|
+
export interface RouteSearch {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `search` type for a navigate to `To`: the route's schema output when `To` is a mapped
|
|
44
|
+
* {@link RouteSearch} key, otherwise the loose `Record<string, unknown>` (so a navigate to any path is
|
|
45
|
+
* always allowed). Keyed on `To` rather than a union, so a mapped route can't fall back to the loose form
|
|
46
|
+
* with a wrong shape - `navigate({ to: "/reports", search: { page: "x" } })` is a compile error.
|
|
47
|
+
*/
|
|
48
|
+
export type NavigateSearchOf<To extends string> = To extends keyof RouteSearch
|
|
49
|
+
? RouteSearch[To]
|
|
50
|
+
: Record<string, unknown>
|
|
51
|
+
|
|
52
|
+
/** The runtime shape of an object-form navigate target (loose - the typed narrowing lives in
|
|
53
|
+
* {@link NavigateFunction}'s generic call signature). `to` is a bare pathname; `search` is serialized
|
|
54
|
+
* onto it; `replace` folds into the options. */
|
|
55
|
+
export interface NavigateTargetInput {
|
|
56
|
+
readonly to: string
|
|
57
|
+
readonly search?: Record<string, unknown>
|
|
58
|
+
readonly replace?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A programmatic navigate, shared by every adapter's `useNavigate`. Three forms: a string path (push, or
|
|
63
|
+
* replace via `{ replace: true }`), a history delta (`-1`/`1`), or an object target `{ to, search, replace }`
|
|
64
|
+
* whose `search` is typed against `to`'s route schema via {@link NavigateSearchOf} (a wrong shape for a
|
|
65
|
+
* mapped route is a compile error; an unmapped path takes a loose `search`). The object form's `search` is
|
|
66
|
+
* serialized onto `to`. A no-op on the server / before hydration.
|
|
67
|
+
*/
|
|
68
|
+
export interface NavigateFunction {
|
|
69
|
+
(to: string | number, options?: NavigateOptions): void
|
|
70
|
+
<To extends string>(target: {
|
|
71
|
+
readonly to: To
|
|
72
|
+
readonly search?: NavigateSearchOf<To>
|
|
73
|
+
readonly replace?: boolean
|
|
74
|
+
}): void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Normalize a navigate argument to the bridge's `(to, options)`: a string path or history-delta passes
|
|
79
|
+
* through; an object target has its `search` serialized onto `to` and its `replace` folded into the
|
|
80
|
+
* options. The one place the object form becomes a URL, so every adapter's `navigate` resolves it
|
|
81
|
+
* identically.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveNavigate(
|
|
84
|
+
to: string | number | NavigateTargetInput,
|
|
85
|
+
options?: NavigateOptions,
|
|
86
|
+
): { readonly to: string | number; readonly options: NavigateOptions | undefined } {
|
|
87
|
+
if (typeof to !== "object") return { to, options }
|
|
88
|
+
const query = to.search !== undefined ? serializeSearch(to.search) : ""
|
|
89
|
+
return { to: to.to + query, options: to.replace === true ? { replace: true } : options }
|
|
90
|
+
}
|
|
91
|
+
|
|
21
92
|
/**
|
|
22
93
|
* A history-aware navigate. A **string** `to` is a same-origin path (`/users/7?tab=a`) navigated to
|
|
23
94
|
* (push, or replace with `{ replace: true }`); a **number** is a history delta (`-1` back, `1`
|
|
@@ -29,7 +100,7 @@ export type BrowserNavigate = (to: string | number, options?: NavigateOptions) =
|
|
|
29
100
|
// per page; absent on the server and before hydration.
|
|
30
101
|
let browserNavigate: BrowserNavigate | undefined
|
|
31
102
|
|
|
32
|
-
/** Register (or clear, with `undefined`) the browser navigate
|
|
103
|
+
/** Register (or clear, with `undefined`) the browser navigate - called by `installHistory`. Not for
|
|
33
104
|
* app use. */
|
|
34
105
|
export function setBrowserNavigate(navigate: BrowserNavigate | undefined): void {
|
|
35
106
|
browserNavigate = navigate
|
|
@@ -40,3 +111,78 @@ export function setBrowserNavigate(navigate: BrowserNavigate | undefined): void
|
|
|
40
111
|
export function getBrowserNavigate(): BrowserNavigate | undefined {
|
|
41
112
|
return browserNavigate
|
|
42
113
|
}
|
|
114
|
+
|
|
115
|
+
/** A parsed navigation target the {@link BlockerFunction} decides on. `pathname`/`search`/`hash` match
|
|
116
|
+
* the DOM `Location` shape (search keeps its `?`, hash its `#`). */
|
|
117
|
+
export interface BlockerLocation {
|
|
118
|
+
readonly pathname: string
|
|
119
|
+
readonly search: string
|
|
120
|
+
readonly hash: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Decide whether a navigation should be halted. Receives where the app is (`currentLocation`) and where
|
|
125
|
+
* it's heading (`nextLocation`), so a guard can allow same-section moves and block only real exits. A
|
|
126
|
+
* boolean form (`useBlocker(isDirty)`) is sugar for `() => isDirty`. Runs synchronously at navigation
|
|
127
|
+
* time - return the current answer; the async "are you sure?" happens afterward via {@link Blocker}'s
|
|
128
|
+
* `proceed`/`reset`.
|
|
129
|
+
*/
|
|
130
|
+
export type BlockerFunction = (args: {
|
|
131
|
+
readonly currentLocation: BlockerLocation
|
|
132
|
+
readonly nextLocation: BlockerLocation
|
|
133
|
+
}) => boolean
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The blocker's lifecycle. `unblocked` - idle, nothing intercepted. `blocked` - a navigation was halted
|
|
137
|
+
* and is awaiting the app's decision (`proceed`/`reset` are live). `proceeding` - the app called
|
|
138
|
+
* `proceed`; the held navigation is being replayed.
|
|
139
|
+
*/
|
|
140
|
+
export type BlockerState = "unblocked" | "blocked" | "proceeding"
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A navigation guard, mirroring react-router's shape. When `state` is `blocked`, `proceed()` lets the
|
|
144
|
+
* held navigation through and `reset()` cancels it (staying put); both are `undefined` otherwise. The
|
|
145
|
+
* pair is what a boolean `when` can't express - the app shows its OWN async confirmation UI, then calls
|
|
146
|
+
* one of them, instead of the browser's synchronous `confirm()`.
|
|
147
|
+
*/
|
|
148
|
+
export interface Blocker {
|
|
149
|
+
readonly state: BlockerState
|
|
150
|
+
readonly proceed: (() => void) | undefined
|
|
151
|
+
readonly reset: (() => void) | undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The idle blocker - a stable reference (no needless adapter re-renders while unblocked). */
|
|
155
|
+
export const IDLE_BLOCKER: Blocker = { state: "unblocked", proceed: undefined, reset: undefined }
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The browser layer's blocker registry - installed by `installHistory` (which owns navigation and can
|
|
159
|
+
* therefore halt, restore, and replay it) and read by an adapter's `useBlocker` through
|
|
160
|
+
* {@link registerBlocker}. Kept here, DOM-free, for the same reason as {@link BrowserNavigate}: a route
|
|
161
|
+
* component's blocker hook must import only this agnostic entry.
|
|
162
|
+
*/
|
|
163
|
+
export interface BlockerController {
|
|
164
|
+
register(shouldBlock: BlockerFunction, onChange: (blocker: Blocker) => void): () => void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The active controller (set by `installHistory`, cleared on teardown). Module-scoped: one app per page.
|
|
168
|
+
let blockerController: BlockerController | undefined
|
|
169
|
+
|
|
170
|
+
/** Register (or clear, with `undefined`) the blocker controller - called by `installHistory`. Not for
|
|
171
|
+
* app use. */
|
|
172
|
+
export function setBlockerController(controller: BlockerController | undefined): void {
|
|
173
|
+
blockerController = controller
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Register a navigation guard, returning an unregister function. `onChange` is called with the current
|
|
178
|
+
* {@link Blocker} whenever its state changes (so the adapter can re-render its confirmation UI). Before
|
|
179
|
+
* `installHistory` has run (SSR, pre-hydration), there's no controller: registration is a no-op and the
|
|
180
|
+
* caller stays on {@link IDLE_BLOCKER}, degrading to native navigation. Called by an adapter's
|
|
181
|
+
* `useBlocker`, not by app code directly.
|
|
182
|
+
*/
|
|
183
|
+
export function registerBlocker(
|
|
184
|
+
shouldBlock: BlockerFunction,
|
|
185
|
+
onChange: (blocker: Blocker) => void,
|
|
186
|
+
): () => void {
|
|
187
|
+
return blockerController?.register(shouldBlock, onChange) ?? (() => {})
|
|
188
|
+
}
|
|
Binary file
|
package/src/plugins/kit.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web/plugins` shared kit
|
|
2
|
+
* `@nifrajs/web/plugins` shared kit - the small infrastructure every CSS-bearing asset plugin reuses, so
|
|
3
3
|
* P1 (CSS Modules), P2 (SCSS), and any future P4 (LESS/Stylus/PostCSS) don't each re-implement the
|
|
4
4
|
* CSS→client-bundle wiring, the deterministic hash, or the optional-peer guard. Internal to
|
|
5
5
|
* `@nifrajs/web` (imported by the sibling plugin modules); not a public subpath.
|
|
@@ -8,7 +8,7 @@ import { existsSync } from "node:fs"
|
|
|
8
8
|
import { dirname, relative } from "node:path"
|
|
9
9
|
import type { BunPlugin } from "bun"
|
|
10
10
|
|
|
11
|
-
/** The argument Bun passes to a plugin's `setup`
|
|
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
14
|
const packageRootCache = new Map<string, string>()
|
|
@@ -23,7 +23,7 @@ function packageRootOf(startDir: string): string {
|
|
|
23
23
|
if (existsSync(`${dir}/package.json`)) break
|
|
24
24
|
const parent = dirname(dir)
|
|
25
25
|
if (parent === dir) {
|
|
26
|
-
dir = startDir // reached the filesystem root with no package.json
|
|
26
|
+
dir = startDir // reached the filesystem root with no package.json - anchor on the file's own dir
|
|
27
27
|
break
|
|
28
28
|
}
|
|
29
29
|
dir = parent
|
|
@@ -33,11 +33,11 @@ function packageRootOf(startDir: string): string {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* A **package-root-relative**, forward-slashed form of an absolute path
|
|
36
|
+
* A **package-root-relative**, forward-slashed form of an absolute path - the input to {@link hash8} for
|
|
37
37
|
* any build-stable identifier (e.g. CSS-module scoped names). Anchoring on the file's nearest
|
|
38
38
|
* `package.json` (not the absolute path, not `process.cwd()`) makes the result independent of BOTH the
|
|
39
|
-
* machine's directory layout AND the working directory: the dom build and the ssr runtime
|
|
40
|
-
* different machines or from different cwds
|
|
39
|
+
* machine's directory layout AND the working directory: the dom build and the ssr runtime - even on
|
|
40
|
+
* different machines or from different cwds - derive the SAME relative path for a given file, so their
|
|
41
41
|
* scoped class maps always agree. (Hashing the absolute path would differ across CI/host; a
|
|
42
42
|
* cwd-relative path would silently desync if the SSR server started from a non-project-root cwd.)
|
|
43
43
|
*/
|
|
@@ -47,7 +47,7 @@ export function reproduciblePath(absolutePath: string): string {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Deterministic 8-hex hash (djb2/xor). Stable across builds
|
|
50
|
+
* Deterministic 8-hex hash (djb2/xor). Stable across builds - no `Date.now`/`Math.random` - so build
|
|
51
51
|
* output is reproducible. The single hash implementation behind CSS-module scoped names (and a drop-in
|
|
52
52
|
* for any SFC scope id).
|
|
53
53
|
*/
|
|
@@ -58,7 +58,7 @@ export function hash8(input: string): string {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module
|
|
61
|
+
* Records compiled CSS and wires it into the client bundle through a virtual `?<namespace>` module -
|
|
62
62
|
* the idiom the Vue plugin established (`?vue-css`). Register one per plugin `setup`; call `emit` per
|
|
63
63
|
* file to stash its CSS and get back the `import` line to append to the JS module.
|
|
64
64
|
*/
|
|
@@ -70,7 +70,7 @@ export interface StylesheetEmitter {
|
|
|
70
70
|
/**
|
|
71
71
|
* Wire the virtual-CSS-module handlers onto `build` for `namespace`, returning an {@link StylesheetEmitter}.
|
|
72
72
|
* The `namespace` must be a plain identifier (letters/`-`); it's used verbatim as the import suffix and
|
|
73
|
-
* the Bun namespace. Only the `"dom"` build should emit CSS
|
|
73
|
+
* the Bun namespace. Only the `"dom"` build should emit CSS - the `"ssr"` build ships no stylesheet.
|
|
74
74
|
*/
|
|
75
75
|
export function createStylesheetEmitter(
|
|
76
76
|
build: PluginBuilder,
|
|
@@ -97,7 +97,7 @@ export function createStylesheetEmitter(
|
|
|
97
97
|
|
|
98
98
|
/**
|
|
99
99
|
* Load an optional peer compiler at build time, throwing a consistent, actionable install-hint error if
|
|
100
|
-
* it's absent
|
|
100
|
+
* it's absent - the `@vue/compiler-sfc` peer pattern, centralized. Build-time only, so the dynamic
|
|
101
101
|
* `import` (which keeps the peer out of the package's hard dependencies) is correct here.
|
|
102
102
|
*/
|
|
103
103
|
export async function requirePeer<T>(
|
|
@@ -107,7 +107,7 @@ export async function requirePeer<T>(
|
|
|
107
107
|
try {
|
|
108
108
|
return (await import(specifier)) as T
|
|
109
109
|
} catch (err) {
|
|
110
|
-
// Only a genuine resolution failure means "not installed"
|
|
110
|
+
// Only a genuine resolution failure means "not installed" - surface anything else (a corrupt
|
|
111
111
|
// install, a native-binding error, a throw at module top-level) as the real error rather than
|
|
112
112
|
// masking it behind a misleading "please install it" hint.
|
|
113
113
|
if ((err as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
|
package/src/plugins/scss.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web/plugins/scss`
|
|
2
|
+
* `@nifrajs/web/plugins/scss` - a SASS/SCSS (`*.scss`, `*.sass`) Bun plugin, in its OWN module so the SSR
|
|
3
3
|
* preload registers it BEFORE any Sass file loads. Mirrors the `@nifrajs/web-vue/plugin` seam: pass
|
|
4
4
|
* `"dom"` for the client bundle (`buildClient({ plugins: [...] })`) and preload `"ssr"` for the server
|
|
5
5
|
* (`bun --preload`).
|
|
6
6
|
*
|
|
7
|
-
* The Dart Sass compiler is an **optional peer** (`sass`, or the faster `sass-embedded`
|
|
7
|
+
* The Dart Sass compiler is an **optional peer** (`sass`, or the faster `sass-embedded` - same API), not
|
|
8
8
|
* a hard dependency: it's loaded on first Sass file and fails loud with an install hint if absent. Pass
|
|
9
9
|
* your own `compiler` to override (e.g. `sass-embedded`, or a stub in tests).
|
|
10
10
|
*
|
|
11
11
|
* Composes with CSS Modules: a `*.module.scss` / `*.module.sass` file is compiled to CSS and then run
|
|
12
12
|
* through the same scoped-class transform as `@nifrajs/web/plugins/css-modules`, so its `import styles`
|
|
13
13
|
* yields the `{ original: scoped }` map (SSR/dom class-map parity included). A plain `*.scss` is a
|
|
14
|
-
* side-effect import
|
|
14
|
+
* side-effect import - its CSS is bundled (dom) and it resolves to an empty module (ssr).
|
|
15
15
|
*/
|
|
16
16
|
import { dirname } from "node:path"
|
|
17
17
|
import { pathToFileURL } from "node:url"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/plugins/vite-leak-guard` - nifra's two client-leak guards, for a Vite/Rollup production
|
|
3
|
+
* build.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* nifra's production default is Bun, and that is not changing: a Vite/Rollup production build inherits
|
|
8
|
+
* Rollup's build profile and gives up the Bun-native advantage nifra competes on. But some apps depend on
|
|
9
|
+
* a Vite-only transform that has no Bun equivalent, and for those a Vite production build is the escape
|
|
10
|
+
* hatch. The moment that hatch exists, the two client-leak guards have to come with it - they are security
|
|
11
|
+
* guards, not lints: one stops secrets and database access from shipping to a browser. A second production
|
|
12
|
+
* pipeline arriving WITHOUT them, or with a hastily re-implemented "mostly ported" copy, is the failure the
|
|
13
|
+
* neutral module-graph seam was built to prevent.
|
|
14
|
+
*
|
|
15
|
+
* So this is not a second implementation. The detection logic (`detectNodeBuiltinsInClient`,
|
|
16
|
+
* `detectServerOnlyInClient`) and the failure MESSAGES (`formatNodeBuiltinLeak`, `formatServerOnlyLeak`)
|
|
17
|
+
* are the exact same functions the Bun build calls. This plugin only adapts Rollup's bundle shape into the
|
|
18
|
+
* neutral `ClientModuleGraph` (`fromRollupBundle`) and runs them. A leak reads identically whichever
|
|
19
|
+
* bundler produced it, and there is one place to change if a guard changes.
|
|
20
|
+
*
|
|
21
|
+
* ## How
|
|
22
|
+
*
|
|
23
|
+
* In `generateBundle` the whole client graph is available: each output chunk lists its `moduleIds`, and
|
|
24
|
+
* `this.getModuleInfo(id)` gives each module's resolved import edges. That is exactly what
|
|
25
|
+
* `fromRollupBundle` needs. Findings fail the build through Rollup's `this.error`, so the message lands in
|
|
26
|
+
* the build output the same way a Bun-build throw does.
|
|
27
|
+
*
|
|
28
|
+
* Add it to the `plugins` of a Vite production build (the LAST plugin, so it sees the final graph):
|
|
29
|
+
*
|
|
30
|
+
* // vite.config.ts (production client build)
|
|
31
|
+
* import { viteLeakGuard } from "@nifrajs/web/plugins/vite-leak-guard"
|
|
32
|
+
* export default { build: { rollupOptions: { plugins: [viteLeakGuard()] } } }
|
|
33
|
+
*/
|
|
34
|
+
import {
|
|
35
|
+
detectNodeBuiltinsInClient,
|
|
36
|
+
detectServerOnlyInClient,
|
|
37
|
+
formatNodeBuiltinLeak,
|
|
38
|
+
formatServerOnlyLeak,
|
|
39
|
+
} from "../build.ts"
|
|
40
|
+
import { fromRollupBundle, type RollupBundleLike } from "../module-graph.ts"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The Rollup plugin-context slice this uses: `getModuleInfo` for a module's resolved imports, and `error`
|
|
44
|
+
* to fail the build. Typed structurally so the file needs no `rollup`/`vite` type dependency.
|
|
45
|
+
*/
|
|
46
|
+
interface RollupPluginContext {
|
|
47
|
+
getModuleInfo(id: string): {
|
|
48
|
+
readonly importedIds?: readonly string[]
|
|
49
|
+
readonly dynamicallyImportedIds?: readonly string[]
|
|
50
|
+
} | null
|
|
51
|
+
/**
|
|
52
|
+
* Takes an `Error`, never a string.
|
|
53
|
+
*
|
|
54
|
+
* Handed a string, rolldown synthesizes its own error type and calls `Error.captureStackTrace` on a
|
|
55
|
+
* plain object - which Bun rejects with "First argument must be an Error object", and THAT becomes the
|
|
56
|
+
* build failure. The guard still fires, but its message is replaced by an internal one naming nothing,
|
|
57
|
+
* so a real client leak reports as a stack-trace complaint. Passing an Error skips that construction
|
|
58
|
+
* entirely and the message survives.
|
|
59
|
+
*/
|
|
60
|
+
error(error: Error): never
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The minimal Rollup plugin shape this returns - `generateBundle` bound to the plugin context. */
|
|
64
|
+
export interface LeakGuardPlugin {
|
|
65
|
+
readonly name: string
|
|
66
|
+
/**
|
|
67
|
+
* The finding that failed the build, or `undefined` when the bundle was clean.
|
|
68
|
+
*
|
|
69
|
+
* Recorded as well as thrown because the throw does not reliably survive. A plugin error is handed to
|
|
70
|
+
* the bundler, which builds its own error to report it, and on some Bun + rolldown combinations that
|
|
71
|
+
* construction itself fails (`Error.captureStackTrace` rejecting a non-Error) - so what surfaces is an
|
|
72
|
+
* internal complaint naming neither the leaked module nor its import chain. A caller that owns the
|
|
73
|
+
* build can read this afterwards and raise the real message from its own code, where nothing can
|
|
74
|
+
* rewrite it. A leak is the one failure that must stay legible: the whole point is naming what
|
|
75
|
+
* reached the browser.
|
|
76
|
+
*/
|
|
77
|
+
leak?: string
|
|
78
|
+
generateBundle(this: RollupPluginContext, options: unknown, bundle: RollupBundleLike): void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A Vite/Rollup plugin that fails the build when server-only code or a `node:` builtin reaches the client
|
|
83
|
+
* bundle - the same two guards, and the same error messages, as nifra's Bun production build.
|
|
84
|
+
*/
|
|
85
|
+
export function viteLeakGuard(): LeakGuardPlugin {
|
|
86
|
+
const plugin: LeakGuardPlugin = {
|
|
87
|
+
name: "nifra:leak-guard",
|
|
88
|
+
generateBundle(_options, bundle) {
|
|
89
|
+
// Resolved import edges per module, straight from Rollup's graph. Dynamic imports count too: a
|
|
90
|
+
// `node:`/server-only module reached only via `import()` still ships to the browser.
|
|
91
|
+
const importsOf = (id: string): readonly string[] => {
|
|
92
|
+
const info = this.getModuleInfo(id)
|
|
93
|
+
if (info === null) return []
|
|
94
|
+
return [...(info.importedIds ?? []), ...(info.dynamicallyImportedIds ?? [])]
|
|
95
|
+
}
|
|
96
|
+
const graph = fromRollupBundle(bundle, importsOf)
|
|
97
|
+
// Node-builtin guard first, then server-only - the same order as the Bun build, so the first error a
|
|
98
|
+
// dev sees is the same across pipelines when a module trips both.
|
|
99
|
+
const leak =
|
|
100
|
+
formatNodeBuiltinLeak(detectNodeBuiltinsInClient(graph)) ??
|
|
101
|
+
formatServerOnlyLeak(detectServerOnlyInClient(graph))
|
|
102
|
+
if (leak === undefined) return
|
|
103
|
+
// Record before throwing: the throw is what fails the build for a standalone user of this plugin,
|
|
104
|
+
// and the record is what lets a caller that owns the build re-raise the real message (see `leak`).
|
|
105
|
+
plugin.leak = leak
|
|
106
|
+
this.error(new Error(leak))
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
return plugin
|
|
110
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/plugins/vite-server-fn` - the Vite half of the server-function client transform.
|
|
3
|
+
*
|
|
4
|
+
* A `*.fn` module must never reach a browser: it holds the function bodies and everything they import.
|
|
5
|
+
* The Bun build replaces each one with client stubs, and this does the same for Vite, in both the dev
|
|
6
|
+
* server and the production build.
|
|
7
|
+
*
|
|
8
|
+
* ## Why the generation is not in here
|
|
9
|
+
*
|
|
10
|
+
* The stub text comes from `internal/server-boundary.ts`, shared verbatim with the Bun plugin. Two
|
|
11
|
+
* implementations of "what a stub looks like" would drift, and the drift would be invisible until a
|
|
12
|
+
* client that worked in dev 404'd in production - the exact shape of failure this codebase has already
|
|
13
|
+
* paid for once. A parity test asserts both pipelines emit the same bytes.
|
|
14
|
+
*
|
|
15
|
+
* ## Why `transform` and not `load`
|
|
16
|
+
*
|
|
17
|
+
* Vite resolves and reads the file itself; taking `load` would mean re-implementing its resolution.
|
|
18
|
+
* `transform` receives the source Vite already read, and returning replacement code discards the
|
|
19
|
+
* original along with its imports - so the server subtree is never followed. `enforce: "pre"` puts this
|
|
20
|
+
* ahead of framework transforms, which have no business seeing server code either.
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
generateServerFnStub,
|
|
24
|
+
SERVER_FN_MODULE,
|
|
25
|
+
serverFnNamespace,
|
|
26
|
+
} from "../internal/server-boundary.ts"
|
|
27
|
+
|
|
28
|
+
/** The slice of a Vite/Rollup plugin this returns. Structural, so `vite` stays an optional peer. */
|
|
29
|
+
export interface ServerFnStubPlugin {
|
|
30
|
+
readonly name: string
|
|
31
|
+
readonly enforce: "pre"
|
|
32
|
+
/** Applied only to the CLIENT build; the server build keeps the real module. */
|
|
33
|
+
readonly applyToEnvironment?: (environment: { readonly name: string }) => boolean
|
|
34
|
+
transform(
|
|
35
|
+
code: string,
|
|
36
|
+
id: string,
|
|
37
|
+
options?: { readonly ssr?: boolean },
|
|
38
|
+
): { code: string; map: null } | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Strips a Vite id's `?query` and `#hash` so the suffix test sees a plain path. */
|
|
42
|
+
const bare = (id: string): string => id.split("?")[0]?.split("#")[0] ?? id
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Replace every `*.fn` module with its client stubs.
|
|
46
|
+
*
|
|
47
|
+
* Client-only by construction: nifra passes this to the client build and the dev server, never to the
|
|
48
|
+
* SSR build, because the server is what actually runs these functions.
|
|
49
|
+
*/
|
|
50
|
+
export function viteServerFnStub(): ServerFnStubPlugin {
|
|
51
|
+
return {
|
|
52
|
+
name: "nifra:server-fn-stub",
|
|
53
|
+
enforce: "pre",
|
|
54
|
+
// Vite 6+ environments: the SSR environment must keep the real module. Older Vite ignores this and
|
|
55
|
+
// relies on nifra only registering the plugin on the client side.
|
|
56
|
+
applyToEnvironment: (environment) => environment.name === "client",
|
|
57
|
+
transform(code, id, options) {
|
|
58
|
+
// Vite 5 ignores `applyToEnvironment` but supplies this transform context for SSR.
|
|
59
|
+
if (options?.ssr === true) return null
|
|
60
|
+
const path = bare(id)
|
|
61
|
+
if (!SERVER_FN_MODULE.test(path)) return null
|
|
62
|
+
// `map: null` is honest rather than lazy: the stub shares no lines with the source, so any
|
|
63
|
+
// mapping would point a debugger at unrelated code.
|
|
64
|
+
return { code: generateServerFnStub(code, serverFnNamespace(path)), map: null }
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|