@nimbus-sh/loom 0.1.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/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/actor.d.ts +222 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +474 -0
- package/dist/callable.d.ts +45 -0
- package/dist/callable.d.ts.map +1 -0
- package/dist/callable.js +60 -0
- package/dist/client.d.ts +58 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +98 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/protocol.d.ts +63 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +45 -0
- package/dist/routing.d.ts +13 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +12 -0
- package/dist/rpc.d.ts +47 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +102 -0
- package/dist/schedules.d.ts +150 -0
- package/dist/schedules.d.ts.map +1 -0
- package/dist/schedules.js +276 -0
- package/package.json +66 -0
- package/src/actor.ts +638 -0
- package/src/callable.ts +76 -0
- package/src/client.ts +153 -0
- package/src/index.ts +19 -0
- package/src/protocol.ts +84 -0
- package/src/routing.ts +19 -0
- package/src/rpc.ts +110 -0
- package/src/schedules.ts +356 -0
package/src/callable.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* callable.ts — the opt-in that makes an actor method reachable over the
|
|
3
|
+
* connection.
|
|
4
|
+
*
|
|
5
|
+
* RPC exposure is allowlist-only: a method a client can invoke by name is a
|
|
6
|
+
* public surface, and an accidental one is a vulnerability. `callable()`
|
|
7
|
+
* is the allowlist mark. The mechanism mirrors the Agents SDK exactly
|
|
8
|
+
* (verified in `agents` 0.20.1 dist, `index.js:54,112-134`): a TC39
|
|
9
|
+
* standard method decorator whose registry is a module-level
|
|
10
|
+
* `WeakMap<Function, CallableMetadata>` keyed by the method's function
|
|
11
|
+
* object. Keying by function makes the mark travel with the method through
|
|
12
|
+
* inheritance and `this[name]` lookup, and costs nothing at class-definition
|
|
13
|
+
* time.
|
|
14
|
+
*
|
|
15
|
+
* The decorator ignores its context argument, so plain-JS callers (tests,
|
|
16
|
+
* codebases without decorator syntax) can mark a method directly:
|
|
17
|
+
* `callable()(MyActor.prototype.greet)`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CallableMetadata {
|
|
21
|
+
/** What the method does, for surface listings. */
|
|
22
|
+
description?: string;
|
|
23
|
+
/**
|
|
24
|
+
* A streaming method receives a {@link import('./rpc.js').StreamingResponse}
|
|
25
|
+
* as its FIRST argument, ahead of the caller's own, and replies through it.
|
|
26
|
+
* Its return value is discarded.
|
|
27
|
+
*/
|
|
28
|
+
streaming?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const registry = new WeakMap<object, CallableMetadata>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Mark a method as callable over the connection. First mark wins; marking
|
|
35
|
+
* the same function twice keeps the first metadata.
|
|
36
|
+
*/
|
|
37
|
+
export function callable(metadata: CallableMetadata = {}) {
|
|
38
|
+
return function markCallable<This, Args extends unknown[], Return>(
|
|
39
|
+
target: (this: This, ...args: Args) => Return,
|
|
40
|
+
_context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
|
|
41
|
+
): (this: This, ...args: Args) => Return {
|
|
42
|
+
if (!registry.has(target)) registry.set(target, metadata);
|
|
43
|
+
return target;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when the value is a function carrying the callable mark. */
|
|
48
|
+
export function isCallable(method: unknown): boolean {
|
|
49
|
+
return typeof method === 'function' && registry.has(method);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The mark's metadata, or undefined for an unmarked value. */
|
|
53
|
+
export function callableMetadata(method: unknown): CallableMetadata | undefined {
|
|
54
|
+
return typeof method === 'function' ? registry.get(method) : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every callable method reachable from an instance, by name, walking the
|
|
59
|
+
* prototype chain. A subclass override without its own mark hides the
|
|
60
|
+
* marked parent method — the override is what `this[name]` resolves to,
|
|
61
|
+
* and it is unmarked.
|
|
62
|
+
*/
|
|
63
|
+
export function callableMethods(target: object): Map<string, CallableMetadata> {
|
|
64
|
+
const found = new Map<string, CallableMetadata>();
|
|
65
|
+
const shadowed = new Set<string>();
|
|
66
|
+
for (let proto = Object.getPrototypeOf(target); proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
|
|
67
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
68
|
+
if (name === 'constructor' || shadowed.has(name)) continue;
|
|
69
|
+
// Own descriptors only — reading `instance[name]` would run getters.
|
|
70
|
+
shadowed.add(name);
|
|
71
|
+
const metadata = callableMetadata(Object.getOwnPropertyDescriptor(proto, name)?.value);
|
|
72
|
+
if (metadata !== undefined) found.set(name, metadata);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.ts — the caller's half of callable RPC: a promise per call, and a
|
|
3
|
+
* typed stub proxy that makes an actor's methods look local.
|
|
4
|
+
*
|
|
5
|
+
* Dependency-free and workerd-free on purpose: the socket is anything with
|
|
6
|
+
* `send` and message listeners — a browser WebSocket, a PartySocket, a
|
|
7
|
+
* server-side WebSocket from `fetch()` — so this module runs wherever the
|
|
8
|
+
* connection was made. Frames and defaults follow the Agents SDK client
|
|
9
|
+
* (verified in `agents` 0.20.1 dist, `client.js:129-151,217-248`): ids are
|
|
10
|
+
* `crypto.randomUUID()`, plain calls time out at 30 s by default, streamed
|
|
11
|
+
* calls (an `onChunk` listener) get no timeout unless one is passed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isRpcResponseFrame, RPC_FRAME_TYPE } from './protocol.js';
|
|
15
|
+
import type { StreamingResponse } from './rpc.js';
|
|
16
|
+
|
|
17
|
+
/** The connection as the client drives it. A browser WebSocket satisfies it. */
|
|
18
|
+
export interface ActorSocket {
|
|
19
|
+
send(data: string): void;
|
|
20
|
+
addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void;
|
|
21
|
+
removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ActorCallOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Reject the call after this long. Default: 30,000 ms for plain calls;
|
|
27
|
+
* none for streamed calls.
|
|
28
|
+
*/
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
/** Receives each `done: false` chunk of a streamed reply. */
|
|
31
|
+
onChunk?: (chunk: unknown) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The target's async methods, callable as promises. Which of them the actor
|
|
36
|
+
* actually answers is decided server-side by the `callable()` mark; an
|
|
37
|
+
* unmarked method rejects with "is not callable".
|
|
38
|
+
*
|
|
39
|
+
* A streaming callable's `StreamingResponse` parameter is server-side —
|
|
40
|
+
* the caller passes the remaining arguments and the promise resolves with
|
|
41
|
+
* the final chunk, so the stub type strips that first parameter. The
|
|
42
|
+
* detection is structural: a method whose first parameter merely ACCEPTS a
|
|
43
|
+
* StreamingResponse (`unknown`, a broad object) is typed as streaming too.
|
|
44
|
+
*/
|
|
45
|
+
export type ActorStub<T> = {
|
|
46
|
+
[K in keyof T as T[K] extends (...args: never[]) => unknown ? K : never]: T[K] extends (
|
|
47
|
+
stream: StreamingResponse,
|
|
48
|
+
...args: infer StreamArgs
|
|
49
|
+
) => unknown
|
|
50
|
+
? (...args: StreamArgs) => Promise<unknown>
|
|
51
|
+
: T[K] extends (...args: infer Args) => infer Return
|
|
52
|
+
? (...args: Args) => Promise<Awaited<Return>>
|
|
53
|
+
: never;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface ActorClient {
|
|
57
|
+
/** Call one callable method by name. */
|
|
58
|
+
call<T = unknown>(method: string, args?: unknown[], options?: ActorCallOptions): Promise<T>;
|
|
59
|
+
/** A proxy whose method calls become `call(name, args)`. */
|
|
60
|
+
stub<T>(options?: ActorCallOptions): ActorStub<T>;
|
|
61
|
+
/** Detach from the socket and reject every call still pending. */
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
|
66
|
+
|
|
67
|
+
interface PendingCall {
|
|
68
|
+
resolve: (value: unknown) => void;
|
|
69
|
+
reject: (error: Error) => void;
|
|
70
|
+
onChunk?: (chunk: unknown) => void;
|
|
71
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Attach an RPC client to a socket. One message listener for all calls. */
|
|
75
|
+
export function actorClient(socket: ActorSocket, defaults: ActorCallOptions = {}): ActorClient {
|
|
76
|
+
const pending = new Map<string, PendingCall>();
|
|
77
|
+
|
|
78
|
+
const onMessage = (event: { data: unknown }): void => {
|
|
79
|
+
if (typeof event.data !== 'string') return;
|
|
80
|
+
let frame: unknown;
|
|
81
|
+
try {
|
|
82
|
+
frame = JSON.parse(event.data);
|
|
83
|
+
} catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!isRpcResponseFrame(frame)) return;
|
|
87
|
+
const call = pending.get(frame.id);
|
|
88
|
+
if (!call) return;
|
|
89
|
+
if (frame.success === false) {
|
|
90
|
+
settle(frame.id)?.reject(new Error(frame.error));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (frame.done === false) {
|
|
94
|
+
call.onChunk?.(frame.result);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
settle(frame.id)?.resolve(frame.result);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const settle = (id: string): PendingCall | undefined => {
|
|
101
|
+
const call = pending.get(id);
|
|
102
|
+
if (!call) return undefined;
|
|
103
|
+
pending.delete(id);
|
|
104
|
+
if (call.timer !== undefined) clearTimeout(call.timer);
|
|
105
|
+
return call;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
socket.addEventListener('message', onMessage);
|
|
109
|
+
|
|
110
|
+
const call = <T>(method: string, args: unknown[] = [], options: ActorCallOptions = {}): Promise<T> => {
|
|
111
|
+
const onChunk = options.onChunk ?? defaults.onChunk;
|
|
112
|
+
const timeoutMs = options.timeoutMs ?? defaults.timeoutMs ?? (onChunk ? undefined : DEFAULT_CALL_TIMEOUT_MS);
|
|
113
|
+
const id = crypto.randomUUID();
|
|
114
|
+
return new Promise<T>((resolve, reject) => {
|
|
115
|
+
const entry: PendingCall = { resolve: resolve as (value: unknown) => void, reject, onChunk };
|
|
116
|
+
if (timeoutMs !== undefined) {
|
|
117
|
+
entry.timer = setTimeout(() => {
|
|
118
|
+
pending.delete(id);
|
|
119
|
+
reject(new Error(`RPC call '${method}' timed out after ${timeoutMs}ms`));
|
|
120
|
+
}, timeoutMs);
|
|
121
|
+
}
|
|
122
|
+
pending.set(id, entry);
|
|
123
|
+
try {
|
|
124
|
+
socket.send(JSON.stringify({ type: RPC_FRAME_TYPE, id, method, args }));
|
|
125
|
+
} catch (e) {
|
|
126
|
+
settle(id);
|
|
127
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
call,
|
|
134
|
+
stub<T>(options?: ActorCallOptions): ActorStub<T> {
|
|
135
|
+
return new Proxy({} as ActorStub<T>, {
|
|
136
|
+
get(_target, name) {
|
|
137
|
+
if (typeof name !== 'string') return undefined;
|
|
138
|
+
// `await stub` (or any promise resolution of the stub itself)
|
|
139
|
+
// probes `.then`; answering with a caller would fire an RPC
|
|
140
|
+
// literally named "then".
|
|
141
|
+
if (name === 'then') return undefined;
|
|
142
|
+
return (...args: unknown[]) => call(name, args, options);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
close(): void {
|
|
147
|
+
socket.removeEventListener('message', onMessage);
|
|
148
|
+
for (const id of [...pending.keys()]) {
|
|
149
|
+
settle(id)?.reject(new Error('RPC client closed'));
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nimbus-sh/loom — the actor framework of Nimbus: partyserver's surface on
|
|
3
|
+
* top, the @nimbus-sh/fabric floor pre-wired underneath.
|
|
4
|
+
*
|
|
5
|
+
* The root export pulls `partyserver`, which imports `cloudflare:workers`,
|
|
6
|
+
* so importing this module outside workerd fails at resolution. Non-workerd
|
|
7
|
+
* consumers (tests, browser clients) import the subpath modules they need —
|
|
8
|
+
* `@nimbus-sh/loom/client.js` is workerd-free by design.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export * from './actor.js';
|
|
12
|
+
export * from './callable.js';
|
|
13
|
+
export * from './client.js';
|
|
14
|
+
export * from './protocol.js';
|
|
15
|
+
export * from './routing.js';
|
|
16
|
+
export * from './rpc.js';
|
|
17
|
+
export * from './schedules.js';
|
|
18
|
+
export { Server } from 'partyserver';
|
|
19
|
+
export type { Connection, ConnectionContext, WSMessage } from 'partyserver';
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol.ts — the JSON frames loom speaks over a WebSocket connection.
|
|
3
|
+
*
|
|
4
|
+
* The frame shapes and type strings match the Cloudflare Agents SDK
|
|
5
|
+
* (`agents` 0.20.1, verified in its shipped dist: `types.js:5-15` for the
|
|
6
|
+
* type strings, `agent-tool-types` d.ts for the request/response shapes).
|
|
7
|
+
* Matching them is deliberate: a client written for an Agents server — the
|
|
8
|
+
* SDK's own `AgentClient` included — can drive a loom actor's state sync and
|
|
9
|
+
* RPC without translation. Only the frames loom implements are declared
|
|
10
|
+
* here; the Agents-product frames (MCP, identity, sessions) are not.
|
|
11
|
+
*
|
|
12
|
+
* A string message that parses as JSON and carries one of these `type`
|
|
13
|
+
* values is a protocol frame and never reaches the embedder's `onMessage`.
|
|
14
|
+
* Everything else — binary, non-JSON text, JSON with any other `type` —
|
|
15
|
+
* passes through untouched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** State sync, both directions: `{ type, state }`. */
|
|
19
|
+
export const STATE_FRAME_TYPE = 'cf_agent_state';
|
|
20
|
+
|
|
21
|
+
/** Server → client: a client state update was refused. */
|
|
22
|
+
export const STATE_ERROR_FRAME_TYPE = 'cf_agent_state_error';
|
|
23
|
+
|
|
24
|
+
/** Callable RPC, both directions. */
|
|
25
|
+
export const RPC_FRAME_TYPE = 'rpc';
|
|
26
|
+
|
|
27
|
+
/** Client → server: replace the actor's state. */
|
|
28
|
+
export interface StateFrame<S = unknown> {
|
|
29
|
+
type: typeof STATE_FRAME_TYPE;
|
|
30
|
+
state: S;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Server → client: the refusal a rejected state update earns. */
|
|
34
|
+
export interface StateErrorFrame {
|
|
35
|
+
type: typeof STATE_ERROR_FRAME_TYPE;
|
|
36
|
+
error: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Client → server: call one `callable()` method by name. */
|
|
40
|
+
export interface RpcRequestFrame {
|
|
41
|
+
type: typeof RPC_FRAME_TYPE;
|
|
42
|
+
/** Correlates the response frames to the call. The client mints it. */
|
|
43
|
+
id: string;
|
|
44
|
+
method: string;
|
|
45
|
+
args: unknown[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Server → client. A streamed reply is many `done: false` frames and one
|
|
50
|
+
* `done: true`; a plain reply is a single `done: true` frame. `done` absent
|
|
51
|
+
* means final (the Agents client treats it so; loom always sets it).
|
|
52
|
+
*/
|
|
53
|
+
export type RpcResponseFrame = { type: typeof RPC_FRAME_TYPE; id: string } & (
|
|
54
|
+
| { success: true; result: unknown; done?: boolean }
|
|
55
|
+
| { success: false; error: string }
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/** Same guard the Agents server uses: `type` plus a `state` key. */
|
|
59
|
+
export function isStateFrame(value: unknown): value is StateFrame {
|
|
60
|
+
return (
|
|
61
|
+
typeof value === 'object' && value !== null
|
|
62
|
+
&& (value as { type?: unknown }).type === STATE_FRAME_TYPE
|
|
63
|
+
&& 'state' in value
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Same guard the Agents server uses: all four fields, `args` an array. */
|
|
68
|
+
export function isRpcRequestFrame(value: unknown): value is RpcRequestFrame {
|
|
69
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
70
|
+
const frame = value as Partial<RpcRequestFrame>;
|
|
71
|
+
return (
|
|
72
|
+
frame.type === RPC_FRAME_TYPE
|
|
73
|
+
&& typeof frame.id === 'string'
|
|
74
|
+
&& typeof frame.method === 'string'
|
|
75
|
+
&& Array.isArray(frame.args)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The client-side guard for response frames. */
|
|
80
|
+
export function isRpcResponseFrame(value: unknown): value is RpcResponseFrame {
|
|
81
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
82
|
+
const frame = value as { type?: unknown; id?: unknown; success?: unknown };
|
|
83
|
+
return frame.type === RPC_FRAME_TYPE && typeof frame.id === 'string' && typeof frame.success === 'boolean';
|
|
84
|
+
}
|
package/src/routing.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routing.ts — URL → binding → named actor instance.
|
|
3
|
+
*
|
|
4
|
+
* partyserver's router already is the mechanism loom wants: it maps
|
|
5
|
+
* `/${prefix}/<binding-kebab>/<name>` onto the Durable Object binding, with
|
|
6
|
+
* `onBeforeConnect`/`onBeforeRequest` gates, CORS, jurisdiction, location
|
|
7
|
+
* hints, and retry over transient DO routing errors. Wrapping it in a
|
|
8
|
+
* parallel implementation would be a drift channel, so loom re-exports it
|
|
9
|
+
* under its own names. The URL convention stays `/parties/...` by default —
|
|
10
|
+
* it is the wire contract PartySocket-family clients build URLs from.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
routePartykitRequest as routeActorRequest,
|
|
15
|
+
getServerByName as getActorByName,
|
|
16
|
+
type PartyServerOptions as ActorRoutingOptions,
|
|
17
|
+
type Lobby,
|
|
18
|
+
type RoutingRetryOptions,
|
|
19
|
+
} from 'partyserver';
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rpc.ts — the server half of callable RPC: dispatch one request frame to a
|
|
3
|
+
* marked method and answer over the connection.
|
|
4
|
+
*
|
|
5
|
+
* The dispatch contract mirrors the Agents SDK (verified in `agents` 0.20.1
|
|
6
|
+
* dist, `index.js:883-934`):
|
|
7
|
+
* - the method must exist on the target AND carry the `callable()` mark;
|
|
8
|
+
* either failure is an error frame, not a crash.
|
|
9
|
+
* - a streaming method receives a {@link StreamingResponse} PREPENDED to
|
|
10
|
+
* the caller's arguments and owns its own reply frames; if it throws
|
|
11
|
+
* with the stream still open, the error closes the stream.
|
|
12
|
+
* - a plain method's awaited return value goes back as one
|
|
13
|
+
* `{ success: true, done: true }` frame; a throw becomes
|
|
14
|
+
* `{ success: false, error }` carrying the message only, never the stack.
|
|
15
|
+
*
|
|
16
|
+
* Sends tolerate exactly one failure: workerd throws a TypeError containing
|
|
17
|
+
* "WebSocket send() after close" when the peer is gone, and a reply that
|
|
18
|
+
* cannot be delivered has no one waiting for it. Every other send error is
|
|
19
|
+
* a real bug and propagates.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { callableMetadata, isCallable } from './callable.js';
|
|
23
|
+
import { RPC_FRAME_TYPE, type RpcRequestFrame, type RpcResponseFrame } from './protocol.js';
|
|
24
|
+
|
|
25
|
+
/** The connection as RPC writes to it. partyserver's `Connection` satisfies it. */
|
|
26
|
+
export interface RpcConnection {
|
|
27
|
+
send(message: string): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sendIfOpen(connection: RpcConnection, frame: RpcResponseFrame): boolean {
|
|
31
|
+
try {
|
|
32
|
+
connection.send(JSON.stringify(frame));
|
|
33
|
+
return true;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e instanceof TypeError && e.message.includes('WebSocket send() after close')) return false;
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The reply channel a streaming callable writes through. One `end()` (or
|
|
42
|
+
* `error()`) closes it; every later write is a refused no-op that returns
|
|
43
|
+
* false, matching the Agents SDK's `StreamingResponse` (dist
|
|
44
|
+
* `index.js:7169-7233`).
|
|
45
|
+
*/
|
|
46
|
+
export class StreamingResponse {
|
|
47
|
+
#closed = false;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly connection: RpcConnection,
|
|
51
|
+
private readonly id: string,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
get isClosed(): boolean {
|
|
55
|
+
return this.#closed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One chunk. Returns false when the stream is closed or the peer is gone. */
|
|
59
|
+
send(chunk: unknown): boolean {
|
|
60
|
+
if (this.#closed) {
|
|
61
|
+
console.warn('loom: StreamingResponse.send() after the stream was closed — chunk not sent');
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return sendIfOpen(this.connection, { type: RPC_FRAME_TYPE, id: this.id, success: true, result: chunk, done: false });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Close the stream, with an optional final chunk. */
|
|
68
|
+
end(finalChunk?: unknown): boolean {
|
|
69
|
+
if (this.#closed) return false;
|
|
70
|
+
this.#closed = true;
|
|
71
|
+
return sendIfOpen(this.connection, { type: RPC_FRAME_TYPE, id: this.id, success: true, result: finalChunk, done: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Close the stream with an error the caller's promise rejects on. */
|
|
75
|
+
error(message: string): boolean {
|
|
76
|
+
if (this.#closed) return false;
|
|
77
|
+
this.#closed = true;
|
|
78
|
+
return sendIfOpen(this.connection, { type: RPC_FRAME_TYPE, id: this.id, success: false, error: message });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Run one request frame against the target and reply on the connection. */
|
|
83
|
+
export async function dispatchRpc(
|
|
84
|
+
target: object,
|
|
85
|
+
connection: RpcConnection,
|
|
86
|
+
frame: RpcRequestFrame,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const method = (target as Record<string, unknown>)[frame.method];
|
|
89
|
+
try {
|
|
90
|
+
if (typeof method !== 'function') throw new Error(`Method ${frame.method} does not exist`);
|
|
91
|
+
if (!isCallable(method)) throw new Error(`Method ${frame.method} is not callable`);
|
|
92
|
+
if (callableMetadata(method)?.streaming) {
|
|
93
|
+
const stream = new StreamingResponse(connection, frame.id);
|
|
94
|
+
try {
|
|
95
|
+
await method.apply(target, [stream, ...frame.args]);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (!stream.isClosed) stream.error(errorMessage(e));
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const result: unknown = await method.apply(target, frame.args);
|
|
102
|
+
sendIfOpen(connection, { type: RPC_FRAME_TYPE, id: frame.id, success: true, result, done: true });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
sendIfOpen(connection, { type: RPC_FRAME_TYPE, id: frame.id, success: false, error: errorMessage(e) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function errorMessage(error: unknown): string {
|
|
109
|
+
return error instanceof Error ? error.message : 'Unknown error occurred';
|
|
110
|
+
}
|