@signe/room 2.10.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/chunk-EUXUH3YW.js +15 -0
- package/dist/chunk-EUXUH3YW.js.map +1 -0
- package/dist/cloudflare/index.d.ts +71 -0
- package/dist/cloudflare/index.js +320 -0
- package/dist/cloudflare/index.js.map +1 -0
- package/dist/index.d.ts +66 -187
- package/dist/index.js +727 -106
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +164 -0
- package/dist/node/index.js +786 -0
- package/dist/node/index.js.map +1 -0
- package/dist/party-dNs-hqkq.d.ts +175 -0
- package/examples/cloudflare/README.md +62 -0
- package/examples/cloudflare/node_modules/.bin/tsc +17 -0
- package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
- package/examples/cloudflare/package.json +24 -0
- package/examples/cloudflare/public/index.html +443 -0
- package/examples/cloudflare/src/index.ts +28 -0
- package/examples/cloudflare/src/room.ts +44 -0
- package/examples/cloudflare/tsconfig.json +10 -0
- package/examples/cloudflare/wrangler.jsonc +25 -0
- package/examples/node/README.md +57 -0
- package/examples/node/node_modules/.bin/tsc +17 -0
- package/examples/node/node_modules/.bin/tsserver +17 -0
- package/examples/node/node_modules/.bin/tsx +17 -0
- package/examples/node/package.json +23 -0
- package/examples/node/public/index.html +443 -0
- package/examples/node/room.ts +44 -0
- package/examples/node/server.sqlite.ts +52 -0
- package/examples/node/server.ts +51 -0
- package/examples/node/tsconfig.json +10 -0
- package/examples/node-game/README.md +66 -0
- package/examples/node-game/package.json +23 -0
- package/examples/node-game/public/index.html +705 -0
- package/examples/node-game/room.ts +145 -0
- package/examples/node-game/server.sqlite.ts +54 -0
- package/examples/node-game/server.ts +53 -0
- package/examples/node-game/tsconfig.json +10 -0
- package/examples/node-shard/README.md +32 -0
- package/examples/node-shard/dev.ts +39 -0
- package/examples/node-shard/package.json +24 -0
- package/examples/node-shard/public/index.html +777 -0
- package/examples/node-shard/room-server.ts +68 -0
- package/examples/node-shard/room.ts +105 -0
- package/examples/node-shard/shared.ts +6 -0
- package/examples/node-shard/tsconfig.json +14 -0
- package/examples/node-shard/world-server.ts +169 -0
- package/package.json +14 -5
- package/readme.md +371 -4
- package/src/cloudflare/index.ts +474 -0
- package/src/jwt.ts +1 -5
- package/src/mock.ts +29 -7
- package/src/node/index.ts +1112 -0
- package/src/server.ts +600 -51
- package/src/session.guard.ts +6 -2
- package/src/shard.ts +91 -23
- package/src/storage.ts +29 -5
- package/src/testing.ts +4 -3
- package/src/types/party.ts +4 -1
- package/src/world.guard.ts +23 -4
- package/src/world.ts +121 -21
- package/examples/game/.vscode/launch.json +0 -11
- package/examples/game/.vscode/settings.json +0 -11
- package/examples/game/README.md +0 -40
- package/examples/game/app/client.tsx +0 -15
- package/examples/game/app/components/Admin.tsx +0 -1089
- package/examples/game/app/components/Room.tsx +0 -162
- package/examples/game/app/styles.css +0 -31
- package/examples/game/package-lock.json +0 -225
- package/examples/game/package.json +0 -20
- package/examples/game/party/game.room.ts +0 -32
- package/examples/game/party/server.ts +0 -10
- package/examples/game/party/shard.ts +0 -5
- package/examples/game/partykit.json +0 -14
- package/examples/game/public/favicon.ico +0 -0
- package/examples/game/public/index.html +0 -27
- package/examples/game/public/normalize.css +0 -351
- package/examples/game/shared/room.schema.ts +0 -14
- package/examples/game/tsconfig.json +0 -109
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse as NodeServerResponse } from "node:http";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import type { Duplex } from "node:stream";
|
|
5
|
+
import type * as Party from "../types/party";
|
|
6
|
+
|
|
7
|
+
export type NodeRoomStorage = {
|
|
8
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
9
|
+
put<T = unknown>(key: string, value: T): Promise<void>;
|
|
10
|
+
put<T = unknown>(entries: Record<string, T>): Promise<void>;
|
|
11
|
+
delete(key: string | string[]): Promise<void | boolean | number>;
|
|
12
|
+
list<T = unknown>(options?: NodeRoomStorageListOptions): Promise<Map<string, T>>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type NodeRoomStorageListOptions = {
|
|
16
|
+
prefix?: string;
|
|
17
|
+
start?: string;
|
|
18
|
+
startAfter?: string;
|
|
19
|
+
end?: string;
|
|
20
|
+
reverse?: boolean;
|
|
21
|
+
limit?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type NodeRoomStorageFactory = (
|
|
25
|
+
namespace: string,
|
|
26
|
+
roomId: string
|
|
27
|
+
) => NodeRoomStorage | Promise<NodeRoomStorage>;
|
|
28
|
+
|
|
29
|
+
export type NodeRoomStorageProvider = {
|
|
30
|
+
getStorage(namespace: string, roomId: string): NodeRoomStorage | Promise<NodeRoomStorage>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type NodeMemoryStorageSnapshot = Record<string, [string, unknown][]>;
|
|
34
|
+
|
|
35
|
+
export type NodeSqliteDatabase = {
|
|
36
|
+
exec(sql: string): unknown;
|
|
37
|
+
prepare(sql: string): {
|
|
38
|
+
get(...params: unknown[]): unknown;
|
|
39
|
+
run(...params: unknown[]): { changes: number | bigint };
|
|
40
|
+
all(...params: unknown[]): unknown[];
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type NodeSqliteStorageOptions = {
|
|
45
|
+
database?: NodeSqliteDatabase;
|
|
46
|
+
databasePath?: string;
|
|
47
|
+
tableName?: string;
|
|
48
|
+
busyTimeoutMs?: number;
|
|
49
|
+
journalMode?: NodeSqliteJournalMode;
|
|
50
|
+
busyRetries?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type NodeSqliteJournalMode =
|
|
54
|
+
| "DELETE"
|
|
55
|
+
| "TRUNCATE"
|
|
56
|
+
| "PERSIST"
|
|
57
|
+
| "MEMORY"
|
|
58
|
+
| "WAL"
|
|
59
|
+
| "OFF"
|
|
60
|
+
| "delete"
|
|
61
|
+
| "truncate"
|
|
62
|
+
| "persist"
|
|
63
|
+
| "memory"
|
|
64
|
+
| "wal"
|
|
65
|
+
| "off";
|
|
66
|
+
|
|
67
|
+
export type NodeServerConstructor<TServer extends Party.Server = Party.Server> = {
|
|
68
|
+
new (room: Party.Room): TServer;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type NodeRoomTransportOptions = {
|
|
72
|
+
partiesPath?: string;
|
|
73
|
+
env?: Record<string, unknown>;
|
|
74
|
+
storage?: NodeRoomStorageFactory | NodeRoomStorageProvider;
|
|
75
|
+
rooms?: Record<string, NodeServerConstructor>;
|
|
76
|
+
externalParties?: Record<string, {
|
|
77
|
+
get(id: string): Partial<Party.Stub>;
|
|
78
|
+
}>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type NodeRequestNext = (error?: unknown) => void;
|
|
82
|
+
|
|
83
|
+
export type NodeWebSocketLike = {
|
|
84
|
+
readyState?: number;
|
|
85
|
+
send(data: string | ArrayBuffer | ArrayBufferView, cb?: (error?: Error) => void): void;
|
|
86
|
+
close(code?: number, reason?: string | Buffer): void;
|
|
87
|
+
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
88
|
+
off?(event: string, listener: (...args: any[]) => void): unknown;
|
|
89
|
+
removeListener?(event: string, listener: (...args: any[]) => void): unknown;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type NodeWebSocketServerLike = {
|
|
93
|
+
handleUpgrade(
|
|
94
|
+
request: IncomingMessage,
|
|
95
|
+
socket: Duplex,
|
|
96
|
+
head: Buffer,
|
|
97
|
+
cb: (webSocket: NodeWebSocketLike) => void
|
|
98
|
+
): void;
|
|
99
|
+
emit?(event: "connection", webSocket: NodeWebSocketLike, request: IncomingMessage): boolean;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type RoomRecord = {
|
|
103
|
+
room: NodeRoom;
|
|
104
|
+
server: Party.Server;
|
|
105
|
+
started: Promise<void>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type ParsedPartyPath = {
|
|
109
|
+
namespace: string;
|
|
110
|
+
roomId: string;
|
|
111
|
+
restPath: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const DEFAULT_PARTIES_PATH = "/parties/main";
|
|
115
|
+
const WEBSOCKET_OPEN = 1;
|
|
116
|
+
const DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 5000;
|
|
117
|
+
const DEFAULT_SQLITE_BUSY_RETRIES = 3;
|
|
118
|
+
const SQLITE_RETRY_BASE_DELAY_MS = 25;
|
|
119
|
+
|
|
120
|
+
export function createMemoryNodeRoomStorage(options: {
|
|
121
|
+
snapshot?: NodeMemoryStorageSnapshot;
|
|
122
|
+
} = {}) {
|
|
123
|
+
return new MemoryNodeRoomStorage(options);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createSqliteNodeRoomStorage(options: NodeSqliteStorageOptions) {
|
|
127
|
+
return new SqliteNodeRoomStorage(options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createNodeRoomTransport<TServer extends Party.Server>(
|
|
131
|
+
ServerClass: NodeServerConstructor<TServer>,
|
|
132
|
+
options: NodeRoomTransportOptions = {}
|
|
133
|
+
) {
|
|
134
|
+
return new NodeRoomTransport(ServerClass, options);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class MemoryNodeRoomStorage implements NodeRoomStorageProvider {
|
|
138
|
+
private readonly rooms = new Map<string, Map<string, unknown>>();
|
|
139
|
+
|
|
140
|
+
constructor(options: { snapshot?: NodeMemoryStorageSnapshot } = {}) {
|
|
141
|
+
if (options.snapshot) {
|
|
142
|
+
this.restore(options.snapshot);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getStorage(namespace: string, roomId: string): NodeRoomStorage {
|
|
147
|
+
const key = getStorageKey(namespace, roomId);
|
|
148
|
+
let memory = this.rooms.get(key);
|
|
149
|
+
|
|
150
|
+
if (!memory) {
|
|
151
|
+
memory = new Map();
|
|
152
|
+
this.rooms.set(key, memory);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return new MemoryNodeRoomStorageInstance(memory);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
snapshot(): NodeMemoryStorageSnapshot {
|
|
159
|
+
const snapshot: NodeMemoryStorageSnapshot = {};
|
|
160
|
+
|
|
161
|
+
for (const [roomKey, memory] of this.rooms) {
|
|
162
|
+
if (memory.size === 0) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
snapshot[roomKey] = Array.from(memory.entries()).map(([key, value]) => [
|
|
167
|
+
key,
|
|
168
|
+
cloneStorageValue(value),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return snapshot;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
restore(snapshot: NodeMemoryStorageSnapshot) {
|
|
176
|
+
this.clear();
|
|
177
|
+
|
|
178
|
+
for (const [roomKey, entries] of Object.entries(snapshot)) {
|
|
179
|
+
this.rooms.set(
|
|
180
|
+
roomKey,
|
|
181
|
+
new Map(entries.map(([key, value]) => [key, cloneStorageValue(value)]))
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
clear() {
|
|
187
|
+
this.rooms.clear();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
class MemoryNodeRoomStorageInstance implements NodeRoomStorage {
|
|
192
|
+
constructor(private readonly memory: Map<string, unknown>) {}
|
|
193
|
+
|
|
194
|
+
async put<T = unknown>(keyOrEntries: string | Record<string, T>, value?: T) {
|
|
195
|
+
if (typeof keyOrEntries === "string") {
|
|
196
|
+
this.memory.set(keyOrEntries, value);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (const [key, entryValue] of Object.entries(keyOrEntries)) {
|
|
201
|
+
this.memory.set(key, entryValue);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async get<T = unknown>(key: string) {
|
|
206
|
+
return this.memory.get(key) as T | undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async delete(keyOrKeys: string | string[]) {
|
|
210
|
+
if (Array.isArray(keyOrKeys)) {
|
|
211
|
+
let deleted = 0;
|
|
212
|
+
for (const key of keyOrKeys) {
|
|
213
|
+
if (this.memory.delete(key)) {
|
|
214
|
+
deleted += 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return deleted;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return this.memory.delete(keyOrKeys);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async list<T = unknown>(options: NodeRoomStorageListOptions = {}) {
|
|
224
|
+
let entries = Array.from(this.memory.entries());
|
|
225
|
+
|
|
226
|
+
if (options.prefix !== undefined) {
|
|
227
|
+
entries = entries.filter(([key]) => key.startsWith(options.prefix!));
|
|
228
|
+
}
|
|
229
|
+
if (options.start !== undefined) {
|
|
230
|
+
entries = entries.filter(([key]) => key >= options.start!);
|
|
231
|
+
}
|
|
232
|
+
if (options.startAfter !== undefined) {
|
|
233
|
+
entries = entries.filter(([key]) => key > options.startAfter!);
|
|
234
|
+
}
|
|
235
|
+
if (options.end !== undefined) {
|
|
236
|
+
entries = entries.filter(([key]) => key < options.end!);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
entries.sort(([a], [b]) => a.localeCompare(b));
|
|
240
|
+
if (options.reverse) {
|
|
241
|
+
entries.reverse();
|
|
242
|
+
}
|
|
243
|
+
if (options.limit !== undefined) {
|
|
244
|
+
entries = entries.slice(0, options.limit);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return new Map(entries) as Map<string, T>;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export class SqliteNodeRoomStorage implements NodeRoomStorageProvider {
|
|
252
|
+
private readonly tableName: string;
|
|
253
|
+
private databasePromise?: Promise<NodeSqliteDatabase>;
|
|
254
|
+
private configured = false;
|
|
255
|
+
|
|
256
|
+
constructor(private readonly options: NodeSqliteStorageOptions) {
|
|
257
|
+
if (!options.database && !options.databasePath) {
|
|
258
|
+
throw new Error("createSqliteNodeRoomStorage requires `database` or `databasePath`.");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.tableName = options.tableName ?? "signe_room_storage";
|
|
262
|
+
assertSafeSqlIdentifier(this.tableName);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async getStorage(namespace: string, roomId: string): Promise<NodeRoomStorage> {
|
|
266
|
+
const database = await this.getDatabase();
|
|
267
|
+
await this.ensureConfigured(database);
|
|
268
|
+
return new SqliteNodeRoomStorageInstance(
|
|
269
|
+
database,
|
|
270
|
+
this.tableName,
|
|
271
|
+
namespace,
|
|
272
|
+
roomId,
|
|
273
|
+
this.options.busyRetries ?? DEFAULT_SQLITE_BUSY_RETRIES
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async getDatabase() {
|
|
278
|
+
if (!this.databasePromise) {
|
|
279
|
+
this.databasePromise = this.createDatabase();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return this.databasePromise;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async createDatabase(): Promise<NodeSqliteDatabase> {
|
|
286
|
+
if (this.options.database) {
|
|
287
|
+
return this.options.database;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const sqliteModule = loadNodeSqliteModule();
|
|
291
|
+
const { DatabaseSync } = sqliteModule;
|
|
292
|
+
return new DatabaseSync(this.options.databasePath!) as NodeSqliteDatabase;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async ensureConfigured(database: NodeSqliteDatabase) {
|
|
296
|
+
if (this.configured) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const busyTimeoutMs = normalizeSqliteTimeout(
|
|
301
|
+
this.options.busyTimeoutMs ?? DEFAULT_SQLITE_BUSY_TIMEOUT_MS
|
|
302
|
+
);
|
|
303
|
+
const journalMode = normalizeSqliteJournalMode(this.options.journalMode ?? "WAL");
|
|
304
|
+
|
|
305
|
+
runSqliteOperation(
|
|
306
|
+
() => {
|
|
307
|
+
database.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
308
|
+
database.exec(`PRAGMA journal_mode = ${journalMode}`);
|
|
309
|
+
database.exec(`
|
|
310
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
311
|
+
namespace TEXT NOT NULL,
|
|
312
|
+
room_id TEXT NOT NULL,
|
|
313
|
+
key TEXT NOT NULL,
|
|
314
|
+
value TEXT NOT NULL,
|
|
315
|
+
PRIMARY KEY (namespace, room_id, key)
|
|
316
|
+
)
|
|
317
|
+
`);
|
|
318
|
+
},
|
|
319
|
+
this.options.busyRetries ?? DEFAULT_SQLITE_BUSY_RETRIES
|
|
320
|
+
);
|
|
321
|
+
this.configured = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
class SqliteNodeRoomStorageInstance implements NodeRoomStorage {
|
|
326
|
+
constructor(
|
|
327
|
+
private readonly database: NodeSqliteDatabase,
|
|
328
|
+
private readonly tableName: string,
|
|
329
|
+
private readonly namespace: string,
|
|
330
|
+
private readonly roomId: string,
|
|
331
|
+
private readonly busyRetries: number
|
|
332
|
+
) {}
|
|
333
|
+
|
|
334
|
+
async get<T = unknown>(key: string): Promise<T | undefined> {
|
|
335
|
+
const row = runSqliteOperation(
|
|
336
|
+
() => this.database
|
|
337
|
+
.prepare(`
|
|
338
|
+
SELECT value
|
|
339
|
+
FROM ${this.tableName}
|
|
340
|
+
WHERE namespace = ? AND room_id = ? AND key = ?
|
|
341
|
+
`)
|
|
342
|
+
.get(this.namespace, this.roomId, key) as { value: string } | undefined,
|
|
343
|
+
this.busyRetries
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return row ? JSON.parse(row.value) as T : undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async put<T = unknown>(keyOrEntries: string | Record<string, T>, value?: T): Promise<void> {
|
|
350
|
+
const entries = typeof keyOrEntries === "string"
|
|
351
|
+
? [[keyOrEntries, value] as const]
|
|
352
|
+
: Object.entries(keyOrEntries);
|
|
353
|
+
|
|
354
|
+
runSqliteOperation(
|
|
355
|
+
() => {
|
|
356
|
+
const statement = this.database.prepare(`
|
|
357
|
+
INSERT INTO ${this.tableName} (namespace, room_id, key, value)
|
|
358
|
+
VALUES (?, ?, ?, ?)
|
|
359
|
+
ON CONFLICT(namespace, room_id, key) DO UPDATE SET value = excluded.value
|
|
360
|
+
`);
|
|
361
|
+
for (const [key, entryValue] of entries) {
|
|
362
|
+
statement.run(this.namespace, this.roomId, key, JSON.stringify(entryValue));
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
this.busyRetries
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async delete(keyOrKeys: string | string[]): Promise<boolean | number> {
|
|
370
|
+
if (Array.isArray(keyOrKeys)) {
|
|
371
|
+
if (keyOrKeys.length === 0) {
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const result = runSqliteOperation(
|
|
376
|
+
() => this.database
|
|
377
|
+
.prepare(`
|
|
378
|
+
DELETE FROM ${this.tableName}
|
|
379
|
+
WHERE namespace = ? AND room_id = ? AND key IN (${keyOrKeys.map(() => "?").join(", ")})
|
|
380
|
+
`)
|
|
381
|
+
.run(this.namespace, this.roomId, ...keyOrKeys),
|
|
382
|
+
this.busyRetries
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
return Number(result.changes);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const result = runSqliteOperation(
|
|
389
|
+
() => this.database
|
|
390
|
+
.prepare(`
|
|
391
|
+
DELETE FROM ${this.tableName}
|
|
392
|
+
WHERE namespace = ? AND room_id = ? AND key = ?
|
|
393
|
+
`)
|
|
394
|
+
.run(this.namespace, this.roomId, keyOrKeys),
|
|
395
|
+
this.busyRetries
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
return Number(result.changes) > 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async list<T = unknown>(options: NodeRoomStorageListOptions = {}): Promise<Map<string, T>> {
|
|
402
|
+
const conditions = ["namespace = ?", "room_id = ?"];
|
|
403
|
+
const params: unknown[] = [this.namespace, this.roomId];
|
|
404
|
+
|
|
405
|
+
if (options.prefix !== undefined) {
|
|
406
|
+
conditions.push("key >= ?", "key < ?");
|
|
407
|
+
params.push(options.prefix, getPrefixEnd(options.prefix));
|
|
408
|
+
}
|
|
409
|
+
if (options.start !== undefined) {
|
|
410
|
+
conditions.push("key >= ?");
|
|
411
|
+
params.push(options.start);
|
|
412
|
+
}
|
|
413
|
+
if (options.startAfter !== undefined) {
|
|
414
|
+
conditions.push("key > ?");
|
|
415
|
+
params.push(options.startAfter);
|
|
416
|
+
}
|
|
417
|
+
if (options.end !== undefined) {
|
|
418
|
+
conditions.push("key < ?");
|
|
419
|
+
params.push(options.end);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const limit = options.limit !== undefined ? normalizeSqliteLimit(options.limit) : undefined;
|
|
423
|
+
const rows = runSqliteOperation(
|
|
424
|
+
() => this.database
|
|
425
|
+
.prepare(`
|
|
426
|
+
SELECT key, value
|
|
427
|
+
FROM ${this.tableName}
|
|
428
|
+
WHERE ${conditions.join(" AND ")}
|
|
429
|
+
ORDER BY key ${options.reverse ? "DESC" : "ASC"}
|
|
430
|
+
${limit !== undefined ? `LIMIT ${limit}` : ""}
|
|
431
|
+
`)
|
|
432
|
+
.all(...params) as { key: string; value: string }[],
|
|
433
|
+
this.busyRetries
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
return new Map(rows.map((row) => [row.key, JSON.parse(row.value) as T]));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export class NodeRoomTransport<TServer extends Party.Server = Party.Server> {
|
|
441
|
+
readonly partiesPath: string;
|
|
442
|
+
readonly env: Record<string, unknown>;
|
|
443
|
+
readonly externalParties: Record<string, {
|
|
444
|
+
get(id: string): Partial<Party.Stub>;
|
|
445
|
+
}>;
|
|
446
|
+
private readonly rooms: Record<string, NodeServerConstructor>;
|
|
447
|
+
private readonly storage: NodeRoomStorageFactory | NodeRoomStorageProvider;
|
|
448
|
+
private readonly records = new Map<string, Promise<RoomRecord>>();
|
|
449
|
+
|
|
450
|
+
constructor(
|
|
451
|
+
private readonly ServerClass: NodeServerConstructor<TServer>,
|
|
452
|
+
options: NodeRoomTransportOptions = {}
|
|
453
|
+
) {
|
|
454
|
+
this.partiesPath = normalizePath(options.partiesPath ?? DEFAULT_PARTIES_PATH);
|
|
455
|
+
this.env = options.env ?? {};
|
|
456
|
+
this.rooms = {
|
|
457
|
+
main: ServerClass,
|
|
458
|
+
...(options.rooms ?? {}),
|
|
459
|
+
};
|
|
460
|
+
this.storage = options.storage ?? createMemoryNodeRoomStorage();
|
|
461
|
+
this.externalParties = options.externalParties ?? {};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async fetch(pathOrRequest: string | Request, init?: RequestInit): Promise<Response> {
|
|
465
|
+
const request = typeof pathOrRequest === "string"
|
|
466
|
+
? new Request(toLocalUrl(pathOrRequest), init)
|
|
467
|
+
: pathOrRequest;
|
|
468
|
+
const parsed = this.parsePartyRequest(request.url);
|
|
469
|
+
|
|
470
|
+
if (!parsed) {
|
|
471
|
+
return new Response("Not Found", { status: 404 });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const record = await this.getRecord(parsed.namespace, parsed.roomId);
|
|
475
|
+
return record.server.onRequest?.(request as unknown as Party.Request) ?? new Response("Not Found", { status: 404 });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async handleNodeRequest(
|
|
479
|
+
req: IncomingMessage,
|
|
480
|
+
res: NodeServerResponse,
|
|
481
|
+
next?: NodeRequestNext
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
const url = getRequestUrl(req);
|
|
484
|
+
|
|
485
|
+
if (!this.parsePartyRequest(url)) {
|
|
486
|
+
if (next) {
|
|
487
|
+
next();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
await writeNodeResponse(res, new Response("Not Found", { status: 404 }));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const request = await createWebRequest(req, url);
|
|
496
|
+
const response = await this.fetch(request);
|
|
497
|
+
await writeNodeResponse(res, response);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (next) {
|
|
500
|
+
next(error);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
await writeNodeResponse(res, new Response("Internal Server Error", { status: 500 }));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
handleUpgrade(
|
|
508
|
+
wsServer: NodeWebSocketServerLike,
|
|
509
|
+
request: IncomingMessage,
|
|
510
|
+
socket: Duplex,
|
|
511
|
+
head: Buffer
|
|
512
|
+
): void {
|
|
513
|
+
const parsed = this.parsePartyRequest(getRequestUrl(request));
|
|
514
|
+
|
|
515
|
+
if (!parsed) {
|
|
516
|
+
socket.destroy();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
wsServer.handleUpgrade(request, socket, head, (webSocket) => {
|
|
521
|
+
void this.acceptWebSocket(webSocket, request, parsed).catch(() => {
|
|
522
|
+
webSocket.close(1011, "Unable to start room connection");
|
|
523
|
+
});
|
|
524
|
+
wsServer.emit?.("connection", webSocket, request);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async acceptWebSocket(
|
|
529
|
+
webSocket: NodeWebSocketLike,
|
|
530
|
+
request: IncomingMessage | Request,
|
|
531
|
+
parsedPath?: ParsedPartyPath
|
|
532
|
+
): Promise<NodeConnection> {
|
|
533
|
+
const url = request instanceof Request ? request.url : getRequestUrl(request);
|
|
534
|
+
const parsed = parsedPath ?? this.parsePartyRequest(url);
|
|
535
|
+
|
|
536
|
+
if (!parsed) {
|
|
537
|
+
webSocket.close(1008, "Invalid room path");
|
|
538
|
+
throw new Error(`Unable to route WebSocket URL: ${url}`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const record = await this.getRecord(parsed.namespace, parsed.roomId);
|
|
542
|
+
const connection = new NodeConnection(webSocket, url, getConnectionIdFromUrl(url));
|
|
543
|
+
const connectRequest = request instanceof Request
|
|
544
|
+
? request
|
|
545
|
+
: await createWebRequest(request, url, false);
|
|
546
|
+
|
|
547
|
+
await record.server.onConnect?.(connection as unknown as Party.Connection, {
|
|
548
|
+
request: connectRequest as unknown as Party.Request,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const onMessage = (data: unknown) => {
|
|
552
|
+
void record.server.onMessage?.(normalizeWebSocketMessage(data), connection as unknown as Party.Connection);
|
|
553
|
+
};
|
|
554
|
+
const onClose = () => {
|
|
555
|
+
record.room.deleteConnection(connection.id, connection);
|
|
556
|
+
void record.server.onClose?.(connection as unknown as Party.Connection);
|
|
557
|
+
};
|
|
558
|
+
const onError = (error: Error) => {
|
|
559
|
+
void record.server.onError?.(connection as unknown as Party.Connection, error);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
webSocket.on("message", onMessage);
|
|
563
|
+
webSocket.on("close", onClose);
|
|
564
|
+
webSocket.on("error", onError);
|
|
565
|
+
record.room.addConnection(connection);
|
|
566
|
+
|
|
567
|
+
return connection;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
getRoom(namespace: string, roomId: string): Promise<NodeRoom> {
|
|
571
|
+
return this.getRecord(namespace, roomId).then((record) => record.room);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
getNamespacePath(namespace: string, roomId: string) {
|
|
575
|
+
const baseSegments = trimSlashes(this.getPartiesBase()).split("/").slice(0, -1);
|
|
576
|
+
return `/${[...baseSegments, namespace, encodeURIComponent(roomId)].join("/")}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private async getRecord(namespace: string, roomId: string): Promise<RoomRecord> {
|
|
580
|
+
const key = `${namespace}:${roomId}`;
|
|
581
|
+
const existing = this.records.get(key);
|
|
582
|
+
|
|
583
|
+
if (existing) {
|
|
584
|
+
return existing;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const recordPromise = this.createRecord(namespace, roomId);
|
|
588
|
+
this.records.set(key, recordPromise);
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
return await recordPromise;
|
|
592
|
+
} catch (error) {
|
|
593
|
+
this.records.delete(key);
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private async createRecord(namespace: string, roomId: string): Promise<RoomRecord> {
|
|
599
|
+
const ServerClass = this.rooms[namespace] ?? this.ServerClass;
|
|
600
|
+
const room = new NodeRoom({
|
|
601
|
+
id: roomId,
|
|
602
|
+
name: namespace,
|
|
603
|
+
env: this.env,
|
|
604
|
+
storage: await this.resolveStorage(namespace, roomId),
|
|
605
|
+
transport: this,
|
|
606
|
+
});
|
|
607
|
+
const server = new ServerClass(room as Party.Room);
|
|
608
|
+
const record: RoomRecord = {
|
|
609
|
+
room,
|
|
610
|
+
server,
|
|
611
|
+
started: Promise.resolve(server.onStart?.()).then(() => undefined),
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
await record.started;
|
|
615
|
+
return record;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private async resolveStorage(namespace: string, roomId: string): Promise<NodeRoomStorage> {
|
|
619
|
+
if (typeof this.storage === "function") {
|
|
620
|
+
return this.storage(namespace, roomId);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return this.storage.getStorage(namespace, roomId);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private parsePartyRequest(url: string): ParsedPartyPath | null {
|
|
627
|
+
const requestUrl = new URL(url, "http://localhost");
|
|
628
|
+
const partiesBase = this.getPartiesBase();
|
|
629
|
+
const segments = trimSlashes(requestUrl.pathname).split("/");
|
|
630
|
+
const configuredSegments = trimSlashes(partiesBase).split("/");
|
|
631
|
+
const baseSegments = configuredSegments.slice(0, -1);
|
|
632
|
+
|
|
633
|
+
if (segments.length < baseSegments.length + 2) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
for (let index = 0; index < baseSegments.length; index++) {
|
|
638
|
+
if (segments[index] !== baseSegments[index]) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const namespace = decodeURIComponent(segments[baseSegments.length]);
|
|
644
|
+
const roomId = decodeURIComponent(segments[baseSegments.length + 1]);
|
|
645
|
+
const rest = segments.slice(baseSegments.length + 2).join("/");
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
namespace,
|
|
649
|
+
roomId,
|
|
650
|
+
restPath: rest ? `/${rest}` : "/",
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private getPartiesBase() {
|
|
655
|
+
return this.partiesPath;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export class NodeRoom implements Party.Room {
|
|
660
|
+
readonly id: string;
|
|
661
|
+
readonly internalID: string;
|
|
662
|
+
readonly name: string;
|
|
663
|
+
readonly env: Record<string, unknown>;
|
|
664
|
+
readonly storage: Party.Storage;
|
|
665
|
+
readonly context: Party.Context;
|
|
666
|
+
readonly connections = new Map<string, Party.Connection>();
|
|
667
|
+
readonly parties: Party.Context["parties"];
|
|
668
|
+
readonly analytics = {} as Party.Room["analytics"];
|
|
669
|
+
|
|
670
|
+
constructor(options: {
|
|
671
|
+
id: string;
|
|
672
|
+
name: string;
|
|
673
|
+
env: Record<string, unknown>;
|
|
674
|
+
storage: NodeRoomStorage;
|
|
675
|
+
transport: NodeRoomTransport;
|
|
676
|
+
}) {
|
|
677
|
+
this.id = options.id;
|
|
678
|
+
this.internalID = `${options.name}:${options.id}`;
|
|
679
|
+
this.name = options.name;
|
|
680
|
+
this.env = options.env;
|
|
681
|
+
this.storage = options.storage as Party.Storage;
|
|
682
|
+
this.parties = createPartiesContext(options.transport);
|
|
683
|
+
this.context = {
|
|
684
|
+
parties: this.parties,
|
|
685
|
+
ai: {},
|
|
686
|
+
vectorize: {},
|
|
687
|
+
analytics: this.analytics,
|
|
688
|
+
assets: {
|
|
689
|
+
fetch: async () => null,
|
|
690
|
+
},
|
|
691
|
+
bindings: {
|
|
692
|
+
r2: {},
|
|
693
|
+
kv: {},
|
|
694
|
+
},
|
|
695
|
+
} as Party.Context;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T> {
|
|
699
|
+
return callback();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
broadcast(msg: string | ArrayBuffer | ArrayBufferView, without: string[] = []) {
|
|
703
|
+
for (const connection of this.connections.values()) {
|
|
704
|
+
if (!without.includes(connection.id)) {
|
|
705
|
+
connection.send(msg);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
getConnection<TState = unknown>(id: string): Party.Connection<TState> | undefined {
|
|
711
|
+
let connection: Party.Connection | undefined;
|
|
712
|
+
for (const current of this.connections.values()) {
|
|
713
|
+
if (current.id === id || current.sessionId === id) {
|
|
714
|
+
connection = current;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return connection as Party.Connection<TState> | undefined;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
getConnections<TState = unknown>(): Iterable<Party.Connection<TState>> {
|
|
721
|
+
return Array.from(this.connections.values()) as Party.Connection<TState>[];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
addConnection(connection: NodeConnection) {
|
|
725
|
+
this.connections.set(connection.id, connection as unknown as Party.Connection);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
deleteConnection(id: string, connection?: NodeConnection) {
|
|
729
|
+
if (connection) {
|
|
730
|
+
this.connections.delete(connection.id);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
for (const [connectionKey, current] of this.connections) {
|
|
735
|
+
if (current.id === id || current.sessionId === id) {
|
|
736
|
+
this.connections.delete(connectionKey);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export class NodeConnection<TState = unknown> {
|
|
743
|
+
readonly id = createConnectionId();
|
|
744
|
+
readonly sessionId: string;
|
|
745
|
+
readonly socket: this = this;
|
|
746
|
+
readonly uri: string;
|
|
747
|
+
state: Party.ConnectionState<TState> | TState | null = null;
|
|
748
|
+
private attachment: unknown = null;
|
|
749
|
+
|
|
750
|
+
constructor(
|
|
751
|
+
private readonly webSocket: NodeWebSocketLike,
|
|
752
|
+
uri: string,
|
|
753
|
+
sessionId?: string
|
|
754
|
+
) {
|
|
755
|
+
this.sessionId = sessionId || this.id;
|
|
756
|
+
this.uri = uri;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
send(data: string | ArrayBuffer | ArrayBufferView) {
|
|
760
|
+
if (this.webSocket.readyState === undefined || this.webSocket.readyState === WEBSOCKET_OPEN) {
|
|
761
|
+
this.webSocket.send(data);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
close(code?: number, reason?: string) {
|
|
766
|
+
this.webSocket.close(code, reason);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
setState(state: TState | Party.ConnectionSetStateFn<TState> | null) {
|
|
770
|
+
this.state = typeof state === "function"
|
|
771
|
+
? (state as Party.ConnectionSetStateFn<TState>)(this.state as Party.ConnectionState<TState>)
|
|
772
|
+
: state;
|
|
773
|
+
return this.state as Party.ConnectionState<TState>;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
serializeAttachment<T = unknown>(attachment: T): void {
|
|
777
|
+
this.attachment = attachment;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
deserializeAttachment<T = unknown>(): T | null {
|
|
781
|
+
return this.attachment as T | null;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function createPartiesContext(transport: NodeRoomTransport): Party.Context["parties"] {
|
|
786
|
+
return new Proxy({}, {
|
|
787
|
+
get(_target, namespace: string) {
|
|
788
|
+
const externalNamespace = transport.externalParties[namespace];
|
|
789
|
+
if (externalNamespace) {
|
|
790
|
+
return externalNamespace;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
get(roomId: string) {
|
|
795
|
+
return {
|
|
796
|
+
connect: () => {
|
|
797
|
+
throw new Error("Party stub connect() is not implemented by @signe/room/node");
|
|
798
|
+
},
|
|
799
|
+
async socket(pathOrInit?: string | RequestInit, init?: RequestInit) {
|
|
800
|
+
const path = typeof pathOrInit === "string" ? pathOrInit : "/";
|
|
801
|
+
const requestInit = typeof pathOrInit === "string" ? init : pathOrInit;
|
|
802
|
+
const request = new Request(
|
|
803
|
+
toLocalUrl(`${transport.getNamespacePath(namespace, roomId)}${normalizeStubPath(path)}`),
|
|
804
|
+
requestInit
|
|
805
|
+
);
|
|
806
|
+
const pair = createInMemoryWebSocketPair();
|
|
807
|
+
|
|
808
|
+
void transport.acceptWebSocket(pair.server, request).catch(() => {
|
|
809
|
+
pair.client.close(1011, "Unable to start room connection");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
return pair.client as unknown as WebSocket;
|
|
813
|
+
},
|
|
814
|
+
fetch(pathOrInit?: string | RequestInit | Request, init?: RequestInit) {
|
|
815
|
+
const path = typeof pathOrInit === "string" ? pathOrInit : "/";
|
|
816
|
+
const requestInit = typeof pathOrInit === "string" ? init : pathOrInit;
|
|
817
|
+
return transport.fetch(`${transport.getNamespacePath(namespace, roomId)}${normalizeStubPath(path)}`, requestInit as RequestInit);
|
|
818
|
+
},
|
|
819
|
+
};
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
},
|
|
823
|
+
}) as Party.Context["parties"];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function createInMemoryWebSocketPair() {
|
|
827
|
+
const client = new InMemoryWebSocket();
|
|
828
|
+
const server = new InMemoryWebSocket();
|
|
829
|
+
|
|
830
|
+
client.setPeer(server);
|
|
831
|
+
server.setPeer(client);
|
|
832
|
+
|
|
833
|
+
return { client, server };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
class InMemoryWebSocket implements NodeWebSocketLike {
|
|
837
|
+
readyState = WEBSOCKET_OPEN;
|
|
838
|
+
private readonly emitter = new EventEmitter();
|
|
839
|
+
private peer?: InMemoryWebSocket;
|
|
840
|
+
|
|
841
|
+
setPeer(peer: InMemoryWebSocket) {
|
|
842
|
+
this.peer = peer;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
send(data: string | ArrayBuffer | ArrayBufferView, cb?: (error?: Error) => void): void {
|
|
846
|
+
if (this.readyState !== WEBSOCKET_OPEN || this.peer?.readyState !== WEBSOCKET_OPEN) {
|
|
847
|
+
cb?.(new Error("WebSocket is not open"));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
queueMicrotask(() => {
|
|
852
|
+
this.peer?.emitMessage(data);
|
|
853
|
+
cb?.();
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
close(code?: number, reason?: string | Buffer): void {
|
|
858
|
+
if (this.readyState !== WEBSOCKET_OPEN) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
this.readyState = 3;
|
|
863
|
+
this.emitter.emit("close", code, reason);
|
|
864
|
+
|
|
865
|
+
if (this.peer?.readyState === WEBSOCKET_OPEN) {
|
|
866
|
+
this.peer.readyState = 3;
|
|
867
|
+
this.peer.emitter.emit("close", code, reason);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
on(event: string, listener: (...args: any[]) => void): unknown {
|
|
872
|
+
this.emitter.on(event, listener);
|
|
873
|
+
return this;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
off(event: string, listener: (...args: any[]) => void): unknown {
|
|
877
|
+
this.emitter.off(event, listener);
|
|
878
|
+
return this;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
removeListener(event: string, listener: (...args: any[]) => void): unknown {
|
|
882
|
+
this.emitter.removeListener(event, listener);
|
|
883
|
+
return this;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
addEventListener(type: string, listener: (event: any) => void): void {
|
|
887
|
+
if (type === "message") {
|
|
888
|
+
this.on("message", (data) => listener({ data }));
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
this.on(type, (event) => listener(event));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private emitMessage(data: string | ArrayBuffer | ArrayBufferView) {
|
|
896
|
+
if (this.readyState === WEBSOCKET_OPEN) {
|
|
897
|
+
this.emitter.emit("message", data);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function createWebRequest(req: IncomingMessage, url: string, includeBody = true): Promise<Request> {
|
|
903
|
+
const headers = new Headers();
|
|
904
|
+
|
|
905
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
906
|
+
if (Array.isArray(value)) {
|
|
907
|
+
for (const item of value) {
|
|
908
|
+
headers.append(key, item);
|
|
909
|
+
}
|
|
910
|
+
} else if (value !== undefined) {
|
|
911
|
+
headers.set(key, String(value));
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const method = req.method ?? "GET";
|
|
916
|
+
const hasBody = includeBody && !["GET", "HEAD"].includes(method);
|
|
917
|
+
const body = hasBody ? await readIncomingBody(req) : undefined;
|
|
918
|
+
|
|
919
|
+
return new Request(url, {
|
|
920
|
+
method,
|
|
921
|
+
headers,
|
|
922
|
+
body,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function readIncomingBody(req: IncomingMessage): Promise<Uint8Array> {
|
|
927
|
+
const chunks: Uint8Array[] = [];
|
|
928
|
+
|
|
929
|
+
for await (const chunk of req) {
|
|
930
|
+
if (typeof chunk === "string") {
|
|
931
|
+
chunks.push(new TextEncoder().encode(chunk));
|
|
932
|
+
} else {
|
|
933
|
+
chunks.push(chunk);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
938
|
+
const body = new Uint8Array(size);
|
|
939
|
+
let offset = 0;
|
|
940
|
+
|
|
941
|
+
for (const chunk of chunks) {
|
|
942
|
+
body.set(chunk, offset);
|
|
943
|
+
offset += chunk.byteLength;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return body;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function writeNodeResponse(res: NodeServerResponse, response: Response) {
|
|
950
|
+
res.statusCode = response.status;
|
|
951
|
+
response.headers.forEach((value, key) => {
|
|
952
|
+
res.setHeader(key, value);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
956
|
+
res.end(body);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function getRequestUrl(req: IncomingMessage) {
|
|
960
|
+
const protocol = req.headers["x-forwarded-proto"] ?? "http";
|
|
961
|
+
const host = req.headers.host ?? "localhost";
|
|
962
|
+
return `${protocol}://${host}${req.url ?? "/"}`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function normalizeWebSocketMessage(data: unknown): string | ArrayBuffer | ArrayBufferView {
|
|
966
|
+
if (typeof data === "string") {
|
|
967
|
+
return data;
|
|
968
|
+
}
|
|
969
|
+
if (data instanceof ArrayBuffer) {
|
|
970
|
+
return new TextDecoder().decode(data);
|
|
971
|
+
}
|
|
972
|
+
if (ArrayBuffer.isView(data)) {
|
|
973
|
+
return new TextDecoder().decode(data);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return String(data);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function normalizePath(path: string) {
|
|
980
|
+
return `/${trimSlashes(path)}`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function normalizeStubPath(path: string) {
|
|
984
|
+
if (!path || path === "/") {
|
|
985
|
+
return "";
|
|
986
|
+
}
|
|
987
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function toLocalUrl(path: string) {
|
|
991
|
+
return path.startsWith("http://") || path.startsWith("https://")
|
|
992
|
+
? path
|
|
993
|
+
: `http://localhost${path.startsWith("/") ? path : `/${path}`}`;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function getConnectionIdFromUrl(url: string) {
|
|
997
|
+
const requestedId = new URL(url).searchParams.get("id")?.trim();
|
|
998
|
+
return requestedId || undefined;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function trimSlashes(value: string) {
|
|
1002
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function getStorageKey(namespace: string, roomId: string) {
|
|
1006
|
+
return `${encodeURIComponent(namespace)}:${encodeURIComponent(roomId)}`;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function assertSafeSqlIdentifier(identifier: string) {
|
|
1010
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
1011
|
+
throw new Error(`Invalid SQLite table name: ${identifier}`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function getPrefixEnd(prefix: string) {
|
|
1016
|
+
return `${prefix}\uffff`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function normalizeSqliteLimit(value: number) {
|
|
1020
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1021
|
+
return 0;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return Math.floor(value);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function normalizeSqliteTimeout(value: number) {
|
|
1028
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1029
|
+
return DEFAULT_SQLITE_BUSY_TIMEOUT_MS;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return Math.floor(value);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function normalizeSqliteJournalMode(value: NodeSqliteJournalMode) {
|
|
1036
|
+
const journalMode = value.toUpperCase() as NodeSqliteJournalMode;
|
|
1037
|
+
const allowedModes = new Set<NodeSqliteJournalMode>([
|
|
1038
|
+
"DELETE",
|
|
1039
|
+
"TRUNCATE",
|
|
1040
|
+
"PERSIST",
|
|
1041
|
+
"MEMORY",
|
|
1042
|
+
"WAL",
|
|
1043
|
+
"OFF",
|
|
1044
|
+
]);
|
|
1045
|
+
|
|
1046
|
+
if (!allowedModes.has(journalMode)) {
|
|
1047
|
+
throw new Error(`Invalid SQLite journal mode: ${value}`);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return journalMode;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function runSqliteOperation<T>(operation: () => T, retries: number): T {
|
|
1054
|
+
const maxRetries = Math.max(0, Math.floor(retries));
|
|
1055
|
+
|
|
1056
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1057
|
+
try {
|
|
1058
|
+
return operation();
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
if (!isSqliteBusyError(error) || attempt >= maxRetries) {
|
|
1061
|
+
throw error;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
sleepSync(Math.min(250, SQLITE_RETRY_BASE_DELAY_MS * 2 ** attempt));
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function isSqliteBusyError(error: unknown) {
|
|
1070
|
+
if (!error || typeof error !== "object") {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const err = error as { errcode?: number; errstr?: string; code?: string; message?: string };
|
|
1075
|
+
return (
|
|
1076
|
+
err.errcode === 5 ||
|
|
1077
|
+
err.errcode === 6 ||
|
|
1078
|
+
err.errstr === "database is locked" ||
|
|
1079
|
+
err.errstr === "database table is locked" ||
|
|
1080
|
+
err.code === "SQLITE_BUSY" ||
|
|
1081
|
+
err.code === "SQLITE_LOCKED" ||
|
|
1082
|
+
err.message?.includes("database is locked") ||
|
|
1083
|
+
err.message?.includes("database table is locked")
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function sleepSync(ms: number) {
|
|
1088
|
+
const buffer = new SharedArrayBuffer(4);
|
|
1089
|
+
const view = new Int32Array(buffer);
|
|
1090
|
+
Atomics.wait(view, 0, 0, ms);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function loadNodeSqliteModule(): typeof import("node:sqlite") {
|
|
1094
|
+
const require = createRequire(`${process.cwd()}/package.json`);
|
|
1095
|
+
return require("node:sqlite");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function cloneStorageValue<T>(value: T): T {
|
|
1099
|
+
if (typeof structuredClone === "function") {
|
|
1100
|
+
try {
|
|
1101
|
+
return structuredClone(value);
|
|
1102
|
+
} catch {
|
|
1103
|
+
return value;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return value;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function createConnectionId() {
|
|
1111
|
+
return Math.random().toString(36).slice(2, 12);
|
|
1112
|
+
}
|