@nifrajs/web 5.3.0 → 5.4.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 +40 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +120 -41
- package/dist/build.js.map +1 -1
- package/dist/internal/node-adapter.d.ts +19 -0
- package/dist/internal/node-adapter.d.ts.map +1 -0
- package/dist/internal/node-adapter.js +48 -0
- package/dist/internal/node-adapter.js.map +1 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +3 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/public-url.d.ts +0 -7
- package/dist/internal/public-url.d.ts.map +1 -1
- package/dist/internal/public-url.js +0 -13
- package/dist/internal/public-url.js.map +1 -1
- package/dist/public-dir.d.ts +4 -7
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +47 -95
- package/dist/public-dir.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -3
- package/dist/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +140 -50
- package/src/internal/node-adapter.ts +57 -0
- package/src/internal/parity.ts +3 -2
- package/src/internal/public-url.ts +0 -13
- package/src/public-dir.ts +62 -96
- package/src/vite.ts +3 -3
package/src/build.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
singleCopyPlugin as declaredSingleCopyPlugin,
|
|
26
26
|
readSingleCopyDeclaration,
|
|
27
27
|
} from "@nifrajs/core/single-copy"
|
|
28
|
+
import { isHiddenPath } from "@nifrajs/core/static-file"
|
|
28
29
|
import { type BunPlugin, Glob } from "bun"
|
|
29
30
|
import {
|
|
30
31
|
aggregateSizeReport,
|
|
@@ -55,6 +56,7 @@ import { loadCopySplit } from "./internal/copy-split.ts"
|
|
|
55
56
|
import { assertDisposableDir } from "./internal/disposable-dir.ts"
|
|
56
57
|
import { entryChunks } from "./internal/entry-chunks.ts"
|
|
57
58
|
import { dedupePolicyFor } from "./internal/identity-policy.ts"
|
|
59
|
+
import { nodeAdapterRefusal } from "./internal/node-adapter.ts"
|
|
58
60
|
import {
|
|
59
61
|
assertDevelopmentProductionParity,
|
|
60
62
|
assertIdentityParity,
|
|
@@ -65,7 +67,7 @@ import {
|
|
|
65
67
|
servablePrerenderedPages,
|
|
66
68
|
} from "./internal/prerendered-pages.ts"
|
|
67
69
|
import { privateEnvCheck } from "./internal/private-env.ts"
|
|
68
|
-
import {
|
|
70
|
+
import { publicUrlPath } from "./internal/public-url.ts"
|
|
69
71
|
import {
|
|
70
72
|
assertNoSecrets,
|
|
71
73
|
buildEnvironment,
|
|
@@ -100,7 +102,7 @@ import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.ts"
|
|
|
100
102
|
import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
|
|
101
103
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
102
104
|
import { type PrerenderOptions, prerenderRoutes } from "./prerender.ts"
|
|
103
|
-
import {
|
|
105
|
+
import { IMMUTABLE, ONE_DAY } from "./public-dir.ts"
|
|
104
106
|
import { createZoneClassifier } from "./zones.ts"
|
|
105
107
|
|
|
106
108
|
export * from "./build-plan.ts"
|
|
@@ -360,7 +362,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
|
|
|
360
362
|
const sourceReal = await realpath(source)
|
|
361
363
|
const copied: string[] = []
|
|
362
364
|
for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
|
|
363
|
-
if (
|
|
365
|
+
if (isHiddenPath(rel)) continue
|
|
364
366
|
const candidate = join(source, rel)
|
|
365
367
|
const candidateStat = await lstat(candidate)
|
|
366
368
|
if (candidateStat.isSymbolicLink()) {
|
|
@@ -1379,8 +1381,9 @@ export function generateServerEntry(options: {
|
|
|
1379
1381
|
if (target === "static") {
|
|
1380
1382
|
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
|
|
1381
1383
|
}
|
|
1382
|
-
// Bun
|
|
1383
|
-
//
|
|
1384
|
+
// Bun serves the client bundle through `servePublicDir` and Node through @nifrajs/node's own reader,
|
|
1385
|
+
// both on core's static-file policy. Deno bundles for the browser condition, where `node:fs` is a
|
|
1386
|
+
// stub, so its entry runs the same checks on Deno's own file API.
|
|
1384
1387
|
const nodeFs = target === "bun" || target === "node"
|
|
1385
1388
|
const prerendered = Object.entries(options.prerenderedPages ?? {})
|
|
1386
1389
|
if (prerendered.length > 0 && !nodeFs) {
|
|
@@ -1388,13 +1391,10 @@ export function generateServerEntry(options: {
|
|
|
1388
1391
|
`[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`,
|
|
1389
1392
|
)
|
|
1390
1393
|
}
|
|
1391
|
-
const
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
? 'import { createWebApp, prerenderedPageReader, servePublicDir } from "@nifrajs/web"'
|
|
1396
|
-
: 'import { createWebApp, servePublicDir } from "@nifrajs/web"',
|
|
1397
|
-
]
|
|
1394
|
+
const webImports = ["createWebApp"]
|
|
1395
|
+
if (prerendered.length > 0) webImports.push("prerenderedPageReader")
|
|
1396
|
+
if (target === "bun") webImports.push("servePublicDir")
|
|
1397
|
+
const lines: string[] = [`import { ${webImports.join(", ")} } from "@nifrajs/web"`]
|
|
1398
1398
|
if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
|
|
1399
1399
|
lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
|
|
1400
1400
|
if (useImport !== undefined) lines.push(`import { use } from ${JSON.stringify(useImport)}`)
|
|
@@ -1413,7 +1413,13 @@ export function generateServerEntry(options: {
|
|
|
1413
1413
|
if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
|
|
1414
1414
|
if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
|
|
1415
1415
|
if (nodeFs) lines.push('import { fileURLToPath } from "node:url"')
|
|
1416
|
-
if (target === "deno")
|
|
1416
|
+
if (target === "deno") {
|
|
1417
|
+
lines.push(
|
|
1418
|
+
'import { parseByteRange } from "@nifrajs/core/range"',
|
|
1419
|
+
'import { contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf } from "@nifrajs/core/static-file"',
|
|
1420
|
+
'import { createHash } from "node:crypto"',
|
|
1421
|
+
)
|
|
1422
|
+
}
|
|
1417
1423
|
if (prerendered.length > 0) {
|
|
1418
1424
|
const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]))
|
|
1419
1425
|
const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]))
|
|
@@ -1494,6 +1500,34 @@ export function generateServerEntry(options: {
|
|
|
1494
1500
|
...(hashedFiles !== undefined
|
|
1495
1501
|
? [`const HASHED_FILES = new Set(${JSON.stringify(hashedFiles)})`]
|
|
1496
1502
|
: []),
|
|
1503
|
+
)
|
|
1504
|
+
if (target === "node") {
|
|
1505
|
+
// The app itself with the adapter's own reader, not a `{ fetch }` wrapper around app.fetch: the
|
|
1506
|
+
// wrapper hid the node-direct lane, so every page built an undici Request and drained a Response,
|
|
1507
|
+
// and every file went through one too. The root mount serves only the files the build listed.
|
|
1508
|
+
lines.push(
|
|
1509
|
+
"await serve(app, {",
|
|
1510
|
+
" port: Number(process.env.PORT ?? 3000),",
|
|
1511
|
+
" static: [",
|
|
1512
|
+
" {",
|
|
1513
|
+
' dir: fileURLToPath(new URL("./assets/", import.meta.url)),',
|
|
1514
|
+
' prefix: "/assets",',
|
|
1515
|
+
...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
|
|
1516
|
+
` cacheControl: ${JSON.stringify(ONE_DAY)},`,
|
|
1517
|
+
" },",
|
|
1518
|
+
" {",
|
|
1519
|
+
" dir: fileURLToPath(STATIC_ROOT),",
|
|
1520
|
+
' prefix: "/",',
|
|
1521
|
+
" files: PUBLIC_FILES,",
|
|
1522
|
+
" immutable: false,",
|
|
1523
|
+
` cacheControl: ${JSON.stringify(ONE_DAY)},`,
|
|
1524
|
+
" },",
|
|
1525
|
+
" ],",
|
|
1526
|
+
"})",
|
|
1527
|
+
)
|
|
1528
|
+
return `${lines.join("\n")}\n`
|
|
1529
|
+
}
|
|
1530
|
+
lines.push(
|
|
1497
1531
|
"const staticPath = (pathname: string): string | undefined => {",
|
|
1498
1532
|
" if (PUBLIC_FILES.has(pathname)) return '.' + pathname",
|
|
1499
1533
|
' if (!pathname.startsWith("/assets/")) return undefined',
|
|
@@ -1505,11 +1539,10 @@ export function generateServerEntry(options: {
|
|
|
1505
1539
|
' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
|
|
1506
1540
|
" return segments.every(safe) ? '.' + pathname : undefined",
|
|
1507
1541
|
"}",
|
|
1508
|
-
// The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy,
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
|
|
1512
|
-
...(nodeFs
|
|
1542
|
+
// The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, content
|
|
1543
|
+
// tags, byte ranges and conditional requests, and a descriptor opened only once the real path is
|
|
1544
|
+
// inside the root, so a link planted in the deployed tree cannot reach a file outside it.
|
|
1545
|
+
...(target === "bun"
|
|
1513
1546
|
? [
|
|
1514
1547
|
"const serveStatic = servePublicDir({",
|
|
1515
1548
|
" dir: fileURLToPath(STATIC_ROOT),",
|
|
@@ -1545,21 +1578,6 @@ export function generateServerEntry(options: {
|
|
|
1545
1578
|
)
|
|
1546
1579
|
return `${lines.join("\n")}\n`
|
|
1547
1580
|
}
|
|
1548
|
-
if (target === "node") {
|
|
1549
|
-
lines.push(
|
|
1550
|
-
"await serve(",
|
|
1551
|
-
" {",
|
|
1552
|
-
" async fetch(req, platform) {",
|
|
1553
|
-
" const { pathname } = new URL(req.url)",
|
|
1554
|
-
" if (staticPath(pathname) !== undefined) return staticResponse(req)",
|
|
1555
|
-
" return app.fetch(req, platform)",
|
|
1556
|
-
" },",
|
|
1557
|
-
" },",
|
|
1558
|
-
" { port: Number(process.env.PORT ?? 3000) },",
|
|
1559
|
-
")",
|
|
1560
|
-
)
|
|
1561
|
-
return `${lines.join("\n")}\n`
|
|
1562
|
-
}
|
|
1563
1581
|
// deno
|
|
1564
1582
|
lines.push(
|
|
1565
1583
|
"// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
@@ -1577,34 +1595,39 @@ export function generateServerEntry(options: {
|
|
|
1577
1595
|
}
|
|
1578
1596
|
|
|
1579
1597
|
/**
|
|
1580
|
-
* The Deno entry's static handler: `servePublicDir`'s checks on Deno's file API. The real
|
|
1581
|
-
* inside the root and cross no hidden segment, and the opened file must still be the one
|
|
1582
|
-
* names, so a link planted in the deployed tree cannot reach a file outside it or a `.env`
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1598
|
+
* The Deno entry's static handler: `servePublicDir`'s checks and answers on Deno's file API. The real
|
|
1599
|
+
* path must sit inside the root and cross no hidden segment, and the opened file must still be the one
|
|
1600
|
+
* that path names, so a link planted in the deployed tree cannot reach a file outside it or a `.env`
|
|
1601
|
+
* inside it. The file is typed by its real path, tagged by its content and dated only by an mtime its
|
|
1602
|
+
* own last write set, through the same policy as servePublicDir: a current cached copy is a 304, and a
|
|
1603
|
+
* range survives only an `If-Range` naming that tag. A file is read once to tag it, and one small
|
|
1604
|
+
* enough is then served from those bytes; otherwise one byte range is served from a seek, several as
|
|
1605
|
+
* the whole file.
|
|
1585
1606
|
*/
|
|
1586
1607
|
function denoStaticResponse(hashed: boolean): string[] {
|
|
1587
1608
|
return [
|
|
1588
|
-
|
|
1589
|
-
"
|
|
1590
|
-
' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
|
|
1609
|
+
"const staticHeaders = (pathname: string, file: string): Record<string, string> => ({",
|
|
1610
|
+
' "content-type": contentTypeOf(file),',
|
|
1591
1611
|
` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
|
|
1592
1612
|
' "x-content-type-options": "nosniff",',
|
|
1593
1613
|
' "accept-ranges": "bytes",',
|
|
1594
1614
|
"})",
|
|
1595
1615
|
"// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1596
1616
|
"const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
|
|
1597
|
-
"
|
|
1598
|
-
|
|
1617
|
+
"// One per process: the tag of a file is remembered by its real path.",
|
|
1618
|
+
"const STATIC_TAGS = contentTags(createHash)",
|
|
1619
|
+
"// 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 = 256 * 1024",
|
|
1599
1621
|
"const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
|
|
1600
1622
|
' const missing = () => new Response("not found", { status: 404 })',
|
|
1601
1623
|
' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
|
|
1602
1624
|
" let file, info",
|
|
1625
|
+
' let real = ""',
|
|
1603
1626
|
" try {",
|
|
1604
1627
|
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1605
|
-
"
|
|
1628
|
+
" real = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
|
|
1606
1629
|
' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
|
|
1607
|
-
" if (
|
|
1630
|
+
" if (isHiddenPath(real.slice(STATIC_REAL.length + 1))) return missing()",
|
|
1608
1631
|
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1609
1632
|
" file = await Deno.open(real)",
|
|
1610
1633
|
" // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
|
|
@@ -1622,12 +1645,61 @@ function denoStaticResponse(hashed: boolean): string[] {
|
|
|
1622
1645
|
" file?.close()",
|
|
1623
1646
|
" return missing()",
|
|
1624
1647
|
" }",
|
|
1648
|
+
" if (!info.isFile) {",
|
|
1649
|
+
" file.close()",
|
|
1650
|
+
" return missing()",
|
|
1651
|
+
" }",
|
|
1625
1652
|
" const size: number = info.size",
|
|
1626
|
-
" const headers = new Headers(staticHeaders(pathname))",
|
|
1627
|
-
|
|
1628
|
-
|
|
1653
|
+
" const headers = new Headers(staticHeaders(pathname, real))",
|
|
1654
|
+
" // Deno reports a field the platform does not keep as null; NaN never matches a remembered version.",
|
|
1655
|
+
" const version = {",
|
|
1656
|
+
" dev: info.dev,",
|
|
1657
|
+
" ino: info.ino ?? Number.NaN,",
|
|
1658
|
+
" size,",
|
|
1659
|
+
" mtimeMs: info.mtime?.getTime() ?? Number.NaN,",
|
|
1660
|
+
" ctimeMs: info.ctime?.getTime() ?? Number.NaN,",
|
|
1661
|
+
" }",
|
|
1662
|
+
" let whole: Uint8Array<ArrayBuffer> | undefined",
|
|
1663
|
+
" let moved = false",
|
|
1664
|
+
" let etag: string | undefined",
|
|
1665
|
+
" try {",
|
|
1666
|
+
" etag = STATIC_TAGS.cached(real, version)",
|
|
1667
|
+
" if (etag === undefined && size <= STATIC_INLINE_MAX) {",
|
|
1668
|
+
" whole = new Uint8Array(size)",
|
|
1669
|
+
" let filled = 0",
|
|
1670
|
+
" while (filled < size) {",
|
|
1671
|
+
" const read: number | null = await file.read(whole.subarray(filled))",
|
|
1672
|
+
" if (read === null || read === 0) break",
|
|
1673
|
+
" filled += read",
|
|
1674
|
+
" }",
|
|
1675
|
+
" if (filled < size) whole = whole.subarray(0, filled)",
|
|
1676
|
+
" etag = STATIC_TAGS.ofBytes(real, version, whole)",
|
|
1677
|
+
" }",
|
|
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
|
+
" } catch {",
|
|
1685
|
+
" file.close()",
|
|
1686
|
+
" return missing()",
|
|
1687
|
+
" }",
|
|
1688
|
+
' if (etag !== undefined) headers.set("etag", etag)',
|
|
1689
|
+
" const lastModified = lastModifiedOf(version)",
|
|
1690
|
+
' if (lastModified !== undefined) headers.set("last-modified", new Date(lastModified).toUTCString())',
|
|
1691
|
+
' if (isNotModified(req.headers.get("if-none-match"), req.headers.get("if-modified-since"), etag, lastModified)) {',
|
|
1692
|
+
" file.close()",
|
|
1693
|
+
' headers.delete("content-type")',
|
|
1694
|
+
" return new Response(null, { status: 304, headers })",
|
|
1695
|
+
" }",
|
|
1696
|
+
' const rangeHeader = req.method === "GET" ? req.headers.get("range") : null',
|
|
1697
|
+
" const range =",
|
|
1698
|
+
' rangeHeader !== null && ifRangeAllowsPartial(req.headers.get("if-range"), etag, lastModified)',
|
|
1699
|
+
" ? parseByteRange(rangeHeader, size)",
|
|
1700
|
+
' : { kind: "none" as const }',
|
|
1701
|
+
' if (req.method === "HEAD" || range.kind === "unsatisfiable") {',
|
|
1629
1702
|
" file.close()",
|
|
1630
|
-
" if (!info.isFile) return missing()",
|
|
1631
1703
|
' if (range.kind !== "unsatisfiable") {',
|
|
1632
1704
|
' headers.set("content-length", String(size))',
|
|
1633
1705
|
" return new Response(null, { headers })",
|
|
@@ -1638,10 +1710,16 @@ function denoStaticResponse(hashed: boolean): string[] {
|
|
|
1638
1710
|
" }",
|
|
1639
1711
|
' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
|
|
1640
1712
|
" const { start, end } = range.ranges[0]!",
|
|
1713
|
+
' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
|
|
1714
|
+
" if (whole !== undefined) {",
|
|
1715
|
+
" file.close()",
|
|
1716
|
+
" const part = whole.subarray(start, end + 1)",
|
|
1717
|
+
' headers.set("content-length", String(part.byteLength))',
|
|
1718
|
+
" return new Response(part, { status: 206, headers })",
|
|
1719
|
+
" }",
|
|
1641
1720
|
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1642
1721
|
" await file.seek(start, Deno.SeekMode.Start)",
|
|
1643
1722
|
" let left = end - start + 1",
|
|
1644
|
-
' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
|
|
1645
1723
|
' headers.set("content-length", String(left))',
|
|
1646
1724
|
" const body = new ReadableStream<Uint8Array>({",
|
|
1647
1725
|
" async pull(controller) {",
|
|
@@ -1663,6 +1741,13 @@ function denoStaticResponse(hashed: boolean): string[] {
|
|
|
1663
1741
|
" })",
|
|
1664
1742
|
" return new Response(body, { status: 206, headers })",
|
|
1665
1743
|
" }",
|
|
1744
|
+
" if (whole !== undefined) {",
|
|
1745
|
+
" file.close()",
|
|
1746
|
+
' headers.set("content-length", String(whole.byteLength))',
|
|
1747
|
+
" return new Response(whole, { headers })",
|
|
1748
|
+
" }",
|
|
1749
|
+
" // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
|
|
1750
|
+
" if (moved) await file.seek(0, Deno.SeekMode.Start)",
|
|
1666
1751
|
' headers.set("content-length", String(size))',
|
|
1667
1752
|
" return new Response(file.readable, { headers })",
|
|
1668
1753
|
"}",
|
|
@@ -1924,6 +2009,11 @@ export async function buildTargetWith(
|
|
|
1924
2009
|
]
|
|
1925
2010
|
assertDisposableDir("outDir", outDir, sources)
|
|
1926
2011
|
assertDisposableDir("workDir", workDir, sources)
|
|
2012
|
+
// Before anything is cleared, so a refused build leaves the last deploy dir in place.
|
|
2013
|
+
if (targetPlan.target === "node") {
|
|
2014
|
+
const refusal = nodeAdapterRefusal(workDir)
|
|
2015
|
+
if (refusal !== undefined) throw new Error(refusal)
|
|
2016
|
+
}
|
|
1927
2017
|
const { rmSync } = await import("node:fs")
|
|
1928
2018
|
rmSync(outDir, { recursive: true, force: true })
|
|
1929
2019
|
rmSync(workDir, { recursive: true, force: true })
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** A package as installed: its directory and the version its package.json declares. */
|
|
5
|
+
export interface InstalledPackage {
|
|
6
|
+
readonly dir: string
|
|
7
|
+
readonly version: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The `version` a package.json declares, or `undefined` when it declares none. */
|
|
11
|
+
function manifestVersion(path: string | URL): string | undefined {
|
|
12
|
+
const manifest: unknown = JSON.parse(readFileSync(path, "utf8"))
|
|
13
|
+
return typeof manifest === "object" &&
|
|
14
|
+
manifest !== null &&
|
|
15
|
+
"version" in manifest &&
|
|
16
|
+
typeof manifest.version === "string"
|
|
17
|
+
? manifest.version
|
|
18
|
+
: undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The copy of `name` a module in `fromDir` imports: the first `node_modules/<name>` walking up, as
|
|
23
|
+
* both bundlers resolve a bare specifier. Walked rather than `Bun.resolveSync`, which keeps its first
|
|
24
|
+
* answer for the life of the process.
|
|
25
|
+
*/
|
|
26
|
+
export function installedPackage(name: string, fromDir: string): InstalledPackage | undefined {
|
|
27
|
+
for (let dir = resolve(fromDir); ; dir = dirname(dir)) {
|
|
28
|
+
const manifest = join(dir, "node_modules", name, "package.json")
|
|
29
|
+
if (existsSync(manifest)) {
|
|
30
|
+
const version = manifestVersion(manifest)
|
|
31
|
+
return version === undefined ? undefined : { dir: dirname(manifest), version }
|
|
32
|
+
}
|
|
33
|
+
if (dirname(dir) === dir) return undefined
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why a `--target node` build whose server entry is generated in `workDir` must stop before
|
|
39
|
+
* bundling, or `undefined`. The entry is written for the `@nifrajs/node` of this package's release and
|
|
40
|
+
* the bundle inlines whichever copy is installed: a 5.3 adapter built cleanly, then threw at startup
|
|
41
|
+
* on the static mounts.
|
|
42
|
+
*/
|
|
43
|
+
export function nodeAdapterRefusal(
|
|
44
|
+
workDir: string,
|
|
45
|
+
// src/internal/ and dist/internal/ both sit two levels below the package root.
|
|
46
|
+
webVersion = manifestVersion(new URL("../../package.json", import.meta.url)),
|
|
47
|
+
): string | undefined {
|
|
48
|
+
const adapter = installedPackage("@nifrajs/node", workDir)
|
|
49
|
+
if (adapter === undefined || webVersion === undefined) return undefined
|
|
50
|
+
if (Bun.semver.order(adapter.version, webVersion) >= 0) return undefined
|
|
51
|
+
return (
|
|
52
|
+
`[nifra/web] --target node would bundle @nifrajs/node ${adapter.version} (${adapter.dir}), older ` +
|
|
53
|
+
`than @nifrajs/web ${webVersion}. The server entry is generated for the adapter of the same ` +
|
|
54
|
+
"release, and an older one can fail when the server starts. Upgrade the nifra packages " +
|
|
55
|
+
`together: nifra upgrade ${webVersion}`
|
|
56
|
+
)
|
|
57
|
+
}
|
package/src/internal/parity.ts
CHANGED
|
@@ -16,11 +16,12 @@ import {
|
|
|
16
16
|
SINGLE_COPY_REGISTER_SPECIFIER,
|
|
17
17
|
type SingleCopyRegistration,
|
|
18
18
|
} from "@nifrajs/core/single-copy"
|
|
19
|
+
import { isHiddenPath } from "@nifrajs/core/static-file"
|
|
19
20
|
import { discoverRoutes } from "../fs.ts"
|
|
20
21
|
import { codeUnitOrder } from "./code-unit-order.ts"
|
|
21
22
|
import { withoutComments } from "./html-spans.ts"
|
|
22
23
|
import { isIdentitySensitivePackage } from "./identity-policy.ts"
|
|
23
|
-
import {
|
|
24
|
+
import { publicUrlPath } from "./public-url.ts"
|
|
24
25
|
|
|
25
26
|
const DEPENDENCY_FIELDS = [
|
|
26
27
|
"dependencies",
|
|
@@ -1077,7 +1078,7 @@ const publicFilesUnder = (publicDir: string | false | undefined): readonly strin
|
|
|
1077
1078
|
const files: string[] = []
|
|
1078
1079
|
const walk = (current: string): void => {
|
|
1079
1080
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
1080
|
-
if (
|
|
1081
|
+
if (isHiddenPath(entry.name)) continue // the build skips it too
|
|
1081
1082
|
const path = join(current, entry.name)
|
|
1082
1083
|
if (entry.isDirectory()) walk(path)
|
|
1083
1084
|
else if (entry.isFile()) files.push(publicUrlPath(relative(root, path)))
|
|
@@ -20,16 +20,3 @@ function encodePathSegment(segment: string): string {
|
|
|
20
20
|
export function publicUrlPath(rel: string): string {
|
|
21
21
|
return `/${rel.split(sep).map(encodePathSegment).join("/")}`
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Whether a path under the public directory crosses a dot-segment other than `.well-known`. Serving,
|
|
26
|
-
* the build copy and the parity listing all apply it, so an editor or VCS artifact left in `public/`
|
|
27
|
-
* (`.env`, `.git/`, `.DS_Store`) is neither served in development nor shipped by a build. Both
|
|
28
|
-
* separators split: a request path uses `/`, a relative file path the OS separator.
|
|
29
|
-
*/
|
|
30
|
-
export function isHiddenPublicPath(rel: string): boolean {
|
|
31
|
-
for (const segment of rel.split(/[\\/]/)) {
|
|
32
|
-
if (segment.startsWith(".") && segment !== ".well-known") return true
|
|
33
|
-
}
|
|
34
|
-
return false
|
|
35
|
-
}
|