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.
Files changed (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. 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
+ }