@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -6
- package/native/index.d.ts +37 -2
- package/native/index.js +56 -54
- package/package.json +11 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +24 -2
- package/src/actions-bridge.ts +2 -2
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +199 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +39 -13
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +7 -3
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +14 -9
- package/src/build/compile.ts +41 -4
- package/src/build/config.ts +46 -10
- package/src/build/css.ts +286 -0
- package/src/build/index.ts +135 -20
- package/src/build/manifest.ts +23 -4
- package/src/build/scan.ts +59 -20
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +258 -0
- package/src/build-time.ts +10 -0
- package/src/cf.ts +2 -1
- package/src/cli.ts +19 -4
- package/src/config.ts +111 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +11 -28
- package/src/handlers.ts +41 -2
- package/src/index.ts +3 -1
- package/src/native.ts +18 -0
- package/src/routes.ts +20 -1
- package/src/run.ts +40 -8
- package/src/worker.ts +88 -20
package/src/actions/load.ts
CHANGED
|
@@ -14,7 +14,8 @@ export function mountActions(app: unknown, prefix: string, what = 'actions entry
|
|
|
14
14
|
throw new Error(
|
|
15
15
|
`${what}: build the app with new Elysia({ adapter: brust() }) — import { brust } from '@brust/core/elysia'`,
|
|
16
16
|
)
|
|
17
|
-
|
|
17
|
+
// L16 R16.4: the root prefix of an actions-only build is no prefix at all (`prefix: '/'` would double the slash).
|
|
18
|
+
return new Elysia({ adapter: brust(), ...(prefix === '/' ? {} : { prefix }) }).use(app)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export async function loadActionsApp(file: string, prefix: string): Promise<AnyElysia> {
|
package/src/actions/prefix.ts
CHANGED
|
@@ -5,8 +5,10 @@ export const DEFAULT_MAX_RESPONSE_BYTES = 8_388_608
|
|
|
5
5
|
export const MAX_ACTION_BYTES = 67_108_864
|
|
6
6
|
const SEG = /^[A-Za-z0-9._~-]+$/
|
|
7
7
|
|
|
8
|
-
/** Why `p` cannot be the actions prefix, or null. Segment-safe like the server's match.
|
|
9
|
-
|
|
8
|
+
/** Why `p` cannot be the actions prefix, or null. Segment-safe like the server's match. `root`: an actions-only
|
|
9
|
+
* build (L16 R16.4) may mount at `/`; a normal build never may (its page routes would be unreachable). */
|
|
10
|
+
export function prefixError(p: unknown, root = false): string | null {
|
|
11
|
+
if (root && p === '/') return null
|
|
10
12
|
if (typeof p !== 'string' || !p.startsWith('/') || p.length < 2 || p.endsWith('/'))
|
|
11
13
|
return `must start with '/', be longer than '/' and have no trailing '/' (got ${JSON.stringify(p)})`
|
|
12
14
|
const segs = p.slice(1).split('/')
|
|
@@ -21,3 +23,23 @@ export function prefixError(p: unknown): string | null {
|
|
|
21
23
|
return `${p} is reserved for the server's own /_brust/ paths`
|
|
22
24
|
return null
|
|
23
25
|
}
|
|
26
|
+
|
|
27
|
+
/** L16 R16.1: `base_url` is an absolute http(s) origin: scheme, host, optional port; no path, query, fragment or userinfo. */
|
|
28
|
+
export function baseUrlError(v: unknown): string | null {
|
|
29
|
+
if (typeof v !== 'string' || !/^https?:\/\//.test(v))
|
|
30
|
+
return `must be an absolute http(s) origin such as https://api.example.com (got ${JSON.stringify(v)})`
|
|
31
|
+
let u: URL
|
|
32
|
+
try {
|
|
33
|
+
u = new URL(v)
|
|
34
|
+
} catch {
|
|
35
|
+
return `is not a valid URL (got ${JSON.stringify(v)})`
|
|
36
|
+
}
|
|
37
|
+
const authority = v.replace(/^https?:\/\//, '')
|
|
38
|
+
if (u.search !== '' || u.hash !== '' || u.username !== '' || u.password !== '' || /[/?#@\\\s]/.test(authority))
|
|
39
|
+
return `must be an origin only: no path, query, fragment or credentials (got ${JSON.stringify(v)})`
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** L16 R16.2: the default `RequestInit.credentials` of the browser client. */
|
|
44
|
+
export const ACTION_CREDENTIALS = ['same-origin', 'include', 'omit'] as const
|
|
45
|
+
export type ActionCredentials = (typeof ACTION_CREDENTIALS)[number]
|
package/src/actions-bridge.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface FetchRequest {
|
|
|
22
22
|
method: string
|
|
23
23
|
url: string
|
|
24
24
|
headers: [string, string][]
|
|
25
|
-
/** `{heap: n}` (F82): more than 262144 bytes, fetched with `takeFetchBody(slot)`. */
|
|
25
|
+
/** `{heap: n}` (F82): more than 262144 bytes (`ACTION_INLINE_BODY_BYTES`, independent of the slot size), fetched with `takeFetchBody(slot)`. */
|
|
26
26
|
body: { text: string } | { b64: string } | { heap: number } | null
|
|
27
27
|
req: LoaderReq
|
|
28
28
|
}
|
|
@@ -139,7 +139,7 @@ export const noActions = async (_: Request): Promise<Response> =>
|
|
|
139
139
|
new Response('brust: actions are not configured in this worker', { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
|
140
140
|
|
|
141
141
|
/** The `fetch` call (makeDispatch). Never rejects, never writes JSON: every outcome is a frame. A body of
|
|
142
|
-
* more than 262144 bytes arrives by `io.takeBody` (A1 `{heap}`); a response that does not fit the slot leaves
|
|
142
|
+
* more than 262144 bytes (`ACTION_INLINE_BODY_BYTES`, not the slot size) arrives by `io.takeBody` (A1 `{heap}`); a response that does not fit the slot leaves
|
|
143
143
|
* by `io.putReply` with only status/headers/`heap` in the slot (F82), unless it exceeds `max_response_bytes`. */
|
|
144
144
|
export async function runFetch(requestJson: string, view: Uint8Array, slot: number, slots: number, io: HeapIo = nativeHeapIo): Promise<number> {
|
|
145
145
|
const a = app
|
package/src/browser.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
/// <reference path="./css-modules.d.ts" />
|
|
1
2
|
// `@brust/core` under the `browser` export condition (and what every browser bundle of
|
|
2
3
|
// `brust build` resolves the package root to): only the side-effect-free pieces. No `run`, no
|
|
3
4
|
// config, no napi addon — a react island may import `cache` from the package root.
|
|
4
5
|
import { cacheComponent } from './cache-core'
|
|
6
|
+
export { buildTime } from './build-time'
|
|
5
7
|
import type { InvalidateArgs, InvalidateResult } from './native'
|
|
6
8
|
|
|
7
9
|
function invalidate(_args: InvalidateArgs): InvalidateResult {
|
|
@@ -26,4 +28,4 @@ export {
|
|
|
26
28
|
Outlet,
|
|
27
29
|
redirect,
|
|
28
30
|
} from './routes'
|
|
29
|
-
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
|
|
31
|
+
export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, RouteSsgConfig, Verdict } from './routes'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// `brust build --actions-only` (gaps round L16, R16.3/R16.4): a dist with NO page routes: the actions app behind
|
|
2
|
+
// the same Rust admission (method gate, body caps, `Content-Encoding`, the cross-site guard, CORS), every other
|
|
3
|
+
// path a 404 problem+json. With `--target cloudflare` it also writes `<out>/cf/`: a Worker serving only actions
|
|
4
|
+
// (no Static Assets). The routes entry is neither read nor required.
|
|
5
|
+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
7
|
+
import { buildActions } from './bundle'
|
|
8
|
+
import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
|
|
9
|
+
import type { ActionsBuildConfig, BuildConfig } from './config'
|
|
10
|
+
import { BuildError } from './errors'
|
|
11
|
+
import { MANIFEST_VERSION, type ManifestJson } from './manifest'
|
|
12
|
+
|
|
13
|
+
const MARKER = '.brust'
|
|
14
|
+
|
|
15
|
+
export async function runActionsOnly(o: {
|
|
16
|
+
appRoot: string
|
|
17
|
+
dist: string
|
|
18
|
+
config: BuildConfig
|
|
19
|
+
target: 'bun' | 'cloudflare' | 'static' | undefined
|
|
20
|
+
wasmFromSource: boolean
|
|
21
|
+
statOk: (p: string) => boolean
|
|
22
|
+
assertSafeOutDir: (dist: string, appRoot: string, keep: string[]) => void
|
|
23
|
+
log: (s: string) => void
|
|
24
|
+
}): Promise<void> {
|
|
25
|
+
const { appRoot, dist, config } = o
|
|
26
|
+
const actions: ActionsBuildConfig | null = config.actions
|
|
27
|
+
if (!actions) throw new BuildError('actions-only', '--actions-only needs an [actions] table in brust.toml')
|
|
28
|
+
if (o.target === 'static') throw new BuildError('actions-only', '--actions-only builds no pages: it cannot be combined with --target static')
|
|
29
|
+
const entry = resolve(appRoot, actions.entry)
|
|
30
|
+
const rel = relative(appRoot, entry)
|
|
31
|
+
if (isAbsolute(actions.entry) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !o.statOk(entry))
|
|
32
|
+
throw new BuildError('actions-entry', `actions.entry ${actions.entry} is not a file inside the app`)
|
|
33
|
+
o.assertSafeOutDir(dist, appRoot, [entry])
|
|
34
|
+
if (config.cache) o.log('[brust] actions-only: [cache] and route options do not apply (ignored)')
|
|
35
|
+
if (actions.baseUrl !== undefined) o.log('[brust] actions-only: [actions] base_url is for the pages that call these actions (ignored here)')
|
|
36
|
+
const cf: CfPrepared | null = o.target === 'cloudflare' ? await prepareCloudflare(appRoot, config.cloudflare, { fromSource: o.wasmFromSource, env: process.env }) : null
|
|
37
|
+
|
|
38
|
+
const tmp = join(dirname(dist), `.${basename(dist)}.brust-tmp`)
|
|
39
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
40
|
+
mkdirSync(tmp, { recursive: true })
|
|
41
|
+
try {
|
|
42
|
+
const manifest: ManifestJson = {
|
|
43
|
+
version: MANIFEST_VERSION,
|
|
44
|
+
routes: [],
|
|
45
|
+
components: {},
|
|
46
|
+
assets: { runtime: '' },
|
|
47
|
+
jobs_module: 'jobs.js',
|
|
48
|
+
actions: { entry: await buildActions(tmp, entry), prefix: actions.prefix, maxBodyBytes: actions.maxBodyBytes, maxResponseBytes: actions.maxResponseBytes, crossSite: actions.crossSite },
|
|
49
|
+
actions_only: true,
|
|
50
|
+
}
|
|
51
|
+
mkdirSync(join(tmp, 'jinja'), { recursive: true })
|
|
52
|
+
writeFileSync(join(tmp, 'jobs.js'), '// generated by `brust build --actions-only`: no pages, so no jobs\nexport default {}\n')
|
|
53
|
+
writeFileSync(join(tmp, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
54
|
+
writeFileSync(join(tmp, MARKER), 'generated by `brust build`: this directory is replaced on every build\n')
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(tmp, 'index.js'),
|
|
57
|
+
[
|
|
58
|
+
'// generated by `brust build --actions-only` — `bun dist/index.js` serves the actions (do not edit)',
|
|
59
|
+
"process.env.BRUST_PREBUILT = '1'",
|
|
60
|
+
'process.env.BRUST_DIST_DIR = import.meta.dir',
|
|
61
|
+
"const { run } = await import('@brust/core')",
|
|
62
|
+
'await run()',
|
|
63
|
+
'',
|
|
64
|
+
].join('\n'),
|
|
65
|
+
)
|
|
66
|
+
if (cf) {
|
|
67
|
+
const warnings = await buildCloudflare(
|
|
68
|
+
tmp,
|
|
69
|
+
{ appRoot, entry, dist, compiled: new Map(), manifest, sourceOf: new Map(), actionsEntry: entry, actionsOnly: true, log: o.log },
|
|
70
|
+
cf,
|
|
71
|
+
)
|
|
72
|
+
for (const w of warnings) o.log(w)
|
|
73
|
+
}
|
|
74
|
+
rmSync(join(tmp, '.stage'), { recursive: true, force: true })
|
|
75
|
+
if (existsSync(dist)) {
|
|
76
|
+
const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
|
|
77
|
+
rmSync(old, { recursive: true, force: true })
|
|
78
|
+
renameSync(dist, old)
|
|
79
|
+
renameSync(tmp, dist)
|
|
80
|
+
rmSync(old, { recursive: true, force: true })
|
|
81
|
+
} else renameSync(tmp, dist)
|
|
82
|
+
} catch (e) {
|
|
83
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
84
|
+
throw e
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Build-time evaluation of precompute slots (L2, G4/H1). The compiler marks the slots that read
|
|
2
|
+
// nothing request- or time-dependent (`ir.build_eval`) and emits one thunk per slot (`evalTs`).
|
|
3
|
+
// Here they are bundled once, run in a Bun subprocess (`TZ=UTC`, a timeout per slot, JSON-only
|
|
4
|
+
// values, a size cap) and the results go back to the compiler as slot -> literal overrides: the
|
|
5
|
+
// slot, and a job left with no slots, disappear. A pure helper that fails keeps its job with a
|
|
6
|
+
// `build-eval` warning; a failing `buildTime(…)` is a build error (the user asked for it).
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import type { BunPlugin } from 'bun'
|
|
9
|
+
import { type SourceOf, PRODUCTION, sourceDirPlugin, stage, write } from './bundle'
|
|
10
|
+
import type { Compiled } from './compile'
|
|
11
|
+
import { BuildError } from './errors'
|
|
12
|
+
|
|
13
|
+
const V1 = { timeoutMs: 2_000, capBytes: 64 * 1024 }
|
|
14
|
+
const BUILD_TIME = { timeoutMs: 10_000, capBytes: 256 * 1024 }
|
|
15
|
+
|
|
16
|
+
interface Candidate {
|
|
17
|
+
key: string
|
|
18
|
+
bt: boolean
|
|
19
|
+
where: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The runner the subprocess executes: loads the bundle, guards the sources of nondeterminism,
|
|
23
|
+
* and prints `S <i>` before and `R <i> <json>` / `E <i> <message>` after each slot. */
|
|
24
|
+
const RUNNER = `
|
|
25
|
+
const mod = await import(process.argv[2])
|
|
26
|
+
const start = Number(process.argv[3])
|
|
27
|
+
const slots = mod.default
|
|
28
|
+
const deny = (what) => () => { throw new Error('build-eval: ' + what + ' is not allowed (it differs between runs)') }
|
|
29
|
+
const RealDate = globalThis.Date
|
|
30
|
+
globalThis.Date = new Proxy(RealDate, {
|
|
31
|
+
construct(t, a) { if (a.length === 0) deny('new Date()')(); return Reflect.construct(t, a) },
|
|
32
|
+
apply() { deny('Date()')() },
|
|
33
|
+
get(t, p, r) { if (p === 'now') deny('Date.now()')(); return Reflect.get(t, p, r) },
|
|
34
|
+
})
|
|
35
|
+
Math.random = deny('Math.random()')
|
|
36
|
+
if (globalThis.performance) globalThis.performance.now = deny('performance.now()')
|
|
37
|
+
if (globalThis.crypto) { for (const k of ['randomUUID', 'getRandomValues']) { try { globalThis.crypto[k] = deny('crypto.' + k + '()') } catch {} } }
|
|
38
|
+
globalThis.fetch = deny('fetch()')
|
|
39
|
+
const out = (l) => process.stdout.write(l + '\\n')
|
|
40
|
+
const env = process.env
|
|
41
|
+
try { Object.defineProperty(process, 'env', { configurable: true, get: deny('process.env') }) } catch {}
|
|
42
|
+
const enc = (v, path) => {
|
|
43
|
+
if (v === null || typeof v === 'boolean' || typeof v === 'string') return v
|
|
44
|
+
if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error('not JSON: ' + path + ' is ' + v); return v }
|
|
45
|
+
if (Array.isArray(v)) return v.map((x, i) => { if (x === undefined && !(i in v)) throw new Error('not JSON: sparse array at ' + path); return enc(x, path + '[' + i + ']') })
|
|
46
|
+
if (typeof v === 'object' && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)) {
|
|
47
|
+
return { $: Object.keys(v).map((k) => { if (v[k] === undefined) throw new Error('not JSON: ' + path + '.' + k + ' is undefined'); return [k, enc(v[k], path + '.' + k)] }) }
|
|
48
|
+
}
|
|
49
|
+
throw new Error('not JSON: ' + path + ' is ' + (v === undefined ? 'undefined' : typeof v === 'object' ? (v.constructor && v.constructor.name) : typeof v))
|
|
50
|
+
}
|
|
51
|
+
for (let i = start; i < slots.length; i++) {
|
|
52
|
+
out('S ' + i)
|
|
53
|
+
try {
|
|
54
|
+
const v = await slots[i].fn()
|
|
55
|
+
out('R ' + i + ' ' + JSON.stringify(enc(v, 'value')))
|
|
56
|
+
} catch (e) {
|
|
57
|
+
out('E ' + i + ' ' + String(e && e.message ? e.message : e).split('\\n')[0])
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
/** The evaluator sees an explicit allowlist, never the build host's environment: `process.env`,
|
|
63
|
+
* `Bun.env` and `import.meta.env` all read from it, however they are spelled. */
|
|
64
|
+
function evalEnv(): Record<string, string> {
|
|
65
|
+
const env: Record<string, string> = { TZ: 'UTC', NODE_ENV: 'production' }
|
|
66
|
+
for (const k of ['PATH', 'HOME']) if (process.env[k] !== undefined) env[k] = process.env[k]!
|
|
67
|
+
return env
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Outcome = { ok: true; value: unknown } | { ok: false; reason: string }
|
|
71
|
+
|
|
72
|
+
/** Runs slots `[0, n)` of `entry` one after the other, restarting the subprocess after a timeout or crash. */
|
|
73
|
+
async function runAll(entry: string, runner: string, cands: Candidate[], cwd: string): Promise<Outcome[]> {
|
|
74
|
+
const out: (Outcome | undefined)[] = new Array(cands.length).fill(undefined)
|
|
75
|
+
let next = 0
|
|
76
|
+
while (next < cands.length) {
|
|
77
|
+
const proc = Bun.spawn([process.execPath, runner, entry, String(next)], { cwd, env: evalEnv(), stdout: 'pipe', stderr: 'pipe' })
|
|
78
|
+
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader()
|
|
79
|
+
const dec = new TextDecoder()
|
|
80
|
+
let buf = ''
|
|
81
|
+
let current = -1
|
|
82
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
83
|
+
let timedOut = false
|
|
84
|
+
const arm = (i: number) => {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
const limit = cands[i]!.bt ? BUILD_TIME.timeoutMs : V1.timeoutMs
|
|
87
|
+
timer = setTimeout(() => {
|
|
88
|
+
timedOut = true
|
|
89
|
+
proc.kill('SIGKILL')
|
|
90
|
+
}, limit)
|
|
91
|
+
}
|
|
92
|
+
const line = (l: string) => {
|
|
93
|
+
const m = /^([SRE]) (\d+)(?: (.*))?$/.exec(l)
|
|
94
|
+
if (!m) return
|
|
95
|
+
const i = Number(m[2])
|
|
96
|
+
if (m[1] === 'S') {
|
|
97
|
+
current = i
|
|
98
|
+
arm(i)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
const cap = cands[i]!.bt ? BUILD_TIME.capBytes : V1.capBytes
|
|
103
|
+
if (m[1] === 'E') out[i] = { ok: false, reason: m[3] ?? 'threw' }
|
|
104
|
+
else if ((m[3] ?? '').length > cap) out[i] = { ok: false, reason: `value is larger than ${cap / 1024} KiB` }
|
|
105
|
+
else out[i] = { ok: true, value: JSON.parse(m[3]!) }
|
|
106
|
+
next = i + 1
|
|
107
|
+
current = -1
|
|
108
|
+
}
|
|
109
|
+
for (;;) {
|
|
110
|
+
const { done, value } = await reader.read()
|
|
111
|
+
if (done) break
|
|
112
|
+
buf += dec.decode(value, { stream: true })
|
|
113
|
+
let nl: number
|
|
114
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
115
|
+
line(buf.slice(0, nl))
|
|
116
|
+
buf = buf.slice(nl + 1)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
await proc.exited
|
|
121
|
+
if (current >= 0) {
|
|
122
|
+
const limit = (cands[current]!.bt ? BUILD_TIME.timeoutMs : V1.timeoutMs) / 1000
|
|
123
|
+
const stderr = (await new Response(proc.stderr as ReadableStream).text()).trim().split('\n')[0]
|
|
124
|
+
out[current] = { ok: false, reason: timedOut ? `timed out after ${limit} s` : `the evaluator exited${stderr ? `: ${stderr}` : ''}` }
|
|
125
|
+
next = current + 1
|
|
126
|
+
} else if (next < cands.length && !out[next]) {
|
|
127
|
+
// Exited before reporting anything (the bundle failed to load).
|
|
128
|
+
const stderr = (await new Response(proc.stderr as ReadableStream).text()).trim().split('\n').slice(0, 3).join(' | ')
|
|
129
|
+
for (let i = next; i < cands.length; i++) out[i] = { ok: false, reason: `the evaluator could not start${stderr ? `: ${stderr}` : ''}` }
|
|
130
|
+
next = cands.length
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out as Outcome[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `@brust/core` inside the evaluator is the side-effect-free `buildTime` module, not the server. */
|
|
137
|
+
function corePlugin(appRoot: string): BunPlugin {
|
|
138
|
+
return {
|
|
139
|
+
name: 'brust-eval-core',
|
|
140
|
+
setup(b) {
|
|
141
|
+
b.onResolve({ filter: /^@brust\/core$/ }, () => ({ path: Bun.resolveSync('@brust/core/build-time', appRoot) }))
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Evaluates every build-time slot of `compiled`; returns `key -> encoded JSON value` for those that
|
|
147
|
+
* succeeded. Throws a `BuildError` for a failing `buildTime(…)`; logs `warning build-eval` for a
|
|
148
|
+
* pure helper that fails (its job stays). */
|
|
149
|
+
export async function evaluateBuildSlots(
|
|
150
|
+
dist: string,
|
|
151
|
+
compiled: Map<string, Compiled>,
|
|
152
|
+
sourceOf: SourceOf,
|
|
153
|
+
appRoot: string,
|
|
154
|
+
log: (s: string) => void,
|
|
155
|
+
): Promise<Record<string, unknown>> {
|
|
156
|
+
// `BRUST_BUILD_EVAL=0` turns evaluation off (every slot stays a job): the reference for dual runs.
|
|
157
|
+
if (process.env.BRUST_BUILD_EVAL === '0') return {}
|
|
158
|
+
const cands: Candidate[] = []
|
|
159
|
+
const imports: string[] = []
|
|
160
|
+
const entries: string[] = []
|
|
161
|
+
for (const c of compiled.values()) {
|
|
162
|
+
const slots = (c.ir.build_eval ?? []) as { key: string; build_time: boolean; line: number; col: number }[]
|
|
163
|
+
if (c.evalTs === undefined || slots.length === 0) continue
|
|
164
|
+
const file = join(dist, '.stage', 'eval', `${c.id}.eval.ts`)
|
|
165
|
+
write(file, c.evalTs)
|
|
166
|
+
stage(sourceOf, file, c)
|
|
167
|
+
const n = imports.length
|
|
168
|
+
imports.push(`import { slots as s${n} } from ${JSON.stringify(file)}`)
|
|
169
|
+
for (const s of slots) {
|
|
170
|
+
const where = `${c.file.slice(appRoot.length + 1)}:${s.line}:${s.col}`
|
|
171
|
+
cands.push({ key: s.key, bt: s.build_time, where })
|
|
172
|
+
entries.push(` { fn: s${n}[${JSON.stringify(s.key)}] },`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (cands.length === 0) return {}
|
|
176
|
+
const entry = join(dist, '.stage', 'eval-entry.ts')
|
|
177
|
+
write(entry, `${imports.join('\n')}\nexport default [\n${entries.join('\n')}\n]\n`)
|
|
178
|
+
const res = await Bun.build({
|
|
179
|
+
entrypoints: [entry],
|
|
180
|
+
target: 'bun',
|
|
181
|
+
format: 'esm',
|
|
182
|
+
define: PRODUCTION,
|
|
183
|
+
plugins: [corePlugin(appRoot), sourceDirPlugin(sourceOf, appRoot)],
|
|
184
|
+
})
|
|
185
|
+
if (!res.success) throw new BuildError('build-eval', `cannot bundle the build-time evaluation: ${res.logs.map(String).join('\n')}`)
|
|
186
|
+
const bundle = join(dist, '.stage', 'eval.js')
|
|
187
|
+
write(bundle, await res.outputs[0]!.text())
|
|
188
|
+
const runner = join(dist, '.stage', 'eval-run.js')
|
|
189
|
+
write(runner, RUNNER)
|
|
190
|
+
const results = await runAll(bundle, runner, cands, appRoot)
|
|
191
|
+
const values: Record<string, unknown> = {}
|
|
192
|
+
results.forEach((r, i) => {
|
|
193
|
+
const c = cands[i]!
|
|
194
|
+
if (r.ok) values[c.key] = r.value
|
|
195
|
+
else if (c.bt) throw new BuildError('build-time', `${c.where} buildTime(…) failed at build: ${r.reason}`)
|
|
196
|
+
else log(`warning build-eval ${c.where} not evaluated at build, the job stays: ${r.reason}`)
|
|
197
|
+
})
|
|
198
|
+
return values
|
|
199
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// lucide-react icons rendered at build time (L23). The compiler records an `IconRequest` for every
|
|
2
|
+
// icon it could inline; here each is rendered with `react-dom/server` (the app's own React and the
|
|
3
|
+
// installed lucide-react, so the markup is exactly what React would produce for that version),
|
|
4
|
+
// dynamic props rendered as sentinels the compiler lowers back to attributes. The result goes back
|
|
5
|
+
// with the next compile (`iconSvgs`). An icon that cannot be rendered keeps the L22 island.
|
|
6
|
+
import type { Compiled } from './compile'
|
|
7
|
+
|
|
8
|
+
interface Request {
|
|
9
|
+
key: string
|
|
10
|
+
source: string
|
|
11
|
+
export: string
|
|
12
|
+
props: Record<string, unknown>
|
|
13
|
+
dynamic: string[]
|
|
14
|
+
provider: Record<string, unknown>
|
|
15
|
+
line: number
|
|
16
|
+
col: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sentinel = (name: string) => `__BRUST_ICON_${name}__`
|
|
20
|
+
|
|
21
|
+
export interface IconMarkup {
|
|
22
|
+
/** Dynamic props rendered as sentinels. */
|
|
23
|
+
svg: string
|
|
24
|
+
/** The same icon with the dynamic props absent: React's default for an `undefined` prop. */
|
|
25
|
+
base: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function renderIcons(compiled: Map<string, Compiled>, appRoot: string, log: (s: string) => void): Promise<Record<string, IconMarkup>> {
|
|
29
|
+
const all: { c: Compiled; r: Request }[] = []
|
|
30
|
+
for (const c of compiled.values()) for (const r of (c.ir.icon_requests ?? []) as Request[]) all.push({ c, r })
|
|
31
|
+
if (all.length === 0) return {}
|
|
32
|
+
// One React for the icon module and the renderer: both resolve from the app.
|
|
33
|
+
const resolveFrom = (spec: string, from: string) => Bun.resolveSync(spec, from)
|
|
34
|
+
const server = await import(resolveFrom('react-dom/server', appRoot))
|
|
35
|
+
const react = await import(resolveFrom('react', appRoot))
|
|
36
|
+
const out: Record<string, IconMarkup> = {}
|
|
37
|
+
const mods = new Map<string, Record<string, unknown>>()
|
|
38
|
+
for (const { c, r } of all) {
|
|
39
|
+
const where = `${c.file.slice(appRoot.length + 1)}:${r.line}:${r.col}`
|
|
40
|
+
try {
|
|
41
|
+
let mod = mods.get(r.source)
|
|
42
|
+
if (!mod) {
|
|
43
|
+
mod = (await import(resolveFrom(r.source, appRoot))) as Record<string, unknown>
|
|
44
|
+
mods.set(r.source, mod)
|
|
45
|
+
}
|
|
46
|
+
const Comp = mod[r.export] as unknown
|
|
47
|
+
if (typeof Comp !== 'function' && (typeof Comp !== 'object' || Comp === null)) throw new Error(`${r.source} has no export \`${r.export}\``)
|
|
48
|
+
const render = (dynamic: Record<string, unknown>): string => {
|
|
49
|
+
let el = react.createElement(Comp, { ...r.props, ...dynamic })
|
|
50
|
+
if (Object.keys(r.provider).length > 0) {
|
|
51
|
+
const Provider = mod.LucideProvider
|
|
52
|
+
if (!Provider) throw new Error(`${r.source} has no LucideProvider (the installed lucide-react predates it)`)
|
|
53
|
+
el = react.createElement(Provider, r.provider, el)
|
|
54
|
+
}
|
|
55
|
+
const markup: string = server.renderToStaticMarkup(el)
|
|
56
|
+
if (!markup.startsWith('<svg') || !markup.endsWith('</svg>')) throw new Error('it does not render an <svg>')
|
|
57
|
+
if (/NaN|undefined/.test(markup)) throw new Error('a dynamic prop is combined with another (for example absoluteStrokeWidth with a dynamic size)')
|
|
58
|
+
return markup
|
|
59
|
+
}
|
|
60
|
+
const svg = render(Object.fromEntries(r.dynamic.map((d) => [d, sentinel(d)])))
|
|
61
|
+
for (const d of r.dynamic) if (!svg.includes(sentinel(d))) throw new Error(`prop \`${d}\` does not reach the markup`)
|
|
62
|
+
// `size={undefined}` renders React's default (or the provider's value): render that too.
|
|
63
|
+
out[r.key] = { svg, base: r.dynamic.length > 0 ? render({}) : svg }
|
|
64
|
+
} catch (e) {
|
|
65
|
+
log(`warning icon-render ${where} ${r.export} from ${r.source} stays a React island: ${e instanceof Error ? e.message : String(e)}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
package/src/build/bundle.ts
CHANGED
|
@@ -12,11 +12,19 @@ import { builtinModules } from 'node:module'
|
|
|
12
12
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
13
13
|
import type { BunPlugin } from 'bun'
|
|
14
14
|
import type { Compiled } from './compile'
|
|
15
|
+
import { cssPlugin } from './css'
|
|
15
16
|
import { BuildError } from './errors'
|
|
16
17
|
|
|
17
18
|
const REACT_EXTERNALS = ['react', 'react/*', 'react-dom', 'react-dom/*']
|
|
18
|
-
/** Production React (and the production JSX transform: `react/jsx-runtime`, never `jsxDEV`).
|
|
19
|
-
|
|
19
|
+
/** Production React (and the production JSX transform: `react/jsx-runtime`, never `jsxDEV`). `brust dev` builds set
|
|
20
|
+
* `BRUST_BUILD_MODE=development` for readable errors and hydration diffs (R13.4); `brust build` never does. */
|
|
21
|
+
export const PRODUCTION: Record<string, string> = { 'process.env.NODE_ENV': JSON.stringify(process.env.BRUST_BUILD_MODE === 'development' ? 'development' : 'production'), __BRUST_DEV__: 'false' }
|
|
22
|
+
|
|
23
|
+
/** `brust build --dev` (L14, R14.3): the client runtime exposes its live instances on a dev global. `__BRUST_DEV__` is a
|
|
24
|
+
* define, `false` in every other build, so the minifier removes the dev code from production bundles. */
|
|
25
|
+
export function setDevBuild(dev: boolean): void {
|
|
26
|
+
PRODUCTION.__BRUST_DEV__ = dev ? 'true' : 'false'
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
const matchesExternal = (spec: string, external: string[]) =>
|
|
22
30
|
// A trailing `*` is a prefix (`elysia/*`, `node:*`, `cloudflare:*`): the generated CF entry imports `cloudflare:workers`.
|
|
@@ -34,6 +42,8 @@ export interface BrowserCtx {
|
|
|
34
42
|
serverOnly: string[]
|
|
35
43
|
/** Real paths of server-only files (the actions entry, R8): a browser import of one is refused. */
|
|
36
44
|
serverFiles?: string[]
|
|
45
|
+
/** L19: CSS Module class maps for `cssPlugin` (absent: no CSS was built). */
|
|
46
|
+
cssMaps?: Map<string, Record<string, string>>
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
/** Resolution for staged artifacts (`importer` = a staged file registered in `sourceOf`): relative
|
|
@@ -146,7 +156,7 @@ function serverOnlyPlugin(ctx: BrowserCtx, violations: string[]): BunPlugin {
|
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
const hex = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 10)
|
|
149
|
-
const write = (file: string, text: string) => {
|
|
159
|
+
export const write = (file: string, text: string) => {
|
|
150
160
|
mkdirSync(dirname(file), { recursive: true })
|
|
151
161
|
writeFileSync(file, text)
|
|
152
162
|
}
|
|
@@ -166,7 +176,7 @@ async function browserBuild(
|
|
|
166
176
|
try {
|
|
167
177
|
res = await Bun.build({
|
|
168
178
|
...config,
|
|
169
|
-
plugins: [serverOnlyPlugin(ctx, violations), ...(config.plugins ?? [])],
|
|
179
|
+
plugins: [serverOnlyPlugin(ctx, violations), cssPlugin(ctx.cssMaps ?? new Map()), ...(config.plugins ?? [])],
|
|
170
180
|
target: 'browser',
|
|
171
181
|
format: 'esm',
|
|
172
182
|
minify: true,
|
|
@@ -262,13 +272,22 @@ export async function buildClientChunks(dist: string, compiled: Map<string, Comp
|
|
|
262
272
|
}
|
|
263
273
|
|
|
264
274
|
/** Registers a written artifact (by its path and its real path: the importer Bun reports). */
|
|
265
|
-
function stage(sourceOf: SourceOf, file: string, c: Compiled): void {
|
|
275
|
+
export function stage(sourceOf: SourceOf, file: string, c: Compiled): void {
|
|
266
276
|
sourceOf.set(file, c.file)
|
|
267
277
|
sourceOf.set(realpathSync(file), c.file)
|
|
268
278
|
}
|
|
269
279
|
|
|
270
280
|
const isReact = (c: Compiled) => typeof c.ir.tier === 'object' && c.ir.tier !== null && 'React' in c.ir.tier
|
|
271
281
|
|
|
282
|
+
/** The `import` line that brings a react-tier component's code in as `local`: its own module, or
|
|
283
|
+
* for a package export (L22) the export itself. */
|
|
284
|
+
export function componentImport(c: Compiled, local: string): string {
|
|
285
|
+
const ext = c.ir.external
|
|
286
|
+
if (!ext) return `import ${local} from ${JSON.stringify(c.file)}`
|
|
287
|
+
const from = JSON.stringify(ext.source)
|
|
288
|
+
return ext.export === 'default' ? `import ${local} from ${from}` : `import { ${JSON.stringify(ext.export).replace(/^"|"$/g, '')} as ${local} } from ${from}`
|
|
289
|
+
}
|
|
290
|
+
|
|
272
291
|
/** The island entry of a react-tier component: registers `(host, props) => …` under its id. A
|
|
273
292
|
* `client_only` component was never server-rendered (its host is empty), so it mounts with
|
|
274
293
|
* `createRoot`; hydrating it would fail (React #418). Everything else hydrates the SSR markup. */
|
|
@@ -278,7 +297,7 @@ export function islandShim(c: Compiled): string {
|
|
|
278
297
|
? ['createRoot', `createRoot(host, { identifierPrefix: ${JSON.stringify(c.id)} }).render(createElement(Comp, props))`]
|
|
279
298
|
: ['hydrateRoot', `hydrateRoot(host, createElement(Comp, props), { identifierPrefix: ${JSON.stringify(c.id)} })`]
|
|
280
299
|
return (
|
|
281
|
-
`import { ${api} } from 'react-dom/client'\nimport { createElement } from 'react'\
|
|
300
|
+
`import { ${api} } from 'react-dom/client'\nimport { createElement } from 'react'\n${componentImport(c, 'Comp')}\n` +
|
|
282
301
|
`;((globalThis as any).__brustIslands ||= []).push([${JSON.stringify(c.id)}, (host: Element, props: any) => { ${mount} }])\n` +
|
|
283
302
|
`;(globalThis as any).__brustIslandReady?.()\n`
|
|
284
303
|
)
|
|
@@ -335,8 +354,8 @@ const ACTIONS_EXTERNALS = [...JOBS_EXTERNALS, 'elysia', 'elysia/*', '@elysia/*']
|
|
|
335
354
|
|
|
336
355
|
/** `dist/actions.js` (M3-F E1): the actions entry for the worker (`target: 'bun'`). No `define`: Elysia
|
|
337
356
|
* and app code read NODE_ENV at runtime (run.ts sets production). */
|
|
338
|
-
export async function buildActions(dist: string, entry: string): Promise<string> {
|
|
339
|
-
const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [workerStorePlugin] })
|
|
357
|
+
export async function buildActions(dist: string, entry: string, cssMaps: Map<string, Record<string, string>> = new Map()): Promise<string> {
|
|
358
|
+
const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [cssPlugin(cssMaps), workerStorePlugin] })
|
|
340
359
|
if (!res.success) throw new BuildError('bundle', `actions: ${res.logs.map(String).join('\n')}`)
|
|
341
360
|
write(join(dist, 'actions.js'), await res.outputs[0]!.text())
|
|
342
361
|
return 'actions.js'
|
|
@@ -344,8 +363,8 @@ export async function buildActions(dist: string, entry: string): Promise<string>
|
|
|
344
363
|
|
|
345
364
|
/** `dist/cache-adapter.js` (cache adapters R8): a custom adapter module for the worker (`target: 'bun'`), with
|
|
346
365
|
* the actions externals so it shares the app's dependencies. */
|
|
347
|
-
export async function buildCacheAdapter(dist: string, entry: string): Promise<string> {
|
|
348
|
-
const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS })
|
|
366
|
+
export async function buildCacheAdapter(dist: string, entry: string, cssMaps: Map<string, Record<string, string>> = new Map()): Promise<string> {
|
|
367
|
+
const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [cssPlugin(cssMaps)] })
|
|
349
368
|
if (!res.success) throw new BuildError('bundle', `cache adapter: ${res.logs.map(String).join('\n')}`)
|
|
350
369
|
write(join(dist, 'cache-adapter.js'), await res.outputs[0]!.text())
|
|
351
370
|
return 'cache-adapter.js'
|
|
@@ -353,7 +372,14 @@ export async function buildCacheAdapter(dist: string, entry: string): Promise<st
|
|
|
353
372
|
|
|
354
373
|
/** `dist/jobs.js` (`target: 'bun'`, React and `@brust/core` external): default export
|
|
355
374
|
* `{ [componentId]: { precompute?, ssr? } }`; `ssr` only for react-tier, non-client_only components. */
|
|
356
|
-
export async function buildJobs(
|
|
375
|
+
export async function buildJobs(
|
|
376
|
+
dist: string,
|
|
377
|
+
compiled: Map<string, Compiled>,
|
|
378
|
+
sourceOf: SourceOf,
|
|
379
|
+
appRoot: string,
|
|
380
|
+
stores: string[] = [],
|
|
381
|
+
cssMaps: Map<string, Record<string, string>> = new Map(),
|
|
382
|
+
): Promise<void> {
|
|
357
383
|
const imports: string[] = ["import { createElement } from 'react'", "import { renderToString } from 'react-dom/server'"]
|
|
358
384
|
const entries: string[] = []
|
|
359
385
|
const storePaths = [...new Set(stores)].sort()
|
|
@@ -374,7 +400,7 @@ export async function buildJobs(dist: string, compiled: Map<string, Compiled>, s
|
|
|
374
400
|
fns.push(`precompute: j${n}.precompute`)
|
|
375
401
|
}
|
|
376
402
|
if (isReact(c) && !c.ir.tier.React.client_only) {
|
|
377
|
-
imports.push(`
|
|
403
|
+
imports.push(componentImport(c, `C${n}`))
|
|
378
404
|
// Ruling 2bf3775a (R1): the island's component id prefixes React's useId on BOTH sides
|
|
379
405
|
// (the shim's hydrateRoot passes the same prefix), so ids differ across islands.
|
|
380
406
|
fns.push(`ssr: (props: any) => renderToString(createElement(C${n}, props), { identifierPrefix: ${JSON.stringify(c.id)} })`)
|
|
@@ -390,7 +416,7 @@ export async function buildJobs(dist: string, compiled: Map<string, Compiled>, s
|
|
|
390
416
|
format: 'esm',
|
|
391
417
|
define: PRODUCTION,
|
|
392
418
|
external: JOBS_EXTERNALS,
|
|
393
|
-
plugins: [workerStorePlugin, sourceDirPlugin(sourceOf, appRoot, JOBS_EXTERNALS)],
|
|
419
|
+
plugins: [cssPlugin(cssMaps), workerStorePlugin, sourceDirPlugin(sourceOf, appRoot, JOBS_EXTERNALS)],
|
|
394
420
|
})
|
|
395
421
|
if (!res.success) throw new BuildError('bundle', `jobs: ${res.logs.map(String).join('\n')}`)
|
|
396
422
|
write(join(dist, 'jobs.js'), await res.outputs[0]!.text())
|
package/src/build/cf-assets.ts
CHANGED
|
@@ -21,8 +21,10 @@ export function isHashed(file: string): boolean {
|
|
|
21
21
|
|
|
22
22
|
const segOk = (s: string) => s !== '' && !s.startsWith('.') && SEG.test(s)
|
|
23
23
|
|
|
24
|
-
/** `client/<file>.js|.css`: `is_safe_island_filename` / `is_safe_css_filename
|
|
25
|
-
|
|
24
|
+
/** `client/<file>.js|.css`: `is_safe_island_filename` / `is_safe_css_filename`, plus the fonts and images a
|
|
25
|
+
* stylesheet references (`is_safe_client_asset_filename`, L19). */
|
|
26
|
+
const CLIENT_EXT = /\.(js|css|woff2|woff|ttf|otf|eot|png|jpg|jpeg|gif|webp|avif|svg|ico)$/
|
|
27
|
+
const clientOk = (f: string) => segOk(f) && !f.includes('..') && CLIENT_EXT.test(f)
|
|
26
28
|
|
|
27
29
|
function walk(dir: string, rel = ''): string[] {
|
|
28
30
|
const out: string[] = []
|
package/src/build/cf-entry.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface CfEntryInput {
|
|
|
28
28
|
entry: string
|
|
29
29
|
jobsEntry: string
|
|
30
30
|
actionsEntry: string | null
|
|
31
|
+
/** L16: no pages: the routes module and the jobs module are not imported; the handlers serve an empty route table. */
|
|
32
|
+
actionsOnly?: boolean
|
|
31
33
|
prefix: string
|
|
32
34
|
manifestPath: string
|
|
33
35
|
templatesPath: string
|
|
@@ -41,16 +43,18 @@ export function cfEntrySource(i: CfEntryInput): string {
|
|
|
41
43
|
const q = JSON.stringify
|
|
42
44
|
const actions = i.actionsEntry !== null
|
|
43
45
|
const c = i.cache ?? null
|
|
46
|
+
const pages = i.actionsOnly !== true
|
|
44
47
|
return `${[
|
|
45
48
|
'// generated by `brust build --target cloudflare` — do not edit (M4-CF spec §4)',
|
|
46
|
-
`import * as R from ${q(i.entry)}`,
|
|
47
|
-
`import J, * as JM from ${q(i.jobsEntry)}`,
|
|
49
|
+
...(pages ? [`import * as R from ${q(i.entry)}`, `import J, * as JM from ${q(i.jobsEntry)}`] : []),
|
|
48
50
|
...(actions ? [`import A from ${q(i.actionsEntry)}`] : []),
|
|
49
51
|
`import M from ${q(i.manifestPath)}`,
|
|
50
52
|
`import T from ${q(i.templatesPath)}`,
|
|
51
53
|
`import { fetchInScope, flattenRoutes, jsonCall, makeHandlers, noActions } from "@brust/core/server-handlers"`,
|
|
52
54
|
...(actions ? ['import { mountActions } from "@brust/core/server-handlers/actions"'] : []),
|
|
53
|
-
|
|
55
|
+
pages
|
|
56
|
+
? 'const h = makeHandlers({ leaves: flattenRoutes(R.routes ?? R.default).leaves, jobs: J, manifest: M, stores: JM.stores ?? {} })'
|
|
57
|
+
: 'const h = makeHandlers({ leaves: [], jobs: {}, manifest: M, stores: {} })',
|
|
54
58
|
...(actions ? [`const app = mountActions(A, ${q(i.prefix)}).compile() // startup-phase compile (R9)`] : []),
|
|
55
59
|
'export const loader = jsonCall(h.loader)',
|
|
56
60
|
'export const jobs = jsonCall(h.jobs)',
|
package/src/build/cf-wrangler.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface MergeOpts {
|
|
|
20
20
|
/** worker-build entry, relative to `worker/` (core-cf `brust.main`) */
|
|
21
21
|
main: string
|
|
22
22
|
version: string
|
|
23
|
+
/** L16: an actions-only Worker has no Static Assets (and no ASSETS binding). */
|
|
24
|
+
noAssets?: boolean
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const OWNED = ['main', 'assets', 'compatibility_date', 'no_bundle', 'find_additional_modules', 'base_dir', 'rules', 'build', 'site']
|
|
@@ -106,7 +108,7 @@ export function mergeWrangler(user: UserWrangler, o: MergeOpts): { config: Json;
|
|
|
106
108
|
...(name !== undefined ? { name } : {}),
|
|
107
109
|
main: `worker/${o.main}`,
|
|
108
110
|
compatibility_date: o.compatibilityDate,
|
|
109
|
-
assets: { directory: 'assets', binding: 'ASSETS', run_worker_first: false, html_handling: 'none', not_found_handling: 'none' },
|
|
111
|
+
...(o.noAssets ? {} : { assets: { directory: 'assets', binding: 'ASSETS', run_worker_first: false, html_handling: 'none', not_found_handling: 'none' } }),
|
|
110
112
|
...Object.fromEntries(Object.entries(rest).filter(([k]) => k !== 'name')),
|
|
111
113
|
}
|
|
112
114
|
return { config, warnings }
|