@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.
- package/README.md +8 -0
- package/dist/build.d.ts +252 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +755 -6
- package/dist/build.js.map +1 -1
- package/dist/chunk-names.d.ts +4 -0
- package/dist/chunk-names.d.ts.map +1 -0
- package/dist/chunk-names.js +46 -0
- package/dist/chunk-names.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +19 -2
- package/dist/client.js.map +1 -1
- package/dist/dev.d.ts +1 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +2 -1
- package/dist/dev.js.map +1 -1
- package/dist/fonts-google.d.ts +2 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js.map +1 -1
- package/dist/fonts.d.ts +2 -1
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +0 -16
- package/dist/fonts.js.map +1 -1
- package/dist/index.d.ts +138 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +268 -23
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +82 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/server-only.d.ts +21 -0
- package/dist/server-only.d.ts.map +1 -0
- package/dist/server-only.js +25 -0
- package/dist/server-only.js.map +1 -0
- package/dist/vite.d.ts +21 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +68 -4
- package/dist/vite.js.map +1 -1
- package/package.json +6 -1
- package/src/build.ts +1066 -12
- package/src/chunk-names.ts +45 -0
- package/src/client.ts +19 -2
- package/src/dev.ts +3 -2
- package/src/fonts-google.ts +3 -2
- package/src/fonts.ts +2 -1
- package/src/index.ts +373 -32
- package/src/manifest.ts +84 -6
- package/src/server-only.ts +25 -0
- package/src/vite.ts +82 -5
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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 {
|
|
10
|
+
import { server } from "@nifrajs/core"
|
|
11
11
|
import {
|
|
12
12
|
DEFERRED_ERROR_CODE,
|
|
13
13
|
DEFERRED_RUNTIME,
|
|
@@ -19,7 +19,17 @@ import {
|
|
|
19
19
|
} from "./deferred.ts"
|
|
20
20
|
import { isDraftEnabled } from "./draft.ts"
|
|
21
21
|
import { ISR_REVALIDATE_HEADER } from "./isr.ts"
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
LayoutEntry,
|
|
24
|
+
LinkDescriptor,
|
|
25
|
+
Manifest,
|
|
26
|
+
Meta,
|
|
27
|
+
MetaArgs,
|
|
28
|
+
MetaInput,
|
|
29
|
+
RouteEntry,
|
|
30
|
+
RouteModule,
|
|
31
|
+
ScriptDescriptor,
|
|
32
|
+
} from "./manifest.ts"
|
|
23
33
|
import {
|
|
24
34
|
DATA_HEADER,
|
|
25
35
|
PRERENDERED_GLOBAL,
|
|
@@ -76,6 +86,7 @@ export {
|
|
|
76
86
|
filePathToPatterns,
|
|
77
87
|
type GetStaticPaths,
|
|
78
88
|
type LayoutEntry,
|
|
89
|
+
type LinkDescriptor,
|
|
79
90
|
type Loader,
|
|
80
91
|
type LoaderContext,
|
|
81
92
|
type Manifest,
|
|
@@ -84,6 +95,7 @@ export {
|
|
|
84
95
|
type MetaInput,
|
|
85
96
|
type RouteEntry,
|
|
86
97
|
type RouteModule,
|
|
98
|
+
type ScriptDescriptor,
|
|
87
99
|
type StaticPath,
|
|
88
100
|
type StaticPaths,
|
|
89
101
|
type StaticRoutes,
|
|
@@ -122,6 +134,35 @@ export {
|
|
|
122
134
|
// Deferred loader data (`defer()` + the `Deferred<T>` type) — consumed by the adapter's `<Await>`.
|
|
123
135
|
export { type Deferred, defer }
|
|
124
136
|
|
|
137
|
+
/** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
|
|
138
|
+
* object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
|
|
139
|
+
* type-level (zero runtime cost). The property is optional + `never`-typed, so a `ServerOnly<T>`
|
|
140
|
+
* stays assignment-compatible with `T` — it documents intent without obstructing real use. */
|
|
141
|
+
declare const SERVER_ONLY_BRAND: unique symbol
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Type-level intent marker for a value that must only exist on the server — a secret, a DB handle, a
|
|
145
|
+
* server-only client. `ServerOnly<T>` is structurally `T` (the brand is an optional phantom field, so
|
|
146
|
+
* existing code keeps type-checking), but it advertises to readers + the compiler that the value is
|
|
147
|
+
* not meant to cross to the browser. It is **purely type-level** and erases at build — it does NOT,
|
|
148
|
+
* by itself, keep the value out of the client bundle.
|
|
149
|
+
*
|
|
150
|
+
* The enforcement is two runtime conventions:
|
|
151
|
+
* - add the side-effect import `import "@nifrajs/web/server-only"` at the top of the module — the
|
|
152
|
+
* client build ({@link buildClient}) fails loud, with the import chain, if it reaches a browser
|
|
153
|
+
* chunk (the poison-import marker);
|
|
154
|
+
* - or name the file `*.server.ts` — the `.server` convention empties it in the client build.
|
|
155
|
+
*
|
|
156
|
+
* Use this brand to express the intent in the types; pair it with one of those runtime markers so a
|
|
157
|
+
* leak is caught at build time rather than shipping a secret to the client.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* import "@nifrajs/web/server-only"
|
|
161
|
+
* import type { ServerOnly } from "@nifrajs/web"
|
|
162
|
+
* export const apiKey: ServerOnly<string> = process.env.SECRET_API_KEY!
|
|
163
|
+
*/
|
|
164
|
+
export type ServerOnly<T> = T & { readonly [SERVER_ONLY_BRAND]?: never }
|
|
165
|
+
|
|
125
166
|
/** The data handed to a route component. Opaque to the core. `actionData` is the return of a
|
|
126
167
|
* route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
|
|
127
168
|
* (absent on SSR): they drive **optimistic UI** — render from `submission.formData` while `pending`. */
|
|
@@ -178,6 +219,14 @@ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
|
|
|
178
219
|
* reads it so hydration after a native form POST matches the server-rendered markup. */
|
|
179
220
|
export const ACTION_GLOBAL = "__NIFRA_ACTION__"
|
|
180
221
|
|
|
222
|
+
/**
|
|
223
|
+
* The single default port for the dev server (`@nifrajs/web/dev`, `@nifrajs/web/vite`) **and**
|
|
224
|
+
* `nifra start`. Deliberately uncommon: `3000`/`5173`/`8080` collide with whatever else is running
|
|
225
|
+
* (Next, Vite, a stray Node API). `4321` rarely is — and being the *same* constant across `nifra dev`
|
|
226
|
+
* and `nifra start` means a project's URL doesn't change between commands. Override per-run with
|
|
227
|
+
* `--port <n>` or the `PORT` env var. */
|
|
228
|
+
export const DEFAULT_DEV_PORT = 4321
|
|
229
|
+
|
|
181
230
|
// Shared, stateless — allocated once at module load, not per render/stream.
|
|
182
231
|
const TEXT_ENCODER = new TextEncoder()
|
|
183
232
|
|
|
@@ -241,7 +290,17 @@ export interface RenderPageOptions {
|
|
|
241
290
|
/** Document title (fallback when `head.title` is unset). */
|
|
242
291
|
readonly title?: string
|
|
243
292
|
/** Resolved route head — `title` overrides `title` above; `meta`/`link` render as managed
|
|
244
|
-
* (`data-nifra`) tags the client updates on navigation.
|
|
293
|
+
* (`data-nifra`) tags the client updates on navigation.
|
|
294
|
+
*
|
|
295
|
+
* **Head contract (the layout chain contributes).** `createWebApp` resolves this via
|
|
296
|
+
* {@link mergeHeads}: a route's head is its **layout chain's** `meta`/`head` exports merged with the
|
|
297
|
+
* page's. A `_layout.tsx` may `export const meta` (or `export function meta(args)`) — its tags land
|
|
298
|
+
* on every page below it (the home for `hreflang`, `preconnect`, a section-default `<title>`). The
|
|
299
|
+
* merge is **nearest-wins for scalars** (the page's `title` overrides an inner layout's, which
|
|
300
|
+
* overrides an outer one; an undefined page title keeps the layout's) and **concatenated for the
|
|
301
|
+
* `meta`/`link` arrays** (outermost layout first, page last). `<link>` attributes are name-validated
|
|
302
|
+
* (any letter/digit/hyphen name — covers `rel`/`href`/`hreflang`/`crossorigin`/`media`/`sizes`/`as`/
|
|
303
|
+
* `integrity`/`fetchpriority`/…) and value-escaped against XSS. */
|
|
245
304
|
readonly head?: Meta
|
|
246
305
|
/** Id of the container wrapping the app markup (default `"root"`). */
|
|
247
306
|
readonly rootId?: string
|
|
@@ -612,6 +671,23 @@ function escapeHtml(value: string): string {
|
|
|
612
671
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
613
672
|
}
|
|
614
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
|
|
676
|
+
* a script's text may contain any character EXCEPT the sequences that the parser treats as element
|
|
677
|
+
* boundaries: `</` (which would begin the end tag — `</script>` is the obvious XSS vector, but ANY `</`
|
|
678
|
+
* starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
|
|
679
|
+
* (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
|
|
680
|
+
* identical character, so a JSON-LD payload is byte-equivalent after parsing — but the raw `<`/`>` the
|
|
681
|
+
* HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
|
|
682
|
+
* mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
|
|
683
|
+
*/
|
|
684
|
+
function escapeScriptContent(content: string): string {
|
|
685
|
+
// Order matters: rewrite `]]>` first (its `>` becomes `>`), then every `<` (covers `</` and
|
|
686
|
+
// `<!--` in one pass). `<` → `<`; the lone `>` of `]]>` → `>`. A bare `>` elsewhere is
|
|
687
|
+
// harmless in script data, so only the `]]>` close is targeted for `>`.
|
|
688
|
+
return content.replaceAll("]]>", "]]\\u003e").replaceAll("<", "\\u003c")
|
|
689
|
+
}
|
|
690
|
+
|
|
615
691
|
function escapeAttr(value: string): string {
|
|
616
692
|
return value
|
|
617
693
|
.replaceAll("&", "&")
|
|
@@ -620,15 +696,31 @@ function escapeAttr(value: string): string {
|
|
|
620
696
|
.replaceAll(">", ">")
|
|
621
697
|
}
|
|
622
698
|
|
|
699
|
+
// Attribute-NAME guard for emitted `<meta>`/`<link>` tags. Intentionally a *name-shape* allowlist, not
|
|
700
|
+
// a hardcoded per-attribute one: any letter/digit/hyphen name beginning with a letter is permitted, so
|
|
701
|
+
// every standard `<link>` attribute flows through — `rel`, `href`, `hreflang`, `crossorigin`, `media`,
|
|
702
|
+
// `sizes`, `type`, `as`, `title`, `integrity`, `referrerpolicy`, `imagesrcset`, `imagesizes`,
|
|
703
|
+
// `disabled`, `color`, `fetchpriority` — alongside `<meta>`'s `name`/`property`/`content` and custom
|
|
704
|
+
// `data-*`. A hardcoded list would silently drop valid attrs (the bug this guards against: `hreflang`
|
|
705
|
+
// /`crossorigin` getting filtered out). What it rejects is name-shape abuse only — a name with a space,
|
|
706
|
+
// `=`, `>`, quote, or a leading digit can't break out of the tag. Attribute VALUES are escaped
|
|
707
|
+
// separately (they may carry loader data → XSS), so a widened name set never widens the injection
|
|
708
|
+
// surface. Names that fail the shape check are dropped.
|
|
623
709
|
const SAFE_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/
|
|
624
710
|
|
|
625
|
-
/** Serialize a meta/link tag's attributes: validate the name (
|
|
626
|
-
*
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
711
|
+
/** Serialize a meta/link tag's attributes: validate the name shape (see {@link SAFE_ATTR_NAME}) +
|
|
712
|
+
* escape the value against XSS. Invalid attribute names are dropped. Values follow the HTML attribute
|
|
713
|
+
* conventions {@link LinkDescriptor} types: a string renders `name="escaped"`; `true` renders the bare
|
|
714
|
+
* boolean attribute (`disabled`); `false` and `undefined` are skipped (a conditionally-absent attr). */
|
|
715
|
+
function tagAttrs(attrs: Record<string, string | boolean | undefined>): string {
|
|
716
|
+
let out = ""
|
|
717
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
718
|
+
if (value === undefined || value === false) continue // omitted / absent boolean attribute
|
|
719
|
+
if (!SAFE_ATTR_NAME.test(name)) continue // name-shape abuse — dropped (see SAFE_ATTR_NAME)
|
|
720
|
+
if (out !== "") out += " "
|
|
721
|
+
out += value === true ? name : `${name}="${escapeAttr(value)}"`
|
|
722
|
+
}
|
|
723
|
+
return out
|
|
632
724
|
}
|
|
633
725
|
|
|
634
726
|
// Memoize the serialized head-tag string by the resolved `Meta` object's identity. A STATIC route
|
|
@@ -638,10 +730,13 @@ function tagAttrs(attrs: Record<string, string>): string {
|
|
|
638
730
|
// content can vary). WeakMap so a route module that's GC'd takes its entry with it.
|
|
639
731
|
const headTagsCache = new WeakMap<Meta, string>()
|
|
640
732
|
|
|
641
|
-
/** Render a route's `meta`/`link` as managed (`data-nifra`) head tags. Title is set
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
733
|
+
/** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
|
|
734
|
+
* separately. XSS-safe by construction: every attribute flows through {@link tagAttrs} (name shape-
|
|
735
|
+
* validated, value HTML-escaped), and each `script[].content` through `escapeScriptContent`
|
|
736
|
+
* (breakout-escaped) — so loader-derived strings (LLM-authored `og:*`, user content) can't inject markup
|
|
737
|
+
* or close the tag early. String concatenation (no intermediate `.map()` arrays + spread) — parity with
|
|
738
|
+
* the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
|
|
739
|
+
* is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
|
|
645
740
|
function headTags(head: Meta | undefined): string {
|
|
646
741
|
if (head === undefined) return ""
|
|
647
742
|
const cached = headTagsCache.get(head)
|
|
@@ -649,6 +744,12 @@ function headTags(head: Meta | undefined): string {
|
|
|
649
744
|
let out = ""
|
|
650
745
|
if (head.meta !== undefined) for (const m of head.meta) out += `<meta ${tagAttrs(m)} data-nifra>`
|
|
651
746
|
if (head.link !== undefined) for (const l of head.link) out += `<link ${tagAttrs(l)} data-nifra>`
|
|
747
|
+
// `<script>` slot — JSON-LD + other inert head scripts. `type` is attribute-escaped; `content` is
|
|
748
|
+
// breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
|
|
749
|
+
// element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
|
|
750
|
+
if (head.script !== undefined)
|
|
751
|
+
for (const s of head.script)
|
|
752
|
+
out += `<script type="${escapeAttr(s.type ?? "application/ld+json")}" data-nifra>${escapeScriptContent(s.content)}</script>`
|
|
652
753
|
headTagsCache.set(head, out)
|
|
653
754
|
return out
|
|
654
755
|
}
|
|
@@ -659,6 +760,118 @@ export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
|
|
|
659
760
|
return typeof meta === "function" ? meta(args) : meta
|
|
660
761
|
}
|
|
661
762
|
|
|
763
|
+
/**
|
|
764
|
+
* A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
|
|
765
|
+
* engines which URL is authoritative for a page (deduping query-string / tracking variants).
|
|
766
|
+
*
|
|
767
|
+
* ```ts
|
|
768
|
+
* export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
|
|
769
|
+
* ```
|
|
770
|
+
*/
|
|
771
|
+
export function canonical(href: string): LinkDescriptor {
|
|
772
|
+
return { rel: "canonical", href }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Inputs for {@link openGraph} — the common Open Graph properties. All optional; only the provided
|
|
776
|
+
* ones become tags. `type` defaults to `"website"`. */
|
|
777
|
+
export interface OpenGraphInput {
|
|
778
|
+
readonly title?: string
|
|
779
|
+
readonly description?: string
|
|
780
|
+
/** Absolute URL of the share image (`og:image`). */
|
|
781
|
+
readonly image?: string
|
|
782
|
+
/** Canonical URL of the page (`og:url`). */
|
|
783
|
+
readonly url?: string
|
|
784
|
+
/** Object type (`og:type`) — `"website"`, `"article"`, … Default `"website"`. */
|
|
785
|
+
readonly type?: string
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
|
|
790
|
+
* properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
|
|
791
|
+
*
|
|
792
|
+
* ```ts
|
|
793
|
+
* export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
|
|
794
|
+
* ```
|
|
795
|
+
*/
|
|
796
|
+
export function openGraph(input: OpenGraphInput): Array<Record<string, string>> {
|
|
797
|
+
const tags: Array<Record<string, string>> = []
|
|
798
|
+
const add = (property: string, content: string | undefined): void => {
|
|
799
|
+
if (content !== undefined) tags.push({ property, content })
|
|
800
|
+
}
|
|
801
|
+
add("og:title", input.title)
|
|
802
|
+
add("og:description", input.description)
|
|
803
|
+
add("og:image", input.image)
|
|
804
|
+
add("og:url", input.url)
|
|
805
|
+
// `og:type` always present (the spec's required property) — default "website" when unset.
|
|
806
|
+
tags.push({ property: "og:type", content: input.type ?? "website" })
|
|
807
|
+
return tags
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
|
|
812
|
+
* object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
|
|
813
|
+
* `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
|
|
814
|
+
*
|
|
815
|
+
* ```ts
|
|
816
|
+
* export const meta = {
|
|
817
|
+
* script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
|
|
818
|
+
* }
|
|
819
|
+
* ```
|
|
820
|
+
*/
|
|
821
|
+
export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
|
|
822
|
+
return { type: "application/ld+json", content: JSON.stringify(data) }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
|
|
827
|
+
*
|
|
828
|
+
* The head contract (see {@link CreateWebAppOptions} and `LayoutEntry`): a `_layout.tsx` may export
|
|
829
|
+
* `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
|
|
830
|
+
* it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
|
|
831
|
+
* render chain), and merges:
|
|
832
|
+
* - **`title`** — *nearest-wins*: the last defined value across the list, so the page overrides an
|
|
833
|
+
* inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
|
|
834
|
+
* page `title` keeps it.
|
|
835
|
+
* - **`meta` / `link`** — *concatenated* in list order (outermost first, page last). Duplicate-tag
|
|
836
|
+
* de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
|
|
837
|
+
* can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
|
|
838
|
+
*
|
|
839
|
+
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
840
|
+
* static (by-reference) `Meta` and there is exactly one — otherwise a new object each call. That is
|
|
841
|
+
* fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
842
|
+
* content can vary with loader data anyway).
|
|
843
|
+
*/
|
|
844
|
+
export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
845
|
+
// Single-head fast path (a route with no layout `meta`, by far the common case) — return the
|
|
846
|
+
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
847
|
+
if (heads.length === 1) return heads[0] as Meta
|
|
848
|
+
let title: string | undefined
|
|
849
|
+
const meta: Array<Record<string, string>> = []
|
|
850
|
+
const link: LinkDescriptor[] = []
|
|
851
|
+
const script: ScriptDescriptor[] = []
|
|
852
|
+
for (const h of heads) {
|
|
853
|
+
if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
|
|
854
|
+
if (h.meta !== undefined) meta.push(...h.meta)
|
|
855
|
+
if (h.link !== undefined) link.push(...h.link)
|
|
856
|
+
if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
|
|
857
|
+
}
|
|
858
|
+
// Build the result with only the fields that were actually contributed — an empty `meta`/`link`/
|
|
859
|
+
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
860
|
+
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
861
|
+
// `undefined`).
|
|
862
|
+
const merged: {
|
|
863
|
+
title?: string
|
|
864
|
+
meta?: Meta["meta"]
|
|
865
|
+
link?: Meta["link"]
|
|
866
|
+
script?: Meta["script"]
|
|
867
|
+
} = {}
|
|
868
|
+
if (title !== undefined) merged.title = title
|
|
869
|
+
if (meta.length > 0) merged.meta = meta
|
|
870
|
+
if (link.length > 0) merged.link = link
|
|
871
|
+
if (script.length > 0) merged.script = script
|
|
872
|
+
return merged as Meta
|
|
873
|
+
}
|
|
874
|
+
|
|
662
875
|
export interface CreateWebAppOptions {
|
|
663
876
|
readonly adapter: RenderAdapter
|
|
664
877
|
readonly manifest: Manifest
|
|
@@ -667,8 +880,24 @@ export interface CreateWebAppOptions {
|
|
|
667
880
|
/** Default document title for all pages. */
|
|
668
881
|
readonly title?: string
|
|
669
882
|
/** Injected into each loader's `ctx.api` — typically an `inProcessClient(app)` (typed
|
|
670
|
-
* per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
|
|
883
|
+
* per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
|
|
884
|
+
*
|
|
885
|
+
* **Auto-mount.** When `api` exposes a callable `.fetch(url, init)` (every `inProcessClient(backend)`
|
|
886
|
+
* does — see {@link isMountableApi}), `createWebApp` also serves that backend over HTTP at
|
|
887
|
+
* {@link apiPrefix} (default `/api`): a request whose pathname starts with the prefix is dispatched
|
|
888
|
+
* straight to `api.fetch(req.url, req)` before page routing, and the backend's `Response` is returned
|
|
889
|
+
* untouched. This closes the reported gap where `POST /api/sync` / `/api/auth/*` 404/405'd against the
|
|
890
|
+
* page router until the app author hand-wrote an `if (pathname.startsWith("/api/")) …` branch in
|
|
891
|
+
* `server-bun.ts`. The mount runs in `nifra dev` too (the Vite dev server dispatches every request
|
|
892
|
+
* through this same app). Pass `apiPrefix: ""` to disable it (pages-only). */
|
|
671
893
|
readonly api?: unknown
|
|
894
|
+
/** HTTP path prefix the {@link api} backend is auto-mounted at (default `"/api"`). A request whose
|
|
895
|
+
* pathname is exactly the prefix or starts with `prefix + "/"` is dispatched to the backend before
|
|
896
|
+
* page routing; the backend therefore defines its routes at the **full** path (`server().post("/api/
|
|
897
|
+
* sync", …)`), matching the in-process `inProcessClient` call sites. Set to `""` to disable the
|
|
898
|
+
* auto-mount entirely (the app serves pages only and `api` stays a loader-only `ctx.api`). Mounting
|
|
899
|
+
* is also a no-op when `api` has no callable `.fetch` (e.g. a plain object passed as `ctx.api`). */
|
|
900
|
+
readonly apiPrefix?: string
|
|
672
901
|
/** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
|
|
673
902
|
* signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
|
|
674
903
|
* Pair with `withISR({ draftSecret })` so editors bypass the cache. Omit to disable draft mode. */
|
|
@@ -719,13 +948,48 @@ interface RouteContext {
|
|
|
719
948
|
readonly env: unknown
|
|
720
949
|
}
|
|
721
950
|
|
|
951
|
+
/** The minimal shape the `/api/*` auto-mount needs: a `fetch(url, init)` that yields a `Response`.
|
|
952
|
+
* `inProcessClient(backend)` exposes exactly this (its in-process bridge); a raw `@nifrajs/core`
|
|
953
|
+
* `Server` does not match (its `fetch` takes a `Request`, not `(url, init)`) — which is intended,
|
|
954
|
+
* since the documented `api` is the `inProcessClient` proxy, not the bare server. */
|
|
955
|
+
interface MountableApi {
|
|
956
|
+
readonly fetch: (url: string, init?: RequestInit) => Response | Promise<Response>
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Narrow an opaque `api` to a {@link MountableApi}. Guards the `/api/*` auto-mount: an `api` set to a
|
|
961
|
+
* plain loader-only value (no `.fetch`) leaves the app pages-only rather than crashing. Kept a
|
|
962
|
+
* structural shape check (not an `instanceof`) so any in-process bridge — `inProcessClient`, a test
|
|
963
|
+
* stub, a custom agent — is mountable without `@nifrajs/web` depending on `@nifrajs/client`.
|
|
964
|
+
*/
|
|
965
|
+
function isMountableApi(api: unknown): api is MountableApi {
|
|
966
|
+
// inProcessClient(backend) is a CALLABLE Eden proxy — `typeof` is "function", not "object" — so the
|
|
967
|
+
// guard must accept both; it only needs a callable `.fetch` (the in-process bridge the outer proxy
|
|
968
|
+
// exposes for the mount). An object-only check silently disabled the whole /api/* auto-mount.
|
|
969
|
+
return (
|
|
970
|
+
(typeof api === "object" || typeof api === "function") &&
|
|
971
|
+
api !== null &&
|
|
972
|
+
typeof (api as { fetch?: unknown }).fetch === "function"
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
|
|
722
976
|
/**
|
|
723
977
|
* Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
|
|
724
978
|
* and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
|
|
725
979
|
* lifecycle, so matching, params, and precedence are battle-tested. fs-free — feed it a
|
|
726
980
|
* manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
|
|
981
|
+
*
|
|
982
|
+
* **Typed platform bindings.** Pass `Env` — `createWebApp<Env>({ … })` — to declare the app's Workers
|
|
983
|
+
* bindings ONCE. It seeds the returned `Server`'s context with `{ env: Env }` (exactly as the backend's
|
|
984
|
+
* `server<Env>()` does), so `app.fetch(req, { env })` / `toFetchHandler(app)` type-check the `env`
|
|
985
|
+
* argument against the declared shape — no per-binding cast at the edge entry. Per-route loaders/actions
|
|
986
|
+
* stay typed independently of this call: annotate them with `@nifrajs/client`'s `LoaderArgs<Api, Env>`
|
|
987
|
+
* (same `Env`) so `ctx.env.MY_KV` is typed there too. Omit the parameter and `Env` is `unknown` — the
|
|
988
|
+
* secure default; validate at the trust boundary before use.
|
|
727
989
|
*/
|
|
728
|
-
export function createWebApp
|
|
990
|
+
export function createWebApp<Env = unknown>(
|
|
991
|
+
options: CreateWebAppOptions,
|
|
992
|
+
): ReturnType<typeof server<Env>> {
|
|
729
993
|
const { adapter, manifest, clientEntry, title, api } = options
|
|
730
994
|
const titleOption = title === undefined ? {} : { title }
|
|
731
995
|
// Draft/preview: when a `draftSecret` is configured, each request's signed `__nifra_draft` cookie is
|
|
@@ -747,26 +1011,87 @@ export function createWebApp(options: CreateWebAppOptions): Server {
|
|
|
747
1011
|
if (perRoute !== undefined) return { styles: perRoute }
|
|
748
1012
|
return options.styles && options.styles.length > 0 ? { styles: options.styles } : {}
|
|
749
1013
|
}
|
|
750
|
-
|
|
1014
|
+
// Seed the context with the declared `Env` so `app.fetch(req, { env })` / `toFetchHandler(app)` type
|
|
1015
|
+
// the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
|
|
1016
|
+
// `app.fetch(req, { env })`; this is a compile-time-only seed (`server<Env>()` casts, doesn't store).
|
|
1017
|
+
const app = server<Env>()
|
|
1018
|
+
// Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
|
|
1019
|
+
// `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
|
|
1020
|
+
// for every method (POST/GET/PUT/…) — not just the GET page routes — and a backend 404 (`/api/none`)
|
|
1021
|
+
// surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
|
|
1022
|
+
// (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
|
|
1023
|
+
// at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
|
|
1024
|
+
// sites. `apiPrefix: ""` opts out; a non-mountable `api` (no `.fetch`) is left pages-only. The hook
|
|
1025
|
+
// is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
|
|
1026
|
+
//
|
|
1027
|
+
// Backward-compat: an app that still hand-dispatches `/api/*` in its server entry (the old
|
|
1028
|
+
// `if (pathname.startsWith("/api/")) return backend.fetch(req)`) intercepts BEFORE `app.fetch` is
|
|
1029
|
+
// ever called, so that branch returns first and this hook never sees the request — no double
|
|
1030
|
+
// handling. Apps that drop the hand-dispatch now rely on this mount instead (dev + prod alike).
|
|
1031
|
+
const apiPrefix = options.apiPrefix ?? "/api"
|
|
1032
|
+
if (apiPrefix !== "" && isMountableApi(api)) {
|
|
1033
|
+
const mountedApi = api
|
|
1034
|
+
app.onRequest((req) => {
|
|
1035
|
+
const { pathname } = new URL(req.url)
|
|
1036
|
+
// Exactly the prefix (`/api`) or a sub-path (`/api/…`) — NOT a sibling like `/apixyz` that merely
|
|
1037
|
+
// shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
|
|
1038
|
+
if (pathname === apiPrefix || pathname.startsWith(`${apiPrefix}/`)) {
|
|
1039
|
+
return mountedApi.fetch(req.url, req)
|
|
1040
|
+
}
|
|
1041
|
+
return undefined // not an API path → continue to page routing
|
|
1042
|
+
})
|
|
1043
|
+
}
|
|
751
1044
|
// SSG `fallback: "404"`: the set of concrete paths that actually exist for those routes. An unlisted
|
|
752
1045
|
// path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
|
|
753
1046
|
// SSR on demand). O(1) membership via a Set.
|
|
754
1047
|
const prerenderedSet = new Set(options.prerenderedPaths ?? [])
|
|
755
1048
|
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
|
|
1049
|
+
// Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
|
|
1050
|
+
// render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
|
|
1051
|
+
// contributes). Loaded lazily — the data-only and 405 branches return before any layout is needed.
|
|
1052
|
+
const loadLayoutModules = (
|
|
1053
|
+
route: RouteEntry,
|
|
1054
|
+
): Promise<ReadonlyArray<{ default: unknown; meta?: MetaInput }>> =>
|
|
759
1055
|
Promise.all(
|
|
760
1056
|
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
761
|
-
route.layoutIds.map((id) =>
|
|
762
|
-
(manifest.layouts[id] as LayoutEntry).load().then((m) => m.default),
|
|
763
|
-
),
|
|
1057
|
+
route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).load()),
|
|
764
1058
|
)
|
|
765
1059
|
|
|
1060
|
+
// Build a route's render chain + its merged `<head>` from the already-loaded layout modules + the
|
|
1061
|
+
// page module. The chain is `[…layout components, page]`; the head merges each layout's `meta`
|
|
1062
|
+
// export (outermost→innermost) with the page's (last), so a `head`/`meta` on `_layout.tsx`
|
|
1063
|
+
// contributes sitewide tags. `metaArgs` (loader data + params) feed function-form `meta`s.
|
|
1064
|
+
const resolveChainAndHead = (
|
|
1065
|
+
layoutModules: ReadonlyArray<{ default: unknown; meta?: MetaInput }>,
|
|
1066
|
+
page: RouteModule,
|
|
1067
|
+
metaArgs: MetaArgs,
|
|
1068
|
+
): { chain: unknown[]; head: Meta } => {
|
|
1069
|
+
const chain = [...layoutModules.map((m) => m.default), page.default]
|
|
1070
|
+
const heads = [
|
|
1071
|
+
...layoutModules.map((m) => resolveMeta(m.meta, metaArgs)),
|
|
1072
|
+
resolveMeta(page.meta, metaArgs),
|
|
1073
|
+
]
|
|
1074
|
+
return { chain, head: mergeHeads(heads) }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
766
1077
|
// Dir a special-file id lives in: `_error`→"" , `a/b/_error`→"a/b" (and likewise for `_layout`).
|
|
767
1078
|
const dirOfId = (id: string, suffix: string): string =>
|
|
768
1079
|
id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
|
|
769
1080
|
|
|
1081
|
+
// The request's origin (scheme + host + port, NO trailing slash) — the one server fact `meta()` needs
|
|
1082
|
+
// for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
|
|
1083
|
+
// `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
|
|
1084
|
+
// a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
|
|
1085
|
+
// (shouldn't happen on a real request) degrades to `""` — the documented unknown-origin default —
|
|
1086
|
+
// rather than throwing during render.
|
|
1087
|
+
const originOf = (req: Request): string => {
|
|
1088
|
+
try {
|
|
1089
|
+
return new URL(req.url).origin
|
|
1090
|
+
} catch {
|
|
1091
|
+
return ""
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
770
1095
|
/**
|
|
771
1096
|
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
772
1097
|
* half of error UI — works on every adapter, no client takeover). The boundary renders in place of
|
|
@@ -883,8 +1208,11 @@ export function createWebApp(options: CreateWebAppOptions): Server {
|
|
|
883
1208
|
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
884
1209
|
})
|
|
885
1210
|
}
|
|
886
|
-
const chain =
|
|
887
|
-
|
|
1211
|
+
const { chain, head } = resolveChainAndHead(await loadLayoutModules(route), mod, {
|
|
1212
|
+
data,
|
|
1213
|
+
params: c.params,
|
|
1214
|
+
origin: originOf(c.req),
|
|
1215
|
+
})
|
|
888
1216
|
const hydrateRoute = mod.hydrate !== false
|
|
889
1217
|
try {
|
|
890
1218
|
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
@@ -968,8 +1296,11 @@ export function createWebApp(options: CreateWebAppOptions): Server {
|
|
|
968
1296
|
const data = mod.loader
|
|
969
1297
|
? await mod.loader({ params: c.params, request: c.req, api, env: c.env, draft })
|
|
970
1298
|
: null
|
|
971
|
-
const chain =
|
|
972
|
-
|
|
1299
|
+
const { chain, head } = resolveChainAndHead(await loadLayoutModules(route), mod, {
|
|
1300
|
+
data,
|
|
1301
|
+
params: c.params,
|
|
1302
|
+
origin: originOf(c.req),
|
|
1303
|
+
})
|
|
973
1304
|
const hydrateRoute = mod.hydrate !== false
|
|
974
1305
|
// A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too —
|
|
975
1306
|
// `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
|
|
@@ -1054,7 +1385,7 @@ export function generateClientEntry(
|
|
|
1054
1385
|
}
|
|
1055
1386
|
|
|
1056
1387
|
return `${[
|
|
1057
|
-
'import { createClientRouter, createMatcher, resolveMeta } from "@nifrajs/web"',
|
|
1388
|
+
'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
|
|
1058
1389
|
'import { applyHead, installForms, installHistory } from "@nifrajs/web/client"',
|
|
1059
1390
|
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
1060
1391
|
// access yields `undefined` if absent — unlike a named import, which would be a link error.
|
|
@@ -1064,7 +1395,7 @@ export function generateClientEntry(
|
|
|
1064
1395
|
`const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
|
|
1065
1396
|
// Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
|
|
1066
1397
|
// route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
|
|
1067
|
-
// visited. loadModule caches the [layouts…, page] component chain + the
|
|
1398
|
+
// visited. loadModule caches the [layouts…, page] component chain + the chain's meta list per id.
|
|
1068
1399
|
"const loaders = {",
|
|
1069
1400
|
...loaderRows,
|
|
1070
1401
|
"}",
|
|
@@ -1077,15 +1408,19 @@ export function generateClientEntry(
|
|
|
1077
1408
|
// the adapter's boundary so a client render error renders the `_error` UI. DOM-transparent, so the
|
|
1078
1409
|
// hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
|
|
1079
1410
|
// the adapter has no `errorBoundary`.
|
|
1411
|
+
// `metas[id]` is the chain's `meta` exports in head order (outermost layout → … → page), so a
|
|
1412
|
+
// soft-nav merges the layout chain's head with the page's — matching the SSR `<head>` (sitewide
|
|
1413
|
+
// layout tags persist across client navigation, no flash of page-only head). `_error` carries no
|
|
1414
|
+
// head (a terminal boundary), so it's excluded from the meta list for error routes.
|
|
1080
1415
|
" if (errorBoundary && errorRouteIds.has(id)) {",
|
|
1081
1416
|
" const fallback = mods[mods.length - 1].default",
|
|
1082
1417
|
" const page = mods[mods.length - 2].default",
|
|
1083
1418
|
" const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
|
|
1084
1419
|
" chains[id] = [...layouts, errorBoundary(fallback), page]",
|
|
1085
|
-
" metas[id] = mods
|
|
1420
|
+
" metas[id] = mods.slice(0, mods.length - 1).map((m) => m.meta)",
|
|
1086
1421
|
" } else {",
|
|
1087
1422
|
" chains[id] = mods.map((m) => m.default)",
|
|
1088
|
-
" metas[id] = mods
|
|
1423
|
+
" metas[id] = mods.map((m) => m.meta)",
|
|
1089
1424
|
" }",
|
|
1090
1425
|
"}",
|
|
1091
1426
|
"const patterns = [",
|
|
@@ -1118,7 +1453,13 @@ export function generateClientEntry(
|
|
|
1118
1453
|
" mountRouter({ router, routes: chains, container: root })",
|
|
1119
1454
|
" router.subscribe(() => {",
|
|
1120
1455
|
" const s = router.snapshot()",
|
|
1121
|
-
|
|
1456
|
+
// Merge the matched route's chain meta (layouts→page) into one head — same contract as SSR.
|
|
1457
|
+
" if (!s.pending) {",
|
|
1458
|
+
// `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
|
|
1459
|
+
// page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url — no head drift.
|
|
1460
|
+
" const args = { data: s.data, params: s.params, origin: location.origin }",
|
|
1461
|
+
" applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
|
|
1462
|
+
" }",
|
|
1122
1463
|
" })",
|
|
1123
1464
|
"})",
|
|
1124
1465
|
].join("\n")}\n`
|