@brust/core 2.0.0-alpha.33 → 2.0.0-alpha.35
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/native/index.d.ts +19 -1
- package/native/index.js +55 -54
- package/package.json +12 -10
- package/src/actions/client.ts +3 -4
- package/src/browser.ts +3 -0
- package/src/build/actions-only.ts +7 -10
- package/src/build/bundle.ts +21 -14
- package/src/build/cf-assets.ts +11 -9
- package/src/build/cf-wrangler.ts +3 -2
- package/src/build/cloudflare.ts +10 -6
- package/src/build/compile.ts +5 -2
- package/src/build/config.ts +26 -7
- package/src/build/css.ts +53 -12
- package/src/build/esm-resolve.ts +7 -2
- package/src/build/index.ts +44 -26
- package/src/build/manifest.ts +6 -0
- package/src/build/posix.ts +35 -0
- package/src/build/route-conflicts.ts +39 -0
- package/src/build/ssg.ts +3 -2
- package/src/build/static.ts +3 -2
- package/src/build/swap.ts +47 -0
- package/src/build/tailwind.ts +13 -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 +29 -1
- package/src/dev/bun.ts +11 -7
- package/src/dev/client.ts +4 -1
- package/src/dev/devtools.ts +5 -3
- package/src/dev/open.ts +7 -0
- package/src/dev/proxy.ts +21 -17
- package/src/dev.ts +5 -3
- package/src/index.ts +5 -0
- package/src/launcher-watch.ts +45 -0
- package/src/native.ts +7 -0
- package/src/routes.ts +13 -0
- package/src/run.ts +27 -0
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 }
|
package/src/build/cf-wrangler.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// `wrangler.jsonc` for `dist/cf/` (M4-CF R5, R6, R7): the user's app-root config merged with the keys brust owns.
|
|
2
2
|
// Pure apart from `readUserWrangler`. Never adds an `account_id`; copies one only if the user wrote it.
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
|
-
import { isAbsolute, join
|
|
4
|
+
import { isAbsolute, join } from 'node:path'
|
|
5
5
|
import { BuildError } from './errors'
|
|
6
|
+
import { relPosix } from './posix'
|
|
6
7
|
|
|
7
8
|
type Json = Record<string, unknown>
|
|
8
9
|
export interface UserWrangler {
|
|
@@ -49,7 +50,7 @@ export function workerNameOf(pkgName: string): string {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function rebase(o: MergeOpts, p: string): string {
|
|
52
|
-
return isAbsolute(p) ? p :
|
|
53
|
+
return isAbsolute(p) ? p : relPosix(o.cfDir, join(o.appRoot, p))
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function assertNoAssetsBinding(v: unknown, path: string): void {
|
package/src/build/cloudflare.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
6
6
|
import { builtinModules } from 'node:module'
|
|
7
7
|
import { dirname, join, relative } from 'node:path'
|
|
8
|
+
import { relPosix } from './posix'
|
|
8
9
|
import type { BunPlugin } from 'bun'
|
|
9
10
|
import { BrustConfigError, type CorsConfig, loadCors } from '../config'
|
|
10
11
|
import { layoutAssets } from './cf-assets'
|
|
@@ -76,7 +77,7 @@ export interface CfBuildCtx {
|
|
|
76
77
|
/** the FINAL dist dir (`out` is a tmp sibling that is renamed to it): wrangler paths are relative to `<dist>/cf` */
|
|
77
78
|
dist: string
|
|
78
79
|
compiled: Map<string, Compiled>
|
|
79
|
-
manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow'; stream?: string[]; maxStreamBytes?: number } | null; cache?: CacheRecord }
|
|
80
|
+
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 }
|
|
80
81
|
sourceOf: SourceOf
|
|
81
82
|
actionsEntry: string | null
|
|
82
83
|
/** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
|
|
@@ -90,6 +91,9 @@ export interface CfBuildCtx {
|
|
|
90
91
|
log: (s: string) => void
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/** 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). */
|
|
95
|
+
export const workerDefine = (basePath = ''): Record<string, string> => ({ ...PRODUCTION, 'process.env.BRUST_BASE_PATH': JSON.stringify(basePath) })
|
|
96
|
+
|
|
93
97
|
/** The resolution conditions of the Worker bundle; the package-resolver probe (`sourceDirPlugin`) must carry the same (F223). */
|
|
94
98
|
export const CF_CONDITIONS = ['workerd', 'worker', 'browser']
|
|
95
99
|
|
|
@@ -102,7 +106,7 @@ function cfGuard(appRoot: string, violations: string[]): BunPlugin {
|
|
|
102
106
|
name: 'brust-cf-guard',
|
|
103
107
|
setup(b) {
|
|
104
108
|
b.onResolve({ filter: /^bun(:.*)?$/ }, (a) => {
|
|
105
|
-
violations.push(`${
|
|
109
|
+
violations.push(`${relPosix(appRoot, a.importer) || a.importer} imports ${a.path}`)
|
|
106
110
|
return { path: a.path, external: true }
|
|
107
111
|
})
|
|
108
112
|
b.onResolve({ filter: /^[a-z_]+(\/[a-z_/]+)?$/ }, (a) => (BUILTINS.has(a.path) ? { path: `node:${a.path}`, external: true } : undefined))
|
|
@@ -117,7 +121,7 @@ function cfAlias(alias: Record<string, string>, sourceOf: SourceOf): BunPlugin {
|
|
|
117
121
|
setup(b) {
|
|
118
122
|
if (Object.keys(alias).length === 0) return
|
|
119
123
|
b.onResolve({ filter: /.*/ }, (a) => {
|
|
120
|
-
if (a.importer === '' || a.importer
|
|
124
|
+
if (a.importer === '' || /[\\/]node_modules[\\/]/.test(a.importer)) return undefined
|
|
121
125
|
let abs: string
|
|
122
126
|
try {
|
|
123
127
|
abs = Bun.resolveSync(a.path, dirname(sourceOf.get(a.importer) ?? a.importer))
|
|
@@ -287,10 +291,10 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
287
291
|
target: 'browser',
|
|
288
292
|
format: 'esm',
|
|
289
293
|
conditions: CF_CONDITIONS,
|
|
290
|
-
define:
|
|
294
|
+
define: workerDefine(ctx.manifest.base_path),
|
|
291
295
|
minify: false,
|
|
292
296
|
external: ['node:*', 'cloudflare:*', glueSpec],
|
|
293
|
-
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)],
|
|
297
|
+
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)],
|
|
294
298
|
})
|
|
295
299
|
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`)
|
|
296
300
|
if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
|
|
@@ -302,7 +306,7 @@ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepare
|
|
|
302
306
|
writeFileSync(appFile, await js[0]!.text())
|
|
303
307
|
|
|
304
308
|
// An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
|
|
305
|
-
if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
|
|
309
|
+
if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion, ctx.manifest.base_path ?? '').warnings)
|
|
306
310
|
const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
|
|
307
311
|
if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
|
|
308
312
|
if (doClass) {
|
package/src/build/compile.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// A component several trees reach keeps one compile, chosen by role, not by route order (F75).
|
|
5
5
|
import { readFileSync, realpathSync } from 'node:fs'
|
|
6
6
|
import { dirname, relative, sep } from 'node:path'
|
|
7
|
+
import { nativeOf, relPosix } from './posix'
|
|
7
8
|
import { type CompiledComponent, type CompiledTree, compileTree, scanLoadWrites } from '../../native/index.js'
|
|
8
9
|
import type { FlatRoute, Route } from '../routes'
|
|
9
10
|
import { BuildError } from './errors'
|
|
@@ -29,6 +30,8 @@ export type ComponentIR = Record<string, any> & {
|
|
|
29
30
|
exprs?: Record<string, string>
|
|
30
31
|
/** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
|
|
31
32
|
rows_literal?: unknown[]
|
|
33
|
+
/** F502: the branch condition the server checks per instance before it runs the child's job. */
|
|
34
|
+
when?: unknown
|
|
32
35
|
}[]
|
|
33
36
|
/** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
|
|
34
37
|
build_eval_instances?: {
|
|
@@ -196,7 +199,7 @@ export function compileApp(opts: {
|
|
|
196
199
|
)
|
|
197
200
|
let rootId = rootIdOfFile.get(file)
|
|
198
201
|
if (!rootId) {
|
|
199
|
-
const rel =
|
|
202
|
+
const rel = relPosix(appRoot, file) // G5: the Rust resolver assumes `/`
|
|
200
203
|
const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson, sourceRoots, opts.loadWrites)
|
|
201
204
|
if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
|
|
202
205
|
for (const [i, c] of tree.components.entries()) {
|
|
@@ -226,7 +229,7 @@ export function compileApp(opts: {
|
|
|
226
229
|
ir.stores ??= []
|
|
227
230
|
compiled.set(c.id, {
|
|
228
231
|
id: c.id,
|
|
229
|
-
file: `${appRoot}/${c.source}
|
|
232
|
+
file: nativeOf(`${appRoot}/${c.source}`), // the spelling Bun.resolveSync returns: these are Map keys and `===` operands
|
|
230
233
|
ir,
|
|
231
234
|
jinja: c.jinja,
|
|
232
235
|
serverTs: c.serverTs,
|
package/src/build/config.ts
CHANGED
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
streamEntryError,
|
|
23
23
|
} from '../actions/prefix'
|
|
24
24
|
import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
|
|
25
|
+
import { normalizeBasePath } from '../config'
|
|
25
26
|
import { BuildError } from './errors'
|
|
27
|
+
import { posixOf } from './posix'
|
|
26
28
|
|
|
27
29
|
// [actions] (opt-in, ruling R6): present table => actions on. Both byte caps cross napi as u32 (F82: 1..64 MiB / 256 KiB..64 MiB).
|
|
28
30
|
// [actions]
|
|
@@ -107,6 +109,8 @@ export interface BuildConfig {
|
|
|
107
109
|
cssGlobal: string[]
|
|
108
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. */
|
|
109
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
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -273,7 +277,7 @@ function resolveAlias(file: string, appRoot: string, p: string): string {
|
|
|
273
277
|
}
|
|
274
278
|
}
|
|
275
279
|
|
|
276
|
-
function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
|
|
280
|
+
function parseActions(file: string, raw: unknown, actionsOnly: boolean, basePath = ''): ActionsBuildConfig | null {
|
|
277
281
|
if (raw === undefined) return null
|
|
278
282
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
|
|
279
283
|
const t = raw as Record<string, unknown>
|
|
@@ -282,12 +286,16 @@ function parseActions(file: string, raw: unknown, actionsOnly: boolean): Actions
|
|
|
282
286
|
if (typeof t.entry !== 'string' || t.entry.trim() === '') throw new BuildError('config', `${file}: actions.entry must be a non-empty string`)
|
|
283
287
|
entry = t.entry.trim().replace(/^\.\//, '')
|
|
284
288
|
}
|
|
285
|
-
|
|
289
|
+
// F495: the default prefix lives under the base path, like every URL brust owns.
|
|
290
|
+
let prefix = `${basePath}${DEFAULT_ACTION_PREFIX}`
|
|
286
291
|
if (t.prefix !== undefined) {
|
|
287
292
|
// `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
|
|
288
293
|
const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
|
|
289
294
|
if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
|
|
290
295
|
prefix = t.prefix as string
|
|
296
|
+
// F495: an explicit prefix is used as written, and must itself be under the base path.
|
|
297
|
+
if (basePath !== '' && prefix !== basePath && !prefix.startsWith(`${basePath}/`))
|
|
298
|
+
throw new BuildError('config', `${file}: actions.prefix ${JSON.stringify(prefix)} must lie under server.base_path ${JSON.stringify(basePath)}`)
|
|
291
299
|
}
|
|
292
300
|
let maxBodyBytes = DEFAULT_MAX_BODY_BYTES
|
|
293
301
|
if (t.max_body_bytes !== undefined) {
|
|
@@ -396,10 +404,20 @@ export function parseSourceRoots(file: string, appRoot: string, raw: unknown): s
|
|
|
396
404
|
return [...out].sort()
|
|
397
405
|
}
|
|
398
406
|
|
|
407
|
+
/** `[server] base_path` (F495): the one validator is config.ts's; a bad value is a build error here too. */
|
|
408
|
+
function parseBasePath(file: string, server: unknown): string {
|
|
409
|
+
if (server === undefined) return ''
|
|
410
|
+
if (!isTable(server)) throw new BuildError('config', `${file}: [server] must be a table`)
|
|
411
|
+
if (server.base_path === undefined) return ''
|
|
412
|
+
const r = normalizeBasePath(server.base_path)
|
|
413
|
+
if (!r.ok) throw new BuildError('config', `${file}: server.base_path ${r.why}`)
|
|
414
|
+
return r.value
|
|
415
|
+
}
|
|
416
|
+
|
|
399
417
|
export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
|
|
400
418
|
const file = join(appRoot, 'brust.toml')
|
|
401
419
|
const f = Bun.file(file)
|
|
402
|
-
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [] }
|
|
420
|
+
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [], basePath: '' }
|
|
403
421
|
let parsed: unknown
|
|
404
422
|
try {
|
|
405
423
|
parsed = Bun.TOML.parse(await f.text())
|
|
@@ -407,17 +425,18 @@ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolea
|
|
|
407
425
|
throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
|
|
408
426
|
}
|
|
409
427
|
const root = (parsed ?? {}) as Record<string, unknown>
|
|
410
|
-
const
|
|
428
|
+
const basePath = parseBasePath(file, root.server)
|
|
429
|
+
const actions = parseActions(file, root.actions, o.actionsOnly === true, basePath)
|
|
411
430
|
const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
|
|
412
431
|
const cache = await parseCache(file, appRoot, root.cache)
|
|
413
432
|
const cssGlobal = parseCss(file, root.css)
|
|
414
433
|
const build = root.build
|
|
415
|
-
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [] }
|
|
434
|
+
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [], basePath }
|
|
416
435
|
if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
|
|
417
436
|
const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
|
|
418
437
|
const so = (build as Record<string, unknown>).server_only
|
|
419
|
-
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots }
|
|
438
|
+
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
|
|
420
439
|
if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
|
|
421
440
|
throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
|
|
422
|
-
return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots }
|
|
441
|
+
return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
|
|
423
442
|
}
|
package/src/build/css.ts
CHANGED
|
@@ -122,9 +122,27 @@ const ASSET_FILE = /\.(?:woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico)$/i
|
|
|
122
122
|
|
|
123
123
|
const SOURCE_URL = /url\(\s*(["']?)([^"')]+?)\1\s*\)/g
|
|
124
124
|
|
|
125
|
+
/** A `url()` value as the file system spells it: CSS escapes undone (Bun prints a Windows path as `C:\\Users\\…`). */
|
|
126
|
+
/** CSS Syntax 4.3.7: an escaped code point that is zero, a surrogate or above U+10FFFF is U+FFFD (never a RangeError). */
|
|
127
|
+
const cssCodePoint = (n: number): number => (n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? 0xfffd : n)
|
|
128
|
+
|
|
129
|
+
export function cssUnescape(s: string): string {
|
|
130
|
+
return s.replace(/\\(?:([0-9a-fA-F]{1,6})\s?|([^\n0-9a-fA-F]))/g, (_m, h: string | undefined, c: string | undefined) =>
|
|
131
|
+
h !== undefined ? String.fromCodePoint(cssCodePoint(Number.parseInt(h, 16))) : c!,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
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
|
+
|
|
139
|
+
/** F490: an absolute local path in either spelling: POSIX `/…`, a Windows drive `C:\…` / `C:/…`, or a UNC `\\host\…`. */
|
|
140
|
+
export const localAbsolute = (p: string): boolean => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
|
|
141
|
+
|
|
125
142
|
/** F393: a `url()` that is the absolute path of a file on disk (see the `loader` in `buildCss`) becomes a
|
|
126
|
-
* hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written.
|
|
127
|
-
|
|
143
|
+
* hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written.
|
|
144
|
+
* F490: on Windows that path is `C:\\…` (CSS-escaped), so the value is unescaped and both spellings are taken. */
|
|
145
|
+
function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>, base = ''): string {
|
|
128
146
|
const emit = (path: string, suffix: string) => {
|
|
129
147
|
const { name, bytes } = hashedAsset(path)
|
|
130
148
|
if (!assets.some((a) => a.name === name)) assets.push({ name, bytes })
|
|
@@ -133,12 +151,29 @@ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Arra
|
|
|
133
151
|
return text.replace(SOURCE_URL, (whole, _q: string, path: string) => {
|
|
134
152
|
const abs = suffixed.get(path)
|
|
135
153
|
if (abs !== undefined) return emit(abs, path.slice(path.search(/[?#]/)))
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
154
|
+
const file = cssUnescape(path)
|
|
155
|
+
const ext = extname(file).toLowerCase()
|
|
156
|
+
if (!localAbsolute(file) || !SERVED_ASSET_EXT.includes(ext) || brustUrl(file, base) || !existsSync(file)) return whole
|
|
157
|
+
return emit(file, '')
|
|
139
158
|
})
|
|
140
159
|
}
|
|
141
160
|
|
|
161
|
+
/** F490 fail closed: a `url()` left in the built CSS that still names a file on this machine would ship the machine's
|
|
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
|
|
163
|
+
* the app root or the dist dir (`roots`). Any other root URL passes, even one that happens to exist on the build
|
|
164
|
+
* machine (`url(/tmp/x)`, `url(/fonts/x.woff2)`): only the app's own files are known to be local. */
|
|
165
|
+
export function leakedLocalPaths(text: string, roots: string[], base = ''): string[] {
|
|
166
|
+
const out: string[] = []
|
|
167
|
+
for (const m of text.matchAll(SOURCE_URL)) {
|
|
168
|
+
const v = cssUnescape(m[2]!)
|
|
169
|
+
if (brustUrl(v, base) || !localAbsolute(v)) continue
|
|
170
|
+
const posix = v.replaceAll('\\', '/')
|
|
171
|
+
const local = !v.startsWith('/') || roots.some((r) => posix === r || posix.startsWith(`${r}/`))
|
|
172
|
+
if (local && !out.includes(v)) out.push(v)
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
142
177
|
/** F393: a `url()` asset with a `?query` / `#fragment` (`?v=3`, `?#iefix`, an SVG font's `#id`) is not given
|
|
143
178
|
* Bun's path form: a plugin-resolved specifier is printed as written. This plugin resolves such a specifier
|
|
144
179
|
* itself and records the file it names, so `emitSourceAssets` can copy it and keep the suffix verbatim. The
|
|
@@ -172,7 +207,7 @@ export async function buildCss(
|
|
|
172
207
|
dist: string,
|
|
173
208
|
sources: string[],
|
|
174
209
|
minify = true,
|
|
175
|
-
o: { appRoot?: string; log?: (s: string) => void } = {},
|
|
210
|
+
o: { appRoot?: string; log?: (s: string) => void; basePath?: string; ownOutput?: string[] } = {},
|
|
176
211
|
): Promise<CssBuild> {
|
|
177
212
|
const file = new Map<string, string>()
|
|
178
213
|
const maps = new Map<string, Record<string, string>>()
|
|
@@ -189,7 +224,9 @@ export async function buildCss(
|
|
|
189
224
|
if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
|
|
190
225
|
return hit
|
|
191
226
|
}
|
|
192
|
-
|
|
227
|
+
// F501: the output dirs of this app that Tailwind must not scan, as app-root-relative patterns (only those under it).
|
|
228
|
+
const own = (o.ownOutput ?? []).map((d) => relative(real(appRoot), real(d)).split(sep).join('/')).filter((r) => r !== '' && r !== '..' && !r.startsWith('../') && !isAbsolute(r))
|
|
229
|
+
plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log, [...new Set(own)]))
|
|
193
230
|
if (tw.imports.length > 1)
|
|
194
231
|
o.log?.(
|
|
195
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`,
|
|
@@ -210,7 +247,7 @@ export async function buildCss(
|
|
|
210
247
|
format: 'esm',
|
|
211
248
|
minify,
|
|
212
249
|
naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
|
|
213
|
-
external: ['/public/*', '/_brust/*'],
|
|
250
|
+
external: ['/public/*', '/_brust/*', ...(o.basePath ? [`${o.basePath}/public/*`, `${o.basePath}/_brust/*`] : [])],
|
|
214
251
|
root: appRoot,
|
|
215
252
|
plugins,
|
|
216
253
|
// F393, verified on Bun 1.4.4 only: its CSS bundler turns an asset under the size threshold into a `data:`
|
|
@@ -258,7 +295,11 @@ export async function buildCss(
|
|
|
258
295
|
`${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
|
|
259
296
|
)
|
|
260
297
|
}
|
|
261
|
-
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))
|
|
299
|
+
const roots = [appRoot, dist].flatMap((r) => [r, real(r)]).map((r) => r.replaceAll('\\', '/').replace(/\/+$/, '')).filter((r) => r !== '')
|
|
300
|
+
const leaks = [...css].flatMap(([i, t]) => leakedLocalPaths(t, roots, o.basePath).map((p) => `url(${p}) in ${sources[i]}`))
|
|
301
|
+
if (leaks.length > 0)
|
|
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/…`)
|
|
262
303
|
for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
|
|
263
304
|
for (const [i, src] of sources.entries()) {
|
|
264
305
|
let text = css.get(i)
|
|
@@ -316,9 +357,9 @@ export function tailwindUse(sources: string[]): { using: string[]; imports: stri
|
|
|
316
357
|
* (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
|
|
317
358
|
* stylesheet itself is linked by the server (R19.3), never bundled into JS.
|
|
318
359
|
* F411: with `clientDir` (`<dist>/client`), an import of a served asset (`SERVED_ASSET_EXT`: a font, an image) is
|
|
319
|
-
* 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
|
|
320
361
|
* file is written there, so the URL can be preloaded (`<link rel="preload" as="font" href={font}>`). */
|
|
321
|
-
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 {
|
|
322
363
|
return {
|
|
323
364
|
name: 'brust-css',
|
|
324
365
|
setup(b) {
|
|
@@ -336,7 +377,7 @@ export function cssPlugin(maps: Map<string, Record<string, string>>, clientDir?:
|
|
|
336
377
|
mkdirSync(clientDir, { recursive: true })
|
|
337
378
|
writeFileSync(to, bytes)
|
|
338
379
|
}
|
|
339
|
-
return { contents: `export default ${JSON.stringify(
|
|
380
|
+
return { contents: `export default ${JSON.stringify(`${basePath}/_brust/client/${name}`)}\n`, loader: 'js' }
|
|
340
381
|
})
|
|
341
382
|
},
|
|
342
383
|
}
|
package/src/build/esm-resolve.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { createHash } from 'node:crypto'
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
11
11
|
import { dirname, join, sep } from 'node:path'
|
|
12
12
|
import { BuildError } from './errors'
|
|
13
|
+
import { nativeOf } from './posix'
|
|
13
14
|
|
|
14
15
|
/** A specifier relative to its importer: `./x`, `../x` and the bare `.` / `..` (a directory). The one test every resolver path uses (F218, F244). */
|
|
15
16
|
export const RELATIVE_SPEC = /^\.\.?(\/|$)/
|
|
@@ -46,11 +47,14 @@ export function clearResolveCache(): void {
|
|
|
46
47
|
const PROBE = `
|
|
47
48
|
const [target, entry, spec, define, conditions] = process.argv.slice(1)
|
|
48
49
|
const source = 'import ' + JSON.stringify(spec) + '\\n'
|
|
50
|
+
// F490: on Windows the virtual entry comes back to onLoad as C:/…/__brust_resolve__.ts while argv spells it C:\\…, so
|
|
51
|
+
// the two compare in one spelling; else the entry itself was taken for the answer.
|
|
52
|
+
const entryPosix = entry.replaceAll('\\\\', '/')
|
|
49
53
|
let found
|
|
50
54
|
await Bun.build({
|
|
51
55
|
entrypoints: [entry], target, write: false, define: JSON.parse(define), conditions: JSON.parse(conditions), files: { [entry]: source },
|
|
52
56
|
plugins: [{ name: 'o', setup(b) { b.onLoad({ filter: /./ }, (a) => {
|
|
53
|
-
if (a.path ===
|
|
57
|
+
if (a.path.replaceAll('\\\\', '/') === entryPosix) return { contents: source, loader: 'ts' }
|
|
54
58
|
found ??= a.path
|
|
55
59
|
return { contents: '', loader: 'js' }
|
|
56
60
|
}) } }],
|
|
@@ -77,7 +81,8 @@ export function resolveNative(spec: string, from: string, target: ResolveTarget,
|
|
|
77
81
|
const timeout = opts.timeoutMs ?? RESOLVE_TIMEOUT_MS
|
|
78
82
|
const r = Bun.spawnSync([process.execPath, '-e', PROBE, target, join(from, '__brust_resolve__.ts'), spec, defs, conds], { cwd: opts.appRoot ?? from, stdout: 'pipe', stderr: 'pipe', timeout, killSignal: 'SIGKILL' })
|
|
79
83
|
const out = r.exitCode === 0 ? r.stdout.toString() : ''
|
|
80
|
-
|
|
84
|
+
// F490: one spelling out of the resolver, the platform's (a file system path; POSIX: unchanged).
|
|
85
|
+
let path = nativeOf(out)
|
|
81
86
|
if (path === '') {
|
|
82
87
|
// The probe did not answer: today's answer (`Bun.resolveSync`) stands, and the build output says why.
|
|
83
88
|
const why = r.exitCode === null || r.signalCode ? `the probe did not answer within ${timeout} ms` : r.exitCode !== 0 ? `the probe failed (${r.stderr.toString().trim().split('\n')[0] || `exit ${r.exitCode}`})` : 'the probe resolved nothing'
|
package/src/build/index.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
// a previous brust dist (no `manifest.json` / `.brust` marker; `--force` overrides), is refused
|
|
6
6
|
// before anything is written or removed.
|
|
7
7
|
import { createHash } from 'node:crypto'
|
|
8
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync,
|
|
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'
|
|
@@ -19,6 +20,8 @@ import { type Compiled, compileApp, scanAppWrites } from './compile'
|
|
|
19
20
|
import { buildCss, CssGraph, directCss, resolveGlobals, routeCssSources } from './css'
|
|
20
21
|
import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
|
|
21
22
|
import { BuildError } from './errors'
|
|
23
|
+
import { escapes, relPosix } from './posix'
|
|
24
|
+
import { swapDist } from './swap'
|
|
22
25
|
import { importsStore } from './features'
|
|
23
26
|
import { type CacheRecord, type ManifestJson, needsWorkers, reachedJobs, tierOf, writeManifest } from './manifest'
|
|
24
27
|
import { scanRoutes } from './scan'
|
|
@@ -83,6 +86,14 @@ function statOk(p: string): boolean {
|
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
function realRoot(p: string): string {
|
|
90
|
+
try {
|
|
91
|
+
return realpathSync(p)
|
|
92
|
+
} catch {
|
|
93
|
+
return p
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
export async function runBuild(opts: {
|
|
87
98
|
appRoot: string
|
|
88
99
|
entry: string
|
|
@@ -96,6 +107,8 @@ export async function runBuild(opts: {
|
|
|
96
107
|
strict?: boolean
|
|
97
108
|
/** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
|
|
98
109
|
exprReads?: ExprReads
|
|
110
|
+
/** The router's refusal of a pattern set (F496; tests swap it). Default: the addon. */
|
|
111
|
+
checkPatterns?: CheckPatterns
|
|
99
112
|
/** `--wasm-from-source` (cloudflare only). */
|
|
100
113
|
wasmFromSource?: boolean
|
|
101
114
|
/** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
|
|
@@ -105,15 +118,19 @@ export async function runBuild(opts: {
|
|
|
105
118
|
log: (s: string) => void
|
|
106
119
|
}): Promise<void> {
|
|
107
120
|
setDevBuild(opts.dev === true)
|
|
108
|
-
|
|
121
|
+
// The app root is the real path: Windows keeps a symlinked cwd as the link, and `relative(link, file)` across drives is
|
|
122
|
+
// an absolute path that every `join(appRoot, rel)` then glues onto the link (N2). POSIX cwd is already real.
|
|
123
|
+
const appRoot = realRoot(resolve(opts.appRoot))
|
|
109
124
|
const entry = resolve(appRoot, opts.entry)
|
|
110
125
|
const dist = resolve(appRoot, opts.outDir)
|
|
111
126
|
assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
|
|
112
127
|
assertReplaceable(dist, opts.force)
|
|
113
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
|
|
114
131
|
if (opts.actionsOnly)
|
|
115
132
|
return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
|
|
116
|
-
const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots } = built
|
|
133
|
+
const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots, basePath } = built
|
|
117
134
|
// L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
|
|
118
135
|
// is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
|
|
119
136
|
const remote =
|
|
@@ -126,15 +143,18 @@ export async function runBuild(opts: {
|
|
|
126
143
|
let leaves: ReturnType<typeof validateRoutes>
|
|
127
144
|
try {
|
|
128
145
|
leaves = validateRoutes(routes)
|
|
146
|
+
checkBasePath(leaves, basePath)
|
|
129
147
|
} catch (e) {
|
|
130
148
|
if (e instanceof BrustRouteError) throw new BuildError(e.rule, e.message)
|
|
131
149
|
throw e
|
|
132
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()))
|
|
133
153
|
let actionsEntry: string | null = null
|
|
134
154
|
if (actions) {
|
|
135
155
|
actionsEntry = resolve(appRoot, actions.entry)
|
|
136
156
|
const rel = relative(appRoot, actionsEntry)
|
|
137
|
-
if (isAbsolute(actions.entry) ||
|
|
157
|
+
if (isAbsolute(actions.entry) || escapes(rel) || !statOk(actionsEntry)) {
|
|
138
158
|
throw new BuildError('actions-entry', `actions.entry ${actions.entry} is not a file inside the app`)
|
|
139
159
|
}
|
|
140
160
|
// F470: a route under the prefix answers GET and HEAD of its own paths, the actions app everything else there. The
|
|
@@ -155,7 +175,7 @@ export async function runBuild(opts: {
|
|
|
155
175
|
if (cache?.module !== undefined) {
|
|
156
176
|
cacheModule = resolve(appRoot, cache.module)
|
|
157
177
|
const rel = relative(appRoot, cacheModule)
|
|
158
|
-
if (isAbsolute(cache.module) ||
|
|
178
|
+
if (isAbsolute(cache.module) || escapes(rel) || !statOk(cacheModule)) {
|
|
159
179
|
throw new BuildError('cache', `cache.module ${cache.module} is not a file inside the app`)
|
|
160
180
|
}
|
|
161
181
|
}
|
|
@@ -164,7 +184,7 @@ export async function runBuild(opts: {
|
|
|
164
184
|
// R9.3/R9.4: every reason to refuse a static build, before any write.
|
|
165
185
|
const ssgPlan =
|
|
166
186
|
opts.target === 'static'
|
|
167
|
-
? 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)
|
|
168
188
|
: null
|
|
169
189
|
|
|
170
190
|
// Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
|
|
@@ -178,7 +198,7 @@ export async function runBuild(opts: {
|
|
|
178
198
|
rmSync(tmp, { recursive: true, force: true })
|
|
179
199
|
mkdirSync(tmp, { recursive: true })
|
|
180
200
|
try {
|
|
181
|
-
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, actionsRemote: remote?.prefix ?? null, log: opts.log })
|
|
182
202
|
if (ssgPlan) {
|
|
183
203
|
// Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
|
|
184
204
|
const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
|
|
@@ -186,13 +206,7 @@ export async function runBuild(opts: {
|
|
|
186
206
|
if (hybrid && cf && actions) writeStaticWorker(tmp, actions, cf, opts.log)
|
|
187
207
|
}
|
|
188
208
|
rmSync(join(tmp, '.stage'), { recursive: true, force: true })
|
|
189
|
-
|
|
190
|
-
const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
|
|
191
|
-
rmSync(old, { recursive: true, force: true })
|
|
192
|
-
renameSync(dist, old)
|
|
193
|
-
renameSync(tmp, dist)
|
|
194
|
-
rmSync(old, { recursive: true, force: true })
|
|
195
|
-
} else renameSync(tmp, dist)
|
|
209
|
+
swapDist(tmp, dist)
|
|
196
210
|
} catch (e) {
|
|
197
211
|
rmSync(tmp, { recursive: true, force: true })
|
|
198
212
|
throw e
|
|
@@ -227,6 +241,8 @@ async function buildInto(
|
|
|
227
241
|
cssGlobal: string[]
|
|
228
242
|
/** `[build] source_roots` (F256): normalised, app-root-relative directories outside the app a relative import may reach. */
|
|
229
243
|
sourceRoots: string[]
|
|
244
|
+
/** `[server] base_path` (F495), `''` = the root. */
|
|
245
|
+
basePath: string
|
|
230
246
|
/** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
|
|
231
247
|
actionsRemote: string | null
|
|
232
248
|
/** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
|
|
@@ -242,13 +258,13 @@ async function buildInto(
|
|
|
242
258
|
const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
|
|
243
259
|
mkdirSync(join(out, 'client'), { recursive: true })
|
|
244
260
|
// Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
|
|
245
|
-
const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log })
|
|
261
|
+
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, '.brust')] })
|
|
246
262
|
for (const c of directCss(entry))
|
|
247
263
|
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`)
|
|
248
|
-
const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client') }
|
|
264
|
+
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 }
|
|
249
265
|
// The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
|
|
250
266
|
const runtime = await buildRuntime(out, ctx)
|
|
251
|
-
const runtimeUrls = { core:
|
|
267
|
+
const runtimeUrls = { core: `${brustRoot(ctx)}/${runtime.core}`, store: `${brustRoot(ctx)}/${runtime.store}` }
|
|
252
268
|
// F230: one scan per build for load-time writes into another app module's exports; every compile reads it.
|
|
253
269
|
const loadWrites = scanAppWrites(appRoot, o.appModules, o.log, o.sourceRoots)
|
|
254
270
|
// Where a React island renders, the load-time writers run first: every one in the SSR jobs bundle (server code the
|
|
@@ -277,12 +293,12 @@ async function buildInto(
|
|
|
277
293
|
}
|
|
278
294
|
const out = writes.filter((w) => [...w.mods].some((m) => reached.has(m)))
|
|
279
295
|
for (const w of out) {
|
|
280
|
-
const mods = [...w.mods].filter((m) => reached.has(m)).map((m) =>
|
|
281
|
-
o.log(`[brust] warning load-writer-in-client ${
|
|
296
|
+
const mods = [...w.mods].filter((m) => reached.has(m)).map((m) => relPosix(appRoot, m)).join(', ')
|
|
297
|
+
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`)
|
|
282
298
|
// F480: a server-only import reached through this writer names the chain in `server-only-in-client`.
|
|
283
299
|
ctx.loadWriters ??= new Map()
|
|
284
300
|
if (!ctx.loadWriters.has(w.file))
|
|
285
|
-
ctx.loadWriters.set(w.file, `${
|
|
301
|
+
ctx.loadWriters.set(w.file, `${relPosix(appRoot, w.file)} is loaded into the React island ${c.id} because it is judged to write ${mods} when it loads: F230 load writes`)
|
|
286
302
|
}
|
|
287
303
|
return [...new Set(out.map((w) => w.file))]
|
|
288
304
|
}
|
|
@@ -340,10 +356,12 @@ async function buildInto(
|
|
|
340
356
|
const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
|
|
341
357
|
if (files.length > 0) r.css = files
|
|
342
358
|
}
|
|
343
|
-
await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers)
|
|
359
|
+
await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers, o.basePath)
|
|
360
|
+
// F495: absent without a base path, so an app at the root writes the manifest it always did.
|
|
361
|
+
if (o.basePath !== '') manifest.base_path = o.basePath
|
|
344
362
|
manifest.actions =
|
|
345
363
|
o.actions && o.actionsEntry
|
|
346
|
-
? { 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 }
|
|
364
|
+
? { 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 }
|
|
347
365
|
: null
|
|
348
366
|
if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
|
|
349
367
|
if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
|
|
@@ -376,7 +394,7 @@ async function buildInto(
|
|
|
376
394
|
)
|
|
377
395
|
for (const w of warnings) o.log(w)
|
|
378
396
|
}
|
|
379
|
-
const entryRel =
|
|
397
|
+
const entryRel = relPosix(o.dist, entry) // `/`-spelled: a dist built on Windows runs on Linux (join() below re-spells it)
|
|
380
398
|
writeFileSync(
|
|
381
399
|
join(out, 'index.js'),
|
|
382
400
|
[
|
|
@@ -399,7 +417,7 @@ export function devJson(appRoot: string, compiled: Map<string, Compiled>): { ver
|
|
|
399
417
|
const tier = tierOf(c.ir)
|
|
400
418
|
const t = c.ir.tier
|
|
401
419
|
components[c.id] = {
|
|
402
|
-
file:
|
|
420
|
+
file: relPosix(appRoot, c.file),
|
|
403
421
|
tier,
|
|
404
422
|
...(tier === 'react' && t && typeof t === 'object' && 'React' in t ? { reason: String((t as { React: { reason: string } }).React.reason) } : {}),
|
|
405
423
|
diagnostics: (c.diagnostics ?? []).map((d) => ({ class: d.class, rule: d.rule, line: d.line, col: d.col, message: d.message, remediation: d.remediation })),
|