@nifrajs/web 2.11.0 → 2.12.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-vite.d.ts.map +1 -1
- package/dist/build-vite.js +10 -0
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +7 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +5 -2
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -7
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +1 -1
- package/dist/conformance.d.ts.map +1 -1
- package/dist/deferred.d.ts +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +14 -3
- package/dist/deferred.js.map +1 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +16 -0
- package/dist/dev.js.map +1 -1
- package/dist/fonts-google.d.ts +2 -2
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +12 -3
- package/dist/fonts-google.js.map +1 -1
- package/dist/index.d.ts +65 -117
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +60 -66
- package/dist/index.js.map +1 -1
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +4 -1
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +3 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +23 -20
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +5 -3
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/navigation.d.ts +9 -0
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +8 -1
- package/dist/navigation.js.map +1 -1
- package/dist/render-seam.d.ts +61 -0
- package/dist/render-seam.d.ts.map +1 -0
- package/dist/render-seam.js +22 -0
- package/dist/render-seam.js.map +1 -0
- package/package.json +2 -2
- package/src/build-vite.ts +11 -0
- package/src/build.ts +18 -1
- package/src/client.ts +13 -7
- package/src/conformance.ts +1 -1
- package/src/deferred.ts +14 -2
- package/src/dev.ts +14 -0
- package/src/fonts-google.ts +14 -5
- package/src/index.ts +137 -156
- package/src/isr.ts +4 -1
- package/src/llms-txt.ts +24 -20
- package/src/manifest.ts +5 -3
- package/src/navigation.ts +14 -1
- package/src/render-seam.ts +76 -0
package/src/fonts-google.ts
CHANGED
|
@@ -30,8 +30,8 @@ import type { LinkDescriptor } from "./manifest.ts"
|
|
|
30
30
|
export interface GoogleFontOptions {
|
|
31
31
|
/** Family name exactly as Google lists it, e.g. `"Inter"`, `"Open Sans"`, `"Roboto Mono"`. */
|
|
32
32
|
readonly family: string
|
|
33
|
-
/** Weights to request - numbers (`400`), numeric strings, a variable range (`"100
|
|
34
|
-
* keywords `"normal"`/`"bold"`. Defaults to `[400]`. */
|
|
33
|
+
/** Weights to request - numbers (`400`), numeric strings, a variable range (`"100..900"`, the
|
|
34
|
+
* CSS-style `"100 900"` also accepted), or the keywords `"normal"`/`"bold"`. Defaults to `[400]`. */
|
|
35
35
|
readonly weights?: readonly (number | string)[]
|
|
36
36
|
/** Styles to request. Defaults to `["normal"]`. */
|
|
37
37
|
readonly styles?: readonly ("normal" | "italic")[]
|
|
@@ -144,7 +144,8 @@ function validateFamily(family: string): string {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/** Normalize a weight to the token Google expects: `400`, `"700"`, `normal`→`400`, `bold`→`700`, or a
|
|
147
|
-
* variable range `"100 900"
|
|
147
|
+
* variable range as `"100..900"` (also accepted spelled `"100 900"`). Rejects anything else (these
|
|
148
|
+
* land in the request URL). */
|
|
148
149
|
function normalizeWeight(weight: number | string): string {
|
|
149
150
|
if (typeof weight === "number") {
|
|
150
151
|
if (!Number.isInteger(weight) || weight < 1 || weight > 1000) {
|
|
@@ -155,8 +156,16 @@ function normalizeWeight(weight: number | string): string {
|
|
|
155
156
|
const w = weight.trim()
|
|
156
157
|
if (w === "normal") return "400"
|
|
157
158
|
if (w === "bold") return "700"
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
if (/^\d{1,4}$/.test(w)) return w
|
|
160
|
+
// A variable range - Google's own "100..900" axis syntax or the CSS-style "100 900" spelling.
|
|
161
|
+
// Either way the URL token must be the DOTTED form; a literal space 400s at the CSS2 endpoint.
|
|
162
|
+
const range = /^(\d{1,4})(?:\.\.| )(\d{1,4})$/.exec(w)
|
|
163
|
+
if (range !== null) {
|
|
164
|
+
if (Number(range[1]) >= Number(range[2])) {
|
|
165
|
+
throw new Error(`loadGoogleFont: invalid weight range ${JSON.stringify(weight)} (min < max)`)
|
|
166
|
+
}
|
|
167
|
+
return `${range[1]}..${range[2]}`
|
|
168
|
+
}
|
|
160
169
|
throw new Error(`loadGoogleFont: invalid weight ${JSON.stringify(weight)}`)
|
|
161
170
|
}
|
|
162
171
|
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type BackendMountHandler,
|
|
14
14
|
NIFRA_BACKEND_MOUNT,
|
|
15
15
|
} from "@nifrajs/core/mount"
|
|
16
|
-
import { server, type UrlParts, urlPartsOf } from "@nifrajs/core/server"
|
|
16
|
+
import { type ServerOptions, server, type UrlParts, urlPartsOf } from "@nifrajs/core/server"
|
|
17
17
|
import {
|
|
18
18
|
DEFERRED_ERROR_CODE,
|
|
19
19
|
DEFERRED_RUNTIME,
|
|
@@ -48,6 +48,14 @@ import type {
|
|
|
48
48
|
ScriptDescriptor,
|
|
49
49
|
UnsafeScriptDescriptor,
|
|
50
50
|
} from "./manifest.ts"
|
|
51
|
+
import type { RenderAdapter, RenderProps } from "./render-seam.ts"
|
|
52
|
+
import {
|
|
53
|
+
ACTION_GLOBAL,
|
|
54
|
+
DATA_GLOBAL,
|
|
55
|
+
LAYOUT_DATA_GLOBAL,
|
|
56
|
+
ROOT_ATTRIBUTE,
|
|
57
|
+
ROUTE_GLOBAL,
|
|
58
|
+
} from "./render-seam.ts"
|
|
51
59
|
import {
|
|
52
60
|
createMatcher,
|
|
53
61
|
DATA_HEADER,
|
|
@@ -57,7 +65,6 @@ import {
|
|
|
57
65
|
RETAIN_HEADER,
|
|
58
66
|
REVALIDATE_HEADER,
|
|
59
67
|
STATUS_HEADER,
|
|
60
|
-
type Submission,
|
|
61
68
|
} from "./router.ts"
|
|
62
69
|
import { searchOf, searchOfChain } from "./search.ts"
|
|
63
70
|
|
|
@@ -117,6 +124,7 @@ export {
|
|
|
117
124
|
type Manifest,
|
|
118
125
|
type Meta,
|
|
119
126
|
type MetaArgs,
|
|
127
|
+
type MetaDescriptor,
|
|
120
128
|
type MetaInput,
|
|
121
129
|
type RouteEntry,
|
|
122
130
|
type RouteModule,
|
|
@@ -235,6 +243,18 @@ export {
|
|
|
235
243
|
RenderAdapterConformanceError,
|
|
236
244
|
type RenderAdapterConformanceFixture,
|
|
237
245
|
} from "./conformance.ts"
|
|
246
|
+
export {
|
|
247
|
+
ACTION_GLOBAL,
|
|
248
|
+
DATA_GLOBAL,
|
|
249
|
+
LAYOUT_DATA_GLOBAL,
|
|
250
|
+
type RenderAdapter,
|
|
251
|
+
type RenderProps,
|
|
252
|
+
ROOT_ATTRIBUTE,
|
|
253
|
+
ROUTE_GLOBAL,
|
|
254
|
+
type SsrModuleLoader,
|
|
255
|
+
setSsrModuleLoader,
|
|
256
|
+
ssrModuleLoader,
|
|
257
|
+
} from "./render-seam.ts"
|
|
238
258
|
|
|
239
259
|
// The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
|
|
240
260
|
// (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
|
|
@@ -242,142 +262,6 @@ export {
|
|
|
242
262
|
// produce the identical value by construction. An adapter's `useSearch` binding reads its result.
|
|
243
263
|
export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
|
|
244
264
|
|
|
245
|
-
/** The data handed to a route component. Opaque to the core. `actionData` is the return of a
|
|
246
|
-
* route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
|
|
247
|
-
* (absent on SSR): they drive **optimistic UI** - render from `submission.formData` while `pending`. */
|
|
248
|
-
export interface RenderProps {
|
|
249
|
-
readonly data: unknown
|
|
250
|
-
/**
|
|
251
|
-
* Per-layout loader data, aligned index-for-index with the layout prefix of `chain`.
|
|
252
|
-
*
|
|
253
|
-
* Absent when no layout in the chain exports a `loader`, so a page-only app is byte-identical to
|
|
254
|
-
* before. An adapter that does not read it renders exactly as it did; one that does passes
|
|
255
|
-
* `layoutData[i]` to `chain[i]`.
|
|
256
|
-
*/
|
|
257
|
-
readonly layoutData?: readonly unknown[]
|
|
258
|
-
readonly actionData?: unknown
|
|
259
|
-
/** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
|
|
260
|
-
readonly pending?: boolean
|
|
261
|
-
/** The path a client navigation is transitioning TO while `pending` (client-only; absent on SSR and
|
|
262
|
-
* when idle). Lets an adapter tell a `NavLink` whether its own target is the one loading. */
|
|
263
|
-
readonly pendingPath?: string
|
|
264
|
-
/** The in-flight client submit, for optimistic UI (client-only; absent on SSR + when idle). */
|
|
265
|
-
readonly submission?: Submission
|
|
266
|
-
/** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). Threaded identically on
|
|
267
|
-
* SSR (from the request match) and on client navigation (from router state) so an adapter's
|
|
268
|
-
* `useParams` reads the same value on both, with no hydration mismatch. Absent ⇒ no params (`{}`). */
|
|
269
|
-
readonly params?: Readonly<Record<string, string>>
|
|
270
|
-
/** The current URL's `pathname + search` (no hash - the hash never reaches the server). Threaded on
|
|
271
|
-
* SSR and client alike so an adapter's `useLocation`/`useSearchParams` hydrate consistently. Absent
|
|
272
|
-
* on adapters/callers that don't supply it (treated as `""`). */
|
|
273
|
-
readonly path?: string
|
|
274
|
-
/** The route's typed, validated search params (the value the loader also gets as `ctx.search`),
|
|
275
|
-
* threaded to the adapter's `useSearch`. Derived identically on SSR (from the request) and on client
|
|
276
|
-
* navigation (adapter mount) via the shared `searchOf`, so a component reading it hydrates with no
|
|
277
|
-
* drift. Absent ⇒ no search context (`{}`). */
|
|
278
|
-
readonly search?: Record<string, unknown>
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* The seam every render adapter implements. New adapters should prove these invariants with
|
|
283
|
-
* {@link assertRenderAdapterConformance}; framework-specific behavior remains locally tested.
|
|
284
|
-
*/
|
|
285
|
-
export interface RenderAdapter {
|
|
286
|
-
/**
|
|
287
|
-
* Server: render a route's layout `chain` (outermost layout → page) to a **stream** of HTML
|
|
288
|
-
* bytes, including the framework's hydration markers. The page (innermost) receives `props`
|
|
289
|
-
* (the loader data); each layout wraps the child via its `children`. Returns (or resolves to)
|
|
290
|
-
* a Web `ReadableStream<Uint8Array>`: a streaming renderer flushes the shell first and streams
|
|
291
|
-
* Suspense boundaries as they resolve; a non-streaming renderer may return a one-chunk stream.
|
|
292
|
-
* May be async - e.g. React resolves once the shell is renderable, so a shell-render throw can
|
|
293
|
-
* still map to an error status before any byte is sent.
|
|
294
|
-
*/
|
|
295
|
-
renderToStream(
|
|
296
|
-
chain: readonly unknown[],
|
|
297
|
-
props: RenderProps,
|
|
298
|
-
): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
|
|
299
|
-
/**
|
|
300
|
-
* Server (optional): render the chain to a complete HTML **string** in one pass, with the same
|
|
301
|
-
* hydration markers `renderToStream` produces. When provided, `renderPage` uses it for any page
|
|
302
|
-
* that does **not** `defer()` - buffering the document is faster than the streaming pipeline there,
|
|
303
|
-
* because the framework's synchronous renderer (React `renderToString`, Solid `renderToString`,
|
|
304
|
-
* Preact `render`, Vue `renderToString`, Svelte `render`) is markedly cheaper than its streaming
|
|
305
|
-
* renderer - most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
|
|
306
|
-
* that `defer()` keep the streaming path (progressive `<Await>` needs it). Markup MUST be
|
|
307
|
-
* hydration-equivalent to `renderToStream`; a throw surfaces like the streaming shell-readiness
|
|
308
|
-
* await (the `_error` boundary maps it to a status). Omit it to always stream.
|
|
309
|
-
*/
|
|
310
|
-
renderToString?(chain: readonly unknown[], props: RenderProps): string | Promise<string>
|
|
311
|
-
/**
|
|
312
|
-
* Server: per-document bootstrap markup injected into `<head>` that the client
|
|
313
|
-
* `hydrate` requires (Solid: `generateHydrationScript()`). Empty string if none.
|
|
314
|
-
*/
|
|
315
|
-
hydrationHead(nonce?: string): string
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Loads a module through the DEV SERVER'S module graph rather than the runtime's. Takes an absolute
|
|
320
|
-
* file path or a bare specifier; resolution, compilation and caching are the dev server's.
|
|
321
|
-
*/
|
|
322
|
-
export type SsrModuleLoader = (id: string) => Promise<unknown>
|
|
323
|
-
|
|
324
|
-
/* A `globalThis` singleton for the same reason `@nifrajs/web-react`'s router context is one: in dev
|
|
325
|
-
* this module is evaluated twice in one process (the CLI's copy under Bun, and the app's copy), and a
|
|
326
|
-
* module-level `let` would leave the adapter reading a slot the dev server set on the other copy. */
|
|
327
|
-
const SSR_MODULE_LOADER_SLOT = Symbol.for("nifra.web.ssr-module-loader")
|
|
328
|
-
/* Explicitly `| undefined`: clearing the slot is part of the contract (a dev server clears it on
|
|
329
|
-
* stop), and under `exactOptionalPropertyTypes` "absent" and "set to undefined" are separate types. */
|
|
330
|
-
const loaderSlot = globalThis as { [SSR_MODULE_LOADER_SLOT]?: SsrModuleLoader | undefined }
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Publishes the dev server's SSR module loader. Called by a dev server that owns SSR resolution
|
|
334
|
-
* itself; pass `undefined` to clear it on shutdown.
|
|
335
|
-
*/
|
|
336
|
-
export function setSsrModuleLoader(load: SsrModuleLoader | undefined): void {
|
|
337
|
-
loaderSlot[SSR_MODULE_LOADER_SLOT] = load
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* The dev server's SSR module loader, or `undefined` when nothing owns SSR resolution but the runtime
|
|
342
|
-
* - the Bun dev pipeline, and every production build, where the adapter's assets are compiled ahead
|
|
343
|
-
* of time and a plain `import` is already the right thing.
|
|
344
|
-
*
|
|
345
|
-
* An adapter that must load a COMPILED asset at runtime (a `.svelte` component, say) has to prefer
|
|
346
|
-
* this over a bare `import`, and has to take the framework's SERVER RENDERER from it as well, not
|
|
347
|
-
* just the component. A component compiled by the dev server renders through the renderer the dev
|
|
348
|
-
* server resolved; mixing the two puts two copies of the framework runtime in one component tree,
|
|
349
|
-
* where context written by one half is invisible to the other.
|
|
350
|
-
*/
|
|
351
|
-
export function ssrModuleLoader(): SsrModuleLoader | undefined {
|
|
352
|
-
return loaderSlot[SSR_MODULE_LOADER_SLOT]
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
356
|
-
export const DATA_GLOBAL = "__NIFRA_DATA__"
|
|
357
|
-
/** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
|
|
358
|
-
* a page-only app's HTML is byte-identical to before layout loaders existed. */
|
|
359
|
-
export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__"
|
|
360
|
-
|
|
361
|
-
/** Global the server writes the matched route id into; the client uses it to pick the chain. */
|
|
362
|
-
export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
|
|
363
|
-
|
|
364
|
-
/** Global the server serializes an action's data return into (absent on GETs); the client
|
|
365
|
-
* reads it so hydration after a native form POST matches the server-rendered markup. */
|
|
366
|
-
export const ACTION_GLOBAL = "__NIFRA_ACTION__"
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Marker attribute on the app container, emitted ONLY when `rootId` is not the default.
|
|
370
|
-
*
|
|
371
|
-
* `rootId` is a per-RENDER option; the client entry is a per-BUILD artifact, so it cannot be
|
|
372
|
-
* generated knowing which id a given render will use. The container therefore announces itself, and
|
|
373
|
-
* the entry finds it by this attribute before falling back to `#root`. It has to be the DOM rather
|
|
374
|
-
* than another `window.__NIFRA_*` global: a second copy of the id can drift from the markup, while
|
|
375
|
-
* this is written by the same expression that writes the id and cannot.
|
|
376
|
-
*
|
|
377
|
-
* Absent on a default render, so an app that never sets `rootId` emits the same bytes it always has.
|
|
378
|
-
*/
|
|
379
|
-
export const ROOT_ATTRIBUTE = "data-nifra-root"
|
|
380
|
-
|
|
381
265
|
/**
|
|
382
266
|
* Pre-hydration form guard - a tiny inline script flushed in `<head>` (it runs in the window between
|
|
383
267
|
* first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
|
|
@@ -1028,6 +912,20 @@ function withDuplicateInstanceHint(err: unknown): unknown {
|
|
|
1028
912
|
return augmented
|
|
1029
913
|
}
|
|
1030
914
|
|
|
915
|
+
/**
|
|
916
|
+
* An action's `Response` passes straight through - except a redirect on a client-submit data
|
|
917
|
+
* request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
|
|
918
|
+
* X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the
|
|
919
|
+
* returned- and thrown-Response paths, so `return redirect()` and `throw redirect()` agree.
|
|
920
|
+
*/
|
|
921
|
+
function actionResponse(response: Response, isDataRequest: boolean): Response {
|
|
922
|
+
if (isDataRequest && response.status >= 300 && response.status < 400) {
|
|
923
|
+
const location = response.headers.get("location") ?? "/"
|
|
924
|
+
return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } })
|
|
925
|
+
}
|
|
926
|
+
return response
|
|
927
|
+
}
|
|
928
|
+
|
|
1031
929
|
/** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
|
|
1032
930
|
type LoadedLayoutModules = ReadonlyArray<{
|
|
1033
931
|
default: unknown
|
|
@@ -1414,13 +1312,67 @@ export function unsafeInlineScript(
|
|
|
1414
1312
|
}
|
|
1415
1313
|
}
|
|
1416
1314
|
|
|
1417
|
-
export interface CreateWebAppOptions {
|
|
1315
|
+
export interface CreateWebAppOptions<Env = unknown> {
|
|
1418
1316
|
readonly adapter: RenderAdapter
|
|
1419
1317
|
readonly manifest: Manifest
|
|
1420
1318
|
/** URL of the built client entry (module script) injected into every page. */
|
|
1421
1319
|
readonly clientEntry: string
|
|
1422
1320
|
/** Default document title for all pages. */
|
|
1423
1321
|
readonly title?: string
|
|
1322
|
+
/**
|
|
1323
|
+
* Options for the underlying `server()` - `requestTimeoutMs`, `admission`, `gracefulSignals`, and
|
|
1324
|
+
* the rest of {@link ServerOptions}.
|
|
1325
|
+
*
|
|
1326
|
+
* Needed because the `Server` is constructed in here, so a caller has no other way to reach its
|
|
1327
|
+
* constructor: a page app could set no request timeout and no capacity gate, which are exactly the
|
|
1328
|
+
* two knobs a production readiness check looks for. An SSR app wants them MORE than a backend does -
|
|
1329
|
+
* a render is slow and allocation-heavy, so a burst that a JSON API would absorb is the one that
|
|
1330
|
+
* exhausts the pod.
|
|
1331
|
+
*
|
|
1332
|
+
* Applies to the whole app, {@link mounts} and the {@link api} auto-mount included: the capacity gate
|
|
1333
|
+
* sits at the fetch entry, ahead of the request hooks those are dispatched from.
|
|
1334
|
+
*/
|
|
1335
|
+
readonly server?: ServerOptions
|
|
1336
|
+
/**
|
|
1337
|
+
* Runs against the app BEFORE any page route is registered - the seam for `securityHeaders()`,
|
|
1338
|
+
* `requestId()`, `logger()`, a rate limit, or anything else applied with `use`.
|
|
1339
|
+
*
|
|
1340
|
+
* ```ts
|
|
1341
|
+
* createWebApp({
|
|
1342
|
+
* …,
|
|
1343
|
+
* use: (app) => {
|
|
1344
|
+
* app.use(securityHeaders())
|
|
1345
|
+
* app.use(requestId())
|
|
1346
|
+
* },
|
|
1347
|
+
* })
|
|
1348
|
+
* ```
|
|
1349
|
+
*
|
|
1350
|
+
* A callback rather than an array because `use` is overloaded - `Middleware`, `IdentityPlugin`,
|
|
1351
|
+
* `ContextPlugin` - and only the real call site instantiates the right overload. The return value
|
|
1352
|
+
* is ignored: this is for cross-cutting concerns, not for declaring routes (declare those on the
|
|
1353
|
+
* returned app, where they get their types).
|
|
1354
|
+
*
|
|
1355
|
+
* **Why the timing matters.** `beforeHandle`, `afterHandle`, `around`, `derive`/`decorate`, and
|
|
1356
|
+
* `onError` are snapshotted into each route AS IT IS DECLARED, and `createWebApp` declares every
|
|
1357
|
+
* page (plus the `/*` catch-all) before it returns - so a caller's `app.use(…)` afterwards binds
|
|
1358
|
+
* them to nothing, silently. `requestId()` is exactly that case: it is a `derive`, so applied late
|
|
1359
|
+
* it leaves every page without `c.requestId`. Route assurance evidence is order-scoped the same
|
|
1360
|
+
* way, so a late `securityHeaders()` leaves the pages unable to PROVE the header to `nifra assure`
|
|
1361
|
+
* even though it does still set it.
|
|
1362
|
+
*
|
|
1363
|
+
* `onRequest`/`onResponse` (and the response header/body hooks) are app-global arrays read at
|
|
1364
|
+
* request time, so those alone DO work when added late. Using this seam for everything avoids
|
|
1365
|
+
* having to remember which is which.
|
|
1366
|
+
*
|
|
1367
|
+
* Runs ahead of the {@link mounts} / {@link api} request hooks, so an `onRequest` middleware guards
|
|
1368
|
+
* a mounted auth handler too.
|
|
1369
|
+
*
|
|
1370
|
+
* A `ContextPlugin` applied here takes runtime effect but cannot widen the declared return type of
|
|
1371
|
+
* this function, so `c.requestId` is not typed on routes you declare afterwards. Named plugins are
|
|
1372
|
+
* idempotent, so `app.use(requestId())` on the returned app recovers the TYPE without applying the
|
|
1373
|
+
* plugin a second time.
|
|
1374
|
+
*/
|
|
1375
|
+
readonly use?: (app: ReturnType<typeof server<Env>>) => void
|
|
1424
1376
|
/** Injected into each loader's `ctx.api` - typically an `inProcessClient(app)` (typed
|
|
1425
1377
|
* per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
|
|
1426
1378
|
*
|
|
@@ -1485,6 +1437,11 @@ export interface CreateWebAppOptions {
|
|
|
1485
1437
|
* `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
|
|
1486
1438
|
* into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
|
|
1487
1439
|
readonly prerenderedPaths?: readonly string[]
|
|
1440
|
+
/** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
|
|
1441
|
+
* those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
|
|
1442
|
+
* team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
|
|
1443
|
+
* routinely. Turn it on only for a repo whose guidelines you would publish as a page. */
|
|
1444
|
+
readonly publishLocalGuidelines?: boolean
|
|
1488
1445
|
/** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
|
|
1489
1446
|
* the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
|
|
1490
1447
|
* `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
|
|
@@ -1573,7 +1530,7 @@ const urlPartsFor = (request: Request): UrlParts => {
|
|
|
1573
1530
|
* secure default; validate at the trust boundary before use.
|
|
1574
1531
|
*/
|
|
1575
1532
|
export function createWebApp<Env = unknown>(
|
|
1576
|
-
options: CreateWebAppOptions
|
|
1533
|
+
options: CreateWebAppOptions<Env>,
|
|
1577
1534
|
): ReturnType<typeof server<Env>> {
|
|
1578
1535
|
const { adapter, manifest, clientEntry, title, api } = options
|
|
1579
1536
|
const titleOption = title === undefined ? {} : { title }
|
|
@@ -1599,7 +1556,11 @@ export function createWebApp<Env = unknown>(
|
|
|
1599
1556
|
// Seed the context with the declared `Env` so `app.fetch(req, { env })` / `toFetchHandler(app)` type
|
|
1600
1557
|
// the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
|
|
1601
1558
|
// `app.fetch(req, { env })`; this is a compile-time-only seed (`server<Env>()` casts, doesn't store).
|
|
1602
|
-
const app = server<Env>()
|
|
1559
|
+
const app = server<Env>(options.server)
|
|
1560
|
+
// Before the mount hooks and before every page route, so the order-scoped hooks (`derive`,
|
|
1561
|
+
// `beforeHandle`, assurance evidence) actually bind to them - see the `use` option. A caller
|
|
1562
|
+
// cannot do this after the fact: by the time this function returns, the routes are declared.
|
|
1563
|
+
options.use?.(app)
|
|
1603
1564
|
// Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
|
|
1604
1565
|
// `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
|
|
1605
1566
|
// for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
|
|
@@ -2142,6 +2103,13 @@ export function createWebApp<Env = unknown>(
|
|
|
2142
2103
|
}
|
|
2143
2104
|
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
2144
2105
|
}
|
|
2106
|
+
// A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
|
|
2107
|
+
// reads naturally and must not silently serialize the `Response` as loader data. Mirror the
|
|
2108
|
+
// catch above exactly: a status signal renders its boundary, any other `Response` (a
|
|
2109
|
+
// redirect, a hand-rolled response) passes through to core verbatim - so return and throw
|
|
2110
|
+
// are interchangeable.
|
|
2111
|
+
if (isStatusSignal(data)) return renderStatusSignal(c.req, data)
|
|
2112
|
+
if (data instanceof Response) return data
|
|
2145
2113
|
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
|
|
2146
2114
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
2147
2115
|
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
@@ -2248,11 +2216,21 @@ export function createWebApp<Env = unknown>(
|
|
|
2248
2216
|
draft,
|
|
2249
2217
|
search: loaderSearch(mod.searchSchema, c.req),
|
|
2250
2218
|
}
|
|
2251
|
-
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
2252
|
-
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
2253
|
-
const layoutModules = await runLayoutGates(route, actionContext)
|
|
2254
|
-
const result = await mod.action(actionContext)
|
|
2255
2219
|
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
|
|
2220
|
+
let layoutModules: LoadedLayoutModules
|
|
2221
|
+
let result: unknown
|
|
2222
|
+
try {
|
|
2223
|
+
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
2224
|
+
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
2225
|
+
layoutModules = await runLayoutGates(route, actionContext)
|
|
2226
|
+
result = await mod.action(actionContext)
|
|
2227
|
+
} catch (err) {
|
|
2228
|
+
// A THROWN Response is the same control-flow signal as a returned one (`throw redirect()`
|
|
2229
|
+
// in a gate or an action) - route it through the same conversion as the returned-Response
|
|
2230
|
+
// branch below, so throw and return are interchangeable on the mutation path too.
|
|
2231
|
+
if (err instanceof Response) return actionResponse(err, isDataRequest)
|
|
2232
|
+
throw err
|
|
2233
|
+
}
|
|
2256
2234
|
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
2257
2235
|
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
2258
2236
|
// responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
|
|
@@ -2262,15 +2240,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2262
2240
|
const revalidateHeader: Record<string, string> = isRevalidate
|
|
2263
2241
|
? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
|
|
2264
2242
|
: {}
|
|
2265
|
-
if (actionResult instanceof Response)
|
|
2266
|
-
// Client submit can't read a 3xx Location (fetch follows it to HTML), so convey the
|
|
2267
|
-
// redirect via a header on a 204 and let the client navigate. Native forms get the 3xx.
|
|
2268
|
-
if (isDataRequest && actionResult.status >= 300 && actionResult.status < 400) {
|
|
2269
|
-
const location = actionResult.headers.get("location") ?? "/"
|
|
2270
|
-
return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } })
|
|
2271
|
-
}
|
|
2272
|
-
return actionResult
|
|
2273
|
-
}
|
|
2243
|
+
if (actionResult instanceof Response) return actionResponse(actionResult, isDataRequest)
|
|
2274
2244
|
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
2275
2245
|
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
2276
2246
|
if (isDataRequest) {
|
|
@@ -2327,15 +2297,16 @@ export function createWebApp<Env = unknown>(
|
|
|
2327
2297
|
}
|
|
2328
2298
|
|
|
2329
2299
|
// Register llms.txt & llms-full.txt
|
|
2300
|
+
const llmsOptions = { includeLocalGuidelines: options.publishLocalGuidelines === true }
|
|
2330
2301
|
app.register("GET", "/llms.txt", undefined, async () => {
|
|
2331
|
-
const text = await generateLlmsTxt(false, manifest.routes, api)
|
|
2302
|
+
const text = await generateLlmsTxt(false, manifest.routes, api, llmsOptions)
|
|
2332
2303
|
return new Response(text, {
|
|
2333
2304
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
2334
2305
|
})
|
|
2335
2306
|
})
|
|
2336
2307
|
|
|
2337
2308
|
app.register("GET", "/llms-full.txt", undefined, async () => {
|
|
2338
|
-
const text = await generateLlmsTxt(true, manifest.routes, api)
|
|
2309
|
+
const text = await generateLlmsTxt(true, manifest.routes, api, llmsOptions)
|
|
2339
2310
|
return new Response(text, {
|
|
2340
2311
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
2341
2312
|
})
|
|
@@ -2428,6 +2399,16 @@ export function generateClientEntry(
|
|
|
2428
2399
|
// access yields `undefined` if absent - unlike a named import, which would be a link error.
|
|
2429
2400
|
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
2430
2401
|
"const { mountRouter } = __adapter",
|
|
2402
|
+
// The assurance hook is optional: ordinary client entries pay only for the namespace lookup, while
|
|
2403
|
+
// hydration runners get a runtime identity from the exact adapter module that mounted the page. The
|
|
2404
|
+
// registry is token-only and lives on a Symbol.for key so it cannot collide with app data.
|
|
2405
|
+
"const __hydrationHook = __adapter.hydrationAssuranceHook",
|
|
2406
|
+
'if (__hydrationHook && typeof __hydrationHook.runtimeIdentity === "function") {',
|
|
2407
|
+
' const __runtimeKey = Symbol.for("nifra.hydration.runtime")',
|
|
2408
|
+
" const __runtimeState = globalThis[__runtimeKey]",
|
|
2409
|
+
" const __identities = __runtimeState && Array.isArray(__runtimeState.identities) ? __runtimeState.identities : []",
|
|
2410
|
+
" globalThis[__runtimeKey] = { framework: __hydrationHook.framework, identities: [...__identities, __hydrationHook.runtimeIdentity()] }",
|
|
2411
|
+
"}",
|
|
2431
2412
|
// The `clientModule` contract is a string specifier, so nothing type-checks that the module it
|
|
2432
2413
|
// names actually exports `mountRouter`. Without this the miss surfaces as
|
|
2433
2414
|
// "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
|
package/src/isr.ts
CHANGED
|
@@ -572,7 +572,10 @@ export function withISR(
|
|
|
572
572
|
}
|
|
573
573
|
|
|
574
574
|
return async (req, platform) => {
|
|
575
|
-
|
|
575
|
+
// A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML documents keyed
|
|
576
|
+
// by URL, and serving one to a loader-data fetch would hand the client HTML where it expects
|
|
577
|
+
// the loader payload. (The write path already refuses to cache data-mode responses.)
|
|
578
|
+
const key = req.method === "GET" && req.headers.get("x-nifra-data") === null ? keyOf(req) : null
|
|
576
579
|
if (key === null) return app.fetch(req, platform)
|
|
577
580
|
|
|
578
581
|
// Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
|
package/src/llms-txt.ts
CHANGED
|
@@ -79,35 +79,39 @@ function clientCall(method: string, path: string, schema: unknown): string {
|
|
|
79
79
|
return `await ${chain}${call}`
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Read the project's `AGENTS.md`, or `""` if there isn't one (or we're on an edge runtime with no
|
|
83
|
+
* filesystem). Never throws - a missing file just means no guidelines section. */
|
|
84
|
+
async function readAgentsMd(): Promise<string> {
|
|
85
|
+
// Edge bundles define this flag and dead-code-eliminate the filesystem branch. Keeping the import
|
|
86
|
+
// behind a runtime-only `typeof Bun` check is insufficient: Rollup must still emit an unresolved
|
|
87
|
+
// `node:` dependency, which workerd cannot load.
|
|
88
|
+
const edgeRuntime = (
|
|
89
|
+
globalThis as typeof globalThis & { readonly __NIFRA_EDGE_RUNTIME__?: boolean }
|
|
90
|
+
).__NIFRA_EDGE_RUNTIME__
|
|
91
|
+
if (edgeRuntime === true) return ""
|
|
92
|
+
try {
|
|
93
|
+
if (typeof Bun !== "undefined") return await Bun.file("AGENTS.md").text()
|
|
94
|
+
const fs = await import("node:fs/promises")
|
|
95
|
+
return await fs.readFile("AGENTS.md", "utf-8")
|
|
96
|
+
} catch {
|
|
97
|
+
return ""
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
82
101
|
export async function generateLlmsTxt(
|
|
83
102
|
full: boolean,
|
|
84
103
|
pageRoutes: ReadonlyArray<{ readonly pattern: string; readonly id: string }>,
|
|
85
104
|
backend: unknown,
|
|
105
|
+
options: { readonly includeLocalGuidelines?: boolean } = {},
|
|
86
106
|
): Promise<string> {
|
|
87
107
|
let output = ""
|
|
88
108
|
output += `# Nifra App Context\n\n`
|
|
89
109
|
output += `This is a machine-readable context endpoint describing the API routes, pages, and conventions of this Nifra application.\n\n`
|
|
90
110
|
|
|
91
|
-
// 1. Project Guidelines
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// behind a runtime-only `typeof Bun` check is insufficient: Rollup must still emit an unresolved
|
|
96
|
-
// `node:` dependency, which workerd cannot load.
|
|
97
|
-
const edgeRuntime = (
|
|
98
|
-
globalThis as typeof globalThis & { readonly __NIFRA_EDGE_RUNTIME__?: boolean }
|
|
99
|
-
).__NIFRA_EDGE_RUNTIME__
|
|
100
|
-
if (edgeRuntime !== true) {
|
|
101
|
-
if (typeof Bun !== "undefined") {
|
|
102
|
-
agentsMd = await Bun.file("AGENTS.md").text()
|
|
103
|
-
} else {
|
|
104
|
-
const fs = await import("node:fs/promises")
|
|
105
|
-
agentsMd = await fs.readFile("AGENTS.md", "utf-8")
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
} catch {
|
|
109
|
-
// ignore
|
|
110
|
-
}
|
|
111
|
+
// 1. Project Guidelines. Off unless the app opts in: `AGENTS.md` is a repo file written for the
|
|
112
|
+
// team, not a page, and this endpoint is public - a note about an unshipped feature or an
|
|
113
|
+
// internal hostname in it would be served to anyone who asks.
|
|
114
|
+
const agentsMd = options.includeLocalGuidelines === true ? await readAgentsMd() : ""
|
|
111
115
|
|
|
112
116
|
if (agentsMd) {
|
|
113
117
|
output += `## Guidelines & Conventions\n\n`
|
package/src/manifest.ts
CHANGED
|
@@ -222,9 +222,11 @@ export interface RouteModule {
|
|
|
222
222
|
*/
|
|
223
223
|
readonly getStaticPaths?: GetStaticPaths
|
|
224
224
|
/**
|
|
225
|
-
* ISR freshness for this route, in **seconds**. `createWebApp` emits it as an
|
|
226
|
-
* response header that `withISR` reads to set the page's cache TTL
|
|
227
|
-
* default). Older-than-`revalidate` cached pages are served stale while
|
|
225
|
+
* ISR freshness for this route, in **seconds**. `createWebApp` emits it as an
|
|
226
|
+
* `x-nifra-isr-revalidate` response header that `withISR` reads to set the page's cache TTL
|
|
227
|
+
* (overriding the wrapper's default). Older-than-`revalidate` cached pages are served stale while
|
|
228
|
+
* regenerating. (Distinct from the action-revalidation `x-nifra-revalidate` header - a CSV path
|
|
229
|
+
* list the client parses to refetch - so the two channels never alias.)
|
|
228
230
|
*/
|
|
229
231
|
readonly revalidate?: number
|
|
230
232
|
/** Optional bounded tags used by ISR on-demand invalidation (`?tag=...`). */
|
package/src/navigation.ts
CHANGED
|
@@ -18,6 +18,13 @@ import { serializeSearch } from "./search.ts"
|
|
|
18
18
|
export interface NavigateOptions {
|
|
19
19
|
/** Replace the current history entry instead of pushing a new one (like `history.replaceState`). */
|
|
20
20
|
readonly replace?: boolean
|
|
21
|
+
/**
|
|
22
|
+
* Opaque per-entry state stored on the created history entry (under `history.state.nifraState`, so
|
|
23
|
+
* it never collides with the router's own bookkeeping keys). Structured-cloneable only, like
|
|
24
|
+
* `history.pushState`'s first argument. Read it back via `history.state.nifraState`; the browser
|
|
25
|
+
* restores it automatically on back/forward. Ignored for history-delta navigations.
|
|
26
|
+
*/
|
|
27
|
+
readonly state?: unknown
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/**
|
|
@@ -56,6 +63,7 @@ export interface NavigateTargetInput {
|
|
|
56
63
|
readonly to: string
|
|
57
64
|
readonly search?: Record<string, unknown>
|
|
58
65
|
readonly replace?: boolean
|
|
66
|
+
readonly state?: unknown
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
/**
|
|
@@ -71,6 +79,7 @@ export interface NavigateFunction {
|
|
|
71
79
|
readonly to: To
|
|
72
80
|
readonly search?: NavigateSearchOf<To>
|
|
73
81
|
readonly replace?: boolean
|
|
82
|
+
readonly state?: unknown
|
|
74
83
|
}): void
|
|
75
84
|
}
|
|
76
85
|
|
|
@@ -86,7 +95,11 @@ export function resolveNavigate(
|
|
|
86
95
|
): { readonly to: string | number; readonly options: NavigateOptions | undefined } {
|
|
87
96
|
if (typeof to !== "object") return { to, options }
|
|
88
97
|
const query = to.search !== undefined ? serializeSearch(to.search) : ""
|
|
89
|
-
|
|
98
|
+
if (to.replace !== true && to.state === undefined) return { to: to.to + query, options }
|
|
99
|
+
const resolved: { replace?: boolean; state?: unknown } = { ...options }
|
|
100
|
+
if (to.replace === true) resolved.replace = true
|
|
101
|
+
if (to.state !== undefined) resolved.state = to.state
|
|
102
|
+
return { to: to.to + query, options: resolved }
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
/**
|