@nifrajs/web 1.13.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/build.d.ts +14 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/build.js +105 -40
  4. package/dist/build.js.map +1 -1
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +2 -10
  7. package/dist/client.js.map +1 -1
  8. package/dist/deferred.d.ts +3 -2
  9. package/dist/deferred.d.ts.map +1 -1
  10. package/dist/deferred.js +14 -8
  11. package/dist/deferred.js.map +1 -1
  12. package/dist/draft.d.ts +1 -1
  13. package/dist/draft.d.ts.map +1 -1
  14. package/dist/draft.js +1 -1
  15. package/dist/draft.js.map +1 -1
  16. package/dist/forms.d.ts +1 -1
  17. package/dist/forms.d.ts.map +1 -1
  18. package/dist/index.d.ts +11 -9
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +12 -22
  21. package/dist/index.js.map +1 -1
  22. package/dist/manifest.d.ts +2 -5
  23. package/dist/manifest.d.ts.map +1 -1
  24. package/dist/manifest.js +1 -6
  25. package/dist/manifest.js.map +1 -1
  26. package/dist/plugins/postcss.d.ts +50 -0
  27. package/dist/plugins/postcss.d.ts.map +1 -0
  28. package/dist/plugins/postcss.js +68 -0
  29. package/dist/plugins/postcss.js.map +1 -0
  30. package/dist/plugins/svg.d.ts +44 -0
  31. package/dist/plugins/svg.d.ts.map +1 -0
  32. package/dist/plugins/svg.js +110 -0
  33. package/dist/plugins/svg.js.map +1 -0
  34. package/dist/router.d.ts +3 -0
  35. package/dist/router.d.ts.map +1 -1
  36. package/dist/router.js +4 -4
  37. package/dist/router.js.map +1 -1
  38. package/package.json +13 -2
  39. package/src/build.ts +109 -41
  40. package/src/client.ts +2 -8
  41. package/src/deferred.ts +16 -6
  42. package/src/draft.ts +1 -1
  43. package/src/forms.ts +1 -1
  44. package/src/index.ts +18 -34
  45. package/src/manifest.ts +2 -8
  46. package/src/plugins/postcss.ts +129 -0
  47. package/src/plugins/svg.ts +156 -0
  48. package/src/router.ts +7 -4
package/src/build.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * are Bun-specific and never on the request path (own subpath, like `@nifrajs/web/fs`); the *output*
6
6
  * runs on any runtime.
7
7
  */
8
- import { mkdirSync, rmSync, writeFileSync } from "node:fs"
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
9
9
  import { dirname, relative, resolve as resolvePath } from "node:path"
10
10
  import type { BunPlugin } from "bun"
11
11
  import { sanitizeOutputNames } from "./chunk-names.ts"
@@ -570,6 +570,69 @@ export function parseManifestClientEntry(source: string): string | undefined {
570
570
  return MANIFEST_CLIENT_ENTRY.exec(source)?.[1]
571
571
  }
572
572
 
573
+ // The baked asset lines `generateServerManifest` emits: `export const styles = […]` then
574
+ // `export const routeStyles = {…}`, each a JSON literal followed by the next `export const`. The capture
575
+ // is `[\s\S]*?` (multi-line, non-greedy up to the following `export const`) NOT `.+` - a committed
576
+ // manifest carries no format-ignore pragma, so a formatter can wrap a long `routeStyles` map across lines;
577
+ // a single-line regex would then miss it and silently DROP every baked stylesheet on re-sync.
578
+ const MANIFEST_STYLES = /export const styles = ([\s\S]*?)\nexport const /
579
+ const MANIFEST_ROUTE_STYLES = /export const routeStyles = ([\s\S]*?)\nexport const /
580
+
581
+ /** Parse a baked JSON literal captured from a committed manifest, tolerating a formatter's TRAILING COMMAS
582
+ * (biome/prettier add one when wrapping a multi-line array/object; strict `JSON.parse` would reject it).
583
+ * Only a comma immediately before a closing `]`/`}` is stripped, so commas inside string values are safe. */
584
+ function parseManifestLiteral(raw: string): unknown {
585
+ return JSON.parse(raw.replace(/,(\s*[\]}])/g, "$1"))
586
+ }
587
+
588
+ /** The baked top-level `styles` array in a committed server-manifest (empty if absent/unparseable). Pure. */
589
+ export function parseManifestStyles(source: string): string[] {
590
+ const raw = MANIFEST_STYLES.exec(source)?.[1]
591
+ if (raw === undefined) return []
592
+ try {
593
+ const value = parseManifestLiteral(raw)
594
+ return Array.isArray(value) ? (value as string[]) : []
595
+ } catch {
596
+ return []
597
+ }
598
+ }
599
+
600
+ /** The baked per-route `routeStyles` map in a committed server-manifest (empty if absent/unparseable). Pure. */
601
+ export function parseManifestRouteStyles(source: string): Record<string, string[]> {
602
+ const raw = MANIFEST_ROUTE_STYLES.exec(source)?.[1]
603
+ if (raw === undefined) return {}
604
+ try {
605
+ const value = parseManifestLiteral(raw)
606
+ return value !== null && typeof value === "object" && !Array.isArray(value)
607
+ ? (value as Record<string, string[]>)
608
+ : {}
609
+ } catch {
610
+ return {}
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Re-emit a committed server-manifest from a freshly-discovered route tree, PRESERVING its baked
616
+ * client-asset references (`clientEntry` / `styles` / `routeStyles`) and its eager-vs-lazy shape. This is
617
+ * what makes `nifra sync-manifest` a route-table refresh (renamed / added / removed routes) that does NOT
618
+ * need a full build. It deliberately does NOT rebuild the client bundle: a brand-new HYDRATING route still
619
+ * needs a full build so its client chunk exists - this only re-syncs the server manifest's route table.
620
+ * Pure: `source` + the discovered `manifest` in, new source out.
621
+ */
622
+ export function resyncServerManifestSource(
623
+ source: string,
624
+ manifest: Parameters<typeof generateServerManifest>[0],
625
+ routesPrefix: string,
626
+ ): string {
627
+ return generateServerManifest(manifest, {
628
+ resolve: (file) => `${routesPrefix}${file}`,
629
+ clientEntry: parseManifestClientEntry(source) ?? "",
630
+ styles: parseManifestStyles(source),
631
+ routeStyles: parseManifestRouteStyles(source),
632
+ lazy: source.includes("const loaders ="),
633
+ })
634
+ }
635
+
573
636
  /**
574
637
  * Diff the route files a committed server-manifest imports against the files freshly discovered in
575
638
  * `routes/`. Returns the `missing` (in routes/, not in manifest — stale manifest) and `extra` (in
@@ -632,7 +695,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
632
695
  mkdirSync(outDir, { recursive: true })
633
696
 
634
697
  const routeManifest = discoverRoutes(routesDir)
635
- // The bootstrap's filename is `_velo`-namespaced (not `entry.ts`) so its `[name]` can't collide with
698
+ // The bootstrap's filename is `_nifra`-namespaced (not `entry.ts`) so its `[name]` can't collide with
636
699
  // a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS.
637
700
  const mode = options.minify === false ? "development" : "production"
638
701
  // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
@@ -641,7 +704,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
641
704
  // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck — guard it.
642
705
  const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
643
706
  const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv)
644
- const entryFile = `${outDir}/_nifra-entry.ts`
707
+ // Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
708
+ // importing file, so an absolute `--out /tmp/deploy` must still resolve the app's dependencies.
709
+ // A unique directory also avoids clobbering a user file or colliding with parallel builds.
710
+ const entryDir = mkdtempSync(resolvePath(dirname(routesDir), ".nifra-client-"))
711
+ const entryFile = `${entryDir}/_nifra-entry.ts`
645
712
  // A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
646
713
  // an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
647
714
  writeFileSync(
@@ -667,49 +734,50 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
667
734
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
668
735
  // `@types/bun`'s `BuildConfig`, so spread it in (spread props skip the excess-property check).
669
736
  const buildExtras = { metafile: true }
670
- const result = await Bun.build({
671
- entrypoints: [entryFile, ...routeFiles.map(resolve)],
672
- outdir: outDir,
673
- target: "browser",
674
- naming: "[name]-[hash].[ext]",
675
- publicPath,
676
- splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
677
- // `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
678
- // strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
679
- // below — both the aggregate and per-route — via the metafile, for `<link>` injection.
680
- ...buildExtras,
681
- minify: options.minify ?? true,
682
- plugins: [
683
- reactDedupePlugin(routesDir),
684
- preactDedupePlugin(routesDir),
685
- svelteDedupePlugin(routesDir),
686
- serverOnlyEmptyPlugin(),
687
- ...(options.plugins ?? []),
688
- ],
689
- ...(options.conditions ? { conditions: [...options.conditions] } : {}),
690
- // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
691
- // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
692
- // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
693
- // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback — so secrets
694
- // never leak). Callers can override any of these via `options.define` (layered last).
695
- define: {
696
- "process.env": "({})",
697
- "process.env.NODE_ENV": JSON.stringify(mode),
698
- ...publicDefines,
699
- ...options.define,
700
- },
701
- })
737
+ const result = await (async () => {
738
+ try {
739
+ return await Bun.build({
740
+ entrypoints: [entryFile, ...routeFiles.map(resolve)],
741
+ outdir: outDir,
742
+ target: "browser",
743
+ naming: "[name]-[hash].[ext]",
744
+ publicPath,
745
+ splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
746
+ // `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
747
+ // strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
748
+ // below — both the aggregate and per-route — via the metafile, for `<link>` injection.
749
+ ...buildExtras,
750
+ minify: options.minify ?? true,
751
+ plugins: [
752
+ reactDedupePlugin(routesDir),
753
+ preactDedupePlugin(routesDir),
754
+ svelteDedupePlugin(routesDir),
755
+ serverOnlyEmptyPlugin(),
756
+ ...(options.plugins ?? []),
757
+ ],
758
+ ...(options.conditions ? { conditions: [...options.conditions] } : {}),
759
+ // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
760
+ // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
761
+ // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
762
+ // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback — so secrets
763
+ // never leak). Callers can override any of these via `options.define` (layered last).
764
+ define: {
765
+ "process.env": "({})",
766
+ "process.env.NODE_ENV": JSON.stringify(mode),
767
+ ...publicDefines,
768
+ ...options.define,
769
+ },
770
+ })
771
+ } finally {
772
+ rmSync(entryDir, { recursive: true, force: true })
773
+ }
774
+ })()
702
775
  if (!result.success) {
703
776
  throw new Error(
704
777
  `[nifra/web] client build failed:\n${result.logs.map((l) => String(l)).join("\n")}`,
705
778
  )
706
779
  }
707
780
 
708
- // The generated client-entry SOURCE (`_nifra-entry.ts`) was written into outDir only to serve as a
709
- // Bun.build entrypoint; the shipped artifact is the content-hashed `_nifra-entry-<hash>.js` the HTML
710
- // references. Remove the leftover source so it never ships in a static deploy dir (nothing links it).
711
- rmSync(entryFile, { force: true })
712
-
713
781
  // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
714
782
  // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
715
783
  // instead — caught at build time, not by a confused user in the browser. Graph-based (the metafile's
@@ -1166,7 +1234,7 @@ export function generateServerEntry(options: {
1166
1234
  lines.push('import { clientEntry, manifest, styles, routeStyles } from "./server-manifest"')
1167
1235
  // cf-pages/vercel/deno need the fetch-handler shape; bun/node call app.fetch directly.
1168
1236
  const usesToFetch = target === "cf-pages" || target === "vercel" || target === "deno"
1169
- if (usesToFetch) lines.push('import { toFetchHandler } from "@nifrajs/core"')
1237
+ if (usesToFetch) lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
1170
1238
  if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
1171
1239
  lines.push(
1172
1240
  "",
package/src/client.ts CHANGED
@@ -44,16 +44,10 @@ export function installHistory(
44
44
  return raw
45
45
  }
46
46
  }
47
- // Resolve a fragment id to its element — by `id`, then a legacy `<a name>` — or null if absent.
47
+ // Resolve a fragment id to its element by `id`, or null if absent.
48
48
  const findAnchor = (id: string): Element | null => {
49
49
  if (id === "") return null
50
- const byId = document.getElementById(id)
51
- if (byId !== null) return byId
52
- try {
53
- return document.querySelector(`a[name="${CSS.escape(id)}"]`)
54
- } catch {
55
- return null // CSS/CSS.escape unavailable or an exotic id → no named-anchor match
56
- }
50
+ return document.getElementById(id)
57
51
  }
58
52
 
59
53
  // Wrap a navigation's render in a View Transition when the browser supports it — a graceful
package/src/deferred.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * protocol — the marker, the resolved/deferred split, and the tiny client registry that streamed
6
6
  * resolution scripts settle. The per-adapter `<Await>` lives in `@nifrajs/web-{solid,react}/client`.
7
7
  */
8
+ import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-codec"
8
9
 
9
10
  /**
10
11
  * A loader value marked to stream in after the shell. The component consumes it with the adapter's
@@ -28,6 +29,9 @@ export interface Deferred<T> {
28
29
  * component knows to `<Await>` it. The `id` is a placeholder until the server assigns the real one.
29
30
  */
30
31
  export function defer<T>(promise: Promise<T>): Deferred<T> {
32
+ // Claim the promise immediately: an eager rejection may otherwise be reported as unhandled before
33
+ // the SSR/NDJSON consumer attaches. Keep the original promise so consumers observe its rejection.
34
+ void promise.catch(() => {})
31
35
  return { __nifra_deferred: true, id: -1, promise }
32
36
  }
33
37
 
@@ -158,21 +162,22 @@ const NDJSON_ENCODER = new TextEncoder()
158
162
  export function ndjsonStream(
159
163
  forClient: unknown,
160
164
  deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
165
+ codec: TransportCodec = plainJsonCodec,
161
166
  ): ReadableStream<Uint8Array> {
162
167
  return new ReadableStream({
163
168
  async start(controller) {
164
- controller.enqueue(NDJSON_ENCODER.encode(`${JSON.stringify(forClient)}\n`))
169
+ controller.enqueue(NDJSON_ENCODER.encode(`${codec.encode(forClient)}\n`))
165
170
  await Promise.all(
166
171
  deferred.map(async (d) => {
167
172
  try {
168
173
  const value = await d.promise
169
- controller.enqueue(NDJSON_ENCODER.encode(`${JSON.stringify({ i: d.id, v: value })}\n`))
174
+ controller.enqueue(NDJSON_ENCODER.encode(`${codec.encode({ i: d.id, v: value })}\n`))
170
175
  } catch (err) {
171
176
  // Redact: stream a stable opaque code, never the raw error text; log the real reason
172
177
  // server-side. The client's <Await> errorFallback receives this code.
173
178
  console.error("[nifra/web] deferred value rejected:", err)
174
179
  controller.enqueue(
175
- NDJSON_ENCODER.encode(`${JSON.stringify({ i: d.id, e: DEFERRED_ERROR_CODE })}\n`),
180
+ NDJSON_ENCODER.encode(`${codec.encode({ i: d.id, e: DEFERRED_ERROR_CODE })}\n`),
176
181
  )
177
182
  }
178
183
  }),
@@ -256,7 +261,11 @@ function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
256
261
  return marker
257
262
  }
258
263
  if (Array.isArray(value)) return value.map(walk)
259
- if (value !== null && typeof value === "object") {
264
+ if (
265
+ value !== null &&
266
+ typeof value === "object" &&
267
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
268
+ ) {
260
269
  const out: Record<string, unknown> = {}
261
270
  for (const [key, v] of Object.entries(value)) out[key] = walk(v)
262
271
  return out
@@ -276,6 +285,7 @@ function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
276
285
  export async function parseNdjsonData(
277
286
  stream: ReadableStream<Uint8Array>,
278
287
  signal?: AbortSignal,
288
+ codec: TransportCodec = plainJsonCodec,
279
289
  ): Promise<unknown> {
280
290
  const reader = stream.getReader()
281
291
  const dec = new TextDecoder()
@@ -305,7 +315,7 @@ export async function parseNdjsonData(
305
315
 
306
316
  const pending: Pending = new Map()
307
317
  const first = await readLine()
308
- const data = markersFromPlaceholders(first === null ? null : JSON.parse(first), pending)
318
+ const data = markersFromPlaceholders(first === null ? null : codec.decode(first), pending)
309
319
 
310
320
  void (async () => {
311
321
  try {
@@ -313,7 +323,7 @@ export async function parseNdjsonData(
313
323
  const line = await readLine()
314
324
  if (line === null) break
315
325
  if (line === "") continue
316
- const msg = JSON.parse(line) as { i: number; v?: unknown; e?: string }
326
+ const msg = codec.decode(line) as { i: number; v?: unknown; e?: string }
317
327
  const entry = pending.get(msg.i)
318
328
  if (entry === undefined) continue
319
329
  pending.delete(msg.i)
package/src/draft.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * createWebApp({ ..., draftSecret: env.DRAFT_SECRET }) // → loaders get ctx.draft
15
15
  * withISR(app, { ..., draftSecret: env.DRAFT_SECRET }) // → editors bypass the cache
16
16
  */
17
- import { type CookieOptions, parseCookies, signValue, unsignValue } from "@nifrajs/core"
17
+ import { type CookieOptions, parseCookies, signValue, unsignValue } from "@nifrajs/core/server"
18
18
 
19
19
  /** The cookie name nifra uses for draft/preview mode. */
20
20
  export const DRAFT_COOKIE = "__nifra_draft"
package/src/forms.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * runtime is a trivial pass-through — all the work is in the types. It checks the field KEY, never the
19
19
  * MEANING (a `field("email")` collecting a phone is still valid here); that stays an app/AI concern.
20
20
  */
21
- import type { RouteInfo, Server } from "@nifrajs/core"
21
+ import type { RouteInfo, Server } from "@nifrajs/core/server"
22
22
 
23
23
  // Derived from `FormData` itself (not the `FormDataEntryValue` global) so this file stays DOM-lib-free:
24
24
  // `@nifrajs/web`'s server surface is typechecked by the root config, which has no DOM lib.
package/src/index.ts CHANGED
@@ -7,12 +7,13 @@
7
7
  * the adapter interprets them. That keeps this package both framework-agnostic and free
8
8
  * of DOM types: it is pure server orchestration + string building.
9
9
  */
10
- import { server } from "@nifrajs/core"
10
+
11
11
  import {
12
12
  type BackendMount,
13
13
  type BackendMountHandler,
14
14
  NIFRA_BACKEND_MOUNT,
15
15
  } from "@nifrajs/core/mount"
16
+ import { server } from "@nifrajs/core/server"
16
17
  import {
17
18
  DEFERRED_ERROR_CODE,
18
19
  DEFERRED_RUNTIME,
@@ -86,7 +87,6 @@ export {
86
87
  export {
87
88
  type Action,
88
89
  buildManifest,
89
- enumeratePrerenderedPaths,
90
90
  enumerateStaticRoutes,
91
91
  filePathToPattern,
92
92
  filePathToPatterns,
@@ -201,6 +201,9 @@ export interface RenderProps {
201
201
  readonly actionData?: unknown
202
202
  /** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
203
203
  readonly pending?: boolean
204
+ /** The path a client navigation is transitioning TO while `pending` (client-only; absent on SSR and
205
+ * when idle). Lets an adapter tell a `NavLink` whether its own target is the one loading. */
206
+ readonly pendingPath?: string
204
207
  /** The in-flight client submit, for optimistic UI (client-only; absent on SSR + when idle). */
205
208
  readonly submission?: Submission
206
209
  /** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). Threaded identically on
@@ -687,21 +690,17 @@ function isSameOriginPath(location: string): boolean {
687
690
  /**
688
691
  * Build a redirect `Response` — return it from a route `action` for the Post/Redirect/Get
689
692
  * pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
690
- * Defaults to 303 (See Other); pass `307`/`308` (or `{ status }`) to preserve the method.
693
+ * Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
691
694
  *
692
695
  * **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
693
696
  * off-origin/absolute destination throws unless you pass `{ external: true }` — this closes the
694
697
  * open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
695
698
  * which returns the action's `Response` verbatim.
696
699
  *
697
- * @param statusOrOptions a status number (back-compat) or `{ status?, external? }`.
700
+ * @param options redirect status and whether an off-origin destination is intentional.
698
701
  */
699
- export function redirect(
700
- location: string,
701
- statusOrOptions: number | RedirectOptions = 303,
702
- ): Response {
703
- const opts = typeof statusOrOptions === "number" ? { status: statusOrOptions } : statusOrOptions
704
- if (opts.external !== true && !isSameOriginPath(location)) {
702
+ export function redirect(location: string, options: RedirectOptions = {}): Response {
703
+ if (options.external !== true && !isSameOriginPath(location)) {
705
704
  throw new Error(
706
705
  `[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//"), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`,
707
706
  )
@@ -715,7 +714,7 @@ export function redirect(
715
714
  `[nifra/web] redirect location contains a CR/LF character — refusing to emit a header-injecting redirect.`,
716
715
  )
717
716
  }
718
- return new Response(null, { status: opts.status ?? 303, headers: { location } })
717
+ return new Response(null, { status: options.status ?? 303, headers: { location } })
719
718
  }
720
719
 
721
720
  /** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
@@ -967,15 +966,14 @@ export interface CreateWebAppOptions {
967
966
  * mount interface from `@nifrajs/core/mount`; `createWebApp` also serves that backend over HTTP at
968
967
  * {@link apiPrefix} (default `/api`): a request whose pathname starts with the prefix is dispatched
969
968
  * before page routing with the same `env`/`waitUntil` platform context, and the backend's `Response`
970
- * is returned untouched. Legacy custom `.fetch(url, init)` bridges remain mountable but cannot
971
- * receive platform context. The mount runs in `nifra dev` too. Pass `apiPrefix: ""` to disable it. */
969
+ * is returned untouched. The mount runs in `nifra dev` too. Pass `apiPrefix: ""` to disable it. */
972
970
  readonly api?: unknown
973
971
  /** HTTP path prefix the {@link api} backend is auto-mounted at (default `"/api"`). A request whose
974
972
  * pathname is exactly the prefix or starts with `prefix + "/"` is dispatched to the backend before
975
973
  * page routing; the backend therefore defines its routes at the **full** path (`server().post("/api/
976
974
  * sync", …)`), matching the in-process `inProcessClient` call sites. Set to `""` to disable the
977
975
  * auto-mount entirely (the app serves pages only and `api` stays a loader-only `ctx.api`). Mounting
978
- * is also a no-op when `api` has neither the symbol mount nor a legacy callable `.fetch`. */
976
+ * is also a no-op when `api` does not expose the symbol mount. */
979
977
  readonly apiPrefix?: string
980
978
  /** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
981
979
  * signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
@@ -994,7 +992,7 @@ export interface CreateWebAppOptions {
994
992
  * of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
995
993
  * page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
996
994
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
997
- /** SSG: the prerendered-path set (e.g. from `enumeratePrerenderedPaths` or the build's
995
+ /** SSG: the prerendered-path set (e.g. `enumerateStaticRoutes(routes).paths` or the build's
998
996
  * `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
999
997
  * into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
1000
998
  readonly prerenderedPaths?: readonly string[]
@@ -1027,24 +1025,15 @@ interface RouteContext {
1027
1025
  readonly env: unknown
1028
1026
  }
1029
1027
 
1030
- /** Legacy pre-symbol mount bridge. Kept for custom integrations; it cannot receive platform context. */
1031
- interface LegacyMountableApi {
1032
- readonly fetch: (url: string, init?: RequestInit) => Response | Promise<Response>
1033
- }
1034
-
1035
1028
  /**
1036
- * Resolve the explicit backend mount interface, then fall back to the released `.fetch(url, init)`
1037
- * convention. The symbol seam forwards platform context without making web depend on client.
1029
+ * Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
1030
+ * context without making web depend on client.
1038
1031
  */
1039
1032
  function backendMountOf(api: unknown): BackendMountHandler | undefined {
1040
1033
  if ((typeof api !== "object" && typeof api !== "function") || api === null) return undefined
1041
1034
  const explicit = (api as Partial<BackendMount>)[NIFRA_BACKEND_MOUNT]
1042
- if (typeof explicit === "function") {
1043
- return (request, platform) => explicit.call(api, request, platform)
1044
- }
1045
- const legacy = (api as Partial<LegacyMountableApi>).fetch
1046
- if (typeof legacy !== "function") return undefined
1047
- return (request) => legacy.call(api, request.url, request)
1035
+ if (typeof explicit !== "function") return undefined
1036
+ return (request, platform) => explicit.call(api, request, platform)
1048
1037
  }
1049
1038
 
1050
1039
  /**
@@ -1095,13 +1084,8 @@ export function createWebApp<Env = unknown>(
1095
1084
  // surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
1096
1085
  // (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
1097
1086
  // at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
1098
- // sites. `apiPrefix: ""` opts out; a non-mountable `api` (no `.fetch`) is left pages-only. The hook
1087
+ // sites. `apiPrefix: ""` opts out; a non-mountable `api` (no symbol mount) is left pages-only. The hook
1099
1088
  // is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
1100
- //
1101
- // Backward-compat: an app that still hand-dispatches `/api/*` in its server entry (the old
1102
- // `if (pathname.startsWith("/api/")) return backend.fetch(req)`) intercepts BEFORE `app.fetch` is
1103
- // ever called, so that branch returns first and this hook never sees the request — no double
1104
- // handling. Apps that drop the hand-dispatch now rely on this mount instead (dev + prod alike).
1105
1089
  const apiPrefix = options.apiPrefix ?? "/api"
1106
1090
  const mountedApi = backendMountOf(api)
1107
1091
  if (apiPrefix !== "" && mountedApi !== undefined) {
package/src/manifest.ts CHANGED
@@ -108,7 +108,7 @@ export interface MetaArgs<Data = unknown> {
108
108
  * `og:url`/`canonical`/`og:image` built from it never drifts between the server-rendered `<head>` and
109
109
  * a soft-nav. Use it for absolute URLs (`origin + "/posts/" + slug`) instead of threading `siteUrl`
110
110
  * through loader data. Empty string (`""`) when the origin is unknown (e.g. a hand-built test render
111
- * with no request URL) — back-compat-safe: a `meta()` that ignores `origin` is unchanged.
111
+ * with no request URL). A `meta()` that ignores `origin` is unchanged.
112
112
  */
113
113
  readonly origin: string
114
114
  }
@@ -381,7 +381,7 @@ const encodeRouteParam = (value: string): string => {
381
381
  * Substitute a route pattern's `:param` segments with concrete values: `/users/:id` + `{id:"7"}` →
382
382
  * `/users/7`. Returns the filled path plus any params that had no value (a `getStaticPaths` bug) so
383
383
  * the caller can skip rather than emit a path with a literal `:name`. Shared by the SSG driver and
384
- * {@link enumeratePrerenderedPaths}.
384
+ * {@link enumerateStaticRoutes}.
385
385
  */
386
386
  export function fillRoutePattern(
387
387
  pattern: string,
@@ -438,9 +438,3 @@ export async function enumerateStaticRoutes(routes: readonly RouteEntry[]): Prom
438
438
  }
439
439
  return { paths, fallbacks }
440
440
  }
441
-
442
- /** The prerendered-path subset of {@link enumerateStaticRoutes} — kept for callers that only need the
443
- * paths (e.g. injecting `window.__NIFRA_PRERENDERED__`). */
444
- export async function enumeratePrerenderedPaths(routes: readonly RouteEntry[]): Promise<string[]> {
445
- return (await enumerateStaticRoutes(routes)).paths
446
- }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `@nifrajs/web/plugins/postcss` - a PostCSS (`*.css`, `*.pcss`, `*.postcss`) Bun plugin, in its OWN
3
+ * module so the SSR preload registers it BEFORE any CSS file loads. Mirrors `@nifrajs/web/plugins/scss`:
4
+ * pass `"dom"` for the client bundle (`buildClient({ plugins: [...] })`) and preload `"ssr"` for the
5
+ * server (`bun --preload`).
6
+ *
7
+ * PostCSS (`postcss`) is an **optional peer**, not a hard dependency: it's loaded on the first CSS file
8
+ * and fails loud with an install hint if absent. The plugin list comes from an explicit `plugins`
9
+ * option, or - when omitted - from the project's `postcss.config.js`, loaded via the optional
10
+ * `postcss-load-config` peer. This is the Tailwind v4 path: a config with `@tailwindcss/postcss` runs
11
+ * inside PostCSS, so `app.css` importing `tailwindcss` is compiled during `Bun.build` with no
12
+ * framework-specific code (v4 bundles its own Lightning CSS, so no separate autoprefixer step).
13
+ *
14
+ * Composes with CSS Modules: a `*.module.css` / `*.module.pcss` file is processed and then run through
15
+ * the same scoped-class transform as `@nifrajs/web/plugins/css-modules`, so `import styles` yields the
16
+ * `{ original: scoped }` map (SSR/dom class-map parity). A plain CSS file is a side-effect import - its
17
+ * CSS is bundled (dom) and it resolves to an empty module (ssr).
18
+ */
19
+ import type { BunPlugin } from "bun"
20
+ import { transformCssModule } from "./css-modules.ts"
21
+ import { createStylesheetEmitter, reproduciblePath, requirePeer } from "./kit.ts"
22
+
23
+ const STYLE_NS = "nifra-postcss"
24
+
25
+ /** The subset of the `postcss` API this plugin uses (structural, so no hard dependency on its types). */
26
+ export type PostcssProcessor = (plugins?: readonly unknown[]) => {
27
+ process(
28
+ css: string,
29
+ options: { readonly from?: string; readonly to?: string },
30
+ ): PromiseLike<{ readonly css: string }>
31
+ }
32
+
33
+ /** The subset of `postcss-load-config` this plugin uses when no explicit `plugins` are given. */
34
+ export type PostcssConfigLoader = (
35
+ ctx?: Record<string, unknown>,
36
+ path?: string,
37
+ ) => Promise<{ readonly plugins?: readonly unknown[]; readonly options?: Record<string, unknown> }>
38
+
39
+ export interface PostcssPluginOptions {
40
+ /** Inject the `postcss` function (default: the optional peer, loaded lazily). Pass a stub in tests. */
41
+ readonly postcss?: PostcssProcessor
42
+ /** PostCSS plugins to run. When omitted, the project's `postcss.config.js` is loaded instead. */
43
+ readonly plugins?: readonly unknown[]
44
+ /** Directory (or file) to load `postcss.config.js` from, when `plugins` is omitted. Default: cwd. */
45
+ readonly config?: string
46
+ /** Inject the config loader (default: the `postcss-load-config` optional peer). */
47
+ readonly loadConfig?: PostcssConfigLoader
48
+ }
49
+
50
+ const CSS_FILTER = /\.(css|pcss|postcss)(\?|$)/
51
+ const isModuleRequest = (cleanPath: string): boolean =>
52
+ /\.module\.(css|pcss|postcss)$/.test(cleanPath)
53
+
54
+ /**
55
+ * The PostCSS Bun plugin. `"dom"` → bundles the processed CSS (and, for `*.module.*`, exports the
56
+ * scoped class map); `"ssr"` → the class map only for `*.module.*`, an empty module for plain CSS.
57
+ * Tolerates a trailing `?query` (dev servers append one to bust Bun's import cache).
58
+ */
59
+ export function postcssBunPlugin(
60
+ generate: "dom" | "ssr",
61
+ options: PostcssPluginOptions = {},
62
+ ): BunPlugin {
63
+ return {
64
+ name: `nifra-postcss-${generate}`,
65
+ setup(build) {
66
+ const stylesheet = createStylesheetEmitter(build, STYLE_NS)
67
+ let processor = options.postcss
68
+ let pluginList: readonly unknown[] | undefined = options.plugins
69
+
70
+ const getProcessor = async (): Promise<PostcssProcessor> => {
71
+ processor ??= (
72
+ await requirePeer<{ default: PostcssProcessor }>("postcss", {
73
+ feature: "PostCSS/Tailwind support",
74
+ install: "bun add -d postcss",
75
+ })
76
+ ).default
77
+ return processor
78
+ }
79
+
80
+ const getPlugins = async (): Promise<readonly unknown[]> => {
81
+ if (pluginList !== undefined) return pluginList
82
+ const load =
83
+ options.loadConfig ??
84
+ (
85
+ await requirePeer<{ default: PostcssConfigLoader }>("postcss-load-config", {
86
+ feature: "loading postcss.config.js (or pass `plugins` explicitly)",
87
+ install: "bun add -d postcss-load-config",
88
+ })
89
+ ).default
90
+ const loaded = await load({}, options.config)
91
+ pluginList = loaded.plugins ?? []
92
+ return pluginList
93
+ }
94
+
95
+ build.onLoad({ filter: CSS_FILTER }, async (args) => {
96
+ const path = args.path.split("?")[0] ?? args.path
97
+ const source = await Bun.file(path).text()
98
+ const postcss = await getProcessor()
99
+ const plugins = await getPlugins()
100
+ let css: string
101
+ try {
102
+ css = (await postcss(plugins).process(source, { from: path })).css
103
+ } catch (err) {
104
+ throw new Error(
105
+ `[nifra/web] failed to process ${path}: ${(err as Error)?.message ?? err}`,
106
+ { cause: err },
107
+ )
108
+ }
109
+
110
+ if (isModuleRequest(path)) {
111
+ // *.module.css → process, then scope class names through the shared CSS-modules transform.
112
+ // Scope off the cwd-relative path so names are reproducible across machines/CI.
113
+ const { exports, css: scopedCss } = transformCssModule(css, reproduciblePath(path))
114
+ const js = `export default ${JSON.stringify(exports)}\n`
115
+ return {
116
+ contents: generate === "dom" ? js + stylesheet.emit(path, scopedCss) : js,
117
+ loader: "js",
118
+ }
119
+ }
120
+
121
+ // Plain CSS → side-effect import: bundle the CSS on dom, an empty module on ssr.
122
+ return {
123
+ contents: generate === "dom" ? stylesheet.emit(path, css) : "",
124
+ loader: "js",
125
+ }
126
+ })
127
+ },
128
+ }
129
+ }