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
package/src/errors.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2 errors — one class, discriminated by `code`.
|
|
3
|
+
*
|
|
4
|
+
* See specs/a2-implementation.md §10 and docs/reference/02-errors.mdx.
|
|
5
|
+
* A single class serializes cleanly across the push-route boundary and
|
|
6
|
+
* keeps `instanceof` checks working after bundling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type A2ErrorCode =
|
|
10
|
+
| 'INVALID_PAYLOAD' // schema validation failed — thrown before anything is written
|
|
11
|
+
| 'UNKNOWN_EVENT_TYPE' // event type not in the machine's `events` map
|
|
12
|
+
| 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
|
|
13
|
+
| 'SUPERSEDED_ATTEMPT' // handler append from an attempt a recovery claim replaced
|
|
14
|
+
| 'CLAIM_EXPIRED' // ctx.signal abort reason: the attempt's lease lapsed without renewal
|
|
15
|
+
| 'STORE_UNAVAILABLE' // store backend failure — retryable, nothing was written
|
|
16
|
+
| 'STORE_NOT_CONFIGURED' // production boot with no `store` passed
|
|
17
|
+
| 'UNKNOWN_PRESENCE_FIELD' // presence field not in the contract's `presence` map
|
|
18
|
+
| 'PRESENCE_NOT_SUPPORTED' // contract declares presence but the store lacks the capability
|
|
19
|
+
|
|
20
|
+
export class A2Error extends Error {
|
|
21
|
+
readonly code: A2ErrorCode
|
|
22
|
+
/** e.g. the Standard Schema issues for INVALID_PAYLOAD */
|
|
23
|
+
readonly details: unknown
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
code: A2ErrorCode,
|
|
27
|
+
message: string,
|
|
28
|
+
options?: { details?: unknown; cause?: unknown },
|
|
29
|
+
) {
|
|
30
|
+
super(
|
|
31
|
+
message,
|
|
32
|
+
options && 'cause' in options ? { cause: options.cause } : undefined,
|
|
33
|
+
)
|
|
34
|
+
this.name = 'A2Error'
|
|
35
|
+
this.code = code
|
|
36
|
+
this.details = options?.details
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wrap an arbitrary thrown value as STORE_UNAVAILABLE, preserving the
|
|
42
|
+
* original as `cause`. A2Errors pass through untouched — they already
|
|
43
|
+
* carry their own meaning.
|
|
44
|
+
*/
|
|
45
|
+
export function asStoreUnavailable(err: unknown): A2Error {
|
|
46
|
+
if (err instanceof A2Error) return err
|
|
47
|
+
return new A2Error('STORE_UNAVAILABLE', 'the store backend failed', {
|
|
48
|
+
cause: err,
|
|
49
|
+
})
|
|
50
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { A2Error } from './errors.ts'
|
|
2
|
+
import type { AppendEvent, StoredEvent } from './store.ts'
|
|
3
|
+
|
|
4
|
+
const canonicalJson = (value: unknown): string =>
|
|
5
|
+
JSON.stringify(value, (_key, candidate: unknown) => {
|
|
6
|
+
if (
|
|
7
|
+
candidate === null ||
|
|
8
|
+
typeof candidate !== 'object' ||
|
|
9
|
+
Array.isArray(candidate)
|
|
10
|
+
) {
|
|
11
|
+
return candidate
|
|
12
|
+
}
|
|
13
|
+
return Object.fromEntries(
|
|
14
|
+
Object.entries(candidate).toSorted(([left], [right]) =>
|
|
15
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
}) ?? 'null'
|
|
19
|
+
|
|
20
|
+
export const idempotentReplay = (
|
|
21
|
+
events: readonly AppendEvent[],
|
|
22
|
+
existing: readonly StoredEvent[],
|
|
23
|
+
): StoredEvent[] => {
|
|
24
|
+
const byId = new Map(existing.map((event) => [event.id, event]))
|
|
25
|
+
const ordered = events.map((event) =>
|
|
26
|
+
event.id === undefined ? undefined : byId.get(event.id),
|
|
27
|
+
)
|
|
28
|
+
const firstIndex = ordered[0]?.index
|
|
29
|
+
const batchSize = events[0]?.cause?.batchSize
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
firstIndex === undefined ||
|
|
33
|
+
(batchSize !== undefined && batchSize !== events.length) ||
|
|
34
|
+
ordered.some((stored, index) => {
|
|
35
|
+
const event = events[index]!
|
|
36
|
+
return (
|
|
37
|
+
stored === undefined ||
|
|
38
|
+
stored.index !== firstIndex + index ||
|
|
39
|
+
stored.type !== event.type ||
|
|
40
|
+
stored.lane !== (event.lane ?? null) ||
|
|
41
|
+
stored.cause?.batchSize !== event.cause?.batchSize ||
|
|
42
|
+
canonicalJson(stored.payload) !== canonicalJson(event.payload)
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
) {
|
|
46
|
+
throw new A2Error(
|
|
47
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
48
|
+
'already-appended events do not match the retried batch',
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return ordered as StoredEvent[]
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2 — a durable event log for serverless apps.
|
|
3
|
+
*
|
|
4
|
+
* This entry point is isomorphic by construction: the contract (the
|
|
5
|
+
* vocabulary everything shares), reducers derived from it, and the
|
|
6
|
+
* error type. Implementing a contract — storage, handlers, sessions —
|
|
7
|
+
* lives in experimental-a2/server, which never enters a browser bundle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { contract } from './contract.ts'
|
|
11
|
+
export type {
|
|
12
|
+
AppendInput,
|
|
13
|
+
Contract,
|
|
14
|
+
ContractEvent,
|
|
15
|
+
EventDefs,
|
|
16
|
+
EventBatchFactory,
|
|
17
|
+
PresenceDefs,
|
|
18
|
+
PresenceMap,
|
|
19
|
+
PresencePatch,
|
|
20
|
+
PresenceSnapshot,
|
|
21
|
+
ReducerOptions,
|
|
22
|
+
} from './contract.ts'
|
|
23
|
+
|
|
24
|
+
export type { Reducer, ReducerBuilder } from './reducer.ts'
|
|
25
|
+
|
|
26
|
+
export { A2Error } from './errors.ts'
|
|
27
|
+
export type { A2ErrorCode } from './errors.ts'
|
|
28
|
+
|
|
29
|
+
export type {
|
|
30
|
+
A2AttributeValue,
|
|
31
|
+
A2SpanHandle,
|
|
32
|
+
A2SpanName,
|
|
33
|
+
A2Telemetry,
|
|
34
|
+
} from './telemetry.ts'
|
|
35
|
+
|
|
36
|
+
export type { Event } from './store.ts'
|
|
37
|
+
export type { StandardSchemaV1 } from './standard-schema.ts'
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
}
|