brustjs 0.1.0-alpha
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 +110 -0
- package/package.json +92 -0
- package/runtime/actions.ts +65 -0
- package/runtime/bun.lock +236 -0
- package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
- package/runtime/cli/build.ts +252 -0
- package/runtime/cli/dev.ts +92 -0
- package/runtime/cli/index.ts +30 -0
- package/runtime/cli/native-routes-emit.ts +171 -0
- package/runtime/cli/native-shim-plugin.ts +85 -0
- package/runtime/cli/new.ts +208 -0
- package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
- package/runtime/cli/templates/minimal/_gitignore +4 -0
- package/runtime/cli/templates/minimal/app.css +6 -0
- package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
- package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
- package/runtime/cli/templates/minimal/index.ts +4 -0
- package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
- package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
- package/runtime/cli/templates/minimal/routes.tsx +6 -0
- package/runtime/cli/templates/minimal/tsconfig.json +20 -0
- package/runtime/client/index.ts +121 -0
- package/runtime/config.ts +148 -0
- package/runtime/css/build.ts +54 -0
- package/runtime/css/component-build.ts +78 -0
- package/runtime/css/component-loader.ts +27 -0
- package/runtime/css/manifest.ts +51 -0
- package/runtime/css/process-modules.ts +56 -0
- package/runtime/css/route-deps.ts +33 -0
- package/runtime/css/scan-imports.ts +79 -0
- package/runtime/css.ts +39 -0
- package/runtime/dev/client.ts +49 -0
- package/runtime/dev/coordinator.ts +127 -0
- package/runtime/dev/inject.ts +17 -0
- package/runtime/dev/tui.ts +109 -0
- package/runtime/dev/watcher.ts +109 -0
- package/runtime/dev/worker-registry.ts +96 -0
- package/runtime/dev/ws-channel.ts +99 -0
- package/runtime/index.d.ts +199 -0
- package/runtime/index.js +604 -0
- package/runtime/index.ts +618 -0
- package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
- package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
- package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
- package/runtime/islands/_entries/react-dom.ts +7 -0
- package/runtime/islands/_entries/react.ts +11 -0
- package/runtime/islands/bootstrap.ts +241 -0
- package/runtime/islands/build.ts +141 -0
- package/runtime/islands/importmap.ts +17 -0
- package/runtime/islands/island.tsx +58 -0
- package/runtime/islands/native-render.ts +153 -0
- package/runtime/mcp/extractor.ts +160 -0
- package/runtime/mcp/manifest.ts +50 -0
- package/runtime/mcp/schema.ts +124 -0
- package/runtime/mcp/server.ts +250 -0
- package/runtime/render/inject-css-link.ts +59 -0
- package/runtime/render/inject-dev-client.ts +49 -0
- package/runtime/render/stream.ts +304 -0
- package/runtime/routes.ts +1406 -0
- package/runtime/scan-actions.ts +172 -0
- package/runtime/sse/handler.ts +85 -0
- package/runtime/tsconfig.json +14 -0
- package/runtime/ws/handler.ts +151 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type ChangeKind = 'ts' | 'css' | 'component-css' | 'html' | 'islands'
|
|
5
|
+
|
|
6
|
+
const IGNORE_DIR_SEGMENTS = new Set(['node_modules', '.git', '.brust', 'dist'])
|
|
7
|
+
const TS_RE = /\.(tsx?|jsx?)$/
|
|
8
|
+
const TEST_RE = /\.test\.(tsx?|jsx?)$/
|
|
9
|
+
|
|
10
|
+
/** Classify a changed path. Returns null when the path should be ignored.
|
|
11
|
+
* `root` is used to compute the relative path for ignore-segment matching. */
|
|
12
|
+
export function classifyPath(absPath: string, root: string): ChangeKind | null {
|
|
13
|
+
const rel = path.relative(root, absPath)
|
|
14
|
+
const segs = rel.split(path.sep)
|
|
15
|
+
for (const s of segs) {
|
|
16
|
+
if (IGNORE_DIR_SEGMENTS.has(s)) return null
|
|
17
|
+
}
|
|
18
|
+
if (TEST_RE.test(absPath)) return null
|
|
19
|
+
|
|
20
|
+
const base = path.basename(absPath)
|
|
21
|
+
if (base === 'app.css') return 'css'
|
|
22
|
+
// skip generated module type declarations
|
|
23
|
+
if (absPath.endsWith('.module.css.d.ts')) return null
|
|
24
|
+
// any other .css (including .module.css) is component CSS
|
|
25
|
+
if (absPath.endsWith('.css')) return 'component-css'
|
|
26
|
+
if (absPath.endsWith('.html')) return 'html'
|
|
27
|
+
if (TS_RE.test(absPath)) return 'ts'
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Coalesce {
|
|
32
|
+
add(path: string): void
|
|
33
|
+
flush(): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Internal — exposed for unit tests. */
|
|
37
|
+
export function _testCoalesce(debounceMs: number, flush: (paths: string[]) => void): Coalesce {
|
|
38
|
+
let pending = new Set<string>()
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
40
|
+
return {
|
|
41
|
+
add(p) {
|
|
42
|
+
pending.add(p)
|
|
43
|
+
if (timer) clearTimeout(timer)
|
|
44
|
+
timer = setTimeout(() => {
|
|
45
|
+
const out = Array.from(pending)
|
|
46
|
+
pending = new Set()
|
|
47
|
+
timer = null
|
|
48
|
+
flush(out)
|
|
49
|
+
}, debounceMs)
|
|
50
|
+
},
|
|
51
|
+
flush() {
|
|
52
|
+
if (timer) {
|
|
53
|
+
clearTimeout(timer)
|
|
54
|
+
timer = null
|
|
55
|
+
}
|
|
56
|
+
if (pending.size > 0) {
|
|
57
|
+
const out = Array.from(pending)
|
|
58
|
+
pending = new Set()
|
|
59
|
+
flush(out)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CreateWatcherOptions {
|
|
66
|
+
root: string
|
|
67
|
+
debounceMs?: number
|
|
68
|
+
onChange: (ev: { paths: string[]; kind: ChangeKind }) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Watcher {
|
|
72
|
+
close(): void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Watch `root` recursively. Emits one `onChange` call per debounce window
|
|
76
|
+
* with paths classified by the dominant kind. Mixed-kind windows pick
|
|
77
|
+
* by priority: islands > ts > html > css (islands trigger a full restart
|
|
78
|
+
* that subsumes the others). */
|
|
79
|
+
export function createWatcher(opts: CreateWatcherOptions): Watcher {
|
|
80
|
+
const debounceMs = opts.debounceMs ?? 50
|
|
81
|
+
const kindPriority: ChangeKind[] = ['islands', 'ts', 'html', 'css', 'component-css']
|
|
82
|
+
|
|
83
|
+
const coalesce = _testCoalesce(debounceMs, (paths) => {
|
|
84
|
+
const kinds = new Set<ChangeKind>()
|
|
85
|
+
const keep: string[] = []
|
|
86
|
+
for (const p of paths) {
|
|
87
|
+
const k = classifyPath(p, opts.root)
|
|
88
|
+
if (k === null) continue
|
|
89
|
+
kinds.add(k)
|
|
90
|
+
keep.push(p)
|
|
91
|
+
}
|
|
92
|
+
if (keep.length === 0) return
|
|
93
|
+
const dominant = kindPriority.find((k) => kinds.has(k))!
|
|
94
|
+
opts.onChange({ paths: keep, kind: dominant })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const fsWatcher: FSWatcher = watch(opts.root, { recursive: true }, (_event, filename) => {
|
|
98
|
+
if (!filename) return
|
|
99
|
+
const abs = path.resolve(opts.root, filename)
|
|
100
|
+
coalesce.add(abs)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
close() {
|
|
105
|
+
fsWatcher.close()
|
|
106
|
+
coalesce.flush()
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const TERMINATE_TIMEOUT_MS = 2000
|
|
2
|
+
|
|
3
|
+
interface RegistryState {
|
|
4
|
+
workers: Worker[]
|
|
5
|
+
entry: string | null
|
|
6
|
+
count: number
|
|
7
|
+
baseEnv: Record<string, string> | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const state: RegistryState = {
|
|
11
|
+
workers: [],
|
|
12
|
+
entry: null,
|
|
13
|
+
count: 0,
|
|
14
|
+
baseEnv: null,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Called once by brust.serve() in dev mode AFTER it spawns the initial
|
|
18
|
+
* pool. Hands the references to the registry so the coordinator can
|
|
19
|
+
* churn them later. */
|
|
20
|
+
export function registerInitialPool(
|
|
21
|
+
workers: Worker[],
|
|
22
|
+
entry: string,
|
|
23
|
+
count: number,
|
|
24
|
+
baseEnv: Record<string, string>,
|
|
25
|
+
): void {
|
|
26
|
+
state.workers = workers.slice()
|
|
27
|
+
state.entry = entry
|
|
28
|
+
state.count = count
|
|
29
|
+
state.baseEnv = baseEnv
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Terminate every Worker with a 2s per-worker grace. If termination
|
|
33
|
+
* doesn't return in time, abandon the reference and continue. */
|
|
34
|
+
export async function terminateAll(): Promise<void> {
|
|
35
|
+
const olds = state.workers
|
|
36
|
+
state.workers = []
|
|
37
|
+
await Promise.all(
|
|
38
|
+
olds.map(async (w) => {
|
|
39
|
+
try {
|
|
40
|
+
await Promise.race([
|
|
41
|
+
w.terminate(),
|
|
42
|
+
new Promise<void>((resolve) => setTimeout(resolve, TERMINATE_TIMEOUT_MS)),
|
|
43
|
+
])
|
|
44
|
+
} catch {
|
|
45
|
+
// Already-terminated rejections swallowed.
|
|
46
|
+
}
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Spawn `count` fresh Workers using the entry + env captured at
|
|
52
|
+
* registerInitialPool time. Each worker gets BRUST_WORKER_ID=i.
|
|
53
|
+
* Resolves only after every fresh worker reports `brust-worker-ready`
|
|
54
|
+
* via postMessage — so the caller (coordinator) doesn't broadcast
|
|
55
|
+
* `reload` against workers whose message listeners aren't installed
|
|
56
|
+
* yet. Falls back after a 5s grace if a worker never signals. */
|
|
57
|
+
export async function spawnAll(): Promise<void> {
|
|
58
|
+
if (state.entry === null || state.baseEnv === null) {
|
|
59
|
+
throw new Error('worker-registry: spawnAll called before registerInitialPool')
|
|
60
|
+
}
|
|
61
|
+
const fresh: Worker[] = []
|
|
62
|
+
const readies: Promise<void>[] = []
|
|
63
|
+
for (let i = 0; i < state.count; i++) {
|
|
64
|
+
const w = new Worker(state.entry, {
|
|
65
|
+
env: { ...state.baseEnv, BRUST_WORKER_ID: String(i) },
|
|
66
|
+
})
|
|
67
|
+
fresh.push(w)
|
|
68
|
+
readies.push(
|
|
69
|
+
new Promise<void>((resolve) => {
|
|
70
|
+
const onMsg = (e: MessageEvent) => {
|
|
71
|
+
const d: any = (e as any).data
|
|
72
|
+
if (d && d.type === 'brust-worker-ready') {
|
|
73
|
+
;(w as any).removeEventListener?.('message', onMsg)
|
|
74
|
+
resolve()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
;(w as any).addEventListener?.('message', onMsg)
|
|
78
|
+
// Grace: don't hang forever if a worker crashes before reporting.
|
|
79
|
+
setTimeout(resolve, 5000)
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
state.workers = fresh
|
|
84
|
+
await Promise.all(readies)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Test helper. */
|
|
88
|
+
export function _workersForTests(): Worker[] {
|
|
89
|
+
return [...state.workers]
|
|
90
|
+
}
|
|
91
|
+
export function _resetForTests(): void {
|
|
92
|
+
state.workers = []
|
|
93
|
+
state.entry = null
|
|
94
|
+
state.count = 0
|
|
95
|
+
state.baseEnv = null
|
|
96
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Route, WsHandlers, WsSocket } from '../routes.ts'
|
|
2
|
+
|
|
3
|
+
/** Server-to-client protocol. Client never sends after open. */
|
|
4
|
+
export type DevMessage =
|
|
5
|
+
| { type: 'building' }
|
|
6
|
+
| { type: 'reload' }
|
|
7
|
+
| { type: 'css-update'; href: string }
|
|
8
|
+
| { type: 'error'; message: string; stack?: string }
|
|
9
|
+
| { type: 'ok' }
|
|
10
|
+
|
|
11
|
+
// Worker-side state. WS connections to /_brust/dev are dispatched by Rust
|
|
12
|
+
// to a worker isolate, so this Set lives in that worker's module copy.
|
|
13
|
+
// Each worker has its own independent set; only the worker holding a
|
|
14
|
+
// given connection has it in `clients`.
|
|
15
|
+
const clients: Set<WsSocket> = new Set()
|
|
16
|
+
|
|
17
|
+
export function _clientCountForTests(): number {
|
|
18
|
+
return clients.size
|
|
19
|
+
}
|
|
20
|
+
export function _resetForTests(): void {
|
|
21
|
+
clients.clear()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build the synthetic /_brust/dev WS route. brust.run() prepends this
|
|
25
|
+
* to opts.routes in dev mode (both main + worker route arrays). */
|
|
26
|
+
export function createDevWsRoute(): Route {
|
|
27
|
+
return {
|
|
28
|
+
path: '/_brust/dev',
|
|
29
|
+
websocket: () => Promise.resolve(devHandlers),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const devHandlers: WsHandlers = {
|
|
34
|
+
open(socket) {
|
|
35
|
+
clients.add(socket)
|
|
36
|
+
},
|
|
37
|
+
close(socket) {
|
|
38
|
+
clients.delete(socket)
|
|
39
|
+
},
|
|
40
|
+
message() {
|
|
41
|
+
/* ignore — server→client only */
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Worker-side: forward JSON to every client connected to this worker. */
|
|
46
|
+
async function broadcastLocal(json: string): Promise<void> {
|
|
47
|
+
const sends: Promise<unknown>[] = []
|
|
48
|
+
for (const s of clients) {
|
|
49
|
+
sends.push(
|
|
50
|
+
s.send(json).catch(() => {
|
|
51
|
+
clients.delete(s)
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
await Promise.all(sends)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let workerListenerInstalled = false
|
|
59
|
+
|
|
60
|
+
/** Worker-side: install a message listener that receives `dev-broadcast`
|
|
61
|
+
* envelopes from the main process and forwards them to this worker's
|
|
62
|
+
* local clients. Idempotent. After install, posts `brust-worker-ready`
|
|
63
|
+
* back to the parent so spawnAll() knows the worker is ready to relay
|
|
64
|
+
* broadcasts (avoids a race where `reload` is dispatched before the
|
|
65
|
+
* fresh worker's listener is wired). */
|
|
66
|
+
export function installWorkerBroadcastListener(): void {
|
|
67
|
+
if (workerListenerInstalled) return
|
|
68
|
+
workerListenerInstalled = true
|
|
69
|
+
;(globalThis as any).addEventListener('message', (e: MessageEvent) => {
|
|
70
|
+
const data: any = (e as any).data
|
|
71
|
+
if (data && data.type === 'dev-broadcast' && typeof data.json === 'string') {
|
|
72
|
+
void broadcastLocal(data.json)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
// Signal readiness to parent (main thread). In a Worker context,
|
|
76
|
+
// postMessage targets the parent.
|
|
77
|
+
try {
|
|
78
|
+
;(globalThis as any).postMessage({ type: 'brust-worker-ready' })
|
|
79
|
+
} catch {
|
|
80
|
+
/* not in a worker (unit test); harmless */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Main-side: relay the message to every worker via postMessage. Each
|
|
85
|
+
* worker's installed message listener forwards to its local clients.
|
|
86
|
+
* Per-worker postMessage failures are swallowed (worker may be already
|
|
87
|
+
* terminating). */
|
|
88
|
+
export async function broadcast(msg: DevMessage): Promise<void> {
|
|
89
|
+
const json = JSON.stringify(msg)
|
|
90
|
+
const { _workersForTests } = await import('./worker-registry.ts')
|
|
91
|
+
const workers = _workersForTests()
|
|
92
|
+
for (const w of workers) {
|
|
93
|
+
try {
|
|
94
|
+
w.postMessage({ type: 'dev-broadcast', json })
|
|
95
|
+
} catch {
|
|
96
|
+
/* worker already terminated; drop */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/* auto-generated by NAPI-RS */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
export declare function beginServe(opts: ServeOptions): NapiResult<undefined>
|
|
4
|
+
|
|
5
|
+
export declare function configureCache(maxEntries: number): NapiResult<undefined>
|
|
6
|
+
|
|
7
|
+
export declare function configureCssDir(path: string): NapiResult<undefined>
|
|
8
|
+
|
|
9
|
+
export declare function configureIslandsDir(path: string): NapiResult<undefined>
|
|
10
|
+
|
|
11
|
+
export declare function isWorker(): boolean
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sub-project J — boot-time listing of registered minijinja templates.
|
|
15
|
+
* JS dispatcher uses this to validate every `native: true` route's
|
|
16
|
+
* `Component.name` is present (warns on mismatch per Reviewer Fix 1).
|
|
17
|
+
*/
|
|
18
|
+
export declare function napiListNativeTemplates(): Array<string>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sub-project J — boot-time loader for `.brust/jinja/*.jinja` templates.
|
|
22
|
+
* Returns the list of template names registered (each `<Name>.jinja` file
|
|
23
|
+
* stem becomes the lookup key). Lenient on missing/non-directory `dir`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function napiLoadJinjaTemplates(dir: string): Array<string>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register literal SSE paths. Rust's accept loop will match incoming GET
|
|
29
|
+
* requests against this set; matched requests enter the SSE dispatch branch
|
|
30
|
+
* instead of the normal render path. Call once at boot before begin_serve.
|
|
31
|
+
* Only exact-match (literal) paths are supported — parameterized routes
|
|
32
|
+
* (e.g. `/sse/{room}`) require a matchit-rs follow-up.
|
|
33
|
+
*/
|
|
34
|
+
export declare function napiRegisterSsePaths(paths: Array<string>): NapiResult<undefined>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Boot-time registry of literal WS paths. Mirror of napi_register_sse_paths.
|
|
38
|
+
* Call once before begin_serve; exact-match only (parameterized routes are a
|
|
39
|
+
* follow-up).
|
|
40
|
+
*/
|
|
41
|
+
export declare function napiRegisterWsPaths(paths: Array<string>): NapiResult<undefined>
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Worker-driven render chunk delivery. Worker calls this once per chunk
|
|
45
|
+
* it wants to emit; final call uses `len = 0` to close the channel.
|
|
46
|
+
*
|
|
47
|
+
* Contract (spec §5.2):
|
|
48
|
+
* - `len > 0`: read SAB[0..len], send Bytes through render_slot.chunk_tx,
|
|
49
|
+
* await ack. Resolves after Rust writes the chunk to the socket.
|
|
50
|
+
* - `len == 0`: send Final, await ack. Closes the response.
|
|
51
|
+
* - Bounds violation (len > buf_len) → NAPI Err.
|
|
52
|
+
* - Slot empty (no in-flight render for this worker) → NAPI Err.
|
|
53
|
+
* - Ack receiver dropped (handle_conn torn down mid-stream) → NAPI Err
|
|
54
|
+
* (NOT hang — worker's sink propagates via cb(err) to renderer Promise).
|
|
55
|
+
*/
|
|
56
|
+
export declare function napiRenderChunk(workerId: number, len: number): Promise<NapiResult<undefined>>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Buffering-path finalizer: equivalent to `napi_render_chunk(_, len)` followed
|
|
60
|
+
* by `napi_render_chunk(_, 0)` but in a single tsfn crossing. Cuts JS-side
|
|
61
|
+
* per-request overhead by one full Promise+await cycle.
|
|
62
|
+
*
|
|
63
|
+
* Streaming-path callers MUST NOT use this — they send N body chunks then a
|
|
64
|
+
* separate `Final`. Calling this with `streaming=true` meta is logged at WARN
|
|
65
|
+
* on the Rust side and falls back to emitting chunked headers + framed body
|
|
66
|
+
* + chunked terminator (byte-equivalent to Bytes-then-Final in chunked mode).
|
|
67
|
+
*
|
|
68
|
+
* Same error semantics as `napi_render_chunk`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function napiRenderChunkFinal(workerId: number, len: number): Promise<NapiResult<undefined>>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sub-project J — render via minijinja using SAB-side-channeled loader data.
|
|
74
|
+
*
|
|
75
|
+
* SAB convention (spec §6 last paragraph): per-napi-call. `napi_render_jinja`
|
|
76
|
+
* treats SAB[0..data_len] as INBOUND raw JSON written by the JS worker. It
|
|
77
|
+
* renders the template Rust-side, assembles a `[meta_len: u16 BE][meta JSON]
|
|
78
|
+
* [body]` payload, writes it back into the SAB, and returns its byte length —
|
|
79
|
+
* the FAST LANE. The JS native branch returns this length up to the tsfn, and
|
|
80
|
+
* the dispatch loop's fast-lane arm reads the framed bytes directly from the
|
|
81
|
+
* SAB and ships them via `build_single_response_bytes`. No chunk channel, no
|
|
82
|
+
* per-chunk ack round-trip.
|
|
83
|
+
*
|
|
84
|
+
* The render reads the inbound JSON into an owned `Vec` first, so overwriting
|
|
85
|
+
* the SAB with the assembled response afterward is safe (no aliasing).
|
|
86
|
+
*
|
|
87
|
+
* SYNCHRONOUS napi fn (not `async`): the body is pure CPU work (jinja render +
|
|
88
|
+
* SAB memcpy) with no `.await`, so it's a direct FFI call from the worker
|
|
89
|
+
* thread — no Promise, no microtask round-trip. This is what lets native
|
|
90
|
+
* routes reach the same crossing floor as actions: the only Rust↔JS hops left
|
|
91
|
+
* are the tsfn call and the render-Promise resolution.
|
|
92
|
+
*
|
|
93
|
+
* Errors:
|
|
94
|
+
* - worker id not registered → NAPI Err
|
|
95
|
+
* - `data_len > buf_len` → NAPI Err (caller should have written ≤ buf_len)
|
|
96
|
+
* - assembled response > buf_len → writes a small framed 500 that fits and
|
|
97
|
+
* returns ITS length, so the client gets a response instead of hanging.
|
|
98
|
+
* - `jinja::render` failure → writes a framed 500 into the SAB and returns its
|
|
99
|
+
* length (the protocol error is converted to an HTTP 500 on the wire).
|
|
100
|
+
*/
|
|
101
|
+
export declare function napiRenderJinja(workerId: number, dataLen: number, templateName: string): NapiResult<number>
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Drop the connection's sender, which signals the per-conn task to exit
|
|
105
|
+
* and close the TCP socket. Idempotent — a missing conn is a no-op.
|
|
106
|
+
*/
|
|
107
|
+
export declare function napiSseClose(connId: bigint): NapiResult<undefined>
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* JS provides a callback that fires once when Rust detects client disconnect.
|
|
111
|
+
* Stored as a thread-safe wrapper on the SseConn.
|
|
112
|
+
*/
|
|
113
|
+
export declare function napiSseRegisterAbort(connId: bigint, cb: () => void): NapiResult<undefined>
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* JS reports the middleware open verdict. Single-shot — a second call is
|
|
117
|
+
* dropped (open_tx is taken on first use).
|
|
118
|
+
*/
|
|
119
|
+
export declare function napiSseSignalOpen(connId: bigint, status: number, body: Buffer, contentType: string): NapiResult<undefined>
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enqueue one SSE frame for the given connection. Returns a Promise that
|
|
123
|
+
* resolves when the Rust-side per-conn task has finished the TCP write —
|
|
124
|
+
* cooperative backpressure for the JS reader loop.
|
|
125
|
+
*/
|
|
126
|
+
export declare function napiSseWrite(connId: bigint, bytes: Buffer): Promise<NapiResult<undefined>>
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Initiate close. code defaults applied client-side (default 1000);
|
|
130
|
+
* reason capped at 123 bytes (RFC 6455) at the JS layer.
|
|
131
|
+
* Idempotent — missing conn is a silent no-op.
|
|
132
|
+
*/
|
|
133
|
+
export declare function napiWsClose(connId: bigint, code: number, reason: string): Promise<NapiResult<undefined>>
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* JS registers per-conn callbacks. Each Function arg is converted to a tsfn
|
|
137
|
+
* then wrapped in a Box<dyn Fn> closure, matching the field shape Task 3
|
|
138
|
+
* chose to keep cargo test --lib happy without napi linker symbols.
|
|
139
|
+
*
|
|
140
|
+
* Uses struct-payload form (WsMessageArg / WsCloseArg) because napi-rs
|
|
141
|
+
* does not accept tuple type parameters for Function<(A, B), R>.
|
|
142
|
+
* Task 5 + Task 9 must use matching TS object shapes.
|
|
143
|
+
*
|
|
144
|
+
* Single-shot registration; a second call replaces both handlers.
|
|
145
|
+
*/
|
|
146
|
+
export declare function napiWsRegisterHandlers(connId: bigint, onMessage: (arg: WsMessageArg) => void, onClose: (arg: WsCloseArg) => void): NapiResult<undefined>
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Send one frame. is_binary=false → Text frame (UTF-8 validated before
|
|
150
|
+
* enqueue), true → Binary frame. Returns Promise<()> resolving after the
|
|
151
|
+
* TCP write completes — cooperative backpressure, mirrors napi_sse_write.
|
|
152
|
+
*/
|
|
153
|
+
export declare function napiWsSend(connId: bigint, data: Buffer, isBinary: boolean): Promise<NapiResult<undefined>>
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* JS reports the middleware verdict + chosen subprotocol. Single-shot;
|
|
157
|
+
* second call is a no-op (the Option is taken).
|
|
158
|
+
*/
|
|
159
|
+
export declare function napiWsSignalOpen(connId: bigint, status: number, body: Buffer, contentType: string, subprotocol: string): NapiResult<undefined>
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Register the set of action ids that Rust will accept on
|
|
163
|
+
* /_brust/action/<id>. Called once at boot from the main thread.
|
|
164
|
+
* Validates charset and rejects duplicates. Replaces any previous set
|
|
165
|
+
* (no incremental registration in MVP — register once at boot).
|
|
166
|
+
*/
|
|
167
|
+
export declare function registerActions(ids: Array<string>): NapiResult<number>
|
|
168
|
+
|
|
169
|
+
export declare function registerRenderer(buf: Uint8Array, f: (arg: number | string) => Promise<number>): NapiResult<number>
|
|
170
|
+
|
|
171
|
+
export declare function registerRoutes(configs: Array<string>): NapiResult<number>
|
|
172
|
+
|
|
173
|
+
export interface ServeOptions {
|
|
174
|
+
port: number
|
|
175
|
+
workers: number
|
|
176
|
+
entry: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export declare function untilReady(timeoutMs: number): Promise<NapiResult<undefined>>
|
|
180
|
+
|
|
181
|
+
export declare function untilShutdown(): Promise<NapiResult<undefined>>
|
|
182
|
+
|
|
183
|
+
export declare function workerId(): number | null
|
|
184
|
+
|
|
185
|
+
/** Argument struct for the JS on_close callback. */
|
|
186
|
+
export interface WsCloseArg {
|
|
187
|
+
code: number
|
|
188
|
+
reason: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Argument struct for the JS on_message callback. napi-rs does not accept
|
|
193
|
+
* tuple type parameters for Function<(A, B), R> so we use an object payload
|
|
194
|
+
* instead. Task 5 + Task 9 must construct a matching TS type.
|
|
195
|
+
*/
|
|
196
|
+
export interface WsMessageArg {
|
|
197
|
+
data: Buffer
|
|
198
|
+
isBinary: boolean
|
|
199
|
+
}
|