Simplify to transport-only: remove call protocol, add EventEnvelope, expand stream operators
- Remove src/call.ts (PendingRequestMap, CallEventSchema, CallError) — call protocol belongs in @alkdev/operations
- Add EventEnvelope type ({ type, id, payload }) as the cross-platform serialization contract
- Simplify createPubSub: replace PubSubPublishArgsByKey tuple model with PubSubEventMap; publish(type, id, payload) and subscribe(type, id) use explicit id for topic scoping
- Update Redis adapter to serialize/deserialize full EventEnvelope
- Expand operators: add take, reduce, toArray, batch, dedupe, window, flat, groupBy, chain, join
- Remove @alkdev/typebox runtime dependency (was only used by call.ts)
- Remove ./call sub-path export from package.json and tsup config
- Update all architecture docs to reflect transport-only scope, add Worker adapter, remove call protocol references
- Remove docs/architecture/call-protocol.md
- Update AGENTS.md with new source layout and transport-only principle
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-04-30
|
||||
last_updated: 2026-05-01
|
||||
---
|
||||
|
||||
# API Surface
|
||||
@@ -10,45 +10,60 @@ Core pubsub creation, types, and operators. No transport dependencies.
|
||||
## `createPubSub`
|
||||
|
||||
```ts
|
||||
function createPubSub<TPubSubPublishArgsByKey extends PubSubPublishArgsByKey>(
|
||||
config?: PubSubConfig<TPubSubPublishArgsByKey>,
|
||||
): PubSub<TPubSubPublishArgsByKey>;
|
||||
function createPubSub<TEventMap extends PubSubEventMap>(
|
||||
config?: PubSubConfig<TEventMap>,
|
||||
): PubSub<TEventMap>;
|
||||
```
|
||||
|
||||
Factory function. Accepts an optional `eventTarget` config. If none is provided, uses `new EventTarget()` (in-process).
|
||||
|
||||
### Topic Scoping
|
||||
### Event Envelope
|
||||
|
||||
Topics can be scoped with an id:
|
||||
|
||||
- `pubsub.publish("session.status", projectId, payload)` → dispatches to topic `session.status:{projectId}`
|
||||
- `pubsub.subscribe("session.status", projectId)` → subscribes to topic `session.status:{projectId}` only
|
||||
- `pubsub.publish("session.status", payload)` → dispatches to topic `session.status` (unscoped)
|
||||
- `pubsub.subscribe("session.status")` → subscribes to topic `session.status` (unscoped)
|
||||
|
||||
The topic string is either the routing key directly (unscoped) or `{routingKey}:{id}` (scoped). This maps naturally to Redis channel naming and WebSocket message routing.
|
||||
|
||||
### `PubSubPublishArgsByKey`
|
||||
|
||||
The type parameter that defines the event map:
|
||||
Every event dispatched through pubsub uses the `EventEnvelope` format:
|
||||
|
||||
```ts
|
||||
type PubSubPublishArgsByKey = {
|
||||
[key: string]: [] | [unknown] | [number | string, unknown];
|
||||
interface EventEnvelope<TType extends string = string, TPayload = unknown> {
|
||||
readonly type: TType;
|
||||
readonly id: string;
|
||||
readonly payload: TPayload;
|
||||
}
|
||||
```
|
||||
|
||||
The envelope is the cross-platform serialization contract. All transport adapters serialize/deserialize this format. Domain-specific data goes in `payload`.
|
||||
|
||||
### Topic Scoping
|
||||
|
||||
Topics are scoped by `id` using the `type:id` convention:
|
||||
|
||||
```ts
|
||||
pubsub.publish("call.responded", requestId, { output });
|
||||
// → dispatches event with CustomEvent type "call.responded:{requestId}", detail = { type, id, payload }
|
||||
|
||||
const stream = pubsub.subscribe("call.responded", requestId);
|
||||
// → subscribes to topic "call.responded:{requestId}"
|
||||
```
|
||||
|
||||
Unlike the previous tuple-based model, `id` is always required. This simplifies the type system and makes correlation explicit.
|
||||
|
||||
### `PubSubEventMap`
|
||||
|
||||
The type parameter that defines the event map. Maps event type strings to their payload types:
|
||||
|
||||
```ts
|
||||
type PubSubEventMap = {
|
||||
[eventType: string]: unknown;
|
||||
};
|
||||
```
|
||||
|
||||
- `[]` — event with no payload (trigger only)
|
||||
- `[payload]` — unscoped event with payload
|
||||
- `[id, payload]` — scoped event with id and payload
|
||||
|
||||
### `PubSub.subscribe()`
|
||||
|
||||
Returns a `Repeater<unknown>` (async iterable). Consumers iterate with `for await`:
|
||||
Returns a `Repeater<EventEnvelope<TKey, TPayload>>` (async iterable). Consumers iterate with `for await`:
|
||||
|
||||
```ts
|
||||
for await (const payload of pubsub.subscribe("session.status")) {
|
||||
// handle payload
|
||||
for await (const envelope of pubsub.subscribe("session.status", sessionId)) {
|
||||
// envelope.type === "session.status"
|
||||
// envelope.id === sessionId
|
||||
// envelope.payload === the typed payload
|
||||
}
|
||||
```
|
||||
|
||||
@@ -58,21 +73,26 @@ The `Repeater` automatically cleans up its `addEventListener` when the consumer
|
||||
|
||||
| Export | Source | Description |
|
||||
|--------|--------|-------------|
|
||||
| `EventEnvelope<TType, TPayload>` | `types.ts` | Cross-platform envelope: `{ type, id, payload }`. JSON-serializable. |
|
||||
| `TypedEvent<TType, TDetail>` | `types.ts` | Event with typed `type` and `detail`. Omits `CustomEvent`'s untyped fields. |
|
||||
| `TypedEventTarget<TEvent>` | `types.ts` | Extends `EventTarget` with typed `addEventListener`, `dispatchEvent`, `removeEventListener`. |
|
||||
| `TypedEventListener<TEvent>` | `types.ts` | `(evt: TEvent) => void` |
|
||||
| `TypedEventListenerObject<TEvent>` | `types.ts` | `{ handleEvent(object: TEvent): void }` |
|
||||
| `TypedEventListenerOrEventListenerObject<TEvent>` | `types.ts` | Union of the above |
|
||||
| `PubSub<TPubSubPublishArgsByKey>` | `create_pubsub.ts` | `{ publish, subscribe }` |
|
||||
| `PubSubConfig<TPubSubPublishArgsByKey>` | `create_pubsub.ts` | `{ eventTarget?: PubSubEventTarget }` |
|
||||
| `PubSubEvent<TPubSubPublishArgsByKey, TKey>` | `create_pubsub.ts` | Derived `TypedEvent` for a specific event key |
|
||||
| `PubSubEventTarget<TPubSubPublishArgsByKey>` | `create_pubsub.ts` | `TypedEventTarget<PubSubEvent<...>>` |
|
||||
| `PubSub<TEventMap>` | `create_pubsub.ts` | `{ publish, subscribe }` — publish takes `(type, id, payload)`, subscribe takes `(type, id)` and returns `Repeater<EventEnvelope>` |
|
||||
| `PubSubConfig<TEventMap>` | `create_pubsub.ts` | `{ eventTarget?: PubSubEventTarget }` |
|
||||
| `PubSubEvent<TEventMap, TType>` | `create_pubsub.ts` | Derived `TypedEvent` for a specific event type, with `detail` as `EventEnvelope<TType, TPayload>` |
|
||||
| `PubSubEventTarget<TEventMap>` | `create_pubsub.ts` | `TypedEventTarget<PubSubEvent<...>>` |
|
||||
|
||||
## Operators
|
||||
|
||||
All operators return `Repeater` instances and work with any async iterable.
|
||||
All operators work with any `AsyncIterable`. Operators that return `Repeater` provide backpressure-aware push semantics.
|
||||
|
||||
### `filter`
|
||||
### Repeater-returning operators
|
||||
|
||||
These wrap source iterables in a `Repeater` with explicit push/stop control:
|
||||
|
||||
#### `filter`
|
||||
|
||||
```ts
|
||||
function filter<T>(filterFn: (value: T) => Promise<boolean> | boolean): (source: AsyncIterable<T>) => Repeater<T>;
|
||||
@@ -80,22 +100,105 @@ function filter<T>(filterFn: (value: T) => Promise<boolean> | boolean): (source:
|
||||
|
||||
Type-narrowing overload available: `filter<T, U extends T>(fn: (input: T) => input is U)`.
|
||||
|
||||
### `map`
|
||||
#### `map`
|
||||
|
||||
```ts
|
||||
function map<T, O>(mapper: (input: T) => Promise<O> | O): (source: AsyncIterable<T>) => Repeater<O>;
|
||||
```
|
||||
|
||||
### `pipe`
|
||||
#### `pipe`
|
||||
|
||||
```ts
|
||||
function pipe<A, B>(a: A, ab: (a: A) => B): B;
|
||||
function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
|
||||
// up to 5 arguments
|
||||
```
|
||||
|
||||
Compose operators: `pipe(pubsub.subscribe("myEvent"), filter(isRelevant), map(transform))`
|
||||
Compose operators: `pipe(pubsub.subscribe("myEvent", id), filter(isRelevant), map(transform))`
|
||||
|
||||
### AsyncGenerator operators
|
||||
|
||||
These use native `async function*` generators for simpler stream transformations:
|
||||
|
||||
#### `take`
|
||||
|
||||
Yields only the first `count` items from the source.
|
||||
|
||||
```ts
|
||||
async function* take<T>(source: AsyncIterable<T>, count: number): AsyncIterable<T>
|
||||
```
|
||||
|
||||
#### `reduce`
|
||||
|
||||
Reduces the stream to a single value.
|
||||
|
||||
```ts
|
||||
async function reduce<T, U>(source: AsyncIterable<T>, reducer: (acc: U, value: T) => Promise<U> | U, initialValue: U): Promise<U>
|
||||
```
|
||||
|
||||
#### `toArray`
|
||||
|
||||
Collects all items into an array.
|
||||
|
||||
```ts
|
||||
async function toArray<T>(source: AsyncIterable<T>): Promise<T[]>
|
||||
```
|
||||
|
||||
#### `batch`
|
||||
|
||||
Groups items into arrays of `size`.
|
||||
|
||||
```ts
|
||||
async function* batch<T>(source: AsyncIterable<T>, size: number): AsyncIterable<T[]>
|
||||
```
|
||||
|
||||
#### `dedupe`
|
||||
|
||||
Yields only unique items (uses `Set` for deduplication).
|
||||
|
||||
```ts
|
||||
async function* dedupe<T>(source: AsyncIterable<T>): AsyncIterable<T>
|
||||
```
|
||||
|
||||
#### `window`
|
||||
|
||||
Sliding window of `size` items, advancing by `step` (default 1).
|
||||
|
||||
```ts
|
||||
async function* window<T>(source: AsyncIterable<T>, size: number, step?: number): AsyncIterable<T[]>
|
||||
```
|
||||
|
||||
#### `flat`
|
||||
|
||||
Flattens an `AsyncIterable<T[]>` into `AsyncIterable<T>`.
|
||||
|
||||
```ts
|
||||
async function* flat<T>(source: AsyncIterable<T[]>): AsyncIterable<T>
|
||||
```
|
||||
|
||||
#### `groupBy`
|
||||
|
||||
Groups items by key into a `Map`. Terminal operation (consumes entire stream).
|
||||
|
||||
```ts
|
||||
async function groupBy<T, K>(source: AsyncIterable<T>, keyFn: (value: T) => K): Promise<Map<K, T[]>>
|
||||
```
|
||||
|
||||
#### `chain`
|
||||
|
||||
Concatenates multiple async iterables into one.
|
||||
|
||||
```ts
|
||||
async function* chain<T>(...sources: AsyncIterable<T>[]): AsyncIterable<T>
|
||||
```
|
||||
|
||||
#### `join`
|
||||
|
||||
Streaming join between two sources on matching keys.
|
||||
|
||||
```ts
|
||||
async function* join<T, U, K>(source1: AsyncIterable<T>, source2: AsyncIterable<U>, keyFn1: (value: T) => K, keyFn2: (value: U) => K): AsyncIterable<[T, U]>
|
||||
```
|
||||
|
||||
## Attribution
|
||||
|
||||
`createPubSub` and operators are adapted from `@graphql-yoga/subscription` (MIT). `TypedEventTarget` types are adapted from `@graphql-yoga/typed-event-target` (MIT). See file headers for full license text.
|
||||
`createPubSub`, `filter`, `map`, and `pipe` are adapted from `@graphql-yoga/subscription` (MIT). `TypedEventTarget` types are adapted from `@graphql-yoga/typed-event-target` (MIT). `Repeater` is inlined from `@repeaterjs/repeater` (MIT). See file headers for full license text.
|
||||
Reference in New Issue
Block a user