bunderstack 0.15.2 → 0.16.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.
@@ -5,6 +5,7 @@ import {
5
5
  checkAccessSync,
6
6
  resolveSession,
7
7
  rowMatchesScope,
8
+ tableEntryForName,
8
9
  type AccessUser,
9
10
  type AuthSessionResolver,
10
11
  type ResolvedAccess,
@@ -55,15 +56,6 @@ export type RealtimeBroker = {
55
56
  ): void | Promise<void>
56
57
  }
57
58
 
58
- function tableEntry(
59
- access: ResolvedAccess,
60
- tableName: string,
61
- ): ResolvedTableAccess | undefined {
62
- for (const entry of access.values()) {
63
- if (entry.tableName === tableName) return entry
64
- }
65
- return undefined
66
- }
67
59
 
68
60
  function isRecord(value: unknown): value is Record<string, unknown> {
69
61
  return value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -166,7 +158,7 @@ export function createRealtimeBroker(opts: {
166
158
  record: Record<string, unknown>,
167
159
  id: unknown,
168
160
  ): boolean {
169
- const entry = tableEntry(opts.access, table)
161
+ const entry = tableEntryForName(opts.access, table)
170
162
  if (!entry) return false
171
163
  const topicMatch =
172
164
  s.subscriptions.has(table) ||
@@ -232,7 +224,7 @@ export function createRealtimeBroker(opts: {
232
224
  subscribers.delete(id)
233
225
  },
234
226
  publish(table, action, record) {
235
- const entry = tableEntry(opts.access, table)
227
+ const entry = tableEntryForName(opts.access, table)
236
228
  if (!entry) return
237
229
  const eventId = nextId++
238
230
  buffer.push({ eventId, table, action, record })
@@ -12,9 +12,9 @@ import type { RealtimeAction, RealtimeBroker } from './index'
12
12
  import {
13
13
  checkAccessSync,
14
14
  rowMatchesScope,
15
+ tableEntryForName,
15
16
  type AccessUser,
16
17
  type ResolvedAccess,
17
- type ResolvedTableAccess,
18
18
  } from '../access'
19
19
 
20
20
  export type RedisLike = {
@@ -45,15 +45,6 @@ type WireEvent = {
45
45
  record: Record<string, unknown>
46
46
  }
47
47
 
48
- function tableEntry(
49
- access: ResolvedAccess,
50
- name: string,
51
- ): ResolvedTableAccess | undefined {
52
- for (const entry of access.values())
53
- if (entry.tableName === name) return entry
54
- return undefined
55
- }
56
-
57
48
  function isRecord(value: unknown): value is Record<string, unknown> {
58
49
  return value !== null && typeof value === 'object' && !Array.isArray(value)
59
50
  }
@@ -109,7 +100,7 @@ export function createRedisRealtimeBroker(opts: {
109
100
  table: string,
110
101
  record: Record<string, unknown>,
111
102
  ): boolean {
112
- const entry = tableEntry(opts.access, table)
103
+ const entry = tableEntryForName(opts.access, table)
113
104
  if (!entry) return false
114
105
  const id = record['id']
115
106
  const topicMatch =
@@ -208,7 +199,7 @@ export function createRedisRealtimeBroker(opts: {
208
199
  subscribers.delete(id)
209
200
  },
210
201
  async publish(table, action, record) {
211
- if (!tableEntry(opts.access, table)) return
202
+ if (!tableEntryForName(opts.access, table)) return
212
203
  try {
213
204
  const client = getRedis()
214
205
  const eventId = await client.incr(counterKey)
package/src/routes.ts ADDED
@@ -0,0 +1,137 @@
1
+ // src/routes.ts — mounting user-supplied Hono routes inside the app.
2
+
3
+ /** A route as Hono reports it on `app.routes`. */
4
+ export type DeclaredRoute = { method: string; path: string }
5
+
6
+ const RESERVED_EXACT = ['/health', '/api/health', '/api/realtime'] as const
7
+
8
+ const RESERVED_PREFIXES = [
9
+ '/api/auth/',
10
+ '/api/trpc/',
11
+ '/api/files/',
12
+ '/files/',
13
+ ] as const
14
+
15
+ /** The first path segment under `/api/`, or undefined when not under it. */
16
+ function apiSegment(path: string): string | undefined {
17
+ if (!path.startsWith('/api/')) return undefined
18
+ return path.slice('/api/'.length).split('/')[0]
19
+ }
20
+
21
+ function collisionFor(
22
+ route: DeclaredRoute,
23
+ tableNames: readonly string[],
24
+ ): string | undefined {
25
+ const { path } = route
26
+ if (RESERVED_EXACT.includes(path as (typeof RESERVED_EXACT)[number])) {
27
+ return `it is reserved by bunderstack`
28
+ }
29
+ for (const prefix of RESERVED_PREFIXES) {
30
+ if (path.startsWith(prefix)) {
31
+ return `"${prefix}*" is reserved by bunderstack`
32
+ }
33
+ }
34
+ const segment = apiSegment(path)
35
+ if (segment === undefined) return undefined
36
+ if (segment === '*' || segment.startsWith(':')) {
37
+ return `a parameter or wildcard here would shadow every generated CRUD route`
38
+ }
39
+ if (tableNames.includes(segment)) {
40
+ return `it collides with the generated CRUD route for table "${segment}"`
41
+ }
42
+ return undefined
43
+ }
44
+
45
+ /**
46
+ * Throws when any declared route would collide with a bunderstack route.
47
+ *
48
+ * Custom routes are registered before the built-ins, so a collision silently
49
+ * shadows core behaviour — including authentication. Failing at construction is
50
+ * the cheapest place to find out.
51
+ */
52
+ export function validateCustomRoutes(
53
+ routes: readonly DeclaredRoute[],
54
+ tableNames: readonly string[],
55
+ ): void {
56
+ const problems: string[] = []
57
+ for (const route of routes) {
58
+ const reason = collisionFor(route, tableNames)
59
+ if (reason) {
60
+ problems.push(` ${route.method} ${route.path} — ${reason}`)
61
+ }
62
+ }
63
+ if (problems.length === 0) return
64
+ throw new Error(
65
+ `[bunderstack] routes: ${problems.length} route(s) collide with bunderstack's own:\n${problems.join('\n')}\nChoose different paths.`,
66
+ )
67
+ }
68
+
69
+ import type { Hono } from 'hono'
70
+
71
+ import type { AccessUser, AuthSessionResolver } from './access'
72
+ import type { DbFor } from './db'
73
+ import type { EmailFacade } from './email'
74
+ import type { JobsRuntimeFacade } from './jobs/define'
75
+ import type { RealtimeFacade } from './realtime/facade'
76
+ import type { AuthInstance, StorageFacade } from './index'
77
+
78
+ import { resolveAccessUser, resolveSession } from './access'
79
+
80
+ export type RouteContext<
81
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
82
+ TEnvResult = Record<string, unknown>,
83
+ > = {
84
+ db: DbFor<TSchema>
85
+ env: TEnvResult
86
+ storage: StorageFacade
87
+ email: EmailFacade
88
+ jobs: JobsRuntimeFacade
89
+ realtime: RealtimeFacade<TSchema>
90
+ auth: AuthInstance
91
+ /** Resolve the caller's session. Costs an auth round-trip; call only when needed. */
92
+ getSession(
93
+ request: Request,
94
+ ): Promise<{ user: AccessUser | null; activeOrganizationId: string | null }>
95
+ /** Convenience wrapper over getSession when the organization is irrelevant. */
96
+ getUser(request: Request): Promise<AccessUser | null>
97
+ }
98
+
99
+ /** Alias mirroring the JobContext / BunderstackJobContext pair. */
100
+ export type BunderstackRouteContext<
101
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
102
+ TEnvResult = Record<string, unknown>,
103
+ > = RouteContext<TSchema, TEnvResult>
104
+
105
+ export type RoutesBuilder<
106
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
107
+ TEnvResult = Record<string, unknown>,
108
+ > = (ctx: RouteContext<TSchema, TEnvResult>) => Hono
109
+
110
+ export function createRouteContext<
111
+ TSchema extends Record<string, unknown>,
112
+ TEnvResult,
113
+ >(deps: {
114
+ db: DbFor<TSchema>
115
+ env: TEnvResult
116
+ storage: StorageFacade
117
+ email: EmailFacade
118
+ jobs: JobsRuntimeFacade
119
+ realtime: RealtimeFacade<TSchema>
120
+ auth: AuthInstance
121
+ authResolver: AuthSessionResolver | undefined
122
+ }): RouteContext<TSchema, TEnvResult> {
123
+ return {
124
+ db: deps.db,
125
+ env: deps.env,
126
+ storage: deps.storage,
127
+ email: deps.email,
128
+ jobs: deps.jobs,
129
+ realtime: deps.realtime,
130
+ auth: deps.auth,
131
+ // Lazy on purpose: a webhook has no session, and resolving one eagerly
132
+ // would spend an auth round-trip per request on a value nobody reads.
133
+ getSession: (request) => resolveSession(deps.authResolver, request.headers),
134
+ getUser: (request) => resolveAccessUser(deps.authResolver, request.headers),
135
+ }
136
+ }
137
+
@@ -1,28 +0,0 @@
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
- }
@@ -1,135 +0,0 @@
1
- import { and, eq, lt } from 'drizzle-orm'
2
- import { Hono } from 'hono'
3
-
4
- import type { AnyDb } from '../dialect'
5
- import type { BackgroundDefs } from './define'
6
-
7
- import { cronRunsTableFor } from '../internal-tables'
8
- import { verifyScheduleRequest } from './cron-auth'
9
- import { runCronSlot, runScheduledSlot } from './cron-runner'
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(result, result.status === 'running' ? 202 : 200)
60
- } catch (error) {
61
- if (
62
- error instanceof Error &&
63
- error.message === '[bunderstack] cron slot does not match its schedule'
64
- ) {
65
- return c.json({ error: 'invalid cron slot' }, 400)
66
- }
67
- return c.json({ error: 'cron handler failed' }, 500)
68
- }
69
- })
70
-
71
- app.post('/maintenance/:name', async (c) => {
72
- const name = c.req.param('name')
73
- const slotText = c.req.header('X-Bunderstack-Cron-Slot')
74
- const signature = c.req.header('X-Bunderstack-Cron-Signature')
75
- const slot = Number(slotText)
76
- if (!slotText || !Number.isSafeInteger(slot) || !signature) {
77
- return c.json({ error: 'invalid schedule signature' }, 401)
78
- }
79
- if (
80
- !verifyScheduleRequest(
81
- args.secret,
82
- `maintenance:${name}`,
83
- slot,
84
- signature,
85
- )
86
- ) {
87
- return c.json({ error: 'invalid schedule signature' }, 401)
88
- }
89
- if (name !== 'storage-sweep') {
90
- return c.json({ error: 'unknown maintenance task' }, 404)
91
- }
92
- const current = now()
93
- if (
94
- slot % 60_000 !== 0 ||
95
- slot < current - MAX_SLOT_AGE_MS ||
96
- slot > current + MAX_FUTURE_SLOT_MS
97
- ) {
98
- return c.json({ error: 'invalid cron slot' }, 400)
99
- }
100
- try {
101
- const result = await runScheduledSlot({
102
- db: args.db,
103
- taskId: 'maintenance:storage-sweep',
104
- schedule: STORAGE_SWEEP_SCHEDULE,
105
- slot,
106
- now: current,
107
- run: async () => {
108
- await args.storage.sweep()
109
- },
110
- })
111
- if (result.status === 'succeeded') {
112
- const t = cronRunsTableFor(args.db)
113
- await args.db
114
- .delete(t)
115
- .where(
116
- and(
117
- eq(t.status, 'succeeded'),
118
- lt(t.finishedAt, current - SCHEDULED_RUN_RETENTION_MS),
119
- ),
120
- )
121
- }
122
- return c.json(result, result.status === 'running' ? 202 : 200)
123
- } catch (error) {
124
- if (
125
- error instanceof Error &&
126
- error.message === '[bunderstack] cron slot does not match its schedule'
127
- ) {
128
- return c.json({ error: 'invalid cron slot' }, 400)
129
- }
130
- return c.json({ error: 'maintenance handler failed' }, 500)
131
- }
132
- })
133
-
134
- return app
135
- }
@@ -1,224 +0,0 @@
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
- leaseMs?: number
24
- heartbeatIntervalMs?: number
25
- heartbeatCleanupTimeoutMs?: number
26
- }): Promise<CronRunResult> {
27
- const { db, taskId, schedule, slot, now, run } = args
28
- const leaseMs = args.leaseMs ?? LEASE_MS
29
- const heartbeatIntervalMs =
30
- args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
31
- const heartbeatCleanupTimeoutMs = args.heartbeatCleanupTimeoutMs ?? 1_000
32
-
33
- if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
34
- throw new Error('[bunderstack] cron slot does not match its schedule')
35
- }
36
-
37
- const t = cronRunsTableFor(db)
38
- const leaseUntil = now + leaseMs
39
- const inserted = await db
40
- .insert(t)
41
- .values({
42
- taskId,
43
- scheduledAt: slot,
44
- status: 'running',
45
- attempts: 1,
46
- lockedUntil: leaseUntil,
47
- startedAt: now,
48
- })
49
- .onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
50
- .returning({ taskId: t.taskId, attempts: t.attempts })
51
-
52
- let ownershipAttempt: number
53
- if (inserted[0]) {
54
- ownershipAttempt = Number(inserted[0].attempts)
55
- } else {
56
- const existing = await db
57
- .select({ status: t.status, lockedUntil: t.lockedUntil })
58
- .from(t)
59
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
60
- .limit(1)
61
- const row = existing[0]
62
- if (!row || row.status === 'succeeded') return { status: 'duplicate' }
63
- if (row.status === 'running' && Number(row.lockedUntil) >= now) {
64
- return { status: 'running' }
65
- }
66
- const reclaimed = await db
67
- .update(t)
68
- .set({
69
- status: 'running',
70
- lockedUntil: leaseUntil,
71
- startedAt: now,
72
- attempts: sql`${t.attempts} + 1`,
73
- lastError: null,
74
- })
75
- .where(
76
- and(
77
- eq(t.taskId, taskId),
78
- eq(t.scheduledAt, slot),
79
- or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
80
- ),
81
- )
82
- .returning({ taskId: t.taskId, attempts: t.attempts })
83
- const reclaimedRow = reclaimed[0]
84
- if (!reclaimedRow) return { status: 'running' }
85
- ownershipAttempt = Number(reclaimedRow.attempts)
86
- }
87
-
88
- let heartbeatTimer: Timer | undefined
89
- let heartbeatInFlight: Promise<void> | undefined
90
- let heartbeatStopped = false
91
-
92
- const scheduleHeartbeat = () => {
93
- heartbeatTimer = setTimeout(() => {
94
- heartbeatTimer = undefined
95
- if (heartbeatStopped) return
96
-
97
- heartbeatInFlight = (async () => {
98
- try {
99
- const renewUntil = Date.now() + leaseMs
100
- await db
101
- .update(t)
102
- .set({ lockedUntil: renewUntil })
103
- .where(
104
- and(
105
- eq(t.taskId, taskId),
106
- eq(t.scheduledAt, slot),
107
- eq(t.status, 'running'),
108
- eq(t.startedAt, now),
109
- eq(t.attempts, ownershipAttempt),
110
- ),
111
- )
112
- } catch {
113
- // Best effort renewal
114
- }
115
- })()
116
-
117
- void heartbeatInFlight.finally(() => {
118
- heartbeatInFlight = undefined
119
- if (!heartbeatStopped) scheduleHeartbeat()
120
- })
121
- }, heartbeatIntervalMs)
122
- }
123
-
124
- const stopHeartbeat = async () => {
125
- heartbeatStopped = true
126
- if (heartbeatTimer) {
127
- clearTimeout(heartbeatTimer)
128
- heartbeatTimer = undefined
129
- }
130
- const inFlight = heartbeatInFlight
131
- if (!inFlight) return
132
-
133
- let cleanupTimer: Timer | undefined
134
- try {
135
- await Promise.race([
136
- inFlight,
137
- new Promise<void>((resolve) => {
138
- cleanupTimer = setTimeout(resolve, heartbeatCleanupTimeoutMs)
139
- }),
140
- ])
141
- } finally {
142
- if (cleanupTimer) clearTimeout(cleanupTimer)
143
- }
144
- }
145
-
146
- scheduleHeartbeat()
147
-
148
- try {
149
- await run(new Date(slot))
150
-
151
- const updated = await db
152
- .update(t)
153
- .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
154
- .where(
155
- and(
156
- eq(t.taskId, taskId),
157
- eq(t.scheduledAt, slot),
158
- eq(t.status, 'running'),
159
- eq(t.startedAt, now),
160
- eq(t.attempts, ownershipAttempt),
161
- ),
162
- )
163
- .returning({ taskId: t.taskId })
164
-
165
- if (!updated[0]) {
166
- throw new Error(
167
- '[bunderstack] cron lease ownership was lost during execution',
168
- )
169
- }
170
-
171
- return { status: 'succeeded' }
172
- } catch (error) {
173
- const message = error instanceof Error ? error.message : String(error)
174
- await db
175
- .update(t)
176
- .set({
177
- status: 'failed',
178
- lockedUntil: null,
179
- lastError: message,
180
- finishedAt: Date.now(),
181
- })
182
- .where(
183
- and(
184
- eq(t.taskId, taskId),
185
- eq(t.scheduledAt, slot),
186
- eq(t.status, 'running'),
187
- eq(t.startedAt, now),
188
- eq(t.attempts, ownershipAttempt),
189
- ),
190
- )
191
- throw error
192
- } finally {
193
- await stopHeartbeat()
194
- }
195
- }
196
-
197
- export async function runCronSlot(args: {
198
- db: AnyDb
199
- defs: BackgroundDefs
200
- ctx: Record<string, unknown>
201
- name: string
202
- slot: number
203
- now: number
204
- leaseMs?: number
205
- heartbeatIntervalMs?: number
206
- heartbeatCleanupTimeoutMs?: number
207
- }): Promise<CronRunResult> {
208
- const definition = args.defs[args.name]
209
- if (!definition || definition.kind !== 'cron') {
210
- throw new Error(`[bunderstack] unknown cron "${args.name}"`)
211
- }
212
- return runScheduledSlot({
213
- db: args.db,
214
- taskId: `cron:${args.name}`,
215
- schedule: definition.schedule,
216
- slot: args.slot,
217
- now: args.now,
218
- leaseMs: args.leaseMs,
219
- heartbeatIntervalMs: args.heartbeatIntervalMs,
220
- heartbeatCleanupTimeoutMs: args.heartbeatCleanupTimeoutMs,
221
- run: (scheduledFor) =>
222
- definition.handler({ scheduledFor }, args.ctx as never),
223
- })
224
- }
@@ -1,78 +0,0 @@
1
- import { cronMatches, parseCron } from './cron'
2
-
3
- type Timer = ReturnType<typeof setTimeout> | number
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?.(
49
- error instanceof Error ? error : new Error(String(error)),
50
- )
51
- })
52
- }, delay)
53
- }
54
-
55
- const tick = async () => {
56
- if (closed) return
57
-
58
- const scheduledFor = Math.floor(now() / 60_000) * 60_000
59
- for (const definition of cron) {
60
- if (cronMatches(definition.expression, scheduledFor)) {
61
- await options.runSlot(definition.name, scheduledFor)
62
- }
63
- }
64
-
65
- scheduleNextTick()
66
- }
67
-
68
- return {
69
- tick,
70
- async close() {
71
- closed = true
72
- if (timer) {
73
- clearTimer(timer)
74
- timer = undefined
75
- }
76
- },
77
- }
78
- }