bunderstack 0.4.0 → 0.6.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.
@@ -0,0 +1,28 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+
3
+ function canonical(taskId: string, slot: number): string {
4
+ return `${taskId}\n${slot}`
5
+ }
6
+
7
+ export function signScheduleRequest(
8
+ secret: string,
9
+ taskId: string,
10
+ slot: number,
11
+ ): string {
12
+ const digest = createHmac('sha256', secret)
13
+ .update(canonical(taskId, slot))
14
+ .digest('hex')
15
+ return `sha256=${digest}`
16
+ }
17
+
18
+ export function verifyScheduleRequest(
19
+ secret: string,
20
+ taskId: string,
21
+ slot: number,
22
+ signature: string,
23
+ ): boolean {
24
+ if (!/^sha256=[0-9a-f]{64}$/.test(signature)) return false
25
+ const expected = Buffer.from(signScheduleRequest(secret, taskId, slot))
26
+ const received = Buffer.from(signature)
27
+ return timingSafeEqual(expected, received)
28
+ }
@@ -0,0 +1,131 @@
1
+ import { Hono } from 'hono'
2
+ import { and, eq, lt } from 'drizzle-orm'
3
+
4
+ import type { AnyDb } from '../dialect'
5
+ import type { BackgroundDefs } from './define'
6
+
7
+ import { verifyScheduleRequest } from './cron-auth'
8
+ import { runCronSlot, runScheduledSlot } from './cron-runner'
9
+ import { cronRunsTableFor } from '../internal-tables'
10
+
11
+ const MAX_SLOT_AGE_MS = 60 * 60_000
12
+ const MAX_FUTURE_SLOT_MS = 60_000
13
+ const STORAGE_SWEEP_SCHEDULE = '0 4 * * *'
14
+ const SCHEDULED_RUN_RETENTION_MS = 30 * 24 * 60 * 60_000
15
+
16
+ export function buildCronRouter(args: {
17
+ db: AnyDb
18
+ defs: BackgroundDefs
19
+ ctx: Record<string, unknown>
20
+ secret: string
21
+ storage: { sweep: () => Promise<unknown> }
22
+ now?: () => number
23
+ }): Hono {
24
+ const app = new Hono()
25
+ const now = args.now ?? Date.now
26
+
27
+ app.post('/cron/:name', async (c) => {
28
+ const name = c.req.param('name')
29
+ const slotText = c.req.header('X-Bunderstack-Cron-Slot')
30
+ const signature = c.req.header('X-Bunderstack-Cron-Signature')
31
+ const slot = Number(slotText)
32
+ if (!slotText || !Number.isSafeInteger(slot) || !signature) {
33
+ return c.json({ error: 'invalid schedule signature' }, 401)
34
+ }
35
+ if (!verifyScheduleRequest(args.secret, `cron:${name}`, slot, signature)) {
36
+ return c.json({ error: 'invalid schedule signature' }, 401)
37
+ }
38
+ const definition = args.defs[name]
39
+ if (!definition || definition.kind !== 'cron') {
40
+ return c.json({ error: 'unknown cron' }, 404)
41
+ }
42
+ const current = now()
43
+ if (
44
+ slot % 60_000 !== 0 ||
45
+ slot < current - MAX_SLOT_AGE_MS ||
46
+ slot > current + MAX_FUTURE_SLOT_MS
47
+ ) {
48
+ return c.json({ error: 'invalid cron slot' }, 400)
49
+ }
50
+ try {
51
+ const result = await runCronSlot({
52
+ db: args.db,
53
+ defs: args.defs,
54
+ ctx: args.ctx,
55
+ name,
56
+ slot,
57
+ now: current,
58
+ })
59
+ return c.json(
60
+ result,
61
+ result.status === 'running' ? 202 : 200,
62
+ )
63
+ } catch (error) {
64
+ if (
65
+ error instanceof Error &&
66
+ error.message === '[bunderstack] cron slot does not match its schedule'
67
+ ) {
68
+ return c.json({ error: 'invalid cron slot' }, 400)
69
+ }
70
+ return c.json({ error: 'cron handler failed' }, 500)
71
+ }
72
+ })
73
+
74
+ app.post('/maintenance/:name', async (c) => {
75
+ const name = c.req.param('name')
76
+ const slotText = c.req.header('X-Bunderstack-Cron-Slot')
77
+ const signature = c.req.header('X-Bunderstack-Cron-Signature')
78
+ const slot = Number(slotText)
79
+ if (!slotText || !Number.isSafeInteger(slot) || !signature) {
80
+ return c.json({ error: 'invalid schedule signature' }, 401)
81
+ }
82
+ if (!verifyScheduleRequest(args.secret, `maintenance:${name}`, slot, signature)) {
83
+ return c.json({ error: 'invalid schedule signature' }, 401)
84
+ }
85
+ if (name !== 'storage-sweep') {
86
+ return c.json({ error: 'unknown maintenance task' }, 404)
87
+ }
88
+ const current = now()
89
+ if (
90
+ slot % 60_000 !== 0 ||
91
+ slot < current - MAX_SLOT_AGE_MS ||
92
+ slot > current + MAX_FUTURE_SLOT_MS
93
+ ) {
94
+ return c.json({ error: 'invalid cron slot' }, 400)
95
+ }
96
+ try {
97
+ const result = await runScheduledSlot({
98
+ db: args.db,
99
+ taskId: 'maintenance:storage-sweep',
100
+ schedule: STORAGE_SWEEP_SCHEDULE,
101
+ slot,
102
+ now: current,
103
+ run: async () => {
104
+ await args.storage.sweep()
105
+ },
106
+ })
107
+ if (result.status === 'succeeded') {
108
+ const t = cronRunsTableFor(args.db)
109
+ await args.db
110
+ .delete(t)
111
+ .where(
112
+ and(
113
+ eq(t.status, 'succeeded'),
114
+ lt(t.finishedAt, current - SCHEDULED_RUN_RETENTION_MS),
115
+ ),
116
+ )
117
+ }
118
+ return c.json(result, result.status === 'running' ? 202 : 200)
119
+ } catch (error) {
120
+ if (
121
+ error instanceof Error &&
122
+ error.message === '[bunderstack] cron slot does not match its schedule'
123
+ ) {
124
+ return c.json({ error: 'invalid cron slot' }, 400)
125
+ }
126
+ return c.json({ error: 'maintenance handler failed' }, 500)
127
+ }
128
+ })
129
+
130
+ return app
131
+ }
@@ -0,0 +1,112 @@
1
+ import { and, eq, lt, or, sql } from 'drizzle-orm'
2
+
3
+ import type { AnyDb } from '../dialect'
4
+ import type { BackgroundDefs } from './define'
5
+
6
+ import { cronRunsTableFor } from '../internal-tables'
7
+ import { cronMatches, parseCron } from './cron'
8
+
9
+ const LEASE_MS = 60_000
10
+
11
+ export type CronRunResult =
12
+ | { status: 'succeeded' }
13
+ | { status: 'duplicate' }
14
+ | { status: 'running' }
15
+
16
+ export async function runScheduledSlot(args: {
17
+ db: AnyDb
18
+ taskId: string
19
+ schedule: string
20
+ slot: number
21
+ now: number
22
+ run: (scheduledFor: Date) => Promise<void> | void
23
+ }): Promise<CronRunResult> {
24
+ const { db, taskId, schedule, slot, now, run } = args
25
+ if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
26
+ throw new Error('[bunderstack] cron slot does not match its schedule')
27
+ }
28
+
29
+ const t = cronRunsTableFor(db)
30
+ const leaseUntil = now + LEASE_MS
31
+ const inserted = await db
32
+ .insert(t)
33
+ .values({
34
+ taskId,
35
+ scheduledAt: slot,
36
+ status: 'running',
37
+ attempts: 1,
38
+ lockedUntil: leaseUntil,
39
+ startedAt: now,
40
+ })
41
+ .onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
42
+ .returning({ taskId: t.taskId })
43
+
44
+ if (!inserted[0]) {
45
+ const existing = await db
46
+ .select({ status: t.status, lockedUntil: t.lockedUntil })
47
+ .from(t)
48
+ .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
49
+ .limit(1)
50
+ const row = existing[0]
51
+ if (!row || row.status === 'succeeded') return { status: 'duplicate' }
52
+ if (row.status === 'running' && Number(row.lockedUntil) >= now) {
53
+ return { status: 'running' }
54
+ }
55
+ const reclaimed = await db
56
+ .update(t)
57
+ .set({
58
+ status: 'running',
59
+ lockedUntil: leaseUntil,
60
+ startedAt: now,
61
+ attempts: sql`${t.attempts} + 1`,
62
+ lastError: null,
63
+ })
64
+ .where(
65
+ and(
66
+ eq(t.taskId, taskId),
67
+ eq(t.scheduledAt, slot),
68
+ or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
69
+ ),
70
+ )
71
+ .returning({ taskId: t.taskId })
72
+ if (!reclaimed[0]) return { status: 'running' }
73
+ }
74
+
75
+ try {
76
+ await run(new Date(slot))
77
+ await db
78
+ .update(t)
79
+ .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
80
+ .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
81
+ return { status: 'succeeded' }
82
+ } catch (error) {
83
+ const message = error instanceof Error ? error.message : String(error)
84
+ await db
85
+ .update(t)
86
+ .set({ status: 'failed', lockedUntil: null, lastError: message, finishedAt: Date.now() })
87
+ .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
88
+ throw error
89
+ }
90
+ }
91
+
92
+ export async function runCronSlot(args: {
93
+ db: AnyDb
94
+ defs: BackgroundDefs
95
+ ctx: Record<string, unknown>
96
+ name: string
97
+ slot: number
98
+ now: number
99
+ }): Promise<CronRunResult> {
100
+ const definition = args.defs[args.name]
101
+ if (!definition || definition.kind !== 'cron') {
102
+ throw new Error(`[bunderstack] unknown cron "${args.name}"`)
103
+ }
104
+ return runScheduledSlot({
105
+ db: args.db,
106
+ taskId: `cron:${args.name}`,
107
+ schedule: definition.schedule,
108
+ slot: args.slot,
109
+ now: args.now,
110
+ run: (scheduledFor) => definition.handler({ scheduledFor }, args.ctx as never),
111
+ })
112
+ }
@@ -13,7 +13,7 @@ export const DEFAULT_RETRIES = 3
13
13
  export const DEFAULT_TIMEOUT_MS = 60_000
14
14
 
15
15
  export type EnqueueOptions = {
16
- /** Collapse duplicate enqueues; see spec for cron vs non-cron lifetime. */
16
+ /** Collapse duplicate enqueues while the queue row is non-terminal. */
17
17
  dedupeKey?: string
18
18
  /** Milliseconds from now until the job becomes claimable. */
19
19
  delay?: number
@@ -46,11 +46,12 @@ export type JobContext<
46
46
  jobs: JobsRuntimeFacade
47
47
  }
48
48
 
49
- export type JobDefinition<
49
+ export type QueueJobDefinition<
50
50
  TInput,
51
51
  TSchema extends Record<string, unknown> = Record<string, unknown>,
52
52
  TEnvResult = Record<string, unknown>,
53
53
  > = {
54
+ kind: 'job'
54
55
  /** zod schema for the payload; parsed at enqueue AND before the handler runs. */
55
56
  input?: ZodType<TInput>
56
57
  /** Attempts after the first failure. Default 3 (so 4 total attempts). */
@@ -61,8 +62,6 @@ export type JobDefinition<
61
62
  concurrency?: number
62
63
  /** Lease duration in ms; an expired lease sends the job back to pending. */
63
64
  timeout?: number
64
- /** 5-field UTC cron expression. Cron jobs cannot declare `input`. */
65
- cron?: string
66
65
  handler: (
67
66
  input: TInput,
68
67
  ctx: JobContext<TSchema, TEnvResult>,
@@ -75,23 +74,51 @@ export type JobDefinition<
75
74
  ) => Promise<void> | void
76
75
  }
77
76
 
77
+ export type CronInvocation = { scheduledFor: Date }
78
+
79
+ export type CronDefinition<
80
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
81
+ TEnvResult = Record<string, unknown>,
82
+ > = {
83
+ kind: 'cron'
84
+ schedule: string
85
+ handler: (
86
+ invocation: CronInvocation,
87
+ ctx: JobContext<TSchema, TEnvResult>,
88
+ ) => Promise<void> | void
89
+ }
90
+
91
+ export type BackgroundDefinition =
92
+ | QueueJobDefinition<any, any, any>
93
+ | CronDefinition<any, any>
94
+ export type BackgroundDefs = Record<string, BackgroundDefinition>
95
+
96
+ /** @deprecated Use QueueJobDefinition. */
97
+ export type JobDefinition<TInput, TSchema extends Record<string, unknown> = Record<string, unknown>, TEnvResult = Record<string, unknown>> = QueueJobDefinition<
98
+ TInput,
99
+ TSchema,
100
+ TEnvResult
101
+ >
102
+
78
103
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
- export type AnyJobDefinition = JobDefinition<any, any, any>
80
- export type JobsDefs = Record<string, AnyJobDefinition>
104
+ export type AnyJobDefinition = QueueJobDefinition<any, any, any>
105
+ export type JobsDefs = BackgroundDefs
106
+
107
+ export type QueueJobKeys<TDefs extends BackgroundDefs> = {
108
+ [K in keyof TDefs & string]: TDefs[K] extends QueueJobDefinition<any, any, any>
109
+ ? K
110
+ : never
111
+ }[keyof TDefs & string]
81
112
 
82
113
  /** Throws when a definition is unusable. Safe to call more than once. */
83
- export function validateJobsDefs(defs: JobsDefs): void {
114
+ export function validateBackgroundDefs(defs: BackgroundDefs): void {
84
115
  for (const [name, def] of Object.entries(defs)) {
85
116
  if (typeof def.handler !== 'function') {
86
- throw new Error(`[bunderstack] job "${name}" has no handler`)
117
+ throw new Error(`[bunderstack] background task "${name}" has no handler`)
87
118
  }
88
- if (def.cron !== undefined) {
89
- parseCron(def.cron) // throws with a clear message on invalid expressions
90
- if (def.input !== undefined) {
91
- throw new Error(
92
- `[bunderstack] job "${name}": cron jobs cannot declare input (nothing enqueues a payload for a schedule)`,
93
- )
94
- }
119
+ if (def.kind === 'cron') {
120
+ parseCron(def.schedule)
121
+ continue
95
122
  }
96
123
  if (def.retries !== undefined && (def.retries < 0 || !Number.isInteger(def.retries))) {
97
124
  throw new Error(`[bunderstack] job "${name}": retries must be a non-negative integer`)
@@ -105,6 +132,9 @@ export function validateJobsDefs(defs: JobsDefs): void {
105
132
  }
106
133
  }
107
134
 
135
+ /** @deprecated Use validateBackgroundDefs. */
136
+ export const validateJobsDefs = validateBackgroundDefs
137
+
108
138
  /** Delay in ms before retry `attempt` (1-based = the attempt that just failed). */
109
139
  export function backoffMs(def: AnyJobDefinition, attempt: number): number {
110
140
  const b = def.backoff
@@ -125,13 +155,19 @@ export function createJobsBuilder<
125
155
  return {
126
156
  /** Identity with inference: pins TInput from the zod schema. */
127
157
  job<TInput = undefined>(
128
- def: JobDefinition<TInput, TSchema, TEnvResult>,
129
- ): JobDefinition<TInput, TSchema, TEnvResult> {
130
- return def
158
+ def: Omit<QueueJobDefinition<TInput, TSchema, TEnvResult>, 'kind'>,
159
+ ): QueueJobDefinition<TInput, TSchema, TEnvResult> {
160
+ return { kind: 'job', ...def }
161
+ },
162
+ cron(
163
+ def: Omit<CronDefinition<TSchema, TEnvResult>, 'kind'>,
164
+ ): CronDefinition<TSchema, TEnvResult> {
165
+ parseCron(def.schedule)
166
+ return { kind: 'cron', ...def }
131
167
  },
132
168
  /** Identity with validation: returns the defs map, typed. */
133
- define<TDefs extends JobsDefs>(defs: TDefs): TDefs {
134
- validateJobsDefs(defs)
169
+ define<TDefs extends BackgroundDefs>(defs: TDefs): TDefs {
170
+ validateBackgroundDefs(defs)
135
171
  return defs
136
172
  },
137
173
  }
@@ -148,7 +184,7 @@ export type BunderstackJobsBuilder<
148
184
  // `TDef extends { input: ZodType<infer I> }` fails structurally because
149
185
  // `input?: ZodType<TInput>` desugars to `ZodType<TInput> | undefined`, which
150
186
  // can never satisfy a required-property pattern.
151
- type JobInputOf<TDef> = TDef extends JobDefinition<infer TInput, any, any>
187
+ type JobInputOf<TDef> = TDef extends QueueJobDefinition<infer TInput, any, any>
152
188
  ? TInput
153
189
  : undefined
154
190
 
@@ -162,7 +198,7 @@ export type JobsFacade<TDefs extends JobsDefs> = Omit<
162
198
  JobsRuntimeFacade,
163
199
  'enqueue'
164
200
  > & {
165
- enqueue<K extends keyof TDefs & string>(
201
+ enqueue<K extends QueueJobKeys<TDefs>>(
166
202
  name: K,
167
203
  ...rest: JobInputOf<TDefs[K]> extends undefined
168
204
  ? [input?: undefined, opts?: EnqueueOptions]
package/src/jobs/index.ts CHANGED
@@ -1,20 +1,38 @@
1
1
  // src/jobs/index.ts — module surface consumed by createBunderstack.
2
2
  export {
3
3
  createJobsBuilder,
4
+ validateBackgroundDefs,
4
5
  validateJobsDefs,
5
6
  DEFAULT_RETRIES,
6
7
  DEFAULT_TIMEOUT_MS,
7
8
  } from './define'
8
9
  export type {
9
10
  AnyJobDefinition,
11
+ BackgroundDefinition,
12
+ BackgroundDefs,
10
13
  BunderstackJobsBuilder,
11
14
  EnqueueOptions,
12
15
  JobContext,
13
16
  JobDefinition,
17
+ QueueJobDefinition,
18
+ CronDefinition,
19
+ CronInvocation,
20
+ QueueJobKeys,
14
21
  JobsDefs,
15
22
  JobsFacade,
16
23
  JobsRuntimeFacade,
17
24
  } from './define'
18
25
  export { enqueueJob } from './queue'
19
26
  export { createJobRunner } from './worker'
27
+ export { runCronSlot, runScheduledSlot } from './cron-runner'
28
+ export type { CronRunResult } from './cron-runner'
29
+ export { buildCronRouter } from './cron-router'
30
+ export { signScheduleRequest, verifyScheduleRequest } from './cron-auth'
31
+ export { startJobWorker } from './runtime'
32
+ export type { StartWorkerOptions, RunWorkerOptions, WorkerHandle } from './runtime'
33
+ export { startLocalCronScheduler } from './local-cron'
34
+ export type {
35
+ LocalCronScheduler,
36
+ LocalCronSchedulerOptions,
37
+ } from './local-cron'
20
38
  export { parseCron, cronMatches } from './cron'
@@ -0,0 +1,76 @@
1
+ import { cronMatches, parseCron } from './cron'
2
+
3
+ type Timer = ReturnType<typeof setTimeout>
4
+
5
+ export type LocalCronDefinition = {
6
+ name: string
7
+ schedule: string
8
+ }
9
+
10
+ export type LocalCronSchedulerOptions = {
11
+ cron: readonly LocalCronDefinition[]
12
+ runSlot: (name: string, scheduledFor: number) => Promise<void>
13
+ now?: () => number
14
+ setTimer?: (callback: () => void, delayMs: number) => Timer
15
+ clearTimer?: (timer: Timer) => void
16
+ onError?: (error: Error) => void
17
+ }
18
+
19
+ export type LocalCronScheduler = {
20
+ tick: () => Promise<void>
21
+ close: () => Promise<void>
22
+ }
23
+
24
+ /**
25
+ * Runs declared cron handlers locally. Production schedules should be delivered
26
+ * by the hosting platform through the signed cron endpoint instead.
27
+ */
28
+ export function startLocalCronScheduler(
29
+ options: LocalCronSchedulerOptions,
30
+ ): LocalCronScheduler {
31
+ const cron = options.cron.map((definition) => ({
32
+ ...definition,
33
+ expression: parseCron(definition.schedule),
34
+ }))
35
+ const now = options.now ?? Date.now
36
+ const setTimer = options.setTimer ?? setTimeout
37
+ const clearTimer = options.clearTimer ?? clearTimeout
38
+ let timer: Timer | undefined
39
+ let closed = false
40
+
41
+ const scheduleNextTick = () => {
42
+ if (closed || timer) return
43
+
44
+ const delay = 60_000 - (now() % 60_000)
45
+ timer = setTimer(() => {
46
+ timer = undefined
47
+ void tick().catch((error: unknown) => {
48
+ options.onError?.(error instanceof Error ? error : new Error(String(error)))
49
+ })
50
+ }, delay)
51
+ }
52
+
53
+ const tick = async () => {
54
+ if (closed) return
55
+
56
+ const scheduledFor = Math.floor(now() / 60_000) * 60_000
57
+ for (const definition of cron) {
58
+ if (cronMatches(definition.expression, scheduledFor)) {
59
+ await options.runSlot(definition.name, scheduledFor)
60
+ }
61
+ }
62
+
63
+ scheduleNextTick()
64
+ }
65
+
66
+ return {
67
+ tick,
68
+ async close() {
69
+ closed = true
70
+ if (timer) {
71
+ clearTimer(timer)
72
+ timer = undefined
73
+ }
74
+ },
75
+ }
76
+ }
package/src/jobs/queue.ts CHANGED
@@ -15,7 +15,9 @@ export async function enqueueJob(
15
15
  opts: EnqueueOptions = {},
16
16
  ): Promise<{ id: string }> {
17
17
  const def = defs[name]
18
- if (!def) throw new Error(`[bunderstack] unknown job type "${name}"`)
18
+ if (!def || def.kind !== 'job') {
19
+ throw new Error(`[bunderstack] unknown queue job "${name}"`)
20
+ }
19
21
  // Fail fast: a bad payload should throw at the call site, not in the worker.
20
22
  const parsed = def.input ? def.input.parse(input) : null
21
23
  const t = jobsTableFor(db)
@@ -0,0 +1,58 @@
1
+ export type WorkerHandle = {
2
+ readonly closed: Promise<void>
3
+ close(): Promise<void>
4
+ }
5
+
6
+ export type StartWorkerOptions = {
7
+ signal?: AbortSignal
8
+ pollIntervalMs?: number
9
+ tick: (now: number) => Promise<void>
10
+ onError?: (error: Error) => void
11
+ }
12
+
13
+ export type RunWorkerOptions = StartWorkerOptions
14
+
15
+ function toError(error: unknown): Error {
16
+ return error instanceof Error ? error : new Error(String(error))
17
+ }
18
+
19
+ function wait(ms: number, signal: AbortSignal): Promise<void> {
20
+ return new Promise((resolve) => {
21
+ if (signal.aborted) return resolve()
22
+ const timer = setTimeout(done, ms)
23
+ function done() {
24
+ clearTimeout(timer)
25
+ signal.removeEventListener('abort', done)
26
+ resolve()
27
+ }
28
+ signal.addEventListener('abort', done, { once: true })
29
+ })
30
+ }
31
+
32
+ export function startJobWorker(options: StartWorkerOptions): WorkerHandle {
33
+ const controller = new AbortController()
34
+ const pollIntervalMs = options.pollIntervalMs ?? 1_000
35
+ const abort = () => controller.abort()
36
+ options.signal?.addEventListener('abort', abort, { once: true })
37
+ if (options.signal?.aborted) abort()
38
+
39
+ const closed = (async () => {
40
+ try {
41
+ while (!controller.signal.aborted) {
42
+ try {
43
+ await options.tick(Date.now())
44
+ } catch (error) {
45
+ options.onError?.(toError(error))
46
+ }
47
+ await wait(pollIntervalMs, controller.signal)
48
+ }
49
+ } finally {
50
+ options.signal?.removeEventListener('abort', abort)
51
+ }
52
+ })()
53
+ const close = () => {
54
+ controller.abort()
55
+ return closed
56
+ }
57
+ return { closed, close }
58
+ }