experimental-a2 0.3.0 → 0.5.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.
- package/CHANGELOG.md +207 -0
- package/dist/ai-CFNeCrRl.d.ts +334 -0
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +49 -9
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +123 -49
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +233 -86
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +599 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +7 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client-BKlyLiOU.js +1008 -0
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/client-D7mvIXrF.d.ts +191 -0
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +152 -0
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +340 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +3 -1
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +4 -3
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +226 -43
- package/dist/devtools-server.js.map +1 -0
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +135 -28
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +647 -61
- package/dist/http.js.map +1 -0
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/internal-DstsI6Re.js +211 -0
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +3 -2
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +47 -15
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +30 -15
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +79 -0
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +501 -0
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +56 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +228 -0
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/server-C72KOw51.js +1457 -0
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/server-DUF9pjsx.d.ts +261 -0
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +10 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +12 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/{log-memory.js → store-memory.js} +145 -34
- package/dist/store-memory.js.map +1 -0
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/{log-postgres.js → store-postgres.js} +214 -24
- package/dist/store-postgres.js.map +1 -0
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
- package/dist/store-redis-http.js.map +1 -0
- package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/{log-redis.js → store-redis.js} +101 -39
- package/dist/store-redis.js.map +1 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
- package/dist/store-sqlite.js.map +1 -0
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +6 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +103 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/wire-BFQmSJ-9.js +284 -0
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +76 -57
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +252 -74
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +263 -0
- package/docs/guides/10-transports.mdx +140 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +839 -110
- package/docs/reference/02-errors.mdx +13 -6
- package/package.json +24 -7
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
- package/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/scheduler-qstash — HTTP scheduler over Upstash QStash.
|
|
3
|
+
*
|
|
4
|
+
* QStash publishes one signed HTTP delivery per scheduler task. Drain tasks
|
|
5
|
+
* use an absolute, whole-second `notBefore` and a fixed-size deduplication id;
|
|
6
|
+
* append tasks hash their stable task id with the callback URL.
|
|
7
|
+
* Store-outage successors carry a signed private generation and back off to
|
|
8
|
+
* one day without deriving their deduplication identity from the local clock.
|
|
9
|
+
* `handler(...servers)` verifies the signature over the raw request body
|
|
10
|
+
* before parsing it, then applies the same drain protocol as the Vercel
|
|
11
|
+
* scheduler adapter.
|
|
12
|
+
*
|
|
13
|
+
* `@upstash/qstash` is an optional peer dependency, imported lazily. The
|
|
14
|
+
* structural `transport` option keeps its types out of A2's public surface and
|
|
15
|
+
* gives tests a provider-faithful injection seam.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
assertSchedulerTargets,
|
|
20
|
+
classifySchedulerSendFailure,
|
|
21
|
+
DRAIN_TIMINGS,
|
|
22
|
+
serverInternals,
|
|
23
|
+
type DrainResult,
|
|
24
|
+
} from './internal.ts'
|
|
25
|
+
import { A2Error } from './errors.ts'
|
|
26
|
+
import { platformVercelOidcToken } from './platform.ts'
|
|
27
|
+
import { retryableLazy } from './retryable-lazy.ts'
|
|
28
|
+
import { normalizeSchedulerTask, type SchedulerTask } from './scheduler-task.ts'
|
|
29
|
+
import {
|
|
30
|
+
deliverSchedulerAppend,
|
|
31
|
+
type A2Scheduler,
|
|
32
|
+
type DrainableServer,
|
|
33
|
+
} from './server.ts'
|
|
34
|
+
import { SYSTEM_CLOCK, type Clock } from './store.ts'
|
|
35
|
+
|
|
36
|
+
type QStashStoreOutage = {
|
|
37
|
+
version: 1
|
|
38
|
+
/** Zero is the first successor created for a store outage. */
|
|
39
|
+
generation: number
|
|
40
|
+
/** The first failing drain task's due time. Append identity lives on the task. */
|
|
41
|
+
rootDueAt?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type QStashMessage = SchedulerTask & {
|
|
45
|
+
/** Provider-private state authenticated as part of QStash's signed body. */
|
|
46
|
+
_a2QStash?: QStashStoreOutage
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type QStashPublishRequest = {
|
|
50
|
+
url: string
|
|
51
|
+
body: QStashMessage
|
|
52
|
+
/** Headers the transport must deliver to the callback. */
|
|
53
|
+
headers?: Readonly<Record<string, string>>
|
|
54
|
+
/** Header names the transport must redact from provider dashboard/API views. */
|
|
55
|
+
redact?: {
|
|
56
|
+
header: readonly string[]
|
|
57
|
+
}
|
|
58
|
+
/** Whole-second Unix timestamp. Omitted for an immediate delivery. */
|
|
59
|
+
notBefore?: number
|
|
60
|
+
deduplicationId: string
|
|
61
|
+
/** Delivery retries after the initial attempt. */
|
|
62
|
+
retries?: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The request shape passed to an injected `@upstash/qstash` client. */
|
|
66
|
+
export type QStashClientPublishRequest = Omit<
|
|
67
|
+
QStashPublishRequest,
|
|
68
|
+
'headers' | 'redact'
|
|
69
|
+
> & {
|
|
70
|
+
headers?: Record<string, string>
|
|
71
|
+
redact?: {
|
|
72
|
+
header: string[]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The outbound part of `@upstash/qstash` used by the built-in transport. */
|
|
77
|
+
export type QStashPublishingClient = {
|
|
78
|
+
publishJSON(request: QStashClientPublishRequest): Promise<unknown>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type QStashVerifyRequest = {
|
|
82
|
+
signature: string
|
|
83
|
+
/** The untouched request body covered by the signature. */
|
|
84
|
+
body: string
|
|
85
|
+
/** Exact destination URL, checked against the signature subject. */
|
|
86
|
+
url: string
|
|
87
|
+
upstashRegion?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The small part of `@upstash/qstash` this adapter needs. */
|
|
91
|
+
export type QStashTransport = {
|
|
92
|
+
publish(request: QStashPublishRequest): Promise<unknown>
|
|
93
|
+
verify(request: QStashVerifyRequest): Promise<boolean>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type QStashSchedulerOptions = {
|
|
97
|
+
/** Exact URL mounted with `schedulerHandler(...)`. Inferred in local dev and on Vercel. */
|
|
98
|
+
url?: string
|
|
99
|
+
/** QStash API token. Default: `QSTASH_TOKEN`. */
|
|
100
|
+
token?: string
|
|
101
|
+
/** QStash signing key. Default: `QSTASH_CURRENT_SIGNING_KEY`. */
|
|
102
|
+
currentSigningKey?: string
|
|
103
|
+
/** Next QStash signing key. Default: `QSTASH_NEXT_SIGNING_KEY`. */
|
|
104
|
+
nextSigningKey?: string
|
|
105
|
+
/** Delivery retries after the initial attempt. Default: provider default. */
|
|
106
|
+
retries?: number
|
|
107
|
+
/** Maximum callback body and outbound task with outage headroom. Default 1 MiB. */
|
|
108
|
+
maxBodyBytes?: number
|
|
109
|
+
/** Static callback headers. QStash receives them; A2 requests dashboard/API redaction. */
|
|
110
|
+
headers?: Readonly<Record<string, string>>
|
|
111
|
+
/** Injectable publishing client. The built-in receiver still verifies callbacks. */
|
|
112
|
+
client?: QStashPublishingClient
|
|
113
|
+
/** Injectable transport (tests). Default: `@upstash/qstash`. */
|
|
114
|
+
transport?: QStashTransport
|
|
115
|
+
/** Injectable clock for due-time calculations (tests). */
|
|
116
|
+
clock?: Clock
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const qstashTransport = async (
|
|
120
|
+
options: Pick<
|
|
121
|
+
QStashSchedulerOptions,
|
|
122
|
+
'token' | 'currentSigningKey' | 'nextSigningKey' | 'client'
|
|
123
|
+
>,
|
|
124
|
+
developmentMode: boolean | undefined,
|
|
125
|
+
): Promise<QStashTransport> => {
|
|
126
|
+
const sdk = await import('@upstash/qstash').catch(() => {
|
|
127
|
+
throw classifySchedulerSendFailure(
|
|
128
|
+
new Error(
|
|
129
|
+
"experimental-a2/scheduler-qstash needs the '@upstash/qstash' package (optional peer dependency) — install it, or inject a transport",
|
|
130
|
+
),
|
|
131
|
+
'terminal',
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
const developmentOverride =
|
|
135
|
+
developmentMode === undefined ? {} : { devMode: developmentMode }
|
|
136
|
+
const client: QStashPublishingClient =
|
|
137
|
+
options.client ??
|
|
138
|
+
new sdk.Client({
|
|
139
|
+
...(options.token === undefined ? {} : { token: options.token }),
|
|
140
|
+
...developmentOverride,
|
|
141
|
+
})
|
|
142
|
+
const receiver = new sdk.Receiver({
|
|
143
|
+
...(options.currentSigningKey === undefined
|
|
144
|
+
? {}
|
|
145
|
+
: { currentSigningKey: options.currentSigningKey }),
|
|
146
|
+
...(options.nextSigningKey === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { nextSigningKey: options.nextSigningKey }),
|
|
149
|
+
...developmentOverride,
|
|
150
|
+
})
|
|
151
|
+
return {
|
|
152
|
+
publish: (request) => {
|
|
153
|
+
const clientRequest: QStashClientPublishRequest = {
|
|
154
|
+
url: request.url,
|
|
155
|
+
body: request.body,
|
|
156
|
+
...(request.headers === undefined
|
|
157
|
+
? {}
|
|
158
|
+
: { headers: { ...request.headers } }),
|
|
159
|
+
...(request.redact === undefined
|
|
160
|
+
? {}
|
|
161
|
+
: { redact: { header: [...request.redact.header] } }),
|
|
162
|
+
deduplicationId: request.deduplicationId,
|
|
163
|
+
...(request.retries === undefined ? {} : { retries: request.retries }),
|
|
164
|
+
...(request.notBefore === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { notBefore: request.notBefore }),
|
|
167
|
+
}
|
|
168
|
+
return client.publishJSON(clientRequest)
|
|
169
|
+
},
|
|
170
|
+
verify: (request) => receiver.verify(request),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const digestId = async (
|
|
175
|
+
parts: readonly (string | number)[],
|
|
176
|
+
): Promise<string> => {
|
|
177
|
+
const digest = await crypto.subtle.digest(
|
|
178
|
+
'SHA-256',
|
|
179
|
+
new TextEncoder().encode(JSON.stringify(parts)),
|
|
180
|
+
)
|
|
181
|
+
const hex = Array.from(new Uint8Array(digest), (byte) =>
|
|
182
|
+
byte.toString(16).padStart(2, '0'),
|
|
183
|
+
).join('')
|
|
184
|
+
return `a2_${hex}`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
type ParsedQStashMessage = {
|
|
188
|
+
task: SchedulerTask
|
|
189
|
+
outage?: QStashStoreOutage
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const recordOf = (value: unknown): Record<string, unknown> | null =>
|
|
193
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
194
|
+
? (value as Record<string, unknown>)
|
|
195
|
+
: null
|
|
196
|
+
|
|
197
|
+
const TRUSTED_SOURCE_HEADER = 'x-vercel-trusted-oidc-idp-token'
|
|
198
|
+
const TRUSTED_SOURCE_DELIVERY_MARGIN_MS = 60_000
|
|
199
|
+
const RESERVED_CALLBACK_HEADERS = new Set([
|
|
200
|
+
'connection',
|
|
201
|
+
'content-length',
|
|
202
|
+
'host',
|
|
203
|
+
'keep-alive',
|
|
204
|
+
'proxy-authenticate',
|
|
205
|
+
'proxy-authorization',
|
|
206
|
+
'te',
|
|
207
|
+
'trailer',
|
|
208
|
+
'transfer-encoding',
|
|
209
|
+
'upgrade',
|
|
210
|
+
'x-vercel-oidc-token',
|
|
211
|
+
TRUSTED_SOURCE_HEADER,
|
|
212
|
+
])
|
|
213
|
+
|
|
214
|
+
const callbackHeaders = (
|
|
215
|
+
value: Readonly<Record<string, string>> | undefined,
|
|
216
|
+
): Readonly<Record<string, string>> | undefined => {
|
|
217
|
+
if (value === undefined) return undefined
|
|
218
|
+
|
|
219
|
+
let entries: [string, string][]
|
|
220
|
+
try {
|
|
221
|
+
entries = Object.entries(value)
|
|
222
|
+
} catch {
|
|
223
|
+
throw new TypeError('qstash() headers must be an HTTP header record')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const normalized: [string, string][] = []
|
|
227
|
+
const seen = new Set<string>()
|
|
228
|
+
for (const [name, headerValue] of entries) {
|
|
229
|
+
if (typeof headerValue !== 'string') {
|
|
230
|
+
throw new TypeError('qstash() header values must be strings')
|
|
231
|
+
}
|
|
232
|
+
let entry: [string, string] | undefined
|
|
233
|
+
try {
|
|
234
|
+
entry = [...new Headers([[name, headerValue]]).entries()][0]
|
|
235
|
+
} catch {
|
|
236
|
+
throw new TypeError(
|
|
237
|
+
'qstash() headers must contain valid HTTP header names and values',
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
if (entry === undefined) {
|
|
241
|
+
throw new TypeError(
|
|
242
|
+
'qstash() headers must contain valid HTTP header names and values',
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
const [normalizedName, normalizedValue] = entry
|
|
246
|
+
if (seen.has(normalizedName)) {
|
|
247
|
+
throw new TypeError(
|
|
248
|
+
`qstash() headers contain duplicate name '${normalizedName}'`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
if (
|
|
252
|
+
normalizedName.startsWith('upstash-') ||
|
|
253
|
+
normalizedName.startsWith('content-type') ||
|
|
254
|
+
RESERVED_CALLBACK_HEADERS.has(normalizedName)
|
|
255
|
+
) {
|
|
256
|
+
throw new TypeError(
|
|
257
|
+
`qstash() headers cannot set reserved header '${normalizedName}'`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
seen.add(normalizedName)
|
|
261
|
+
normalized.push([normalizedName, normalizedValue])
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return normalized.length === 0
|
|
265
|
+
? undefined
|
|
266
|
+
: Object.freeze(Object.fromEntries(normalized))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const publishHeaders = (
|
|
270
|
+
configured: Readonly<Record<string, string>> | undefined,
|
|
271
|
+
generated: Pick<QStashPublishRequest, 'headers' | 'redact'> | undefined,
|
|
272
|
+
): Pick<QStashPublishRequest, 'headers' | 'redact'> | undefined => {
|
|
273
|
+
if (configured === undefined && generated === undefined) return undefined
|
|
274
|
+
|
|
275
|
+
const headers = new Headers()
|
|
276
|
+
const redacted = new Set<string>()
|
|
277
|
+
for (const [name, value] of Object.entries(configured ?? {})) {
|
|
278
|
+
headers.set(name, value)
|
|
279
|
+
redacted.add(name)
|
|
280
|
+
}
|
|
281
|
+
for (const [name, value] of Object.entries(generated?.headers ?? {})) {
|
|
282
|
+
headers.set(name, value)
|
|
283
|
+
redacted.add(name)
|
|
284
|
+
}
|
|
285
|
+
for (const name of generated?.redact?.header ?? []) {
|
|
286
|
+
const normalized = [...new Headers([[name, 'redacted']]).keys()][0]
|
|
287
|
+
if (normalized === undefined) {
|
|
288
|
+
throw new TypeError('qstash generated an invalid redaction header')
|
|
289
|
+
}
|
|
290
|
+
redacted.add(normalized)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const headerEntries = [...headers.entries()]
|
|
294
|
+
return {
|
|
295
|
+
...(headerEntries.length === 0
|
|
296
|
+
? {}
|
|
297
|
+
: { headers: Object.fromEntries(headerEntries) }),
|
|
298
|
+
...(redacted.size === 0
|
|
299
|
+
? {}
|
|
300
|
+
: { redact: { header: [...redacted].toSorted() } }),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const jwtExpirationMs = (token: string): number | null => {
|
|
305
|
+
const parts = token.split('.')
|
|
306
|
+
const payloadPart = parts.length === 3 ? parts[1] : undefined
|
|
307
|
+
if (
|
|
308
|
+
payloadPart === undefined ||
|
|
309
|
+
payloadPart.length === 0 ||
|
|
310
|
+
!/^[A-Za-z0-9_-]+$/.test(payloadPart) ||
|
|
311
|
+
payloadPart.length % 4 === 1
|
|
312
|
+
) {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const decode = globalThis.atob
|
|
318
|
+
if (typeof decode !== 'function') return null
|
|
319
|
+
const base64 = payloadPart
|
|
320
|
+
.replaceAll('-', '+')
|
|
321
|
+
.replaceAll('_', '/')
|
|
322
|
+
.padEnd(payloadPart.length + ((4 - (payloadPart.length % 4)) % 4), '=')
|
|
323
|
+
const binary = decode(base64)
|
|
324
|
+
const bytes = Uint8Array.from(binary, (character) =>
|
|
325
|
+
character.charCodeAt(0),
|
|
326
|
+
)
|
|
327
|
+
const payload = recordOf(JSON.parse(new TextDecoder().decode(bytes)))
|
|
328
|
+
const expiration = payload?.['exp']
|
|
329
|
+
if (
|
|
330
|
+
typeof expiration !== 'number' ||
|
|
331
|
+
!Number.isSafeInteger(expiration) ||
|
|
332
|
+
expiration <= 0 ||
|
|
333
|
+
expiration > Number.MAX_SAFE_INTEGER / 1_000
|
|
334
|
+
) {
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
return expiration * 1_000
|
|
338
|
+
} catch {
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const previewTrustedSource = (
|
|
344
|
+
firstDeliveryAt: number,
|
|
345
|
+
): Pick<QStashPublishRequest, 'headers' | 'redact'> | undefined => {
|
|
346
|
+
const token = platformVercelOidcToken()
|
|
347
|
+
if (token === null) return undefined
|
|
348
|
+
const expiration = jwtExpirationMs(token)
|
|
349
|
+
if (
|
|
350
|
+
expiration === null ||
|
|
351
|
+
firstDeliveryAt + TRUSTED_SOURCE_DELIVERY_MARGIN_MS > expiration
|
|
352
|
+
) {
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
headers: { [TRUSTED_SOURCE_HEADER]: token },
|
|
357
|
+
redact: { header: [TRUSTED_SOURCE_HEADER] },
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const RETRYABLE_HTTP_CLIENT_STATUSES = new Set([408, 409, 425, 429])
|
|
362
|
+
|
|
363
|
+
const httpStatusOf = (error: unknown): number | undefined => {
|
|
364
|
+
try {
|
|
365
|
+
const record = recordOf(error)
|
|
366
|
+
if (!record) return undefined
|
|
367
|
+
const response = recordOf(record['response'])
|
|
368
|
+
const statuses = [
|
|
369
|
+
record['status'],
|
|
370
|
+
record['statusCode'],
|
|
371
|
+
response?.['status'],
|
|
372
|
+
].filter(
|
|
373
|
+
(value): value is number =>
|
|
374
|
+
typeof value === 'number' &&
|
|
375
|
+
Number.isInteger(value) &&
|
|
376
|
+
value >= 100 &&
|
|
377
|
+
value <= 599,
|
|
378
|
+
)
|
|
379
|
+
const [status] = statuses
|
|
380
|
+
if (status === undefined) return undefined
|
|
381
|
+
return statuses.every((candidate) => candidate === status)
|
|
382
|
+
? status
|
|
383
|
+
: undefined
|
|
384
|
+
} catch {
|
|
385
|
+
return undefined
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const classifyQStashPublishFailure = (error: unknown): unknown => {
|
|
390
|
+
const status = httpStatusOf(error)
|
|
391
|
+
const terminal =
|
|
392
|
+
status !== undefined &&
|
|
393
|
+
status >= 400 &&
|
|
394
|
+
status < 500 &&
|
|
395
|
+
!RETRYABLE_HTTP_CLIENT_STATUSES.has(status)
|
|
396
|
+
return classifySchedulerSendFailure(
|
|
397
|
+
error,
|
|
398
|
+
terminal ? 'terminal' : 'retryable',
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const parseTask = (body: string): ParsedQStashMessage | null => {
|
|
403
|
+
let value: unknown
|
|
404
|
+
try {
|
|
405
|
+
value = JSON.parse(body)
|
|
406
|
+
} catch {
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
const task = normalizeSchedulerTask(value)
|
|
410
|
+
const record = recordOf(value)
|
|
411
|
+
if (!task || !record) return null
|
|
412
|
+
|
|
413
|
+
const encodedOutage = record['_a2QStash']
|
|
414
|
+
if (encodedOutage === undefined) return { task }
|
|
415
|
+
const outage = recordOf(encodedOutage)
|
|
416
|
+
const generation = outage?.['generation']
|
|
417
|
+
if (
|
|
418
|
+
!outage ||
|
|
419
|
+
outage['version'] !== 1 ||
|
|
420
|
+
typeof generation !== 'number' ||
|
|
421
|
+
!Number.isSafeInteger(generation) ||
|
|
422
|
+
generation < 0 ||
|
|
423
|
+
generation >= Number.MAX_SAFE_INTEGER
|
|
424
|
+
) {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const rootDueAt = outage['rootDueAt']
|
|
429
|
+
if (task.kind === 'drain') {
|
|
430
|
+
if (
|
|
431
|
+
typeof rootDueAt !== 'number' ||
|
|
432
|
+
!Number.isInteger(rootDueAt) ||
|
|
433
|
+
rootDueAt % 1_000 !== 0
|
|
434
|
+
) {
|
|
435
|
+
return null
|
|
436
|
+
}
|
|
437
|
+
} else if (rootDueAt !== undefined) {
|
|
438
|
+
return null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
task,
|
|
443
|
+
outage: {
|
|
444
|
+
version: 1,
|
|
445
|
+
generation,
|
|
446
|
+
...(rootDueAt === undefined ? {} : { rootDueAt }),
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
|
452
|
+
const STORE_OUTAGE_BASE_DELAY_MS = 30_000
|
|
453
|
+
const STORE_OUTAGE_MAX_DELAY_MS = 24 * 60 * 60 * 1_000
|
|
454
|
+
|
|
455
|
+
const readBoundedBody = async (
|
|
456
|
+
request: Request,
|
|
457
|
+
maxBytes: number,
|
|
458
|
+
): Promise<string | null> => {
|
|
459
|
+
const declared = request.headers.get('content-length')
|
|
460
|
+
if (
|
|
461
|
+
declared !== null &&
|
|
462
|
+
/^\d+$/.test(declared) &&
|
|
463
|
+
Number(declared) > maxBytes
|
|
464
|
+
) {
|
|
465
|
+
return null
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (request.body === null) return ''
|
|
469
|
+
const reader = request.body.getReader()
|
|
470
|
+
const chunks: Uint8Array[] = []
|
|
471
|
+
let length = 0
|
|
472
|
+
try {
|
|
473
|
+
for (;;) {
|
|
474
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
475
|
+
const { done, value } = await reader.read()
|
|
476
|
+
if (done) break
|
|
477
|
+
length += value.byteLength
|
|
478
|
+
if (length > maxBytes) {
|
|
479
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
480
|
+
await reader.cancel().catch(() => {})
|
|
481
|
+
return null
|
|
482
|
+
}
|
|
483
|
+
chunks.push(value)
|
|
484
|
+
}
|
|
485
|
+
} finally {
|
|
486
|
+
reader.releaseLock()
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const bytes = new Uint8Array(length)
|
|
490
|
+
let offset = 0
|
|
491
|
+
for (const chunk of chunks) {
|
|
492
|
+
bytes.set(chunk, offset)
|
|
493
|
+
offset += chunk.byteLength
|
|
494
|
+
}
|
|
495
|
+
return new TextDecoder().decode(bytes)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const storeUnavailable = (error: unknown): error is A2Error =>
|
|
499
|
+
error instanceof A2Error && error.code === 'STORE_UNAVAILABLE'
|
|
500
|
+
|
|
501
|
+
const response = {
|
|
502
|
+
ok: (): Response => new Response('ok'),
|
|
503
|
+
unauthorized: (): Response => new Response('unauthorized', { status: 401 }),
|
|
504
|
+
malformed: (): Response =>
|
|
505
|
+
new Response('invalid a2 scheduler message', {
|
|
506
|
+
status: 489,
|
|
507
|
+
headers: { 'Upstash-NonRetryable-Error': 'true' },
|
|
508
|
+
}),
|
|
509
|
+
tooLarge: (): Response =>
|
|
510
|
+
new Response('a2 scheduler message is too large', {
|
|
511
|
+
status: 489,
|
|
512
|
+
headers: { 'Upstash-NonRetryable-Error': 'true' },
|
|
513
|
+
}),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const CALLBACK_PATH = '/api/a2/scheduler'
|
|
517
|
+
const DEFAULT_DEVELOPMENT_PORT = 3_000
|
|
518
|
+
|
|
519
|
+
const developmentCallbackUrl = (portValue: string | undefined): string => {
|
|
520
|
+
const port =
|
|
521
|
+
portValue === undefined || portValue === ''
|
|
522
|
+
? DEFAULT_DEVELOPMENT_PORT
|
|
523
|
+
: Number(portValue)
|
|
524
|
+
if (
|
|
525
|
+
!Number.isSafeInteger(port) ||
|
|
526
|
+
port < 1 ||
|
|
527
|
+
port > 65_535 ||
|
|
528
|
+
(portValue !== undefined && portValue !== '' && !/^\d+$/.test(portValue))
|
|
529
|
+
) {
|
|
530
|
+
throw new TypeError(
|
|
531
|
+
'qstash() could not infer url: PORT must be an integer from 1 to 65535 for local development',
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
return `http://localhost:${String(port)}${CALLBACK_PATH}`
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
type CallbackResolution = {
|
|
538
|
+
callbackUrl: string
|
|
539
|
+
/** An SDK override. Undefined preserves the SDK's environment behavior. */
|
|
540
|
+
developmentMode?: boolean
|
|
541
|
+
inferredVercelPreview?: true
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const inferCallbackUrl = (): CallbackResolution => {
|
|
545
|
+
const env = typeof process === 'undefined' ? undefined : process.env
|
|
546
|
+
const vercelEnv = env?.['VERCEL_ENV']
|
|
547
|
+
const variable =
|
|
548
|
+
env?.['VERCEL'] !== '1'
|
|
549
|
+
? null
|
|
550
|
+
: vercelEnv === 'production'
|
|
551
|
+
? 'VERCEL_PROJECT_PRODUCTION_URL'
|
|
552
|
+
: vercelEnv === 'preview'
|
|
553
|
+
? 'VERCEL_BRANCH_URL'
|
|
554
|
+
: null
|
|
555
|
+
if (variable !== null) {
|
|
556
|
+
const host = env?.[variable]
|
|
557
|
+
if (!host) {
|
|
558
|
+
throw new TypeError(
|
|
559
|
+
`qstash() could not infer url: ${variable} is missing`,
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
let origin: URL
|
|
563
|
+
try {
|
|
564
|
+
origin = new URL(`https://${host}`)
|
|
565
|
+
} catch {
|
|
566
|
+
throw new TypeError(
|
|
567
|
+
`qstash() could not infer url: ${variable} must be a hostname`,
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
if (
|
|
571
|
+
origin.host !== host ||
|
|
572
|
+
origin.pathname !== '/' ||
|
|
573
|
+
origin.search !== '' ||
|
|
574
|
+
origin.hash !== '' ||
|
|
575
|
+
origin.username !== '' ||
|
|
576
|
+
origin.password !== '' ||
|
|
577
|
+
origin.port !== ''
|
|
578
|
+
) {
|
|
579
|
+
throw new TypeError(
|
|
580
|
+
`qstash() could not infer url: ${variable} must be a hostname`,
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
callbackUrl: new URL(CALLBACK_PATH, origin).toString(),
|
|
585
|
+
developmentMode: false,
|
|
586
|
+
...(vercelEnv === 'preview' ? { inferredVercelPreview: true } : {}),
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (env === undefined) {
|
|
591
|
+
throw new TypeError(
|
|
592
|
+
'qstash() needs url when the runtime cannot identify local development',
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (env['NODE_ENV'] === 'production') {
|
|
597
|
+
throw new TypeError(
|
|
598
|
+
'qstash() needs url in production outside a Vercel production or preview deployment',
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
callbackUrl: developmentCallbackUrl(env['PORT']),
|
|
604
|
+
developmentMode: true,
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
|
|
609
|
+
const env = typeof process === 'undefined' ? undefined : process.env
|
|
610
|
+
const resolution: CallbackResolution =
|
|
611
|
+
options.url === undefined
|
|
612
|
+
? inferCallbackUrl()
|
|
613
|
+
: {
|
|
614
|
+
callbackUrl: options.url,
|
|
615
|
+
...(env?.['NODE_ENV'] === 'production'
|
|
616
|
+
? { developmentMode: false }
|
|
617
|
+
: {}),
|
|
618
|
+
}
|
|
619
|
+
const { callbackUrl, developmentMode, inferredVercelPreview } = resolution
|
|
620
|
+
let parsedUrl: URL
|
|
621
|
+
try {
|
|
622
|
+
parsedUrl = new URL(callbackUrl)
|
|
623
|
+
} catch {
|
|
624
|
+
throw new TypeError('qstash() needs an absolute callback url')
|
|
625
|
+
}
|
|
626
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
627
|
+
throw new TypeError('qstash() callback url must use http or https')
|
|
628
|
+
}
|
|
629
|
+
if (options.client !== undefined && options.transport !== undefined) {
|
|
630
|
+
throw new TypeError('qstash() cannot combine client and transport')
|
|
631
|
+
}
|
|
632
|
+
if (options.client !== undefined && options.token !== undefined) {
|
|
633
|
+
throw new TypeError('qstash() cannot combine client and token')
|
|
634
|
+
}
|
|
635
|
+
const url = parsedUrl.toString()
|
|
636
|
+
const configuredHeaders = callbackHeaders(options.headers)
|
|
637
|
+
const { retries } = options
|
|
638
|
+
if (
|
|
639
|
+
retries !== undefined &&
|
|
640
|
+
(!Number.isSafeInteger(retries) || retries < 0)
|
|
641
|
+
) {
|
|
642
|
+
throw new TypeError('qstash() retries must be a non-negative integer')
|
|
643
|
+
}
|
|
644
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
645
|
+
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1) {
|
|
646
|
+
throw new TypeError('qstash() maxBodyBytes must be a positive integer')
|
|
647
|
+
}
|
|
648
|
+
const clock = options.clock ?? SYSTEM_CLOCK
|
|
649
|
+
|
|
650
|
+
// Lazy: construction does no I/O and does not require the optional peer.
|
|
651
|
+
const transport = retryableLazy(() =>
|
|
652
|
+
options.transport
|
|
653
|
+
? Promise.resolve(options.transport)
|
|
654
|
+
: qstashTransport(options, developmentMode),
|
|
655
|
+
).get
|
|
656
|
+
|
|
657
|
+
const storeOutageDeduplicationId = (
|
|
658
|
+
task: SchedulerTask,
|
|
659
|
+
outage: QStashStoreOutage,
|
|
660
|
+
): Promise<string> => {
|
|
661
|
+
if (task.kind === 'append') {
|
|
662
|
+
return digestId([
|
|
663
|
+
'a2:scheduler-qstash:store-outage:append:v1',
|
|
664
|
+
url,
|
|
665
|
+
task.contract,
|
|
666
|
+
task.sessionId,
|
|
667
|
+
task.id,
|
|
668
|
+
outage.generation,
|
|
669
|
+
])
|
|
670
|
+
}
|
|
671
|
+
const { rootDueAt } = outage
|
|
672
|
+
if (rootDueAt === undefined) {
|
|
673
|
+
throw new TypeError('qstash store-outage drain needs a root dueAt')
|
|
674
|
+
}
|
|
675
|
+
return digestId([
|
|
676
|
+
'a2:scheduler-qstash:store-outage:drain:v1',
|
|
677
|
+
url,
|
|
678
|
+
task.contract,
|
|
679
|
+
task.sessionId,
|
|
680
|
+
rootDueAt,
|
|
681
|
+
outage.generation,
|
|
682
|
+
])
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const sendTask = async (
|
|
686
|
+
task: SchedulerTask,
|
|
687
|
+
outage?: QStashStoreOutage,
|
|
688
|
+
): Promise<void> => {
|
|
689
|
+
const nowMs = clock.now().getTime()
|
|
690
|
+
const requestedDueAt = task.dueAt
|
|
691
|
+
if (!Number.isFinite(requestedDueAt)) {
|
|
692
|
+
throw classifySchedulerSendFailure(
|
|
693
|
+
new TypeError('qstash scheduler dueAt must be a finite number'),
|
|
694
|
+
'terminal',
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
const dueAt =
|
|
698
|
+
requestedDueAt <= nowMs
|
|
699
|
+
? Math.floor(nowMs / 1_000) * 1_000
|
|
700
|
+
: Math.ceil(requestedDueAt / 1_000) * 1_000
|
|
701
|
+
const normalizedTask: SchedulerTask =
|
|
702
|
+
task.kind === 'append'
|
|
703
|
+
? {
|
|
704
|
+
version: 1,
|
|
705
|
+
kind: 'append',
|
|
706
|
+
id: task.id,
|
|
707
|
+
contract: task.contract,
|
|
708
|
+
sessionId: task.sessionId,
|
|
709
|
+
dueAt,
|
|
710
|
+
events: task.events,
|
|
711
|
+
}
|
|
712
|
+
: {
|
|
713
|
+
version: 1,
|
|
714
|
+
kind: 'drain',
|
|
715
|
+
contract: task.contract,
|
|
716
|
+
sessionId: task.sessionId,
|
|
717
|
+
dueAt,
|
|
718
|
+
}
|
|
719
|
+
const message: QStashMessage = {
|
|
720
|
+
...normalizedTask,
|
|
721
|
+
...(outage === undefined ? {} : { _a2QStash: outage }),
|
|
722
|
+
}
|
|
723
|
+
const sizeCheckedMessage: QStashMessage =
|
|
724
|
+
outage === undefined
|
|
725
|
+
? {
|
|
726
|
+
...normalizedTask,
|
|
727
|
+
_a2QStash:
|
|
728
|
+
normalizedTask.kind === 'append'
|
|
729
|
+
? { version: 1, generation: Number.MAX_SAFE_INTEGER }
|
|
730
|
+
: {
|
|
731
|
+
version: 1,
|
|
732
|
+
generation: Number.MAX_SAFE_INTEGER,
|
|
733
|
+
rootDueAt: dueAt,
|
|
734
|
+
},
|
|
735
|
+
}
|
|
736
|
+
: message
|
|
737
|
+
let bodyBytes: number
|
|
738
|
+
try {
|
|
739
|
+
bodyBytes = new TextEncoder().encode(
|
|
740
|
+
JSON.stringify(sizeCheckedMessage),
|
|
741
|
+
).byteLength
|
|
742
|
+
} catch (error) {
|
|
743
|
+
throw classifySchedulerSendFailure(error, 'terminal')
|
|
744
|
+
}
|
|
745
|
+
if (bodyBytes > maxBodyBytes) {
|
|
746
|
+
throw classifySchedulerSendFailure(
|
|
747
|
+
new RangeError('qstash scheduler message exceeds maxBodyBytes'),
|
|
748
|
+
'terminal',
|
|
749
|
+
)
|
|
750
|
+
}
|
|
751
|
+
const deduplicationId =
|
|
752
|
+
outage !== undefined
|
|
753
|
+
? await storeOutageDeduplicationId(task, outage)
|
|
754
|
+
: task.kind === 'append'
|
|
755
|
+
? await digestId(['a2:scheduler-qstash:append:v1', url, task.id])
|
|
756
|
+
: await digestId([
|
|
757
|
+
'a2:scheduler-qstash:v1',
|
|
758
|
+
url,
|
|
759
|
+
task.contract,
|
|
760
|
+
task.sessionId,
|
|
761
|
+
dueAt,
|
|
762
|
+
])
|
|
763
|
+
const trustedSource =
|
|
764
|
+
inferredVercelPreview === true
|
|
765
|
+
? previewTrustedSource(Math.max(nowMs, dueAt))
|
|
766
|
+
: undefined
|
|
767
|
+
const deliveryHeaders = publishHeaders(configuredHeaders, trustedSource)
|
|
768
|
+
const t = await transport()
|
|
769
|
+
try {
|
|
770
|
+
await t.publish({
|
|
771
|
+
url,
|
|
772
|
+
body: message,
|
|
773
|
+
...deliveryHeaders,
|
|
774
|
+
...(dueAt <= nowMs ? {} : { notBefore: dueAt / 1_000 }),
|
|
775
|
+
deduplicationId,
|
|
776
|
+
...(retries === undefined ? {} : { retries }),
|
|
777
|
+
})
|
|
778
|
+
} catch (error) {
|
|
779
|
+
throw classifyQStashPublishFailure(error)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const sendStoreOutageSuccessor = async (
|
|
784
|
+
task: SchedulerTask,
|
|
785
|
+
outage?: QStashStoreOutage,
|
|
786
|
+
): Promise<void> => {
|
|
787
|
+
const generation = (outage?.generation ?? -1) + 1
|
|
788
|
+
const exponent = Math.min(generation, 6)
|
|
789
|
+
const delayMs = Math.min(
|
|
790
|
+
STORE_OUTAGE_MAX_DELAY_MS,
|
|
791
|
+
STORE_OUTAGE_BASE_DELAY_MS * 4 ** exponent,
|
|
792
|
+
)
|
|
793
|
+
const nowMs = clock.now().getTime()
|
|
794
|
+
const dueAt = Math.ceil((nowMs + delayMs) / 1_000) * 1_000
|
|
795
|
+
const nextOutage: QStashStoreOutage =
|
|
796
|
+
task.kind === 'append'
|
|
797
|
+
? { version: 1, generation }
|
|
798
|
+
: { version: 1, generation, rootDueAt: outage?.rootDueAt ?? task.dueAt }
|
|
799
|
+
await sendTask({ ...task, dueAt }, nextOutage)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const successorDueAt = (message: { dueAt: number }): number => {
|
|
803
|
+
const minimumDueAt =
|
|
804
|
+
clock.now().getTime() +
|
|
805
|
+
DRAIN_TIMINGS.claimTtlMs +
|
|
806
|
+
DRAIN_TIMINGS.watchdogGraceMs
|
|
807
|
+
const beats = Math.max(
|
|
808
|
+
1,
|
|
809
|
+
Math.ceil(
|
|
810
|
+
(minimumDueAt - message.dueAt) / DRAIN_TIMINGS.claimHeartbeatMs,
|
|
811
|
+
),
|
|
812
|
+
)
|
|
813
|
+
return message.dueAt + beats * DRAIN_TIMINGS.claimHeartbeatMs
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
async schedule(task) {
|
|
818
|
+
await sendTask(task)
|
|
819
|
+
},
|
|
820
|
+
|
|
821
|
+
handler(...servers: DrainableServer[]) {
|
|
822
|
+
assertSchedulerTargets(servers, { allowStructural: true })
|
|
823
|
+
const byContract = new Map<string, DrainableServer>()
|
|
824
|
+
for (const server of servers) {
|
|
825
|
+
byContract.set(server.contract.name, server)
|
|
826
|
+
}
|
|
827
|
+
return async (req: Request): Promise<Response> => {
|
|
828
|
+
const signature = req.headers.get('upstash-signature')
|
|
829
|
+
if (!signature) return response.unauthorized()
|
|
830
|
+
|
|
831
|
+
const body = await readBoundedBody(req, maxBodyBytes)
|
|
832
|
+
if (body === null) return response.tooLarge()
|
|
833
|
+
const upstashRegion = req.headers.get('upstash-region')
|
|
834
|
+
const t = await transport()
|
|
835
|
+
try {
|
|
836
|
+
const valid = await t.verify({
|
|
837
|
+
signature,
|
|
838
|
+
body,
|
|
839
|
+
url,
|
|
840
|
+
...(upstashRegion === null ? {} : { upstashRegion }),
|
|
841
|
+
})
|
|
842
|
+
if (!valid) return response.unauthorized()
|
|
843
|
+
} catch {
|
|
844
|
+
return response.unauthorized()
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const parsed = parseTask(body)
|
|
848
|
+
if (!parsed) return response.malformed()
|
|
849
|
+
const { task, outage } = parsed
|
|
850
|
+
|
|
851
|
+
const server = byContract.get(task.contract)
|
|
852
|
+
if (!server) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`a2 scheduler: no server for contract '${task.contract}' — pass it to schedulerHandler(...)`,
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
const internals = serverInternals.get(server)
|
|
858
|
+
if (task.kind === 'append') {
|
|
859
|
+
try {
|
|
860
|
+
await deliverSchedulerAppend(server, task)
|
|
861
|
+
} catch (error) {
|
|
862
|
+
if (!storeUnavailable(error)) throw error
|
|
863
|
+
await sendStoreOutageSuccessor(task, outage)
|
|
864
|
+
}
|
|
865
|
+
return response.ok()
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const nextDueAt = successorDueAt(task)
|
|
869
|
+
let result: DrainResult | { settled: boolean; outcome: 'unknown' }
|
|
870
|
+
try {
|
|
871
|
+
result = internals
|
|
872
|
+
? await internals.schedulerDrain(task.sessionId, {
|
|
873
|
+
watchdogDueAt: nextDueAt,
|
|
874
|
+
})
|
|
875
|
+
: {
|
|
876
|
+
...(await server.drain(task.sessionId)),
|
|
877
|
+
outcome: 'unknown' as const,
|
|
878
|
+
}
|
|
879
|
+
} catch (error) {
|
|
880
|
+
if (!storeUnavailable(error)) throw error
|
|
881
|
+
await sendStoreOutageSuccessor(task, outage)
|
|
882
|
+
return response.ok()
|
|
883
|
+
}
|
|
884
|
+
if (result.settled) return response.ok()
|
|
885
|
+
|
|
886
|
+
if (result.outcome === 'busy') {
|
|
887
|
+
if (result.schedulerArm) await result.schedulerArm
|
|
888
|
+
else {
|
|
889
|
+
await sendTask({
|
|
890
|
+
version: 1,
|
|
891
|
+
kind: 'drain',
|
|
892
|
+
contract: task.contract,
|
|
893
|
+
sessionId: task.sessionId,
|
|
894
|
+
dueAt: nextDueAt,
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
return response.ok()
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// QStash's per-message retry default can be lower than A2's failure
|
|
901
|
+
// budget. Hand stalled work to a fresh durable message so recovery
|
|
902
|
+
// continues until the store reports success or dead-letter. If this
|
|
903
|
+
// publish fails, the throw keeps the current delivery retryable.
|
|
904
|
+
await sendTask({
|
|
905
|
+
version: 1,
|
|
906
|
+
kind: 'drain',
|
|
907
|
+
contract: task.contract,
|
|
908
|
+
sessionId: task.sessionId,
|
|
909
|
+
dueAt: nextDueAt,
|
|
910
|
+
})
|
|
911
|
+
return response.ok()
|
|
912
|
+
}
|
|
913
|
+
},
|
|
914
|
+
}
|
|
915
|
+
}
|