test-helpers.ts499 lines · main
| 1 | // Shared harness for runtime integration tests. Materializes a real |
| 2 | // on-disk bundle, spawns a real Deno isolate via the same `bunChildSpawn` |
| 3 | // adapter the production bootstrap uses, runs ONE invocation, returns the |
| 4 | // `InvokeResult` plus a teardown callback. |
| 5 | // |
| 6 | // Tasks 17–22 all share this helper. Keep it test-agnostic: no test-name |
| 7 | // branching, no hard-coded fixtures beyond the "@briven/cli/server" stub. |
| 8 | |
| 9 | import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; |
| 10 | import { tmpdir } from 'node:os'; |
| 11 | import { join, resolve } from 'node:path'; |
| 12 | |
| 13 | import { spawn as bunSpawn } from 'bun'; |
| 14 | |
| 15 | import { IsolatePoolImpl, type SpawnFn, type SpawnedChild } from '../../src/pool-manager.js'; |
| 16 | import type { Bundle, InvokeRequest, InvokeResult } from '../../src/types.js'; |
| 17 | |
| 18 | // Resolve the Deno binary at fixture time (not module load) so each test |
| 19 | // can override via env. Falls through to the default install path on the |
| 20 | // dev machine, then to bare `deno` on PATH (CI). |
| 21 | function resolveDenoPath(): string { |
| 22 | return process.env.BRIVEN_RUNTIME_DENO_PATH ?? '/Users/flndrn/.deno/bin/deno'; |
| 23 | } |
| 24 | |
| 25 | export interface FixtureOpts { |
| 26 | /** Source code for the customer function. Written to briven/functions/<fnName>.ts */ |
| 27 | fnSource: string; |
| 28 | /** Function name (matches the export and the file name). */ |
| 29 | fnName: string; |
| 30 | /** Deployment ID echoed in the ready handshake. */ |
| 31 | deploymentId: string; |
| 32 | /** Optional: extra config overrides on the pool. */ |
| 33 | poolConfig?: Partial<{ |
| 34 | invocationTimeoutMs: number; |
| 35 | idleKillMs: number; |
| 36 | maxInvocationsPerIsolate: number; |
| 37 | maxMemoryMb: number; |
| 38 | maxIsolates: number; |
| 39 | crashLoopThreshold: number; |
| 40 | crashLoopWindowMs: number; |
| 41 | }>; |
| 42 | /** Optional: surface isolate stderr lines (for debugging a failing test). */ |
| 43 | onLog?: (line: unknown, projectId: string) => void; |
| 44 | /** Optional: project env vars exposed via Deno.env (allow-env list). */ |
| 45 | projectEnv?: Record<string, string>; |
| 46 | } |
| 47 | |
| 48 | export interface FixtureResult { |
| 49 | result: InvokeResult; |
| 50 | pool: IsolatePoolImpl; |
| 51 | cleanup: () => Promise<void>; |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Materialize a fake bundle on disk, construct a pool against the real |
| 56 | * Deno binary, run one invoke, return the result + the pool (for tests |
| 57 | * that want to inspect state) + a cleanup callback. |
| 58 | * |
| 59 | * The caller MUST call `cleanup()` even on error (use try/finally). |
| 60 | */ |
| 61 | export async function runIntegrationFixture(opts: FixtureOpts): Promise<FixtureResult> { |
| 62 | const workDir = await mkdtemp(join(tmpdir(), 'briven-int-')); |
| 63 | const bundleDir = join(workDir, 'bundle'); |
| 64 | const isolateBase = join(workDir, 'isolates'); |
| 65 | const runtimeStubDir = join(workDir, 'stub'); |
| 66 | |
| 67 | // Vendor the real isolate-runtime stubs into the work dir. The |
| 68 | // materializer will copy them again into the per-isolate tmp dir, but |
| 69 | // it expects them to live under `runtimeStubDir`. |
| 70 | const realStubDir = resolve(import.meta.dir, '..', '..', 'src', 'isolate-runtime'); |
| 71 | await mkdir(runtimeStubDir, { recursive: true }); |
| 72 | for (const f of ['loop.ts', 'server.ts', 'types.ts']) { |
| 73 | await copyFile(join(realStubDir, f), join(runtimeStubDir, f)); |
| 74 | } |
| 75 | |
| 76 | // Customer function source. |
| 77 | await mkdir(join(bundleDir, 'functions'), { recursive: true }); |
| 78 | await writeFile(join(bundleDir, 'functions', `${opts.fnName}.ts`), opts.fnSource); |
| 79 | |
| 80 | await mkdir(isolateBase, { recursive: true }); |
| 81 | |
| 82 | const bundle: Bundle = { |
| 83 | projectId: 'p-int', |
| 84 | deploymentId: opts.deploymentId, |
| 85 | functionNames: [opts.fnName], |
| 86 | directory: bundleDir, |
| 87 | }; |
| 88 | const request: InvokeRequest = { |
| 89 | projectId: 'p-int', |
| 90 | functionName: opts.fnName, |
| 91 | args: {}, |
| 92 | deploymentId: opts.deploymentId, |
| 93 | requestId: `req-${Date.now()}`, |
| 94 | auth: null, |
| 95 | }; |
| 96 | |
| 97 | const denoPath = resolveDenoPath(); |
| 98 | const spawn = makeBunChildSpawn(denoPath); |
| 99 | |
| 100 | const pool = new IsolatePoolImpl({ |
| 101 | spawn, |
| 102 | runtimeStubDir, |
| 103 | isolateBaseDir: isolateBase, |
| 104 | maxIsolates: opts.poolConfig?.maxIsolates ?? 50, |
| 105 | maxMemoryMb: opts.poolConfig?.maxMemoryMb ?? 128, |
| 106 | invocationTimeoutMs: opts.poolConfig?.invocationTimeoutMs ?? 30_000, |
| 107 | idleKillMs: opts.poolConfig?.idleKillMs ?? 10 * 60_000, |
| 108 | maxInvocationsPerIsolate: opts.poolConfig?.maxInvocationsPerIsolate ?? 1000, |
| 109 | crashLoopThreshold: opts.poolConfig?.crashLoopThreshold ?? 3, |
| 110 | crashLoopWindowMs: opts.poolConfig?.crashLoopWindowMs ?? 60_000, |
| 111 | runQueryProxy: async () => [], |
| 112 | onLog: (line, projectId) => { |
| 113 | if (opts.onLog) opts.onLog(line, projectId); |
| 114 | }, |
| 115 | loadProjectEnv: async () => opts.projectEnv ?? {}, |
| 116 | denoPath, |
| 117 | }); |
| 118 | |
| 119 | const result = await pool.invoke(bundle, request); |
| 120 | |
| 121 | const cleanup = async () => { |
| 122 | await pool.shutdown(); |
| 123 | await rm(workDir, { recursive: true, force: true }); |
| 124 | }; |
| 125 | |
| 126 | return { result, pool, cleanup }; |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Adapter that maps Bun's `spawn` API onto the `SpawnFn` interface the |
| 131 | * pool expects. Mirrors `apps/runtime/src/runtime-bootstrap.ts` — |
| 132 | * intentionally duplicated for now (Phase 2 may extract a shared helper). |
| 133 | */ |
| 134 | function makeBunChildSpawn(denoPath: string): SpawnFn { |
| 135 | return async ({ args, env: childEnv, cwd }) => { |
| 136 | const proc = bunSpawn({ |
| 137 | cmd: [denoPath, ...args], |
| 138 | cwd, |
| 139 | env: childEnv, |
| 140 | stdin: 'pipe', |
| 141 | stdout: 'pipe', |
| 142 | stderr: 'pipe', |
| 143 | }); |
| 144 | |
| 145 | const stdoutLines = lineIterator(proc.stdout as ReadableStream<Uint8Array>); |
| 146 | const stderrLines = lineIterator(proc.stderr as ReadableStream<Uint8Array>); |
| 147 | |
| 148 | const child: SpawnedChild = { |
| 149 | pid: proc.pid, |
| 150 | stdin: { |
| 151 | write: async (line: string) => { |
| 152 | const n = proc.stdin.write(line); |
| 153 | await proc.stdin.flush(); |
| 154 | return typeof n === 'number' ? n > 0 : Boolean(n); |
| 155 | }, |
| 156 | end: () => proc.stdin.end(), |
| 157 | }, |
| 158 | stdout: { |
| 159 | next: () => stdoutLines.next().then((r) => (r.done ? null : r.value)), |
| 160 | }, |
| 161 | stderr: { |
| 162 | next: () => stderrLines.next().then((r) => (r.done ? null : r.value)), |
| 163 | }, |
| 164 | wait: async () => { |
| 165 | const exitCode = await proc.exited; |
| 166 | return { exitCode, signal: null }; |
| 167 | }, |
| 168 | kill: (signal: string) => proc.kill(signal as never), |
| 169 | }; |
| 170 | return child; |
| 171 | }; |
| 172 | } |
| 173 | |
| 174 | // --------------------------------------------------------------------------- |
| 175 | // Multi-invocation helpers — used by Tasks 20–22. |
| 176 | // |
| 177 | // All three reuse the same per-test scratch dir layout `runIntegrationFixture` |
| 178 | // builds (one workDir, one runtimeStubDir, one isolateBase). They diverge only |
| 179 | // on how many bundles they materialize and how many invokes they fire against |
| 180 | // the same pool. |
| 181 | // --------------------------------------------------------------------------- |
| 182 | |
| 183 | /** Vendor stub files into runtimeStubDir. Mirrors `runIntegrationFixture`. */ |
| 184 | async function vendorRuntimeStubs(runtimeStubDir: string): Promise<void> { |
| 185 | await mkdir(runtimeStubDir, { recursive: true }); |
| 186 | const realStubDir = resolve(import.meta.dir, '..', '..', 'src', 'isolate-runtime'); |
| 187 | for (const f of ['loop.ts', 'server.ts', 'types.ts']) { |
| 188 | await copyFile(join(realStubDir, f), join(runtimeStubDir, f)); |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Wrap a SpawnFn so each spawn's `child.pid` is recorded into `sink`. |
| 194 | * Used by `runTwoSequentialInvocations` and `runIdleKillFixture` to prove |
| 195 | * a respawn happened (different PID). |
| 196 | */ |
| 197 | function withPidRecorder(inner: SpawnFn, sink: number[]): SpawnFn { |
| 198 | return async (opts) => { |
| 199 | const child = await inner(opts); |
| 200 | sink.push(child.pid); |
| 201 | return child; |
| 202 | }; |
| 203 | } |
| 204 | |
| 205 | export interface TwoInvocationsOpts { |
| 206 | first: { fnSource: string; fnName: string; deploymentId: string }; |
| 207 | second: { fnSource: string; fnName: string; deploymentId: string }; |
| 208 | poolConfig?: FixtureOpts['poolConfig']; |
| 209 | } |
| 210 | |
| 211 | export interface TwoInvocationsResult { |
| 212 | first: InvokeResult; |
| 213 | second: InvokeResult; |
| 214 | /** PIDs observed across the two invocations. Should be 2 distinct values for deploy invalidation. */ |
| 215 | pidsObserved: number[]; |
| 216 | cleanup: () => Promise<void>; |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * Two sequential invocations against the SAME pool/projectId, with two |
| 221 | * different `deploymentId`s and two separately-materialized bundles. The |
| 222 | * second invocation triggers deploy-invalidation: the first isolate is |
| 223 | * retired and a fresh one cold-starts. PIDs are tracked via a wrapped |
| 224 | * SpawnFn so the test can assert two distinct PIDs. |
| 225 | */ |
| 226 | export async function runTwoSequentialInvocations( |
| 227 | opts: TwoInvocationsOpts, |
| 228 | ): Promise<TwoInvocationsResult> { |
| 229 | const workDir = await mkdtemp(join(tmpdir(), 'briven-int-')); |
| 230 | const bundleDirA = join(workDir, 'bundle-a'); |
| 231 | const bundleDirB = join(workDir, 'bundle-b'); |
| 232 | const isolateBase = join(workDir, 'isolates'); |
| 233 | const runtimeStubDir = join(workDir, 'stub'); |
| 234 | await vendorRuntimeStubs(runtimeStubDir); |
| 235 | |
| 236 | await mkdir(join(bundleDirA, 'functions'), { recursive: true }); |
| 237 | await writeFile(join(bundleDirA, 'functions', `${opts.first.fnName}.ts`), opts.first.fnSource); |
| 238 | await mkdir(join(bundleDirB, 'functions'), { recursive: true }); |
| 239 | await writeFile(join(bundleDirB, 'functions', `${opts.second.fnName}.ts`), opts.second.fnSource); |
| 240 | await mkdir(isolateBase, { recursive: true }); |
| 241 | |
| 242 | const denoPath = resolveDenoPath(); |
| 243 | const pidsObserved: number[] = []; |
| 244 | const spawn = withPidRecorder(makeBunChildSpawn(denoPath), pidsObserved); |
| 245 | |
| 246 | const pool = new IsolatePoolImpl({ |
| 247 | spawn, |
| 248 | runtimeStubDir, |
| 249 | isolateBaseDir: isolateBase, |
| 250 | maxIsolates: opts.poolConfig?.maxIsolates ?? 50, |
| 251 | maxMemoryMb: opts.poolConfig?.maxMemoryMb ?? 128, |
| 252 | invocationTimeoutMs: opts.poolConfig?.invocationTimeoutMs ?? 30_000, |
| 253 | idleKillMs: opts.poolConfig?.idleKillMs ?? 10 * 60_000, |
| 254 | maxInvocationsPerIsolate: opts.poolConfig?.maxInvocationsPerIsolate ?? 1000, |
| 255 | crashLoopThreshold: opts.poolConfig?.crashLoopThreshold ?? 3, |
| 256 | crashLoopWindowMs: opts.poolConfig?.crashLoopWindowMs ?? 60_000, |
| 257 | runQueryProxy: async () => [], |
| 258 | onLog: () => {}, |
| 259 | loadProjectEnv: async () => ({}), |
| 260 | denoPath, |
| 261 | }); |
| 262 | |
| 263 | const bundleA: Bundle = { |
| 264 | projectId: 'p-int', |
| 265 | deploymentId: opts.first.deploymentId, |
| 266 | functionNames: [opts.first.fnName], |
| 267 | directory: bundleDirA, |
| 268 | }; |
| 269 | const requestA: InvokeRequest = { |
| 270 | projectId: 'p-int', |
| 271 | functionName: opts.first.fnName, |
| 272 | args: {}, |
| 273 | deploymentId: opts.first.deploymentId, |
| 274 | requestId: `req-${Date.now()}-a`, |
| 275 | auth: null, |
| 276 | }; |
| 277 | const first = await pool.invoke(bundleA, requestA); |
| 278 | |
| 279 | const bundleB: Bundle = { |
| 280 | projectId: 'p-int', |
| 281 | deploymentId: opts.second.deploymentId, |
| 282 | functionNames: [opts.second.fnName], |
| 283 | directory: bundleDirB, |
| 284 | }; |
| 285 | const requestB: InvokeRequest = { |
| 286 | projectId: 'p-int', |
| 287 | functionName: opts.second.fnName, |
| 288 | args: {}, |
| 289 | deploymentId: opts.second.deploymentId, |
| 290 | requestId: `req-${Date.now()}-b`, |
| 291 | auth: null, |
| 292 | }; |
| 293 | const second = await pool.invoke(bundleB, requestB); |
| 294 | |
| 295 | const cleanup = async () => { |
| 296 | await pool.shutdown(); |
| 297 | await rm(workDir, { recursive: true, force: true }); |
| 298 | }; |
| 299 | return { first, second, pidsObserved, cleanup }; |
| 300 | } |
| 301 | |
| 302 | export interface RepeatedFixtureOpts { |
| 303 | fnName: string; |
| 304 | fnSource: string; |
| 305 | deploymentId: string; |
| 306 | count: number; |
| 307 | poolConfig?: FixtureOpts['poolConfig']; |
| 308 | } |
| 309 | |
| 310 | export interface RepeatedFixtureResult { |
| 311 | results: InvokeResult[]; |
| 312 | cleanup: () => Promise<void>; |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Run `count` sequential invocations against the same pool, projectId, |
| 317 | * deploymentId, and bundle. Used by the crash-loop breaker test so the |
| 318 | * breaker history accumulates across all calls. |
| 319 | */ |
| 320 | export async function runFixtureRepeated( |
| 321 | opts: RepeatedFixtureOpts, |
| 322 | ): Promise<RepeatedFixtureResult> { |
| 323 | const workDir = await mkdtemp(join(tmpdir(), 'briven-int-')); |
| 324 | const bundleDir = join(workDir, 'bundle'); |
| 325 | const isolateBase = join(workDir, 'isolates'); |
| 326 | const runtimeStubDir = join(workDir, 'stub'); |
| 327 | await vendorRuntimeStubs(runtimeStubDir); |
| 328 | |
| 329 | await mkdir(join(bundleDir, 'functions'), { recursive: true }); |
| 330 | await writeFile(join(bundleDir, 'functions', `${opts.fnName}.ts`), opts.fnSource); |
| 331 | await mkdir(isolateBase, { recursive: true }); |
| 332 | |
| 333 | const denoPath = resolveDenoPath(); |
| 334 | const spawn = makeBunChildSpawn(denoPath); |
| 335 | |
| 336 | const pool = new IsolatePoolImpl({ |
| 337 | spawn, |
| 338 | runtimeStubDir, |
| 339 | isolateBaseDir: isolateBase, |
| 340 | maxIsolates: opts.poolConfig?.maxIsolates ?? 50, |
| 341 | maxMemoryMb: opts.poolConfig?.maxMemoryMb ?? 128, |
| 342 | invocationTimeoutMs: opts.poolConfig?.invocationTimeoutMs ?? 30_000, |
| 343 | idleKillMs: opts.poolConfig?.idleKillMs ?? 10 * 60_000, |
| 344 | maxInvocationsPerIsolate: opts.poolConfig?.maxInvocationsPerIsolate ?? 1000, |
| 345 | crashLoopThreshold: opts.poolConfig?.crashLoopThreshold ?? 3, |
| 346 | crashLoopWindowMs: opts.poolConfig?.crashLoopWindowMs ?? 60_000, |
| 347 | runQueryProxy: async () => [], |
| 348 | onLog: () => {}, |
| 349 | loadProjectEnv: async () => ({}), |
| 350 | denoPath, |
| 351 | }); |
| 352 | |
| 353 | const bundle: Bundle = { |
| 354 | projectId: 'p-int', |
| 355 | deploymentId: opts.deploymentId, |
| 356 | functionNames: [opts.fnName], |
| 357 | directory: bundleDir, |
| 358 | }; |
| 359 | |
| 360 | const results: InvokeResult[] = []; |
| 361 | for (let i = 0; i < opts.count; i++) { |
| 362 | const request: InvokeRequest = { |
| 363 | projectId: 'p-int', |
| 364 | functionName: opts.fnName, |
| 365 | args: {}, |
| 366 | deploymentId: opts.deploymentId, |
| 367 | requestId: `req-${Date.now()}-${i}`, |
| 368 | auth: null, |
| 369 | }; |
| 370 | results.push(await pool.invoke(bundle, request)); |
| 371 | } |
| 372 | |
| 373 | const cleanup = async () => { |
| 374 | await pool.shutdown(); |
| 375 | await rm(workDir, { recursive: true, force: true }); |
| 376 | }; |
| 377 | return { results, cleanup }; |
| 378 | } |
| 379 | |
| 380 | export interface IdleKillFixtureOpts { |
| 381 | fnSource: string; |
| 382 | fnName: string; |
| 383 | deploymentId: string; |
| 384 | /** Idle threshold in ms; the helper waits 2x this between invokes before triggering the sweeper. */ |
| 385 | idleKillMs: number; |
| 386 | } |
| 387 | |
| 388 | export interface IdleKillFixtureResult { |
| 389 | firstPid: number; |
| 390 | secondPid: number; |
| 391 | cleanup: () => Promise<void>; |
| 392 | } |
| 393 | |
| 394 | /** |
| 395 | * Two sequential invocations against the same pool with the idle sweeper |
| 396 | * triggered between them, proving the first isolate gets retired and the |
| 397 | * second invocation cold-starts a fresh process. |
| 398 | */ |
| 399 | export async function runIdleKillFixture( |
| 400 | opts: IdleKillFixtureOpts, |
| 401 | ): Promise<IdleKillFixtureResult> { |
| 402 | const workDir = await mkdtemp(join(tmpdir(), 'briven-int-')); |
| 403 | const bundleDir = join(workDir, 'bundle'); |
| 404 | const isolateBase = join(workDir, 'isolates'); |
| 405 | const runtimeStubDir = join(workDir, 'stub'); |
| 406 | await vendorRuntimeStubs(runtimeStubDir); |
| 407 | |
| 408 | await mkdir(join(bundleDir, 'functions'), { recursive: true }); |
| 409 | await writeFile(join(bundleDir, 'functions', `${opts.fnName}.ts`), opts.fnSource); |
| 410 | await mkdir(isolateBase, { recursive: true }); |
| 411 | |
| 412 | const denoPath = resolveDenoPath(); |
| 413 | const pidsObserved: number[] = []; |
| 414 | const spawn = withPidRecorder(makeBunChildSpawn(denoPath), pidsObserved); |
| 415 | |
| 416 | const pool = new IsolatePoolImpl({ |
| 417 | spawn, |
| 418 | runtimeStubDir, |
| 419 | isolateBaseDir: isolateBase, |
| 420 | maxIsolates: 50, |
| 421 | maxMemoryMb: 128, |
| 422 | invocationTimeoutMs: 30_000, |
| 423 | idleKillMs: opts.idleKillMs, |
| 424 | maxInvocationsPerIsolate: 1000, |
| 425 | crashLoopThreshold: 3, |
| 426 | crashLoopWindowMs: 60_000, |
| 427 | runQueryProxy: async () => [], |
| 428 | onLog: () => {}, |
| 429 | loadProjectEnv: async () => ({}), |
| 430 | denoPath, |
| 431 | }); |
| 432 | |
| 433 | const bundle: Bundle = { |
| 434 | projectId: 'p-int', |
| 435 | deploymentId: opts.deploymentId, |
| 436 | functionNames: [opts.fnName], |
| 437 | directory: bundleDir, |
| 438 | }; |
| 439 | const makeRequest = (suffix: string): InvokeRequest => ({ |
| 440 | projectId: 'p-int', |
| 441 | functionName: opts.fnName, |
| 442 | args: {}, |
| 443 | deploymentId: opts.deploymentId, |
| 444 | requestId: `req-${Date.now()}-${suffix}`, |
| 445 | auth: null, |
| 446 | }); |
| 447 | |
| 448 | const first = await pool.invoke(bundle, makeRequest('a')); |
| 449 | if (!first.ok) { |
| 450 | await pool.shutdown(); |
| 451 | await rm(workDir, { recursive: true, force: true }); |
| 452 | throw new Error( |
| 453 | `idle-kill helper: first invoke failed with code=${first.code} message=${first.message}`, |
| 454 | ); |
| 455 | } |
| 456 | const firstPid = pidsObserved[0] ?? -1; |
| 457 | |
| 458 | // Wait long enough that the entry's lastActivityAt is older than idleKillMs. |
| 459 | await new Promise((r) => setTimeout(r, Math.max(opts.idleKillMs * 2, 50))); |
| 460 | await pool.triggerIdleCheck(); |
| 461 | |
| 462 | const second = await pool.invoke(bundle, makeRequest('b')); |
| 463 | if (!second.ok) { |
| 464 | await pool.shutdown(); |
| 465 | await rm(workDir, { recursive: true, force: true }); |
| 466 | throw new Error( |
| 467 | `idle-kill helper: second invoke failed with code=${second.code} message=${second.message}`, |
| 468 | ); |
| 469 | } |
| 470 | const secondPid = pidsObserved[1] ?? -1; |
| 471 | |
| 472 | const cleanup = async () => { |
| 473 | await pool.shutdown(); |
| 474 | await rm(workDir, { recursive: true, force: true }); |
| 475 | }; |
| 476 | return { firstPid, secondPid, cleanup }; |
| 477 | } |
| 478 | |
| 479 | async function* lineIterator( |
| 480 | stream: ReadableStream<Uint8Array>, |
| 481 | ): AsyncGenerator<string, void, void> { |
| 482 | const reader = stream.getReader(); |
| 483 | const decoder = new TextDecoder(); |
| 484 | let buf = ''; |
| 485 | while (true) { |
| 486 | const { value, done } = await reader.read(); |
| 487 | if (done) { |
| 488 | if (buf.length > 0) yield buf; |
| 489 | return; |
| 490 | } |
| 491 | buf += decoder.decode(value, { stream: true }); |
| 492 | let nl: number; |
| 493 | while ((nl = buf.indexOf('\n')) !== -1) { |
| 494 | const line = buf.slice(0, nl); |
| 495 | buf = buf.slice(nl + 1); |
| 496 | if (line) yield line; |
| 497 | } |
| 498 | } |
| 499 | } |