@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.
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +487 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +112 -25
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +283 -64
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -1
- package/dist/deferred.d.ts +3 -2
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +11 -8
- package/dist/deferred.js.map +1 -1
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +61 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +87 -4
- package/dist/draft.js.map +1 -1
- package/dist/fs.d.ts +14 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +116 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +565 -53
- package/dist/index.js.map +1 -1
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +1 -14
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +51 -8
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +130 -14
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -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/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +64 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +114 -31
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts +10 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +44 -12
- package/dist/vite.js.map +1 -1
- package/package.json +17 -2
- package/src/build-vite.ts +606 -0
- package/src/build.ts +432 -73
- package/src/bun-dev-entry.ts +184 -0
- package/src/client.ts +8 -0
- package/src/deferred.ts +13 -6
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +467 -65
- package/src/draft.ts +116 -4
- package/src/fs.ts +22 -4
- package/src/index.ts +727 -57
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/isr.ts +1 -13
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +188 -13
- package/src/module-graph.ts +146 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/public-dir.ts +108 -0
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +198 -34
- 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 {
|
|
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
|
-
|
|
76
|
+
return {
|
|
56
77
|
httpOnly: true,
|
|
57
78
|
sameSite: "lax",
|
|
58
79
|
path: options.path ?? "/",
|
|
59
80
|
maxAge: options.maxAgeSeconds ?? 3600,
|
|
60
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
}
|