@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.
@@ -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
- export function layoutAssets(out: string, cfDir: string, version: string): { files: string[]; warnings: string[] } {
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), `_brust/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), `public/${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
- `/_brust/client/*\n Cache-Control: ${IMMUTABLE}`,
75
+ `${base}/_brust/client/*\n Cache-Control: ${IMMUTABLE}`,
74
76
  ]
75
- const pub = files.filter((f) => f.startsWith('public/'))
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(`/public/*\n Cache-Control: ${PUBLIC_DEFAULT}`)
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
+ }
@@ -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 core = resolveCoreCf({ appRoot, fromSource: flags.fromSource, env: flags.env })
46
+ const resolved = resolveCoreCf({ appRoot, fromSource: flags.fromSource, env: flags.env })
46
47
  const user = await readUserWrangler(appRoot)
47
- const warnings = [...core.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: PRODUCTION,
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) {
@@ -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?: {
@@ -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
- let prefix = DEFAULT_ACTION_PREFIX
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 actions = parseActions(file, root.actions, o.actionsOnly === true)
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>): 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.startsWith('/public/') || file.startsWith('/_brust/') || !existsSync(file)) return whole
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.startsWith('/public/') || v.startsWith('/_brust/') || !localAbsolute(v)) continue
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
- plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log))
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>`, the very file a stylesheet's `url()` of it is rewritten to; the
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(`/_brust/client/${name}`)}\n`, loader: 'js' }
380
+ return { contents: `export default ${JSON.stringify(`${basePath}/_brust/client/${name}`)}\n`, loader: 'js' }
375
381
  })
376
382
  },
377
383
  }
@@ -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 { type BrowserCtx, setDevBuild, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, nestedOf, storeModules } from './bundle'
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: `/_brust/${runtime.core}`, store: `/_brust/${runtime.store}` }
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
- if (!ctx.loadWriters.has(w.file))
291
- 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`)
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)
@@ -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
- export function planStatic(leaves: FlatRoute[], params: Map<string, unknown>): StaticPlan {
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, file: '404.html', routeId: catchAll.id, pattern: catchAll.pattern, expect: 404 })
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