@robodev-ai/runtime 0.2.0 → 0.4.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/package.json +2 -2
- package/src/auth-core.ts +49 -0
- package/src/index.ts +73 -1
- package/src/local-auth.test.ts +262 -6
- package/src/local-auth.ts +180 -5
- package/src/local-google.test.ts +93 -0
- package/src/local-google.ts +232 -0
- package/src/local-jobs.test.ts +36 -17
- package/src/local-storage.test.ts +217 -0
- package/src/local-storage.ts +213 -0
- package/src/sockets.test.ts +112 -0
- package/src/sockets.ts +293 -0
- package/src/storage-rules.test.ts +110 -0
- package/src/storage-rules.ts +80 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { StorageClient, StorageObject, StorageUploadOptions } from "@robodev-ai/sdk";
|
|
5
|
+
import {
|
|
6
|
+
assertObjectSize,
|
|
7
|
+
mintStorageServeToken,
|
|
8
|
+
parseLogicalKey,
|
|
9
|
+
storageListQuerySchema,
|
|
10
|
+
} from "./storage-rules.js";
|
|
11
|
+
|
|
12
|
+
export type StorageQueryable = {
|
|
13
|
+
query: <T extends Record<string, unknown> = Record<string, unknown>>(
|
|
14
|
+
sql: string,
|
|
15
|
+
values?: unknown[],
|
|
16
|
+
) => Promise<{ rows: T[] }>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type LocalObjectRow = {
|
|
20
|
+
key: string;
|
|
21
|
+
public: boolean;
|
|
22
|
+
size_bytes: string | number;
|
|
23
|
+
content_type: string;
|
|
24
|
+
created_at: Date;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type LocalStorageClientOptions = {
|
|
28
|
+
query: StorageQueryable["query"];
|
|
29
|
+
objectsDir: string;
|
|
30
|
+
publicBaseUrl: string;
|
|
31
|
+
projectId: string;
|
|
32
|
+
jwtSecret: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function notFound(key: string): never {
|
|
36
|
+
throw Object.assign(new Error(`Storage object not found: ${key}`), {
|
|
37
|
+
statusCode: 404,
|
|
38
|
+
code: "not_found",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function escapeLike(value: string): string {
|
|
43
|
+
return value.replace(/[\\%_]/g, "\\$&");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function objectPath(objectsDir: string, key: string): string {
|
|
47
|
+
return join(objectsDir, ...key.split("/"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stableObjectUrl(publicBaseUrl: string, key: string): string {
|
|
51
|
+
return `${publicBaseUrl.replace(/\/$/, "")}/storage/objects/${key}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toStorageObject(publicBaseUrl: string, row: LocalObjectRow): StorageObject {
|
|
55
|
+
return {
|
|
56
|
+
key: row.key,
|
|
57
|
+
public: row.public,
|
|
58
|
+
size: Number(row.size_bytes),
|
|
59
|
+
contentType: row.content_type,
|
|
60
|
+
url: stableObjectUrl(publicBaseUrl, row.key),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function loadRow(
|
|
65
|
+
query: StorageQueryable["query"],
|
|
66
|
+
key: string,
|
|
67
|
+
): Promise<LocalObjectRow | null> {
|
|
68
|
+
const result = await query<LocalObjectRow>(
|
|
69
|
+
`SELECT key, public, size_bytes, content_type, created_at
|
|
70
|
+
FROM robodev_storage.objects WHERE key = $1`,
|
|
71
|
+
[key],
|
|
72
|
+
);
|
|
73
|
+
return result.rows[0] ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readObjectFile(objectsDir: string, key: string): Promise<Buffer | null> {
|
|
77
|
+
try {
|
|
78
|
+
return await readFile(objectPath(objectsDir, key));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Creates the reserved `robodev_storage` schema. Idempotent, not drizzle, not public. */
|
|
85
|
+
export async function ensureStorageSchema(db: StorageQueryable): Promise<void> {
|
|
86
|
+
await db.query(`CREATE SCHEMA IF NOT EXISTS robodev_storage`);
|
|
87
|
+
await db.query(`
|
|
88
|
+
CREATE TABLE IF NOT EXISTS robodev_storage.objects (
|
|
89
|
+
key TEXT PRIMARY KEY,
|
|
90
|
+
public BOOLEAN NOT NULL,
|
|
91
|
+
size_bytes INTEGER NOT NULL,
|
|
92
|
+
content_type TEXT NOT NULL,
|
|
93
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
94
|
+
)
|
|
95
|
+
`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function resolveLocalObject(input: {
|
|
99
|
+
query: StorageQueryable["query"];
|
|
100
|
+
objectsDir: string;
|
|
101
|
+
key: string;
|
|
102
|
+
}): Promise<{ row: LocalObjectRow; body: Buffer } | null> {
|
|
103
|
+
const logicalKey = parseLogicalKey(input.key);
|
|
104
|
+
const row = await loadRow(input.query, logicalKey);
|
|
105
|
+
if (!row) return null;
|
|
106
|
+
const body = await readObjectFile(input.objectsDir, logicalKey);
|
|
107
|
+
if (!body) return null;
|
|
108
|
+
return { row, body };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createLocalStorageClient(options: LocalStorageClientOptions): StorageClient {
|
|
112
|
+
const query = options.query;
|
|
113
|
+
const objectsDir = options.objectsDir;
|
|
114
|
+
const publicBaseUrl = options.publicBaseUrl;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
async upload(key, data, upload?: StorageUploadOptions) {
|
|
118
|
+
const logicalKey = parseLogicalKey(key);
|
|
119
|
+
const contentType = upload?.contentType?.trim() || "application/octet-stream";
|
|
120
|
+
const isPublic = Boolean(upload?.public);
|
|
121
|
+
const buffer = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
|
|
122
|
+
assertObjectSize(buffer.byteLength);
|
|
123
|
+
|
|
124
|
+
await mkdir(objectsDir, { recursive: true });
|
|
125
|
+
const dest = objectPath(objectsDir, logicalKey);
|
|
126
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
127
|
+
const tmp = join(objectsDir, `.tmp-${randomBytes(8).toString("hex")}`);
|
|
128
|
+
await writeFile(tmp, buffer);
|
|
129
|
+
await rename(tmp, dest);
|
|
130
|
+
|
|
131
|
+
const result = await query<LocalObjectRow>(
|
|
132
|
+
`INSERT INTO robodev_storage.objects (key, public, size_bytes, content_type)
|
|
133
|
+
VALUES ($1, $2, $3, $4)
|
|
134
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
135
|
+
public = EXCLUDED.public,
|
|
136
|
+
size_bytes = EXCLUDED.size_bytes,
|
|
137
|
+
content_type = EXCLUDED.content_type
|
|
138
|
+
RETURNING key, public, size_bytes, content_type, created_at`,
|
|
139
|
+
[logicalKey, isPublic, buffer.byteLength, contentType],
|
|
140
|
+
);
|
|
141
|
+
const row = result.rows[0];
|
|
142
|
+
if (!row) throw new Error("Failed to upsert storage metadata");
|
|
143
|
+
return toStorageObject(publicBaseUrl, row);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async get(key) {
|
|
147
|
+
const logicalKey = parseLogicalKey(key);
|
|
148
|
+
const resolved = await resolveLocalObject({ query, objectsDir, key: logicalKey });
|
|
149
|
+
if (!resolved) notFound(logicalKey);
|
|
150
|
+
return {
|
|
151
|
+
body: resolved.body,
|
|
152
|
+
contentType: resolved.row.content_type,
|
|
153
|
+
public: resolved.row.public,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async getUrl(key, urlOptions) {
|
|
158
|
+
const logicalKey = parseLogicalKey(key);
|
|
159
|
+
const row = await loadRow(query, logicalKey);
|
|
160
|
+
if (!row) notFound(logicalKey);
|
|
161
|
+
const body = await readObjectFile(objectsDir, logicalKey);
|
|
162
|
+
if (!body) notFound(logicalKey);
|
|
163
|
+
if (row.public) return stableObjectUrl(publicBaseUrl, logicalKey);
|
|
164
|
+
const { exp, sig } = mintStorageServeToken(
|
|
165
|
+
options.jwtSecret,
|
|
166
|
+
options.projectId,
|
|
167
|
+
logicalKey,
|
|
168
|
+
urlOptions?.expiresIn,
|
|
169
|
+
);
|
|
170
|
+
return `${stableObjectUrl(publicBaseUrl, logicalKey)}?exp=${exp}&sig=${sig}`;
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async delete(key) {
|
|
174
|
+
const logicalKey = parseLogicalKey(key);
|
|
175
|
+
const row = await loadRow(query, logicalKey);
|
|
176
|
+
if (!row) notFound(logicalKey);
|
|
177
|
+
try {
|
|
178
|
+
await unlink(objectPath(objectsDir, logicalKey));
|
|
179
|
+
} catch {
|
|
180
|
+
/* ignore missing file */
|
|
181
|
+
}
|
|
182
|
+
await query(`DELETE FROM robodev_storage.objects WHERE key = $1`, [logicalKey]);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async list(listOptions) {
|
|
186
|
+
const parsed = storageListQuerySchema.parse({
|
|
187
|
+
prefix: listOptions?.prefix,
|
|
188
|
+
limit: listOptions?.limit,
|
|
189
|
+
offset: listOptions?.offset,
|
|
190
|
+
});
|
|
191
|
+
const prefix = parsed.prefix ?? "";
|
|
192
|
+
if (prefix) parseLogicalKey(prefix.replace(/\/+$/, "") || prefix);
|
|
193
|
+
const like = prefix ? `${escapeLike(prefix)}%` : "%";
|
|
194
|
+
const count = await query<{ count: string }>(
|
|
195
|
+
`SELECT count(*)::text AS count FROM robodev_storage.objects
|
|
196
|
+
WHERE key LIKE $1 ESCAPE E'\\\\'`,
|
|
197
|
+
[like],
|
|
198
|
+
);
|
|
199
|
+
const result = await query<LocalObjectRow>(
|
|
200
|
+
`SELECT key, public, size_bytes, content_type, created_at
|
|
201
|
+
FROM robodev_storage.objects
|
|
202
|
+
WHERE key LIKE $1 ESCAPE E'\\\\'
|
|
203
|
+
ORDER BY created_at DESC
|
|
204
|
+
LIMIT $2 OFFSET $3`,
|
|
205
|
+
[like, parsed.limit, parsed.offset],
|
|
206
|
+
);
|
|
207
|
+
return {
|
|
208
|
+
objects: result.rows.map((row) => toStorageObject(publicBaseUrl, row)),
|
|
209
|
+
total: Number(count.rows[0]?.count ?? 0),
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
MAX_PROJECT_SOCKETS,
|
|
5
|
+
MAX_SOCKET_PAYLOAD_BYTES,
|
|
6
|
+
SOCKET_CLOSE_INVALID,
|
|
7
|
+
SOCKET_CLOSE_POLICY,
|
|
8
|
+
SOCKET_CLOSE_TOO_BIG,
|
|
9
|
+
attachProjectSocket,
|
|
10
|
+
closeProjectSockets,
|
|
11
|
+
dispatchSocketsSend,
|
|
12
|
+
joinSocketRoom,
|
|
13
|
+
parseSocketMessage,
|
|
14
|
+
projectSocketCount,
|
|
15
|
+
sendToSocketRoom,
|
|
16
|
+
setProjectSocketNames,
|
|
17
|
+
stringifySocketPayload,
|
|
18
|
+
} from "./sockets.js";
|
|
19
|
+
|
|
20
|
+
function mockSocket() {
|
|
21
|
+
const sent: string[] = [];
|
|
22
|
+
let readyState = 1;
|
|
23
|
+
return {
|
|
24
|
+
sent,
|
|
25
|
+
get readyState() {
|
|
26
|
+
return readyState;
|
|
27
|
+
},
|
|
28
|
+
send(data: string) {
|
|
29
|
+
sent.push(data);
|
|
30
|
+
},
|
|
31
|
+
close() {
|
|
32
|
+
readyState = 3;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("51st connection is rejected with 1008", () => {
|
|
38
|
+
const projectId = "sock-limit";
|
|
39
|
+
closeProjectSockets(projectId);
|
|
40
|
+
for (let i = 0; i < MAX_PROJECT_SOCKETS; i++) {
|
|
41
|
+
const attached = attachProjectSocket({
|
|
42
|
+
projectId,
|
|
43
|
+
name: "chat",
|
|
44
|
+
socket: mockSocket(),
|
|
45
|
+
});
|
|
46
|
+
assert.equal(attached.ok, true);
|
|
47
|
+
}
|
|
48
|
+
assert.equal(projectSocketCount(projectId), MAX_PROJECT_SOCKETS);
|
|
49
|
+
const extra = attachProjectSocket({
|
|
50
|
+
projectId,
|
|
51
|
+
name: "chat",
|
|
52
|
+
socket: mockSocket(),
|
|
53
|
+
});
|
|
54
|
+
assert.equal(extra.ok, false);
|
|
55
|
+
if (!extra.ok) {
|
|
56
|
+
assert.equal(extra.code, SOCKET_CLOSE_POLICY);
|
|
57
|
+
}
|
|
58
|
+
closeProjectSockets(projectId);
|
|
59
|
+
assert.equal(projectSocketCount(projectId), 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("unknown socket name throws unknown_socket", () => {
|
|
63
|
+
setProjectSocketNames("sock-known", ["chat"]);
|
|
64
|
+
assert.throws(
|
|
65
|
+
() => dispatchSocketsSend("sock-known", ["chat"], { name: "missing", payload: { ok: true } }),
|
|
66
|
+
(err: { message?: string; code?: string }) =>
|
|
67
|
+
err.message === "unknown_socket" && err.code === "unknown_socket",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("room broadcast stays on the same socket name", () => {
|
|
72
|
+
const projectId = "sock-rooms";
|
|
73
|
+
closeProjectSockets(projectId);
|
|
74
|
+
setProjectSocketNames(projectId, ["chat", "alerts"]);
|
|
75
|
+
const chatA = mockSocket();
|
|
76
|
+
const chatB = mockSocket();
|
|
77
|
+
const alerts = mockSocket();
|
|
78
|
+
const a = attachProjectSocket({ projectId, name: "chat", socket: chatA });
|
|
79
|
+
const b = attachProjectSocket({ projectId, name: "chat", socket: chatB });
|
|
80
|
+
const c = attachProjectSocket({ projectId, name: "alerts", socket: alerts });
|
|
81
|
+
assert.ok(a.ok && b.ok && c.ok);
|
|
82
|
+
if (!a.ok || !b.ok || !c.ok) return;
|
|
83
|
+
joinSocketRoom(a.conn, "lobby");
|
|
84
|
+
joinSocketRoom(b.conn, "lobby");
|
|
85
|
+
joinSocketRoom(c.conn, "lobby");
|
|
86
|
+
sendToSocketRoom(projectId, "chat", "lobby", JSON.stringify({ hi: true }), a.conn);
|
|
87
|
+
assert.deepEqual(chatA.sent, []);
|
|
88
|
+
assert.deepEqual(chatB.sent, [JSON.stringify({ hi: true })]);
|
|
89
|
+
assert.deepEqual(alerts.sent, []);
|
|
90
|
+
dispatchSocketsSend(projectId, ["chat", "alerts"], {
|
|
91
|
+
name: "chat",
|
|
92
|
+
room: "missing",
|
|
93
|
+
payload: { nope: true },
|
|
94
|
+
});
|
|
95
|
+
assert.deepEqual(chatA.sent, []);
|
|
96
|
+
assert.deepEqual(chatB.sent, [JSON.stringify({ hi: true })]);
|
|
97
|
+
closeProjectSockets(projectId);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("JSON payload size is capped at 100KB", () => {
|
|
101
|
+
const ok = stringifySocketPayload({ ok: true });
|
|
102
|
+
assert.equal(ok, JSON.stringify({ ok: true }));
|
|
103
|
+
assert.throws(() => stringifySocketPayload("a".repeat(MAX_SOCKET_PAYLOAD_BYTES + 1)), /100KB/);
|
|
104
|
+
const parsed = parseSocketMessage('{"ok":true}');
|
|
105
|
+
assert.equal(parsed.ok, true);
|
|
106
|
+
const invalid = parseSocketMessage("{nope");
|
|
107
|
+
assert.equal(invalid.ok, false);
|
|
108
|
+
if (!invalid.ok) assert.equal(invalid.code, SOCKET_CLOSE_INVALID);
|
|
109
|
+
const tooBig = parseSocketMessage("a".repeat(MAX_SOCKET_PAYLOAD_BYTES + 1));
|
|
110
|
+
assert.equal(tooBig.ok, false);
|
|
111
|
+
if (!tooBig.ok) assert.equal(tooBig.code, SOCKET_CLOSE_TOO_BIG);
|
|
112
|
+
});
|
package/src/sockets.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiAuth,
|
|
3
|
+
SocketHandlerContext,
|
|
4
|
+
SocketRoomClient,
|
|
5
|
+
SocketsClient,
|
|
6
|
+
} from "@robodev-ai/sdk";
|
|
7
|
+
import { clampTimeoutMs, withHandlerTimeout } from "./handler-timeout.js";
|
|
8
|
+
import { id } from "./ids.js";
|
|
9
|
+
|
|
10
|
+
export const MAX_PROJECT_SOCKETS = 50;
|
|
11
|
+
export const MAX_SOCKET_PAYLOAD_BYTES = 100 * 1024;
|
|
12
|
+
export const SOCKET_IDLE_PING_MS = 60_000;
|
|
13
|
+
export const SOCKET_CLOSE_POLICY = 1008;
|
|
14
|
+
export const SOCKET_CLOSE_INVALID = 1007;
|
|
15
|
+
export const SOCKET_CLOSE_TOO_BIG = 1009;
|
|
16
|
+
export const SOCKET_CLOSE_RESTART = 1012;
|
|
17
|
+
|
|
18
|
+
export type SocketWire = {
|
|
19
|
+
send: (data: string) => void;
|
|
20
|
+
close: (code?: number, reason?: string) => void;
|
|
21
|
+
readyState: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type LiveConn = {
|
|
25
|
+
id: string;
|
|
26
|
+
projectId: string;
|
|
27
|
+
name: string;
|
|
28
|
+
socket: SocketWire;
|
|
29
|
+
rooms: Set<string>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SocketSessionHelpers = {
|
|
33
|
+
send: (payload: unknown) => void;
|
|
34
|
+
close: (code?: number, reason?: string) => void;
|
|
35
|
+
room: SocketRoomClient;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type BindSocketSessionInput = {
|
|
39
|
+
projectId: string;
|
|
40
|
+
name: string;
|
|
41
|
+
socket: SocketWire;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
createContext: (helpers: SocketSessionHelpers) => SocketHandlerContext;
|
|
44
|
+
onConnect?: (ctx: SocketHandlerContext) => Promise<void> | void;
|
|
45
|
+
onMessage: (ctx: SocketHandlerContext, value: unknown) => Promise<void> | void;
|
|
46
|
+
onClose?: (ctx: SocketHandlerContext) => Promise<void> | void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const byProject = new Map<string, Set<LiveConn>>();
|
|
50
|
+
const knownNamesByProject = new Map<string, string[]>();
|
|
51
|
+
|
|
52
|
+
export function setProjectSocketNames(projectId: string, names: string[]): void {
|
|
53
|
+
knownNamesByProject.set(projectId, names);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stringifySocketPayload(payload: unknown): string {
|
|
57
|
+
const json = JSON.stringify(payload);
|
|
58
|
+
if (Buffer.byteLength(json, "utf8") > MAX_SOCKET_PAYLOAD_BYTES) {
|
|
59
|
+
throw new Error("socket payload must be at most 100KB");
|
|
60
|
+
}
|
|
61
|
+
return json;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseSocketMessage(
|
|
65
|
+
raw: string,
|
|
66
|
+
): { ok: true; value: unknown } | { ok: false; code: number } {
|
|
67
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_SOCKET_PAYLOAD_BYTES) {
|
|
68
|
+
return { ok: false, code: SOCKET_CLOSE_TOO_BIG };
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return { ok: true, value: JSON.parse(raw) as unknown };
|
|
72
|
+
} catch {
|
|
73
|
+
return { ok: false, code: SOCKET_CLOSE_INVALID };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function socketAuthDisplay(auth: ApiAuth): "required" | "public" {
|
|
78
|
+
return auth === "required" || typeof auth === "function" ? "required" : "public";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function assertKnownSocket(name: string, known: Iterable<string>): void {
|
|
82
|
+
if (!new Set(known).has(name)) {
|
|
83
|
+
throw Object.assign(new Error("unknown_socket"), { code: "unknown_socket" });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function projectSocketCount(projectId: string): number {
|
|
88
|
+
return byProject.get(projectId)?.size ?? 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function closeProjectSockets(
|
|
92
|
+
projectId: string,
|
|
93
|
+
code = SOCKET_CLOSE_RESTART,
|
|
94
|
+
reason = "deployed",
|
|
95
|
+
): void {
|
|
96
|
+
const set = byProject.get(projectId);
|
|
97
|
+
if (!set) return;
|
|
98
|
+
for (const conn of [...set]) {
|
|
99
|
+
try {
|
|
100
|
+
conn.socket.close(code, reason);
|
|
101
|
+
} catch {
|
|
102
|
+
/* already closed */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
byProject.delete(projectId);
|
|
106
|
+
knownNamesByProject.delete(projectId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function attachProjectSocket(input: {
|
|
110
|
+
projectId: string;
|
|
111
|
+
name: string;
|
|
112
|
+
socket: SocketWire;
|
|
113
|
+
}): { ok: true; conn: LiveConn } | { ok: false; code: number; reason: string } {
|
|
114
|
+
const current = byProject.get(input.projectId) ?? new Set<LiveConn>();
|
|
115
|
+
if (current.size >= MAX_PROJECT_SOCKETS) {
|
|
116
|
+
return { ok: false, code: SOCKET_CLOSE_POLICY, reason: "too_many_connections" };
|
|
117
|
+
}
|
|
118
|
+
const conn: LiveConn = {
|
|
119
|
+
id: id("sock"),
|
|
120
|
+
projectId: input.projectId,
|
|
121
|
+
name: input.name,
|
|
122
|
+
socket: input.socket,
|
|
123
|
+
rooms: new Set(),
|
|
124
|
+
};
|
|
125
|
+
current.add(conn);
|
|
126
|
+
byProject.set(input.projectId, current);
|
|
127
|
+
return { ok: true, conn };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function detachProjectSocket(conn: LiveConn): void {
|
|
131
|
+
const set = byProject.get(conn.projectId);
|
|
132
|
+
if (!set) return;
|
|
133
|
+
set.delete(conn);
|
|
134
|
+
if (set.size === 0) byProject.delete(conn.projectId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function joinSocketRoom(conn: LiveConn, roomId: string): void {
|
|
138
|
+
conn.rooms.add(roomId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function leaveSocketRoom(conn: LiveConn, roomId: string): void {
|
|
142
|
+
conn.rooms.delete(roomId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sendRaw(socket: SocketWire, json: string): void {
|
|
146
|
+
if (socket.readyState !== 1) return;
|
|
147
|
+
socket.send(json);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function sendToSocketName(projectId: string, name: string, json: string): void {
|
|
151
|
+
const set = byProject.get(projectId);
|
|
152
|
+
if (!set) return;
|
|
153
|
+
for (const conn of set) {
|
|
154
|
+
if (conn.name === name) sendRaw(conn.socket, json);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function sendToSocketRoom(
|
|
159
|
+
projectId: string,
|
|
160
|
+
name: string,
|
|
161
|
+
roomId: string,
|
|
162
|
+
json: string,
|
|
163
|
+
except?: LiveConn,
|
|
164
|
+
): void {
|
|
165
|
+
const set = byProject.get(projectId);
|
|
166
|
+
if (!set) return;
|
|
167
|
+
for (const conn of set) {
|
|
168
|
+
if (conn === except) continue;
|
|
169
|
+
if (conn.name === name && conn.rooms.has(roomId)) sendRaw(conn.socket, json);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function dispatchSocketsSend(
|
|
174
|
+
projectId: string,
|
|
175
|
+
knownNames: Iterable<string>,
|
|
176
|
+
input: { name: string; room?: string; payload: unknown },
|
|
177
|
+
): void {
|
|
178
|
+
assertKnownSocket(input.name, knownNames);
|
|
179
|
+
const json = stringifySocketPayload(input.payload);
|
|
180
|
+
if (input.room !== undefined) {
|
|
181
|
+
sendToSocketRoom(projectId, input.name, input.room, json);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
sendToSocketName(projectId, input.name, json);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createProjectSocketsClient(projectId: string): SocketsClient {
|
|
188
|
+
function knownNames(): string[] {
|
|
189
|
+
return knownNamesByProject.get(projectId) ?? [];
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
send(input) {
|
|
193
|
+
dispatchSocketsSend(projectId, knownNames(), input);
|
|
194
|
+
},
|
|
195
|
+
broadcast(input) {
|
|
196
|
+
dispatchSocketsSend(projectId, knownNames(), input);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runSocketCallback(
|
|
202
|
+
timeoutMs: number | undefined,
|
|
203
|
+
run: () => Promise<void> | void,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
await withHandlerTimeout(() => Promise.resolve(run()), clampTimeoutMs(timeoutMs));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function bindSocketSession(
|
|
209
|
+
input: BindSocketSessionInput,
|
|
210
|
+
): Promise<
|
|
211
|
+
| { ok: false; code: number; reason: string }
|
|
212
|
+
| { ok: true; onText: (raw: string) => void; onBinary: () => void; onClose: () => void }
|
|
213
|
+
> {
|
|
214
|
+
const attached = attachProjectSocket({
|
|
215
|
+
projectId: input.projectId,
|
|
216
|
+
name: input.name,
|
|
217
|
+
socket: input.socket,
|
|
218
|
+
});
|
|
219
|
+
if (!attached.ok) return attached;
|
|
220
|
+
|
|
221
|
+
const helpers: SocketSessionHelpers = {
|
|
222
|
+
send(payload) {
|
|
223
|
+
sendRaw(input.socket, stringifySocketPayload(payload));
|
|
224
|
+
},
|
|
225
|
+
close(code, reason) {
|
|
226
|
+
input.socket.close(code, reason);
|
|
227
|
+
},
|
|
228
|
+
room: {
|
|
229
|
+
join: (roomId) => joinSocketRoom(attached.conn, roomId),
|
|
230
|
+
leave: (roomId) => leaveSocketRoom(attached.conn, roomId),
|
|
231
|
+
broadcast: (roomId, payload) => {
|
|
232
|
+
sendToSocketRoom(
|
|
233
|
+
input.projectId,
|
|
234
|
+
input.name,
|
|
235
|
+
roomId,
|
|
236
|
+
stringifySocketPayload(payload),
|
|
237
|
+
attached.conn,
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const ctx = input.createContext(helpers);
|
|
244
|
+
|
|
245
|
+
let closed = false;
|
|
246
|
+
const finish = async () => {
|
|
247
|
+
if (closed) return;
|
|
248
|
+
closed = true;
|
|
249
|
+
detachProjectSocket(attached.conn);
|
|
250
|
+
if (input.onClose) {
|
|
251
|
+
try {
|
|
252
|
+
await runSocketCallback(input.timeoutMs, () => input.onClose?.(ctx));
|
|
253
|
+
} catch {
|
|
254
|
+
/* onClose must not throw to the wire */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (input.onConnect) {
|
|
260
|
+
try {
|
|
261
|
+
await runSocketCallback(input.timeoutMs, () => input.onConnect?.(ctx));
|
|
262
|
+
} catch {
|
|
263
|
+
input.socket.close(1011, "handler_failed");
|
|
264
|
+
await finish();
|
|
265
|
+
return { ok: false, code: 1011, reason: "handler_failed" };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
onText(raw) {
|
|
272
|
+
if (closed) return;
|
|
273
|
+
const parsed = parseSocketMessage(raw);
|
|
274
|
+
if (!parsed.ok) {
|
|
275
|
+
input.socket.close(
|
|
276
|
+
parsed.code,
|
|
277
|
+
parsed.code === SOCKET_CLOSE_TOO_BIG ? "too_big" : "invalid_json",
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
void runSocketCallback(input.timeoutMs, () => input.onMessage(ctx, parsed.value)).catch(
|
|
282
|
+
() => undefined,
|
|
283
|
+
);
|
|
284
|
+
},
|
|
285
|
+
onBinary() {
|
|
286
|
+
if (closed) return;
|
|
287
|
+
input.socket.close(1003, "binary not supported");
|
|
288
|
+
},
|
|
289
|
+
onClose() {
|
|
290
|
+
void finish();
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|