@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.11
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 +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +330 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +341 -43
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +317 -0
- package/src/build/compile.ts +74 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +224 -20
- package/src/build/manifest.ts +69 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -20
- package/src/build/server-only.ts +39 -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 +258 -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/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +19 -0
- package/src/cli.ts +70 -2
- package/src/config.ts +127 -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 +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +61 -14
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +213 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/worker.ts +129 -245
package/src/native.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface StartOptions {
|
|
|
22
22
|
workers: number
|
|
23
23
|
claimTimeoutMs?: number
|
|
24
24
|
callTimeoutMs?: number
|
|
25
|
+
/** L10: the biggest loader/jobs reply that may cross the heap when it does not fit the slot; absent = 8388608. */
|
|
26
|
+
maxLoaderResponseBytes?: number
|
|
25
27
|
/** tokio I/O threads; absent = one per core. */
|
|
26
28
|
workerThreads?: number
|
|
27
29
|
l1Capacity?: number
|
|
@@ -31,6 +33,20 @@ export interface StartOptions {
|
|
|
31
33
|
cors?: CorsOptions
|
|
32
34
|
/** The actions subtree (M3-F A3); absent = no actions. Boot error `actions: …` when invalid. */
|
|
33
35
|
actions?: ActionsOptions
|
|
36
|
+
/** L16: what the page tells the browser `client()` (base URL, credentials, a non-default prefix); absent = from `actions` alone. */
|
|
37
|
+
actionsClient?: ActionsClientOptions
|
|
38
|
+
/** The cache adapter (`manifest.cache`); absent = none (byte-identical server). */
|
|
39
|
+
l2?: L2Options
|
|
40
|
+
/** `brust dev` only (L14): a request carrying this token (`x-brust-dev-token`) is traced; absent in production. */
|
|
41
|
+
devToken?: string
|
|
42
|
+
}
|
|
43
|
+
/** napi `NapiL2Options`; defaults 50 ms, 1048576 bytes. */
|
|
44
|
+
export interface L2Options {
|
|
45
|
+
adapter: string
|
|
46
|
+
getTimeoutMs?: number
|
|
47
|
+
maxBodyBytes?: number
|
|
48
|
+
namespace: string
|
|
49
|
+
buildId: string
|
|
34
50
|
}
|
|
35
51
|
/** napi `NapiCorsOptions` (camelCase keys; snake_case keys are silently dropped). */
|
|
36
52
|
export interface CorsOptions {
|
|
@@ -41,12 +57,22 @@ export interface CorsOptions {
|
|
|
41
57
|
credentials?: boolean
|
|
42
58
|
maxAgeSeconds?: number
|
|
43
59
|
}
|
|
44
|
-
/** napi `
|
|
60
|
+
/** napi `NapiActionsClient`. */
|
|
61
|
+
export interface ActionsClientOptions {
|
|
62
|
+
prefix?: string
|
|
63
|
+
baseUrl?: string
|
|
64
|
+
credentials?: 'same-origin' | 'include' | 'omit'
|
|
65
|
+
}
|
|
66
|
+
/** napi `NapiActionsOptions`; defaults `/_brust/action`, 1048576, 8388608. */
|
|
45
67
|
export interface ActionsOptions {
|
|
46
68
|
prefix?: string
|
|
47
69
|
maxBodyBytes?: number
|
|
70
|
+
/** F82: response body cap (bytes). Default 8388608. */
|
|
71
|
+
maxResponseBytes?: number
|
|
72
|
+
/** F87: cross-site mutating requests. Default `reject`. */
|
|
73
|
+
crossSite?: 'reject' | 'allow'
|
|
48
74
|
}
|
|
49
|
-
export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch', requestJson: string, slot: number) => Promise<number>
|
|
75
|
+
export type WorkerFn = (kind: 'loader' | 'jobs' | 'fetch' | 'cache', requestJson: string, slot: number) => Promise<number>
|
|
50
76
|
export interface NativeBinding {
|
|
51
77
|
startServer(opts: StartOptions): void
|
|
52
78
|
registerWorker(buf: Uint8Array, slots: number, f: WorkerFn): number
|
|
@@ -57,6 +83,20 @@ export interface NativeBinding {
|
|
|
57
83
|
localAddr(): string
|
|
58
84
|
/** M3-P P6 — see crates/brust-napi/src/server.rs `plan_jobs`. Never throws. */
|
|
59
85
|
planJobs(slot: number, len: number): string
|
|
86
|
+
/** L10 — `planJobs` for a loader response too large for the slot: the bytes are handed over directly. Never throws. */
|
|
87
|
+
planJobsBuf(slot: number, buf: Uint8Array): string
|
|
88
|
+
/** F82 — the heap request body of the in-flight `fetch` call, once; null if none. Never throws. */
|
|
89
|
+
takeFetchBody(slot: number): Uint8Array | null
|
|
90
|
+
/** F82 — hand Rust a response body too large for the slot; false = declined. Never throws. */
|
|
91
|
+
putFetchReply(slot: number, body: Uint8Array): boolean
|
|
92
|
+
/** cache adapter R10 — a foreign broadcast event: tier 1 + the floor at `seq`; never re-published. */
|
|
93
|
+
cacheReplay(args: InvalidateArgs, seq: number | null): InvalidateResult
|
|
94
|
+
/** cache adapter R9 — bump `stats.l2[name]`; false = unknown name / no adapter / no server. Never throws. */
|
|
95
|
+
cacheL2Count(name: string): boolean
|
|
96
|
+
/** cache adapter R10 — drop both tier-1 caches (a broadcast gap); the L1 entries removed. */
|
|
97
|
+
cacheClear(): number
|
|
98
|
+
/** Static output (gaps round R9.4): the request reads of a cache key expression; throws when it does not parse. */
|
|
99
|
+
cacheExprReads(src: string): string[]
|
|
60
100
|
}
|
|
61
101
|
|
|
62
102
|
let binding: NativeBinding | null = null
|
|
@@ -83,3 +123,10 @@ export const cacheInvalidate = (args: InvalidateArgs): InvalidateResult => nativ
|
|
|
83
123
|
export const cacheStats = (): string => native().cacheStats()
|
|
84
124
|
export const localAddr = (): string => native().localAddr()
|
|
85
125
|
export const planJobs = (slot: number, len: number): string => native().planJobs(slot, len)
|
|
126
|
+
export const planJobsBuf = (slot: number, buf: Uint8Array): string => native().planJobsBuf(slot, buf)
|
|
127
|
+
export const takeFetchBody = (slot: number): Uint8Array | null => native().takeFetchBody(slot)
|
|
128
|
+
export const putFetchReply = (slot: number, body: Uint8Array): boolean => native().putFetchReply(slot, body)
|
|
129
|
+
export const cacheReplay = (args: InvalidateArgs, seq: number | null): InvalidateResult => native().cacheReplay(args, seq)
|
|
130
|
+
export const cacheL2Count = (name: string): boolean => native().cacheL2Count(name)
|
|
131
|
+
export const cacheClear = (): number => native().cacheClear()
|
|
132
|
+
export const cacheExprReads = (src: string): string[] => native().cacheExprReads(src)
|
package/src/routes.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/// <reference path="./css-modules.d.ts" />
|
|
1
2
|
// Route tree, ids and loader verdicts (spec S4, S7 step 4). M2 routes carry ONLY
|
|
2
3
|
// `path/Component/loader/cache/children`; every other 0.1.x field fails loudly (M3).
|
|
3
4
|
|
|
@@ -29,15 +30,21 @@ export interface LoaderCtx<P = Record<string, string>> {
|
|
|
29
30
|
req: LoaderReq
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** `brust build --target static` (gaps round R9.2): `params` lists the values of a dynamic route's
|
|
34
|
+
* `{name}` segments, one record per page; `false` leaves the route out of the static output. The
|
|
35
|
+
* server and Cloudflare targets ignore it. */
|
|
36
|
+
export type RouteSsgConfig<P = Record<string, string>> = { params?: () => P[] | Promise<P[]> } | false
|
|
37
|
+
|
|
32
38
|
export interface Route<P = Record<string, string>, D = unknown> {
|
|
33
39
|
path?: string
|
|
34
40
|
Component?: ComponentType<any>
|
|
35
41
|
loader?: (ctx: LoaderCtx<P>) => Promise<D> | D
|
|
36
42
|
cache?: RouteCacheConfig
|
|
43
|
+
ssg?: RouteSsgConfig<P>
|
|
37
44
|
children?: Route<any, any>[]
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
export const ALLOWED_ROUTE_FIELDS = ['path', 'Component', 'loader', 'cache', 'children'] as const
|
|
47
|
+
export const ALLOWED_ROUTE_FIELDS = ['path', 'Component', 'loader', 'cache', 'ssg', 'children'] as const
|
|
41
48
|
const ALLOWED_CACHE_FIELDS = ['ttl_seconds', 'prefix', 'bypass', 'tags'] as const
|
|
42
49
|
const M3_CACHE_FIELDS = ['key', 'key_ttl_seconds'] as const
|
|
43
50
|
|
|
@@ -61,6 +68,18 @@ function validate(node: Route, where: string): void {
|
|
|
61
68
|
if (node.path !== undefined && typeof node.path !== 'string') throw new BrustRouteError(`route ${where}: path must be a string`)
|
|
62
69
|
if (node.loader !== undefined && typeof node.loader !== 'function')
|
|
63
70
|
throw new BrustRouteError(`route ${where}: loader must be a function`)
|
|
71
|
+
if (node.ssg !== undefined) {
|
|
72
|
+
const ssg = node.ssg as unknown
|
|
73
|
+
if (ssg !== false && (typeof ssg !== 'object' || ssg === null || Array.isArray(ssg)))
|
|
74
|
+
throw new BrustRouteError(`route ${where}: ssg must be false or { params }`)
|
|
75
|
+
if (ssg !== false) {
|
|
76
|
+
for (const k of Object.keys(ssg as object))
|
|
77
|
+
if (k !== 'params') throw new BrustRouteError(`route ${where}: unknown ssg field ssg.${k} (allowed: params)`)
|
|
78
|
+
const params = (ssg as { params?: unknown }).params
|
|
79
|
+
if (params !== undefined && typeof params !== 'function') throw new BrustRouteError(`route ${where}: ssg.params must be a function`)
|
|
80
|
+
}
|
|
81
|
+
if (node.children !== undefined) throw new BrustRouteError(`route ${where}: ssg on a layout route is not supported (put it on each leaf)`)
|
|
82
|
+
}
|
|
64
83
|
if (node.cache !== undefined) {
|
|
65
84
|
if (typeof node.cache !== 'object' || node.cache === null) throw new BrustRouteError(`route ${where}: cache must be an object`)
|
|
66
85
|
for (const k of Object.keys(node.cache)) {
|
package/src/run.ts
CHANGED
|
@@ -4,42 +4,72 @@
|
|
|
4
4
|
// once (130 / 143); a signal before every worker registered exits 0 at once (nothing is being
|
|
5
5
|
// served yet, and the server's drain only starts once it accepts). A worker that dies (error,
|
|
6
6
|
// process.exit, OOM) → drain → exit 1: M2 does not respawn, the supervisor restarts the process.
|
|
7
|
+
// F165: a build whose manifest says `needs_workers: false` (no loader, job, actions app or cache
|
|
8
|
+
// adapter) spawns none; the server then serves at once and a stray worker call answers 503.
|
|
7
9
|
// Nothing here returns on its own: a started server keeps the process alive and a drained one
|
|
8
10
|
// does not end it, so every exit is an explicit `process.exit`.
|
|
9
11
|
import { existsSync, readFileSync } from 'node:fs'
|
|
10
12
|
import { join, resolve } from 'node:path'
|
|
11
|
-
import { type BrustConfig, loadConfig } from './config'
|
|
12
|
-
import { beginDrain, type StartOptions, startServer, untilReady } from './native'
|
|
13
|
-
import {
|
|
13
|
+
import { type BrustConfig, type ConfigOverrides, loadConfig } from './config'
|
|
14
|
+
import { beginDrain, localAddr, type StartOptions, startServer, untilReady } from './native'
|
|
15
|
+
import { DEFAULT_MAX_LOADER_RESPONSE_BYTES } from './worker'
|
|
14
16
|
|
|
15
17
|
export interface RunOptions {
|
|
16
18
|
distDir?: string
|
|
17
19
|
/** The routes module the workers import (`export const routes` or default). */
|
|
18
20
|
entry?: string
|
|
19
21
|
/** Overrides below env (CLI flags): see config.ts (incl. `bootTimeoutMs`, default 30000). */
|
|
20
|
-
config?:
|
|
22
|
+
config?: ConfigOverrides
|
|
23
|
+
/** Static output (gaps round L9): runs once every worker is ready, with the bound `host:port`;
|
|
24
|
+
* then the server drains and the process exits with the returned code. */
|
|
25
|
+
onReady?: (addr: string) => Promise<number>
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
/** The manifest fields `run` needs (build/manifest.ts owns the full shape). */
|
|
24
29
|
export interface RunManifest {
|
|
25
|
-
actions?: { entry: string; prefix: string; maxBodyBytes: number } | null
|
|
30
|
+
actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null
|
|
31
|
+
/** L16 R16.3: `brust build --actions-only`: no page routes and no routes entry. */
|
|
32
|
+
actions_only?: boolean
|
|
33
|
+
/** L16 R16.4b: a page-only build; the actions app is deployed elsewhere (`[actions] base_url`). */
|
|
34
|
+
actions_remote?: { prefix: string } | null
|
|
35
|
+
/** `[cache]`: the server's L2 settings (the workers load the adapter itself). */
|
|
36
|
+
cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string } | null
|
|
37
|
+
/** F165: `false` = no route can call a worker; absent (older manifests) = the pool is needed. */
|
|
38
|
+
needs_workers?: boolean
|
|
26
39
|
}
|
|
27
40
|
|
|
28
|
-
/**
|
|
29
|
-
export function
|
|
41
|
+
/** F165 (R40.1): the pool size `run` spawns: none only when the build said no request needs one. */
|
|
42
|
+
export function workerCount(cfg: BrustConfig, m: RunManifest): number {
|
|
43
|
+
return m.needs_workers === false ? 0 : cfg.workers
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pure: the napi `StartOptions`. No actions/cors/cache → exactly the pre-actions keys. */
|
|
47
|
+
export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string, devToken?: string): StartOptions {
|
|
30
48
|
return {
|
|
31
49
|
host: cfg.host,
|
|
32
50
|
port: cfg.port,
|
|
33
51
|
distDir,
|
|
34
|
-
workers: cfg
|
|
52
|
+
workers: workerCount(cfg, m),
|
|
35
53
|
callTimeoutMs: cfg.callTimeoutMs,
|
|
54
|
+
...(cfg.maxLoaderResponseBytes !== undefined && cfg.maxLoaderResponseBytes !== DEFAULT_MAX_LOADER_RESPONSE_BYTES ? { maxLoaderResponseBytes: cfg.maxLoaderResponseBytes } : {}),
|
|
36
55
|
workerThreads: cfg.ioThreads,
|
|
37
56
|
generator: `brust/${version}`,
|
|
38
|
-
...(m.actions ? { actions: { prefix: m.actions.prefix, maxBodyBytes: m.actions.maxBodyBytes } } : {}),
|
|
57
|
+
...(m.actions ? { actions: { prefix: m.actions.prefix, maxBodyBytes: m.actions.maxBodyBytes, ...(m.actions.maxResponseBytes !== undefined ? { maxResponseBytes: m.actions.maxResponseBytes } : {}), ...(m.actions.crossSite ? { crossSite: m.actions.crossSite } : {}) } } : {}),
|
|
58
|
+
...(actionsClient(cfg, m) ? { actionsClient: actionsClient(cfg, m) } : {}),
|
|
39
59
|
...(cfg.cors ? { cors: cfg.cors } : {}),
|
|
60
|
+
...(devToken ? { devToken } : {}),
|
|
61
|
+
...(m.cache ? { l2: { adapter: m.cache.adapter, getTimeoutMs: m.cache.getTimeoutMs, maxBodyBytes: m.cache.maxBodyBytes, namespace: m.cache.namespace, buildId: m.cache.buildId } } : {}),
|
|
40
62
|
}
|
|
41
63
|
}
|
|
42
64
|
|
|
65
|
+
/** L16: what the page tells the browser `client()` (R16.1/R16.2); absent while it would only repeat the prefix script. */
|
|
66
|
+
function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptions['actionsClient']> | undefined {
|
|
67
|
+
const credentials = cfg.actionsCredentials !== undefined && cfg.actionsCredentials !== 'same-origin' ? cfg.actionsCredentials : undefined
|
|
68
|
+
if (cfg.actionsBaseUrl === undefined && credentials === undefined && !m.actions_remote) return undefined
|
|
69
|
+
const prefix = m.actions?.prefix ?? m.actions_remote?.prefix
|
|
70
|
+
return { ...(prefix ? { prefix } : {}), ...(cfg.actionsBaseUrl !== undefined ? { baseUrl: cfg.actionsBaseUrl } : {}), ...(credentials ? { credentials } : {}) }
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
/** Every worker's response buffer, kept reachable until the process exits (see run()). */
|
|
44
74
|
const workerBuffers: SharedArrayBuffer[] = []
|
|
45
75
|
|
|
@@ -60,7 +90,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
|
|
|
60
90
|
fail(`bad manifest ${manifest}: ${(e as Error).message}`)
|
|
61
91
|
}
|
|
62
92
|
const entry = resolve(process.env.BRUST_APP_ENTRY || opts.entry || 'routes.tsx')
|
|
63
|
-
|
|
93
|
+
// R16.3: an actions-only dist has no pages, so no routes module either.
|
|
94
|
+
if (!m.actions_only && !existsSync(entry)) fail(`routes entry ${entry} not found`)
|
|
64
95
|
|
|
65
96
|
let cfg: BrustConfig
|
|
66
97
|
try {
|
|
@@ -69,9 +100,11 @@ export async function run(opts: RunOptions = {}): Promise<void> {
|
|
|
69
100
|
fail((e as Error).message)
|
|
70
101
|
}
|
|
71
102
|
|
|
103
|
+
if (m.actions_remote && cfg.actionsBaseUrl === undefined)
|
|
104
|
+
fail('this build has no local actions app: set [actions] base_url (or BRUST_ACTIONS_BASE_URL) to the deployment that serves them')
|
|
72
105
|
const version: string = (await Bun.file(join(import.meta.dir, '../package.json')).json()).version
|
|
73
106
|
try {
|
|
74
|
-
startServer(startOptions(cfg, distDir, m, version))
|
|
107
|
+
startServer(startOptions(cfg, distDir, m, version, process.env.BRUST_DEV_TOKEN || undefined))
|
|
75
108
|
} catch (e) {
|
|
76
109
|
fail((e as Error).message)
|
|
77
110
|
}
|
|
@@ -84,8 +117,12 @@ export async function run(opts: RunOptions = {}): Promise<void> {
|
|
|
84
117
|
...process.env,
|
|
85
118
|
NODE_ENV: process.env.NODE_ENV,
|
|
86
119
|
BRUST_RENDER_SLOTS: String(cfg.renderSlots),
|
|
120
|
+
BRUST_SLOT_BYTES: String(cfg.slotBytes),
|
|
121
|
+
BRUST_MAX_LOADER_RESPONSE_BYTES: String(cfg.maxLoaderResponseBytes),
|
|
87
122
|
BRUST_DIST_DIR: distDir,
|
|
88
123
|
BRUST_APP_ENTRY: entry,
|
|
124
|
+
// One cache-adapter origin per process: its workers' published events are not replayed here.
|
|
125
|
+
BRUST_CACHE_ORIGIN: crypto.randomUUID(),
|
|
89
126
|
}
|
|
90
127
|
|
|
91
128
|
let ready = false
|
|
@@ -106,11 +143,12 @@ export async function run(opts: RunOptions = {}): Promise<void> {
|
|
|
106
143
|
.finally(() => exit(failed ? 1 : code))
|
|
107
144
|
}
|
|
108
145
|
|
|
146
|
+
const workers = workerCount(cfg, m)
|
|
109
147
|
const url = new URL('./worker.ts', import.meta.url)
|
|
110
|
-
for (let i = 0; i <
|
|
148
|
+
for (let i = 0; i < workers; i++) {
|
|
111
149
|
// The response buffer is owned HERE, for the process lifetime: the Rust dispatcher keeps a raw
|
|
112
150
|
// pointer into it, so it must not be freed with the worker (crates/brust-napi dispatch.rs).
|
|
113
|
-
const sab = new SharedArrayBuffer(
|
|
151
|
+
const sab = new SharedArrayBuffer(cfg.slotBytes * cfg.renderSlots)
|
|
114
152
|
workerBuffers.push(sab)
|
|
115
153
|
const w = new Worker(url, { env: { ...env, BRUST_WORKER_ID: String(i) }, workerData: { sab } } as WorkerOptions)
|
|
116
154
|
// No respawn in M2: a worker that fails to boot or dies later takes the process down loudly
|
|
@@ -152,6 +190,15 @@ export async function run(opts: RunOptions = {}): Promise<void> {
|
|
|
152
190
|
fail(`workers not ready: ${String((e as Error).message ?? e)} (BRUST_BOOT_TIMEOUT_MS=${cfg.bootTimeoutMs})`)
|
|
153
191
|
}
|
|
154
192
|
ready = true
|
|
155
|
-
console.log(`[brust] ready (${
|
|
193
|
+
console.log(`[brust] ready (${workers} worker${workers === 1 ? '' : 's'})`)
|
|
194
|
+
if (opts.onReady) {
|
|
195
|
+
let code = 1
|
|
196
|
+
try {
|
|
197
|
+
code = await opts.onReady(localAddr())
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error(`[brust] ${(e as Error).stack ?? String(e)}`)
|
|
200
|
+
}
|
|
201
|
+
if (!draining) drainThenExit(code)
|
|
202
|
+
}
|
|
156
203
|
await new Promise<never>(() => {})
|
|
157
204
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// What the generated Cloudflare entry (`brust-app.js`) imports: the host-agnostic handler code and the
|
|
2
|
+
// store-scoped action fetch. No Bun / worker_threads here; `actions-bridge`'s napi import is stubbed by the
|
|
3
|
+
// CF bundle (M4-CF R8).
|
|
4
|
+
import { type CfCacheBridge, type CfInstallExt, installCache } from './cache-adapter/workers'
|
|
5
|
+
import type { InvalidateArgs, InvalidateResult } from './native'
|
|
6
|
+
|
|
7
|
+
export { fetchInScope, noActions } from './actions-bridge'
|
|
8
|
+
export { jsonCall, makeHandlers } from './handlers'
|
|
9
|
+
export { flattenRoutes } from './routes'
|
|
10
|
+
|
|
11
|
+
/** What the wasm side hands over at start (`#[event(start]` -> `install({ invalidate, l2Count })`, ruling 19753649). */
|
|
12
|
+
export interface CfRuntime {
|
|
13
|
+
invalidate(args: InvalidateArgs): InvalidateResult
|
|
14
|
+
/** Bumps a `stats.l2` counter (cache adapters R9). */
|
|
15
|
+
l2Count?(name: string): boolean
|
|
16
|
+
}
|
|
17
|
+
export type { CfCacheBridge, CfInstallExt }
|
|
18
|
+
|
|
19
|
+
let rt: CfRuntime | null = null
|
|
20
|
+
|
|
21
|
+
/** Called by the wasm glue once at Worker start; the CF bundle's napi stub forwards `cacheInvalidate` to it.
|
|
22
|
+
* With a cache adapter (`ext`, from the generated entry) it answers the adapter bridge (R11, R14). */
|
|
23
|
+
export function install(runtime: CfRuntime, ext?: CfInstallExt): { cache: CfCacheBridge | null } {
|
|
24
|
+
rt = runtime
|
|
25
|
+
return { cache: ext ? installCache(runtime, ext) : null }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The installed runtime; throws before `install` (a request cannot arrive earlier). */
|
|
29
|
+
export function installedRuntime(): CfRuntime {
|
|
30
|
+
if (!rt) throw new Error('brust: the Cloudflare runtime is not installed (install(rt) was not called at start)')
|
|
31
|
+
return rt
|
|
32
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// `brust shadcn init [component ...]`: sets a brust project up for shadcn/ui and adds components (F175, R41.1-R41.10).
|
|
2
|
+
// shadcn's own `init` does not detect brust, so this writes what `add` needs (components.json, the `@/*` path),
|
|
3
|
+
// installs the four packages the components import, and runs ONE pinned shadcn CLI for the theme and the components.
|
|
4
|
+
// Every child process is spawned from an argv array, never through a shell; the runner is injected for tests.
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
7
|
+
import { type ObjectNode, raw, readObject } from './jsonc'
|
|
8
|
+
|
|
9
|
+
/** The shadcn CLI version this command runs (R41.6). A release that moves it re-runs the transcript. */
|
|
10
|
+
export const SHADCN_VERSION = '4.21.4'
|
|
11
|
+
|
|
12
|
+
/** The packages the components import (R41.5). */
|
|
13
|
+
export const SHADCN_DEPS = ['radix-ui', 'lucide-react', 'class-variance-authority', 'tw-animate-css'] as const
|
|
14
|
+
|
|
15
|
+
/** Runs `argv` in `cwd` with the terminal attached; resolves to the exit code. Never a shell string. */
|
|
16
|
+
export type Runner = (argv: string[], cwd: string) => Promise<number>
|
|
17
|
+
|
|
18
|
+
export interface InitOptions {
|
|
19
|
+
/** Project root (cwd). */
|
|
20
|
+
root: string
|
|
21
|
+
components: string[]
|
|
22
|
+
force?: boolean
|
|
23
|
+
css?: string
|
|
24
|
+
baseColor?: string
|
|
25
|
+
shadcnVersion?: string
|
|
26
|
+
run: Runner
|
|
27
|
+
out: (line: string) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const COMPONENT = /^[a-z0-9][a-z0-9-]*$/
|
|
31
|
+
const REGISTRY_URL = /^https:\/\/[^\s]+$/
|
|
32
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
|
|
33
|
+
const COLOR = /^[a-z]+$/
|
|
34
|
+
|
|
35
|
+
/** The `components.json` of the tested fixture (`tests/fixtures/shadcn/components.json`), for `css` and `baseColor` (R41.2). */
|
|
36
|
+
export function componentsJson(css: string, baseColor: string): string {
|
|
37
|
+
return `{
|
|
38
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
39
|
+
"style": "new-york",
|
|
40
|
+
"rsc": false,
|
|
41
|
+
"tsx": true,
|
|
42
|
+
"tailwind": { "config": "", "css": ${JSON.stringify(css)}, "baseColor": ${JSON.stringify(baseColor)}, "cssVariables": true, "prefix": "" },
|
|
43
|
+
"iconLibrary": "lucide",
|
|
44
|
+
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }
|
|
45
|
+
}
|
|
46
|
+
`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type PathEdit =
|
|
50
|
+
| { kind: 'added'; text: string }
|
|
51
|
+
| { kind: 'present' }
|
|
52
|
+
| { kind: 'other'; target: string }
|
|
53
|
+
| { kind: 'unparseable'; why: string }
|
|
54
|
+
|
|
55
|
+
const ENTRY = '"@/*": ["./*"]'
|
|
56
|
+
|
|
57
|
+
/** The separator before a non-empty object's first member, so the new entry takes the same line and indent. */
|
|
58
|
+
function lead(text: string, o: ObjectNode): string {
|
|
59
|
+
const first = o.members[0]
|
|
60
|
+
if (!first) return ' '
|
|
61
|
+
const nl = text.lastIndexOf('\n', first.keyStart)
|
|
62
|
+
const indent = nl > o.open ? text.slice(nl + 1, first.keyStart) : ''
|
|
63
|
+
if (nl > o.open && /^\s*$/.test(indent)) return `\n${indent}`
|
|
64
|
+
const same = text.slice(o.open + 1, first.keyStart)
|
|
65
|
+
return /^\s*$/.test(same) ? same : ' '
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Adds `"@/*": ["./*"]` to `compilerOptions.paths` as a text splice (R41.4): comments and layout are kept. */
|
|
69
|
+
export function addAliasPath(text: string): PathEdit {
|
|
70
|
+
let top: ObjectNode
|
|
71
|
+
try {
|
|
72
|
+
top = readObject(text)
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { kind: 'unparseable', why: (e as Error).message }
|
|
75
|
+
}
|
|
76
|
+
const co = top.members.find((m) => m.key === 'compilerOptions')?.object
|
|
77
|
+
if (!co) return { kind: 'unparseable', why: 'no compilerOptions object' }
|
|
78
|
+
const pathsMember = co.members.find((m) => m.key === 'paths')
|
|
79
|
+
if (!pathsMember) {
|
|
80
|
+
const ws = lead(text, co)
|
|
81
|
+
const body = co.members.length > 0 ? `${ws}"paths": { ${ENTRY} },` : ` "paths": { ${ENTRY} } `
|
|
82
|
+
return { kind: 'added', text: text.slice(0, co.open + 1) + body + text.slice(co.open + 1) }
|
|
83
|
+
}
|
|
84
|
+
const paths = pathsMember.object
|
|
85
|
+
if (!paths) return { kind: 'unparseable', why: 'compilerOptions.paths is not an object' }
|
|
86
|
+
const existing = paths.members.find((m) => m.key === '@/*')
|
|
87
|
+
if (existing) {
|
|
88
|
+
const target = raw(text, existing)
|
|
89
|
+
return target.replace(/\s+/g, '') === '["./*"]' ? { kind: 'present' } : { kind: 'other', target }
|
|
90
|
+
}
|
|
91
|
+
const ws = lead(text, paths)
|
|
92
|
+
const body = paths.members.length > 0 ? `${ws}${ENTRY},` : ` ${ENTRY} `
|
|
93
|
+
return { kind: 'added', text: text.slice(0, paths.open + 1) + body + text.slice(paths.open + 1) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The Tailwind entry CSS: `--css`, else the one top-level `*.css` that imports `tailwindcss` (R41.3). */
|
|
97
|
+
function tailwindEntry(root: string, css: string | undefined): { css: string } | { error: string } {
|
|
98
|
+
if (css !== undefined) {
|
|
99
|
+
const abs = resolve(root, css)
|
|
100
|
+
const rel = relative(root, abs)
|
|
101
|
+
if (css.startsWith('-') || isAbsolute(css) || rel.startsWith('..') || !existsSync(abs)) {
|
|
102
|
+
return { error: `--css ${JSON.stringify(css)} must be an existing CSS file inside the project` }
|
|
103
|
+
}
|
|
104
|
+
return { css: rel.split('\\').join('/') }
|
|
105
|
+
}
|
|
106
|
+
const hit = ['app.css', 'styles.css', 'global.css', 'globals.css', 'index.css'].filter((f) => {
|
|
107
|
+
const p = join(root, f)
|
|
108
|
+
return existsSync(p) && /@import\s+["']tailwindcss["']/.test(readFileSync(p, 'utf8'))
|
|
109
|
+
})
|
|
110
|
+
if (hit.length === 1) return { css: hit[0]! }
|
|
111
|
+
return { error: hit.length === 0 ? 'cannot tell which CSS file is the Tailwind entry; pass --css <file>' : `more than one Tailwind entry (${hit.join(', ')}); pass --css <file>` }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type Step = 'done' | 'skipped' | 'failed'
|
|
115
|
+
|
|
116
|
+
/** Returns the process exit code: 0 ok, 2 usage / not a brust project, else the failing child's code. */
|
|
117
|
+
export async function shadcnInit(o: InitOptions): Promise<number> {
|
|
118
|
+
const { root, out } = o
|
|
119
|
+
const fail2 = (msg: string): number => {
|
|
120
|
+
out(`error shadcn-init ${msg}`)
|
|
121
|
+
return 2
|
|
122
|
+
}
|
|
123
|
+
// R41.1: a brust project root.
|
|
124
|
+
const pkgPath = join(root, 'package.json')
|
|
125
|
+
if (!existsSync(pkgPath)) return fail2('no package.json here: run it in the root of a brust project')
|
|
126
|
+
let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
127
|
+
try {
|
|
128
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
129
|
+
} catch {
|
|
130
|
+
return fail2('package.json is not valid JSON')
|
|
131
|
+
}
|
|
132
|
+
if (!pkg.dependencies?.['@brust/core'] && !pkg.devDependencies?.['@brust/core']) {
|
|
133
|
+
return fail2('package.json does not depend on @brust/core: run it in the root of a brust project')
|
|
134
|
+
}
|
|
135
|
+
// R41.8: validate everything that reaches an argv before anything is written.
|
|
136
|
+
for (const c of o.components) {
|
|
137
|
+
if (!COMPONENT.test(c) && !REGISTRY_URL.test(c)) return fail2(`component ${JSON.stringify(c)} must match ^[a-z0-9][a-z0-9-]*$ or be an https:// registry URL`)
|
|
138
|
+
}
|
|
139
|
+
const version = o.shadcnVersion ?? SHADCN_VERSION
|
|
140
|
+
if (!VERSION.test(version)) return fail2(`--shadcn-version ${JSON.stringify(version)} must be an exact version like ${SHADCN_VERSION}`)
|
|
141
|
+
const baseColor = o.baseColor ?? 'neutral'
|
|
142
|
+
if (!COLOR.test(baseColor)) return fail2(`--base-color ${JSON.stringify(baseColor)} must be a lowercase colour name`)
|
|
143
|
+
const entry = tailwindEntry(root, o.css)
|
|
144
|
+
if ('error' in entry) return fail2(entry.error)
|
|
145
|
+
|
|
146
|
+
const report = (name: string, step: Step, why = ''): void => out(`${step === 'done' ? '✓' : step === 'skipped' ? '-' : '✗'} ${name}: ${step}${why ? ` (${why})` : ''}`)
|
|
147
|
+
const stop = (name: string, code: number): number => {
|
|
148
|
+
report(name, 'failed', `exit ${code}`)
|
|
149
|
+
out(`error shadcn-init ${name} failed with exit code ${code}; fix it and run the command again (finished steps are skipped)`)
|
|
150
|
+
return code
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// R41.2: components.json.
|
|
154
|
+
const cj = join(root, 'components.json')
|
|
155
|
+
if (!existsSync(cj) || o.force) {
|
|
156
|
+
writeFileSync(cj, componentsJson(entry.css, baseColor))
|
|
157
|
+
report('components.json', 'done', entry.css)
|
|
158
|
+
} else report('components.json', 'skipped', 'exists; --force replaces it')
|
|
159
|
+
|
|
160
|
+
// R41.4: the `@/*` path.
|
|
161
|
+
const tsPath = join(root, 'tsconfig.json')
|
|
162
|
+
if (!existsSync(tsPath)) {
|
|
163
|
+
report('tsconfig.json', 'skipped', `no tsconfig.json; add ${JSON.stringify({ compilerOptions: { paths: { '@/*': ['./*'] } } })}`)
|
|
164
|
+
} else {
|
|
165
|
+
const edit = addAliasPath(readFileSync(tsPath, 'utf8'))
|
|
166
|
+
if (edit.kind === 'added') {
|
|
167
|
+
writeFileSync(tsPath, edit.text)
|
|
168
|
+
report('tsconfig.json', 'done', 'added "@/*": ["./*"] under compilerOptions.paths')
|
|
169
|
+
} else if (edit.kind === 'present') report('tsconfig.json', 'skipped', '"@/*" is already there')
|
|
170
|
+
else if (edit.kind === 'other') report('tsconfig.json', 'skipped', `warning: "@/*" already points at ${edit.target}; shadcn's imports need ["./*"]`)
|
|
171
|
+
else report('tsconfig.json', 'skipped', `not edited (${edit.why}); add this line to compilerOptions.paths yourself: ${ENTRY}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// R41.5: dependencies.
|
|
175
|
+
const have = { ...pkg.devDependencies, ...pkg.dependencies }
|
|
176
|
+
const missing = SHADCN_DEPS.filter((d) => !have[d])
|
|
177
|
+
if (missing.length === 0) report('dependencies', 'skipped', 'all four are installed')
|
|
178
|
+
else {
|
|
179
|
+
const code = await o.run(['bun', 'add', ...missing], root)
|
|
180
|
+
if (code !== 0) return stop('bun add', code)
|
|
181
|
+
report('dependencies', 'done', missing.join(' '))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// R41.6 / R41.7: the pinned shadcn CLI. `bun x` is `bunx`; argv only.
|
|
185
|
+
const shadcn = (...args: string[]): string[] => ['bun', 'x', `shadcn@${version}`, ...args]
|
|
186
|
+
const cssText = readFileSync(join(root, entry.css), 'utf8')
|
|
187
|
+
if (cssText.includes('shadcn/tailwind.css') && !o.force) report('theme', 'skipped', `${entry.css} already has the shadcn theme; --force runs it again`)
|
|
188
|
+
else {
|
|
189
|
+
const code = await o.run(shadcn('add', 'index', '-y'), root)
|
|
190
|
+
if (code !== 0) return stop('shadcn add index', code)
|
|
191
|
+
report('theme', 'done', `shadcn@${version} add index`)
|
|
192
|
+
}
|
|
193
|
+
// A component whose file is already in components/ui is not added again (idempotent); a URL always is.
|
|
194
|
+
const todo = o.components.filter((c) => o.force || REGISTRY_URL.test(c) || !existsSync(join(root, 'components/ui', `${c}.tsx`)))
|
|
195
|
+
for (const c of o.components) if (!todo.includes(c)) report(c, 'skipped', 'components/ui already has it')
|
|
196
|
+
if (todo.length > 0) {
|
|
197
|
+
const code = await o.run(shadcn('add', ...todo, '-y'), root)
|
|
198
|
+
if (code !== 0) return stop(`shadcn add ${todo.join(' ')}`, code)
|
|
199
|
+
for (const c of todo) report(c, 'done')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
out('')
|
|
203
|
+
out('next:')
|
|
204
|
+
out(" import the components from './components/ui/<name>' in a page and add the route to routes.tsx")
|
|
205
|
+
out(' bun run build')
|
|
206
|
+
return 0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The real runner: argv spawn with the terminal attached, no shell. */
|
|
210
|
+
export const spawnRunner: Runner = async (argv, cwd) => {
|
|
211
|
+
const p = Bun.spawn(argv, { cwd, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
|
|
212
|
+
return await p.exited
|
|
213
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// A minimal JSONC reader that keeps byte offsets, so a file can be edited with a text splice (comments, trailing
|
|
2
|
+
// commas and formatting survive). It does not build values; it finds object members. Anything it cannot read throws.
|
|
3
|
+
|
|
4
|
+
export interface Member {
|
|
5
|
+
key: string
|
|
6
|
+
/** Offset of the member's key (its opening quote). */
|
|
7
|
+
keyStart: number
|
|
8
|
+
/** Offset of the member's value (the `{` of an object value). */
|
|
9
|
+
value: number
|
|
10
|
+
/** Offset just past the value. */
|
|
11
|
+
end: number
|
|
12
|
+
/** Parsed object when the value is an object, else `undefined`. */
|
|
13
|
+
object?: ObjectNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ObjectNode {
|
|
17
|
+
/** Offset of `{`. */
|
|
18
|
+
open: number
|
|
19
|
+
/** Offset of `}`. */
|
|
20
|
+
close: number
|
|
21
|
+
members: Member[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class Reader {
|
|
25
|
+
i = 0
|
|
26
|
+
constructor(readonly s: string) {}
|
|
27
|
+
|
|
28
|
+
ws(): void {
|
|
29
|
+
const { s } = this
|
|
30
|
+
for (;;) {
|
|
31
|
+
const c = s[this.i]
|
|
32
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '') this.i++
|
|
33
|
+
else if (c === '/' && s[this.i + 1] === '/') {
|
|
34
|
+
while (this.i < s.length && s[this.i] !== '\n') this.i++
|
|
35
|
+
} else if (c === '/' && s[this.i + 1] === '*') {
|
|
36
|
+
const e = s.indexOf('*/', this.i + 2)
|
|
37
|
+
if (e < 0) throw new Error('unterminated comment')
|
|
38
|
+
this.i = e + 2
|
|
39
|
+
} else return
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
string(): string {
|
|
44
|
+
const { s } = this
|
|
45
|
+
if (s[this.i] !== '"') throw new Error(`expected a string at ${this.i}`)
|
|
46
|
+
let j = this.i + 1
|
|
47
|
+
let out = ''
|
|
48
|
+
while (j < s.length && s[j] !== '"') {
|
|
49
|
+
if (s[j] === '\\') {
|
|
50
|
+
out += s[j + 1] ?? ''
|
|
51
|
+
j += 2
|
|
52
|
+
} else if (s[j] === '\n') throw new Error('newline in a string')
|
|
53
|
+
else out += s[j++]
|
|
54
|
+
}
|
|
55
|
+
if (s[j] !== '"') throw new Error('unterminated string')
|
|
56
|
+
this.i = j + 1
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Skips one value; returns its object node when it is an object. */
|
|
61
|
+
value(): ObjectNode | undefined {
|
|
62
|
+
this.ws()
|
|
63
|
+
const c = this.s[this.i]
|
|
64
|
+
if (c === '{') return this.object()
|
|
65
|
+
if (c === '[') {
|
|
66
|
+
this.i++
|
|
67
|
+
for (;;) {
|
|
68
|
+
this.ws()
|
|
69
|
+
if (this.s[this.i] === ']') {
|
|
70
|
+
this.i++
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
this.value()
|
|
74
|
+
this.ws()
|
|
75
|
+
if (this.s[this.i] === ',') this.i++
|
|
76
|
+
else if (this.s[this.i] !== ']') throw new Error(`expected , or ] at ${this.i}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (c === '"') {
|
|
80
|
+
this.string()
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
const m = /^(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/.exec(this.s.slice(this.i, this.i + 40))
|
|
84
|
+
if (!m) throw new Error(`unexpected character at ${this.i}`)
|
|
85
|
+
this.i += m[0].length
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
object(): ObjectNode {
|
|
90
|
+
const open = this.i
|
|
91
|
+
this.i++
|
|
92
|
+
const members: Member[] = []
|
|
93
|
+
for (;;) {
|
|
94
|
+
this.ws()
|
|
95
|
+
if (this.s[this.i] === '}') {
|
|
96
|
+
this.i++
|
|
97
|
+
return { open, close: this.i - 1, members }
|
|
98
|
+
}
|
|
99
|
+
const keyStart = this.i
|
|
100
|
+
const key = this.string()
|
|
101
|
+
this.ws()
|
|
102
|
+
if (this.s[this.i] !== ':') throw new Error(`expected : at ${this.i}`)
|
|
103
|
+
this.i++
|
|
104
|
+
this.ws()
|
|
105
|
+
const value = this.i
|
|
106
|
+
const object = this.value()
|
|
107
|
+
members.push({ key, keyStart, value, end: this.i, ...(object && { object }) })
|
|
108
|
+
this.ws()
|
|
109
|
+
if (this.s[this.i] === ',') this.i++
|
|
110
|
+
else if (this.s[this.i] !== '}') throw new Error(`expected , or } at ${this.i}`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The top-level object of a JSONC text. Throws on anything that is not JSONC. */
|
|
116
|
+
export function readObject(text: string): ObjectNode {
|
|
117
|
+
const r = new Reader(text)
|
|
118
|
+
r.ws()
|
|
119
|
+
if (text[r.i] !== '{') throw new Error('not an object')
|
|
120
|
+
const o = r.object()
|
|
121
|
+
r.ws()
|
|
122
|
+
if (r.i !== text.length) throw new Error('trailing content')
|
|
123
|
+
return o
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The raw text of a member's value. */
|
|
127
|
+
export function raw(text: string, m: Member): string {
|
|
128
|
+
return text.slice(m.value, m.end)
|
|
129
|
+
}
|