@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.
Files changed (64) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +82 -4
  3. package/native/index.js +58 -54
  4. package/package.json +29 -15
  5. package/src/actions/client.ts +53 -7
  6. package/src/actions/elysia.ts +1 -1
  7. package/src/actions/load.ts +82 -1
  8. package/src/actions/prefix.ts +44 -2
  9. package/src/actions-bridge.ts +44 -9
  10. package/src/assets.d.ts +55 -0
  11. package/src/browser.ts +6 -1
  12. package/src/build/actions-only.ts +86 -0
  13. package/src/build/build-eval.ts +332 -0
  14. package/src/build/build-icons.ts +69 -0
  15. package/src/build/bundle.ts +466 -54
  16. package/src/build/cf-assets.ts +4 -2
  17. package/src/build/cf-entry.ts +9 -4
  18. package/src/build/cf-wrangler.ts +3 -1
  19. package/src/build/cloudflare.ts +34 -13
  20. package/src/build/compile.ts +263 -6
  21. package/src/build/config.ts +138 -11
  22. package/src/build/css.ts +353 -0
  23. package/src/build/esm-resolve.ts +170 -0
  24. package/src/build/features.ts +119 -0
  25. package/src/build/index.ts +210 -27
  26. package/src/build/manifest.ts +94 -7
  27. package/src/build/pkg-src.ts +514 -0
  28. package/src/build/recognised.ts +19 -0
  29. package/src/build/scan.ts +71 -20
  30. package/src/build/server-only.ts +62 -0
  31. package/src/build/ssg-render.ts +65 -0
  32. package/src/build/ssg.ts +146 -0
  33. package/src/build/static-worker.ts +91 -0
  34. package/src/build/static.ts +259 -0
  35. package/src/build/tailwind.ts +117 -0
  36. package/src/build/tsconfig-paths.ts +67 -0
  37. package/src/build-time.ts +10 -0
  38. package/src/cache-adapter/types.ts +1 -1
  39. package/src/cf.ts +4 -1
  40. package/src/cli.ts +40 -4
  41. package/src/config.ts +111 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +378 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +358 -0
  47. package/src/dev/diag.ts +30 -0
  48. package/src/dev/proxy.ts +256 -0
  49. package/src/dev/tui.ts +144 -0
  50. package/src/dev/watch.ts +130 -0
  51. package/src/dev.ts +15 -28
  52. package/src/handlers.ts +116 -10
  53. package/src/index.ts +6 -1
  54. package/src/native.ts +32 -1
  55. package/src/navigate-browser.ts +19 -0
  56. package/src/navigate.ts +19 -0
  57. package/src/navigation.ts +91 -0
  58. package/src/routes.ts +31 -1
  59. package/src/run.ts +65 -13
  60. package/src/shadcn/init.ts +513 -0
  61. package/src/shadcn/jsonc.ts +129 -0
  62. package/src/shadcn/registry.ts +149 -0
  63. package/src/store/builtin.ts +4 -0
  64. package/src/worker.ts +105 -22
@@ -0,0 +1,19 @@
1
+ // `navigate()` (SPA navigation, design §5.3): the types shared by the browser and server entries, and the server stub.
2
+ export interface NavigateOptions {
3
+ /** Replace the current history entry instead of pushing a new one. */
4
+ replace?: boolean
5
+ }
6
+ export type NavTrigger = 'link' | 'navigate' | 'popstate'
7
+ /** `detail` of `brust:nav:load`. */
8
+ export interface NavLoadDetail { id: number; url: string; from: string; trigger: NavTrigger }
9
+ /** `detail` of `brust:nav:success`. */
10
+ export interface NavSuccessDetail extends NavLoadDetail { status: number }
11
+ /** `detail` of `brust:nav:error`: `swapped` tells whether a page (a 404, an error page) was swapped in anyway. */
12
+ export interface NavErrorDetail extends NavLoadDetail { status?: number; error?: unknown; swapped: boolean }
13
+
14
+ export function navigate(_url: string, _opts?: NavigateOptions): Promise<void> {
15
+ throw new Error('navigate is browser-only (call it from a component or island running in the browser)')
16
+ }
17
+
18
+ /** The page cache lives in the browser: nothing to clear on the server. */
19
+ export function clearNavigationCache(): void {}
@@ -0,0 +1,91 @@
1
+ // The built-in `navigation` store (SPA navigation design §11): the `brust:nav:*` events as
2
+ // signals, so a component reads navigation state through `useStore(navigation)` like any other store. Browser-safe and
3
+ // React-free, like `@brust/core/store`. The name is reserved (the compiler refuses a user `defineStore('navigation')`).
4
+ //
5
+ // Server: the factory paints `idle` and the loader seeds `url` with the request's path + search (handlers.ts), so the first
6
+ // paint is right and hydration matches. Browser: this module listens to the four events once and `batch`-sets the signals.
7
+ import { batch, defineStore, signal } from '@brust/runtime-dom/store'
8
+
9
+ export type NavigationTrigger = 'link' | 'navigate' | 'popstate'
10
+
11
+ const at = (): string => (typeof location === 'undefined' ? '/' : location.pathname + location.search)
12
+
13
+ /** `new URL(href).pathname + search`, the way the store spells a page; `href` itself when it does not parse. */
14
+ const path = (href: unknown): string => {
15
+ try {
16
+ const u = new URL(String(href), typeof location === 'undefined' ? 'http://x' : location.href)
17
+ return u.pathname + u.search
18
+ } catch {
19
+ return String(href)
20
+ }
21
+ }
22
+
23
+ declare const __BRUST_DEV__: boolean | undefined
24
+
25
+ // Instances this module's factory made: a record under the name that is not one belongs to someone else's defineStore.
26
+ const made = new WeakSet<object>()
27
+
28
+ export const navigation = defineStore('navigation', () => {
29
+ const store = {
30
+ /** `'loading'` from `brust:nav:load` until `success` / `error`. */
31
+ state: signal<'idle' | 'loading'>('idle'),
32
+ /** Path + search of the page on screen: updated at `success`, and at `error` when the page was swapped in. */
33
+ url: signal<string>(at()),
34
+ /** Path + search being loaded, else `null`. */
35
+ to: signal<string | null>(null),
36
+ /** Of the current or last navigation. */
37
+ trigger: signal<NavigationTrigger | null>(null),
38
+ /** HTTP status of the last navigation. */
39
+ status: signal<number | null>(null),
40
+ /** Message of the last failed navigation; cleared at the next `load`. */
41
+ error: signal<string | null>(null),
42
+ /** The last navigation id (0 before any). */
43
+ id: signal<number>(0),
44
+ }
45
+ made.add(store)
46
+ return store
47
+ })
48
+
49
+ // One subscription per tab, whichever chunk evaluates this module first (the store registry is shared the same way).
50
+ const BOUND = Symbol.for('brust.navigation')
51
+ if (typeof document !== 'undefined' && !(globalThis as { [BOUND]?: true })[BOUND]) {
52
+ ;(globalThis as { [BOUND]?: true })[BOUND] = true
53
+ // A user `defineStore('navigation', …)` outside a component is not seen by the compiler (`store-name-reserved`); it
54
+ // would share this record by name, so say so in dev.
55
+ if (typeof __BRUST_DEV__ !== 'undefined' && __BRUST_DEV__ && !made.has(navigation()))
56
+ console.warn("[brust] the store name 'navigation' is reserved for @brust/core/navigation, but another defineStore('navigation') made the record in use; rename yours")
57
+ const on = (name: string, apply: (d: Record<string, unknown>) => void) =>
58
+ document.addEventListener(`brust:nav:${name}`, (e) => apply((e as CustomEvent).detail ?? {}))
59
+ on('load', (d) =>
60
+ batch(() => {
61
+ navigation.state.set('loading')
62
+ navigation.to.set(path(d.url))
63
+ navigation.trigger.set(d.trigger as NavigationTrigger)
64
+ navigation.error.set(null)
65
+ navigation.id.set(d.id as number)
66
+ }),
67
+ )
68
+ on('success', (d) =>
69
+ batch(() => {
70
+ navigation.state.set('idle')
71
+ navigation.url.set(path(d.url))
72
+ navigation.to.set(null)
73
+ navigation.trigger.set(d.trigger as NavigationTrigger)
74
+ navigation.status.set(d.status as number)
75
+ navigation.error.set(null)
76
+ navigation.id.set(d.id as number)
77
+ }),
78
+ )
79
+ on('error', (d) =>
80
+ batch(() => {
81
+ navigation.state.set('idle')
82
+ // A swapped error page (404, 500) is on screen; a failed swap leaves the old page where it was.
83
+ if (d.swapped) navigation.url.set(path(d.url))
84
+ navigation.to.set(null)
85
+ navigation.trigger.set(d.trigger as NavigationTrigger)
86
+ navigation.status.set((d.status as number | undefined) || null) // 0: no response
87
+ navigation.error.set(d.error instanceof Error ? d.error.message : d.error != null ? String(d.error) : `HTTP ${d.status}`)
88
+ navigation.id.set(d.id as number)
89
+ }),
90
+ )
91
+ }
package/src/routes.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /// <reference path="./css-modules.d.ts" />
2
+ /// <reference path="./assets.d.ts" />
1
3
  // Route tree, ids and loader verdicts (spec S4, S7 step 4). M2 routes carry ONLY
2
4
  // `path/Component/loader/cache/children`; every other 0.1.x field fails loudly (M3).
3
5
 
@@ -23,21 +25,37 @@ export interface LoaderReq {
23
25
  search: Record<string, string>
24
26
  }
25
27
 
28
+ /** The response a loader shapes (F464). One per request, shared by every loader in the chain in chain order:
29
+ * `set` replaces, `append` adds (repeated `Set-Cookie` survive). The headers go on whatever the chain answers:
30
+ * the page, `notFound()`, `redirect()` and an `httpError()`. `Content-Type`, `Content-Length`,
31
+ * `Transfer-Encoding`, `Connection` and `Location` are the server's (a redirect's comes from `redirect()`):
32
+ * setting one throws in dev and is dropped with a warning in production. */
33
+ export interface LoaderRes {
34
+ headers: Headers
35
+ }
36
+
26
37
  export interface LoaderCtx<P = Record<string, string>> {
27
38
  params: P
28
39
  path: string
29
40
  req: LoaderReq
41
+ res: LoaderRes
30
42
  }
31
43
 
44
+ /** `brust build --target static` (gaps round R9.2): `params` lists the values of a dynamic route's
45
+ * `{name}` segments, one record per page; `false` leaves the route out of the static output. The
46
+ * server and Cloudflare targets ignore it. */
47
+ export type RouteSsgConfig<P = Record<string, string>> = { params?: () => P[] | Promise<P[]> } | false
48
+
32
49
  export interface Route<P = Record<string, string>, D = unknown> {
33
50
  path?: string
34
51
  Component?: ComponentType<any>
35
52
  loader?: (ctx: LoaderCtx<P>) => Promise<D> | D
36
53
  cache?: RouteCacheConfig
54
+ ssg?: RouteSsgConfig<P>
37
55
  children?: Route<any, any>[]
38
56
  }
39
57
 
40
- export const ALLOWED_ROUTE_FIELDS = ['path', 'Component', 'loader', 'cache', 'children'] as const
58
+ export const ALLOWED_ROUTE_FIELDS = ['path', 'Component', 'loader', 'cache', 'ssg', 'children'] as const
41
59
  const ALLOWED_CACHE_FIELDS = ['ttl_seconds', 'prefix', 'bypass', 'tags'] as const
42
60
  const M3_CACHE_FIELDS = ['key', 'key_ttl_seconds'] as const
43
61
 
@@ -61,6 +79,18 @@ function validate(node: Route, where: string): void {
61
79
  if (node.path !== undefined && typeof node.path !== 'string') throw new BrustRouteError(`route ${where}: path must be a string`)
62
80
  if (node.loader !== undefined && typeof node.loader !== 'function')
63
81
  throw new BrustRouteError(`route ${where}: loader must be a function`)
82
+ if (node.ssg !== undefined) {
83
+ const ssg = node.ssg as unknown
84
+ if (ssg !== false && (typeof ssg !== 'object' || ssg === null || Array.isArray(ssg)))
85
+ throw new BrustRouteError(`route ${where}: ssg must be false or { params }`)
86
+ if (ssg !== false) {
87
+ for (const k of Object.keys(ssg as object))
88
+ if (k !== 'params') throw new BrustRouteError(`route ${where}: unknown ssg field ssg.${k} (allowed: params)`)
89
+ const params = (ssg as { params?: unknown }).params
90
+ if (params !== undefined && typeof params !== 'function') throw new BrustRouteError(`route ${where}: ssg.params must be a function`)
91
+ }
92
+ if (node.children !== undefined) throw new BrustRouteError(`route ${where}: ssg on a layout route is not supported (put it on each leaf)`)
93
+ }
64
94
  if (node.cache !== undefined) {
65
95
  if (typeof node.cache !== 'object' || node.cache === null) throw new BrustRouteError(`route ${where}: cache must be an object`)
66
96
  for (const k of Object.keys(node.cache)) {
package/src/run.ts CHANGED
@@ -4,45 +4,82 @@
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 { SLOT_BYTES } from './worker'
13
+ import { type BrustConfig, type ConfigOverrides, loadConfig } from './config'
14
+ import { beginDrain, localAddr, type StartOptions, type StreamOptions, 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?: Partial<BrustConfig>
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; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null
30
+ actions?: { entry: string; prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } & StreamKeys | 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
26
35
  /** `[cache]`: the server's L2 settings (the workers load the adapter itself). */
27
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
39
+ }
40
+
41
+ /** F475: the `[actions] stream` keys of `manifest.actions`, passed to napi as they are. */
42
+ type StreamKeys = Pick<StreamOptions, 'stream' | 'maxStreamBytes' | 'streamIdleMs' | 'streamTotalMs' | 'maxStreams'>
43
+
44
+ /** F475: the stream keys `a` carries; none without a `stream` list, so an app without one starts as before. */
45
+ function streamOptions(a: StreamKeys): StreamKeys {
46
+ if (!a.stream?.length) return {}
47
+ const { stream, maxStreamBytes, streamIdleMs, streamTotalMs, maxStreams } = a
48
+ return { stream, maxStreamBytes, streamIdleMs, streamTotalMs, ...(maxStreams !== undefined ? { maxStreams } : {}) }
49
+ }
50
+
51
+ /** F165 (R40.1): the pool size `run` spawns: none only when the build said no request needs one. */
52
+ export function workerCount(cfg: BrustConfig, m: RunManifest): number {
53
+ return m.needs_workers === false ? 0 : cfg.workers
28
54
  }
29
55
 
30
56
  /** Pure: the napi `StartOptions`. No actions/cors/cache → exactly the pre-actions keys. */
31
- export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string): StartOptions {
57
+ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string, devToken?: string): StartOptions {
32
58
  return {
33
59
  host: cfg.host,
34
60
  port: cfg.port,
35
61
  distDir,
36
- workers: cfg.workers,
62
+ workers: workerCount(cfg, m),
37
63
  callTimeoutMs: cfg.callTimeoutMs,
64
+ ...(cfg.maxLoaderResponseBytes !== undefined && cfg.maxLoaderResponseBytes !== DEFAULT_MAX_LOADER_RESPONSE_BYTES ? { maxLoaderResponseBytes: cfg.maxLoaderResponseBytes } : {}),
38
65
  workerThreads: cfg.ioThreads,
39
66
  generator: `brust/${version}`,
40
- ...(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 } : {}) } } : {}),
67
+ ...(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 } : {}), ...streamOptions(m.actions) } } : {}),
68
+ ...(actionsClient(cfg, m) ? { actionsClient: actionsClient(cfg, m) } : {}),
41
69
  ...(cfg.cors ? { cors: cfg.cors } : {}),
70
+ ...(devToken ? { devToken } : {}),
42
71
  ...(m.cache ? { l2: { adapter: m.cache.adapter, getTimeoutMs: m.cache.getTimeoutMs, maxBodyBytes: m.cache.maxBodyBytes, namespace: m.cache.namespace, buildId: m.cache.buildId } } : {}),
43
72
  }
44
73
  }
45
74
 
75
+ /** L16: what the page tells the browser `client()` (R16.1/R16.2); absent while it would only repeat the prefix script. */
76
+ function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptions['actionsClient']> | undefined {
77
+ const credentials = cfg.actionsCredentials !== undefined && cfg.actionsCredentials !== 'same-origin' ? cfg.actionsCredentials : undefined
78
+ if (cfg.actionsBaseUrl === undefined && credentials === undefined && !m.actions_remote) return undefined
79
+ const prefix = m.actions?.prefix ?? m.actions_remote?.prefix
80
+ return { ...(prefix ? { prefix } : {}), ...(cfg.actionsBaseUrl !== undefined ? { baseUrl: cfg.actionsBaseUrl } : {}), ...(credentials ? { credentials } : {}) }
81
+ }
82
+
46
83
  /** Every worker's response buffer, kept reachable until the process exits (see run()). */
47
84
  const workerBuffers: SharedArrayBuffer[] = []
48
85
 
@@ -63,7 +100,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
63
100
  fail(`bad manifest ${manifest}: ${(e as Error).message}`)
64
101
  }
65
102
  const entry = resolve(process.env.BRUST_APP_ENTRY || opts.entry || 'routes.tsx')
66
- if (!existsSync(entry)) fail(`routes entry ${entry} not found`)
103
+ // R16.3: an actions-only dist has no pages, so no routes module either.
104
+ if (!m.actions_only && !existsSync(entry)) fail(`routes entry ${entry} not found`)
67
105
 
68
106
  let cfg: BrustConfig
69
107
  try {
@@ -72,9 +110,11 @@ export async function run(opts: RunOptions = {}): Promise<void> {
72
110
  fail((e as Error).message)
73
111
  }
74
112
 
113
+ if (m.actions_remote && cfg.actionsBaseUrl === undefined)
114
+ fail('this build has no local actions app: set [actions] base_url (or BRUST_ACTIONS_BASE_URL) to the deployment that serves them')
75
115
  const version: string = (await Bun.file(join(import.meta.dir, '../package.json')).json()).version
76
116
  try {
77
- startServer(startOptions(cfg, distDir, m, version))
117
+ startServer(startOptions(cfg, distDir, m, version, process.env.BRUST_DEV_TOKEN || undefined))
78
118
  } catch (e) {
79
119
  fail((e as Error).message)
80
120
  }
@@ -87,6 +127,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
87
127
  ...process.env,
88
128
  NODE_ENV: process.env.NODE_ENV,
89
129
  BRUST_RENDER_SLOTS: String(cfg.renderSlots),
130
+ BRUST_SLOT_BYTES: String(cfg.slotBytes),
131
+ BRUST_MAX_LOADER_RESPONSE_BYTES: String(cfg.maxLoaderResponseBytes),
90
132
  BRUST_DIST_DIR: distDir,
91
133
  BRUST_APP_ENTRY: entry,
92
134
  // One cache-adapter origin per process: its workers' published events are not replayed here.
@@ -111,11 +153,12 @@ export async function run(opts: RunOptions = {}): Promise<void> {
111
153
  .finally(() => exit(failed ? 1 : code))
112
154
  }
113
155
 
156
+ const workers = workerCount(cfg, m)
114
157
  const url = new URL('./worker.ts', import.meta.url)
115
- for (let i = 0; i < cfg.workers; i++) {
158
+ for (let i = 0; i < workers; i++) {
116
159
  // The response buffer is owned HERE, for the process lifetime: the Rust dispatcher keeps a raw
117
160
  // pointer into it, so it must not be freed with the worker (crates/brust-napi dispatch.rs).
118
- const sab = new SharedArrayBuffer(SLOT_BYTES * cfg.renderSlots)
161
+ const sab = new SharedArrayBuffer(cfg.slotBytes * cfg.renderSlots)
119
162
  workerBuffers.push(sab)
120
163
  const w = new Worker(url, { env: { ...env, BRUST_WORKER_ID: String(i) }, workerData: { sab } } as WorkerOptions)
121
164
  // No respawn in M2: a worker that fails to boot or dies later takes the process down loudly
@@ -157,6 +200,15 @@ export async function run(opts: RunOptions = {}): Promise<void> {
157
200
  fail(`workers not ready: ${String((e as Error).message ?? e)} (BRUST_BOOT_TIMEOUT_MS=${cfg.bootTimeoutMs})`)
158
201
  }
159
202
  ready = true
160
- console.log(`[brust] ready (${cfg.workers} worker${cfg.workers === 1 ? '' : 's'})`)
203
+ console.log(`[brust] ready (${workers} worker${workers === 1 ? '' : 's'})`)
204
+ if (opts.onReady) {
205
+ let code = 1
206
+ try {
207
+ code = await opts.onReady(localAddr())
208
+ } catch (e) {
209
+ console.error(`[brust] ${(e as Error).stack ?? String(e)}`)
210
+ }
211
+ if (!draining) drainThenExit(code)
212
+ }
161
213
  await new Promise<never>(() => {})
162
214
  }