@nifrajs/web 2.10.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 +12 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +28 -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-ssr-graph.d.ts +33 -0
- package/dist/dev-ssr-graph.d.ts.map +1 -0
- package/dist/dev-ssr-graph.js +285 -0
- package/dist/dev-ssr-graph.js.map +1 -0
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +88 -17
- 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 +68 -119
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -114
- package/dist/index.js.map +1 -1
- package/dist/internal/head-merge.d.ts +27 -0
- package/dist/internal/head-merge.d.ts.map +1 -0
- package/dist/internal/head-merge.js +65 -0
- package/dist/internal/head-merge.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +1 -2
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +1 -2
- package/dist/internal/server-boundary.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/plugins/css-modules.d.ts +12 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +54 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +66 -0
- package/dist/plugins/kit.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/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +27 -1
- package/dist/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/build-vite.ts +11 -0
- package/src/build.ts +18 -1
- package/src/client.ts +41 -7
- package/src/conformance.ts +1 -1
- package/src/deferred.ts +14 -2
- package/src/dev-ssr-graph.ts +311 -0
- package/src/dev.ts +91 -19
- package/src/fonts-google.ts +14 -5
- package/src/index.ts +155 -189
- package/src/internal/head-merge.ts +80 -0
- package/src/internal/server-boundary.ts +1 -2
- 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/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +71 -0
- package/src/render-seam.ts +76 -0
- package/src/vite.ts +29 -1
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,
|
|
@@ -25,6 +25,10 @@ import {
|
|
|
25
25
|
} from "./deferred.ts"
|
|
26
26
|
import { isDraftEnabled } from "./draft.ts"
|
|
27
27
|
import { trustedHeadAttributes } from "./internal/head-attributes.ts"
|
|
28
|
+
// Head resolution/merging. Defined in an internal module so `@nifrajs/web/client` can export the same
|
|
29
|
+
// two functions to the generated client entry without dragging THIS module (and its server code) into
|
|
30
|
+
// the browser graph - see internal/head-merge.ts. Re-exported here so the public API is unchanged.
|
|
31
|
+
import { mergeHeads, resolveMeta } from "./internal/head-merge.ts"
|
|
28
32
|
import { jsStringLiteral } from "./internal/js-string.ts"
|
|
29
33
|
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
|
|
30
34
|
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.ts"
|
|
@@ -44,6 +48,14 @@ import type {
|
|
|
44
48
|
ScriptDescriptor,
|
|
45
49
|
UnsafeScriptDescriptor,
|
|
46
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"
|
|
47
59
|
import {
|
|
48
60
|
createMatcher,
|
|
49
61
|
DATA_HEADER,
|
|
@@ -53,7 +65,6 @@ import {
|
|
|
53
65
|
RETAIN_HEADER,
|
|
54
66
|
REVALIDATE_HEADER,
|
|
55
67
|
STATUS_HEADER,
|
|
56
|
-
type Submission,
|
|
57
68
|
} from "./router.ts"
|
|
58
69
|
import { searchOf, searchOfChain } from "./search.ts"
|
|
59
70
|
|
|
@@ -113,6 +124,7 @@ export {
|
|
|
113
124
|
type Manifest,
|
|
114
125
|
type Meta,
|
|
115
126
|
type MetaArgs,
|
|
127
|
+
type MetaDescriptor,
|
|
116
128
|
type MetaInput,
|
|
117
129
|
type RouteEntry,
|
|
118
130
|
type RouteModule,
|
|
@@ -195,7 +207,7 @@ export {
|
|
|
195
207
|
type SubmitOptions,
|
|
196
208
|
} from "./router.ts"
|
|
197
209
|
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
198
|
-
export { type Deferred, defer }
|
|
210
|
+
export { type Deferred, defer, mergeHeads, resolveMeta }
|
|
199
211
|
|
|
200
212
|
/** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
|
|
201
213
|
* object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
|
|
@@ -231,6 +243,18 @@ export {
|
|
|
231
243
|
RenderAdapterConformanceError,
|
|
232
244
|
type RenderAdapterConformanceFixture,
|
|
233
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"
|
|
234
258
|
|
|
235
259
|
// The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
|
|
236
260
|
// (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
|
|
@@ -238,105 +262,6 @@ export {
|
|
|
238
262
|
// produce the identical value by construction. An adapter's `useSearch` binding reads its result.
|
|
239
263
|
export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
|
|
240
264
|
|
|
241
|
-
/** The data handed to a route component. Opaque to the core. `actionData` is the return of a
|
|
242
|
-
* route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
|
|
243
|
-
* (absent on SSR): they drive **optimistic UI** - render from `submission.formData` while `pending`. */
|
|
244
|
-
export interface RenderProps {
|
|
245
|
-
readonly data: unknown
|
|
246
|
-
/**
|
|
247
|
-
* Per-layout loader data, aligned index-for-index with the layout prefix of `chain`.
|
|
248
|
-
*
|
|
249
|
-
* Absent when no layout in the chain exports a `loader`, so a page-only app is byte-identical to
|
|
250
|
-
* before. An adapter that does not read it renders exactly as it did; one that does passes
|
|
251
|
-
* `layoutData[i]` to `chain[i]`.
|
|
252
|
-
*/
|
|
253
|
-
readonly layoutData?: readonly unknown[]
|
|
254
|
-
readonly actionData?: unknown
|
|
255
|
-
/** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
|
|
256
|
-
readonly pending?: boolean
|
|
257
|
-
/** The path a client navigation is transitioning TO while `pending` (client-only; absent on SSR and
|
|
258
|
-
* when idle). Lets an adapter tell a `NavLink` whether its own target is the one loading. */
|
|
259
|
-
readonly pendingPath?: string
|
|
260
|
-
/** The in-flight client submit, for optimistic UI (client-only; absent on SSR + when idle). */
|
|
261
|
-
readonly submission?: Submission
|
|
262
|
-
/** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). Threaded identically on
|
|
263
|
-
* SSR (from the request match) and on client navigation (from router state) so an adapter's
|
|
264
|
-
* `useParams` reads the same value on both, with no hydration mismatch. Absent ⇒ no params (`{}`). */
|
|
265
|
-
readonly params?: Readonly<Record<string, string>>
|
|
266
|
-
/** The current URL's `pathname + search` (no hash - the hash never reaches the server). Threaded on
|
|
267
|
-
* SSR and client alike so an adapter's `useLocation`/`useSearchParams` hydrate consistently. Absent
|
|
268
|
-
* on adapters/callers that don't supply it (treated as `""`). */
|
|
269
|
-
readonly path?: string
|
|
270
|
-
/** The route's typed, validated search params (the value the loader also gets as `ctx.search`),
|
|
271
|
-
* threaded to the adapter's `useSearch`. Derived identically on SSR (from the request) and on client
|
|
272
|
-
* navigation (adapter mount) via the shared `searchOf`, so a component reading it hydrates with no
|
|
273
|
-
* drift. Absent ⇒ no search context (`{}`). */
|
|
274
|
-
readonly search?: Record<string, unknown>
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* The seam every render adapter implements. New adapters should prove these invariants with
|
|
279
|
-
* {@link assertRenderAdapterConformance}; framework-specific behavior remains locally tested.
|
|
280
|
-
*/
|
|
281
|
-
export interface RenderAdapter {
|
|
282
|
-
/**
|
|
283
|
-
* Server: render a route's layout `chain` (outermost layout → page) to a **stream** of HTML
|
|
284
|
-
* bytes, including the framework's hydration markers. The page (innermost) receives `props`
|
|
285
|
-
* (the loader data); each layout wraps the child via its `children`. Returns (or resolves to)
|
|
286
|
-
* a Web `ReadableStream<Uint8Array>`: a streaming renderer flushes the shell first and streams
|
|
287
|
-
* Suspense boundaries as they resolve; a non-streaming renderer may return a one-chunk stream.
|
|
288
|
-
* May be async - e.g. React resolves once the shell is renderable, so a shell-render throw can
|
|
289
|
-
* still map to an error status before any byte is sent.
|
|
290
|
-
*/
|
|
291
|
-
renderToStream(
|
|
292
|
-
chain: readonly unknown[],
|
|
293
|
-
props: RenderProps,
|
|
294
|
-
): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
|
|
295
|
-
/**
|
|
296
|
-
* Server (optional): render the chain to a complete HTML **string** in one pass, with the same
|
|
297
|
-
* hydration markers `renderToStream` produces. When provided, `renderPage` uses it for any page
|
|
298
|
-
* that does **not** `defer()` - buffering the document is faster than the streaming pipeline there,
|
|
299
|
-
* because the framework's synchronous renderer (React `renderToString`, Solid `renderToString`,
|
|
300
|
-
* Preact `render`, Vue `renderToString`, Svelte `render`) is markedly cheaper than its streaming
|
|
301
|
-
* renderer - most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
|
|
302
|
-
* that `defer()` keep the streaming path (progressive `<Await>` needs it). Markup MUST be
|
|
303
|
-
* hydration-equivalent to `renderToStream`; a throw surfaces like the streaming shell-readiness
|
|
304
|
-
* await (the `_error` boundary maps it to a status). Omit it to always stream.
|
|
305
|
-
*/
|
|
306
|
-
renderToString?(chain: readonly unknown[], props: RenderProps): string | Promise<string>
|
|
307
|
-
/**
|
|
308
|
-
* Server: per-document bootstrap markup injected into `<head>` that the client
|
|
309
|
-
* `hydrate` requires (Solid: `generateHydrationScript()`). Empty string if none.
|
|
310
|
-
*/
|
|
311
|
-
hydrationHead(nonce?: string): string
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
315
|
-
export const DATA_GLOBAL = "__NIFRA_DATA__"
|
|
316
|
-
/** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
|
|
317
|
-
* a page-only app's HTML is byte-identical to before layout loaders existed. */
|
|
318
|
-
export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__"
|
|
319
|
-
|
|
320
|
-
/** Global the server writes the matched route id into; the client uses it to pick the chain. */
|
|
321
|
-
export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
|
|
322
|
-
|
|
323
|
-
/** Global the server serializes an action's data return into (absent on GETs); the client
|
|
324
|
-
* reads it so hydration after a native form POST matches the server-rendered markup. */
|
|
325
|
-
export const ACTION_GLOBAL = "__NIFRA_ACTION__"
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Marker attribute on the app container, emitted ONLY when `rootId` is not the default.
|
|
329
|
-
*
|
|
330
|
-
* `rootId` is a per-RENDER option; the client entry is a per-BUILD artifact, so it cannot be
|
|
331
|
-
* generated knowing which id a given render will use. The container therefore announces itself, and
|
|
332
|
-
* the entry finds it by this attribute before falling back to `#root`. It has to be the DOM rather
|
|
333
|
-
* than another `window.__NIFRA_*` global: a second copy of the id can drift from the markup, while
|
|
334
|
-
* this is written by the same expression that writes the id and cannot.
|
|
335
|
-
*
|
|
336
|
-
* Absent on a default render, so an app that never sets `rootId` emits the same bytes it always has.
|
|
337
|
-
*/
|
|
338
|
-
export const ROOT_ATTRIBUTE = "data-nifra-root"
|
|
339
|
-
|
|
340
265
|
/**
|
|
341
266
|
* Pre-hydration form guard - a tiny inline script flushed in `<head>` (it runs in the window between
|
|
342
267
|
* first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
|
|
@@ -987,6 +912,20 @@ function withDuplicateInstanceHint(err: unknown): unknown {
|
|
|
987
912
|
return augmented
|
|
988
913
|
}
|
|
989
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
|
+
|
|
990
929
|
/** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
|
|
991
930
|
type LoadedLayoutModules = ReadonlyArray<{
|
|
992
931
|
default: unknown
|
|
@@ -1287,12 +1226,6 @@ function headTags(head: Meta | undefined): string {
|
|
|
1287
1226
|
return out
|
|
1288
1227
|
}
|
|
1289
1228
|
|
|
1290
|
-
/** Resolve a route's `meta` (static or a function of the loader data + params) to a {@link Meta}. */
|
|
1291
|
-
export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
|
|
1292
|
-
if (meta === undefined) return {}
|
|
1293
|
-
return typeof meta === "function" ? meta(args) : meta
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
1229
|
/**
|
|
1297
1230
|
* A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
|
|
1298
1231
|
* engines which URL is authoritative for a page (deduping query-string / tracking variants).
|
|
@@ -1379,75 +1312,67 @@ export function unsafeInlineScript(
|
|
|
1379
1312
|
}
|
|
1380
1313
|
}
|
|
1381
1314
|
|
|
1382
|
-
|
|
1383
|
-
* Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
|
|
1384
|
-
*
|
|
1385
|
-
* The head contract (see {@link CreateWebAppOptions} and `LayoutEntry`): a `_layout.tsx` may export
|
|
1386
|
-
* `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
|
|
1387
|
-
* it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
|
|
1388
|
-
* render chain), and merges:
|
|
1389
|
-
* - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
|
|
1390
|
-
* inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
|
|
1391
|
-
* page `title` keeps it.
|
|
1392
|
-
* - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
|
|
1393
|
-
* de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
|
|
1394
|
-
* can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
|
|
1395
|
-
*
|
|
1396
|
-
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
1397
|
-
* static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
|
|
1398
|
-
* fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
1399
|
-
* content can vary with loader data anyway).
|
|
1400
|
-
*/
|
|
1401
|
-
export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
1402
|
-
// Single-head fast path (a route with no layout `meta`, by far the common case) - return the
|
|
1403
|
-
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
1404
|
-
if (heads.length === 1) return heads[0] as Meta
|
|
1405
|
-
let title: string | undefined
|
|
1406
|
-
let lang: string | undefined
|
|
1407
|
-
let dir: Meta["dir"]
|
|
1408
|
-
const meta: MetaDescriptor[] = []
|
|
1409
|
-
const link: LinkDescriptor[] = []
|
|
1410
|
-
const script: ScriptDescriptor[] = []
|
|
1411
|
-
const unsafeScript: UnsafeScriptDescriptor[] = []
|
|
1412
|
-
for (const h of heads) {
|
|
1413
|
-
if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
|
|
1414
|
-
if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
|
|
1415
|
-
if (h.dir !== undefined) dir = h.dir
|
|
1416
|
-
if (h.meta !== undefined) meta.push(...h.meta)
|
|
1417
|
-
if (h.link !== undefined) link.push(...h.link)
|
|
1418
|
-
if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
|
|
1419
|
-
if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
|
|
1420
|
-
}
|
|
1421
|
-
// Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
|
|
1422
|
-
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
1423
|
-
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
1424
|
-
// `undefined`).
|
|
1425
|
-
const merged: {
|
|
1426
|
-
title?: string
|
|
1427
|
-
meta?: Meta["meta"]
|
|
1428
|
-
link?: Meta["link"]
|
|
1429
|
-
script?: Meta["script"]
|
|
1430
|
-
unsafeScript?: Meta["unsafeScript"]
|
|
1431
|
-
lang?: string
|
|
1432
|
-
dir?: Meta["dir"]
|
|
1433
|
-
} = {}
|
|
1434
|
-
if (title !== undefined) merged.title = title
|
|
1435
|
-
if (meta.length > 0) merged.meta = meta
|
|
1436
|
-
if (link.length > 0) merged.link = link
|
|
1437
|
-
if (script.length > 0) merged.script = script
|
|
1438
|
-
if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
|
|
1439
|
-
if (lang !== undefined) merged.lang = lang
|
|
1440
|
-
if (dir !== undefined) merged.dir = dir
|
|
1441
|
-
return merged as Meta
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
export interface CreateWebAppOptions {
|
|
1315
|
+
export interface CreateWebAppOptions<Env = unknown> {
|
|
1445
1316
|
readonly adapter: RenderAdapter
|
|
1446
1317
|
readonly manifest: Manifest
|
|
1447
1318
|
/** URL of the built client entry (module script) injected into every page. */
|
|
1448
1319
|
readonly clientEntry: string
|
|
1449
1320
|
/** Default document title for all pages. */
|
|
1450
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
|
|
1451
1376
|
/** Injected into each loader's `ctx.api` - typically an `inProcessClient(app)` (typed
|
|
1452
1377
|
* per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
|
|
1453
1378
|
*
|
|
@@ -1512,6 +1437,11 @@ export interface CreateWebAppOptions {
|
|
|
1512
1437
|
* `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
|
|
1513
1438
|
* into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
|
|
1514
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
|
|
1515
1445
|
/** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
|
|
1516
1446
|
* the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
|
|
1517
1447
|
* `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
|
|
@@ -1600,7 +1530,7 @@ const urlPartsFor = (request: Request): UrlParts => {
|
|
|
1600
1530
|
* secure default; validate at the trust boundary before use.
|
|
1601
1531
|
*/
|
|
1602
1532
|
export function createWebApp<Env = unknown>(
|
|
1603
|
-
options: CreateWebAppOptions
|
|
1533
|
+
options: CreateWebAppOptions<Env>,
|
|
1604
1534
|
): ReturnType<typeof server<Env>> {
|
|
1605
1535
|
const { adapter, manifest, clientEntry, title, api } = options
|
|
1606
1536
|
const titleOption = title === undefined ? {} : { title }
|
|
@@ -1626,7 +1556,11 @@ export function createWebApp<Env = unknown>(
|
|
|
1626
1556
|
// Seed the context with the declared `Env` so `app.fetch(req, { env })` / `toFetchHandler(app)` type
|
|
1627
1557
|
// the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
|
|
1628
1558
|
// `app.fetch(req, { env })`; this is a compile-time-only seed (`server<Env>()` casts, doesn't store).
|
|
1629
|
-
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)
|
|
1630
1564
|
// Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
|
|
1631
1565
|
// `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
|
|
1632
1566
|
// for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
|
|
@@ -2169,6 +2103,13 @@ export function createWebApp<Env = unknown>(
|
|
|
2169
2103
|
}
|
|
2170
2104
|
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
2171
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
|
|
2172
2113
|
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
|
|
2173
2114
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
2174
2115
|
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
@@ -2275,11 +2216,21 @@ export function createWebApp<Env = unknown>(
|
|
|
2275
2216
|
draft,
|
|
2276
2217
|
search: loaderSearch(mod.searchSchema, c.req),
|
|
2277
2218
|
}
|
|
2278
|
-
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
2279
|
-
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
2280
|
-
const layoutModules = await runLayoutGates(route, actionContext)
|
|
2281
|
-
const result = await mod.action(actionContext)
|
|
2282
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
|
+
}
|
|
2283
2234
|
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
2284
2235
|
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
2285
2236
|
// responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
|
|
@@ -2289,15 +2240,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2289
2240
|
const revalidateHeader: Record<string, string> = isRevalidate
|
|
2290
2241
|
? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
|
|
2291
2242
|
: {}
|
|
2292
|
-
if (actionResult instanceof Response)
|
|
2293
|
-
// Client submit can't read a 3xx Location (fetch follows it to HTML), so convey the
|
|
2294
|
-
// redirect via a header on a 204 and let the client navigate. Native forms get the 3xx.
|
|
2295
|
-
if (isDataRequest && actionResult.status >= 300 && actionResult.status < 400) {
|
|
2296
|
-
const location = actionResult.headers.get("location") ?? "/"
|
|
2297
|
-
return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } })
|
|
2298
|
-
}
|
|
2299
|
-
return actionResult
|
|
2300
|
-
}
|
|
2243
|
+
if (actionResult instanceof Response) return actionResponse(actionResult, isDataRequest)
|
|
2301
2244
|
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
2302
2245
|
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
2303
2246
|
if (isDataRequest) {
|
|
@@ -2354,15 +2297,16 @@ export function createWebApp<Env = unknown>(
|
|
|
2354
2297
|
}
|
|
2355
2298
|
|
|
2356
2299
|
// Register llms.txt & llms-full.txt
|
|
2300
|
+
const llmsOptions = { includeLocalGuidelines: options.publishLocalGuidelines === true }
|
|
2357
2301
|
app.register("GET", "/llms.txt", undefined, async () => {
|
|
2358
|
-
const text = await generateLlmsTxt(false, manifest.routes, api)
|
|
2302
|
+
const text = await generateLlmsTxt(false, manifest.routes, api, llmsOptions)
|
|
2359
2303
|
return new Response(text, {
|
|
2360
2304
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
2361
2305
|
})
|
|
2362
2306
|
})
|
|
2363
2307
|
|
|
2364
2308
|
app.register("GET", "/llms-full.txt", undefined, async () => {
|
|
2365
|
-
const text = await generateLlmsTxt(true, manifest.routes, api)
|
|
2309
|
+
const text = await generateLlmsTxt(true, manifest.routes, api, llmsOptions)
|
|
2366
2310
|
return new Response(text, {
|
|
2367
2311
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
2368
2312
|
})
|
|
@@ -2447,12 +2391,24 @@ export function generateClientEntry(
|
|
|
2447
2391
|
}
|
|
2448
2392
|
|
|
2449
2393
|
return `${[
|
|
2450
|
-
|
|
2394
|
+
// `/client`, never the root: the root's graph carries the server (renderPage, the static-file
|
|
2395
|
+
// server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
|
|
2396
|
+
'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
|
|
2451
2397
|
'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
|
|
2452
2398
|
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
2453
2399
|
// access yields `undefined` if absent - unlike a named import, which would be a link error.
|
|
2454
2400
|
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
2455
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
|
+
"}",
|
|
2456
2412
|
// The `clientModule` contract is a string specifier, so nothing type-checks that the module it
|
|
2457
2413
|
// names actually exports `mountRouter`. Without this the miss surfaces as
|
|
2458
2414
|
// "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
|
|
@@ -2464,6 +2420,16 @@ export function generateClientEntry(
|
|
|
2464
2420
|
'`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
|
|
2465
2421
|
)})`,
|
|
2466
2422
|
"const errorBoundary = __adapter.errorBoundary",
|
|
2423
|
+
// A hot update that the framework cannot actually apply must end in a reload, not in nothing.
|
|
2424
|
+
//
|
|
2425
|
+
// Bun's dev server applies React Fast Refresh to JSX modules. For React that patches the component
|
|
2426
|
+
// and preserves state. For Preact it does not - the module still LOOKS Fast-Refresh-able, so the
|
|
2427
|
+
// update is accepted and then swallowed: `bun:beforeUpdate`/`bun:afterUpdate` both fire, the server
|
|
2428
|
+
// logs a rebuild, and the page silently keeps rendering the old code. An edit that appears to do
|
|
2429
|
+
// nothing is the worst failure mode in a dev loop - worse than a reload, because there is no signal
|
|
2430
|
+
// to act on. An adapter that knows its framework is in that position says so, and the entry turns
|
|
2431
|
+
// the swallowed update into an honest full reload.
|
|
2432
|
+
'if (import.meta.hot && __adapter.hotUpdateNeedsReload) import.meta.hot.on("bun:afterUpdate", () => location.reload())',
|
|
2467
2433
|
`const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
|
|
2468
2434
|
// Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
|
|
2469
2435
|
// route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Head resolution + merging, in its own module so the CLIENT can have it without the server.
|
|
3
|
+
*
|
|
4
|
+
* These two are pure functions over `Meta`, but they used to live in `index.ts` - the module that also
|
|
5
|
+
* carries `renderPage`, `createWebApp` and the static-file server. The generated client entry imported
|
|
6
|
+
* them from `@nifrajs/web`, which under a bundler was harmless (server code tree-shakes away) and under
|
|
7
|
+
* Vite's dev server was fatal: it serves each module as-is, so pulling `index.ts` into the browser graph
|
|
8
|
+
* executed `public-dir.ts` and the page died on `Module "node:fs/promises" has been externalized`
|
|
9
|
+
* before hydrating. Splitting them out means the client entry never names the server module at all,
|
|
10
|
+
* rather than relying on a bundler to remove it afterwards.
|
|
11
|
+
*
|
|
12
|
+
* `index.ts` and `client.ts` both re-export from here, so the public API is unchanged in both places.
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
LinkDescriptor,
|
|
16
|
+
Meta,
|
|
17
|
+
MetaArgs,
|
|
18
|
+
MetaDescriptor,
|
|
19
|
+
MetaInput,
|
|
20
|
+
ScriptDescriptor,
|
|
21
|
+
UnsafeScriptDescriptor,
|
|
22
|
+
} from "../manifest.ts"
|
|
23
|
+
|
|
24
|
+
/** Resolve a route's `meta` export - a static object, or a function of the route's data/params. */
|
|
25
|
+
export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
|
|
26
|
+
if (meta === undefined) return {}
|
|
27
|
+
return typeof meta === "function" ? meta(args) : meta
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Merge a layout chain's heads, outermost first: `title`/`lang`/`dir` are nearest-wins, and
|
|
32
|
+
* `meta`/`link`/`script` concatenate in chain order.
|
|
33
|
+
*
|
|
34
|
+
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
35
|
+
* static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
|
|
36
|
+
* fine: `headTags`'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
37
|
+
* content can vary with loader data anyway).
|
|
38
|
+
*/
|
|
39
|
+
export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
40
|
+
// Single-head fast path (a route with no layout `meta`, by far the common case) - return the
|
|
41
|
+
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
42
|
+
if (heads.length === 1) return heads[0] as Meta
|
|
43
|
+
let title: string | undefined
|
|
44
|
+
let lang: string | undefined
|
|
45
|
+
let dir: Meta["dir"]
|
|
46
|
+
const meta: MetaDescriptor[] = []
|
|
47
|
+
const link: LinkDescriptor[] = []
|
|
48
|
+
const script: ScriptDescriptor[] = []
|
|
49
|
+
const unsafeScript: UnsafeScriptDescriptor[] = []
|
|
50
|
+
for (const h of heads) {
|
|
51
|
+
if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
|
|
52
|
+
if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
|
|
53
|
+
if (h.dir !== undefined) dir = h.dir
|
|
54
|
+
if (h.meta !== undefined) meta.push(...h.meta)
|
|
55
|
+
if (h.link !== undefined) link.push(...h.link)
|
|
56
|
+
if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
|
|
57
|
+
if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
|
|
58
|
+
}
|
|
59
|
+
// Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
|
|
60
|
+
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
61
|
+
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
62
|
+
// `undefined`).
|
|
63
|
+
const merged: {
|
|
64
|
+
title?: string
|
|
65
|
+
meta?: Meta["meta"]
|
|
66
|
+
link?: Meta["link"]
|
|
67
|
+
script?: Meta["script"]
|
|
68
|
+
unsafeScript?: Meta["unsafeScript"]
|
|
69
|
+
lang?: string
|
|
70
|
+
dir?: Meta["dir"]
|
|
71
|
+
} = {}
|
|
72
|
+
if (title !== undefined) merged.title = title
|
|
73
|
+
if (meta.length > 0) merged.meta = meta
|
|
74
|
+
if (link.length > 0) merged.link = link
|
|
75
|
+
if (script.length > 0) merged.script = script
|
|
76
|
+
if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
|
|
77
|
+
if (lang !== undefined) merged.lang = lang
|
|
78
|
+
if (dir !== undefined) merged.dir = dir
|
|
79
|
+
return merged as Meta
|
|
80
|
+
}
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The client build must never ship server-only code, or server-only config, to a browser. Three
|
|
5
5
|
* conventions enforce that, and each has to mean the SAME thing in every pipeline that builds the client
|
|
6
|
-
* - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun
|
|
7
|
-
* (which cannot transform, so it refuses to start instead):
|
|
6
|
+
* - the Bun client build, the Vite dev server, the Vite production build, and `nifra dev --bun`:
|
|
8
7
|
*
|
|
9
8
|
* - `*.server` modules - emptied, so a secret / `node:` / native import subtree never reaches the client.
|
|
10
9
|
* - `*.fn` modules - replaced with HTTP stubs, so the function bodies (and their imports) stay server-side.
|
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`
|