experimental-a2 0.6.0 → 0.7.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 +22 -0
- package/dist/ai-server.d.ts +4 -5
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +8 -7
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +334 -2
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +1 -1
- package/dist/client.d.ts +202 -2
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1025 -1
- package/dist/client.js.map +1 -0
- package/dist/errors-BQuJpe82.js.map +1 -1
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -0
- package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
- package/dist/internal-DRXJ56EI.js.map +1 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.js +4 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +4 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
- package/dist/server-286j79Mt.js.map +1 -0
- package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
- package/dist/server-DgXmORIq.d.ts.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
- package/dist/store-flRz1OWh.d.ts.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +1 -59
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
- package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +1 -81
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
- package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -3
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +3 -4
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +1 -72
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
- package/dist/wire-B6te_wns.js.map +1 -0
- package/docs/guides/03-react.mdx +20 -28
- package/docs/guides/05-production.mdx +9 -11
- package/docs/guides/06-ai-agents.mdx +8 -14
- package/docs/guides/09-presence.mdx +14 -19
- package/docs/guides/10-transports.mdx +104 -86
- package/docs/reference/01-api.mdx +143 -277
- package/docs/reference/02-errors.mdx +5 -7
- package/package.json +1 -14
- package/src/ai-server.ts +9 -5
- package/src/client.ts +2 -2
- package/src/errors.ts +1 -0
- package/src/internal.ts +3 -62
- package/src/push-envelope.ts +24 -21
- package/src/scheduler-qstash.ts +3 -3
- package/src/scheduler-vercel.ts +2 -2
- package/src/server-fetch.ts +344 -0
- package/src/server.ts +73 -225
- package/src/session-socket.ts +36 -20
- package/src/sse.ts +2 -2
- package/src/store-memory.ts +0 -81
- package/src/store-postgres.ts +0 -100
- package/src/store-redis-core.ts +47 -211
- package/src/store-redis-http.ts +0 -1
- package/src/store-redis.ts +0 -1
- package/src/store-sqlite.ts +0 -119
- package/src/store.ts +0 -60
- package/src/wire.ts +2 -1
- package/dist/ai-D_PGS-JR.d.ts +0 -334
- package/dist/ai-D_PGS-JR.d.ts.map +0 -1
- package/dist/cli-B3VuxoDe.js +0 -599
- package/dist/cli-B3VuxoDe.js.map +0 -1
- package/dist/cli-bin.d.ts +0 -1
- package/dist/cli-bin.js +0 -7
- package/dist/cli-bin.js.map +0 -1
- package/dist/cli.d.ts +0 -20
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -2
- package/dist/client-CdMqi7mC.d.ts +0 -202
- package/dist/client-CdMqi7mC.d.ts.map +0 -1
- package/dist/client-Dj5d3SP_.js +0 -1026
- package/dist/client-Dj5d3SP_.js.map +0 -1
- package/dist/devtools-J_jZ2vQf.d.ts +0 -152
- package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
- package/dist/devtools-kJJaORn-.js +0 -340
- package/dist/devtools-kJJaORn-.js.map +0 -1
- package/dist/devtools-server.browser.d.ts +0 -1
- package/dist/devtools-server.browser.js +0 -6
- package/dist/devtools-server.browser.js.map +0 -1
- package/dist/devtools-server.d.ts +0 -23
- package/dist/devtools-server.d.ts.map +0 -1
- package/dist/devtools-server.js +0 -1270
- package/dist/devtools-server.js.map +0 -1
- package/dist/devtools.d.ts +0 -2
- package/dist/devtools.js +0 -2
- package/dist/errors-W6nwJ-fm.d.ts +0 -21
- package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
- package/dist/http.d.ts +0 -151
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js +0 -706
- package/dist/http.js.map +0 -1
- package/dist/inspection-DaxB5jM2.js +0 -13
- package/dist/inspection-DaxB5jM2.js.map +0 -1
- package/dist/internal-DstsI6Re.js.map +0 -1
- package/dist/platform-B4TnJtWu.js +0 -34
- package/dist/platform-B4TnJtWu.js.map +0 -1
- package/dist/server-DpvjhdoE.d.ts.map +0 -1
- package/dist/server-Duw6MVlB.js.map +0 -1
- package/dist/store-DysUkTH3.d.ts.map +0 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
- package/dist/testing.browser.d.ts +0 -1
- package/dist/testing.browser.js +0 -6
- package/dist/testing.browser.js.map +0 -1
- package/dist/testing.d.ts +0 -32
- package/dist/testing.d.ts.map +0 -1
- package/dist/testing.js +0 -103
- package/dist/testing.js.map +0 -1
- package/dist/wire-BFQmSJ-9.js.map +0 -1
- package/docs/guides/07-devtools.mdx +0 -229
- package/src/cli-bin.ts +0 -5
- package/src/cli.ts +0 -1046
- package/src/devtools-app.ts +0 -989
- package/src/devtools-server.browser.ts +0 -5
- package/src/devtools-server.ts +0 -604
- package/src/devtools.ts +0 -716
- package/src/http.ts +0 -394
- package/src/inspection.ts +0 -39
- package/src/testing.browser.ts +0 -5
- package/src/testing.ts +0 -185
package/src/http.ts
DELETED
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* experimental-a2/http — the wire's server half (specs/a2-api.md §9–10, §13).
|
|
3
|
-
*
|
|
4
|
-
* A thin public assembly. The surface: `handle` — GET, POST, and the
|
|
5
|
-
* socket's frames parsed into intents (stream, history, push,
|
|
6
|
-
* ws-upgrade) with `before`/`after` hooks around them for policy —
|
|
7
|
-
* plus `A2Intent`/`A2Outcome`/`UpgradeFn`/`A2Socket` (its argument
|
|
8
|
-
* types), `errorResponse`/`deserializeError` (the A2Error wire pair
|
|
9
|
-
* both sides branch on), and `schedulerHandler` (the scheduler
|
|
10
|
-
* callback mount). The machinery lives in internal modules — the SSE
|
|
11
|
-
* framing in sse.ts, the push-envelope trust boundary in
|
|
12
|
-
* push-envelope.ts, the socket shell and multiplexed handler in
|
|
13
|
-
* session-socket.ts — because nothing outside `handle` is sanctioned
|
|
14
|
-
* to compose them. Policy — auth, caching, quotas — never enters this
|
|
15
|
-
* module: the hooks own it.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { A2Error } from './errors.ts'
|
|
19
|
-
import {
|
|
20
|
-
assertServerSchedulerBindings,
|
|
21
|
-
assertSchedulerTargets,
|
|
22
|
-
schedulerForServer,
|
|
23
|
-
} from './internal.ts'
|
|
24
|
-
import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
|
|
25
|
-
import type {
|
|
26
|
-
A2Server,
|
|
27
|
-
DrainableServer,
|
|
28
|
-
PushedEvent,
|
|
29
|
-
PushedPresence,
|
|
30
|
-
} from './server.ts'
|
|
31
|
-
import { sseResponse } from './sse.ts'
|
|
32
|
-
import { invalidPushBody, parsePushBody } from './push-envelope.ts'
|
|
33
|
-
import {
|
|
34
|
-
sessionsSocket,
|
|
35
|
-
type A2Socket,
|
|
36
|
-
type SocketSession,
|
|
37
|
-
} from './session-socket.ts'
|
|
38
|
-
import {
|
|
39
|
-
asA2Error,
|
|
40
|
-
errorFromWire,
|
|
41
|
-
errorStatus,
|
|
42
|
-
errorToWire,
|
|
43
|
-
eventToWire,
|
|
44
|
-
} from './wire.ts'
|
|
45
|
-
|
|
46
|
-
export type { A2Socket } from './session-socket.ts'
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Create one scheduler callback route for servers sharing the same configured
|
|
50
|
-
* scheduler. Configuration mistakes throw immediately while the route module
|
|
51
|
-
* is evaluated, before a delivery can be acknowledged by the wrong adapter.
|
|
52
|
-
*/
|
|
53
|
-
export function schedulerHandler(
|
|
54
|
-
firstServer: DrainableServer,
|
|
55
|
-
...servers: DrainableServer[]
|
|
56
|
-
): (req: Request) => Promise<Response> {
|
|
57
|
-
if (firstServer === undefined) {
|
|
58
|
-
throw new TypeError('schedulerHandler() requires at least one A2 server')
|
|
59
|
-
}
|
|
60
|
-
const scheduler = schedulerForServer(firstServer)
|
|
61
|
-
const targets = [firstServer, ...servers]
|
|
62
|
-
assertSchedulerTargets(targets, { allowStructural: false })
|
|
63
|
-
assertServerSchedulerBindings(scheduler, targets)
|
|
64
|
-
return scheduler.handler(...targets)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Serialize an error into the documented wire shape,
|
|
69
|
-
* `{ error: { code, message, details } }`, with the mapped status
|
|
70
|
-
* (400 for caller bugs, 503 for STORE_UNAVAILABLE). Non-A2Errors become a
|
|
71
|
-
* 503 STORE_UNAVAILABLE — from the client's perspective an unknown server
|
|
72
|
-
* failure is retryable-once, not a protocol contract. The natural
|
|
73
|
-
* return value of a `before` hook that wants the wire's own error
|
|
74
|
-
* shapes.
|
|
75
|
-
*/
|
|
76
|
-
export function errorResponse(error: unknown): Response {
|
|
77
|
-
const a2error = asA2Error(error)
|
|
78
|
-
return Response.json(errorToWire(a2error), {
|
|
79
|
-
status: errorStatus(a2error.code),
|
|
80
|
-
})
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The other half of the wire pair: rebuild an `A2Error` from a response
|
|
85
|
-
* body, or null if the body isn't one. Client `push` uses it so both
|
|
86
|
-
* sides branch on identical codes.
|
|
87
|
-
*/
|
|
88
|
-
export function deserializeError(body: unknown): A2Error | null {
|
|
89
|
-
return errorFromWire(body)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── handle: the request protocol, parsed ────────────────────────
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* What a request (or socket frame) asks of a session, parsed — the
|
|
96
|
-
* argument policy hooks branch on. `stream` covers the SSE GET and the
|
|
97
|
-
* socket's subscribe frames; `push` covers the HTTP POST and the
|
|
98
|
-
* socket's push frames; `history` is the bounded cold read the
|
|
99
|
-
* client's `loadHistory` rides; `ws-upgrade` is the upgrade GET before
|
|
100
|
-
* any frame flows.
|
|
101
|
-
*/
|
|
102
|
-
export type A2Intent<D extends EventDefs = EventDefs> =
|
|
103
|
-
| { type: 'ws-upgrade' }
|
|
104
|
-
| {
|
|
105
|
-
type: 'stream'
|
|
106
|
-
sessionId: string
|
|
107
|
-
startAfter: number
|
|
108
|
-
transport: 'sse' | 'ws'
|
|
109
|
-
}
|
|
110
|
-
| { type: 'history'; sessionId: string; gte: number; lte: number }
|
|
111
|
-
| {
|
|
112
|
-
type: 'push'
|
|
113
|
-
sessionId: string
|
|
114
|
-
/** Parsed and branded, payloads not yet validated — `type`
|
|
115
|
-
* autocompletes the contract's names but is any string off the
|
|
116
|
-
* wire; `append` rejects unknown types after the hooks run. */
|
|
117
|
-
events: Array<PushedEvent & { type: SuggestedEventType<D> }>
|
|
118
|
-
presence?: PushedPresence
|
|
119
|
-
transport: 'http' | 'ws'
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** The contract's event names for editor completion, without excluding
|
|
123
|
-
* the wire's reality (any string can arrive; validation is later). */
|
|
124
|
-
type SuggestedEventType<D extends EventDefs> =
|
|
125
|
-
(keyof D & string) | (string & Record<never, never>)
|
|
126
|
-
|
|
127
|
-
/** What the library did with an allowed intent — `after`'s view.
|
|
128
|
-
* `covered` = the closed range is fully present (an immutable slice of
|
|
129
|
-
* the append-only log), the one caching-relevant fact the library
|
|
130
|
-
* owns; policy over it belongs to the app's `after`. */
|
|
131
|
-
export type A2Outcome<D extends EventDefs = EventDefs> =
|
|
132
|
-
| { type: 'stream' }
|
|
133
|
-
| { type: 'history'; covered: boolean; events: ContractEvent<D>[] }
|
|
134
|
-
| { type: 'push'; appended: ContractEvent<D>[] }
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* The platform's socket-upgrade seam: given the attach callback, return
|
|
138
|
-
* the 101 response. On Vercel:
|
|
139
|
-
* `upgrade: (attach) => experimental_upgradeWebSocket(attach)`.
|
|
140
|
-
*/
|
|
141
|
-
export type UpgradeFn = (
|
|
142
|
-
attach: (socket: A2Socket) => void,
|
|
143
|
-
) => Response | Promise<Response>
|
|
144
|
-
|
|
145
|
-
const parseHistoryBounds = (
|
|
146
|
-
gte: string | null,
|
|
147
|
-
lte: string | null,
|
|
148
|
-
): { gte: number; lte: number } => {
|
|
149
|
-
if (gte === null || lte === null) {
|
|
150
|
-
throw new A2Error(
|
|
151
|
-
'INVALID_PAYLOAD',
|
|
152
|
-
'a history read takes both gte and lte',
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
const bounds = { gte: Number(gte), lte: Number(lte) }
|
|
156
|
-
for (const [name, value] of Object.entries(bounds)) {
|
|
157
|
-
if (!Number.isSafeInteger(value) || value < 0) {
|
|
158
|
-
throw new A2Error(
|
|
159
|
-
'INVALID_PAYLOAD',
|
|
160
|
-
`history ${name} must be a non-negative safe integer`,
|
|
161
|
-
)
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (bounds.gte > bounds.lte) {
|
|
165
|
-
throw new A2Error(
|
|
166
|
-
'INVALID_PAYLOAD',
|
|
167
|
-
'history gte must be less than or equal to lte',
|
|
168
|
-
)
|
|
169
|
-
}
|
|
170
|
-
return bounds
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const parseResumeIndex = (raw: string | null): number => {
|
|
174
|
-
if (raw === null) return 0
|
|
175
|
-
const index = Number(raw)
|
|
176
|
-
if (!Number.isSafeInteger(index) || index < 0) {
|
|
177
|
-
throw new A2Error(
|
|
178
|
-
'INVALID_PAYLOAD',
|
|
179
|
-
'stream index must be a non-negative safe integer',
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
return index
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** One bag: the policy hooks and the capability options — the names
|
|
186
|
-
* distinguish them, so `handle(server, { presence: true })` and
|
|
187
|
-
* `handle(server, { before, upgrade })` both read naturally. */
|
|
188
|
-
export type HandleOptions<D extends EventDefs = EventDefs> = {
|
|
189
|
-
/** Return a Response to deny; thrown errors are treated as
|
|
190
|
-
* infrastructure failures and answer with the retryable wire shape. */
|
|
191
|
-
before?(args: {
|
|
192
|
-
request: Request
|
|
193
|
-
intent: A2Intent<D>
|
|
194
|
-
}): Response | undefined | void | Promise<Response | undefined | void>
|
|
195
|
-
after?(args: {
|
|
196
|
-
request: Request
|
|
197
|
-
intent: A2Intent<D>
|
|
198
|
-
outcome: A2Outcome<D>
|
|
199
|
-
response: Response
|
|
200
|
-
}): Response | undefined | void | Promise<Response | undefined | void>
|
|
201
|
-
upgrade?: UpgradeFn
|
|
202
|
-
presence?: boolean
|
|
203
|
-
deadline?: number
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* The session route pair as one call: `export const { GET, POST } =
|
|
208
|
-
* handle(server, options?)` serves the live stream (SSE GET, resumed
|
|
209
|
-
* at `index`), the history slice (GET with `gte`/`lte`), the push
|
|
210
|
-
* envelope (POST), and — with `options.upgrade` — the multiplexed
|
|
211
|
-
* WebSocket, all through one pair of policy hooks.
|
|
212
|
-
*
|
|
213
|
-
* Parsing is protocol, hooks are policy: a request that fails to parse
|
|
214
|
-
* (missing sessionId, malformed bounds, bad push envelope) answers
|
|
215
|
-
* INVALID_PAYLOAD on the wire before any hook runs. `before` sees
|
|
216
|
-
* every parsed intent — HTTP requests and socket frames alike, with
|
|
217
|
-
* `request` always the original (for socket frames: upgrade) Request —
|
|
218
|
-
* and short-circuits by returning a Response: over HTTP it is the
|
|
219
|
-
* response verbatim; over the socket it is translated to the wire's
|
|
220
|
-
* own vocabulary (subscribe → `unsubscribed { reason: 'subscribe
|
|
221
|
-
* rejected' }`, push → an INVALID_PAYLOAD error ack), because a
|
|
222
|
-
* Response body cannot cross a socket. `after` runs only where the
|
|
223
|
-
* library produced an HTTP response — never after a short-circuit,
|
|
224
|
-
* never for socket frames — and may mutate `response.headers` in place
|
|
225
|
-
* or return a replacement (history caching policy lives here, on
|
|
226
|
-
* `outcome.covered`). `options.deadline` (epoch ms) closes sockets
|
|
227
|
-
* cleanly ahead of a known platform deadline, so clients reconnect on
|
|
228
|
-
* our schedule.
|
|
229
|
-
*/
|
|
230
|
-
export function handle<
|
|
231
|
-
D extends EventDefs,
|
|
232
|
-
P extends PresenceDefs = Record<never, never>,
|
|
233
|
-
>(
|
|
234
|
-
server: A2Server<D, P>,
|
|
235
|
-
options?: HandleOptions<D>,
|
|
236
|
-
): {
|
|
237
|
-
GET(req: Request): Promise<Response>
|
|
238
|
-
POST(req: Request): Promise<Response>
|
|
239
|
-
} {
|
|
240
|
-
const presence = options?.presence === true
|
|
241
|
-
|
|
242
|
-
const before = async (
|
|
243
|
-
request: Request,
|
|
244
|
-
intent: A2Intent<D>,
|
|
245
|
-
): Promise<Response | null> => {
|
|
246
|
-
const result = await options?.before?.({ request, intent })
|
|
247
|
-
return result instanceof Response ? result : null
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const decorate = async (
|
|
251
|
-
request: Request,
|
|
252
|
-
intent: A2Intent<D>,
|
|
253
|
-
outcome: A2Outcome<D>,
|
|
254
|
-
response: Response,
|
|
255
|
-
): Promise<Response> => {
|
|
256
|
-
const replaced = await options?.after?.({
|
|
257
|
-
request,
|
|
258
|
-
intent,
|
|
259
|
-
outcome,
|
|
260
|
-
response,
|
|
261
|
-
})
|
|
262
|
-
return replaced instanceof Response ? replaced : response
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Socket frames re-enter `before` with the upgrade Request — one
|
|
266
|
-
// policy point for both wires, and the fix for pushes that were
|
|
267
|
-
// gated only at subscribe time.
|
|
268
|
-
const attach =
|
|
269
|
-
(request: Request) =>
|
|
270
|
-
(socket: A2Socket): void => {
|
|
271
|
-
sessionsSocket<D>(
|
|
272
|
-
async (sessionId, startAfter) => {
|
|
273
|
-
const short = await before(request, {
|
|
274
|
-
type: 'stream',
|
|
275
|
-
sessionId,
|
|
276
|
-
startAfter,
|
|
277
|
-
transport: 'ws',
|
|
278
|
-
})
|
|
279
|
-
return short === null ? server.session(sessionId) : null
|
|
280
|
-
},
|
|
281
|
-
socket,
|
|
282
|
-
{
|
|
283
|
-
presence,
|
|
284
|
-
...(options?.deadline === undefined
|
|
285
|
-
? {}
|
|
286
|
-
: { deadline: options.deadline }),
|
|
287
|
-
gatePush: async (sessionId, events) => {
|
|
288
|
-
const short = await before(request, {
|
|
289
|
-
type: 'push',
|
|
290
|
-
sessionId,
|
|
291
|
-
events,
|
|
292
|
-
transport: 'ws',
|
|
293
|
-
})
|
|
294
|
-
// The denial Response cannot cross the socket; INVALID_PAYLOAD
|
|
295
|
-
// is the one caller-fault code the client never retries — a
|
|
296
|
-
// retryable code would spin retries against a standing denial.
|
|
297
|
-
return short === null
|
|
298
|
-
? null
|
|
299
|
-
: new A2Error('INVALID_PAYLOAD', 'push rejected')
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
)
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return {
|
|
306
|
-
async GET(req: Request): Promise<Response> {
|
|
307
|
-
try {
|
|
308
|
-
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
309
|
-
const short = await before(req, { type: 'ws-upgrade' })
|
|
310
|
-
if (short !== null) return short
|
|
311
|
-
if (options?.upgrade === undefined) {
|
|
312
|
-
return new Response(
|
|
313
|
-
"WebSocket upgrade requested, but handle() has no upgrade implementation — pass options.upgrade, e.g. { upgrade: (attach) => experimental_upgradeWebSocket(attach) } from '@vercel/functions'",
|
|
314
|
-
{ status: 426 },
|
|
315
|
-
)
|
|
316
|
-
}
|
|
317
|
-
return await options.upgrade(attach(req))
|
|
318
|
-
}
|
|
319
|
-
const { searchParams } = new URL(req.url)
|
|
320
|
-
const sessionId = searchParams.get('sessionId')
|
|
321
|
-
if (sessionId === null || sessionId.length === 0) {
|
|
322
|
-
throw new A2Error('INVALID_PAYLOAD', 'missing sessionId')
|
|
323
|
-
}
|
|
324
|
-
const gteRaw = searchParams.get('gte')
|
|
325
|
-
const lteRaw = searchParams.get('lte')
|
|
326
|
-
if (gteRaw !== null || lteRaw !== null) {
|
|
327
|
-
const bounds = parseHistoryBounds(gteRaw, lteRaw)
|
|
328
|
-
const intent: A2Intent<D> = { type: 'history', sessionId, ...bounds }
|
|
329
|
-
const short = await before(req, intent)
|
|
330
|
-
if (short !== null) return short
|
|
331
|
-
const events = await server.session(sessionId).history(bounds)
|
|
332
|
-
const covered = events.length === bounds.lte - bounds.gte + 1
|
|
333
|
-
return await decorate(
|
|
334
|
-
req,
|
|
335
|
-
intent,
|
|
336
|
-
{ type: 'history', covered, events },
|
|
337
|
-
Response.json(events.map(eventToWire)),
|
|
338
|
-
)
|
|
339
|
-
}
|
|
340
|
-
const startAfter = parseResumeIndex(searchParams.get('index'))
|
|
341
|
-
const intent: A2Intent<D> = {
|
|
342
|
-
type: 'stream',
|
|
343
|
-
sessionId,
|
|
344
|
-
startAfter,
|
|
345
|
-
transport: 'sse',
|
|
346
|
-
}
|
|
347
|
-
const short = await before(req, intent)
|
|
348
|
-
if (short !== null) return short
|
|
349
|
-
const target = server.session(sessionId) as unknown as SocketSession
|
|
350
|
-
const response = sseResponse(
|
|
351
|
-
presence
|
|
352
|
-
? target.stream({ startAfter, presence: true })
|
|
353
|
-
: target.stream({ startAfter }),
|
|
354
|
-
)
|
|
355
|
-
return await decorate(req, intent, { type: 'stream' }, response)
|
|
356
|
-
} catch (err) {
|
|
357
|
-
return errorResponse(err)
|
|
358
|
-
}
|
|
359
|
-
},
|
|
360
|
-
|
|
361
|
-
async POST(req: Request): Promise<Response> {
|
|
362
|
-
try {
|
|
363
|
-
const body = await parsePushBody(req)
|
|
364
|
-
const intent: A2Intent<D> = {
|
|
365
|
-
type: 'push',
|
|
366
|
-
sessionId: body.sessionId,
|
|
367
|
-
events: body.events,
|
|
368
|
-
...(body.presence === undefined ? {} : { presence: body.presence }),
|
|
369
|
-
transport: 'http',
|
|
370
|
-
}
|
|
371
|
-
const short = await before(req, intent)
|
|
372
|
-
if (short !== null) return short
|
|
373
|
-
const session = server.session(body.sessionId)
|
|
374
|
-
if (body.presence !== undefined) {
|
|
375
|
-
const target = session as unknown as SocketSession
|
|
376
|
-
if (typeof target.setPresence !== 'function') {
|
|
377
|
-
throw invalidPushBody('presence is not declared by this contract')
|
|
378
|
-
}
|
|
379
|
-
await target.setPresence(body.presence)
|
|
380
|
-
}
|
|
381
|
-
const appended =
|
|
382
|
-
body.events.length === 0 ? [] : await session.append(...body.events)
|
|
383
|
-
return await decorate(
|
|
384
|
-
req,
|
|
385
|
-
intent,
|
|
386
|
-
{ type: 'push', appended },
|
|
387
|
-
Response.json(appended),
|
|
388
|
-
)
|
|
389
|
-
} catch (err) {
|
|
390
|
-
return errorResponse(err)
|
|
391
|
-
}
|
|
392
|
-
},
|
|
393
|
-
}
|
|
394
|
-
}
|
package/src/inspection.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Private bridge between `createServer` and `experimental-a2/devtools/server`.
|
|
3
|
-
* Servers keep their store closure private; the WeakMap grants A2's own
|
|
4
|
-
* read-only devtools entry point access without adding public methods to
|
|
5
|
-
* application server handles.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { StoredEvent, StoredSessionPage, StoredSnapshot } from './store.ts'
|
|
9
|
-
|
|
10
|
-
export class InspectionUnsupportedError extends Error {}
|
|
11
|
-
|
|
12
|
-
export type ServerInspection = {
|
|
13
|
-
listSessions(options: {
|
|
14
|
-
cursor?: string
|
|
15
|
-
limit: number
|
|
16
|
-
}): Promise<StoredSessionPage>
|
|
17
|
-
readSessionPage(
|
|
18
|
-
sessionId: string,
|
|
19
|
-
options: { afterIndex: number; throughIndex?: number; limit: number },
|
|
20
|
-
): Promise<{
|
|
21
|
-
events: StoredEvent[]
|
|
22
|
-
snapshots: StoredSnapshot[]
|
|
23
|
-
throughIndex: number
|
|
24
|
-
nextIndex: number | null
|
|
25
|
-
}>
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const serverInspection: WeakMap<object, ServerInspection> = new WeakMap()
|
|
29
|
-
|
|
30
|
-
/** Owned live-inspection cadence; mutable only for white-box tests. */
|
|
31
|
-
export const DEVTOOLS_TIMINGS: {
|
|
32
|
-
activeMs: number
|
|
33
|
-
idleMs: number
|
|
34
|
-
heartbeatMs: number
|
|
35
|
-
} = {
|
|
36
|
-
activeMs: 250,
|
|
37
|
-
idleMs: 1_000,
|
|
38
|
-
heartbeatMs: 15_000,
|
|
39
|
-
}
|
package/src/testing.browser.ts
DELETED
package/src/testing.ts
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Offline helpers for loading a captured A2 session and replaying its events
|
|
3
|
-
* through the application's current reducers. This module never constructs a
|
|
4
|
-
* server or dispatches handlers.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { readFile } from 'node:fs/promises'
|
|
8
|
-
import { fileURLToPath } from 'node:url'
|
|
9
|
-
import type { Contract, ContractEvent, EventDefs } from './contract.ts'
|
|
10
|
-
import {
|
|
11
|
-
parseDevtoolsCapture,
|
|
12
|
-
verifyDevtoolsCapture,
|
|
13
|
-
type DevtoolsCapture,
|
|
14
|
-
type DevtoolsCaptureEvent,
|
|
15
|
-
} from './devtools.ts'
|
|
16
|
-
import type { Reducer } from './reducer.ts'
|
|
17
|
-
import { validateSync } from './validate.ts'
|
|
18
|
-
|
|
19
|
-
export type CaptureSource = string | URL
|
|
20
|
-
|
|
21
|
-
export type LoadedCapture<D extends EventDefs> = {
|
|
22
|
-
/** The verified capture, including its footer and operational records. */
|
|
23
|
-
readonly capture: DevtoolsCapture
|
|
24
|
-
/** The contract used to validate and type the captured event history. */
|
|
25
|
-
readonly contract: Contract<D>
|
|
26
|
-
/** Contract-typed public events with `createdAt` revived to `Date`. */
|
|
27
|
-
readonly events: readonly ContractEvent<D>[]
|
|
28
|
-
/** Unmodified wire events, including dispatch and failure metadata. */
|
|
29
|
-
readonly rawEvents: readonly DevtoolsCaptureEvent[]
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export type ReplayCaptureOptions = {
|
|
33
|
-
/** Fold events through this log index, inclusive. Zero means no events. */
|
|
34
|
-
throughIndex?: number
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export type CaptureReplay<S> = {
|
|
38
|
-
readonly state: S
|
|
39
|
-
/** The last event index actually folded, or zero for the initial state. */
|
|
40
|
-
readonly index: number
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const captureError = (message: string): TypeError =>
|
|
44
|
-
new TypeError(`invalid A2 capture: ${message}`)
|
|
45
|
-
|
|
46
|
-
const typedEvents = <D extends EventDefs>(
|
|
47
|
-
capture: DevtoolsCapture,
|
|
48
|
-
contract: Contract<D>,
|
|
49
|
-
): ContractEvent<D>[] => {
|
|
50
|
-
const events: ContractEvent<D>[] = []
|
|
51
|
-
for (const event of capture.events) {
|
|
52
|
-
const schema = Object.hasOwn(contract.events, event.type)
|
|
53
|
-
? contract.events[event.type]
|
|
54
|
-
: undefined
|
|
55
|
-
if (schema === undefined) {
|
|
56
|
-
throw captureError(
|
|
57
|
-
`contract '${contract.name}' has no event type '${event.type}' at index ${event.index}`,
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
const result = validateSync(
|
|
61
|
-
schema,
|
|
62
|
-
structuredClone(event.payload),
|
|
63
|
-
`captured event '${event.type}'`,
|
|
64
|
-
)
|
|
65
|
-
if (result.issues !== undefined) {
|
|
66
|
-
throw captureError(
|
|
67
|
-
`event #${event.index} '${event.type}' has an invalid payload: ${result.issues
|
|
68
|
-
.map((issue) => issue.message)
|
|
69
|
-
.join('; ')}`,
|
|
70
|
-
)
|
|
71
|
-
}
|
|
72
|
-
events.push({
|
|
73
|
-
id: event.id,
|
|
74
|
-
type: event.type,
|
|
75
|
-
payload: result.value,
|
|
76
|
-
index: event.index,
|
|
77
|
-
sessionId: event.sessionId,
|
|
78
|
-
createdAt: new Date(event.createdAt),
|
|
79
|
-
} as ContractEvent<D>)
|
|
80
|
-
}
|
|
81
|
-
return events
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Validate an already-parsed capture against an application's contract. */
|
|
85
|
-
export async function prepareCapture<D extends EventDefs>(
|
|
86
|
-
capture: DevtoolsCapture,
|
|
87
|
-
contract: Contract<D>,
|
|
88
|
-
): Promise<LoadedCapture<D>> {
|
|
89
|
-
await verifyDevtoolsCapture(capture)
|
|
90
|
-
if (capture.contract !== contract.name) {
|
|
91
|
-
throw captureError(
|
|
92
|
-
`expected contract '${contract.name}', received '${capture.contract}'`,
|
|
93
|
-
)
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
capture,
|
|
97
|
-
contract,
|
|
98
|
-
events: typedEvents(capture, contract),
|
|
99
|
-
rawEvents: capture.events,
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const displayUrl = (url: URL): string => {
|
|
104
|
-
if (url.protocol === 'data:') return 'data: URL'
|
|
105
|
-
const safe = new URL(url)
|
|
106
|
-
safe.username = ''
|
|
107
|
-
safe.password = ''
|
|
108
|
-
safe.search = ''
|
|
109
|
-
safe.hash = ''
|
|
110
|
-
return safe.href
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const fetchCapture = async (url: URL): Promise<Uint8Array> => {
|
|
114
|
-
const location = displayUrl(url)
|
|
115
|
-
let response: Response
|
|
116
|
-
try {
|
|
117
|
-
response = await fetch(url)
|
|
118
|
-
} catch {
|
|
119
|
-
throw new Error(`failed to load A2 capture from '${location}'`)
|
|
120
|
-
}
|
|
121
|
-
if (!response.ok) {
|
|
122
|
-
throw new Error(
|
|
123
|
-
`failed to load A2 capture from '${location}': ${response.status} ${response.statusText}`,
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
return new Uint8Array(await response.arrayBuffer())
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const readCapture = async (source: CaptureSource): Promise<Uint8Array> => {
|
|
130
|
-
if (source instanceof URL) {
|
|
131
|
-
return source.protocol === 'file:'
|
|
132
|
-
? readFile(fileURLToPath(source))
|
|
133
|
-
: fetchCapture(source)
|
|
134
|
-
}
|
|
135
|
-
if (/^[a-z]:[\\/]/i.test(source)) return readFile(source)
|
|
136
|
-
let url: URL | undefined
|
|
137
|
-
try {
|
|
138
|
-
url = new URL(source)
|
|
139
|
-
} catch {
|
|
140
|
-
return readFile(source)
|
|
141
|
-
}
|
|
142
|
-
return readCapture(url)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Load, integrity-check, and contract-type a `.a2log` file or URL. */
|
|
146
|
-
export async function loadCapture<D extends EventDefs>(
|
|
147
|
-
source: CaptureSource,
|
|
148
|
-
contract: Contract<D>,
|
|
149
|
-
): Promise<LoadedCapture<D>> {
|
|
150
|
-
const encoded = await readCapture(source)
|
|
151
|
-
const capture = parseDevtoolsCapture(encoded)
|
|
152
|
-
return prepareCapture(capture, contract)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const cloneInitial = <S>(initialState: S): S => {
|
|
156
|
-
try {
|
|
157
|
-
return structuredClone(initialState)
|
|
158
|
-
} catch {
|
|
159
|
-
return initialState
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Purely fold captured events through a reducer, optionally to a cutpoint. */
|
|
164
|
-
export function replayCapture<D extends EventDefs, S>(
|
|
165
|
-
loaded: LoadedCapture<D>,
|
|
166
|
-
reducer: Reducer<D, S>,
|
|
167
|
-
options: ReplayCaptureOptions = {},
|
|
168
|
-
): CaptureReplay<S> {
|
|
169
|
-
const { throughIndex } = options
|
|
170
|
-
if (
|
|
171
|
-
throughIndex !== undefined &&
|
|
172
|
-
(!Number.isInteger(throughIndex) || throughIndex < 0)
|
|
173
|
-
) {
|
|
174
|
-
throw new TypeError('throughIndex must be a non-negative integer')
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
let state = cloneInitial(reducer.initialState)
|
|
178
|
-
let index = 0
|
|
179
|
-
for (const event of loaded.events) {
|
|
180
|
-
if (throughIndex !== undefined && event.index > throughIndex) break
|
|
181
|
-
state = reducer.fold(state, event)
|
|
182
|
-
index = event.index
|
|
183
|
-
}
|
|
184
|
-
return { state, index }
|
|
185
|
-
}
|