@abide/abide 0.103.0 → 0.104.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.
@@ -123,13 +123,39 @@ let registered = false
123
123
  */
124
124
  export function serveCompilation(root: string, compilation: Compilation): void {
125
125
  const real = realOrAsGiven(root)
126
- addCompilation(real, compilation, RUNTIME)
126
+ const handlerFiles = addCompilation(real, compilation, RUNTIME)
127
127
  installSourceMaps(serverMapsOf(compilation, real), real)
128
+ if (handlerFiles.length !== 0) Bun.plugin(handlerFilesPlugin(handlerFiles))
128
129
  if (registered) return
129
130
  registered = true
130
131
  Bun.plugin(modulesPlugin(RUNTIME))
131
132
  }
132
133
 
134
+ // 16.2 — A HANDLER FILE LOADED AT ITS OWN PATH RE-EXPORTS ITS FACE, so every spelling of its import
135
+ // is the module the server mounted. The runtime asks `onResolve` only for a specifier with an
136
+ // extension, while `onLoad` is asked by the path the file resolved to, however it was spelled. One
137
+ // plugin per compilation because the filter is fixed at registration, and the runtime's `onLoad`
138
+ // cannot fall through, so the filter names the handler files and nothing else. The face it names
139
+ // is resolved by `modulesPlugin`, registered once.
140
+ function handlerFilesPlugin(paths: string[]): Bun.BunPlugin {
141
+ return {
142
+ name: 'abide-handler-files',
143
+ setup(build) {
144
+ build.onLoad(
145
+ {
146
+ filter: new RegExp(
147
+ `^(?:${paths.map(RegExp.escape).join('|')})$`,
148
+ ),
149
+ },
150
+ (args) => ({
151
+ contents: `export { default } from ${JSON.stringify(`${args.path}${HANDLER_SUFFIX}`)}\n`,
152
+ loader: 'js',
153
+ }),
154
+ )
155
+ },
156
+ }
157
+ }
158
+
133
159
  /** What the server's table exports: its routes and error pages, server blocks and handlers. */
134
160
  export type BuiltTable = ServerTable & {
135
161
  /** 37.1 — every module a `<script server>` runs in, imported before the app boots. */
@@ -162,18 +188,24 @@ export function serverMapsOf(
162
188
  return maps
163
189
  }
164
190
 
191
+ /** Adds `compilation` to `held`, and answers its handler files' absolute paths. */
165
192
  function addCompilation(
166
193
  root: string,
167
194
  compilation: Compilation,
168
195
  held: Held,
169
- ): void {
196
+ ): string[] {
170
197
  // RESOLVED, not joined: a dependency's file is keyed above the root (`../node_modules/…`).
171
198
  for (const [relative, compiled] of Object.entries(compilation.files))
172
199
  held.files.set(resolve(root, relative), compiled)
173
- for (const [relative, face] of Object.entries(compilation.handlers))
174
- held.handlers.set(resolve(root, relative), face)
200
+ const handlerFiles: string[] = []
201
+ for (const [relative, face] of Object.entries(compilation.handlers)) {
202
+ const path = resolve(root, relative)
203
+ held.handlers.set(path, face)
204
+ handlerFiles.push(path)
205
+ }
175
206
  for (const [name, text] of Object.entries(compilation.tables))
176
207
  held.tables.set(`${root}/${VIRTUAL_DIRECTORY}/${name}`, text)
208
+ return handlerFiles
177
209
  }
178
210
 
179
211
  // `Bun.resolveSync` asks EVERY abide plugin's `onResolve` again — this one's, and the runtime's, which
@@ -292,46 +324,45 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
292
324
 
293
325
  // A HANDLER FILE IS FOUND BY WHERE ITS IMPORT RESOLVES, however the import spells it, and
294
326
  // only from the app's own modules: a package's never names one, and asking bun again for
295
- // each of a package's relative imports is a resolve per module of the bundle. IN A BUNDLE
296
- // THE FILTER IS THE HANDLER FILES' NAMES: a hook matching every relative import costs the
297
- // bundle abide's own `sideEffects`, and the barrel's rest — the validator, the csp — moved
298
- // into the chunk every page loads (38.94). The runtime is told of each compilation after
299
- // it registered, so it matches every relative or `#` import and asks.
327
+ // each of a package's relative imports is a resolve per module of the bundle. THE FILTER
328
+ // IS THE HANDLER FILES' NAMES: a hook matching every relative import costs the bundle
329
+ // abide's own `sideEffects`, and the barrel's rest — the validator, the csp — moved into
330
+ // the chunk every page loads (38.94). ONLY IN A BUNDLE: the runtime finds a handler file
331
+ // by the path it loads at (`handlerFilesPlugin`), which costs no resolve per import.
300
332
  // Each handler file's name without `.ts` — what an import of one ends in.
301
- let stems: string[] | undefined
302
- if (bundling) {
303
- const names = new Set<string>()
333
+ const stems = new Set<string>()
334
+ if (bundling)
304
335
  for (const path of served.handlers.keys())
305
- names.add(basename(path, '.ts'))
306
- stems = [...names]
307
- }
308
- const handlerImport =
309
- stems === undefined
310
- ? RELATIVE_OR_SUBPATH
311
- : new RegExp(
312
- `(?:^|/)(?:${stems.map(RegExp.escape).join('|')})(?:\\.ts)?$`,
313
- )
314
- if (stems?.length !== 0)
315
- build.onResolve({ filter: handlerImport }, (args) => {
316
- if (resolving || !RELATIVE_OR_SUBPATH.test(args.path))
317
- return undefined
318
- const importer = pathOf(args.importer)
319
- if (importer.includes('/node_modules/')) return undefined
320
- let path: string
321
- try {
322
- path = resolved(args.path, args.importer)
323
- } catch {
324
- return undefined
325
- }
326
- const face = served.handlers.get(path)
327
- if (face === undefined) return undefined
328
- // 20.4 — a handler the browser is handed no wrapper for is no import of its.
329
- if (inBrowser(args.importer) && face.client === '')
330
- throw new Error(
331
- `abide: ${pathOf(args.importer)} imports ${args.path}, whose handler withholds itself from the browser (\`clients: { ui: false }\`). Call it from a <script server>, or let the browser have it. RULEBOOK 20.4.`,
332
- )
333
- return { path: `${path}${HANDLER_SUFFIX}` }
334
- })
336
+ stems.add(basename(path, '.ts'))
337
+ if (stems.size !== 0)
338
+ build.onResolve(
339
+ {
340
+ filter: new RegExp(
341
+ `(?:^|/)(?:${[...stems].map(RegExp.escape).join('|')})(?:\\.ts)?$`,
342
+ ),
343
+ },
344
+ (args) => {
345
+ if (resolving || !RELATIVE_OR_SUBPATH.test(args.path))
346
+ return undefined
347
+ const importer = pathOf(args.importer)
348
+ if (importer.includes('/node_modules/'))
349
+ return undefined
350
+ let path: string
351
+ try {
352
+ path = resolved(args.path, args.importer)
353
+ } catch {
354
+ return undefined
355
+ }
356
+ const face = served.handlers.get(path)
357
+ if (face === undefined) return undefined
358
+ // 20.4 — a handler the browser is handed no wrapper for is no import of its.
359
+ if (inBrowser(args.importer) && face.client === '')
360
+ throw new Error(
361
+ `abide: ${pathOf(args.importer)} imports ${args.path}, whose handler withholds itself from the browser (\`clients: { ui: false }\`). Call it from a <script server>, or let the browser have it. RULEBOOK 20.4.`,
362
+ )
363
+ return { path: `${path}${HANDLER_SUFFIX}` }
364
+ },
365
+ )
335
366
 
336
367
  // A RELATIVE SPECIFIER A FACE IMPORTS AT RUN TIME is resolved from the file the face
337
368
  // stands for: bun's runtime reads one from an importer in a namespace as no path at
@@ -340,12 +371,18 @@ function modulesPlugin(served: Held): Bun.BunPlugin {
340
371
  // and where a hook matching every relative import in a package costs the bundle that
341
372
  // package's `sideEffects`: a two-page app grew by 12 KB, the validator one page never
342
373
  // calls moved into the chunk both pages load.
343
- if (!bundling)
374
+ if (!bundling) {
375
+ // A face `handlerFilesPlugin` re-exports, named by its absolute path, which no file
376
+ // is at.
377
+ build.onResolve({ filter: HANDLER_FACE }, (args) => ({
378
+ path: args.path,
379
+ }))
344
380
  build.onResolve({ filter: /^\.\.?\// }, (args) =>
345
381
  resolving || pathOf(args.importer) === args.importer
346
382
  ? undefined
347
383
  : { path: resolved(args.path, args.importer) },
348
384
  )
385
+ }
349
386
 
350
387
  build.onLoad(
351
388
  { filter: /\.abide$/, namespace: componentNamespace },
@@ -15,6 +15,7 @@
15
15
  import { type Dirent, existsSync, readdirSync } from 'node:fs' // bun has no directory listing or sync existence check of its own
16
16
  import { ABIDE_ADDRESS } from '#shared/ADDRESSES.ts'
17
17
  import {
18
+ HOLDS_SERVER,
18
19
  LIB_DIRECTORY,
19
20
  PUBLIC_DIRECTORY,
20
21
  RESERVED_DIRECTORIES,
@@ -28,7 +29,7 @@ type AppFiles = {
28
29
  abide: string[]
29
30
  /** Every handler file, in path order. */
30
31
  handlers: string[]
31
- /** Every module under a `server` or a `lib` directory, in path order — run as it is imported. */
32
+ /** Every module under a `server` or a `lib` directory, in path order — read for 37.13. */
32
33
  modules: string[]
33
34
  /** Every file under a `public` directory, to the address it is answered at below the mount. */
34
35
  published: Map<string, string>
@@ -90,7 +91,14 @@ function walk(
90
91
  if (entry.isDirectory()) {
91
92
  let within = reserved
92
93
  if (RESERVED_DIRECTORIES.has(entry.name)) {
93
- if (reserved !== undefined)
94
+ // 23.16 — `server` nests in the fences that hold code.
95
+ if (
96
+ reserved !== undefined &&
97
+ !(
98
+ entry.name === SERVER_DIRECTORY &&
99
+ HOLDS_SERVER.has(reserved.name)
100
+ )
101
+ )
94
102
  refuse(
95
103
  path,
96
104
  `abide: ${path} is a ${entry.name} directory under the ${reserved.name} directory ${reserved.path}. RULEBOOK 23.16.`,
@@ -29,7 +29,7 @@ import { type Listed, surfaceOf } from './surfaceOf.ts'
29
29
  const OPENAPI_LOG = namedLogger('abide:openapi')
30
30
  const MCP_LOG = namedLogger('abide:mcp')
31
31
 
32
- // What MCP and OpenAPI add to a listing: whether the handler is keyed.
32
+ // What OpenAPI adds to a listing: whether the handler is keyed.
33
33
  type Published = Listed & { keyed: boolean }
34
34
 
35
35
  /** Every handler `surface` lists (`surfaceOf`). */
@@ -212,18 +212,8 @@ export function openApi(
212
212
  return Object.keys(document().paths).length > 0 ? document : undefined
213
213
  }
214
214
 
215
- /** What the MCP surface publishes: a tool per call that takes args or writes, a resource per read. */
216
- type Tools = { tools: Published[]; resources: Published[] }
215
+ const published = new WeakMap<Map<string, Mounted>, Reactive<Published[]>>()
217
216
 
218
- const published = new WeakMap<Map<string, Mounted>, Reactive<Tools>>()
219
-
220
- function toolsOf(handlers: Map<string, Mounted>): Tools {
221
- const found: Tools = { tools: [], resources: [] }
222
- for (const one of listed(handlers, 'mcp'))
223
- if (one.keyed || one.method !== 'GET') found.tools.push(one)
224
- else found.resources.push(one)
225
- return found
226
- }
227
217
 
228
218
  type JsonRpc = {
229
219
  jsonrpc: '2.0'
@@ -264,18 +254,20 @@ export class McpSurface {
264
254
 
265
255
  /** Whether the route table publishes any handler to it. 20.7. */
266
256
  get opened(): boolean {
267
- const { tools, resources } = this.#published()
268
- return tools.length > 0 || resources.length > 0
257
+ return this.#published().length > 0
269
258
  }
270
259
 
271
- #published(): Tools {
272
- return overTable(published, this.#handlers, toolsOf)()
260
+ #published(): Published[] {
261
+ // Every handler the MCP surface lists, each a tool — a read as much as a mutation. 20.9.
262
+ return overTable(published, this.#handlers, (handlers) =>
263
+ listed(handlers, 'mcp'),
264
+ )()
273
265
  }
274
266
 
275
267
  /** Every tool it lists. 20.9. */
276
268
  list(): ToolDefinition[] {
277
269
  const listing: ToolDefinition[] = []
278
- for (const tool of this.#published().tools) {
270
+ for (const tool of this.#published()) {
279
271
  if (tool.description === undefined)
280
272
  MCP_LOG.warning(
281
273
  `${tool.name} is published with no description.`,
@@ -305,7 +297,7 @@ export class McpSurface {
305
297
  }
306
298
 
307
299
  #tool(name: unknown): Published | undefined {
308
- return this.#published().tools.find((one) => one.name === name)
300
+ return this.#published().find((one) => one.name === name)
309
301
  }
310
302
 
311
303
  async #called(
@@ -335,7 +327,7 @@ export class McpSurface {
335
327
  return {
336
328
  result: {
337
329
  protocolVersion: MCP_PROTOCOL_VERSION,
338
- capabilities: { tools: {}, resources: {} },
330
+ capabilities: { tools: {} },
339
331
  serverInfo: appIdentity(),
340
332
  },
341
333
  }
@@ -343,19 +335,6 @@ export class McpSurface {
343
335
  return { result: {} }
344
336
  case 'tools/list':
345
337
  return { result: { tools: this.list() } }
346
- case 'resources/list':
347
- return {
348
- result: {
349
- resources: this.#published().resources.map(
350
- (resource) => ({
351
- uri: `abide:${resource.address}`,
352
- name: resource.name,
353
- description: resource.description ?? '',
354
- mimeType: 'application/json',
355
- }),
356
- ),
357
- },
358
- }
359
338
  case 'tools/call': {
360
339
  const tool = this.#tool(params?.name)
361
340
  if (tool === undefined)
@@ -375,25 +354,6 @@ export class McpSurface {
375
354
  },
376
355
  }
377
356
  }
378
- case 'resources/read': {
379
- const uri = String(params?.uri ?? '')
380
- const resource = this.#published().resources.find(
381
- (one) => `abide:${one.address}` === uri,
382
- )
383
- if (resource === undefined)
384
- return mcpFailure(-32602, `No resource at ${uri}.`)
385
- const { text, failed } = await this.#called(
386
- resource,
387
- undefined,
388
- asker,
389
- )
390
- if (failed) return mcpFailure(-32603, text)
391
- return {
392
- result: {
393
- contents: [{ uri, mimeType: 'application/json', text }],
394
- },
395
- }
396
- }
397
357
  default:
398
358
  return mcpFailure(-32601, `No method ${method}.`)
399
359
  }
@@ -404,8 +364,7 @@ export class McpSurface {
404
364
  * Answers one MCP JSON-RPC request over `surface`, or `undefined` where it publishes nothing.
405
365
  *
406
366
  * 20.9 — THE STREAMABLE-HTTP MCP ENDPOINT, answering JSON-RPC over `POST`, as the request's own
407
- * caller: a handler that takes no args is a resource, read at its address; one that takes args, and
408
- * every mutation, is a tool.
367
+ * caller: every handler it lists is a tool, a read as much as a mutation.
409
368
  */
410
369
  export async function answerMcp(
411
370
  surface: McpSurface,
@@ -1,8 +1,7 @@
1
1
  // 18.7 — THE VERSION A VALUE'S STANDING PRODUCTION IS NAMED BY OVER THE WIRE. Stamped where it is
2
2
  // asked for rather than where a production is minted, so a value no browser follows pays nothing on
3
3
  // its production path. A production is told apart from the one before by the ordinal, what is held
4
- // (6.10 moves no ordinal for `undefined`) and the patch epoch of what is held (5.7 moves no ordinal
5
- // for a patch).
4
+ // and the patch epoch of what is held (5.7 moves no ordinal for a patch).
6
5
  //
7
6
  // ONE COUNTER FOR THE PROCESS, started at the boot time in microseconds, so no two productions share
8
7
  // a number: not two values, not an entry evicted and built again, and not this process and the one
@@ -22,3 +22,9 @@ export const RESERVED_DIRECTORIES: ReadonlySet<string> = new Set([
22
22
  LIB_DIRECTORY,
23
23
  TESTS_DIRECTORY,
24
24
  ])
25
+
26
+ /** 23.16 — the fences a `server` directory nests in: it fences a graph rather than an address. */
27
+ export const HOLDS_SERVER: ReadonlySet<string> = new Set([
28
+ LIB_DIRECTORY,
29
+ TESTS_DIRECTORY,
30
+ ])
@@ -1,14 +1,12 @@
1
- // THE CURSOR. RULEBOOK 2.5: `for await (… of s)` — the production in flight, then each one after
2
- // it. On a streaming producer that is one turn per CHUNK, read from the chunk buffer by ordinal,
3
- // so two chunks between two `await`s are two turns of the loop. On every other value it is the
4
- // standing production: a burst between two turns collapses to the latest, since nothing else is
5
- // held. An app that wants every message keeps them — a channel is an array.
1
+ // THE CURSOR. RULEBOOK 2.6: a streaming producer's chunks, read from the chunk buffer by ordinal,
2
+ // so two chunks between two `await`s are two turns of the loop, and a patch or a write over the
3
+ // stream moves no place (5.7, 9.12). Where the run ENDS is the caller's settling, handed to
4
+ // `closeOn`: `for await` over a `Reactive` is `Reactive.ts`'s `producedRun`.
6
5
  //
7
6
  // A NARROW FACE rather than the `Cell`, the same way `tracking.ts` takes a `Tracked`: what a
8
7
  // cursor reads is a handful of fields, and naming them keeps the cell's internals out of a
9
8
  // second file.
10
9
 
11
- import { PATCHES } from './PATCHES.ts'
12
10
  import {
13
11
  dropReader,
14
12
  newSubscriber,
@@ -23,21 +21,14 @@ import {
23
21
  * place in a stream's chunk buffer without the buffer having to hold one.
24
22
  */
25
23
  export type Retained = Tracked & {
26
- /** The window a `throttle` or a `debounce` holds open, `holding` while a production waits on it. */
27
- cap: { holding: boolean } | null
28
24
  ordinal: number
29
25
  ring: unknown[]
30
26
  ringBase: number
31
- servable: boolean
32
- state: number
33
- /**
34
- * Non-null on a streaming producer, whose ring is the accumulation of the run being served
35
- * (9.1) and is retained whole — the chunks ARE the value.
36
- */
37
- stream: object | null
38
- value: unknown
39
27
  }
40
28
 
29
+ /** What `take` answers when the cursor has caught up — never a chunk, which may be `undefined`. */
30
+ export const CAUGHT_UP: unique symbol = Symbol('caught up')
31
+
41
32
  // The cursors standing over each source, for a patch to keep the place of the ones behind (5.7).
42
33
  const CURSORS = new WeakMap<Retained, Set<Cursor>>()
43
34
 
@@ -70,61 +61,36 @@ export function hasCursorBehind(source: Retained): boolean {
70
61
  }
71
62
 
72
63
  /**
73
- * A live reader of a source's productions, one turn per production, in order.
64
+ * A reader of a stream's chunks, in order, waking whoever waits on it when one lands.
74
65
  *
75
- * A RECORD, NOT A SCOPE. The four closures this was — `take`, `notify`, `stop` and `next` — shared
76
- * one environment holding `at`, `waiting`, `source` and `reader`, and a cursor OUTLIVES the call
77
- * that made it: a `for await` holds one for the life of the loop, so the environment and every
78
- * function object hanging off it is live that whole time. Fields on a prototype are the object
79
- * alone, and `at` — read and written on every turn — becomes a property load on a fixed structure
80
- * rather than a scope load.
66
+ * A RECORD, NOT A SCOPE. A cursor outlives the call that made it — a `for await` holds one for the
67
+ * life of the loop — so fields on a prototype are the object alone, and `at`, read and written on
68
+ * every turn, is a property load on a fixed structure rather than a scope load.
81
69
  */
82
- export class Cursor implements AsyncIterator<unknown> {
70
+ export class Cursor {
83
71
  source: Retained
84
- /** The ordinal of the last production this cursor handed over. */
72
+ /** The ordinal of the last chunk this cursor handed over. */
85
73
  at: number
86
- /**
87
- * The patch epoch the value it last handed over stood at.
88
- *
89
- * A PATCH MINTS NO ORDINAL (5.7) — the follow's version tells a patch from a production by
90
- * exactly that — so a cursor over a value patched in place reads the change off the object.
91
- */
92
- patched: number
93
- /**
94
- * The turns asked for and not yet answered.
95
- *
96
- * A QUEUE rather than one slot because a consumer may ask twice before either answer arrives
97
- * — `Promise.all([ it.next(), it.next() ])` is the shape, and with one slot the second `next`
98
- * overwrote the first's resolver and that promise never settled at all. `for await` asks one
99
- * at a time, which is why nothing here said so.
100
- */
101
- waiting: ((result: IteratorResult<unknown>) => void)[]
74
+ /** Called once at the next wake, then cleared. */
75
+ wake: (() => void) | null
76
+ /** Whether the run `closeOn` was handed has settled. */
77
+ closed: boolean
78
+ /** What that run rejected with. */
79
+ failure: unknown
102
80
  reader: Subscriber
103
81
 
104
82
  constructor(source: Retained, from: number) {
105
83
  this.source = source
106
84
  this.at = from
107
- this.patched = 0
108
- this.waiting = []
109
- // The one closure left, and it captures this cursor and nothing else. `notify` is
110
- // called as a method on the SUBSCRIBER, so it cannot be the cursor's own method —
111
- // and widening `Subscriber` with a back-edge would put a field on the shape every
112
- // reader in the graph carries for the sake of the few that are cursors.
113
- // Registered outside the tracking protocol, so the source it waits on is declared
114
- // rather than discovered — see the same shape in `Reactive`'s `settled`.
85
+ this.wake = null
86
+ this.closed = false
87
+ this.failure = undefined
88
+ // The one closure left, and it captures this cursor and nothing else. `notify` is called as a
89
+ // method on the SUBSCRIBER, so it cannot be the cursor's own method. Registered outside the
90
+ // tracking protocol, so the source it waits on is declared rather than discovered — see the
91
+ // same shape in `Reactive`'s `settled`.
115
92
  this.reader = newSubscriber([source], null)
116
- // One production per waiting turn, in the order they were asked for: a burst that
117
- // lands three productions on a consumer holding two turns answers both.
118
- this.reader.notify = (): void => {
119
- const waiting = this.waiting
120
- while (waiting.length !== 0) {
121
- const next = this.take()
122
- if (next === null) return
123
- ;(waiting.shift() as (result: IteratorResult<unknown>) => void)(
124
- next,
125
- )
126
- }
127
- }
93
+ this.reader.notify = (): void => this.woken()
128
94
  source.readers.push(this.reader)
129
95
  let cursors = CURSORS.get(source)
130
96
  if (cursors === undefined) {
@@ -135,83 +101,60 @@ export class Cursor implements AsyncIterator<unknown> {
135
101
  }
136
102
 
137
103
  /**
138
- * One production, or `null` where the cursor has caught up.
104
+ * The next chunk, or `CAUGHT_UP`.
139
105
  *
140
- * Reading a stream's chunks by ordinal is what keeps a burst from being a gap or a repeat.
106
+ * Reading by ordinal is what keeps a burst from being a gap or a repeat.
141
107
  */
142
- take(): IteratorResult<unknown> | null {
108
+ take(): unknown {
143
109
  const source = this.source
144
- let at = this.at
145
- if (source.stream === null) {
146
- const value = source.value
147
- // 6.10: an `undefined` standing is caught up, not a turn.
148
- if (value === undefined) return null
149
- const patched =
150
- value !== null && typeof value === 'object'
151
- ? (PATCHES.written.get(value) ?? 0)
152
- : 0
153
- if (at >= source.ordinal && patched === this.patched) return null
154
- // 5.9 — A PRODUCTION A WINDOW HOLDS has minted its ordinal and is not yet the value: the
155
- // turn is the release's, which wakes this cursor.
156
- if (source.cap?.holding === true) return null
157
- // The standing production is the only one held, so everything minted or patched
158
- // since the last turn collapses into it.
159
- this.at = source.ordinal
160
- this.patched = patched
161
- return { value, done: false }
162
- }
163
- if (at >= source.ordinal) return null
164
- // A run that restarted moved the buffer past this cursor: answer from its start.
165
- if (at < source.ringBase) at = source.ringBase
166
- const value = source.ring[at - source.ringBase]
167
- this.at = at + 1
168
- return { value, done: false }
169
- }
170
-
171
- next(): Promise<IteratorResult<unknown>> {
172
- if (!this.reader.alive)
173
- return Promise.resolve({ value: undefined, done: true })
174
- // A TURN ALREADY ASKED FOR AND NOT ANSWERED KEEPS THE ORDER: taking from the
175
- // ring here while one is queued would hand the later caller the earlier
176
- // production.
177
- if (this.waiting.length === 0) {
178
- const ready = this.take()
179
- // NOT `async`, and no `await` on the caught-up arm. A cursor behind a
180
- // stream's chunks is the common shape while a burst drains, and an
181
- // `async` here would put a microtask tick between every one of those
182
- // turns.
183
- if (ready !== null) return Promise.resolve(ready)
184
- }
185
- const waiting = this.waiting
186
- return new Promise((resolve) => {
187
- waiting.push(resolve)
188
- })
110
+ if (this.at >= source.ordinal) return CAUGHT_UP
111
+ const value = source.ring[this.at - source.ringBase]
112
+ this.at += 1
113
+ return value
189
114
  }
190
115
 
191
116
  /**
192
- * Ends the cursor: `for await` calls this on `break`, on `return` and on a throw.
117
+ * Ends the cursor's run where `run` settles: `closed` is set, `failure` holds a rejection, and
118
+ * the current `turn` wakes.
193
119
  *
194
- * It is the only thing that drops the subscriber — a live cursor has no end of its own.
120
+ * ONE REACTION ON THE CLOSE FOR THE WHOLE RUN, never one per chunk held until the run ends. A
121
+ * chunk landing in the same turn as the close is still unread when the close wakes the caller,
122
+ * so a drain before reading `closed` hands it over rather than losing it to whichever settled
123
+ * first.
195
124
  */
196
- return(): Promise<IteratorResult<unknown>> {
197
- return this.stop()
125
+ closeOn(run: PromiseLike<unknown>): void {
126
+ run.then(
127
+ () => {
128
+ this.closed = true
129
+ this.woken()
130
+ },
131
+ (error: unknown) => {
132
+ this.closed = true
133
+ this.failure = error
134
+ this.woken()
135
+ },
136
+ )
137
+ }
138
+
139
+ /** Settles at the next chunk or the close, whichever lands first. */
140
+ turn(): Promise<void> {
141
+ return new Promise((resolve) => {
142
+ this.wake = resolve
143
+ })
198
144
  }
199
145
 
200
- throw(): Promise<IteratorResult<unknown>> {
201
- return this.stop()
146
+ private woken(): void {
147
+ const wake = this.wake
148
+ this.wake = null
149
+ wake?.()
202
150
  }
203
151
 
204
- stop(): Promise<IteratorResult<unknown>> {
152
+ /** Drops the subscriber; nothing else does. */
153
+ stop(): void {
205
154
  const reader = this.reader
206
155
  reader.alive = false
207
156
  dropReader(this.source, reader)
208
157
  CURSORS.get(this.source)?.delete(this)
209
- const waiting = this.waiting
210
- while (waiting.length !== 0)
211
- (waiting.shift() as (result: IteratorResult<unknown>) => void)({
212
- value: undefined,
213
- done: true,
214
- })
215
- return Promise.resolve({ value: undefined, done: true })
158
+ this.wake = null
216
159
  }
217
160
  }