@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.
- package/dist/build.d.ts +14 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +105 -40
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -10
- package/dist/client.js.map +1 -1
- package/dist/deferred.d.ts +3 -2
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +14 -8
- package/dist/deferred.js.map +1 -1
- package/dist/draft.d.ts +1 -1
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +1 -1
- package/dist/draft.js.map +1 -1
- package/dist/forms.d.ts +1 -1
- package/dist/forms.d.ts.map +1 -1
- package/dist/index.d.ts +11 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -22
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +2 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +1 -6
- package/dist/manifest.js.map +1 -1
- package/dist/plugins/postcss.d.ts +50 -0
- package/dist/plugins/postcss.d.ts.map +1 -0
- package/dist/plugins/postcss.js +68 -0
- package/dist/plugins/postcss.js.map +1 -0
- package/dist/plugins/svg.d.ts +44 -0
- package/dist/plugins/svg.d.ts.map +1 -0
- package/dist/plugins/svg.js +110 -0
- package/dist/plugins/svg.js.map +1 -0
- package/dist/router.d.ts +3 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -4
- package/dist/router.js.map +1 -1
- package/package.json +13 -2
- package/src/build.ts +109 -41
- package/src/client.ts +2 -8
- package/src/deferred.ts +16 -6
- package/src/draft.ts +1 -1
- package/src/forms.ts +1 -1
- package/src/index.ts +18 -34
- package/src/manifest.ts +2 -8
- package/src/plugins/postcss.ts +129 -0
- package/src/plugins/svg.ts +156 -0
- 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 `
|
|
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
|
-
|
|
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
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
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
|
-
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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 (
|
|
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 :
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
700
|
+
* @param options redirect status and whether an off-origin destination is intentional.
|
|
698
701
|
*/
|
|
699
|
-
export function redirect(
|
|
700
|
-
location
|
|
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:
|
|
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.
|
|
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`
|
|
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.
|
|
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
|
|
1037
|
-
*
|
|
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
|
|
1043
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
+
}
|