@nifrajs/web 0.1.0-alpha.1 → 0.1.0-alpha.3
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 +8 -0
- package/dist/build.d.ts +252 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +755 -6
- package/dist/build.js.map +1 -1
- package/dist/chunk-names.d.ts +4 -0
- package/dist/chunk-names.d.ts.map +1 -0
- package/dist/chunk-names.js +46 -0
- package/dist/chunk-names.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +19 -2
- package/dist/client.js.map +1 -1
- package/dist/dev.d.ts +1 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +2 -1
- package/dist/dev.js.map +1 -1
- package/dist/fonts-google.d.ts +2 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js.map +1 -1
- package/dist/fonts.d.ts +2 -1
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +0 -16
- package/dist/fonts.js.map +1 -1
- package/dist/index.d.ts +138 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +268 -23
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +82 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/server-only.d.ts +21 -0
- package/dist/server-only.d.ts.map +1 -0
- package/dist/server-only.js +25 -0
- package/dist/server-only.js.map +1 -0
- package/dist/vite.d.ts +21 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +68 -4
- package/dist/vite.js.map +1 -1
- package/package.json +7 -2
- package/src/build.ts +1066 -12
- package/src/chunk-names.ts +45 -0
- package/src/client.ts +19 -2
- package/src/dev.ts +3 -2
- package/src/fonts-google.ts +3 -2
- package/src/fonts.ts +2 -1
- package/src/index.ts +373 -32
- package/src/manifest.ts +84 -6
- package/src/server-only.ts +25 -0
- package/src/vite.ts +82 -5
package/src/manifest.ts
CHANGED
|
@@ -28,20 +28,92 @@ export type Loader = (ctx: LoaderContext) => unknown | Promise<unknown>
|
|
|
28
28
|
*/
|
|
29
29
|
export type Action = (ctx: LoaderContext) => unknown | Promise<unknown>
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* One `<link>` tag's attributes for a route/layout's `meta.link`. The common HTML `<link>` attributes
|
|
33
|
+
* are spelled out and **optional** so a typed partial like `{ rel, href, hreflang }` is assignable —
|
|
34
|
+
* the previous `Record<string, string>` required *every* value to be a present string, which rejected
|
|
35
|
+
* exactly that idiomatic shape (the bug this fixes). The index signature keeps custom/`data-*` attrs
|
|
36
|
+
* (and any future standard attr) passing without a cast; `boolean` covers boolean attributes like
|
|
37
|
+
* `disabled` (rendered as a bare attribute when `true`, omitted when `false`), and `undefined` lets a
|
|
38
|
+
* caller spread in a conditionally-absent attribute. Attribute *names* are still shape-validated and
|
|
39
|
+
* values HTML-escaped at render — a widened type never widens the injection surface.
|
|
40
|
+
*/
|
|
41
|
+
export interface LinkDescriptor {
|
|
42
|
+
readonly rel?: string
|
|
43
|
+
readonly href?: string
|
|
44
|
+
readonly hreflang?: string
|
|
45
|
+
readonly crossorigin?: string
|
|
46
|
+
readonly media?: string
|
|
47
|
+
readonly sizes?: string
|
|
48
|
+
readonly type?: string
|
|
49
|
+
readonly as?: string
|
|
50
|
+
readonly integrity?: string
|
|
51
|
+
readonly referrerpolicy?: string
|
|
52
|
+
readonly fetchpriority?: string
|
|
53
|
+
readonly title?: string
|
|
54
|
+
readonly imagesrcset?: string
|
|
55
|
+
readonly imagesizes?: string
|
|
56
|
+
readonly color?: string
|
|
57
|
+
readonly disabled?: boolean
|
|
58
|
+
readonly [attr: string]: string | boolean | undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One `<script>` element a route contributes to `<head>` — for structured data (JSON-LD) and other
|
|
62
|
+
* inert, non-executable head scripts. The `content` is the script body; `type` defaults to
|
|
63
|
+
* `"application/ld+json"` (the common case). The renderer escapes `content` against an HTML breakout
|
|
64
|
+
* (`</`, `<!--`, `]]>`) — see `escapeScriptContent` — so a JSON-LD payload can never close the
|
|
65
|
+
* `<script>` element early. `content` is **JSON/text, never raw HTML**: this slot is not an XSS escape
|
|
66
|
+
* hatch for arbitrary markup. */
|
|
67
|
+
export interface ScriptDescriptor {
|
|
68
|
+
/** The script's `type` attribute. Default `"application/ld+json"`. */
|
|
69
|
+
readonly type?: string
|
|
70
|
+
/** The script body (e.g. a `JSON.stringify`'d JSON-LD object). Escaped for safe `<script>` embedding. */
|
|
71
|
+
readonly content: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The document head a route contributes — title + `<meta>`/`<link>`/`<script>` tag sets. Returned by a
|
|
76
|
+
* route/layout `meta` (statically, or from a {@link MetaArgs} function). Every value is serialized into
|
|
77
|
+
* managed (`data-nifra`) head tags: attribute *names* are shape-validated and *values* HTML-escaped at
|
|
78
|
+
* render (see `tagAttrs`/`headTags` in `@nifrajs/web`), and `script[].content` is breakout-escaped — so
|
|
79
|
+
* loader-derived strings (LLM-authored `og:*`, user content) are XSS-safe in the head by construction.
|
|
80
|
+
* Layout-chain heads merge with the page's via `mergeHeads` (arrays concat outermost→page; `title` is
|
|
81
|
+
* nearest-wins). Build `og:*`/`twitter:*` with `openGraph(...)`, canonical with `canonical(...)`, and
|
|
82
|
+
* JSON-LD with `jsonLd(...)` (all from `@nifrajs/web`) rather than hand-writing the records.
|
|
83
|
+
*/
|
|
32
84
|
export interface Meta {
|
|
33
85
|
readonly title?: string
|
|
34
86
|
readonly meta?: ReadonlyArray<Record<string, string>>
|
|
35
|
-
readonly link?:
|
|
87
|
+
readonly link?: readonly LinkDescriptor[]
|
|
88
|
+
/** Inert head `<script>`s (JSON-LD structured data, etc.). See {@link ScriptDescriptor}. */
|
|
89
|
+
readonly script?: readonly ScriptDescriptor[]
|
|
36
90
|
}
|
|
37
91
|
|
|
38
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Args for a route's `meta` function: the loader's `data` + the route `params` + the request `origin`.
|
|
94
|
+
* `meta()` runs in BOTH SSR and client navigation, so it has **no `request`/`process.env`/server access** —
|
|
95
|
+
* `origin` is the only server-resolved fact it gets (so you needn't thread `siteUrl` through loader data
|
|
96
|
+
* for absolute `og:url`/`canonical`/`og:image` URLs). See {@link origin}.
|
|
97
|
+
*/
|
|
39
98
|
export interface MetaArgs<Data = unknown> {
|
|
40
99
|
readonly data: Data
|
|
41
100
|
readonly params: Record<string, string>
|
|
101
|
+
/**
|
|
102
|
+
* The site origin — scheme + host (+ port), e.g. `"https://news.example.com"`, **with no trailing
|
|
103
|
+
* slash**. The single piece of server/env knowledge `meta()` otherwise can't see: it runs in BOTH
|
|
104
|
+
* SSR and client navigation, so it has no `request`/`process.env`. The framework resolves it from the
|
|
105
|
+
* request URL during SSR and from `location.origin` on client nav — and they match, so an absolute
|
|
106
|
+
* `og:url`/`canonical`/`og:image` built from it never drifts between the server-rendered `<head>` and
|
|
107
|
+
* a soft-nav. Use it for absolute URLs (`origin + "/posts/" + slug`) instead of threading `siteUrl`
|
|
108
|
+
* through loader data. Empty string (`""`) when the origin is unknown (e.g. a hand-built test render
|
|
109
|
+
* with no request URL) — back-compat-safe: a `meta()` that ignores `origin` is unchanged.
|
|
110
|
+
*/
|
|
111
|
+
readonly origin: string
|
|
42
112
|
}
|
|
43
113
|
|
|
44
|
-
/** A route's `meta`: a static {@link Meta}, or a function of the loader data + params
|
|
114
|
+
/** A route's `meta`: a static {@link Meta}, or a function of the loader data + params + the request
|
|
115
|
+
* origin ({@link MetaArgs}). Use the `origin` arg for absolute `canonical`/`og:url`/`og:image` URLs —
|
|
116
|
+
* it's resolved server-side from the request and matches the client's `location.origin`. */
|
|
45
117
|
export type MetaInput = Meta | ((args: MetaArgs) => Meta)
|
|
46
118
|
|
|
47
119
|
/** One concrete parameterization of a dynamic route, returned by {@link GetStaticPaths}. */
|
|
@@ -104,10 +176,16 @@ export interface RouteModule {
|
|
|
104
176
|
readonly islandScripts?: readonly string[]
|
|
105
177
|
}
|
|
106
178
|
|
|
107
|
-
/** A layout (or `_404`/`_error`) entry: its source file (for client codegen) + a lazy loader.
|
|
179
|
+
/** A layout (or `_404`/`_error`) entry: its source file (for client codegen) + a lazy loader.
|
|
180
|
+
*
|
|
181
|
+
* A `_layout.tsx` may export `meta` (static {@link Meta} or a function of the loader data + params,
|
|
182
|
+
* same shape as a route's) to contribute sitewide `<head>` tags — `hreflang`/`preconnect`/etc. that
|
|
183
|
+
* belong on every page under the layout. The layout chain's heads merge with the page's: arrays
|
|
184
|
+
* (`meta`/`link`) concatenate outermost→innermost→page; scalars (`title`) are nearest-wins (the page
|
|
185
|
+
* overrides an inner layout, which overrides an outer one). See `mergeHeads` in `@nifrajs/web`. */
|
|
108
186
|
export interface LayoutEntry {
|
|
109
187
|
readonly file: string
|
|
110
|
-
readonly load: () => Promise<{ default: unknown }>
|
|
188
|
+
readonly load: () => Promise<{ default: unknown; meta?: MetaInput }>
|
|
111
189
|
}
|
|
112
190
|
|
|
113
191
|
/** One matched route: pattern, nested layout ids (outermost → innermost), source file, loader. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/server-only` — the **explicit, opt-in poison-import marker** (Next's `import
|
|
3
|
+
* "server-only"`). A module of PURE server logic that carries no `node:` import — a secret-bearing
|
|
4
|
+
* constant, a server-only API call — can't be caught by nifra's other two client-leak guards: the
|
|
5
|
+
* `.server` convention (which empties `*.server` modules in the client build) needs the file to be
|
|
6
|
+
* NAMED `*.server`, and the node-builtin guard only fires when a `node:` import actually reaches a
|
|
7
|
+
* client chunk. This marker closes that gap: drop a side-effect import at the top of the module —
|
|
8
|
+
*
|
|
9
|
+
* import "@nifrajs/web/server-only"
|
|
10
|
+
*
|
|
11
|
+
* — and the CLIENT build ({@link buildClient}) FAILS LOUD, naming the import chain, if that module
|
|
12
|
+
* ever lands in a browser chunk. On the SERVER build/runtime it's a harmless no-op (this empty
|
|
13
|
+
* module), so the same file runs untouched server-side.
|
|
14
|
+
*
|
|
15
|
+
* Pair it with the type-level {@link ServerOnly} brand (from `@nifrajs/web`) on the module's exports
|
|
16
|
+
* to signal intent to readers + the compiler; this runtime import is what actually fails the build.
|
|
17
|
+
*
|
|
18
|
+
* @see ServerOnly — the type-level intent marker that complements this runtime import.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Intentionally empty. The marker is the IMPORT itself: `buildClient` detects any module that imports
|
|
22
|
+
// this specifier and lands in a client chunk (via the same Bun metafile graph the node-builtin guard
|
|
23
|
+
// walks) and fails the build. There is no value to export and nothing to run — on the server it's a
|
|
24
|
+
// no-op so the marked module behaves normally; on the client the build never completes.
|
|
25
|
+
export {}
|
package/src/vite.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { relative, resolve as resolvePath } from "node:path"
|
|
20
20
|
import { renderDevErrorOverlay } from "./dev-error.ts"
|
|
21
21
|
import { discoverRoutes } from "./fs.ts"
|
|
22
|
-
import { generateClientEntry } from "./index.ts"
|
|
22
|
+
import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
|
|
23
23
|
|
|
24
24
|
/** Minimal app surface — `createWebApp(...)` satisfies it. */
|
|
25
25
|
interface FetchApp {
|
|
@@ -36,6 +36,8 @@ interface ViteLike {
|
|
|
36
36
|
}
|
|
37
37
|
interface ViteModule {
|
|
38
38
|
createServer(config: Record<string, unknown>): Promise<ViteLike>
|
|
39
|
+
// Present only on rolldown-vite (Vite 8+); used to gate the optimizeDeps.jsx-key normalization below.
|
|
40
|
+
readonly rolldownVersion?: string
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// `node:http` server type the request handler runs on (also the HMR WebSocket host — see below).
|
|
@@ -66,7 +68,7 @@ export interface ViteDevServerOptions {
|
|
|
66
68
|
readonly define?: Readonly<Record<string, string>>
|
|
67
69
|
/** Vite project root (default `process.cwd()`). */
|
|
68
70
|
readonly root?: string
|
|
69
|
-
/** Port (default
|
|
71
|
+
/** Port (default {@link DEFAULT_DEV_PORT}). */
|
|
70
72
|
readonly port?: number
|
|
71
73
|
/**
|
|
72
74
|
* Use polling for the file watcher. Native fs events (fsevents/inotify) are unreliable inside
|
|
@@ -111,6 +113,68 @@ function toWebRequest(req: IncomingMessage, body: Buffer | undefined): Request {
|
|
|
111
113
|
return new Request(url, init as unknown as ConstructorParameters<typeof Request>[1])
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
// A Vite plugin's `config` hook — the only hook we wrap. Typed structurally (no `vite` type dep): it
|
|
117
|
+
// takes the user config + env and may return a partial config (possibly a promise). Everything else on
|
|
118
|
+
// the plugin object is preserved by spread, so wrapping is transparent to Vite.
|
|
119
|
+
interface VitePluginLike {
|
|
120
|
+
readonly name?: string
|
|
121
|
+
config?: (config: unknown, env: unknown) => unknown
|
|
122
|
+
readonly [key: string]: unknown
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Strip `optimizeDeps.rollupOptions.jsx` from a plugin's `config` hook output when running under
|
|
127
|
+
* rolldown-vite — the source of the scary, harmless `Warning: Invalid input options … "jsx" Invalid
|
|
128
|
+
* key: Expected never but received "jsx"` on `nifra dev`.
|
|
129
|
+
*
|
|
130
|
+
* Why it happens: `@vitejs/plugin-react@4.x` (and peers) target an *older* rolldown-vite optimizeDeps
|
|
131
|
+
* API — they inject `optimizeDeps.rollupOptions.jsx` to tell the dep pre-bundler to transform JSX. Vite
|
|
132
|
+
* 8's rolldown dep-optimizer renamed that surface to `optimizeDeps.rolldownOptions` (and moved jsx under
|
|
133
|
+
* `transform.jsx`), so the stale `rollupOptions.jsx` is an unrecognized input option → the warning. It's
|
|
134
|
+
* a version-skew artifact, not a real misconfig: the route source JSX transform runs through the
|
|
135
|
+
* plugin's own `transform` hook (untouched here), and node_modules deps that get pre-bundled almost
|
|
136
|
+
* never contain raw JSX — so dropping the dead key changes no behavior and keeps HMR/Fast Refresh
|
|
137
|
+
* intact. We *strip* (rather than translate to `rolldownOptions`) so the fix is version-agnostic: a
|
|
138
|
+
* plugin already emitting the correct `rolldownOptions` is left untouched, and a future plugin bump that
|
|
139
|
+
* stops emitting `rollupOptions.jsx` makes this a no-op.
|
|
140
|
+
*
|
|
141
|
+
* Scoped narrowly: only the `optimizeDeps.rollupOptions.jsx` key is removed, only under rolldown-vite,
|
|
142
|
+
* and only from the value a plugin's `config` hook returns. Non-rolldown Vite is passed through verbatim.
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeRolldownPlugins(
|
|
145
|
+
plugins: readonly unknown[],
|
|
146
|
+
isRolldown: boolean,
|
|
147
|
+
): readonly unknown[] {
|
|
148
|
+
if (!isRolldown) return plugins
|
|
149
|
+
const stripJsxKey = (returned: unknown): unknown => {
|
|
150
|
+
// Only touch a plain-object config carrying optimizeDeps.rollupOptions.jsx; leave anything else as-is.
|
|
151
|
+
if (returned === null || typeof returned !== "object") return returned
|
|
152
|
+
const cfg = returned as { optimizeDeps?: { rollupOptions?: Record<string, unknown> } }
|
|
153
|
+
const rollupOptions = cfg.optimizeDeps?.rollupOptions
|
|
154
|
+
if (rollupOptions === undefined || !("jsx" in rollupOptions)) return returned
|
|
155
|
+
// Clone the affected branch (never mutate the plugin's own return value) and drop the dead key.
|
|
156
|
+
const { jsx: _dropped, ...restRollup } = rollupOptions
|
|
157
|
+
return {
|
|
158
|
+
...cfg,
|
|
159
|
+
optimizeDeps: { ...cfg.optimizeDeps, rollupOptions: restRollup },
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return plugins.map((plugin) => {
|
|
163
|
+
if (plugin === null || typeof plugin !== "object") return plugin
|
|
164
|
+
const p = plugin as VitePluginLike
|
|
165
|
+
if (typeof p.config !== "function") return plugin
|
|
166
|
+
const originalConfig = p.config
|
|
167
|
+
return {
|
|
168
|
+
...p,
|
|
169
|
+
config: (config: unknown, env: unknown) => {
|
|
170
|
+
const returned = originalConfig(config, env)
|
|
171
|
+
// The hook may return a promise — normalize both shapes.
|
|
172
|
+
return returned instanceof Promise ? returned.then(stripJsxKey) : stripJsxKey(returned)
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
114
178
|
/**
|
|
115
179
|
* Start the Vite-backed dev server: Vite serves/HMRs the client; nifra SSRs each request and Vite
|
|
116
180
|
* injects its HMR client + the framework refresh preamble via `transformIndexHtml`.
|
|
@@ -118,7 +182,7 @@ function toWebRequest(req: IncomingMessage, body: Buffer | undefined): Request {
|
|
|
118
182
|
export async function createViteDevServer(options: ViteDevServerOptions): Promise<ViteDevServer> {
|
|
119
183
|
const root = resolvePath(options.root ?? process.cwd())
|
|
120
184
|
const routesDir = resolvePath(options.routesDir)
|
|
121
|
-
const port = options.port ??
|
|
185
|
+
const port = options.port ?? DEFAULT_DEV_PORT
|
|
122
186
|
|
|
123
187
|
// Codegen the client entry with Vite-servable, root-relative specifiers (e.g. `/routes/index.tsx`).
|
|
124
188
|
const manifest = discoverRoutes(routesDir)
|
|
@@ -172,7 +236,14 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
172
236
|
// `conditions: ["bun"]` makes Vite resolve nifra's workspace packages (`@nifrajs/web-react/client`, …) to
|
|
173
237
|
// their TS **source** — so the dev server needs no prior `dist` build of the adapter packages.
|
|
174
238
|
const usePolling = options.poll ?? process.env.CHOKIDAR_USEPOLLING === "1"
|
|
175
|
-
const
|
|
239
|
+
const viteModule = (await import("vite")) as unknown as ViteModule
|
|
240
|
+
const { createServer } = viteModule
|
|
241
|
+
// Under rolldown-vite (Vite 8+), strip the stale `optimizeDeps.rollupOptions.jsx` some framework
|
|
242
|
+
// plugins still emit — it triggers a noisy "Invalid key … jsx" warning but does nothing useful here.
|
|
243
|
+
const plugins = normalizeRolldownPlugins(
|
|
244
|
+
options.plugins ?? [],
|
|
245
|
+
viteModule.rolldownVersion !== undefined,
|
|
246
|
+
)
|
|
176
247
|
vite = await createServer({
|
|
177
248
|
root,
|
|
178
249
|
appType: "custom",
|
|
@@ -182,9 +253,15 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
|
|
|
182
253
|
// Explicit watch config; poll when native fs events aren't delivered (containers/sandboxes).
|
|
183
254
|
watch: usePolling ? { usePolling: true, interval: 80 } : {},
|
|
184
255
|
},
|
|
185
|
-
plugins: [...
|
|
256
|
+
plugins: [...plugins],
|
|
186
257
|
resolve: {
|
|
187
258
|
conditions: [...(options.conditions ?? []), "bun", "module", "browser", "development"],
|
|
259
|
+
// Dedupe React to ONE copy. In a multi-root workspace a shared package can pull react/react-dom
|
|
260
|
+
// from a SIBLING app's node_modules, so the dev server loads two React cores → a second hook
|
|
261
|
+
// dispatcher → `resolveDispatcher().useState` null on any hook-using route (the error points at the
|
|
262
|
+
// component, not the resolution — brutal to diagnose). Mirrors the build-time reactDedupePlugin so
|
|
263
|
+
// dev matches prod. No-op for non-React apps (the package simply isn't present to dedupe).
|
|
264
|
+
dedupe: ["react", "react-dom"],
|
|
188
265
|
},
|
|
189
266
|
...(options.define ? { define: options.define } : {}),
|
|
190
267
|
})
|