@ixo/oracle-runtime-workers 0.2.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/README.md +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- package/src/tasks/store.ts +351 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The socket.io endpoint of one user object — the Workers port of the Node
|
|
3
|
+
* runtime's `WsGateway` + `WsService` + `callFrontendTool`.
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle of one client (the client SDK's `use-websocket-events` hook):
|
|
6
|
+
*
|
|
7
|
+
* GET /socket.io/?EIO=4&transport=websocket&sessionId=…&userDid=…
|
|
8
|
+
* → the shell routes the upgrade to this user's object (`?userDid`);
|
|
9
|
+
* the object accepts the socket (Hibernatable WebSockets API) and
|
|
10
|
+
* answers the engine.io OPEN handshake.
|
|
11
|
+
* 40{"invocation":"…","ucanDelegation":"…"}
|
|
12
|
+
* → the CONNECT packet carries the same UCAN material the HTTP routes
|
|
13
|
+
* take in headers; it is validated with the shell's `authenticate`
|
|
14
|
+
* and the validated DID must be the routed one. Success: `40{sid}` +
|
|
15
|
+
* a `connected` event. Failure: `44{message}` and the socket closes.
|
|
16
|
+
* 42["ping"] / 42["status"] / 42["list-events"]
|
|
17
|
+
* → Node's diagnostic events, same payloads.
|
|
18
|
+
* 42["tool_result",{toolCallId,result,error?}] / 42["action_call_result",…]
|
|
19
|
+
* → settle the pending browser-tool / AG-UI call with that id.
|
|
20
|
+
*
|
|
21
|
+
* Outbound, every event the runtime emits for a session (`ctx.emit.*`,
|
|
22
|
+
* `browser_tool_call`, `action_call`, …) is fanned out to the session's
|
|
23
|
+
* authenticated sockets — the router's tap.
|
|
24
|
+
*
|
|
25
|
+
* Heartbeat: engine.io's server-initiated ping, driven by the OBJECT'S
|
|
26
|
+
* ALARM rather than a timer. A timer would keep the object resident (and
|
|
27
|
+
* billed) for as long as a browser tab is open; with the alarm the object
|
|
28
|
+
* hibernates between pings and is woken for a few milliseconds every
|
|
29
|
+
* `PING_INTERVAL_MS` to send `2` and close the sockets that missed
|
|
30
|
+
* `PING_INTERVAL_MS + PING_TIMEOUT_MS`. Everything the round needs (last
|
|
31
|
+
* ping, last pong) is persisted on the socket attachment, so a round on a
|
|
32
|
+
* freshly woken object is exact. The client adopts the interval and timeout
|
|
33
|
+
* the OPEN handshake declares, so no client change is needed. See "Object
|
|
34
|
+
* lifetime and cost" in the README.
|
|
35
|
+
*/
|
|
36
|
+
import type { AuthOutcome } from '../shell/auth';
|
|
37
|
+
import type { EventSink, SessionEventRouter } from '../do/ambient';
|
|
38
|
+
import type {
|
|
39
|
+
FrontendCallParams,
|
|
40
|
+
FrontendCallSurface,
|
|
41
|
+
} from '../plugin-api/types';
|
|
42
|
+
import type { RawEventPayload } from '../core/runtime-context';
|
|
43
|
+
import {
|
|
44
|
+
FrontendCallRegistry,
|
|
45
|
+
type FrontendCallKind,
|
|
46
|
+
type PendingFrontendCall,
|
|
47
|
+
} from './frontend-call-registry';
|
|
48
|
+
import { SessionSocketHub, type SocketAttachment } from './session-socket-hub';
|
|
49
|
+
import {
|
|
50
|
+
decodeClientFrame,
|
|
51
|
+
encodeAck,
|
|
52
|
+
encodeConnectAck,
|
|
53
|
+
encodeConnectError,
|
|
54
|
+
encodeEvent,
|
|
55
|
+
encodeOpen,
|
|
56
|
+
ENGINE_PONG,
|
|
57
|
+
} from './socket-io-codec';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Server ping cadence advertised in the OPEN handshake. Long on purpose:
|
|
61
|
+
* every ping is an alarm wake of the (otherwise hibernated) object, and a
|
|
62
|
+
* dead connection is noticed by the client after
|
|
63
|
+
* `pingInterval + pingTimeout` either way — three to four minutes was the
|
|
64
|
+
* agreed trade between wake cost and detection time.
|
|
65
|
+
*/
|
|
66
|
+
export const PING_INTERVAL_MS = 180_000;
|
|
67
|
+
export const PING_TIMEOUT_MS = 60_000;
|
|
68
|
+
export const MAX_PAYLOAD_BYTES = 1_000_000;
|
|
69
|
+
/** A socket that has not sent its CONNECT packet by then is closed. */
|
|
70
|
+
export const HANDSHAKE_DEADLINE_MS = 10_000;
|
|
71
|
+
export const BROWSER_TOOL_DEFAULT_TIMEOUT_MS = 15_000;
|
|
72
|
+
export const AG_ACTION_DEFAULT_TIMEOUT_MS = 10_000;
|
|
73
|
+
|
|
74
|
+
/** Header the shell sets on the forwarded upgrade: the DID it routed for. */
|
|
75
|
+
export const ROUTED_USER_HEADER = 'x-routed-user-did';
|
|
76
|
+
|
|
77
|
+
const CLIENT_EVENTS = [
|
|
78
|
+
'ping',
|
|
79
|
+
'status',
|
|
80
|
+
'subscribe',
|
|
81
|
+
'list-events',
|
|
82
|
+
'tool_result',
|
|
83
|
+
'action_call_result',
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
const SERVER_EVENTS = [
|
|
87
|
+
'connected',
|
|
88
|
+
'pong',
|
|
89
|
+
'status',
|
|
90
|
+
'subscribed',
|
|
91
|
+
'available-events',
|
|
92
|
+
'render_component',
|
|
93
|
+
'tool_call',
|
|
94
|
+
'action_call',
|
|
95
|
+
'browser_tool_call',
|
|
96
|
+
'router_update',
|
|
97
|
+
'message_cache_invalidation',
|
|
98
|
+
] as const;
|
|
99
|
+
|
|
100
|
+
/** Close codes in the 4000–4999 application range. */
|
|
101
|
+
const CLOSE_UNAUTHORIZED = 4401;
|
|
102
|
+
const CLOSE_HANDSHAKE_TIMEOUT = 4408;
|
|
103
|
+
const CLOSE_PROTOCOL = 4400;
|
|
104
|
+
|
|
105
|
+
export interface RealtimeEndpointDeps {
|
|
106
|
+
/** `ctx.acceptWebSocket` / `ctx.getWebSockets` of the owning object. */
|
|
107
|
+
ctx: {
|
|
108
|
+
acceptWebSocket(ws: WebSocket, tags?: string[]): void;
|
|
109
|
+
getWebSockets(tag?: string): WebSocket[];
|
|
110
|
+
};
|
|
111
|
+
/** Validates the CONNECT packet's UCAN material (the shell's `authenticate`). */
|
|
112
|
+
authenticate: (auth: {
|
|
113
|
+
invocation?: string;
|
|
114
|
+
ucanDelegation?: string;
|
|
115
|
+
}) => Promise<AuthOutcome>;
|
|
116
|
+
/** Whether `sessionId` exists for this user (a socket to an unknown session is refused). */
|
|
117
|
+
sessionExists: (userDid: string, sessionId: string) => Promise<boolean>;
|
|
118
|
+
/** Event fan-out of the object; the endpoint taps it. */
|
|
119
|
+
router: SessionEventRouter;
|
|
120
|
+
logger: { log: (m: string) => void; warn: (m: string) => void };
|
|
121
|
+
/**
|
|
122
|
+
* The last authenticated socket subscribed to `sessionId` has gone (closed,
|
|
123
|
+
* errored, or dropped by the heartbeat). The Node runtime indexes the
|
|
124
|
+
* session's history into the memory engine at that moment
|
|
125
|
+
* (`WsService.removeClientConnection`); the owning object wires the same.
|
|
126
|
+
*/
|
|
127
|
+
onSessionDrained?: (sessionId: string, userDid: string) => void;
|
|
128
|
+
/**
|
|
129
|
+
* Ask the object to run `alarm()` no later than `at` (the object's own
|
|
130
|
+
* `requestAlarm`): armed for the next heartbeat round whenever a socket is
|
|
131
|
+
* accepted or re-adopted. The object calls `pingTick` from its alarm.
|
|
132
|
+
*/
|
|
133
|
+
requestAlarm?: (at: number) => void;
|
|
134
|
+
now?: () => number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface RealtimeSocketStatus {
|
|
138
|
+
sid: string;
|
|
139
|
+
sessionId: string;
|
|
140
|
+
authenticated: boolean;
|
|
141
|
+
openedAt: number;
|
|
142
|
+
lastPingAt: number | null;
|
|
143
|
+
lastPongAt: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface RealtimeStatus {
|
|
147
|
+
sockets: number;
|
|
148
|
+
authenticated: number;
|
|
149
|
+
sessions: number;
|
|
150
|
+
pendingCalls: PendingFrontendCall[];
|
|
151
|
+
pingIntervalMs: number;
|
|
152
|
+
pingTimeoutMs: number;
|
|
153
|
+
/** When the next heartbeat round is due (null with no sockets). */
|
|
154
|
+
nextPingAt: number | null;
|
|
155
|
+
socketDetails: RealtimeSocketStatus[];
|
|
156
|
+
/**
|
|
157
|
+
* Live JavaScript timers in the isolate (debug routes only): anything here
|
|
158
|
+
* keeps the object resident instead of hibernating between messages.
|
|
159
|
+
*/
|
|
160
|
+
pendingTimers?: Array<{
|
|
161
|
+
id: number;
|
|
162
|
+
kind: 'timeout' | 'interval';
|
|
163
|
+
delayMs: number;
|
|
164
|
+
ageMs: number;
|
|
165
|
+
createdAt: string[];
|
|
166
|
+
}>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
170
|
+
return typeof value === 'object' && value !== null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readString(
|
|
174
|
+
record: Record<string, unknown>,
|
|
175
|
+
key: string,
|
|
176
|
+
): string | undefined {
|
|
177
|
+
const value = record[key];
|
|
178
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class RealtimeEndpoint {
|
|
182
|
+
readonly hub: SessionSocketHub<WebSocket>;
|
|
183
|
+
|
|
184
|
+
readonly calls = new FrontendCallRegistry();
|
|
185
|
+
|
|
186
|
+
readonly frontend: FrontendCallSurface;
|
|
187
|
+
|
|
188
|
+
private readonly handshakeTimers = new Map<
|
|
189
|
+
WebSocket,
|
|
190
|
+
ReturnType<typeof setTimeout>
|
|
191
|
+
>();
|
|
192
|
+
|
|
193
|
+
private readonly now: () => number;
|
|
194
|
+
|
|
195
|
+
constructor(private readonly deps: RealtimeEndpointDeps) {
|
|
196
|
+
this.now = deps.now ?? (() => Date.now());
|
|
197
|
+
this.hub = new SessionSocketHub<WebSocket>({ now: this.now });
|
|
198
|
+
const tap: EventSink = {
|
|
199
|
+
emit: (eventName, payload) => this.fanOut(eventName, payload),
|
|
200
|
+
};
|
|
201
|
+
deps.router.tap(tap);
|
|
202
|
+
this.frontend = {
|
|
203
|
+
callBrowserTool: (params) =>
|
|
204
|
+
this.call(
|
|
205
|
+
'browser',
|
|
206
|
+
'browser_tool_call',
|
|
207
|
+
params,
|
|
208
|
+
BROWSER_TOOL_DEFAULT_TIMEOUT_MS,
|
|
209
|
+
),
|
|
210
|
+
callAgAction: (params) =>
|
|
211
|
+
this.call('agui', 'action_call', params, AG_ACTION_DEFAULT_TIMEOUT_MS),
|
|
212
|
+
hasClient: (sessionId) => this.hub.hasSession(sessionId),
|
|
213
|
+
};
|
|
214
|
+
// Sockets that survived hibernation or a restart.
|
|
215
|
+
const restored = this.hub.restore(deps.ctx.getWebSockets());
|
|
216
|
+
if (restored > 0) {
|
|
217
|
+
deps.logger.log(`[realtime] re-adopted ${restored} hibernated socket(s)`);
|
|
218
|
+
this.scheduleHeartbeat();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── HTTP upgrade ────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/** `GET /socket.io/?EIO=4&transport=websocket&sessionId=…` forwarded by the shell. */
|
|
225
|
+
handleUpgrade(request: Request, url: URL): Response {
|
|
226
|
+
const bad = (message: string, status = 400): Response =>
|
|
227
|
+
Response.json({ code: 3, message }, { status });
|
|
228
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
229
|
+
return bad(
|
|
230
|
+
'Expected a WebSocket upgrade: this runtime serves socket.io over the websocket transport only (connect with transports: ["websocket"]).',
|
|
231
|
+
426,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (url.searchParams.get('EIO') !== '4') {
|
|
235
|
+
return bad(
|
|
236
|
+
'Unsupported protocol version: engine.io v4 (EIO=4) is required.',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (url.searchParams.get('transport') !== 'websocket') {
|
|
240
|
+
return bad('Transport unknown: only the websocket transport is served.');
|
|
241
|
+
}
|
|
242
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
243
|
+
if (!sessionId) return bad('sessionId query parameter is required.');
|
|
244
|
+
const routedUserDid = request.headers.get(ROUTED_USER_HEADER);
|
|
245
|
+
if (!routedUserDid) return bad('userDid query parameter is required.');
|
|
246
|
+
|
|
247
|
+
const pair = new WebSocketPair();
|
|
248
|
+
const [client, server] = [pair[0], pair[1]];
|
|
249
|
+
const sid = crypto.randomUUID();
|
|
250
|
+
this.deps.ctx.acceptWebSocket(server, [`session:${sessionId}`]);
|
|
251
|
+
const openedAt = this.now();
|
|
252
|
+
this.hub.add(server, {
|
|
253
|
+
sid,
|
|
254
|
+
sessionId,
|
|
255
|
+
routedUserDid,
|
|
256
|
+
openedAt,
|
|
257
|
+
lastPongAt: openedAt,
|
|
258
|
+
});
|
|
259
|
+
server.send(
|
|
260
|
+
encodeOpen({
|
|
261
|
+
sid,
|
|
262
|
+
pingIntervalMs: PING_INTERVAL_MS,
|
|
263
|
+
pingTimeoutMs: PING_TIMEOUT_MS,
|
|
264
|
+
maxPayloadBytes: MAX_PAYLOAD_BYTES,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
this.handshakeTimers.set(
|
|
268
|
+
server,
|
|
269
|
+
setTimeout(() => {
|
|
270
|
+
this.handshakeTimers.delete(server);
|
|
271
|
+
if (this.hub.get(server)?.userDid) return;
|
|
272
|
+
this.drop(server, CLOSE_HANDSHAKE_TIMEOUT, 'handshake timeout');
|
|
273
|
+
}, HANDSHAKE_DEADLINE_MS),
|
|
274
|
+
);
|
|
275
|
+
this.scheduleHeartbeat();
|
|
276
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Hibernatable WebSocket handlers ─────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
async onMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
282
|
+
const meta = this.hub.get(ws);
|
|
283
|
+
if (!meta) {
|
|
284
|
+
this.drop(ws, CLOSE_PROTOCOL, 'unknown socket');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (typeof message !== 'string') {
|
|
288
|
+
this.drop(ws, CLOSE_PROTOCOL, 'binary frames are not supported');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const frame = decodeClientFrame(message);
|
|
292
|
+
switch (frame.kind) {
|
|
293
|
+
case 'pong':
|
|
294
|
+
this.hub.notePong(ws);
|
|
295
|
+
return;
|
|
296
|
+
case 'ping':
|
|
297
|
+
this.hub.send(ws, ENGINE_PONG);
|
|
298
|
+
return;
|
|
299
|
+
case 'close':
|
|
300
|
+
case 'disconnect':
|
|
301
|
+
this.drop(ws, 1000, 'client disconnect');
|
|
302
|
+
return;
|
|
303
|
+
case 'connect':
|
|
304
|
+
await this.connect(ws, meta, frame.auth);
|
|
305
|
+
return;
|
|
306
|
+
case 'event':
|
|
307
|
+
if (!meta.userDid) {
|
|
308
|
+
this.hub.send(ws, encodeConnectError('Unauthorized'));
|
|
309
|
+
this.drop(ws, CLOSE_UNAUTHORIZED, 'event before CONNECT');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.handleEvent(ws, meta, frame.name, frame.args[0]);
|
|
313
|
+
if (frame.ackId !== undefined)
|
|
314
|
+
this.hub.send(ws, encodeAck(frame.ackId));
|
|
315
|
+
return;
|
|
316
|
+
case 'ack':
|
|
317
|
+
// The server never requests acks; nothing to match.
|
|
318
|
+
return;
|
|
319
|
+
case 'unsupported':
|
|
320
|
+
this.deps.logger.warn(
|
|
321
|
+
`[realtime] unsupported frame from ${meta.sid}: ${frame.reason}`,
|
|
322
|
+
);
|
|
323
|
+
return;
|
|
324
|
+
default:
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
onClose(ws: WebSocket): void {
|
|
330
|
+
this.forget(ws);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
onError(ws: WebSocket, error: unknown): void {
|
|
334
|
+
this.deps.logger.warn(
|
|
335
|
+
`[realtime] socket error: ${error instanceof Error ? error.message : String(error)}`,
|
|
336
|
+
);
|
|
337
|
+
this.forget(ws);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
status(): RealtimeStatus {
|
|
341
|
+
return {
|
|
342
|
+
sockets: this.hub.size,
|
|
343
|
+
authenticated: this.hub.connectionCount(),
|
|
344
|
+
sessions: this.hub.sessionCount(),
|
|
345
|
+
pendingCalls: this.calls.list(),
|
|
346
|
+
pingIntervalMs: PING_INTERVAL_MS,
|
|
347
|
+
pingTimeoutMs: PING_TIMEOUT_MS,
|
|
348
|
+
nextPingAt: this.nextPingAt(),
|
|
349
|
+
socketDetails: this.hub.entries().map(({ attachment }) => ({
|
|
350
|
+
sid: attachment.sid,
|
|
351
|
+
sessionId: attachment.sessionId,
|
|
352
|
+
authenticated: Boolean(attachment.userDid),
|
|
353
|
+
openedAt: attachment.openedAt,
|
|
354
|
+
lastPingAt: attachment.lastPingAt ?? null,
|
|
355
|
+
lastPongAt: attachment.lastPongAt,
|
|
356
|
+
})),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── heartbeat (alarm-driven) ────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/** When the object should next run `pingTick` (null: no sockets). */
|
|
363
|
+
nextPingAt(): number | null {
|
|
364
|
+
return this.hub.nextPingAt(PING_INTERVAL_MS);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* One heartbeat round, called from the object's alarm: pings every socket
|
|
369
|
+
* and closes the ones that missed the timeout. Returns when the next round
|
|
370
|
+
* is due, or null once no socket is left. Cheap enough to run on a woken
|
|
371
|
+
* object without opening the user's database.
|
|
372
|
+
*/
|
|
373
|
+
pingTick(): number | null {
|
|
374
|
+
const closed = this.hub.heartbeat(PING_INTERVAL_MS, PING_TIMEOUT_MS);
|
|
375
|
+
if (closed.length > 0) {
|
|
376
|
+
this.deps.logger.log(
|
|
377
|
+
`[realtime] closed ${closed.length} socket(s): ping timeout`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return this.nextPingAt();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── handshake ───────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
private async connect(
|
|
386
|
+
ws: WebSocket,
|
|
387
|
+
meta: SocketAttachment,
|
|
388
|
+
auth: unknown,
|
|
389
|
+
): Promise<void> {
|
|
390
|
+
if (meta.userDid) {
|
|
391
|
+
// A second CONNECT on an authenticated socket: acknowledge again.
|
|
392
|
+
this.hub.send(ws, encodeConnectAck(meta.sid));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const record = isRecord(auth) ? auth : {};
|
|
396
|
+
const invocation = readString(record, 'invocation');
|
|
397
|
+
const ucanDelegation = readString(record, 'ucanDelegation');
|
|
398
|
+
if (!invocation && !ucanDelegation) {
|
|
399
|
+
this.refuse(
|
|
400
|
+
ws,
|
|
401
|
+
'Unauthorized: missing UCAN auth (invocation or ucanDelegation)',
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
let outcome: AuthOutcome;
|
|
406
|
+
try {
|
|
407
|
+
outcome = await this.deps.authenticate({
|
|
408
|
+
...(invocation ? { invocation } : {}),
|
|
409
|
+
...(ucanDelegation ? { ucanDelegation } : {}),
|
|
410
|
+
});
|
|
411
|
+
} catch (err) {
|
|
412
|
+
this.deps.logger.warn(
|
|
413
|
+
`[realtime] auth check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
414
|
+
);
|
|
415
|
+
this.refuse(ws, 'Unauthorized: auth check failed');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (!outcome.ok) {
|
|
419
|
+
this.refuse(ws, `Unauthorized: ${outcome.error}`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (outcome.auth.userDid !== meta.routedUserDid) {
|
|
423
|
+
// The shell routed on an unauthenticated query parameter; the token
|
|
424
|
+
// decides. A mismatch is someone pointing their token at another
|
|
425
|
+
// user's object.
|
|
426
|
+
this.refuse(ws, 'Unauthorized: token does not belong to the routed user');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (
|
|
430
|
+
!(await this.deps.sessionExists(outcome.auth.userDid, meta.sessionId))
|
|
431
|
+
) {
|
|
432
|
+
this.refuse(ws, `Session ${meta.sessionId} not found`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// The socket may have gone while we validated.
|
|
436
|
+
if (!this.hub.get(ws)) return;
|
|
437
|
+
const timer = this.handshakeTimers.get(ws);
|
|
438
|
+
if (timer) {
|
|
439
|
+
clearTimeout(timer);
|
|
440
|
+
this.handshakeTimers.delete(ws);
|
|
441
|
+
}
|
|
442
|
+
this.hub.update(ws, { userDid: outcome.auth.userDid });
|
|
443
|
+
this.hub.send(ws, encodeConnectAck(meta.sid));
|
|
444
|
+
this.hub.send(
|
|
445
|
+
ws,
|
|
446
|
+
encodeEvent('connected', {
|
|
447
|
+
message: 'Connected successfully',
|
|
448
|
+
sessionId: meta.sessionId,
|
|
449
|
+
timestamp: new Date(this.now()).toISOString(),
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
this.deps.logger.log(
|
|
453
|
+
`[realtime] ${outcome.auth.userDid} connected to session ${meta.sessionId} (${meta.sid})`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private refuse(ws: WebSocket, message: string): void {
|
|
458
|
+
this.hub.send(ws, encodeConnectError(message));
|
|
459
|
+
this.drop(ws, CLOSE_UNAUTHORIZED, message.slice(0, 120));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── inbound events ──────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
private handleEvent(
|
|
465
|
+
ws: WebSocket,
|
|
466
|
+
meta: SocketAttachment,
|
|
467
|
+
name: string,
|
|
468
|
+
data: unknown,
|
|
469
|
+
): void {
|
|
470
|
+
const timestamp = new Date(this.now()).toISOString();
|
|
471
|
+
switch (name) {
|
|
472
|
+
case 'ping':
|
|
473
|
+
this.hub.send(
|
|
474
|
+
ws,
|
|
475
|
+
encodeEvent('pong', { timestamp, sessionId: meta.sessionId }),
|
|
476
|
+
);
|
|
477
|
+
return;
|
|
478
|
+
case 'status':
|
|
479
|
+
this.hub.send(
|
|
480
|
+
ws,
|
|
481
|
+
encodeEvent('status', {
|
|
482
|
+
connected: true,
|
|
483
|
+
sessionId: meta.sessionId,
|
|
484
|
+
activeSessions: this.hub.sessionCount(),
|
|
485
|
+
totalConnections: this.hub.connectionCount(),
|
|
486
|
+
timestamp,
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
return;
|
|
490
|
+
case 'subscribe':
|
|
491
|
+
// Node keeps this for compatibility: the socket already joined the
|
|
492
|
+
// session named at connect time.
|
|
493
|
+
this.hub.send(
|
|
494
|
+
ws,
|
|
495
|
+
encodeEvent('subscribed', { sessionId: meta.sessionId, timestamp }),
|
|
496
|
+
);
|
|
497
|
+
return;
|
|
498
|
+
case 'list-events':
|
|
499
|
+
this.hub.send(
|
|
500
|
+
ws,
|
|
501
|
+
encodeEvent('available-events', {
|
|
502
|
+
clientEvents: [...CLIENT_EVENTS],
|
|
503
|
+
serverEvents: [...SERVER_EVENTS],
|
|
504
|
+
sessionId: meta.sessionId,
|
|
505
|
+
timestamp,
|
|
506
|
+
}),
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
case 'tool_result':
|
|
510
|
+
this.settle('browser', meta, data);
|
|
511
|
+
return;
|
|
512
|
+
case 'action_call_result':
|
|
513
|
+
this.settle('agui', meta, data);
|
|
514
|
+
return;
|
|
515
|
+
default:
|
|
516
|
+
this.deps.logger.warn(
|
|
517
|
+
`[realtime] ignoring unknown event ${name} from ${meta.sid}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private settle(
|
|
523
|
+
kind: FrontendCallKind,
|
|
524
|
+
meta: SocketAttachment,
|
|
525
|
+
data: unknown,
|
|
526
|
+
): void {
|
|
527
|
+
const record = isRecord(data) ? data : {};
|
|
528
|
+
const toolCallId = readString(record, 'toolCallId');
|
|
529
|
+
if (!toolCallId) {
|
|
530
|
+
this.deps.logger.warn(
|
|
531
|
+
`[realtime] ${kind} result without toolCallId from ${meta.sid}`,
|
|
532
|
+
);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const error = readString(record, 'error');
|
|
536
|
+
const settled = this.calls.settle(kind, {
|
|
537
|
+
toolCallId,
|
|
538
|
+
result: record.result,
|
|
539
|
+
...(error ? { error } : {}),
|
|
540
|
+
});
|
|
541
|
+
if (!settled) {
|
|
542
|
+
this.deps.logger.warn(
|
|
543
|
+
`[realtime] ${kind} result for ${toolCallId} had no pending call (late or duplicate)`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── outbound ────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
private fanOut(eventName: string, payload: RawEventPayload): void {
|
|
551
|
+
const sessionId =
|
|
552
|
+
typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
|
|
553
|
+
if (!sessionId) return;
|
|
554
|
+
this.hub.emitToSession(sessionId, eventName, payload);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private call(
|
|
558
|
+
kind: FrontendCallKind,
|
|
559
|
+
eventName: 'browser_tool_call' | 'action_call',
|
|
560
|
+
params: FrontendCallParams,
|
|
561
|
+
defaultTimeoutMs: number,
|
|
562
|
+
): Promise<unknown> {
|
|
563
|
+
const timeoutMs = params.timeoutMs ?? defaultTimeoutMs;
|
|
564
|
+
// Register BEFORE emitting so a synchronous answer cannot be missed.
|
|
565
|
+
const pending = this.calls.wait(
|
|
566
|
+
{
|
|
567
|
+
kind,
|
|
568
|
+
toolCallId: params.toolCallId,
|
|
569
|
+
toolName: params.toolName,
|
|
570
|
+
sessionId: params.sessionId,
|
|
571
|
+
},
|
|
572
|
+
{ timeoutMs },
|
|
573
|
+
);
|
|
574
|
+
const payload: RawEventPayload = {
|
|
575
|
+
sessionId: params.sessionId,
|
|
576
|
+
requestId: params.toolCallId,
|
|
577
|
+
toolCallId: params.toolCallId,
|
|
578
|
+
toolName: params.toolName,
|
|
579
|
+
args: params.args,
|
|
580
|
+
...(kind === 'agui' ? { status: 'isRunning' } : {}),
|
|
581
|
+
};
|
|
582
|
+
// Through the router: the session's SSE stream sees it too (Node emits
|
|
583
|
+
// it on the root emitter, which feeds both channels).
|
|
584
|
+
this.deps.router.emit(eventName, payload);
|
|
585
|
+
return pending;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ── housekeeping ────────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
private drop(ws: WebSocket, code: number, reason: string): void {
|
|
591
|
+
this.forget(ws);
|
|
592
|
+
try {
|
|
593
|
+
ws.close(code, reason);
|
|
594
|
+
} catch {
|
|
595
|
+
// already closed
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private forget(ws: WebSocket): void {
|
|
600
|
+
const timer = this.handshakeTimers.get(ws);
|
|
601
|
+
if (timer) {
|
|
602
|
+
clearTimeout(timer);
|
|
603
|
+
this.handshakeTimers.delete(ws);
|
|
604
|
+
}
|
|
605
|
+
const attachment = this.hub.get(ws);
|
|
606
|
+
this.hub.remove(ws);
|
|
607
|
+
// Only an authenticated socket ever counted for its session; a socket
|
|
608
|
+
// that never passed CONNECT leaves nothing to drain.
|
|
609
|
+
if (attachment?.userDid && !this.hub.hasSession(attachment.sessionId)) {
|
|
610
|
+
this.deps.onSessionDrained?.(attachment.sessionId, attachment.userDid);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Make sure an alarm is set for the next heartbeat round. */
|
|
615
|
+
private scheduleHeartbeat(): void {
|
|
616
|
+
const at = this.nextPingAt();
|
|
617
|
+
if (at !== null) this.deps.requestAlarm?.(at);
|
|
618
|
+
}
|
|
619
|
+
}
|