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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +8 -0
  2. package/dist/build.d.ts +252 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +755 -6
  5. package/dist/build.js.map +1 -1
  6. package/dist/chunk-names.d.ts +4 -0
  7. package/dist/chunk-names.d.ts.map +1 -0
  8. package/dist/chunk-names.js +46 -0
  9. package/dist/chunk-names.js.map +1 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +19 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/dev.d.ts +1 -1
  14. package/dist/dev.d.ts.map +1 -1
  15. package/dist/dev.js +2 -1
  16. package/dist/dev.js.map +1 -1
  17. package/dist/fonts-google.d.ts +2 -1
  18. package/dist/fonts-google.d.ts.map +1 -1
  19. package/dist/fonts-google.js.map +1 -1
  20. package/dist/fonts.d.ts +2 -1
  21. package/dist/fonts.d.ts.map +1 -1
  22. package/dist/fonts.js +0 -16
  23. package/dist/fonts.js.map +1 -1
  24. package/dist/index.d.ts +138 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +268 -23
  27. package/dist/index.js.map +1 -1
  28. package/dist/manifest.d.ts +82 -5
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js.map +1 -1
  31. package/dist/server-only.d.ts +21 -0
  32. package/dist/server-only.d.ts.map +1 -0
  33. package/dist/server-only.js +25 -0
  34. package/dist/server-only.js.map +1 -0
  35. package/dist/vite.d.ts +21 -1
  36. package/dist/vite.d.ts.map +1 -1
  37. package/dist/vite.js +68 -4
  38. package/dist/vite.js.map +1 -1
  39. package/package.json +7 -2
  40. package/src/build.ts +1066 -12
  41. package/src/chunk-names.ts +45 -0
  42. package/src/client.ts +19 -2
  43. package/src/dev.ts +3 -2
  44. package/src/fonts-google.ts +3 -2
  45. package/src/fonts.ts +2 -1
  46. package/src/index.ts +373 -32
  47. package/src/manifest.ts +84 -6
  48. package/src/server-only.ts +25 -0
  49. package/src/vite.ts +82 -5
package/src/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 { type Server, server } from "@nifrajs/core"
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 { LayoutEntry, Manifest, Meta, MetaArgs, MetaInput, RouteEntry } from "./manifest.ts"
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
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("&", "&amp;")
@@ -620,15 +696,31 @@ function escapeAttr(value: string): string {
620
696
  .replaceAll(">", "&gt;")
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 (dev-authored) + escape the value
626
- * (may carry loader data → XSS). Invalid attribute names are dropped. */
627
- function tagAttrs(attrs: Record<string, string>): string {
628
- return Object.entries(attrs)
629
- .filter(([name]) => SAFE_ATTR_NAME.test(name))
630
- .map(([name, value]) => `${name}="${escapeAttr(value)}"`)
631
- .join(" ")
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 separately.
642
- * String concatenation (no intermediate `.map()` arrays + spread) — parity with the already
643
- * concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output.
644
- * Result is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
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(options: CreateWebAppOptions): Server {
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
- const app = server()
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
- // Resolve a route's layout chain (outermost layout → page). Loaded lazily — the data-only and
757
- // 405 branches return before any layout is needed.
758
- const loadLayouts = (route: RouteEntry): Promise<unknown[]> =>
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 = [...(await loadLayouts(route)), mod.default]
887
- const head = resolveMeta(mod.meta, { data, params: c.params })
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 = [...(await loadLayouts(route)), mod.default]
972
- const head = resolveMeta(mod.meta, { data, params: c.params })
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 page's meta per id.
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[mods.length - 2].meta",
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[mods.length - 1].meta",
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
- " if (!s.pending) applyHead(resolveMeta(metas[s.routeId], { data: s.data, params: s.params }))",
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`