@nifrajs/web 5.4.0 → 5.5.1
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 +54 -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
package/src/build.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatBuildLog } from "./diagnostic.ts"
|
|
1
2
|
import { publicEnvDefines } from "./internal/public-env.ts"
|
|
2
3
|
|
|
3
4
|
export { publicEnvDefines } from "./internal/public-env.ts"
|
|
@@ -33,12 +34,14 @@ import {
|
|
|
33
34
|
type BuildTarget,
|
|
34
35
|
type BuildTargetPlan,
|
|
35
36
|
type Bundler,
|
|
37
|
+
buildAssetPaths,
|
|
36
38
|
type ChunkSize,
|
|
37
39
|
detectNodeBuiltinsInClient,
|
|
38
40
|
detectServerOnlyInClient,
|
|
39
41
|
formatNodeBuiltinLeak,
|
|
40
42
|
formatServerOnlyLeak,
|
|
41
43
|
parseBuildTarget,
|
|
44
|
+
parseManifestBuildAssets,
|
|
42
45
|
parseManifestClientEntry,
|
|
43
46
|
parseManifestCopyChunks,
|
|
44
47
|
parseManifestCssLoading,
|
|
@@ -52,6 +55,7 @@ import { sanitizeOutputNames } from "./chunk-names.ts"
|
|
|
52
55
|
import { type CssLoadingMode, normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.ts"
|
|
53
56
|
import { discoverRoutes } from "./fs.ts"
|
|
54
57
|
import { generateClientEntry, generateServerManifest } from "./index.ts"
|
|
58
|
+
import { missingClientEntry } from "./internal/codegen.ts"
|
|
55
59
|
import { loadCopySplit } from "./internal/copy-split.ts"
|
|
56
60
|
import { assertDisposableDir } from "./internal/disposable-dir.ts"
|
|
57
61
|
import { entryChunks } from "./internal/entry-chunks.ts"
|
|
@@ -396,6 +400,7 @@ export function resyncServerManifestSource(
|
|
|
396
400
|
): string {
|
|
397
401
|
const cssLoading = parseManifestCssLoading(source)
|
|
398
402
|
const copyChunks = parseManifestCopyChunks(source)
|
|
403
|
+
const buildAssets = parseManifestBuildAssets(source)
|
|
399
404
|
return generateServerManifest(manifest, {
|
|
400
405
|
resolve: (file) => `${routesPrefix}${file}`,
|
|
401
406
|
clientEntry: parseManifestClientEntry(source) ?? "",
|
|
@@ -403,6 +408,7 @@ export function resyncServerManifestSource(
|
|
|
403
408
|
routeStyles: parseManifestRouteStyles(source),
|
|
404
409
|
...(cssLoading !== undefined ? { cssLoading } : {}),
|
|
405
410
|
...(copyChunks !== undefined ? { copyChunks } : {}),
|
|
411
|
+
...(buildAssets !== undefined ? { buildAssets } : {}),
|
|
406
412
|
lazy: MANIFEST_IS_LAZY.test(source),
|
|
407
413
|
})
|
|
408
414
|
}
|
|
@@ -569,7 +575,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
|
|
|
569
575
|
if (verdict !== undefined) throw new Error(verdict)
|
|
570
576
|
if (refused.size > 0) throw new Error(formatRefusedFiles(refused, root))
|
|
571
577
|
throw new Error(
|
|
572
|
-
`[nifra/web] client build failed:\n${result.logs.map((l) =>
|
|
578
|
+
`[nifra/web] client build failed:\n${result.logs.map((l) => formatBuildLog(l, root)).join("\n")}`,
|
|
573
579
|
)
|
|
574
580
|
}
|
|
575
581
|
|
|
@@ -816,9 +822,16 @@ export interface BuildServerOptions {
|
|
|
816
822
|
readonly serverEntry: string
|
|
817
823
|
/** Output directory for the bundled worker (absolute path). */
|
|
818
824
|
readonly outDir: string
|
|
825
|
+
/**
|
|
826
|
+
* The manifest `buildClient` returned. Supplies {@link clientEntry}, {@link styles},
|
|
827
|
+
* {@link routeStyles}, {@link copyChunks} and {@link buildAssets} at once, so none can be forgotten
|
|
828
|
+
* (a forgotten `styles` ships unstyled pages); an option given explicitly still wins.
|
|
829
|
+
*/
|
|
830
|
+
readonly client?: BuildManifest
|
|
819
831
|
/** The content-hashed client entry URL (from `buildClient`'s manifest) - **baked** into the generated
|
|
820
|
-
* server manifest, since a disk-less worker can't read `manifest.json` at runtime.
|
|
821
|
-
|
|
832
|
+
* server manifest, since a disk-less worker can't read `manifest.json` at runtime. Required unless
|
|
833
|
+
* {@link client} is given. */
|
|
834
|
+
readonly clientEntry?: string
|
|
822
835
|
/** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked into the generated
|
|
823
836
|
* manifest so the server entry hands them to `createWebApp` (→ `<link rel="stylesheet">`). Omit ⇒ no CSS
|
|
824
837
|
* link (the built SSR page would otherwise render unstyled). */
|
|
@@ -827,6 +840,12 @@ export interface BuildServerOptions {
|
|
|
827
840
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
|
|
828
841
|
/** Copy chunk URLs by locale (`buildClient`'s `BuildManifest.copyChunks`) - baked as `copyChunks`. */
|
|
829
842
|
readonly copyChunks?: Readonly<Record<string, string>> | undefined
|
|
843
|
+
/**
|
|
844
|
+
* Every file the build emitted, by request path (`buildClient`'s `assets` plus its `publicFiles`):
|
|
845
|
+
* baked as `buildAssets` and `isBuildAsset(pathname)` for a host policy or a hand-written static
|
|
846
|
+
* handler. Defaults from {@link client}; without either, the manifest exports neither name.
|
|
847
|
+
*/
|
|
848
|
+
readonly buildAssets?: readonly string[] | undefined
|
|
830
849
|
/** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
|
|
831
850
|
readonly cssLoading?: CssLoadingMode
|
|
832
851
|
/** Route/layout file → import specifier in the generated manifest (default: a relative path from the
|
|
@@ -1098,6 +1117,12 @@ function formatRefusedFiles(refused: ReadonlyMap<string, string>, root: string):
|
|
|
1098
1117
|
return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`
|
|
1099
1118
|
}
|
|
1100
1119
|
|
|
1120
|
+
export {
|
|
1121
|
+
checkHostReach,
|
|
1122
|
+
type HostReachOptions,
|
|
1123
|
+
type HostReachReport,
|
|
1124
|
+
type HostReachResult,
|
|
1125
|
+
} from "./host-reach.ts"
|
|
1101
1126
|
export { importGlobPlugin } from "./plugins/import-glob.ts"
|
|
1102
1127
|
export { zoneGuardPlugin } from "./plugins/zone-guard.ts"
|
|
1103
1128
|
|
|
@@ -1153,7 +1178,26 @@ const solidWebServerPlugin = (from: string): BunPlugin => ({
|
|
|
1153
1178
|
* silently ships a broken worker.
|
|
1154
1179
|
*/
|
|
1155
1180
|
export async function buildServer(options: BuildServerOptions): Promise<ServerBuild> {
|
|
1156
|
-
const { routesDir, serverEntry, outDir,
|
|
1181
|
+
const { routesDir, serverEntry, outDir, client } = options
|
|
1182
|
+
if (
|
|
1183
|
+
client !== undefined &&
|
|
1184
|
+
(typeof client !== "object" ||
|
|
1185
|
+
client === null ||
|
|
1186
|
+
typeof client.entry !== "string" ||
|
|
1187
|
+
!Array.isArray(client.assets))
|
|
1188
|
+
) {
|
|
1189
|
+
throw new TypeError(
|
|
1190
|
+
"[nifra/web] buildServer `client` must be the manifest buildClient returned ({ entry, assets, ... })",
|
|
1191
|
+
)
|
|
1192
|
+
}
|
|
1193
|
+
const clientEntry = options.clientEntry ?? client?.entry
|
|
1194
|
+
if (clientEntry === undefined) throw missingClientEntry(clientEntry)
|
|
1195
|
+
const styles = options.styles ?? client?.css
|
|
1196
|
+
const routeStyles = options.routeStyles ?? client?.routeStyles
|
|
1197
|
+
const copyChunks = options.copyChunks ?? client?.copyChunks
|
|
1198
|
+
const cssLoading = options.cssLoading ?? client?.cssLoading
|
|
1199
|
+
const buildAssets =
|
|
1200
|
+
options.buildAssets ?? (client === undefined ? undefined : buildAssetPaths(client))
|
|
1157
1201
|
const entryDir = dirname(serverEntry)
|
|
1158
1202
|
const manifestFile = options.manifestFile ?? "server-manifest.ts"
|
|
1159
1203
|
// Default: import routes relative from the generated manifest (next to serverEntry) to routesDir.
|
|
@@ -1178,7 +1222,8 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1178
1222
|
styles,
|
|
1179
1223
|
routeStyles,
|
|
1180
1224
|
...(cssLoading !== undefined ? { cssLoading } : {}),
|
|
1181
|
-
...(
|
|
1225
|
+
...(copyChunks !== undefined ? { copyChunks } : {}),
|
|
1226
|
+
...(buildAssets !== undefined ? { buildAssets } : {}),
|
|
1182
1227
|
lazy,
|
|
1183
1228
|
}),
|
|
1184
1229
|
)
|
|
@@ -1214,8 +1259,9 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
|
|
|
1214
1259
|
],
|
|
1215
1260
|
})
|
|
1216
1261
|
if (!result.success) {
|
|
1262
|
+
const root = resolvePath(options.root ?? dirname(routesDir))
|
|
1217
1263
|
throw new Error(
|
|
1218
|
-
`[nifra/web] server build failed:\n${result.logs.map((l) =>
|
|
1264
|
+
`[nifra/web] server build failed:\n${result.logs.map((l) => formatBuildLog(l, root)).join("\n")}`,
|
|
1219
1265
|
)
|
|
1220
1266
|
}
|
|
1221
1267
|
// Named as the metafile names outputs: relative to `outdir`.
|
|
@@ -1284,6 +1330,7 @@ export const SERVER_ENTRY_OPTIONS = [
|
|
|
1284
1330
|
"csp",
|
|
1285
1331
|
"nonce",
|
|
1286
1332
|
"i18n",
|
|
1333
|
+
"hostPolicy",
|
|
1287
1334
|
] as const
|
|
1288
1335
|
export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
|
|
1289
1336
|
/** Each importable option mapped to the specifier of the module that exports it. */
|
|
@@ -1308,6 +1355,13 @@ const PLATFORM_CLIENT_IP_HEADERS: Readonly<Partial<Record<BuildTarget, string>>>
|
|
|
1308
1355
|
vercel: "x-real-ip",
|
|
1309
1356
|
}
|
|
1310
1357
|
|
|
1358
|
+
// A static path a host's policy refuses is not answered from disk: it falls through to the app, whose
|
|
1359
|
+
// own copy of the policy refuses it the same way it refuses a page.
|
|
1360
|
+
const HOST_ALLOWS_STATIC =
|
|
1361
|
+
' && hostPolicy.allows(request.headers.get("host") ?? "", pathname, buildAssets)'
|
|
1362
|
+
const NODE_STATIC_ALLOW =
|
|
1363
|
+
" allow: (host: string, pathname: string) => hostPolicy.allows(host, pathname, buildAssets),"
|
|
1364
|
+
|
|
1311
1365
|
/** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
|
|
1312
1366
|
const PRERENDERED_DIR = "_prerendered"
|
|
1313
1367
|
|
|
@@ -1404,11 +1458,18 @@ export function generateServerEntry(options: {
|
|
|
1404
1458
|
if (backendImport !== undefined) {
|
|
1405
1459
|
lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
|
|
1406
1460
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1461
|
+
// A host policy is checked before the static files too, which takes the build's own file set.
|
|
1462
|
+
const hostPolicy = importedOptions.includes("hostPolicy")
|
|
1463
|
+
const manifestImports = [
|
|
1464
|
+
...(hostPolicy ? ["buildAssets"] : []),
|
|
1465
|
+
"clientEntry",
|
|
1466
|
+
...(options.copyChunks === true ? ["copyChunks"] : []),
|
|
1467
|
+
"cssLoading",
|
|
1468
|
+
"manifest",
|
|
1469
|
+
"styles",
|
|
1470
|
+
"routeStyles",
|
|
1471
|
+
]
|
|
1472
|
+
lines.push(`import { ${manifestImports.join(", ")} } from "./server-manifest"`)
|
|
1412
1473
|
// Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
|
|
1413
1474
|
if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
|
|
1414
1475
|
if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
|
|
@@ -1416,7 +1477,7 @@ export function generateServerEntry(options: {
|
|
|
1416
1477
|
if (target === "deno") {
|
|
1417
1478
|
lines.push(
|
|
1418
1479
|
'import { parseByteRange } from "@nifrajs/core/range"',
|
|
1419
|
-
'import { contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf } from "@nifrajs/core/static-file"',
|
|
1480
|
+
'import { CONTENT_SNAPSHOT_MAX, contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf, streamedLastModifiedOf } from "@nifrajs/core/static-file"',
|
|
1420
1481
|
'import { createHash } from "node:crypto"',
|
|
1421
1482
|
)
|
|
1422
1483
|
}
|
|
@@ -1442,6 +1503,7 @@ export function generateServerEntry(options: {
|
|
|
1442
1503
|
...(useImport !== undefined ? [" use,"] : []),
|
|
1443
1504
|
...(prerendered.length > 0 ? [" prerenderedPages,"] : []),
|
|
1444
1505
|
...importedOptions.map((name) => ` ${name},`),
|
|
1506
|
+
...(hostPolicy ? [" buildAssets,"] : []),
|
|
1445
1507
|
" manifest,",
|
|
1446
1508
|
" clientEntry,",
|
|
1447
1509
|
" styles,",
|
|
@@ -1474,7 +1536,7 @@ export function generateServerEntry(options: {
|
|
|
1474
1536
|
" fetch(request: Request, env: Assets, ctx: ExecutionContext) {",
|
|
1475
1537
|
" const { pathname } = new URL(request.url)",
|
|
1476
1538
|
" // Only the build's own outputs: the hashed bundle prefix and the files it copied.",
|
|
1477
|
-
|
|
1539
|
+
` if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))${hostPolicy ? HOST_ALLOWS_STATIC : ""}) {`,
|
|
1478
1540
|
" return env.ASSETS.fetch(request)",
|
|
1479
1541
|
" }",
|
|
1480
1542
|
" return handler.fetch(request, env as never, ctx)",
|
|
@@ -1514,6 +1576,7 @@ export function generateServerEntry(options: {
|
|
|
1514
1576
|
' prefix: "/assets",',
|
|
1515
1577
|
...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
|
|
1516
1578
|
` cacheControl: ${JSON.stringify(ONE_DAY)},`,
|
|
1579
|
+
...(hostPolicy ? [NODE_STATIC_ALLOW] : []),
|
|
1517
1580
|
" },",
|
|
1518
1581
|
" {",
|
|
1519
1582
|
" dir: fileURLToPath(STATIC_ROOT),",
|
|
@@ -1521,6 +1584,7 @@ export function generateServerEntry(options: {
|
|
|
1521
1584
|
" files: PUBLIC_FILES,",
|
|
1522
1585
|
" immutable: false,",
|
|
1523
1586
|
` cacheControl: ${JSON.stringify(ONE_DAY)},`,
|
|
1587
|
+
...(hostPolicy ? [NODE_STATIC_ALLOW] : []),
|
|
1524
1588
|
" },",
|
|
1525
1589
|
" ],",
|
|
1526
1590
|
"})",
|
|
@@ -1564,7 +1628,7 @@ export function generateServerEntry(options: {
|
|
|
1564
1628
|
" async fetch(req, server) {",
|
|
1565
1629
|
" const { pathname } = new URL(req.url)",
|
|
1566
1630
|
// Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
|
|
1567
|
-
|
|
1631
|
+
` if (staticPath(pathname) !== undefined${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`,
|
|
1568
1632
|
" return staticResponse(req)",
|
|
1569
1633
|
" }",
|
|
1570
1634
|
` ${markFramed}`,
|
|
@@ -1584,7 +1648,7 @@ export function generateServerEntry(options: {
|
|
|
1584
1648
|
'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {',
|
|
1585
1649
|
" const { pathname } = new URL(req.url)",
|
|
1586
1650
|
" const filePath = staticPath(pathname)",
|
|
1587
|
-
|
|
1651
|
+
` if (filePath !== undefined${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`,
|
|
1588
1652
|
" return staticResponse(req, filePath, pathname)",
|
|
1589
1653
|
" }",
|
|
1590
1654
|
` ${markFramed}`,
|
|
@@ -1615,9 +1679,10 @@ function denoStaticResponse(hashed: boolean): string[] {
|
|
|
1615
1679
|
"// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1616
1680
|
"const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
|
|
1617
1681
|
"// One per process: the tag of a file is remembered by its real path.",
|
|
1618
|
-
"
|
|
1682
|
+
"// A stat costs Deno more than hashing a snapshot of up to 24 KiB, so only a larger one is restatted.",
|
|
1683
|
+
"const STATIC_TAGS = contentTags(createHash, { rehashMax: 24 * 1024 })",
|
|
1619
1684
|
"// Up to this many bytes are read whole to tag a file, and the response is then served from them.",
|
|
1620
|
-
"const STATIC_INLINE_MAX =
|
|
1685
|
+
"const STATIC_INLINE_MAX = CONTENT_SNAPSHOT_MAX",
|
|
1621
1686
|
"const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
|
|
1622
1687
|
' const missing = () => new Response("not found", { status: 404 })',
|
|
1623
1688
|
' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
|
|
@@ -1652,19 +1717,24 @@ function denoStaticResponse(hashed: boolean): string[] {
|
|
|
1652
1717
|
" const size: number = info.size",
|
|
1653
1718
|
" const headers = new Headers(staticHeaders(pathname, real))",
|
|
1654
1719
|
" // Deno reports a field the platform does not keep as null; NaN never matches a remembered version.",
|
|
1655
|
-
" const
|
|
1656
|
-
" dev:
|
|
1657
|
-
" ino:
|
|
1658
|
-
" size,",
|
|
1659
|
-
" mtimeMs:
|
|
1660
|
-
" ctimeMs:
|
|
1661
|
-
" }",
|
|
1720
|
+
" const versionOf = (stat: { dev: number; ino: number | null; size: number; mtime: Date | null; ctime: Date | null }) => ({",
|
|
1721
|
+
" dev: stat.dev,",
|
|
1722
|
+
" ino: stat.ino ?? Number.NaN,",
|
|
1723
|
+
" size: stat.size,",
|
|
1724
|
+
" mtimeMs: stat.mtime?.getTime() ?? Number.NaN,",
|
|
1725
|
+
" ctimeMs: stat.ctime?.getTime() ?? Number.NaN,",
|
|
1726
|
+
" })",
|
|
1727
|
+
" const version = versionOf(info)",
|
|
1728
|
+
" const snapshot = size <= STATIC_INLINE_MAX",
|
|
1729
|
+
" const lastModified = snapshot ? lastModifiedOf(version) : streamedLastModifiedOf(version)",
|
|
1730
|
+
' const ifNoneMatch = req.headers.get("if-none-match")',
|
|
1731
|
+
' const ifModifiedSince = req.headers.get("if-modified-since")',
|
|
1662
1732
|
" let whole: Uint8Array<ArrayBuffer> | undefined",
|
|
1663
|
-
"
|
|
1664
|
-
"
|
|
1733
|
+
" // A HEAD or a 304 sends none of the file, so the tag remembered for this settled version",
|
|
1734
|
+
" // answers it from the stat alone: a write since that tag would show in the stat.",
|
|
1735
|
+
" let etag: string | undefined = snapshot ? STATIC_TAGS.cached(real, version) : undefined",
|
|
1665
1736
|
" try {",
|
|
1666
|
-
|
|
1667
|
-
" if (etag === undefined && size <= STATIC_INLINE_MAX) {",
|
|
1737
|
+
' if (snapshot && (etag === undefined || !(req.method === "HEAD" || isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)))) {',
|
|
1668
1738
|
" whole = new Uint8Array(size)",
|
|
1669
1739
|
" let filled = 0",
|
|
1670
1740
|
" while (filled < size) {",
|
|
@@ -1673,29 +1743,23 @@ function denoStaticResponse(hashed: boolean): string[] {
|
|
|
1673
1743
|
" filled += read",
|
|
1674
1744
|
" }",
|
|
1675
1745
|
" if (filled < size) whole = whole.subarray(0, filled)",
|
|
1676
|
-
" etag = STATIC_TAGS.
|
|
1746
|
+
" etag = await STATIC_TAGS.ofSnapshot(real, version, whole, async () => versionOf(await file.stat()))",
|
|
1677
1747
|
" }",
|
|
1678
|
-
" etag ??= await STATIC_TAGS.ofFile(real, version, async (bytes: Uint8Array, position: number) => {",
|
|
1679
|
-
" moved = true",
|
|
1680
|
-
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1681
|
-
" await file.seek(position, Deno.SeekMode.Start)",
|
|
1682
|
-
" return (await file.read(bytes)) ?? 0",
|
|
1683
|
-
" })",
|
|
1684
1748
|
" } catch {",
|
|
1685
1749
|
" file.close()",
|
|
1686
1750
|
" return missing()",
|
|
1687
1751
|
" }",
|
|
1688
1752
|
' if (etag !== undefined) headers.set("etag", etag)',
|
|
1689
|
-
" const lastModified = lastModifiedOf(version)",
|
|
1690
1753
|
' if (lastModified !== undefined) headers.set("last-modified", new Date(lastModified).toUTCString())',
|
|
1691
|
-
|
|
1754
|
+
" if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {",
|
|
1692
1755
|
" file.close()",
|
|
1693
1756
|
' headers.delete("content-type")',
|
|
1694
1757
|
" return new Response(null, { status: 304, headers })",
|
|
1695
1758
|
" }",
|
|
1696
1759
|
' const rangeHeader = req.method === "GET" ? req.headers.get("range") : null',
|
|
1697
1760
|
" const range =",
|
|
1698
|
-
'
|
|
1761
|
+
" // A streamed body's date is weak: a write during the stream must never splice two versions.",
|
|
1762
|
+
' rangeHeader !== null && ifRangeAllowsPartial(req.headers.get("if-range"), etag, snapshot ? lastModified : undefined)',
|
|
1699
1763
|
" ? parseByteRange(rangeHeader, size)",
|
|
1700
1764
|
' : { kind: "none" as const }',
|
|
1701
1765
|
' if (req.method === "HEAD" || range.kind === "unsatisfiable") {',
|
|
@@ -1746,8 +1810,6 @@ function denoStaticResponse(hashed: boolean): string[] {
|
|
|
1746
1810
|
' headers.set("content-length", String(whole.byteLength))',
|
|
1747
1811
|
" return new Response(whole, { headers })",
|
|
1748
1812
|
" }",
|
|
1749
|
-
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1750
|
-
" if (moved) await file.seek(0, Deno.SeekMode.Start)",
|
|
1751
1813
|
' headers.set("content-length", String(size))',
|
|
1752
1814
|
" return new Response(file.readable, { headers })",
|
|
1753
1815
|
"}",
|
|
@@ -1899,6 +1961,7 @@ export const bunBundler: Bundler = {
|
|
|
1899
1961
|
...(input.styles !== undefined ? { styles: input.styles } : {}),
|
|
1900
1962
|
...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
|
|
1901
1963
|
...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
|
|
1964
|
+
...(input.buildAssets !== undefined ? { buildAssets: input.buildAssets } : {}),
|
|
1902
1965
|
...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
|
|
1903
1966
|
...(input.define ? { define: input.define } : {}),
|
|
1904
1967
|
...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
|
|
@@ -2157,6 +2220,7 @@ export async function buildTargetWith(
|
|
|
2157
2220
|
...(client.css !== undefined ? { styles: client.css } : {}),
|
|
2158
2221
|
...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
|
|
2159
2222
|
...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
|
|
2223
|
+
buildAssets: buildAssetPaths(client),
|
|
2160
2224
|
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
2161
2225
|
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
2162
2226
|
...(client.cssLoading !== undefined
|
|
@@ -2175,10 +2239,16 @@ export async function buildTargetWith(
|
|
|
2175
2239
|
cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
|
|
2176
2240
|
// The app's real patterns, so a directory is only collapsed into a glob once the route table
|
|
2177
2241
|
// proves nothing can be served beneath it.
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2242
|
+
// A host policy must see every file request, which Pages answers from the CDN before the worker
|
|
2243
|
+
// when the path is excluded, so with one nothing is: each file costs an invocation, and the
|
|
2244
|
+
// worker serves the allowed ones from ASSETS.
|
|
2245
|
+
const rules =
|
|
2246
|
+
options.optionImports?.hostPolicy === undefined
|
|
2247
|
+
? cloudflareRouteRules(
|
|
2248
|
+
publicFiles,
|
|
2249
|
+
discoverRoutes(routesDir).routes.map((route) => route.pattern),
|
|
2250
|
+
)
|
|
2251
|
+
: { include: ["/*"], exclude: [], omitted: [] }
|
|
2182
2252
|
writeFileSync(
|
|
2183
2253
|
`${outDir}/_routes.json`,
|
|
2184
2254
|
`${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`,
|
package/src/deferred.ts
CHANGED
|
@@ -47,6 +47,20 @@ function isDeferred(value: unknown): value is Deferred<unknown> {
|
|
|
47
47
|
)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** `target[key] = value` runs the prototype setter for a `__proto__` key; define it as data instead. */
|
|
51
|
+
function setOwn(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
52
|
+
if (key === "__proto__") {
|
|
53
|
+
Object.defineProperty(target, key, {
|
|
54
|
+
value,
|
|
55
|
+
enumerable: true,
|
|
56
|
+
writable: true,
|
|
57
|
+
configurable: true,
|
|
58
|
+
})
|
|
59
|
+
} else {
|
|
60
|
+
target[key] = value
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
/**
|
|
51
65
|
* Split a loader (or action) result into the **component-facing** data (deferred values carry their
|
|
52
66
|
* assigned `id` + promise, for `<Await>`) and the **client-serializable** data (deferred values
|
|
@@ -113,8 +127,8 @@ export function prepareDeferred(
|
|
|
113
127
|
const r = split(v)
|
|
114
128
|
if (r === undefined) {
|
|
115
129
|
if (fc !== undefined && cl !== undefined) {
|
|
116
|
-
fc
|
|
117
|
-
cl
|
|
130
|
+
setOwn(fc, k, v)
|
|
131
|
+
setOwn(cl, k, v)
|
|
118
132
|
}
|
|
119
133
|
continue
|
|
120
134
|
}
|
|
@@ -125,12 +139,12 @@ export function prepareDeferred(
|
|
|
125
139
|
if (!Object.hasOwn(input, prev)) continue
|
|
126
140
|
if (prev === k) break
|
|
127
141
|
const prevValue = input[prev]
|
|
128
|
-
fc
|
|
129
|
-
cl
|
|
142
|
+
setOwn(fc, prev, prevValue)
|
|
143
|
+
setOwn(cl, prev, prevValue)
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
|
-
fc
|
|
133
|
-
cl
|
|
146
|
+
setOwn(fc, k, r.fc)
|
|
147
|
+
setOwn(cl, k, r.cl)
|
|
134
148
|
}
|
|
135
149
|
return fc === undefined || cl === undefined ? undefined : { fc, cl }
|
|
136
150
|
}
|
package/src/dev.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
isLoopbackHost,
|
|
66
66
|
} from "./dev-session.ts"
|
|
67
67
|
import { createSsrGraph, type SsrGraph } from "./dev-ssr-graph.ts"
|
|
68
|
+
import { formatBuildLog } from "./diagnostic.ts"
|
|
68
69
|
import { discoverRoutes } from "./fs.ts"
|
|
69
70
|
import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
|
|
70
71
|
import { DEV_ENTRY_FILE } from "./internal/dev-reserved.ts"
|
|
@@ -634,11 +635,6 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
634
635
|
* Overlapping runs are collapsed - a save during a bundle queues exactly one re-run, so a burst of edits
|
|
635
636
|
* cannot pile up builds behind each other.
|
|
636
637
|
*/
|
|
637
|
-
/**
|
|
638
|
-
* Format a client-build rejection for the guard's report. Bun.build rejects with an AggregateError
|
|
639
|
-
* whose message is just "Bundle failed" - the actionable part (which file, which import) lives in
|
|
640
|
-
* `.errors`. Surface it, or the guard reports a failure while hiding the reason.
|
|
641
|
-
*/
|
|
642
638
|
/** A Host header's name: lowercased, without its port or IPv6 brackets. */
|
|
643
639
|
function hostName(host: string): string {
|
|
644
640
|
const name = host.trim().toLowerCase()
|
|
@@ -648,9 +644,14 @@ function hostName(host: string): string {
|
|
|
648
644
|
return colon === -1 ? name : name.slice(0, colon)
|
|
649
645
|
}
|
|
650
646
|
|
|
647
|
+
/**
|
|
648
|
+
* Format a client-build rejection for the guard's report. Bun.build rejects with an AggregateError
|
|
649
|
+
* whose message is just "Bundle failed" - the actionable part (which file, which import) lives in
|
|
650
|
+
* `.errors`. Surface it, or the guard reports a failure while hiding the reason.
|
|
651
|
+
*/
|
|
651
652
|
export function buildFailureDetail(err: unknown): string {
|
|
652
653
|
return err instanceof AggregateError && err.errors.length > 0
|
|
653
|
-
? err.errors.map((e) => ` ${e
|
|
654
|
+
? err.errors.map((e) => ` ${formatBuildLog(e).replaceAll("\n", "\n ")}`).join("\n")
|
|
654
655
|
: ` ${err instanceof Error ? err.message : String(err)}`
|
|
655
656
|
}
|
|
656
657
|
|
package/src/diagnostic.ts
CHANGED
|
@@ -34,6 +34,49 @@ export {
|
|
|
34
34
|
/** Shared endpoint name used by both dev pipelines and the agent-facing MCP tools. */
|
|
35
35
|
export const LAST_ERROR_PATH = "/__nifra/last-error"
|
|
36
36
|
|
|
37
|
+
/** `file` relative to `root` when it sits inside it (the bundler reports real paths, so a symlinked
|
|
38
|
+
* root is compared by its real path too), else `file` as given. */
|
|
39
|
+
function pathUnder(root: string, file: string): string {
|
|
40
|
+
const inside = (base: string): string | undefined => {
|
|
41
|
+
const rel = relative(base, file).replaceAll("\\", "/")
|
|
42
|
+
return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? undefined : rel
|
|
43
|
+
}
|
|
44
|
+
let real: string | undefined
|
|
45
|
+
try {
|
|
46
|
+
real = realpathSync(root)
|
|
47
|
+
} catch {
|
|
48
|
+
real = undefined
|
|
49
|
+
}
|
|
50
|
+
return inside(root) ?? (real === undefined ? undefined : inside(real)) ?? file
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One bundler log as `file:line:column: message`, the source line it points at underneath. Bun's
|
|
55
|
+
* `BuildMessage`/`ResolveMessage` carry the place in `position`, which their `String()` drops: a
|
|
56
|
+
* failed build said "Could not resolve: ./db" without naming the module that imported it.
|
|
57
|
+
*/
|
|
58
|
+
export function formatBuildLog(log: unknown, root: string = process.cwd()): string {
|
|
59
|
+
const message =
|
|
60
|
+
typeof log === "object" && log !== null && "message" in log && typeof log.message === "string"
|
|
61
|
+
? log.message
|
|
62
|
+
: String(log)
|
|
63
|
+
const position =
|
|
64
|
+
typeof log === "object" && log !== null && "position" in log ? log.position : undefined
|
|
65
|
+
if (typeof position !== "object" || position === null || !("file" in position)) return message
|
|
66
|
+
const file = typeof position.file === "string" ? position.file : ""
|
|
67
|
+
if (file === "") return message
|
|
68
|
+
const where = [
|
|
69
|
+
pathUnder(root, file),
|
|
70
|
+
"line" in position && typeof position.line === "number" ? position.line : undefined,
|
|
71
|
+
"column" in position && typeof position.column === "number" ? position.column : undefined,
|
|
72
|
+
]
|
|
73
|
+
.filter((part) => part !== undefined)
|
|
74
|
+
.join(":")
|
|
75
|
+
const text =
|
|
76
|
+
"lineText" in position && typeof position.lineText === "string" ? position.lineText.trim() : ""
|
|
77
|
+
return text === "" ? `${where}: ${message}` : `${where}: ${message}\n ${text}`
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
/** One parsed stack frame. `file`/`line`/`column` are present only when the frame could be located. */
|
|
38
81
|
export interface DiagnosticFrame {
|
|
39
82
|
readonly raw: string
|