@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.31
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 +66 -6
- package/native/index.d.ts +82 -4
- package/native/index.js +58 -54
- package/package.json +29 -15
- package/src/actions/client.ts +53 -7
- package/src/actions/elysia.ts +1 -1
- package/src/actions/load.ts +82 -1
- package/src/actions/prefix.ts +44 -2
- package/src/actions-bridge.ts +44 -9
- package/src/assets.d.ts +55 -0
- package/src/browser.ts +6 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +332 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +466 -54
- package/src/build/cf-assets.ts +4 -2
- package/src/build/cf-entry.ts +9 -4
- package/src/build/cf-wrangler.ts +3 -1
- package/src/build/cloudflare.ts +34 -13
- package/src/build/compile.ts +263 -6
- package/src/build/config.ts +138 -11
- package/src/build/css.ts +353 -0
- package/src/build/esm-resolve.ts +170 -0
- package/src/build/features.ts +119 -0
- package/src/build/index.ts +210 -27
- package/src/build/manifest.ts +94 -7
- package/src/build/pkg-src.ts +514 -0
- package/src/build/recognised.ts +19 -0
- package/src/build/scan.ts +71 -20
- package/src/build/server-only.ts +62 -0
- 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 +259 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -0
- package/src/cache-adapter/types.ts +1 -1
- package/src/cf.ts +4 -1
- package/src/cli.ts +40 -4
- package/src/config.ts +111 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +378 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +358 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +256 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +130 -0
- package/src/dev.ts +15 -28
- package/src/handlers.ts +116 -10
- package/src/index.ts +6 -1
- package/src/native.ts +32 -1
- package/src/navigate-browser.ts +19 -0
- package/src/navigate.ts +19 -0
- package/src/navigation.ts +91 -0
- package/src/routes.ts +31 -1
- package/src/run.ts +65 -13
- package/src/shadcn/init.ts +513 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/shadcn/registry.ts +149 -0
- package/src/store/builtin.ts +4 -0
- package/src/worker.ts +105 -22
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// F228: a registry URL given to `brust shadcn init` names a third-party item that declares its own file targets, known
|
|
2
|
+
// only once the item is fetched. This fetches the item JSON BEFORE the shadcn CLI is spawned (https only, a size cap, a
|
|
3
|
+
// timeout, redirects followed by hand and only to https) and checks every path it can make the CLI write. The command
|
|
4
|
+
// then hands the CLI the file it checked, not the URL, so the item cannot change between the check and the write.
|
|
5
|
+
|
|
6
|
+
/** The most bytes of item JSON read; a bigger answer is refused. */
|
|
7
|
+
export const ITEM_MAX_BYTES = 1024 * 1024
|
|
8
|
+
/** The whole fetch (redirects included) gets this long. */
|
|
9
|
+
export const ITEM_TIMEOUT_MS = 15_000
|
|
10
|
+
/** Redirects followed by hand; each target must be https. */
|
|
11
|
+
export const MAX_REDIRECTS = 3
|
|
12
|
+
|
|
13
|
+
export interface FetchOptions {
|
|
14
|
+
/** URL schemes allowed for the request and every redirect. Default `https:` only; a test passes `http:` for a local server. */
|
|
15
|
+
schemes?: string[]
|
|
16
|
+
maxBytes?: number
|
|
17
|
+
timeoutMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Fetches `url` and returns the body text, or throws an `Error` whose message is the reason it was refused. */
|
|
21
|
+
export async function fetchRegistryItem(url: string, o: FetchOptions = {}): Promise<string> {
|
|
22
|
+
const schemes = o.schemes ?? ['https:']
|
|
23
|
+
const maxBytes = o.maxBytes ?? ITEM_MAX_BYTES
|
|
24
|
+
const signal = AbortSignal.timeout(o.timeoutMs ?? ITEM_TIMEOUT_MS)
|
|
25
|
+
let cur = url
|
|
26
|
+
try {
|
|
27
|
+
for (let hop = 0; ; hop++) {
|
|
28
|
+
let u: URL
|
|
29
|
+
try {
|
|
30
|
+
u = new URL(cur)
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`${cur} is not a URL`)
|
|
33
|
+
}
|
|
34
|
+
if (!schemes.includes(u.protocol)) throw new Error(`${cur} is not an ${schemes.join(' or ').replace(/:/g, '')} URL`)
|
|
35
|
+
if (u.username !== '' || u.password !== '') throw new Error(`${u.origin}${u.pathname} carries credentials in the URL`)
|
|
36
|
+
const res = await fetch(u, { redirect: 'manual', signal, headers: { accept: 'application/json' } })
|
|
37
|
+
if (res.status >= 300 && res.status < 400) {
|
|
38
|
+
const next = res.headers.get('location')
|
|
39
|
+
await res.body?.cancel()
|
|
40
|
+
if (!next) throw new Error(`${cur} answered ${res.status} with no Location`)
|
|
41
|
+
if (hop >= MAX_REDIRECTS) throw new Error(`${url} redirects more than ${MAX_REDIRECTS} times`)
|
|
42
|
+
cur = new URL(next, u).toString() // the next loop turn refuses a non-https target
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
await res.body?.cancel()
|
|
47
|
+
throw new Error(`${cur} answered HTTP ${res.status}`)
|
|
48
|
+
}
|
|
49
|
+
const declared = Number(res.headers.get('content-length'))
|
|
50
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
51
|
+
await res.body?.cancel()
|
|
52
|
+
throw new Error(`${cur} is ${declared} bytes; the limit is ${maxBytes}`)
|
|
53
|
+
}
|
|
54
|
+
const reader = res.body?.getReader()
|
|
55
|
+
if (!reader) return ''
|
|
56
|
+
const chunks: Uint8Array[] = []
|
|
57
|
+
let size = 0
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read()
|
|
60
|
+
if (done) break
|
|
61
|
+
size += value.length
|
|
62
|
+
if (size > maxBytes) {
|
|
63
|
+
await reader.cancel()
|
|
64
|
+
throw new Error(`${cur} is bigger than the limit of ${maxBytes} bytes`)
|
|
65
|
+
}
|
|
66
|
+
chunks.push(value)
|
|
67
|
+
}
|
|
68
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (signal.aborted) throw new Error(`${url} did not answer within ${(o.timeoutMs ?? ITEM_TIMEOUT_MS) / 1000} s`)
|
|
72
|
+
throw e instanceof Error ? e : new Error(String(e))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True for a path that is not a plain relative path: absolute, a drive, a backslash, a NUL, a control character or `..`. */
|
|
77
|
+
export function unsafeRel(p: string): boolean {
|
|
78
|
+
return p === '' || p.startsWith('/') || /^[a-zA-Z]:/.test(p) || p.includes('\\') || /[\x00-\x1f]/.test(p) || p.split('/').some((s) => s === '..') || (p.includes('%') && /%2e|%2f|%5c|%00/i.test(p))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VettedItem {
|
|
82
|
+
/** Project-relative paths the CLI may write for this item, beyond the alias directories (checked for symlinks). */
|
|
83
|
+
paths: string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const PLAIN_NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
87
|
+
|
|
88
|
+
/** The top-level keys of an item in the pinned registry schema (`@shadcn/registry` 0.1.3). Allowlist, not denylist: a key
|
|
89
|
+
* this does not name (`font`, which makes the CLI write the CSS entry and a layout file, or one a later schema adds) is a
|
|
90
|
+
* refusal, because the CLI could write something this never judged. */
|
|
91
|
+
const ITEM_KEYS = new Set(['$schema', 'extends', 'name', 'type', 'title', 'author', 'description', 'dependencies', 'devDependencies', 'registryDependencies', 'files', 'tailwind', 'cssVars', 'css', 'envVars', 'meta', 'docs', 'categories'])
|
|
92
|
+
/** Item types whose files the CLI rewrites to a framework path (`registry:page`) or whose write set is not judged (`registry:font`). */
|
|
93
|
+
const REFUSED_TYPES = new Set(['registry:font', 'registry:page'])
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Judges item JSON: the file targets it declares and the other things it makes the CLI write or fetch. Returns the
|
|
97
|
+
* paths to check for symlinks, or the reason it is refused. Fail closed: anything it cannot judge is a refusal.
|
|
98
|
+
*/
|
|
99
|
+
export function vetItem(text: string, cssEntry: string): VettedItem | { error: string } {
|
|
100
|
+
let item: unknown
|
|
101
|
+
try {
|
|
102
|
+
item = JSON.parse(text)
|
|
103
|
+
} catch {
|
|
104
|
+
return { error: 'is not valid JSON' }
|
|
105
|
+
}
|
|
106
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) return { error: 'is not a JSON object' }
|
|
107
|
+
const o = item as Record<string, unknown>
|
|
108
|
+
if (o.type !== undefined && (typeof o.type !== 'string' || REFUSED_TYPES.has(o.type))) return { error: `is of type ${JSON.stringify(o.type)}, whose files the CLI places by rules this check does not model` }
|
|
109
|
+
const unknown = Object.keys(o).find((k) => !ITEM_KEYS.has(k))
|
|
110
|
+
if (unknown !== undefined) return { error: `has the key ${JSON.stringify(unknown)}, which this check does not know; it may make the CLI write files that cannot be checked` }
|
|
111
|
+
const paths: string[] = []
|
|
112
|
+
if (o.files !== undefined) {
|
|
113
|
+
if (!Array.isArray(o.files)) return { error: '"files" is not an array' }
|
|
114
|
+
for (const f of o.files) {
|
|
115
|
+
if (typeof f !== 'object' || f === null) return { error: 'has a file entry that is not an object' }
|
|
116
|
+
const { path, target } = f as { path?: unknown; target?: unknown }
|
|
117
|
+
if (path !== undefined && typeof path !== 'string') return { error: 'has a file with a "path" that is not a string' }
|
|
118
|
+
if (target !== undefined && typeof target !== 'string') return { error: 'has a file with a "target" that is not a string' }
|
|
119
|
+
if (typeof path === 'string' && unsafeRel(path)) return { error: `declares the file path ${JSON.stringify(path)}, which is not a plain path inside the project` }
|
|
120
|
+
if (typeof target === 'string' && target !== '') {
|
|
121
|
+
// The CLI's own rules (resolveFilePath): `~/x` is project-relative; `@components|ui|lib|hooks/x` is inside that alias
|
|
122
|
+
// directory (scanned already); any other target is joined to the project root, once as written and, in a `src/`
|
|
123
|
+
// project, under `src/` (it drops one `src/` from the target either way). All of them are checked.
|
|
124
|
+
const t = target.startsWith('~/') ? target.slice(2) : target
|
|
125
|
+
const alias = /^@(components|ui|lib|hooks)\/(.+)$/.exec(t)
|
|
126
|
+
if (alias) {
|
|
127
|
+
if (unsafeRel(alias[2]!)) return { error: `declares the file target ${JSON.stringify(target)}, which is not a plain path inside the project` }
|
|
128
|
+
} else {
|
|
129
|
+
const rel = t.replace(/^@([^/]+)\//, '$1/')
|
|
130
|
+
if (unsafeRel(rel)) return { error: `declares the file target ${JSON.stringify(target)}, which is not a plain path inside the project` }
|
|
131
|
+
const bare = rel.replace('src/', '')
|
|
132
|
+
paths.push(rel, bare, `src/${bare}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// what else the CLI writes: the Tailwind CSS entry (css, cssVars), env files (envVars)
|
|
138
|
+
if (o.css !== undefined || o.cssVars !== undefined || o.tailwind !== undefined) paths.push(cssEntry)
|
|
139
|
+
if (o.envVars !== undefined) paths.push('.env', '.env.local')
|
|
140
|
+
// dependencies on other items the CLI would fetch after this check (a `config` that would merge into components.json is an unknown key, refused above)
|
|
141
|
+
const deps = [...(Array.isArray(o.registryDependencies) ? o.registryDependencies : o.registryDependencies === undefined ? [] : [null]), ...(o.extends === undefined ? [] : [o.extends])]
|
|
142
|
+
for (const d of deps) {
|
|
143
|
+
if (typeof d !== 'string') return { error: 'has a registryDependencies / extends entry that is not a string' }
|
|
144
|
+
if (!PLAIN_NAME.test(d)) {
|
|
145
|
+
return { error: `depends on ${JSON.stringify(d)}, which the CLI would fetch after this check; pass that URL to the command as its own argument so it is checked too` }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { paths }
|
|
149
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Store modules brust ships. The compiler names them by package specifier (`StoreRef.module`) instead of an app-relative
|
|
2
|
+
// path, so the manifest's `stores`, the `jobs.js` stores table and the loader's `touchStores` all use this spelling.
|
|
3
|
+
export const NAVIGATION_MODULE = '@brust/core/navigation'
|
|
4
|
+
export const BUILTIN_STORE_MODULES: readonly string[] = [NAVIGATION_MODULE]
|
package/src/worker.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// The Bun side of contract 7 (`crates/brust-server/src/protocol.rs`): requests arrive as inline
|
|
2
2
|
// JSON, responses go into this worker's SharedArrayBuffer slot (SAB rule, `dispatch.rs`). A
|
|
3
3
|
// handler NEVER rejects across the tsfn: every failure becomes `{error}` / `{id, error}`.
|
|
4
|
+
import { appendFileSync } from 'node:fs'
|
|
4
5
|
import { dirname, join, resolve } from 'node:path'
|
|
5
6
|
import { workerData } from 'node:worker_threads'
|
|
6
7
|
import { type HeapIo, runFetch, setActionsApp, setActionsLimits } from './actions-bridge'
|
|
7
8
|
import { DEFAULT_MAX_RESPONSE_BYTES } from './actions/prefix'
|
|
8
9
|
import { type CacheBridgeDeps, runCache } from './cache-adapter/bridge'
|
|
9
10
|
import type { CacheManifest } from './cache-adapter/load'
|
|
10
|
-
import { type Handlers, type JobsModule, type ManifestLike, makeHandlers, toJson } from './handlers'
|
|
11
|
-
import { cacheClear, cacheL2Count, cacheReplay, planJobs, putFetchReply, registerWorker, takeFetchBody } from './native'
|
|
11
|
+
import { type Handlers, type JobsModule, type ManifestLike, makeHandlers, type SsgRecord, toJson } from './handlers'
|
|
12
|
+
import { cacheClear, cacheL2Count, cacheReplay, planJobs, planJobsBuf, putFetchReply, registerWorker, takeFetchBody } from './native'
|
|
13
|
+
import { installProductionJsxDev, installServerOnlyRuntime } from './build/server-only'
|
|
12
14
|
import { flattenRoutes, type Route } from './routes'
|
|
13
15
|
|
|
14
16
|
export * from './handlers'
|
|
@@ -17,24 +19,58 @@ export * from './handlers'
|
|
|
17
19
|
|
|
18
20
|
const enc = new TextEncoder()
|
|
19
21
|
|
|
22
|
+
/** L10: the default `maxLoaderResponseBytes` (config.ts keeps the range). */
|
|
23
|
+
export const DEFAULT_MAX_LOADER_RESPONSE_BYTES = 8_388_608
|
|
24
|
+
|
|
25
|
+
/** L10: a reply too large for the slot crosses by heap: `HeapIo.putReply` hands Rust the bytes, and the slot holds
|
|
26
|
+
* the 5-byte marker `[0x00][u32 LE n]` (JSON never starts with 0x00). `cap` = `maxLoaderResponseBytes`. */
|
|
27
|
+
export const HEAP_MARKER_BYTES = 5
|
|
28
|
+
export interface HeapReply {
|
|
29
|
+
io: HeapIo
|
|
30
|
+
/** `planJobsBuf` (napi): plan a loader response handed over directly. */
|
|
31
|
+
planBuf: (slot: number, buf: Uint8Array) => string
|
|
32
|
+
/** Default: the worker's `maxLoaderResponseBytes` (env `BRUST_MAX_LOADER_RESPONSE_BYTES`, 8 MiB). */
|
|
33
|
+
cap?: number
|
|
34
|
+
}
|
|
35
|
+
const nativeHeap: HeapReply = { io: cacheHeapIo(), planBuf: (slot, buf) => planJobsBuf(slot, buf) }
|
|
36
|
+
let loaderCap = DEFAULT_MAX_LOADER_RESPONSE_BYTES
|
|
37
|
+
|
|
20
38
|
/** Writes `json` as UTF-8 at the start of `slot`'s sub-region `[slot*sub, slot*sub+sub)`,
|
|
21
39
|
* `sub = floor(view.byteLength / slots)`; returns the byte length (> 0, ≤ sub). ONE copy: the
|
|
22
40
|
* encoder writes straight into the slot (M3-P P1); it stops at the last whole code point that
|
|
23
41
|
* fits, so `read < json.length` means "did not fit" and the neighbouring slot is never touched.
|
|
24
|
-
* A response that does not fit
|
|
25
|
-
*
|
|
26
|
-
|
|
42
|
+
* A response that does not fit goes by heap when `io` and `cap` are given (L10): encoded once, handed to
|
|
43
|
+
* Rust with `io.putReply`, and the slot holds the 5-byte marker. Over `cap` (or declined), or with no `io`, it is
|
|
44
|
+
* replaced by a small `{"error":"response too large: <n> > <limit>"}` (`n` = its exact UTF-8 length, computed
|
|
45
|
+
* only on this path). */
|
|
46
|
+
export function writeSlot(view: Uint8Array, slot: number, slots: number, json: string, io?: HeapIo, cap?: number): number {
|
|
27
47
|
const sub = Math.floor(view.byteLength / Math.max(1, slots))
|
|
28
48
|
const dst = view.subarray(slot * sub, slot * sub + sub)
|
|
29
49
|
const r = enc.encodeInto(json, dst)
|
|
30
50
|
if (r.read === json.length) return r.written
|
|
31
|
-
|
|
51
|
+
let why = `response too large: ${Buffer.byteLength(json)} > ${sub}`
|
|
52
|
+
if (io && cap !== undefined && sub >= HEAP_MARKER_BYTES) {
|
|
53
|
+
const bytes = enc.encode(json)
|
|
54
|
+
const n = heapMarker(dst, slot, bytes, io, cap)
|
|
55
|
+
if (n > 0) return n
|
|
56
|
+
// Name the real reason: over the cap, or a put Rust declined while under it (idle slot, a second put).
|
|
57
|
+
why = bytes.length > cap ? `response too large: ${bytes.length} > ${cap} (maxLoaderResponseBytes)` : `heap reply declined: ${bytes.length} bytes did not fit the ${sub}-byte slot and Rust refused the handover`
|
|
58
|
+
}
|
|
59
|
+
const err = JSON.stringify({ error: why })
|
|
32
60
|
const e = enc.encodeInto(err, dst)
|
|
33
61
|
if (e.read === err.length) return e.written
|
|
34
62
|
// Not even the message fits: the first `sub` bytes of a fixed one (ASCII, so byte = char).
|
|
35
63
|
return enc.encodeInto('{"error":"too large"}', dst).written
|
|
36
64
|
}
|
|
37
65
|
|
|
66
|
+
/** Hand `bytes` to Rust and write the marker at `dst`; 0 when over `cap` or Rust declined. */
|
|
67
|
+
function heapMarker(dst: Uint8Array, slot: number, bytes: Uint8Array, io: HeapIo, cap: number): number {
|
|
68
|
+
if (bytes.length > cap || !io.putReply(slot, bytes)) return 0
|
|
69
|
+
dst[0] = 0
|
|
70
|
+
new DataView(dst.buffer, dst.byteOffset + 1, 4).setUint32(0, bytes.length, true)
|
|
71
|
+
return HEAP_MARKER_BYTES
|
|
72
|
+
}
|
|
73
|
+
|
|
38
74
|
/** `planJobs` (napi): the misses' JobsRequest JSON, '' (every job hit) or PLAN_DECLINED. */
|
|
39
75
|
export type PlanJobs = (slot: number, len: number) => string
|
|
40
76
|
/** `planJobs` declined: the loader response already in the slot is the answer (Rust plans after the call). */
|
|
@@ -44,11 +80,13 @@ const PLANNED = { planned: true } as const
|
|
|
44
80
|
/** The tsfn callback for `registerWorker`. Never rejects: a parse error, an unknown kind or a
|
|
45
81
|
* throwing handler is written to the slot as `{error}`. A loader request with `plan: true` whose
|
|
46
82
|
* chain answered `ok` is planned in the same call (`plan`, the napi `planJobs` by default — the
|
|
47
|
-
* addon is touched only then).
|
|
48
|
-
|
|
83
|
+
* addon is touched only then). A loader/jobs/planned reply that does not fit the slot crosses by heap
|
|
84
|
+
* (`heap`, the napi addon by default; L10). */
|
|
85
|
+
export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan: PlanJobs = planJobs, heap: HeapReply = nativeHeap) {
|
|
86
|
+
const cap = () => heap.cap ?? loaderCap
|
|
49
87
|
return async (kind: string, requestJson: string, slot: number): Promise<number> => {
|
|
50
88
|
if (kind === 'fetch') return runFetch(requestJson, view, slot, slots)
|
|
51
|
-
if (kind === 'cache') return runCache(requestJson, view, slot, slots, cacheHeapIo, cacheDeps)
|
|
89
|
+
if (kind === 'cache') return runCache(requestJson, view, slot, slots, cacheHeapIo(), cacheDeps)
|
|
52
90
|
let out: unknown
|
|
53
91
|
try {
|
|
54
92
|
const req = JSON.parse(requestJson)
|
|
@@ -58,9 +96,20 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
|
|
|
58
96
|
// One round trip (M3-P P6): the ctx goes to Rust through this slot, synchronously, and only
|
|
59
97
|
// the jobs Rust found missing run here; the final answer replaces it in the same slot.
|
|
60
98
|
const json = toJson(out)
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
99
|
+
const sub = Math.floor(view.byteLength / Math.max(1, slots))
|
|
100
|
+
const dst = view.subarray(slot * sub, slot * sub + sub)
|
|
101
|
+
const w = enc.encodeInto(json, dst)
|
|
102
|
+
let misses: string
|
|
103
|
+
if (w.read === json.length) {
|
|
104
|
+
misses = plan(slot, w.written)
|
|
105
|
+
if (misses === PLAN_DECLINED) return w.written
|
|
106
|
+
} else {
|
|
107
|
+
// L10: too large for the slot — plan from the bytes directly; a declined plan hands them over as the answer.
|
|
108
|
+
const bytes = enc.encode(json)
|
|
109
|
+
if (bytes.length > cap()) return writeSlot(view, slot, slots, json, heap.io, cap())
|
|
110
|
+
misses = heap.planBuf(slot, bytes)
|
|
111
|
+
if (misses === PLAN_DECLINED) return writeSlot(view, slot, slots, json, heap.io, cap())
|
|
112
|
+
}
|
|
64
113
|
out = misses === '' ? PLANNED : { planned: true, results: (await h.jobs(JSON.parse(misses))).results }
|
|
65
114
|
}
|
|
66
115
|
} else if (kind === 'jobs') out = await h.jobs(req)
|
|
@@ -68,13 +117,15 @@ export function makeDispatch(h: Handlers, view: Uint8Array, slots: number, plan:
|
|
|
68
117
|
} catch (e) {
|
|
69
118
|
out = { error: String(e) }
|
|
70
119
|
}
|
|
71
|
-
return writeSlot(view, slot, slots, toJson(out))
|
|
120
|
+
return writeSlot(view, slot, slots, toJson(out), heap.io, cap())
|
|
72
121
|
}
|
|
73
122
|
}
|
|
74
123
|
|
|
75
124
|
// ---- cache adapter (the `cache` call kind) ----
|
|
76
125
|
|
|
77
|
-
|
|
126
|
+
function cacheHeapIo(): HeapIo {
|
|
127
|
+
return { takeBody: (slot) => takeFetchBody(slot), putReply: (slot, body) => putFetchReply(slot, body) }
|
|
128
|
+
}
|
|
78
129
|
const count = (name: string) => void cacheL2Count(name)
|
|
79
130
|
let cacheDeps: CacheBridgeDeps = { store: undefined, getTimeoutMs: 50, maxBodyBytes: 1_048_576, count }
|
|
80
131
|
|
|
@@ -97,12 +148,20 @@ async function startCacheAdapter(m: CacheManifest, dist: string, appDir: string,
|
|
|
97
148
|
return { adapter, origin }
|
|
98
149
|
}
|
|
99
150
|
|
|
151
|
+
// ---- static output (gaps round R9.5) ----
|
|
152
|
+
|
|
153
|
+
/** Appends each record as one JSON line to `file` (every worker of the render appends to it). */
|
|
154
|
+
function ssgLog(file: string): (r: SsgRecord) => void {
|
|
155
|
+
return (r) => appendFileSync(file, `${JSON.stringify(r)}\n`)
|
|
156
|
+
}
|
|
157
|
+
|
|
100
158
|
// ---- worker entry ----
|
|
101
159
|
|
|
160
|
+
/** The default response slot (`slotBytes`, config.ts: 64 KiB..16 MiB). */
|
|
102
161
|
export const SLOT_BYTES = 256 * 1024
|
|
103
162
|
|
|
104
163
|
export interface StartWorkerOptions {
|
|
105
|
-
/** The response buffer to register (`
|
|
164
|
+
/** The response buffer to register (`slotBytes * slots` bytes). `run()` allocates it on the
|
|
106
165
|
* MAIN thread and keeps it for the process lifetime, passing it as `workerData.sab`: the Rust
|
|
107
166
|
* dispatcher holds a raw pointer into it until the process exits, so its backing store must not
|
|
108
167
|
* die with this worker (`crates/brust-napi/src/dispatch.rs` BufPtr). Absent (tests, a worker
|
|
@@ -121,29 +180,53 @@ export async function startWorker(opts: StartWorkerOptions = {}): Promise<number
|
|
|
121
180
|
const env = process.env
|
|
122
181
|
const slots = Math.max(1, Number.parseInt(env.BRUST_RENDER_SLOTS ?? '1', 10) || 1)
|
|
123
182
|
const dist = resolve(env.BRUST_DIST_DIR ?? 'dist')
|
|
124
|
-
|
|
183
|
+
// L16 R16.3: an actions-only dist has no routes module (so no BRUST_APP_ENTRY either).
|
|
184
|
+
const manifestFile = join(dist, 'manifest.json')
|
|
185
|
+
const hasManifest = await Bun.file(manifestFile).exists()
|
|
186
|
+
const actionsOnly = hasManifest && !env.BRUST_APP_ENTRY && ((await Bun.file(manifestFile).json()) as { actions_only?: boolean }).actions_only === true
|
|
187
|
+
const entry = env.BRUST_APP_ENTRY ?? (actionsOnly ? join(dist, 'index.js') : undefined)
|
|
125
188
|
if (!entry) throw new Error('BRUST_APP_ENTRY is not set')
|
|
126
|
-
const manifest = (await Bun.file(
|
|
127
|
-
|
|
128
|
-
|
|
189
|
+
const manifest = (await Bun.file(manifestFile).json()) as ManifestLike & { cache?: CacheManifest | null; actions_only?: boolean }
|
|
190
|
+
let routes: Route[] | undefined = []
|
|
191
|
+
if (!manifest.actions_only) {
|
|
192
|
+
installServerOnlyRuntime()
|
|
193
|
+
installProductionJsxDev(dirname(resolve(entry)))
|
|
194
|
+
const app = await import(resolve(entry))
|
|
195
|
+
routes = (app.routes ?? app.default) as Route[] | undefined
|
|
196
|
+
}
|
|
129
197
|
if (!Array.isArray(routes)) throw new Error(`${entry} exports no routes (export const routes or default)`)
|
|
130
198
|
const jobsMod = await import(join(dist, manifest.jobs_module ?? 'jobs.js'))
|
|
131
199
|
const jobs = (jobsMod.default ?? jobsMod) as JobsModule
|
|
132
200
|
if (manifest.actions) {
|
|
133
201
|
// Lazy: elysia is an optional peer, so an app without [actions] must boot without it installed (R7).
|
|
134
|
-
const { loadActionsApp } = await import('./actions/load')
|
|
135
|
-
|
|
202
|
+
const { loadActionsApp, shadowedGetActions, streamRouteCheck } = await import('./actions/load')
|
|
203
|
+
const actionsApp = await loadActionsApp(join(dist, manifest.actions.entry), manifest.actions.prefix)
|
|
204
|
+
setActionsApp(actionsApp)
|
|
205
|
+
// F470: a GET under the prefix that a route there matches is the route's; say which GET actions that hides (once)
|
|
206
|
+
const shadowed = env.BRUST_WORKER_ID && env.BRUST_WORKER_ID !== '0' ? [] : shadowedGetActions(actionsApp, flattenRoutes(routes).leaves.map((l) => l.pattern), manifest.actions.prefix)
|
|
207
|
+
if (shadowed.length) console.warn(`[brust] warning: a route under the actions prefix answers GET there, so these GET actions are never reached: ${shadowed.join(', ')}`)
|
|
208
|
+
// F475: a listed route must parse nothing (the app reads the stream itself); an unlisted parse-none route stays buffered.
|
|
209
|
+
const { missing, parsed, unlisted } = streamRouteCheck(actionsApp, manifest.actions.stream ?? [], manifest.actions.prefix)
|
|
210
|
+
if (missing.length) throw new Error(`[actions] stream lists ${missing.join(', ')}, but no { parse: 'none' } action route of that method and path exists`)
|
|
211
|
+
if (parsed.length)
|
|
212
|
+
throw new Error(`[actions] stream covers ${parsed.join(', ')}, which parse their body; give them { parse: 'none' } or narrow the stream entry`)
|
|
213
|
+
if (unlisted.length && (!env.BRUST_WORKER_ID || env.BRUST_WORKER_ID === '0'))
|
|
214
|
+
console.warn(`[brust] warning: these { parse: 'none' } actions are not in [actions] stream, so their bodies are buffered, capped at max_body_bytes: ${unlisted.join(', ')}`)
|
|
136
215
|
setActionsLimits({ maxResponseBytes: manifest.actions.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES })
|
|
137
216
|
}
|
|
217
|
+
loaderCap = Number.parseInt(env.BRUST_MAX_LOADER_RESPONSE_BYTES ?? '', 10) || DEFAULT_MAX_LOADER_RESPONSE_BYTES
|
|
218
|
+
const slotBytes = Number.parseInt(env.BRUST_SLOT_BYTES ?? '', 10) || SLOT_BYTES
|
|
138
219
|
const workerId = Number.parseInt(env.BRUST_WORKER_ID ?? '0', 10) || 0
|
|
139
220
|
const l2 = manifest.cache ? await startCacheAdapter(manifest.cache, dist, dirname(resolve(entry)), workerId) : null
|
|
140
|
-
sab = opts.sab ?? new SharedArrayBuffer(
|
|
221
|
+
sab = opts.sab ?? new SharedArrayBuffer(slotBytes * slots)
|
|
141
222
|
view = new Uint8Array(sab)
|
|
142
223
|
const handlers = makeHandlers({
|
|
143
224
|
leaves: flattenRoutes(routes).leaves,
|
|
144
225
|
jobs,
|
|
145
226
|
manifest,
|
|
146
227
|
stores: (jobsMod.stores ?? {}) as Record<string, object>,
|
|
228
|
+
...(env.BRUST_SSG === '1' && env.BRUST_SSG_LOG ? { ssg: ssgLog(env.BRUST_SSG_LOG) } : {}),
|
|
229
|
+
dev: env.NODE_ENV === 'development',
|
|
147
230
|
})
|
|
148
231
|
const id = registerWorker(view, slots, makeDispatch(handlers, view, slots))
|
|
149
232
|
if (l2 && env.BRUST_WORKER_ID === '0') {
|