@nifrajs/web 2.0.0 → 2.2.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.
Files changed (102) hide show
  1. package/dist/build-vite.d.ts +68 -0
  2. package/dist/build-vite.d.ts.map +1 -0
  3. package/dist/build-vite.js +487 -0
  4. package/dist/build-vite.js.map +1 -0
  5. package/dist/build.d.ts +112 -25
  6. package/dist/build.d.ts.map +1 -1
  7. package/dist/build.js +283 -64
  8. package/dist/build.js.map +1 -1
  9. package/dist/bun-dev-entry.d.ts +83 -0
  10. package/dist/bun-dev-entry.d.ts.map +1 -0
  11. package/dist/bun-dev-entry.js +136 -0
  12. package/dist/bun-dev-entry.js.map +1 -0
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +10 -0
  15. package/dist/client.js.map +1 -1
  16. package/dist/deferred.d.ts +3 -2
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +11 -8
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/dev-port.d.ts +36 -0
  21. package/dist/dev-port.d.ts.map +1 -0
  22. package/dist/dev-port.js +48 -0
  23. package/dist/dev-port.js.map +1 -0
  24. package/dist/dev.d.ts +61 -4
  25. package/dist/dev.d.ts.map +1 -1
  26. package/dist/dev.js +419 -60
  27. package/dist/dev.js.map +1 -1
  28. package/dist/draft.d.ts +37 -0
  29. package/dist/draft.d.ts.map +1 -1
  30. package/dist/draft.js +87 -4
  31. package/dist/draft.js.map +1 -1
  32. package/dist/fs.d.ts +14 -1
  33. package/dist/fs.d.ts.map +1 -1
  34. package/dist/fs.js +7 -1
  35. package/dist/fs.js.map +1 -1
  36. package/dist/index.d.ts +116 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +565 -53
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/timing-safe-equal.d.ts +16 -0
  41. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  42. package/dist/internal/timing-safe-equal.js +26 -0
  43. package/dist/internal/timing-safe-equal.js.map +1 -0
  44. package/dist/isr.d.ts.map +1 -1
  45. package/dist/isr.js +1 -14
  46. package/dist/isr.js.map +1 -1
  47. package/dist/llms-txt.d.ts.map +1 -1
  48. package/dist/llms-txt.js +12 -6
  49. package/dist/llms-txt.js.map +1 -1
  50. package/dist/manifest.d.ts +51 -8
  51. package/dist/manifest.d.ts.map +1 -1
  52. package/dist/manifest.js +130 -14
  53. package/dist/manifest.js.map +1 -1
  54. package/dist/module-graph.d.ts +91 -0
  55. package/dist/module-graph.d.ts.map +1 -0
  56. package/dist/module-graph.js +92 -0
  57. package/dist/module-graph.js.map +1 -0
  58. package/dist/plugins/css-modules.d.ts.map +1 -1
  59. package/dist/plugins/css-modules.js +0 -0
  60. package/dist/plugins/css-modules.js.map +1 -1
  61. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  62. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  63. package/dist/plugins/vite-leak-guard.js +67 -0
  64. package/dist/plugins/vite-leak-guard.js.map +1 -0
  65. package/dist/public-dir.d.ts +36 -0
  66. package/dist/public-dir.d.ts.map +1 -0
  67. package/dist/public-dir.js +89 -0
  68. package/dist/public-dir.js.map +1 -0
  69. package/dist/route-manifest.d.ts +91 -0
  70. package/dist/route-manifest.d.ts.map +1 -0
  71. package/dist/route-manifest.js +128 -0
  72. package/dist/route-manifest.js.map +1 -0
  73. package/dist/router.d.ts +64 -1
  74. package/dist/router.d.ts.map +1 -1
  75. package/dist/router.js +114 -31
  76. package/dist/router.js.map +1 -1
  77. package/dist/vite.d.ts +10 -4
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +44 -12
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +17 -2
  82. package/src/build-vite.ts +606 -0
  83. package/src/build.ts +432 -73
  84. package/src/bun-dev-entry.ts +184 -0
  85. package/src/client.ts +8 -0
  86. package/src/deferred.ts +13 -6
  87. package/src/dev-port.ts +63 -0
  88. package/src/dev.ts +467 -65
  89. package/src/draft.ts +116 -4
  90. package/src/fs.ts +22 -4
  91. package/src/index.ts +727 -57
  92. package/src/internal/timing-safe-equal.ts +24 -0
  93. package/src/isr.ts +1 -13
  94. package/src/llms-txt.ts +13 -5
  95. package/src/manifest.ts +188 -13
  96. package/src/module-graph.ts +146 -0
  97. package/src/plugins/css-modules.ts +0 -0
  98. package/src/plugins/vite-leak-guard.ts +110 -0
  99. package/src/public-dir.ts +108 -0
  100. package/src/route-manifest.ts +240 -0
  101. package/src/router.ts +198 -34
  102. package/src/vite.ts +62 -20
package/src/draft.ts CHANGED
@@ -14,7 +14,14 @@
14
14
  * createWebApp({ ..., draftSecret: env.DRAFT_SECRET }) // → loaders get ctx.draft
15
15
  * withISR(app, { ..., draftSecret: env.DRAFT_SECRET }) // → editors bypass the cache
16
16
  */
17
- import { type CookieOptions, parseCookies, signValue, unsignValue } from "@nifrajs/core/server"
17
+ import {
18
+ type CookieOptions,
19
+ parseCookies,
20
+ serializeCookie,
21
+ signValue,
22
+ unsignValue,
23
+ } from "@nifrajs/core/server"
24
+ import { timingSafeEqual } from "./internal/timing-safe-equal.ts"
18
25
 
19
26
  /** The cookie name nifra uses for draft/preview mode. */
20
27
  export const DRAFT_COOKIE = "__nifra_draft"
@@ -51,15 +58,28 @@ export async function enableDraft(
51
58
  options: EnableDraftOptions = {},
52
59
  ): Promise<void> {
53
60
  const signed = await signValue(DRAFT_MARKER, secret)
61
+ c.set.cookie(DRAFT_COOKIE, signed, draftCookieOptions(options))
62
+ }
63
+
64
+ /**
65
+ * The attributes draft mode always sets. Shared by {@link enableDraft} (which writes through
66
+ * `c.set.cookie`) and {@link previewEndpoint} (which serializes its own `Set-Cookie`), so the two
67
+ * can't drift on the properties that make the cookie unforgeable and unreadable from JS.
68
+ *
69
+ * `secure` is resolved here rather than left to the caller: `serializeCookie` is pure and applies
70
+ * **no** security defaults, so the `previewEndpoint` path would silently emit a cookie without
71
+ * `Secure` — over plain HTTP that hands the draft cookie to any network observer. Passing it
72
+ * explicitly makes both paths identical instead of one inheriting a default the other never sees.
73
+ */
74
+ function draftCookieOptions(options: EnableDraftOptions): CookieOptions {
54
75
  // Explicit security attributes (don't lean on the context default): signed + HttpOnly + SameSite=Lax.
55
- const cookieOptions: CookieOptions = {
76
+ return {
56
77
  httpOnly: true,
57
78
  sameSite: "lax",
58
79
  path: options.path ?? "/",
59
80
  maxAge: options.maxAgeSeconds ?? 3600,
60
- ...(options.secure === undefined ? {} : { secure: options.secure }),
81
+ secure: options.secure ?? true,
61
82
  }
62
- c.set.cookie(DRAFT_COOKIE, signed, cookieOptions)
63
83
  }
64
84
 
65
85
  /** Turn draft mode **off**: clear the `__nifra_draft` cookie. Match the `path` used in `enableDraft`. */
@@ -80,3 +100,95 @@ export async function isDraftEnabled(request: Request, secret: string): Promise<
80
100
  if (cookie === undefined) return false
81
101
  return (await unsignValue(cookie, secret)) !== null
82
102
  }
103
+
104
+ /**
105
+ * Accept only a same-site destination, or `null`.
106
+ *
107
+ * A redirect target that arrives in the query string is attacker-controlled: anyone can mint a link
108
+ * to your own preview route and choose where the editor lands. "Starts with `/`" is the check people
109
+ * write and it is not enough — `//evil.com` is a protocol-relative URL and `/\evil.com` is normalized
110
+ * to one by browsers, so both start with a slash and navigate off-site. Control characters are refused
111
+ * as well, since a newline in a `Location` header splits the response.
112
+ */
113
+ function safeRedirectPath(value: string): string | null {
114
+ if (!value.startsWith("/")) return null
115
+ if (value.startsWith("//") || value.startsWith("/\\")) return null
116
+ // Escaped rather than written literally: raw control bytes in source make the file read as binary
117
+ // to ordinary tooling (grep, diffs) and are one reformat away from being silently mangled.
118
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point - this IS the check that refuses them.
119
+ if (/[\u0000-\u001f\u007f]/u.test(value)) return null
120
+ return value
121
+ }
122
+
123
+ /** Config for {@link previewEndpoint}. */
124
+ export interface PreviewEndpointOptions {
125
+ /** Shared secret the preview link must carry. Compared in **constant time**. */
126
+ readonly secret: string
127
+ /** Secret that **signs** the draft cookie — the same one passed to `createWebApp({ draftSecret })`
128
+ * and `withISR({ draftSecret })`. Keep it distinct from {@link secret}: that one travels in URLs
129
+ * (logs, `Referer`, browser history), this one never leaves the server. */
130
+ readonly draftSecret: string
131
+ /** Query parameter carrying the token. Default `"token"`. */
132
+ readonly tokenParam?: string
133
+ /** Query parameter carrying the destination. Default `"to"`. */
134
+ readonly redirectParam?: string
135
+ /** Where to send the editor when the destination parameter is absent. Default `"/"`. Must be
136
+ * site-relative; a non-relative value throws at construction rather than at request time. */
137
+ readonly fallbackPath?: string
138
+ /** Cookie lifetime/path/secure overrides, exactly as {@link enableDraft} takes them. */
139
+ readonly cookie?: EnableDraftOptions
140
+ }
141
+
142
+ /**
143
+ * A **preview / draft-mode entry point** — a `fetch` handler that checks a preview token, turns draft
144
+ * mode on, and redirects the editor to the page they wanted. `GET` with `?token=<secret>&to=/some/path`;
145
+ * mount it on a nifra route, e.g. `app.get("/api/preview", (c) => handler(c.req))`.
146
+ *
147
+ * This is the link-borne sibling of `revalidateEndpoint`. It exists because the alternative — telling
148
+ * you to gate the route yourself — means hand-rolling two checks that are easy to get subtly wrong and
149
+ * that fail silently when you do: the token compare must not exit early on the first wrong character,
150
+ * and the `?to=` destination must not be allowed to point off-site. Both are handled here.
151
+ *
152
+ * A CMS "Preview" button is a plain link, so the token has to ride the query string. That has a cost
153
+ * no endpoint can remove — the secret lands in server logs, the `Referer` header, and browser history.
154
+ * Use a preview token minted for that purpose, rotate it, and never reuse a token that grants anything
155
+ * beyond draft mode.
156
+ *
157
+ * Wrong or missing token → `401`; an off-site `to` → `400`; success → `302` with the signed cookie and
158
+ * `Cache-Control: no-store`, so no shared cache can ever replay one editor's draft session to a visitor.
159
+ */
160
+ export function previewEndpoint(
161
+ options: PreviewEndpointOptions,
162
+ ): (request: Request) => Promise<Response> {
163
+ const tokenParam = options.tokenParam ?? "token"
164
+ const redirectParam = options.redirectParam ?? "to"
165
+ const fallbackPath = options.fallbackPath ?? "/"
166
+ // Fail at construction, not on the request that happens to omit `?to=`: a misconfigured fallback is
167
+ // a deploy-time mistake and should surface at boot rather than as a rare 400 in production.
168
+ if (safeRedirectPath(fallbackPath) === null) {
169
+ throw new Error(
170
+ `[nifra] previewEndpoint fallbackPath must be a site-relative path, got ${JSON.stringify(fallbackPath)}`,
171
+ )
172
+ }
173
+ const cookieOptions = draftCookieOptions(options.cookie ?? {})
174
+ return async (request) => {
175
+ const url = new URL(request.url)
176
+ if (!timingSafeEqual(url.searchParams.get(tokenParam) ?? "", options.secret)) {
177
+ return Response.json({ ok: false, error: "unauthorized" }, { status: 401 })
178
+ }
179
+ const requested = url.searchParams.get(redirectParam)
180
+ const destination = requested === null ? fallbackPath : safeRedirectPath(requested)
181
+ if (destination === null) {
182
+ return Response.json({ ok: false, error: "invalid_redirect" }, { status: 400 })
183
+ }
184
+ const signed = await signValue(DRAFT_MARKER, options.draftSecret)
185
+ return new Response(null, {
186
+ status: 302,
187
+ headers: {
188
+ location: destination,
189
+ "set-cookie": serializeCookie(DRAFT_COOKIE, signed, cookieOptions),
190
+ "cache-control": "no-store",
191
+ },
192
+ })
193
+ }
194
+ }
package/src/fs.ts CHANGED
@@ -9,8 +9,21 @@ import { buildManifest, type Manifest, type RouteModule } from "./manifest.ts"
9
9
  /** Options for {@link discoverRoutes}. */
10
10
  export interface DiscoverRoutesOptions {
11
11
  /** Appended (as `?<importQuery>`) to each route's dynamic import — the dev server sets a
12
- * changing value to bust Bun's module cache so SSR picks up edited route files. */
12
+ * changing value to bust Bun's module cache so SSR picks up edited route files. Ignored when
13
+ * {@link load} is supplied, which does its own cache handling. */
13
14
  readonly importQuery?: string
15
+ /**
16
+ * Load a route module by its absolute path, instead of a bare dynamic `import()`.
17
+ *
18
+ * This is what lets a pipeline own SSR resolution. A bare `import()` always resolves through the
19
+ * RUNTIME - Bun - so the Vite dev server ended up serving the client while Bun resolved the server,
20
+ * two resolvers disagreeing about one specifier in a single process. That is the dual-React bug and
21
+ * the reason `resolve.dedupe` never reached SSR: the alias governs Vite, and SSR never asked Vite.
22
+ *
23
+ * Pass `vite.ssrLoadModule` here and the Vite pipeline resolves both halves. Omit it and Bun does,
24
+ * which is correct for the Bun pipeline. Either way one toolchain owns the phase.
25
+ */
26
+ readonly load?: (absolutePath: string) => Promise<unknown>
14
27
  }
15
28
 
16
29
  /** Scan a `routes/` directory (recursively) and build the route manifest. */
@@ -19,8 +32,13 @@ export function discoverRoutes(dir: string, options: DiscoverRoutesOptions = {})
19
32
  .map((file) => file.replaceAll("\\", "/")) // normalize Windows separators
20
33
  .filter((file) => /\.(tsx|jsx|svelte|vue|mdx)$/.test(file))
21
34
  const query = options.importQuery ? `?${options.importQuery}` : ""
22
- return buildManifest(
23
- files,
24
- (file) => () => import(`${dir}/${file}${query}`) as Promise<RouteModule>,
35
+ const load = options.load
36
+ return buildManifest(files, (file) =>
37
+ load === undefined
38
+ ? () => import(`${dir}/${file}${query}`) as Promise<RouteModule>
39
+ : // No `importQuery` here: an injected loader owns its own invalidation (Vite's module graph
40
+ // re-evaluates on change), and appending a cache-buster would defeat it by minting a new
41
+ // module id per request.
42
+ () => load(`${dir}/${file}`) as Promise<RouteModule>,
25
43
  )
26
44
  }