@brust/core 2.0.0-alpha.34 → 2.0.0-alpha.36
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/README.md +2 -1
- package/native/index.d.ts +26 -1
- package/native/index.js +55 -54
- package/package.json +10 -10
- package/src/actions/client.ts +3 -4
- package/src/browser.ts +3 -0
- package/src/build/actions-only.ts +2 -1
- package/src/build/bundle.ts +58 -19
- package/src/build/cf-assets.ts +11 -9
- package/src/build/cf-workerd.ts +63 -0
- package/src/build/cloudflare.ts +26 -6
- package/src/build/compile.ts +2 -0
- package/src/build/config.ts +36 -7
- package/src/build/css.ts +18 -12
- package/src/build/index.ts +30 -12
- package/src/build/manifest.ts +8 -0
- package/src/build/route-conflicts.ts +39 -0
- package/src/build/ssg.ts +3 -2
- package/src/build/static.ts +7 -2
- package/src/build/tailwind.ts +15 -3
- package/src/build/tsconfig-paths.ts +31 -3
- package/src/cache-adapter/conformance.ts +77 -0
- package/src/cache-adapter/memory.ts +44 -12
- package/src/cache-adapter/types.ts +7 -1
- package/src/cache.ts +11 -0
- package/src/cf.ts +3 -0
- package/src/config.ts +91 -2
- package/src/dev/bun.ts +8 -6
- package/src/dev/client.ts +4 -1
- package/src/dev/devtools.ts +5 -3
- package/src/dev/proxy.ts +21 -17
- package/src/index.ts +5 -0
- package/src/launcher-watch.ts +54 -1
- package/src/native.ts +11 -0
- package/src/routes.ts +13 -0
- package/src/run.ts +39 -1
package/src/build/cf-assets.ts
CHANGED
|
@@ -37,8 +37,10 @@ function walk(dir: string, rel = ''): string[] {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** Copies the admissible files of `<out>/client` and `<out>/public` into `<cfDir>/assets/...`; returns the
|
|
40
|
-
* asset-relative paths (`_brust/client/x.js`, `public/img/a.png`) and writes `assets/_headers`.
|
|
41
|
-
|
|
40
|
+
* asset-relative paths (`_brust/client/x.js`, `public/img/a.png`) and writes `assets/_headers`. F495: an app under a base path
|
|
41
|
+
* (`/portal`) lays them out under it (`portal/_brust/client/x.js`), so the platform serves the very URLs the pages name. */
|
|
42
|
+
export function layoutAssets(out: string, cfDir: string, version: string, base = ''): { files: string[]; warnings: string[] } {
|
|
43
|
+
const under = base === '' ? '' : `${base.slice(1)}/`
|
|
42
44
|
const assets = join(cfDir, 'assets')
|
|
43
45
|
mkdirSync(assets, { recursive: true })
|
|
44
46
|
const files: string[] = []
|
|
@@ -51,32 +53,32 @@ export function layoutAssets(out: string, cfDir: string, version: string): { fil
|
|
|
51
53
|
const client = join(out, 'client')
|
|
52
54
|
if (existsSync(client))
|
|
53
55
|
for (const f of walk(client)) {
|
|
54
|
-
if (!f.includes('/') && clientOk(f)) copy(join(client, f),
|
|
56
|
+
if (!f.includes('/') && clientOk(f)) copy(join(client, f), `${under}_brust/client/${f}`)
|
|
55
57
|
else warnings.push(`[brust] assets: client/${f} is not served natively; skipped`)
|
|
56
58
|
}
|
|
57
59
|
const pub = join(out, 'public')
|
|
58
60
|
if (existsSync(pub))
|
|
59
61
|
for (const f of walk(pub)) {
|
|
60
|
-
if (f.split('/').every(segOk)) copy(join(pub, f),
|
|
62
|
+
if (f.split('/').every(segOk)) copy(join(pub, f), `${under}public/${f}`)
|
|
61
63
|
else warnings.push(`[brust] assets: public/${f} has a name the native host would not serve; skipped`)
|
|
62
64
|
}
|
|
63
|
-
const h = headersFile(files, version)
|
|
65
|
+
const h = headersFile(files, version, base)
|
|
64
66
|
warnings.push(...h.warnings)
|
|
65
67
|
writeFileSync(join(assets, '_headers'), h.text)
|
|
66
68
|
return { files, warnings }
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
export function headersFile(files: string[], version: string): { text: string; warnings: string[] } {
|
|
71
|
+
export function headersFile(files: string[], version: string, base = ''): { text: string; warnings: string[] } {
|
|
70
72
|
const warnings: string[] = []
|
|
71
73
|
const rules: string[] = [
|
|
72
74
|
`/*\n X-Content-Type-Options: nosniff\n X-Powered-By: brust/${version}`,
|
|
73
|
-
|
|
75
|
+
`${base}/_brust/client/*\n Cache-Control: ${IMMUTABLE}`,
|
|
74
76
|
]
|
|
75
|
-
const pub = files.filter((f) => f.startsWith('
|
|
77
|
+
const pub = files.filter((f) => f.startsWith(`${base === '' ? '' : `${base.slice(1)}/`}public/`))
|
|
76
78
|
if (rules.length + pub.length <= MAX_RULES)
|
|
77
79
|
for (const f of pub) rules.push(`/${f}\n Cache-Control: ${isHashed(f) ? IMMUTABLE : PUBLIC_DEFAULT}`)
|
|
78
80
|
else if (pub.length > 0) {
|
|
79
|
-
rules.push(
|
|
81
|
+
rules.push(`${base}/public/*\n Cache-Control: ${PUBLIC_DEFAULT}`)
|
|
80
82
|
warnings.push(`[brust] assets: ${pub.length} public files exceed the 100-rule _headers limit; one /public/* max-age=3600 rule (no per-file immutable)`)
|
|
81
83
|
}
|
|
82
84
|
return { text: `${rules.join('\n\n')}\n`, warnings }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// F482: the newest `compatibility_date` the app's installed workerd runs. `wrangler dev` (and so `brust dev --target
|
|
2
|
+
// cloudflare`) refuses a date newer than that, while the default date is the @brust/core-cf build date, which can be newer.
|
|
3
|
+
// workerd's npm package carries its own answer: `lib/main.js` exports `compatibilityDate` (the date part of its version,
|
|
4
|
+
// `1.YYYYMMDD.n`). Read statically (the package is never imported).
|
|
5
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export interface WorkerdDate {
|
|
9
|
+
/** `YYYY-MM-DD`: the newest compatibility date this workerd supports. */
|
|
10
|
+
date: string
|
|
11
|
+
version: string
|
|
12
|
+
/** The package directory it was read from. */
|
|
13
|
+
dir: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The directory of package `name` as Node resolves it from `from`: the nearest `node_modules/<name>` up the tree
|
|
17
|
+
* (package managers that nest, like pnpm, keep a package's own dependencies beside its real directory). */
|
|
18
|
+
function findPackage(name: string, from: string): string | null {
|
|
19
|
+
for (let dir = from; ; dir = dirname(dir)) {
|
|
20
|
+
const p = join(dir, 'node_modules', name)
|
|
21
|
+
if (existsSync(join(p, 'package.json'))) return realpathSync(p)
|
|
22
|
+
if (dirname(dir) === dir) return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const readJson = (file: string): { version?: unknown } | null => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The compatibility date of the workerd in package directory `dir`, or null when it cannot be told. */
|
|
35
|
+
function dateOf(dir: string): WorkerdDate | null {
|
|
36
|
+
const version = String(readJson(join(dir, 'package.json'))?.version ?? '')
|
|
37
|
+
let main = ''
|
|
38
|
+
try {
|
|
39
|
+
const f = join(dir, 'lib', 'main.js')
|
|
40
|
+
if (statSync(f).isFile()) main = readFileSync(f, 'utf8')
|
|
41
|
+
} catch {}
|
|
42
|
+
const exported = /\bcompatibilityDate\s*=\s*"(\d{4}-\d{2}-\d{2})"/.exec(main)?.[1]
|
|
43
|
+
const v = /^1\.(\d{4})(\d{2})(\d{2})\./.exec(version)
|
|
44
|
+
const date = exported ?? (v ? `${v[1]}-${v[2]}-${v[3]}` : undefined)
|
|
45
|
+
return date ? { date, version, dir } : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The workerd the app's wrangler runs: wrangler → miniflare → workerd, each resolved from the real directory of the one
|
|
49
|
+
* before it (so a nested install is the right copy); else a `workerd` the app resolves itself; else null. */
|
|
50
|
+
export function installedWorkerd(appRoot: string): WorkerdDate | null {
|
|
51
|
+
let from = appRoot
|
|
52
|
+
for (const name of ['wrangler', 'miniflare']) {
|
|
53
|
+
const dir = findPackage(name, from)
|
|
54
|
+
if (!dir) {
|
|
55
|
+
from = ''
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
from = dir
|
|
59
|
+
}
|
|
60
|
+
const viaWrangler = from ? findPackage('workerd', from) : null
|
|
61
|
+
const dir = viaWrangler ?? findPackage('workerd', appRoot)
|
|
62
|
+
return dir ? dateOf(dir) : null
|
|
63
|
+
}
|
package/src/build/cloudflare.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { type CfConfigJson, cfEntrySource } from './cf-entry'
|
|
|
14
14
|
import { type CoreCf, resolveCoreCf } from './cf-core'
|
|
15
15
|
import { type CacheNeed, mergeWrangler, readUserWrangler, renderWrangler, type UserWrangler, verifyCacheBindings } from './cf-wrangler'
|
|
16
16
|
import { type CloudflareBuildConfig, CF_MIN_COMPAT_DATE } from './config'
|
|
17
|
+
import { installedWorkerd } from './cf-workerd'
|
|
17
18
|
import { MANIFEST_VERSION } from './manifest'
|
|
18
19
|
import { exposePlugin, PRODUCTION, type SourceOf, sourceDirPlugin } from './bundle'
|
|
19
20
|
import type { Compiled } from './compile'
|
|
@@ -42,9 +43,25 @@ const DEFAULT_CFG: CloudflareBuildConfig = { name: undefined, compatibilityDate:
|
|
|
42
43
|
|
|
43
44
|
/** Everything that can fail on the app's own input or install, before a byte is written (index.ts rule). */
|
|
44
45
|
export async function prepareCloudflare(appRoot: string, cfg: CloudflareBuildConfig | null, flags: CfFlags): Promise<CfPrepared> {
|
|
45
|
-
const
|
|
46
|
+
const resolved = resolveCoreCf({ appRoot, fromSource: flags.fromSource, env: flags.env })
|
|
46
47
|
const user = await readUserWrangler(appRoot)
|
|
47
|
-
const warnings = [...
|
|
48
|
+
const warnings = [...resolved.warnings]
|
|
49
|
+
// F482: the default date is the core's build date, bounded by what the installed workerd runs (wrangler dev refuses a newer
|
|
50
|
+
// one). An explicit date is used as written, with a warning when it outruns workerd. Not resolvable: today's behaviour.
|
|
51
|
+
const workerd = installedWorkerd(appRoot)
|
|
52
|
+
let core = resolved
|
|
53
|
+
if (cfg?.compatibilityDate !== undefined) {
|
|
54
|
+
if (workerd && cfg.compatibilityDate > workerd.date)
|
|
55
|
+
warnings.push(
|
|
56
|
+
`[brust] warning cf-compat-date compatibility_date ${cfg.compatibilityDate} in brust.toml is newer than ${workerd.date}, the newest the installed workerd ${workerd.version} runs: \`wrangler dev\` and \`brust dev --target cloudflare\` will not start; lower it or upgrade wrangler`,
|
|
57
|
+
)
|
|
58
|
+
} else if (workerd && workerd.date < resolved.meta.compatibilityDate) {
|
|
59
|
+
if (workerd.date >= CF_MIN_COMPAT_DATE) core = { ...resolved, meta: { ...resolved.meta, compatibilityDate: workerd.date } }
|
|
60
|
+
else
|
|
61
|
+
warnings.push(
|
|
62
|
+
`[brust] warning cf-compat-date the installed workerd ${workerd.version} runs dates only up to ${workerd.date}, older than ${CF_MIN_COMPAT_DATE}, the oldest brust supports: the build keeps ${resolved.meta.compatibilityDate}, but \`wrangler dev\` will not start; upgrade wrangler`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
48
65
|
try {
|
|
49
66
|
const toml = Bun.file(join(appRoot, 'brust.toml'))
|
|
50
67
|
if (await toml.exists()) {
|
|
@@ -77,7 +94,7 @@ export interface CfBuildCtx {
|
|
|
77
94
|
/** the FINAL dist dir (`out` is a tmp sibling that is renamed to it): wrangler paths are relative to `<dist>/cf` */
|
|
78
95
|
dist: string
|
|
79
96
|
compiled: Map<string, Compiled>
|
|
80
|
-
manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number } | null; cache?: CacheRecord }
|
|
97
|
+
manifest: { components: Record<string, { template: string }>; /** F495 `[server] base_path`: the asset tree is laid out under it. */ base_path?: string; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number } | null; cache?: CacheRecord }
|
|
81
98
|
sourceOf: SourceOf
|
|
82
99
|
actionsEntry: string | null
|
|
83
100
|
/** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
|
|
@@ -91,6 +108,9 @@ export interface CfBuildCtx {
|
|
|
91
108
|
log: (s: string) => void
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
/** F495: the Worker bundle's `define`: production, and the app's base path baked in for `basePath` of `@brust/core` (cf.ts reads it; a Worker has no build-time env). */
|
|
112
|
+
export const workerDefine = (basePath = ''): Record<string, string> => ({ ...PRODUCTION, 'process.env.BRUST_BASE_PATH': JSON.stringify(basePath) })
|
|
113
|
+
|
|
94
114
|
/** The resolution conditions of the Worker bundle; the package-resolver probe (`sourceDirPlugin`) must carry the same (F223). */
|
|
95
115
|
export const CF_CONDITIONS = ['workerd', 'worker', 'browser']
|
|
96
116
|
|
|
@@ -288,10 +308,10 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
288
308
|
target: 'browser',
|
|
289
309
|
format: 'esm',
|
|
290
310
|
conditions: CF_CONDITIONS,
|
|
291
|
-
define:
|
|
311
|
+
define: workerDefine(ctx.manifest.base_path),
|
|
292
312
|
minify: false,
|
|
293
313
|
external: ['node:*', 'cloudflare:*', glueSpec],
|
|
294
|
-
plugins: [serverOnlyEmptyPlugin(), cssPlugin(ctx.cssMaps ?? new Map(), join(out, 'client')), cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'], 'browser', CF_CONDITIONS), exposePlugin(ctx.compiled)],
|
|
314
|
+
plugins: [serverOnlyEmptyPlugin(), cssPlugin(ctx.cssMaps ?? new Map(), join(out, 'client'), ctx.manifest.base_path ?? ''), cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'], 'browser', CF_CONDITIONS), exposePlugin(ctx.compiled)],
|
|
295
315
|
})
|
|
296
316
|
if (violations.length > 0) throw new BuildError('cf-unsupported', `${[...new Set(violations)].join('\n')}\nthese modules do not exist on Cloudflare Workers; swap them with [cloudflare] alias`)
|
|
297
317
|
if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
|
|
@@ -303,7 +323,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
303
323
|
writeFileSync(appFile, await js[0]!.text())
|
|
304
324
|
|
|
305
325
|
// An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
|
|
306
|
-
if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
|
|
326
|
+
if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion, ctx.manifest.base_path ?? '').warnings)
|
|
307
327
|
const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
|
|
308
328
|
if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
|
|
309
329
|
if (doClass) {
|
package/src/build/compile.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type ComponentIR = Record<string, any> & {
|
|
|
30
30
|
exprs?: Record<string, string>
|
|
31
31
|
/** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
|
|
32
32
|
rows_literal?: unknown[]
|
|
33
|
+
/** F502: the branch condition the server checks per instance before it runs the child's job. */
|
|
34
|
+
when?: unknown
|
|
33
35
|
}[]
|
|
34
36
|
/** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
|
|
35
37
|
build_eval_instances?: {
|
package/src/build/config.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
streamEntryError,
|
|
23
23
|
} from '../actions/prefix'
|
|
24
24
|
import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
|
|
25
|
+
import { checkTrailingSlash, normalizeBasePath, type TrailingSlash } from '../config'
|
|
25
26
|
import { BuildError } from './errors'
|
|
26
27
|
import { posixOf } from './posix'
|
|
27
28
|
|
|
@@ -108,6 +109,10 @@ export interface BuildConfig {
|
|
|
108
109
|
cssGlobal: string[]
|
|
109
110
|
/** `[build] source_roots` (F256): directories outside the app a relative import may reach, normalised and relative to the app root (`../shared`). Empty = the app only. */
|
|
110
111
|
sourceRoots: string[]
|
|
112
|
+
/** `[server] base_path` (F495), normalised (`/portal`); `''` = the app is mounted at the root. Baked into every URL brust emits. */
|
|
113
|
+
basePath: string
|
|
114
|
+
/** `[server] trailing_slash` (F506), baked into the manifest; `strict` (the default) = the path is matched as written. */
|
|
115
|
+
trailingSlash: TrailingSlash
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -274,7 +279,7 @@ function resolveAlias(file: string, appRoot: string, p: string): string {
|
|
|
274
279
|
}
|
|
275
280
|
}
|
|
276
281
|
|
|
277
|
-
function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
|
|
282
|
+
function parseActions(file: string, raw: unknown, actionsOnly: boolean, basePath = ''): ActionsBuildConfig | null {
|
|
278
283
|
if (raw === undefined) return null
|
|
279
284
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
|
|
280
285
|
const t = raw as Record<string, unknown>
|
|
@@ -283,12 +288,16 @@ function parseActions(file: string, raw: unknown, actionsOnly: boolean): Actions
|
|
|
283
288
|
if (typeof t.entry !== 'string' || t.entry.trim() === '') throw new BuildError('config', `${file}: actions.entry must be a non-empty string`)
|
|
284
289
|
entry = t.entry.trim().replace(/^\.\//, '')
|
|
285
290
|
}
|
|
286
|
-
|
|
291
|
+
// F495: the default prefix lives under the base path, like every URL brust owns.
|
|
292
|
+
let prefix = `${basePath}${DEFAULT_ACTION_PREFIX}`
|
|
287
293
|
if (t.prefix !== undefined) {
|
|
288
294
|
// `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
|
|
289
295
|
const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
|
|
290
296
|
if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
|
|
291
297
|
prefix = t.prefix as string
|
|
298
|
+
// F495: an explicit prefix is used as written, and must itself be under the base path.
|
|
299
|
+
if (basePath !== '' && prefix !== basePath && !prefix.startsWith(`${basePath}/`))
|
|
300
|
+
throw new BuildError('config', `${file}: actions.prefix ${JSON.stringify(prefix)} must lie under server.base_path ${JSON.stringify(basePath)}`)
|
|
292
301
|
}
|
|
293
302
|
let maxBodyBytes = DEFAULT_MAX_BODY_BYTES
|
|
294
303
|
if (t.max_body_bytes !== undefined) {
|
|
@@ -397,10 +406,28 @@ export function parseSourceRoots(file: string, appRoot: string, raw: unknown): s
|
|
|
397
406
|
return [...out].sort()
|
|
398
407
|
}
|
|
399
408
|
|
|
409
|
+
/** `[server] base_path` (F495): the one validator is config.ts's; a bad value is a build error here too. */
|
|
410
|
+
function parseBasePath(file: string, server: unknown): string {
|
|
411
|
+
if (server === undefined) return ''
|
|
412
|
+
if (!isTable(server)) throw new BuildError('config', `${file}: [server] must be a table`)
|
|
413
|
+
if (server.base_path === undefined) return ''
|
|
414
|
+
const r = normalizeBasePath(server.base_path)
|
|
415
|
+
if (!r.ok) throw new BuildError('config', `${file}: server.base_path ${r.why}`)
|
|
416
|
+
return r.value
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** `[server] trailing_slash` (F506): the one validator is config.ts's; a bad value is a build error here too. */
|
|
420
|
+
function parseTrailingSlash(file: string, server: unknown): TrailingSlash {
|
|
421
|
+
if (!isTable(server) || server.trailing_slash === undefined) return 'strict'
|
|
422
|
+
const r = checkTrailingSlash(server.trailing_slash)
|
|
423
|
+
if (!r.ok) throw new BuildError('config', `${file}: server.trailing_slash ${r.why}`)
|
|
424
|
+
return r.value
|
|
425
|
+
}
|
|
426
|
+
|
|
400
427
|
export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
|
|
401
428
|
const file = join(appRoot, 'brust.toml')
|
|
402
429
|
const f = Bun.file(file)
|
|
403
|
-
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [] }
|
|
430
|
+
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [], basePath: '', trailingSlash: 'strict' }
|
|
404
431
|
let parsed: unknown
|
|
405
432
|
try {
|
|
406
433
|
parsed = Bun.TOML.parse(await f.text())
|
|
@@ -408,17 +435,19 @@ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolea
|
|
|
408
435
|
throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
|
|
409
436
|
}
|
|
410
437
|
const root = (parsed ?? {}) as Record<string, unknown>
|
|
411
|
-
const
|
|
438
|
+
const basePath = parseBasePath(file, root.server)
|
|
439
|
+
const trailingSlash = parseTrailingSlash(file, root.server)
|
|
440
|
+
const actions = parseActions(file, root.actions, o.actionsOnly === true, basePath)
|
|
412
441
|
const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
|
|
413
442
|
const cache = await parseCache(file, appRoot, root.cache)
|
|
414
443
|
const cssGlobal = parseCss(file, root.css)
|
|
415
444
|
const build = root.build
|
|
416
|
-
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [] }
|
|
445
|
+
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [], basePath, trailingSlash }
|
|
417
446
|
if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
|
|
418
447
|
const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
|
|
419
448
|
const so = (build as Record<string, unknown>).server_only
|
|
420
|
-
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots }
|
|
449
|
+
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots, basePath, trailingSlash }
|
|
421
450
|
if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
|
|
422
451
|
throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
|
|
423
|
-
return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots }
|
|
452
|
+
return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots, basePath, trailingSlash }
|
|
424
453
|
}
|
package/src/build/css.ts
CHANGED
|
@@ -132,13 +132,17 @@ export function cssUnescape(s: string): string {
|
|
|
132
132
|
)
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** F495: a URL brust serves itself, `/public/…` or `/_brust/…`, under the app's base path (`<base>/public/…`) or at the root:
|
|
136
|
+
* written as it is, never a file on this machine. */
|
|
137
|
+
export const brustUrl = (v: string, base = ''): boolean => ['/public/', '/_brust/'].some((p) => v.startsWith(p) || (base !== '' && v.startsWith(`${base}${p}`)))
|
|
138
|
+
|
|
135
139
|
/** F490: an absolute local path in either spelling: POSIX `/…`, a Windows drive `C:\…` / `C:/…`, or a UNC `\\host\…`. */
|
|
136
140
|
export const localAbsolute = (p: string): boolean => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
|
|
137
141
|
|
|
138
142
|
/** F393: a `url()` that is the absolute path of a file on disk (see the `loader` in `buildCss`) becomes a
|
|
139
143
|
* hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written.
|
|
140
144
|
* F490: on Windows that path is `C:\\…` (CSS-escaped), so the value is unescaped and both spellings are taken. */
|
|
141
|
-
function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string
|
|
145
|
+
function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>, base = ''): string {
|
|
142
146
|
const emit = (path: string, suffix: string) => {
|
|
143
147
|
const { name, bytes } = hashedAsset(path)
|
|
144
148
|
if (!assets.some((a) => a.name === name)) assets.push({ name, bytes })
|
|
@@ -149,7 +153,7 @@ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Arra
|
|
|
149
153
|
if (abs !== undefined) return emit(abs, path.slice(path.search(/[?#]/)))
|
|
150
154
|
const file = cssUnescape(path)
|
|
151
155
|
const ext = extname(file).toLowerCase()
|
|
152
|
-
if (!localAbsolute(file) || !SERVED_ASSET_EXT.includes(ext) || file
|
|
156
|
+
if (!localAbsolute(file) || !SERVED_ASSET_EXT.includes(ext) || brustUrl(file, base) || !existsSync(file)) return whole
|
|
153
157
|
return emit(file, '')
|
|
154
158
|
})
|
|
155
159
|
}
|
|
@@ -158,11 +162,11 @@ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Arra
|
|
|
158
162
|
* path and 404 in the browser, so the build fails instead. A leak is a Windows drive or UNC path, or a `/…` path under
|
|
159
163
|
* the app root or the dist dir (`roots`). Any other root URL passes, even one that happens to exist on the build
|
|
160
164
|
* machine (`url(/tmp/x)`, `url(/fonts/x.woff2)`): only the app's own files are known to be local. */
|
|
161
|
-
export function leakedLocalPaths(text: string, roots: string[]): string[] {
|
|
165
|
+
export function leakedLocalPaths(text: string, roots: string[], base = ''): string[] {
|
|
162
166
|
const out: string[] = []
|
|
163
167
|
for (const m of text.matchAll(SOURCE_URL)) {
|
|
164
168
|
const v = cssUnescape(m[2]!)
|
|
165
|
-
if (v
|
|
169
|
+
if (brustUrl(v, base) || !localAbsolute(v)) continue
|
|
166
170
|
const posix = v.replaceAll('\\', '/')
|
|
167
171
|
const local = !v.startsWith('/') || roots.some((r) => posix === r || posix.startsWith(`${r}/`))
|
|
168
172
|
if (local && !out.includes(v)) out.push(v)
|
|
@@ -203,7 +207,7 @@ export async function buildCss(
|
|
|
203
207
|
dist: string,
|
|
204
208
|
sources: string[],
|
|
205
209
|
minify = true,
|
|
206
|
-
o: { appRoot?: string; log?: (s: string) => void } = {},
|
|
210
|
+
o: { appRoot?: string; log?: (s: string) => void; basePath?: string; ownOutput?: string[] } = {},
|
|
207
211
|
): Promise<CssBuild> {
|
|
208
212
|
const file = new Map<string, string>()
|
|
209
213
|
const maps = new Map<string, Record<string, string>>()
|
|
@@ -220,7 +224,9 @@ export async function buildCss(
|
|
|
220
224
|
if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
|
|
221
225
|
return hit
|
|
222
226
|
}
|
|
223
|
-
|
|
227
|
+
// F501/F505: brust's own output dirs, as real absolute paths: each is the base of its own negation in the scan.
|
|
228
|
+
const own = [...new Set((o.ownOutput ?? []).map(real))]
|
|
229
|
+
plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log, own))
|
|
224
230
|
if (tw.imports.length > 1)
|
|
225
231
|
o.log?.(
|
|
226
232
|
`[brust] warning tailwind @import "tailwindcss" is in ${tw.imports.length} files (${tw.imports.map((f) => relative(appRoot, f)).join(', ')}): each one ships the whole of Tailwind; import it once (e.g. app.css) and use @reference elsewhere`,
|
|
@@ -241,7 +247,7 @@ export async function buildCss(
|
|
|
241
247
|
format: 'esm',
|
|
242
248
|
minify,
|
|
243
249
|
naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
|
|
244
|
-
external: ['/public/*', '/_brust/*'],
|
|
250
|
+
external: ['/public/*', '/_brust/*', ...(o.basePath ? [`${o.basePath}/public/*`, `${o.basePath}/_brust/*`] : [])],
|
|
245
251
|
root: appRoot,
|
|
246
252
|
plugins,
|
|
247
253
|
// F393, verified on Bun 1.4.4 only: its CSS bundler turns an asset under the size threshold into a `data:`
|
|
@@ -289,9 +295,9 @@ export async function buildCss(
|
|
|
289
295
|
`${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
|
|
290
296
|
)
|
|
291
297
|
}
|
|
292
|
-
for (const [i, t] of css) css.set(i, emitSourceAssets(t, assets, suffixed))
|
|
298
|
+
for (const [i, t] of css) css.set(i, emitSourceAssets(t, assets, suffixed, o.basePath))
|
|
293
299
|
const roots = [appRoot, dist].flatMap((r) => [r, real(r)]).map((r) => r.replaceAll('\\', '/').replace(/\/+$/, '')).filter((r) => r !== '')
|
|
294
|
-
const leaks = [...css].flatMap(([i, t]) => leakedLocalPaths(t, roots).map((p) => `url(${p}) in ${sources[i]}`))
|
|
300
|
+
const leaks = [...css].flatMap(([i, t]) => leakedLocalPaths(t, roots, o.basePath).map((p) => `url(${p}) in ${sources[i]}`))
|
|
295
301
|
if (leaks.length > 0)
|
|
296
302
|
throw new BuildError('css-asset-path', `${leaks.join('; ')}: the built CSS would name a file on this machine; reference it relative to the stylesheet or as /public/…`)
|
|
297
303
|
for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
|
|
@@ -351,9 +357,9 @@ export function tailwindUse(sources: string[]): { using: string[]; imports: stri
|
|
|
351
357
|
* (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
|
|
352
358
|
* stylesheet itself is linked by the server (R19.3), never bundled into JS.
|
|
353
359
|
* F411: with `clientDir` (`<dist>/client`), an import of a served asset (`SERVED_ASSET_EXT`: a font, an image) is
|
|
354
|
-
* the string `/_brust/client/<name>-<hex10>.<ext
|
|
360
|
+
* the string `/_brust/client/<name>-<hex10>.<ext>` (under `basePath`, F495), the very file a stylesheet's `url()` of it is rewritten to; the
|
|
355
361
|
* file is written there, so the URL can be preloaded (`<link rel="preload" as="font" href={font}>`). */
|
|
356
|
-
export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?: string): BunPlugin {
|
|
362
|
+
export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?: string, basePath = ''): BunPlugin {
|
|
357
363
|
return {
|
|
358
364
|
name: 'brust-css',
|
|
359
365
|
setup(b) {
|
|
@@ -371,7 +377,7 @@ export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?:
|
|
|
371
377
|
mkdirSync(clientDir, { recursive: true })
|
|
372
378
|
writeFileSync(to, bytes)
|
|
373
379
|
}
|
|
374
|
-
return { contents: `export default ${JSON.stringify(
|
|
380
|
+
return { contents: `export default ${JSON.stringify(`${basePath}/_brust/client/${name}`)}\n`, loader: 'js' }
|
|
375
381
|
})
|
|
376
382
|
},
|
|
377
383
|
}
|
package/src/build/index.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { createHash } from 'node:crypto'
|
|
|
8
8
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
|
|
11
|
-
import { BrustRouteError, type FlatRoute, validateRoutes } from '../routes'
|
|
12
|
-
import {
|
|
11
|
+
import { BrustRouteError, checkBasePath, type FlatRoute, validateRoutes } from '../routes'
|
|
12
|
+
import { assertRoutesInstallable, type CheckPatterns, nativeCheckPatterns } from './route-conflicts'
|
|
13
|
+
import { type BrowserCtx, brustRoot, setDevBuild, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, nestedOf, storeModules } from './bundle'
|
|
13
14
|
import { runActionsOnly } from './actions-only'
|
|
14
15
|
import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
|
|
15
16
|
import { renderIcons } from './build-icons'
|
|
@@ -106,6 +107,8 @@ export async function runBuild(opts: {
|
|
|
106
107
|
strict?: boolean
|
|
107
108
|
/** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
|
|
108
109
|
exprReads?: ExprReads
|
|
110
|
+
/** The router's refusal of a pattern set (F496; tests swap it). Default: the addon. */
|
|
111
|
+
checkPatterns?: CheckPatterns
|
|
109
112
|
/** `--wasm-from-source` (cloudflare only). */
|
|
110
113
|
wasmFromSource?: boolean
|
|
111
114
|
/** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
|
|
@@ -123,9 +126,11 @@ export async function runBuild(opts: {
|
|
|
123
126
|
assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
|
|
124
127
|
assertReplaceable(dist, opts.force)
|
|
125
128
|
const built = await loadBuildConfig(appRoot, { actionsOnly: opts.actionsOnly === true })
|
|
129
|
+
// F495: `basePath` of `@brust/core` while the build loads the app's modules (route scan, static render).
|
|
130
|
+
process.env.BRUST_BASE_PATH = built.basePath
|
|
126
131
|
if (opts.actionsOnly)
|
|
127
132
|
return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
|
|
128
|
-
const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots } = built
|
|
133
|
+
const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots, basePath, trailingSlash } = built
|
|
129
134
|
// L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
|
|
130
135
|
// is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
|
|
131
136
|
const remote =
|
|
@@ -138,10 +143,13 @@ export async function runBuild(opts: {
|
|
|
138
143
|
let leaves: ReturnType<typeof validateRoutes>
|
|
139
144
|
try {
|
|
140
145
|
leaves = validateRoutes(routes)
|
|
146
|
+
checkBasePath(leaves, basePath)
|
|
141
147
|
} catch (e) {
|
|
142
148
|
if (e instanceof BrustRouteError) throw new BuildError(e.rule, e.message)
|
|
143
149
|
throw e
|
|
144
150
|
}
|
|
151
|
+
// F496: what the server's router would refuse at `brust start` (a `{*rest}` next to a `{slug}`) fails here.
|
|
152
|
+
assertRoutesInstallable(leaves, opts.checkPatterns ?? (await nativeCheckPatterns()))
|
|
145
153
|
let actionsEntry: string | null = null
|
|
146
154
|
if (actions) {
|
|
147
155
|
actionsEntry = resolve(appRoot, actions.entry)
|
|
@@ -176,7 +184,7 @@ export async function runBuild(opts: {
|
|
|
176
184
|
// R9.3/R9.4: every reason to refuse a static build, before any write.
|
|
177
185
|
const ssgPlan =
|
|
178
186
|
opts.target === 'static'
|
|
179
|
-
? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache)
|
|
187
|
+
? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache, basePath)
|
|
180
188
|
: null
|
|
181
189
|
|
|
182
190
|
// Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
|
|
@@ -190,7 +198,7 @@ export async function runBuild(opts: {
|
|
|
190
198
|
rmSync(tmp, { recursive: true, force: true })
|
|
191
199
|
mkdirSync(tmp, { recursive: true })
|
|
192
200
|
try {
|
|
193
|
-
await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, actionsRemote: remote?.prefix ?? null, log: opts.log })
|
|
201
|
+
await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, basePath, trailingSlash, actionsRemote: remote?.prefix ?? null, log: opts.log })
|
|
194
202
|
if (ssgPlan) {
|
|
195
203
|
// Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
|
|
196
204
|
const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
|
|
@@ -233,6 +241,10 @@ async function buildInto(
|
|
|
233
241
|
cssGlobal: string[]
|
|
234
242
|
/** `[build] source_roots` (F256): normalised, app-root-relative directories outside the app a relative import may reach. */
|
|
235
243
|
sourceRoots: string[]
|
|
244
|
+
/** `[server] base_path` (F495), `''` = the root. */
|
|
245
|
+
basePath: string
|
|
246
|
+
/** `[server] trailing_slash` (F506); `strict` = the default (not written). */
|
|
247
|
+
trailingSlash: import('../config').TrailingSlash
|
|
236
248
|
/** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
|
|
237
249
|
actionsRemote: string | null
|
|
238
250
|
/** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
|
|
@@ -248,13 +260,13 @@ async function buildInto(
|
|
|
248
260
|
const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
|
|
249
261
|
mkdirSync(join(out, 'client'), { recursive: true })
|
|
250
262
|
// Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
|
|
251
|
-
const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log })
|
|
263
|
+
const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log, basePath: o.basePath, ownOutput: [o.dist, join(appRoot, 'dist'), join(appRoot, '.brust')] })
|
|
252
264
|
for (const c of directCss(entry))
|
|
253
265
|
o.log(`[brust] warning css ${relative(appRoot, entry)} imports ${c}: the route entry belongs to no route, so it is linked nowhere; import it from a layout, or list it in [css] global`)
|
|
254
|
-
const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client') }
|
|
266
|
+
const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client'), basePath: o.basePath }
|
|
255
267
|
// The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
|
|
256
268
|
const runtime = await buildRuntime(out, ctx)
|
|
257
|
-
const runtimeUrls = { core:
|
|
269
|
+
const runtimeUrls = { core: `${brustRoot(ctx)}/${runtime.core}`, store: `${brustRoot(ctx)}/${runtime.store}` }
|
|
258
270
|
// F230: one scan per build for load-time writes into another app module's exports; every compile reads it.
|
|
259
271
|
const loadWrites = scanAppWrites(appRoot, o.appModules, o.log, o.sourceRoots)
|
|
260
272
|
// Where a React island renders, the load-time writers run first: every one in the SSR jobs bundle (server code the
|
|
@@ -287,8 +299,10 @@ async function buildInto(
|
|
|
287
299
|
o.log(`[brust] warning load-writer-in-client ${relPosix(appRoot, w.file)} is imported into the browser chunk of the React island ${c.id}: it changes ${mods} when it loads, and the island reads that module (F230). Its code ships to the browser: keep secrets and server-only code out of it, or write the value in the module that declares it`)
|
|
288
300
|
// F480: a server-only import reached through this writer names the chain in `server-only-in-client`.
|
|
289
301
|
ctx.loadWriters ??= new Map()
|
|
290
|
-
|
|
291
|
-
|
|
302
|
+
const lw = ctx.loadWriters.get(w.file) ?? { rel: relPosix(appRoot, w.file), islands: [], writes: [] }
|
|
303
|
+
if (!lw.islands.includes(c.id)) lw.islands.push(c.id)
|
|
304
|
+
for (const m of mods.split(', ')) if (!lw.writes.includes(m)) lw.writes.push(m)
|
|
305
|
+
ctx.loadWriters.set(w.file, lw)
|
|
292
306
|
}
|
|
293
307
|
return [...new Set(out.map((w) => w.file))]
|
|
294
308
|
}
|
|
@@ -346,10 +360,14 @@ async function buildInto(
|
|
|
346
360
|
const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
|
|
347
361
|
if (files.length > 0) r.css = files
|
|
348
362
|
}
|
|
349
|
-
await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers)
|
|
363
|
+
await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers, o.basePath)
|
|
364
|
+
// F495: absent without a base path, so an app at the root writes the manifest it always did.
|
|
365
|
+
if (o.basePath !== '') manifest.base_path = o.basePath
|
|
366
|
+
// F506: absent when strict (the default), so an app that sets nothing writes the manifest it always did.
|
|
367
|
+
if (o.trailingSlash !== 'strict') manifest.trailing_slash = o.trailingSlash
|
|
350
368
|
manifest.actions =
|
|
351
369
|
o.actions && o.actionsEntry
|
|
352
|
-
? { entry: await buildActions(out, o.actionsEntry, css.maps), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite, ...o.actions.stream }
|
|
370
|
+
? { entry: await buildActions(out, o.actionsEntry, css.maps, o.basePath), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite, ...o.actions.stream }
|
|
353
371
|
: null
|
|
354
372
|
if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
|
|
355
373
|
if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
|
package/src/build/manifest.ts
CHANGED
|
@@ -36,6 +36,9 @@ export interface ChildRecord {
|
|
|
36
36
|
exprs?: Record<string, string>
|
|
37
37
|
/** F142 R34.43: the list of a `per-row-literal` instance (a constant the compiler read). */
|
|
38
38
|
rows?: unknown[]
|
|
39
|
+
/** F502: the branch condition (`{truthy}`/`{eq}`/`{not}`/`{all}`/`{any}` over props paths) the server checks per instance before
|
|
40
|
+
* it runs the child's job: a row whose branch does not render the child gets no job. */
|
|
41
|
+
when?: unknown
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export interface ComponentRecord {
|
|
@@ -97,6 +100,10 @@ export interface ManifestJson {
|
|
|
97
100
|
actions: ActionsRecord | null
|
|
98
101
|
/** L16 R16.3: an actions-only build (no routes, no templates); everything but the prefix is a 404 problem+json. */
|
|
99
102
|
actions_only?: true
|
|
103
|
+
/** F495 `[server] base_path`: set only for an app mounted under a path. The server answers under it alone and every URL brust owns lies below it. */
|
|
104
|
+
base_path?: string
|
|
105
|
+
/** F506 `[server] trailing_slash`: set only when it is not `strict` (`redirect` | `ignore`). */
|
|
106
|
+
trailing_slash?: 'redirect' | 'ignore'
|
|
100
107
|
/** L16 R16.4b: a page-only build: no local actions app, `base_url` points at the deployment that has it. */
|
|
101
108
|
actions_remote?: { prefix: string }
|
|
102
109
|
/** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
|
|
@@ -315,6 +322,7 @@ export function childRecords(c: Compiled, clientOnly: string[] = []): ChildRecor
|
|
|
315
322
|
if (inst.literals && Object.keys(inst.literals).length > 0) rec.literals = { ...inst.literals }
|
|
316
323
|
if (inst.exprs && Object.keys(inst.exprs).length > 0) rec.exprs = { ...inst.exprs }
|
|
317
324
|
if (literal) rec.rows = inst.rows_literal
|
|
325
|
+
if (inst.when !== undefined && inst.when !== null) rec.when = inst.when
|
|
318
326
|
return rec
|
|
319
327
|
})
|
|
320
328
|
for (const id of clientOnly) out.push({ id, instances: 'static', props: {} })
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// F496: the server's router refuses some pattern sets (`/{*rest}` next to `/{slug}`); without this the build passes and
|
|
2
|
+
// the first `brust start` fails. The check is the router's own insertion (`routing/routes.rs` `find_pattern_conflict`,
|
|
3
|
+
// through the addon the build already loads): there is no second matcher here.
|
|
4
|
+
import type { FlatRoute } from '../routes'
|
|
5
|
+
import { BuildError } from './errors'
|
|
6
|
+
|
|
7
|
+
export interface PatternConflict {
|
|
8
|
+
/** Index into the patterns given of the pattern the router refused. */
|
|
9
|
+
index: number
|
|
10
|
+
/** Index of the earlier pattern it conflicts with, when there is one. */
|
|
11
|
+
withIndex?: number | null
|
|
12
|
+
/** The router's refusal, as `brust start` would print it. */
|
|
13
|
+
message: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type CheckPatterns = (patterns: string[]) => PatternConflict | null
|
|
17
|
+
|
|
18
|
+
/** The addon's `checkRoutePatterns` (crates/brust-napi/src/compile.rs `check_route_patterns`). */
|
|
19
|
+
export async function nativeCheckPatterns(): Promise<CheckPatterns> {
|
|
20
|
+
const { native } = await import('../native')
|
|
21
|
+
const b = native() as unknown as { checkRoutePatterns?: CheckPatterns }
|
|
22
|
+
if (typeof b.checkRoutePatterns !== 'function')
|
|
23
|
+
throw new BuildError('route-conflict', 'the native addon is older than this build (no checkRoutePatterns): rebuild it')
|
|
24
|
+
return b.checkRoutePatterns.bind(b)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fails the build when the server's router would refuse the leaves' patterns. Catch-all (`*`) leaves never enter the
|
|
28
|
+
* router, so they are left out, as the server leaves them out. The error names the route ids. */
|
|
29
|
+
export function assertRoutesInstallable(leaves: FlatRoute[], check: CheckPatterns): void {
|
|
30
|
+
const routed = leaves.filter((l) => !l.catchAll)
|
|
31
|
+
const found = check(routed.map((l) => l.pattern))
|
|
32
|
+
if (!found) return
|
|
33
|
+
const at = routed[found.index]
|
|
34
|
+
const partner = found.withIndex != null ? routed[found.withIndex] : undefined
|
|
35
|
+
const who = partner
|
|
36
|
+
? `routes ${partner.id} (${partner.pattern}) and ${at?.id} (${at?.pattern}) cannot be installed together`
|
|
37
|
+
: `route ${at?.id} (${at?.pattern}) cannot be installed`
|
|
38
|
+
throw new BuildError('route-conflict', `${who}: ${found.message}`)
|
|
39
|
+
}
|
package/src/build/ssg.ts
CHANGED
|
@@ -77,7 +77,8 @@ function checkValue(leaf: FlatRoute, i: number, name: string, rest: boolean, v:
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** Plans every page. `params` holds each dynamic route's resolved `ssg.params()` by route id. */
|
|
80
|
-
|
|
80
|
+
/** F495: under `base` (`[server] base_path`) the not-found probe is a path under it, so the app's `*` route answers it rather than the server's plain 404. */
|
|
81
|
+
export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>, base = ''): StaticPlan {
|
|
81
82
|
const pages: PlannedPage[] = []
|
|
82
83
|
const skipped: StaticPlan['skipped'] = []
|
|
83
84
|
let catchAll: FlatRoute | undefined
|
|
@@ -133,7 +134,7 @@ export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>): S
|
|
|
133
134
|
seen.set(key, p)
|
|
134
135
|
}
|
|
135
136
|
if (seen.has('404.html')) throw new BuildError('ssg-params', `${seen.get('404.html')!.path} would overwrite 404.html`)
|
|
136
|
-
if (catchAll) pages.push({ path: NOT_FOUND_PATH
|
|
137
|
+
if (catchAll) pages.push({ path: `${base}${NOT_FOUND_PATH}`, file: '404.html', routeId: catchAll.id, pattern: catchAll.pattern, expect: 404 })
|
|
137
138
|
return { pages, skipped, defaultNotFound: catchAll === undefined }
|
|
138
139
|
}
|
|
139
140
|
|