@defrex/autobuild 0.8.0 → 0.10.0

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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -1,923 +0,0 @@
1
- /**
2
- * The remote BuildStore's HTTP face (SPEC §7.2 adapter 2): a small
3
- * self-hosted API over any backing BuildStore. Routes are REST and boring;
4
- * the properties that matter ride on top:
5
- *
6
- * - D6: an `EventValidationError` from the backing store crosses the wire
7
- * as 422 `{kind: 'validation'}` with the message intact — validation
8
- * failures stay agent feedback, never opaque 500s.
9
- * - D8: when `secret` is set, every /builds route requires a Bearer token
10
- * whose scope covers the addressed build (`'*'` = admin). Missing or
11
- * expired token → 401; a token for another build → 403. No secret → open
12
- * (local dev). `/health` is always open. The scope's session dimension
13
- * (§8.1: "scoped to this build *and* session") gates event writes: a
14
- * session-scoped token may only append events attributed to that agent
15
- * session.
16
- *
17
- * Atomic deposits (POST …/deposits) implement the wire convention in
18
- * protocol.ts: the request payload embeds negative placeholder refs, and
19
- * this server substitutes the real revisions *inside* the backing store's
20
- * `appendWithArtifacts` callback — atomicity is the backing store's, not
21
- * re-implemented here.
22
- */
23
- import type { ZodType } from 'zod'
24
- import { EventValidationError, type AbEvent, type EventWrite } from '../../events/catalog'
25
- import type { RepositoryEvent, RepositoryEventWrite } from '../../events/repository'
26
- import type { SessionEvent, SessionEventWrite } from '../../events/sessions'
27
- import type { Via } from '../../events/envelope'
28
- import { systemClock, type BuildStore, type Clock } from '../types'
29
- import type { StreamOutcome, StreamPart, StreamRead, StreamScope } from '../streams/types'
30
- import {
31
- MAX_STREAM_WAIT_SECONDS,
32
- StreamBatchTooLargeError,
33
- StreamClosedError,
34
- } from '../streams/types'
35
- import {
36
- appendStreamBodySchema,
37
- closeStreamBodySchema,
38
- conditionalEventBodySchema,
39
- createStreamBodySchema,
40
- decodeBase64,
41
- depositsBodySchema,
42
- encodeBase64,
43
- ensureRepoBodySchema,
44
- eventWriteWireSchema,
45
- leaseClaimBodySchema,
46
- leaseHolderBodySchema,
47
- newBuildBodySchema,
48
- newSessionBodySchema,
49
- putArtifactBodySchema,
50
- substitutePlaceholderRefs,
51
- type ErrorBody,
52
- type ErrorKind,
53
- } from './protocol'
54
- import { tokenResource, verifyToken, type TokenScope } from './token'
55
- import {
56
- AUTOBUILD_VERSION,
57
- AUTOBUILD_VERSION_HEADER,
58
- REMOTE_STORE_PROTOCOL_VERSION,
59
- REMOTE_STORE_PROTOCOL_VERSION_HEADER,
60
- } from './version'
61
-
62
- export interface StoreServerOptions {
63
- store: BuildStore
64
- /** When set, all /builds routes require a scoped Bearer token (D8). */
65
- secret?: string
66
- /** Time source for token expiry checks; defaults to the system clock. */
67
- clock?: Clock
68
- /** Maximum decoded size of each artifact. Unlimited when omitted. */
69
- maxArtifactBytes?: number
70
- /** Ceiling for a held event read (`wait` on the two event routes), in
71
- * whole seconds. Larger requested values clamp to this, never reject;
72
- * the default is the stream-wait ceiling (30). Hosted deployments set a
73
- * smaller ceiling to fit the routes' function-duration limit. */
74
- maxEventWaitSeconds?: number
75
- /** Observes unexpected backing-store failures without changing their wire response. */
76
- onInternalError?: (error: unknown, request: Request) => unknown | Promise<unknown>
77
- }
78
-
79
- export interface StoreServer {
80
- fetch: (req: Request) => Promise<Response>
81
- }
82
-
83
- /** Thrown by route helpers; the top-level handler maps it to a response. */
84
- class RequestError extends Error {
85
- constructor(
86
- readonly status: number,
87
- readonly kind: ErrorKind,
88
- message: string,
89
- ) {
90
- super(message)
91
- this.name = 'RequestError'
92
- }
93
- }
94
-
95
- function json(status: number, body: unknown): Response {
96
- return new Response(JSON.stringify(body), {
97
- status,
98
- headers: { 'content-type': 'application/json' },
99
- })
100
- }
101
-
102
- function fail(status: number, kind: ErrorKind, error: string): Response {
103
- return json(status, { error, kind } satisfies ErrorBody)
104
- }
105
-
106
- async function readBody<T>(req: Request, schema: ZodType<T>): Promise<T> {
107
- let raw: unknown
108
- try {
109
- raw = await req.json()
110
- } catch {
111
- throw new RequestError(400, 'validation', 'request body is not valid JSON')
112
- }
113
- const parsed = schema.safeParse(raw)
114
- if (!parsed.success) {
115
- throw new RequestError(400, 'validation', `invalid request body: ${parsed.error.message}`)
116
- }
117
- return parsed.data
118
- }
119
-
120
- function intParam(url: URL, name: string): number | undefined {
121
- const raw = url.searchParams.get(name)
122
- if (raw === null) return undefined
123
- const value = Number(raw)
124
- if (!Number.isInteger(value)) {
125
- throw new RequestError(
126
- 400,
127
- 'validation',
128
- `query parameter "${name}" must be an integer, got "${raw}"`,
129
- )
130
- }
131
- return value
132
- }
133
-
134
- /**
135
- * The `wait` parameter's own grammar (AUT-334): one or more ASCII digits —
136
- * deliberately stricter than the lenient `since`/`rev` parse above; anything
137
- * else (empty, whitespace, hex, signs, decimals) is `400 validation`. Values
138
- * above the server's documented ceiling clamp to it; magnitudes beyond a
139
- * safe integer are simply above the ceiling. Absence is the immediate form.
140
- */
141
- function digitsParam(url: URL, name: string, ceiling: number): number | undefined {
142
- const raw = url.searchParams.get(name)
143
- if (raw === null) return undefined
144
- if (!/^[0-9]+$/.test(raw)) {
145
- throw new RequestError(
146
- 400,
147
- 'validation',
148
- `query parameter "${name}" must be a whole number of seconds, got "${raw}"`,
149
- )
150
- }
151
- const value = Number(raw)
152
- if (!Number.isSafeInteger(value) || value > ceiling) return ceiling
153
- return value
154
- }
155
-
156
- function streamScopesEqual(a: StreamScope, b: StreamScope): boolean {
157
- return (
158
- a.kind === b.kind &&
159
- (a.kind === 'build'
160
- ? b.kind === 'build' && a.build === b.build
161
- : a.kind === 'repo'
162
- ? b.kind === 'repo' && a.repo === b.repo
163
- : b.kind === 'session' && a.session === b.session)
164
- )
165
- }
166
-
167
- export function createStoreServer(opts: StoreServerOptions): StoreServer {
168
- const { store } = opts
169
- const clock = opts.clock ?? systemClock
170
- const maxEventWaitSeconds = opts.maxEventWaitSeconds ?? MAX_STREAM_WAIT_SECONDS
171
-
172
- /**
173
- * D8 gate. `buildScope` is the slug being addressed, or `'*'` for admin
174
- * routes (create/list). Runs before build lookup, so an out-of-scope
175
- * token learns nothing about which builds exist. Returns the verified
176
- * scope (null in open local-dev mode) so event-write routes can enforce
177
- * the session dimension.
178
- */
179
- function validateIdentity(req: Request): void {
180
- const clientAutobuild = req.headers.get(AUTOBUILD_VERSION_HEADER)
181
- const clientProtocol = req.headers.get(REMOTE_STORE_PROTOCOL_VERSION_HEADER)
182
- if (clientAutobuild !== AUTOBUILD_VERSION || clientProtocol !== REMOTE_STORE_PROTOCOL_VERSION) {
183
- throw new RequestError(
184
- 409,
185
- 'conflict',
186
- `remote store version mismatch: client Autobuild ${clientAutobuild ?? '(missing)'} protocol ${clientProtocol ?? '(missing)'}; server Autobuild ${AUTOBUILD_VERSION} protocol ${REMOTE_STORE_PROTOCOL_VERSION}`,
187
- )
188
- }
189
- }
190
-
191
- function decodeArtifact(contentBase64: string): Uint8Array {
192
- let content: Uint8Array
193
- try {
194
- content = decodeBase64(contentBase64)
195
- } catch {
196
- throw new RequestError(400, 'validation', 'artifact content is not valid base64')
197
- }
198
- if (opts.maxArtifactBytes !== undefined && content.byteLength > opts.maxArtifactBytes) {
199
- throw new RequestError(
200
- 413,
201
- 'validation',
202
- `artifact exceeds decoded byte ceiling of ${opts.maxArtifactBytes} bytes`,
203
- )
204
- }
205
- return content
206
- }
207
-
208
- function authorize(
209
- req: Request,
210
- kind: 'build' | 'repo' | 'session' | 'admin',
211
- id: string,
212
- ): TokenScope | null {
213
- if (opts.secret === undefined) return null
214
- const header = req.headers.get('authorization')
215
- const match = header === null ? null : /^Bearer\s+(.+)$/i.exec(header)
216
- if (!match) throw new RequestError(401, 'auth', 'missing bearer token')
217
- const scope = verifyToken(opts.secret, match[1]!, clock())
218
- if (scope === null) {
219
- throw new RequestError(401, 'auth', 'invalid or expired token')
220
- }
221
- const resource = tokenResource(scope)
222
- const allowed =
223
- resource.kind === 'deployment' ||
224
- resource.kind === 'admin' ||
225
- (kind !== 'admin' && resource.kind === kind && resource.id === id)
226
- if (!allowed) {
227
- const target = kind === 'admin' ? 'admin operations' : `${kind} "${id}"`
228
- throw new RequestError(
229
- 403,
230
- 'auth',
231
- `token scoped to ${resource.kind} "${resource.id}" may not access ${target}`,
232
- )
233
- }
234
- return scope
235
- }
236
-
237
- /** Loose agent-session extraction — full actor validation stays in the
238
- * backing store so its D6 feedback survives the wire (protocol.ts keeps
239
- * the event schema deliberately loose). */
240
- function agentSession(actor: unknown): string | null {
241
- if (typeof actor !== 'object' || actor === null) return null
242
- const { kind, session } = actor as { kind?: unknown; session?: unknown }
243
- return kind === 'agent' && typeof session === 'string' ? session : null
244
- }
245
-
246
- /**
247
- * The session half of D8 (§8.1: the token is scoped to this build *and
248
- * session*): a session-scoped token may only write events attributed to
249
- * the agent session it was minted for. `'*'` (admin/runner tokens) is
250
- * unrestricted, and reads are gated by build scope alone — the session
251
- * dimension is write attribution.
252
- */
253
- function authorizeSession(scope: TokenScope | null, actor: unknown): void {
254
- if (scope === null || !('session' in scope) || scope.session === '*') return
255
- const session = agentSession(actor)
256
- if (session !== scope.session) {
257
- throw new RequestError(
258
- 403,
259
- 'auth',
260
- `token scoped to session "${scope.session}" may not write events attributed to ${
261
- session === null ? 'a non-agent actor' : `session "${session}"`
262
- }`,
263
- )
264
- }
265
- }
266
-
267
- /**
268
- * Delegated-write attribution (§15.1) on every event-bearing write, applied
269
- * after token verification and before catalog validation (the backing
270
- * store's EventValidationError is the ontology's voice; a mismatched via is
271
- * an authority failure and must be 403 so a caller can distinguish "not
272
- * your delegate" from "malformed event"). With no secret (open local-dev
273
- * mode) there is no token to be authoritative and writes pass through
274
- * unchanged. A human actor claiming a via the token does not carry → 403;
275
- * a token carrying a via stamps it onto human actors (the token is
276
- * authoritative); a non-human actor carrying via falls through to the
277
- * backing store's validation.
278
- */
279
- function enforceVia(scope: TokenScope | null, actor: unknown): unknown {
280
- if (scope === null) return actor
281
- const tokenVia: Via | undefined = 'via' in scope ? scope.via : undefined
282
- const candidate = actor as { kind?: unknown; via?: Via } | null
283
- if (typeof candidate !== 'object' || candidate === null || candidate.kind !== 'human') {
284
- return actor
285
- }
286
- if (candidate.via !== undefined && tokenVia === undefined) {
287
- throw new RequestError(403, 'auth', 'token carries no via; it may not write delegated events')
288
- }
289
- if (
290
- candidate.via !== undefined &&
291
- tokenVia !== undefined &&
292
- JSON.stringify(candidate.via) !== JSON.stringify(tokenVia)
293
- ) {
294
- throw new RequestError(
295
- 403,
296
- 'auth',
297
- `token carries via ${JSON.stringify(tokenVia)}; it may not write events claiming via ${JSON.stringify(candidate.via)}`,
298
- )
299
- }
300
- if (tokenVia !== undefined) {
301
- return { ...candidate, via: tokenVia }
302
- }
303
- return actor
304
- }
305
-
306
- async function adminRoute(req: Request): Promise<Response> {
307
- authorize(req, 'admin', '*')
308
- if (req.method === 'POST') {
309
- const body = await readBody(req, newBuildBodySchema)
310
- if ((await store.getBuild(body.slug)) !== null) {
311
- return fail(409, 'conflict', `build "${body.slug}" already exists`)
312
- }
313
- return json(201, await store.createBuild(body))
314
- }
315
- if (req.method === 'GET') {
316
- return json(200, await store.listBuilds())
317
- }
318
- return fail(404, 'not-found', `no route: ${req.method} /builds`)
319
- }
320
-
321
- async function repoAdminRoute(req: Request): Promise<Response> {
322
- authorize(req, 'admin', '*')
323
- if (req.method !== 'POST') {
324
- return fail(404, 'not-found', `no route: ${req.method} /repos`)
325
- }
326
- const body = await readBody(req, ensureRepoBodySchema)
327
- return json(200, await store.ensureRepo(body.repo))
328
- }
329
-
330
- async function repoRoute(
331
- req: Request,
332
- url: URL,
333
- repo: string,
334
- rest: string,
335
- scope: TokenScope | null,
336
- ): Promise<Response> {
337
- const segments = rest.split('/')
338
- if (segments[0] === 'streams') {
339
- return streamRoute(req, url, { kind: 'repo', repo }, segments.slice(1))
340
- }
341
- switch (`${req.method} ${rest}`) {
342
- case 'POST events': {
343
- const body = await readBody(req, eventWriteWireSchema)
344
- authorizeSession(scope, body.actor)
345
- const actor = enforceVia(scope, body.actor)
346
- return json(201, await store.appendRepo(repo, { ...body, actor } as RepositoryEventWrite))
347
- }
348
- case 'GET events': {
349
- const since = intParam(url, 'since') ?? 0
350
- const wait = digitsParam(url, 'wait', maxEventWaitSeconds)
351
- return json(
352
- 200,
353
- await withDisconnect(
354
- req,
355
- store.getRepoEvents(repo, since, {
356
- ...(wait !== undefined ? { waitSeconds: wait } : {}),
357
- signal: req.signal,
358
- }),
359
- (): RepositoryEvent[] => [],
360
- ),
361
- )
362
- }
363
- case 'POST deposits': {
364
- const body = await readBody(req, depositsBodySchema)
365
- authorizeSession(scope, body.event.actor)
366
- const actor = enforceVia(scope, body.event.actor)
367
- const inputs = body.artifacts.map((artifact) => ({
368
- kind: artifact.kind,
369
- content: decodeArtifact(artifact.contentBase64),
370
- ...(artifact.metadata !== undefined ? { metadata: artifact.metadata } : {}),
371
- }))
372
- const result = await store.appendRepoWithArtifacts(
373
- repo,
374
- inputs,
375
- (deposited) =>
376
- ({
377
- actor,
378
- type: body.event.type,
379
- payload: substitutePlaceholderRefs(body.event.payload, deposited),
380
- }) as RepositoryEventWrite,
381
- )
382
- return json(201, result)
383
- }
384
- case 'POST artifacts': {
385
- const body = await readBody(req, putArtifactBodySchema)
386
- return json(
387
- 201,
388
- await store.putRepoArtifact(repo, {
389
- kind: body.kind,
390
- content: decodeArtifact(body.contentBase64),
391
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
392
- }),
393
- )
394
- }
395
- case 'GET artifacts': {
396
- const kind = url.searchParams.get('kind')
397
- if (kind === null || kind === '') {
398
- throw new RequestError(400, 'validation', 'query parameter "kind" is required')
399
- }
400
- const artifact = await store.getRepoArtifact(repo, kind, intParam(url, 'rev'))
401
- return artifact === null
402
- ? json(200, null)
403
- : json(200, {
404
- meta: artifact.meta,
405
- contentBase64: encodeBase64(artifact.content),
406
- })
407
- }
408
- case 'GET artifact-list':
409
- return json(
410
- 200,
411
- await store.listRepoArtifacts(repo, url.searchParams.get('kind') ?? undefined),
412
- )
413
- case 'POST lease/claim': {
414
- const body = await readBody(req, leaseClaimBodySchema)
415
- return json(200, {
416
- ok: await store.claimRepoLease(repo, body.holder, body.ttlMs),
417
- })
418
- }
419
- case 'POST lease/heartbeat': {
420
- const body = await readBody(req, leaseHolderBodySchema)
421
- return json(200, {
422
- ok: await store.heartbeatRepo(repo, body.holder),
423
- })
424
- }
425
- case 'POST lease/release': {
426
- const body = await readBody(req, leaseHolderBodySchema)
427
- await store.releaseRepoLease(repo, body.holder)
428
- return json(200, { ok: true })
429
- }
430
- default:
431
- return fail(404, 'not-found', `no route: ${req.method} /repos/:repo/${rest}`)
432
- }
433
- }
434
-
435
- /**
436
- * Session family routes (SPEC §7.1.1), mirroring the repository-journal
437
- * family: a session resource token gates exactly its own `/sessions/{id}`
438
- * operations; collection routes live under `/repos/{repo}/sessions` and
439
- * keep the repo/admin matrix. A wrong-scope token gets `403 auth` before
440
- * resource lookup (no existence leak).
441
- */
442
- async function sessionCollectionRoute(req: Request, repo: string): Promise<Response> {
443
- if (req.method === 'POST') {
444
- const body = await readBody(req, newSessionBodySchema)
445
- if (body.repo !== repo) {
446
- return fail(
447
- 400,
448
- 'validation',
449
- `session body repo ${JSON.stringify(body.repo)} does not match the path`,
450
- )
451
- }
452
- return json(201, await store.createSession(body))
453
- }
454
- if (req.method === 'GET') {
455
- return json(200, await store.listSessions(repo))
456
- }
457
- return fail(404, 'not-found', `no route: ${req.method} /repos/:repo/sessions`)
458
- }
459
-
460
- async function sessionRoute(req: Request, url: URL, id: string, rest: string): Promise<Response> {
461
- const segments = rest.split('/')
462
- if (segments[0] === 'streams') {
463
- return streamRoute(req, url, { kind: 'session', session: id }, segments.slice(1))
464
- }
465
- switch (`${req.method} ${rest}`) {
466
- case 'GET record':
467
- return json(200, await store.getSession(id))
468
- case 'POST events': {
469
- const body = await readBody(req, eventWriteWireSchema)
470
- return json(201, await store.appendSessionEvent(id, body as SessionEventWrite))
471
- }
472
- case 'GET events': {
473
- const since = intParam(url, 'since') ?? 0
474
- const wait = digitsParam(url, 'wait', maxEventWaitSeconds)
475
- return json(
476
- 200,
477
- await withDisconnect(
478
- req,
479
- store.getSessionEvents(id, since, {
480
- ...(wait !== undefined ? { waitSeconds: wait } : {}),
481
- signal: req.signal,
482
- }),
483
- (): SessionEvent[] => [],
484
- ),
485
- )
486
- }
487
- case 'POST deposits': {
488
- const body = await readBody(req, depositsBodySchema)
489
- const inputs = body.artifacts.map((artifact) => ({
490
- kind: artifact.kind,
491
- content: decodeArtifact(artifact.contentBase64),
492
- ...(artifact.metadata !== undefined ? { metadata: artifact.metadata } : {}),
493
- }))
494
- const result = await store.appendSessionWithArtifacts(
495
- id,
496
- inputs,
497
- (deposited) =>
498
- ({
499
- actor: body.event.actor,
500
- type: body.event.type,
501
- payload: substitutePlaceholderRefs(body.event.payload, deposited),
502
- }) as SessionEventWrite,
503
- )
504
- return json(201, result)
505
- }
506
- case 'POST artifacts': {
507
- const body = await readBody(req, putArtifactBodySchema)
508
- return json(
509
- 201,
510
- await store.putSessionArtifact(id, {
511
- kind: body.kind,
512
- content: decodeArtifact(body.contentBase64),
513
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
514
- }),
515
- )
516
- }
517
- case 'GET artifacts': {
518
- const kind = url.searchParams.get('kind')
519
- if (kind === null || kind === '') {
520
- throw new RequestError(400, 'validation', 'query parameter "kind" is required')
521
- }
522
- const artifact = await store.getSessionArtifact(id, kind, intParam(url, 'rev'))
523
- return artifact === null
524
- ? json(200, null)
525
- : json(200, {
526
- meta: artifact.meta,
527
- contentBase64: encodeBase64(artifact.content),
528
- })
529
- }
530
- case 'GET artifact-list':
531
- return json(
532
- 200,
533
- await store.listSessionArtifacts(id, url.searchParams.get('kind') ?? undefined),
534
- )
535
- default:
536
- return fail(404, 'not-found', `no route: ${req.method} /sessions/:id/${rest}`)
537
- }
538
- }
539
-
540
- async function buildRoute(
541
- req: Request,
542
- url: URL,
543
- slug: string,
544
- rest: string,
545
- scope: TokenScope | null,
546
- ): Promise<Response> {
547
- const segments = rest.split('/')
548
- if (segments[0] === 'streams') {
549
- return streamRoute(req, url, { kind: 'build', build: slug }, segments.slice(1))
550
- }
551
- switch (`${req.method} ${rest}`) {
552
- case 'POST events': {
553
- const body = await readBody(req, eventWriteWireSchema)
554
- authorizeSession(scope, body.actor)
555
- const actor = enforceVia(scope, body.actor)
556
- return json(201, await store.append(slug, { ...body, actor } as EventWrite))
557
- }
558
- case 'POST events/conditional': {
559
- const body = await readBody(req, conditionalEventBodySchema)
560
- authorizeSession(scope, body.event.actor)
561
- const actor = enforceVia(scope, body.event.actor)
562
- const event = await store.appendIfCurrent(slug, body.expectedSeq, {
563
- ...body.event,
564
- actor,
565
- } as EventWrite)
566
- return json(event === null ? 200 : 201, event)
567
- }
568
- case 'GET events': {
569
- const since = intParam(url, 'since') ?? 0
570
- const wait = digitsParam(url, 'wait', maxEventWaitSeconds)
571
- return json(
572
- 200,
573
- await withDisconnect(
574
- req,
575
- store.getEvents(slug, since, {
576
- ...(wait !== undefined ? { waitSeconds: wait } : {}),
577
- signal: req.signal,
578
- }),
579
- (): AbEvent[] => [],
580
- ),
581
- )
582
- }
583
- case 'POST deposits': {
584
- const body = await readBody(req, depositsBodySchema)
585
- authorizeSession(scope, body.event.actor)
586
- const actor = enforceVia(scope, body.event.actor)
587
- const inputs = body.artifacts.map((artifact) => ({
588
- kind: artifact.kind,
589
- content: decodeArtifact(artifact.contentBase64),
590
- ...(artifact.metadata !== undefined ? { metadata: artifact.metadata } : {}),
591
- }))
592
- // The wire's makeEvent: substitute the deposited revisions for the
593
- // payload's negative placeholders (protocol.ts). Runs inside the
594
- // backing store's atomic appendWithArtifacts, so an invalid event
595
- // rolls the whole deposit back (D6).
596
- const result = await store.appendWithArtifacts(
597
- slug,
598
- inputs,
599
- (deposited) =>
600
- ({
601
- actor,
602
- type: body.event.type,
603
- payload: substitutePlaceholderRefs(body.event.payload, deposited),
604
- }) as EventWrite,
605
- )
606
- return json(201, { event: result.event, artifacts: result.artifacts })
607
- }
608
- case 'POST artifacts': {
609
- const body = await readBody(req, putArtifactBodySchema)
610
- const meta = await store.putArtifact(slug, {
611
- kind: body.kind,
612
- content: decodeArtifact(body.contentBase64),
613
- ...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
614
- })
615
- return json(201, meta)
616
- }
617
- case 'GET artifacts': {
618
- const kind = url.searchParams.get('kind')
619
- if (kind === null || kind === '') {
620
- throw new RequestError(400, 'validation', 'query parameter "kind" is required')
621
- }
622
- const artifact = await store.getArtifact(slug, kind, intParam(url, 'rev'))
623
- if (artifact === null) return json(200, null)
624
- return json(200, {
625
- meta: artifact.meta,
626
- contentBase64: encodeBase64(artifact.content),
627
- })
628
- }
629
- case 'GET artifact-list': {
630
- const kind = url.searchParams.get('kind') ?? undefined
631
- return json(200, await store.listArtifacts(slug, kind))
632
- }
633
- case 'POST lease/claim': {
634
- const body = await readBody(req, leaseClaimBodySchema)
635
- return json(200, { ok: await store.claimLease(slug, body.holder, body.ttlMs) })
636
- }
637
- case 'POST lease/heartbeat': {
638
- const body = await readBody(req, leaseHolderBodySchema)
639
- return json(200, { ok: await store.heartbeat(slug, body.holder) })
640
- }
641
- case 'POST lease/release': {
642
- const body = await readBody(req, leaseHolderBodySchema)
643
- await store.releaseLease(slug, body.holder)
644
- return json(200, { ok: true })
645
- }
646
- default:
647
- return fail(404, 'not-found', `no route: ${req.method} /builds/:slug/${rest}`)
648
- }
649
- }
650
-
651
- /**
652
- * Stream routes (SPEC §7.6), shared by the build and repository families:
653
- * `rest` is the segments after `streams`, the scope comes from the path.
654
- * Addressed operations verify the stream actually belongs to this scope —
655
- * an unknown stream and one scoped elsewhere are the same 404, so no
656
- * cross-scope existence leaks.
657
- */
658
- async function streamRoute(
659
- req: Request,
660
- url: URL,
661
- scope: StreamScope,
662
- rest: string[],
663
- ): Promise<Response> {
664
- if (rest.length === 0) {
665
- if (req.method === 'POST') {
666
- const body = await readBody(req, createStreamBodySchema)
667
- return json(201, await store.createStream(scope, body.label))
668
- }
669
- if (req.method === 'GET') return json(200, await store.listStreams(scope))
670
- return fail(404, 'not-found', `no route: ${req.method} streams`)
671
- }
672
- const [streamId, leaf, ...extra] = rest
673
- if (extra.length > 0 || streamId === undefined) {
674
- return fail(404, 'not-found', `no route: ${req.method} streams/${rest.join('/')}`)
675
- }
676
- const record = await store.getStream(streamId)
677
- if (record === null || !streamScopesEqual(record.scope, scope)) {
678
- return fail(404, 'not-found', `unknown stream "${streamId}"`)
679
- }
680
- if (leaf === undefined || leaf === '') {
681
- if (req.method === 'GET') return json(200, record)
682
- return fail(404, 'not-found', `no route: ${req.method} streams/${streamId}`)
683
- }
684
- if (leaf === 'chunks') {
685
- if (req.method === 'POST') {
686
- const body = await readBody(req, appendStreamBodySchema)
687
- return json(201, await store.appendStreamParts(streamId, body.parts as StreamPart[]))
688
- }
689
- if (req.method === 'GET') {
690
- const since = intParam(url, 'since') ?? 0
691
- // §9 grammar: `wait` is digits-only server-wide, clamped to the
692
- // 30-second stream ceiling (MAX_STREAM_WAIT_SECONDS, not the hosted
693
- // event ceiling of §6's hosted exception).
694
- const wait = digitsParam(url, 'wait', MAX_STREAM_WAIT_SECONDS)
695
- return json(
696
- 200,
697
- await withDisconnect(
698
- req,
699
- store.readStream(streamId, {
700
- since,
701
- ...(wait !== undefined ? { waitSeconds: wait } : {}),
702
- signal: req.signal,
703
- }),
704
- // The empty read carries the already-fetched record's lifecycle
705
- // fields verbatim — the response to a disconnected client is
706
- // discarded either way, but the wire schema must still hold.
707
- (): StreamRead => ({
708
- chunks: [],
709
- status: record.status,
710
- ...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
711
- ...(record.artifact !== undefined ? { artifact: record.artifact } : {}),
712
- }),
713
- ),
714
- )
715
- }
716
- return fail(404, 'not-found', `no route: ${req.method} streams/${streamId}/chunks`)
717
- }
718
- if (leaf === 'close') {
719
- if (req.method !== 'POST') {
720
- return fail(404, 'not-found', `no route: ${req.method} streams/${streamId}/close`)
721
- }
722
- const body = await readBody(req, closeStreamBodySchema)
723
- return json(200, await store.closeStream(streamId, body.outcome as StreamOutcome))
724
- }
725
- return fail(404, 'not-found', `no route: ${req.method} streams/${rest.join('/')}`)
726
- }
727
-
728
- /**
729
- * Top-level addressed stream routes. A stream id is globally unique but
730
- * carries no scope, so the client (which addresses streams by id alone)
731
- * uses these: the server resolves the stream's own scope and authorizes
732
- * against it, exactly like the family routes authorize by path. An
733
- * unknown stream and a scope the token does not cover are both 404 — the
734
- * same no-existence-leak rule the family routes apply.
735
- */
736
- async function globalStreamRoute(req: Request, url: URL, segments: string[]): Promise<Response> {
737
- const streamId = segments[1]
738
- if (streamId === undefined || streamId === '') {
739
- return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
740
- }
741
- const record = await store.getStream(streamId)
742
- if (record === null) return fail(404, 'not-found', `unknown stream "${streamId}"`)
743
- const scopeId =
744
- record.scope.kind === 'build'
745
- ? record.scope.build
746
- : record.scope.kind === 'repo'
747
- ? record.scope.repo
748
- : record.scope.session
749
- authorize(req, record.scope.kind, scopeId)
750
- return streamRoute(req, url, record.scope, segments.slice(1))
751
- }
752
-
753
- async function route(req: Request): Promise<Response> {
754
- const url = new URL(req.url)
755
- let segments: string[]
756
- try {
757
- segments = url.pathname
758
- .split('/')
759
- .filter((part) => part.length > 0)
760
- .map(decodeURIComponent)
761
- } catch {
762
- throw new RequestError(400, 'validation', `malformed path: ${url.pathname}`)
763
- }
764
-
765
- if (segments.length === 1 && segments[0] === 'health' && req.method === 'GET') {
766
- return json(200, {
767
- ok: true,
768
- autobuildVersion: AUTOBUILD_VERSION,
769
- protocolVersion: REMOTE_STORE_PROTOCOL_VERSION,
770
- })
771
- }
772
- if (segments[0] === 'repos' || segments[0] === 'builds' || segments[0] === 'streams') {
773
- validateIdentity(req)
774
- }
775
- if (segments[0] === 'streams') {
776
- return globalStreamRoute(req, url, segments)
777
- }
778
- if (segments[0] === 'sessions' && segments.length >= 2) {
779
- const id = segments[1]!
780
- authorize(req, 'session', id)
781
- if (segments.length === 2) {
782
- if (req.method === 'GET') return sessionRoute(req, url, id, 'record')
783
- return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
784
- }
785
- return sessionRoute(req, url, id, segments.slice(2).join('/'))
786
- }
787
-
788
- if (segments[0] === 'repos') {
789
- if (segments.length === 1) return repoAdminRoute(req)
790
- const repo = segments[1]!
791
- const scope = authorize(req, 'repo', repo)
792
- if (segments.length === 3 && segments[2] === 'sessions') {
793
- return sessionCollectionRoute(req, repo)
794
- }
795
- const record = await store.getRepo(repo)
796
- if (record === null) {
797
- return fail(404, 'not-found', `unknown repo "${repo}"`)
798
- }
799
- if (segments.length === 2) {
800
- if (req.method === 'GET') return json(200, record)
801
- return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
802
- }
803
- return repoRoute(req, url, repo, segments.slice(2).join('/'), scope)
804
- }
805
-
806
- if (segments[0] !== 'builds') {
807
- return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
808
- }
809
- if (segments.length === 1) return adminRoute(req)
810
-
811
- const slug = segments[1]!
812
- const scope = authorize(req, 'build', slug)
813
- const record = await store.getBuild(slug)
814
- if (record === null) {
815
- return fail(404, 'not-found', `unknown build "${slug}"`)
816
- }
817
- if (segments.length === 2) {
818
- if (req.method === 'GET') return json(200, record)
819
- return fail(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
820
- }
821
- return buildRoute(req, url, slug, segments.slice(2).join('/'), scope)
822
- }
823
-
824
- /** A held read must not outlive its client. When the peer disconnects, Bun
825
- * keeps the handler promise pending — which would stall `server.stop()` —
826
- * so a held read races the request's abort signal and resolves empty; the
827
- * response to a disconnected client is discarded either way. The held
828
- * routes also pass the same signal into the backing store read itself, so
829
- * the poll loop behind the request stops too (AUT-380) instead of polling
830
- * to its own wait bound; this guard remains for `server.stop()` and for
831
- * any read that completes between abort and loop exit. */
832
- function withDisconnect<T>(req: Request, read: Promise<T>, empty: () => T): Promise<T> {
833
- const signal = req.signal
834
- if (signal.aborted) return Promise.resolve(empty())
835
- return new Promise<T>((resolve, reject) => {
836
- const onAbort = (): void => resolve(empty())
837
- signal.addEventListener('abort', onAbort, { once: true })
838
- read.then(
839
- (value) => {
840
- signal.removeEventListener('abort', onAbort)
841
- resolve(value)
842
- },
843
- (error) => {
844
- signal.removeEventListener('abort', onAbort)
845
- reject(error)
846
- },
847
- )
848
- })
849
- }
850
-
851
- function errorResponse(error: unknown): Response {
852
- if (error instanceof RequestError) {
853
- return fail(error.status, error.kind, error.message)
854
- }
855
- // D6: validation feedback must survive the wire with its message intact.
856
- if (error instanceof EventValidationError) {
857
- return fail(422, 'validation', error.message)
858
- }
859
- if (error instanceof StreamBatchTooLargeError) {
860
- return fail(413, 'validation', error.message)
861
- }
862
- if (error instanceof StreamClosedError) {
863
- return fail(409, 'conflict', error.message)
864
- }
865
- if (error instanceof Error && error.message.includes('already exists')) {
866
- return fail(409, 'conflict', error.message)
867
- }
868
- if (
869
- error instanceof Error &&
870
- (error.message.startsWith('unknown build') ||
871
- error.message.startsWith('unknown repo') ||
872
- error.message.startsWith('unknown session') ||
873
- error.message.startsWith('unknown stream'))
874
- ) {
875
- return fail(404, 'not-found', error.message)
876
- }
877
- return fail(500, 'internal', error instanceof Error ? error.message : String(error))
878
- }
879
-
880
- return {
881
- fetch: async (req: Request): Promise<Response> => {
882
- try {
883
- return await route(req)
884
- } catch (error) {
885
- const response = errorResponse(error)
886
- if (response.status >= 500 && opts.onInternalError !== undefined) {
887
- try {
888
- await opts.onInternalError(error, req)
889
- } catch {
890
- // Diagnostics must never replace the protocol response.
891
- }
892
- }
893
- return response
894
- }
895
- },
896
- }
897
- }
898
-
899
- export interface StartStoreServerOptions extends StoreServerOptions {
900
- /** 0 (the default) lets the OS pick a free port — tests rely on this. */
901
- port?: number
902
- hostname?: string
903
- }
904
-
905
- /** Wrap the handler in Bun.serve; `stop` force-closes open connections. */
906
- export function startStoreServer(opts: StartStoreServerOptions): {
907
- url: string
908
- stop: () => Promise<void>
909
- } {
910
- const handler = createStoreServer(opts)
911
- const hostname = opts.hostname ?? '127.0.0.1'
912
- const server = Bun.serve({
913
- port: opts.port ?? 0,
914
- hostname,
915
- fetch: handler.fetch,
916
- })
917
- return {
918
- url: `http://${hostname}:${server.port}`,
919
- stop: async () => {
920
- await server.stop(true)
921
- },
922
- }
923
- }