@nifrajs/web 5.4.0 → 5.5.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/CHANGELOG.md +46 -0
- package/dist/build-plan.d.ts +6 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +25 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +2 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +2 -0
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +17 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +81 -40
- package/dist/build.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +20 -6
- package/dist/deferred.js.map +1 -1
- package/dist/dev.d.ts +5 -0
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +7 -6
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic.d.ts +6 -0
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +41 -0
- package/dist/diagnostic.js.map +1 -1
- package/dist/host-policy.d.ts +72 -0
- package/dist/host-policy.d.ts.map +1 -0
- package/dist/host-policy.js +149 -0
- package/dist/host-policy.js.map +1 -0
- package/dist/host-reach.d.ts +49 -0
- package/dist/host-reach.d.ts.map +1 -0
- package/dist/host-reach.js +144 -0
- package/dist/host-reach.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/certified-components.d.ts +8 -0
- package/dist/internal/certified-components.d.ts.map +1 -0
- package/dist/internal/certified-components.js +8 -0
- package/dist/internal/certified-components.js.map +1 -0
- package/dist/internal/codegen.d.ts +8 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +15 -4
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/output-guard.d.ts +6 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +118 -16
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +2 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +16 -11
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/render-document.d.ts +3 -1
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +10 -4
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/web-app.d.ts +15 -1
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +8 -0
- package/dist/internal/web-app.js.map +1 -1
- package/dist/public-dir.d.ts +4 -3
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +24 -18
- package/dist/public-dir.js.map +1 -1
- package/dist/router.d.ts +2 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +9 -8
- package/dist/router.js.map +1 -1
- package/dist/server-components/vite.d.ts.map +1 -1
- package/dist/server-components/vite.js +8 -6
- package/dist/server-components/vite.js.map +1 -1
- package/dist/service-worker.d.ts +3 -1
- package/dist/service-worker.d.ts.map +1 -1
- package/dist/service-worker.js +29 -8
- package/dist/service-worker.js.map +1 -1
- package/dist/zones.d.ts +5 -1
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +17 -4
- package/dist/zones.js.map +1 -1
- package/package.json +4 -4
- package/src/build-plan.ts +26 -0
- package/src/build-vite.ts +4 -0
- package/src/build.ts +114 -44
- package/src/deferred.ts +20 -6
- package/src/dev.ts +7 -6
- package/src/diagnostic.ts +43 -0
- package/src/host-policy.ts +219 -0
- package/src/host-reach.ts +177 -0
- package/src/index.ts +10 -2
- package/src/internal/certified-components.ts +7 -0
- package/src/internal/codegen.ts +24 -5
- package/src/internal/output-guard.ts +166 -15
- package/src/internal/page-execution.ts +23 -9
- package/src/internal/render-document.ts +11 -4
- package/src/internal/web-app.ts +27 -1
- package/src/public-dir.ts +31 -24
- package/src/router.ts +9 -8
- package/src/server-components/vite.ts +11 -6
- package/src/service-worker.ts +32 -9
- package/src/zones.ts +27 -5
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-scoped surfaces: which pages and files each host may serve.
|
|
3
|
+
*
|
|
4
|
+
* An app that answers on more than one host often must not show all of itself on each: a domain
|
|
5
|
+
* someone else points at the app should not render its sign-in page under that domain's certificate.
|
|
6
|
+
* A gate that knows only page paths gets the files wrong instead, because a page is not just its path:
|
|
7
|
+
* its document pulls the client bundle, its stylesheets and whatever `public/` files it links, and a
|
|
8
|
+
* gate that refuses those serves the page unstyled and never hydrates it. So a rule names its pages by
|
|
9
|
+
* route pattern and takes the build's own files by token: `buildAssets` is the exact set the build
|
|
10
|
+
* emitted (`buildAssets` from the generated server manifest), never a guess at its naming scheme.
|
|
11
|
+
*
|
|
12
|
+
* Pure and runtime-free, so the same policy can refuse a soft navigation in the browser.
|
|
13
|
+
*/
|
|
14
|
+
import { Router as CoreRouter } from "@nifrajs/core/router"
|
|
15
|
+
import type { Middleware, NodeRequestContext } from "@nifrajs/core/server"
|
|
16
|
+
import { requestPattern } from "./internal/route-path.ts"
|
|
17
|
+
|
|
18
|
+
/** The files a build emitted, by request path: the generated server manifest's `buildAssets`. */
|
|
19
|
+
export interface BuildAssetSet {
|
|
20
|
+
has(pathname: string): boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One host's surface. A request on a host no rule names is not restricted. */
|
|
24
|
+
export interface HostPolicyRule {
|
|
25
|
+
/**
|
|
26
|
+
* The hosts this rule restricts: names compared without case, port or trailing dot, or a predicate
|
|
27
|
+
* given that lowercase name (`(host) => !ownHosts.has(host)` for "every host that is not ours").
|
|
28
|
+
*/
|
|
29
|
+
readonly hosts: readonly string[] | ((hostname: string) => boolean)
|
|
30
|
+
/** Page routes these hosts render, as route patterns (`/`, `/p/:id`, `/share/*`). */
|
|
31
|
+
readonly pages: readonly string[]
|
|
32
|
+
/**
|
|
33
|
+
* Serve the build's own files on these hosts (default `true`): the client bundle and the files the
|
|
34
|
+
* build copied from `public/`, exactly as the build emitted them.
|
|
35
|
+
*/
|
|
36
|
+
readonly buildAssets?: boolean
|
|
37
|
+
/** Other paths these hosts serve, as route patterns: an API the pages call, a server function. */
|
|
38
|
+
readonly paths?: readonly string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HostPolicyOptions {
|
|
42
|
+
readonly rules: readonly HostPolicyRule[]
|
|
43
|
+
/**
|
|
44
|
+
* The answer to a path a rule refuses. Default: a bare `404` with `cache-control: no-store`, so a
|
|
45
|
+
* restricted host does not even confirm the refused page exists.
|
|
46
|
+
*/
|
|
47
|
+
readonly refuse?: (refused: { readonly host: string; readonly pathname: string }) => Response
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HostPolicy {
|
|
51
|
+
readonly rules: readonly HostPolicyRule[]
|
|
52
|
+
/**
|
|
53
|
+
* Whether `pathname` (as requested, percent-encoded, without the query) may be served on `host` (a
|
|
54
|
+
* Host header value; its port and case are ignored). `buildAssets` is the build's file set; without
|
|
55
|
+
* it a rule's `buildAssets` matches nothing.
|
|
56
|
+
*/
|
|
57
|
+
allows(host: string, pathname: string, buildAssets?: BuildAssetSet): boolean
|
|
58
|
+
/** The refusal for `pathname` on `host`, or `undefined` to serve it. */
|
|
59
|
+
refusal(host: string, pathname: string, buildAssets?: BuildAssetSet): Response | undefined
|
|
60
|
+
/** {@link HostPolicy.refusal} for a request: its Host header (else its URL's host) and path. */
|
|
61
|
+
check(request: Request, buildAssets?: BuildAssetSet): Response | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const HOST_POLICY = Symbol.for("nifra.web.host-policy")
|
|
65
|
+
|
|
66
|
+
interface CompiledRule {
|
|
67
|
+
readonly covers: (hostname: string) => boolean
|
|
68
|
+
readonly matches: (pathname: string) => boolean
|
|
69
|
+
readonly buildAssets: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A Host header value's name: lowercase, without its port, IPv6 brackets or trailing dots. */
|
|
73
|
+
export function hostnameOf(host: string): string {
|
|
74
|
+
const name = host.trim().toLowerCase()
|
|
75
|
+
if (name.startsWith("[")) {
|
|
76
|
+
const end = name.indexOf("]")
|
|
77
|
+
return end === -1 ? name.slice(1) : name.slice(1, end)
|
|
78
|
+
}
|
|
79
|
+
const colon = name.lastIndexOf(":")
|
|
80
|
+
const bare = colon === -1 || name.indexOf(":") !== colon ? name : name.slice(0, colon)
|
|
81
|
+
// `partner.example.` is the same host as `partner.example`; kept, it escapes the rule listing it.
|
|
82
|
+
let end = bare.length
|
|
83
|
+
while (end > 0 && bare.charCodeAt(end - 1) === 46 /* . */) end--
|
|
84
|
+
return end === bare.length ? bare : bare.slice(0, end)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function compilePatterns(
|
|
88
|
+
patterns: readonly string[],
|
|
89
|
+
label: string,
|
|
90
|
+
): (pathname: string) => boolean {
|
|
91
|
+
const router = new CoreRouter<true>()
|
|
92
|
+
for (const pattern of patterns) {
|
|
93
|
+
if (typeof pattern !== "string" || !pattern.startsWith("/")) {
|
|
94
|
+
throw new TypeError(
|
|
95
|
+
`[nifra/web] hostPolicy: ${label} entries are route patterns starting with "/", got ${JSON.stringify(pattern)}`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
router.add("GET", requestPattern(pattern), true)
|
|
99
|
+
}
|
|
100
|
+
return (pathname) => router.find("GET", pathname).found
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function compileRule(rule: HostPolicyRule, index: number): CompiledRule {
|
|
104
|
+
const label = `rules[${index}]`
|
|
105
|
+
if (typeof rule !== "object" || rule === null) {
|
|
106
|
+
throw new TypeError(`[nifra/web] hostPolicy: ${label} must be an object`)
|
|
107
|
+
}
|
|
108
|
+
let covers: (hostname: string) => boolean
|
|
109
|
+
if (typeof rule.hosts === "function") {
|
|
110
|
+
covers = rule.hosts
|
|
111
|
+
} else if (Array.isArray(rule.hosts) && rule.hosts.every((host) => typeof host === "string")) {
|
|
112
|
+
const names = new Set(rule.hosts.map(hostnameOf))
|
|
113
|
+
covers = (hostname) => names.has(hostname)
|
|
114
|
+
} else {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
`[nifra/web] hostPolicy: ${label}.hosts must be a list of host names or a predicate`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (!Array.isArray(rule.pages)) {
|
|
120
|
+
throw new TypeError(
|
|
121
|
+
`[nifra/web] hostPolicy: ${label}.pages must list the page routes the hosts serve`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
const pages = compilePatterns(rule.pages, `${label}.pages`)
|
|
125
|
+
const paths = rule.paths === undefined ? undefined : compilePatterns(rule.paths, `${label}.paths`)
|
|
126
|
+
return {
|
|
127
|
+
covers,
|
|
128
|
+
matches: paths === undefined ? pages : (pathname) => pages(pathname) || paths(pathname),
|
|
129
|
+
buildAssets: rule.buildAssets !== false,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const notFound = (): Response =>
|
|
134
|
+
new Response("Not Found", {
|
|
135
|
+
status: 404,
|
|
136
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Declare which pages and files each host serves. Hand the result to `createWebApp({ hostPolicy })`
|
|
141
|
+
* (or export it as `hostPolicy` from the framework module, which `nifra build`'s server entry reads):
|
|
142
|
+
* it is evaluated before the build's static files, the prerendered pages, the mounts and every page.
|
|
143
|
+
* On Vercel the CDN answers the build's files before the function runs, so there it covers the rest.
|
|
144
|
+
* A hand-written entry calls `check(request, buildAssets)` first, before its own static handler.
|
|
145
|
+
*
|
|
146
|
+
* ```ts
|
|
147
|
+
* export const hostPolicy = createHostPolicy({
|
|
148
|
+
* rules: [{
|
|
149
|
+
* hosts: (host) => !OWN_HOSTS.has(host),
|
|
150
|
+
* pages: ["/", "/p/:id", "/share/*"],
|
|
151
|
+
* paths: ["/api/submissions/*"],
|
|
152
|
+
* }],
|
|
153
|
+
* })
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export function createHostPolicy(options: HostPolicyOptions): HostPolicy {
|
|
157
|
+
if (typeof options !== "object" || options === null || !Array.isArray(options.rules)) {
|
|
158
|
+
throw new TypeError("[nifra/web] createHostPolicy takes { rules: [...] }")
|
|
159
|
+
}
|
|
160
|
+
const rules = options.rules.map(compileRule)
|
|
161
|
+
const refuse = options.refuse ?? notFound
|
|
162
|
+
const allows = (host: string, pathname: string, buildAssets?: BuildAssetSet): boolean => {
|
|
163
|
+
const hostname = hostnameOf(host)
|
|
164
|
+
const rule = rules.find((candidate) => candidate.covers(hostname))
|
|
165
|
+
if (rule === undefined) return true
|
|
166
|
+
if (rule.buildAssets && buildAssets?.has(pathname) === true) return true
|
|
167
|
+
return rule.matches(pathname)
|
|
168
|
+
}
|
|
169
|
+
const refusal = (host: string, pathname: string, buildAssets?: BuildAssetSet) =>
|
|
170
|
+
allows(host, pathname, buildAssets) ? undefined : refuse({ host: hostnameOf(host), pathname })
|
|
171
|
+
const policy: HostPolicy = {
|
|
172
|
+
rules: Object.freeze([...options.rules]),
|
|
173
|
+
allows,
|
|
174
|
+
refusal,
|
|
175
|
+
check(request, buildAssets) {
|
|
176
|
+
const url = new URL(request.url)
|
|
177
|
+
return refusal(request.headers.get("host") ?? url.host, url.pathname, buildAssets)
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
Object.defineProperty(policy, HOST_POLICY, { value: true })
|
|
181
|
+
return Object.freeze(policy)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether `value` came from {@link createHostPolicy}. */
|
|
185
|
+
export function isHostPolicy(value: unknown): value is HostPolicy {
|
|
186
|
+
return typeof value === "object" && value !== null && HOST_POLICY in value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe")
|
|
190
|
+
|
|
191
|
+
/** The path of a Node view's URL, `<protocol>://<host><target>`: the adapter has refused a host
|
|
192
|
+
* holding `/`, `?`, `#` or `@` and resolved the target's dot segments, so it starts at the first `/`
|
|
193
|
+
* after the scheme and ends where core's routing ends it. */
|
|
194
|
+
const targetPath = (url: string): string => {
|
|
195
|
+
const start = url.indexOf("/", url.indexOf("://") + 3)
|
|
196
|
+
if (start === -1) return ""
|
|
197
|
+
let end = url.length
|
|
198
|
+
for (let i = start; i < end; i++) {
|
|
199
|
+
const c = url.charCodeAt(i)
|
|
200
|
+
if (c === 63 /* ? */ || c === 35 /* # */) end = i
|
|
201
|
+
}
|
|
202
|
+
return url.slice(start, end)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A host policy as request middleware, for `createWebApp`: header-only, so its Node twin keeps the
|
|
206
|
+
* direct lane. */
|
|
207
|
+
export function hostPolicyGate(
|
|
208
|
+
policy: HostPolicy,
|
|
209
|
+
buildAssets: BuildAssetSet | undefined,
|
|
210
|
+
): Middleware {
|
|
211
|
+
const gate: Middleware = {
|
|
212
|
+
name: "nifra-host-policy",
|
|
213
|
+
onRequest: (request) => policy.check(request, buildAssets),
|
|
214
|
+
onNodeRequest: (request: NodeRequestContext) =>
|
|
215
|
+
policy.refusal(request.header("host") ?? "", targetPath(request.url), buildAssets),
|
|
216
|
+
}
|
|
217
|
+
Object.defineProperty(gate, BUN_NATIVE_REQUEST_SAFE, { value: true })
|
|
218
|
+
return gate
|
|
219
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a host can load what its pages reference: the check that catches a host policy (or any
|
|
3
|
+
* gate in front of the static files) that serves a page but refuses its stylesheet or bundle. Such a
|
|
4
|
+
* page still answers 200, so a status check passes while the browser renders it unstyled and never
|
|
5
|
+
* hydrates it.
|
|
6
|
+
*
|
|
7
|
+
* Bun-only (HTMLRewriter, Bun.Transpiler), like the rest of `@nifrajs/web/build`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One request the check made. */
|
|
11
|
+
export interface HostReachResult {
|
|
12
|
+
/** The request path, as sent. */
|
|
13
|
+
readonly path: string
|
|
14
|
+
/** `page` for a page the check was given, `asset` for a file a page or another file references. */
|
|
15
|
+
readonly kind: "page" | "asset" | "refused"
|
|
16
|
+
readonly status: number
|
|
17
|
+
/** What referenced it: the page or file path. Absent for a page. */
|
|
18
|
+
readonly from?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HostReachReport {
|
|
22
|
+
readonly host: string
|
|
23
|
+
readonly ok: boolean
|
|
24
|
+
readonly results: readonly HostReachResult[]
|
|
25
|
+
/** The results that broke the expectation: a page or asset not answered 200, a refused path answered. */
|
|
26
|
+
readonly failures: readonly HostReachResult[]
|
|
27
|
+
/** `limit` stopped the walk with declared pages or references left unfetched; `ok` is then `false`. */
|
|
28
|
+
readonly truncated: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HostReachOptions {
|
|
32
|
+
/** Sends one GET. Given the path and the Host header to send. */
|
|
33
|
+
readonly fetch: (path: string, host: string) => Promise<Response>
|
|
34
|
+
/** The host to address (the Host header every request carries). */
|
|
35
|
+
readonly host: string
|
|
36
|
+
/** Page paths that must render on `host` (concrete paths, not patterns). */
|
|
37
|
+
readonly pages: readonly string[]
|
|
38
|
+
/** Paths `host` must refuse with `refusedStatus`. */
|
|
39
|
+
readonly refused?: readonly string[]
|
|
40
|
+
/** The status a refused path answers (default `404`). */
|
|
41
|
+
readonly refusedStatus?: number
|
|
42
|
+
/** At most this many files are fetched, pages included (default 500). */
|
|
43
|
+
readonly limit?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The attributes that make a browser fetch a file before or while a page renders.
|
|
47
|
+
const LINK_RELS = new Set([
|
|
48
|
+
"stylesheet",
|
|
49
|
+
"modulepreload",
|
|
50
|
+
"preload",
|
|
51
|
+
"icon",
|
|
52
|
+
"apple-touch-icon",
|
|
53
|
+
"manifest",
|
|
54
|
+
"mask-icon",
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
/** The URLs a document asks the browser to load: scripts, stylesheets, preloads, icons, images. */
|
|
58
|
+
async function documentReferences(html: string): Promise<string[]> {
|
|
59
|
+
const found: string[] = []
|
|
60
|
+
const rewriter = new HTMLRewriter()
|
|
61
|
+
.on("script[src]", {
|
|
62
|
+
element: (el) => void found.push(el.getAttribute("src") ?? ""),
|
|
63
|
+
})
|
|
64
|
+
.on("link[href]", {
|
|
65
|
+
element: (el) => {
|
|
66
|
+
const rels = (el.getAttribute("rel") ?? "").toLowerCase().split(/\s+/)
|
|
67
|
+
if (rels.some((rel) => LINK_RELS.has(rel))) found.push(el.getAttribute("href") ?? "")
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
.on("img[src], source[src], video[poster]", {
|
|
71
|
+
element: (el) => void found.push(el.getAttribute("src") ?? el.getAttribute("poster") ?? ""),
|
|
72
|
+
})
|
|
73
|
+
await rewriter.transform(new Response(html)).text()
|
|
74
|
+
return found.filter((url) => url !== "")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `url(...)` references in a stylesheet, found by scanning rather than a regex over untrusted text. */
|
|
78
|
+
function cssReferences(css: string): string[] {
|
|
79
|
+
const found: string[] = []
|
|
80
|
+
let at = css.indexOf("url(")
|
|
81
|
+
while (at !== -1) {
|
|
82
|
+
const close = css.indexOf(")", at)
|
|
83
|
+
if (close === -1) break
|
|
84
|
+
const raw = css
|
|
85
|
+
.slice(at + 4, close)
|
|
86
|
+
.trim()
|
|
87
|
+
.replace(/^["']|["']$/g, "")
|
|
88
|
+
if (raw !== "" && !raw.startsWith("data:")) found.push(raw)
|
|
89
|
+
at = css.indexOf("url(", close)
|
|
90
|
+
}
|
|
91
|
+
return found
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const scanner = new Bun.Transpiler({ loader: "js" })
|
|
95
|
+
|
|
96
|
+
/** A script's static and dynamic import specifiers that name files (not bare packages). */
|
|
97
|
+
function scriptReferences(code: string): string[] {
|
|
98
|
+
try {
|
|
99
|
+
return scanner
|
|
100
|
+
.scanImports(code)
|
|
101
|
+
.map((entry) => entry.path)
|
|
102
|
+
.filter((path) => path.startsWith("/") || path.startsWith("./") || path.startsWith("../"))
|
|
103
|
+
} catch {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Render each page on `host`, then fetch every same-origin file each page references, following
|
|
110
|
+
* scripts' imports and stylesheets' `url()`s, and expect 200 for all of them; then expect each
|
|
111
|
+
* `refused` path to answer `refusedStatus`. Cross-origin references are skipped: they are not this
|
|
112
|
+
* app's to serve.
|
|
113
|
+
*/
|
|
114
|
+
export async function checkHostReach(options: HostReachOptions): Promise<HostReachReport> {
|
|
115
|
+
const { host, fetch } = options
|
|
116
|
+
const limit = options.limit ?? 500
|
|
117
|
+
const refusedStatus = options.refusedStatus ?? 404
|
|
118
|
+
const origin = `http://${host}`
|
|
119
|
+
const results: HostReachResult[] = []
|
|
120
|
+
const seen = new Set<string>()
|
|
121
|
+
const queue: { path: string; from: string }[] = []
|
|
122
|
+
const enqueue = (reference: string, base: string): void => {
|
|
123
|
+
let url: URL
|
|
124
|
+
try {
|
|
125
|
+
url = new URL(reference, origin + base)
|
|
126
|
+
} catch {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if (url.origin !== origin) return
|
|
130
|
+
const path = url.pathname
|
|
131
|
+
if (seen.has(path)) return
|
|
132
|
+
seen.add(path)
|
|
133
|
+
queue.push({ path, from: base })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let pagesTruncated = false
|
|
137
|
+
for (const page of options.pages) {
|
|
138
|
+
if (results.length >= limit) {
|
|
139
|
+
pagesTruncated = true
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
seen.add(page)
|
|
143
|
+
const response = await fetch(page, host)
|
|
144
|
+
const text = await response.text()
|
|
145
|
+
results.push({ path: page, kind: "page", status: response.status })
|
|
146
|
+
if (response.status !== 200) continue
|
|
147
|
+
for (const reference of await documentReferences(text)) enqueue(reference, page)
|
|
148
|
+
}
|
|
149
|
+
while (results.length < limit) {
|
|
150
|
+
const next = queue.shift()
|
|
151
|
+
if (next === undefined) break
|
|
152
|
+
const response = await fetch(next.path, host)
|
|
153
|
+
results.push({ path: next.path, kind: "asset", status: response.status, from: next.from })
|
|
154
|
+
if (response.status !== 200) {
|
|
155
|
+
await response.body?.cancel()
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
const type = response.headers.get("content-type") ?? ""
|
|
159
|
+
if (!type.includes("css") && !type.includes("javascript")) {
|
|
160
|
+
await response.body?.cancel()
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const body = await response.text()
|
|
164
|
+
const references = type.includes("css") ? cssReferences(body) : scriptReferences(body)
|
|
165
|
+
for (const reference of references) enqueue(reference, next.path)
|
|
166
|
+
}
|
|
167
|
+
for (const path of options.refused ?? []) {
|
|
168
|
+
const response = await fetch(path, host)
|
|
169
|
+
await response.body?.cancel()
|
|
170
|
+
results.push({ path, kind: "refused", status: response.status })
|
|
171
|
+
}
|
|
172
|
+
const failures = results.filter((result) =>
|
|
173
|
+
result.kind === "refused" ? result.status !== refusedStatus : result.status !== 200,
|
|
174
|
+
)
|
|
175
|
+
const truncated = pagesTruncated || queue.length > 0
|
|
176
|
+
return { host, ok: failures.length === 0 && !truncated, results, failures, truncated }
|
|
177
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -76,6 +76,16 @@ export {
|
|
|
76
76
|
fontFace,
|
|
77
77
|
fontPreload,
|
|
78
78
|
} from "./fonts.ts"
|
|
79
|
+
// Generated client entries intentionally import the browser router from the client subpath:
|
|
80
|
+
// `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
|
|
81
|
+
export {
|
|
82
|
+
type BuildAssetSet,
|
|
83
|
+
createHostPolicy,
|
|
84
|
+
type HostPolicy,
|
|
85
|
+
type HostPolicyOptions,
|
|
86
|
+
type HostPolicyRule,
|
|
87
|
+
isHostPolicy,
|
|
88
|
+
} from "./host-policy.ts"
|
|
79
89
|
export {
|
|
80
90
|
type GenerateClientEntryOptions,
|
|
81
91
|
type GenerateRouteSearchTypesOptions,
|
|
@@ -84,8 +94,6 @@ export {
|
|
|
84
94
|
generateRouteSearchTypes,
|
|
85
95
|
generateServerManifest,
|
|
86
96
|
} from "./internal/codegen.ts"
|
|
87
|
-
// Generated client entries intentionally import the browser router from the client subpath:
|
|
88
|
-
// `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
|
|
89
97
|
export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
|
|
90
98
|
export type { PrerenderedPages } from "./internal/prerendered-pages.ts"
|
|
91
99
|
export {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one Vite and React release the server-component pipeline is certified against. The plugin's
|
|
3
|
+
* guard and the consumer check read these; `component-certification.test.ts` holds the exact pins
|
|
4
|
+
* in every manifest that runs the pipeline to them, so a range bump cannot drift past the guard.
|
|
5
|
+
*/
|
|
6
|
+
export const CERTIFIED_COMPONENT_VITE = "8.2.1"
|
|
7
|
+
export const CERTIFIED_COMPONENT_REACT = "19.3.1"
|
package/src/internal/codegen.ts
CHANGED
|
@@ -17,6 +17,13 @@ import {
|
|
|
17
17
|
import { PRERENDERED_GLOBAL } from "../router.ts"
|
|
18
18
|
import { jsStringLiteral, jsValueLiteral } from "./js-string.ts"
|
|
19
19
|
|
|
20
|
+
/** The refusal for a server build given no client entry, shared by the codegen and `buildServer`. */
|
|
21
|
+
export function missingClientEntry(clientEntry: unknown): TypeError {
|
|
22
|
+
return new TypeError(
|
|
23
|
+
`[nifra/web] the server build needs the client build: pass client (the manifest buildClient returned) or clientEntry (its entry URL), got clientEntry ${clientEntry === null ? "null" : typeof clientEntry}; pass clientEntry "" for an app with no client script`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
/** The globals the page-state handover may set - nothing else in it reaches `window`. */
|
|
21
28
|
const HANDOVER_GLOBALS = [
|
|
22
29
|
DATA_GLOBAL,
|
|
@@ -474,6 +481,12 @@ export interface GenerateServerManifestOptions {
|
|
|
474
481
|
/** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`) - baked as
|
|
475
482
|
* `export const copyChunks` when the client build split copy tables, for `createWebApp`. */
|
|
476
483
|
readonly copyChunks?: Readonly<Record<string, string>> | undefined
|
|
484
|
+
/**
|
|
485
|
+
* The request path of every file the build emitted: the client build's `assets` and the `public/`
|
|
486
|
+
* files it copied. Baked as `export const buildAssets` (a `Set`) and `isBuildAsset(pathname)`, so a
|
|
487
|
+
* server entry or a host policy can allow exactly the files that exist. Omitted ⇒ neither export.
|
|
488
|
+
*/
|
|
489
|
+
readonly buildAssets?: readonly string[] | undefined
|
|
477
490
|
/** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
|
|
478
491
|
* eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
|
|
479
492
|
* first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
|
|
@@ -522,15 +535,12 @@ export function generateServerManifest(
|
|
|
522
535
|
routeStyles,
|
|
523
536
|
cssLoading: requestedCssLoading,
|
|
524
537
|
copyChunks,
|
|
538
|
+
buildAssets,
|
|
525
539
|
lazy = false,
|
|
526
540
|
} = options
|
|
527
541
|
// A missing entry would bake `clientEntry = undefined` and the build would still succeed, leaving
|
|
528
542
|
// every hydrating page to fail at request time. `""` stays valid: an app with no client script.
|
|
529
|
-
if (typeof clientEntry !== "string")
|
|
530
|
-
throw new TypeError(
|
|
531
|
-
`[nifra/web] the server build needs clientEntry (the built client entry URL, buildClient's manifest.entry), got ${clientEntry === null ? "null" : typeof clientEntry}; check the option is spelled clientEntry, or pass "" for an app with no client script`,
|
|
532
|
-
)
|
|
533
|
-
}
|
|
543
|
+
if (typeof clientEntry !== "string") throw missingClientEntry(clientEntry)
|
|
534
544
|
const cssLoading = normalizeCssLoading(requestedCssLoading ?? DEFAULT_CSS_LOADING)
|
|
535
545
|
// Every unique source file in the manifest, both halves of each, sorted for stable output. This is the
|
|
536
546
|
// one generated module that imports frontend and backend halves together: it is the server's
|
|
@@ -568,6 +578,13 @@ export function generateServerManifest(
|
|
|
568
578
|
copyChunks === undefined || Object.keys(copyChunks).length === 0
|
|
569
579
|
? []
|
|
570
580
|
: [`export const copyChunks = ${jsValueLiteral(copyChunks)}`]
|
|
581
|
+
const buildAssetsLines =
|
|
582
|
+
buildAssets === undefined
|
|
583
|
+
? []
|
|
584
|
+
: [
|
|
585
|
+
`export const buildAssets: ReadonlySet<string> = new Set(${jsValueLiteral([...new Set(buildAssets)].sort())})`,
|
|
586
|
+
"export const isBuildAsset = (pathname: string): boolean => buildAssets.has(pathname)",
|
|
587
|
+
]
|
|
571
588
|
if (lazy) {
|
|
572
589
|
// Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
|
|
573
590
|
// code-splitting bundler, loaded on first request). The map keys are the route-relative paths
|
|
@@ -586,6 +603,7 @@ export function generateServerManifest(
|
|
|
586
603
|
routeStylesLine,
|
|
587
604
|
cssLoadingLine,
|
|
588
605
|
...copyChunksLines,
|
|
606
|
+
...buildAssetsLines,
|
|
589
607
|
`export const manifest = buildManifest(Object.keys(loaders), ${LAZY_IMPORTER})`,
|
|
590
608
|
].join("\n")}\n`
|
|
591
609
|
}
|
|
@@ -606,6 +624,7 @@ export function generateServerManifest(
|
|
|
606
624
|
routeStylesLine,
|
|
607
625
|
cssLoadingLine,
|
|
608
626
|
...copyChunksLines,
|
|
627
|
+
...buildAssetsLines,
|
|
609
628
|
`export const manifest = buildManifest(Object.keys(modules), ${EAGER_IMPORTER})`,
|
|
610
629
|
].join("\n")}\n`
|
|
611
630
|
}
|