@nifrajs/web 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +8 -0
  2. package/dist/build.d.ts +252 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +755 -6
  5. package/dist/build.js.map +1 -1
  6. package/dist/chunk-names.d.ts +4 -0
  7. package/dist/chunk-names.d.ts.map +1 -0
  8. package/dist/chunk-names.js +46 -0
  9. package/dist/chunk-names.js.map +1 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +19 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/dev.d.ts +1 -1
  14. package/dist/dev.d.ts.map +1 -1
  15. package/dist/dev.js +2 -1
  16. package/dist/dev.js.map +1 -1
  17. package/dist/fonts-google.d.ts +2 -1
  18. package/dist/fonts-google.d.ts.map +1 -1
  19. package/dist/fonts-google.js.map +1 -1
  20. package/dist/fonts.d.ts +2 -1
  21. package/dist/fonts.d.ts.map +1 -1
  22. package/dist/fonts.js +0 -16
  23. package/dist/fonts.js.map +1 -1
  24. package/dist/index.d.ts +138 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +268 -23
  27. package/dist/index.js.map +1 -1
  28. package/dist/manifest.d.ts +82 -5
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js.map +1 -1
  31. package/dist/server-only.d.ts +21 -0
  32. package/dist/server-only.d.ts.map +1 -0
  33. package/dist/server-only.js +25 -0
  34. package/dist/server-only.js.map +1 -0
  35. package/dist/vite.d.ts +21 -1
  36. package/dist/vite.d.ts.map +1 -1
  37. package/dist/vite.js +68 -4
  38. package/dist/vite.js.map +1 -1
  39. package/package.json +6 -1
  40. package/src/build.ts +1066 -12
  41. package/src/chunk-names.ts +45 -0
  42. package/src/client.ts +19 -2
  43. package/src/dev.ts +3 -2
  44. package/src/fonts-google.ts +3 -2
  45. package/src/fonts.ts +2 -1
  46. package/src/index.ts +373 -32
  47. package/src/manifest.ts +84 -6
  48. package/src/server-only.ts +25 -0
  49. 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
- /** The document head a route contributes — title + `<meta>`/`<link>` tag attribute sets. */
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?: ReadonlyArray<Record<string, string>>
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
- /** Args for a route's `meta` function: the loader's data + the route params. */
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 3000). */
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 ?? 3000
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 { createServer } = (await import("vite")) as unknown as ViteModule
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: [...(options.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
  })