@fieldnotes/sync-server 0.18.0 → 0.19.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 +77 -14
- package/dist/index.cjs +219 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -2
- package/dist/index.d.ts +82 -2
- package/dist/index.js +217 -34
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,34 @@ interface ReadContext {
|
|
|
60
60
|
audience: string | undefined;
|
|
61
61
|
}
|
|
62
62
|
type CanRead = (ctx: ReadContext) => boolean;
|
|
63
|
+
interface OwnerReadContext {
|
|
64
|
+
userId?: string;
|
|
65
|
+
role?: string;
|
|
66
|
+
room: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Decides whether a viewer may see the server-stamped `ownerId` on elements it
|
|
70
|
+
* receives (live ops, snapshots and corrections). Without a hook `ownerId` is
|
|
71
|
+
* stripped from every outbound frame; it stays in the backend for `authorize`.
|
|
72
|
+
*/
|
|
73
|
+
type CanReadOwnerId = (ctx: OwnerReadContext) => boolean;
|
|
74
|
+
interface ResolveAudienceContext {
|
|
75
|
+
userId?: string;
|
|
76
|
+
role?: string;
|
|
77
|
+
room: string;
|
|
78
|
+
/** The incoming element as the client sent it, client-asserted `audience` included. */
|
|
79
|
+
element: WireSyncElement;
|
|
80
|
+
/** The hub's stored element for the same id, if any. */
|
|
81
|
+
currentElement?: OwnedElement;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Decides the authoritative `audience` of an upserted element. The returned
|
|
85
|
+
* value replaces whatever the client asserted (`undefined` clears the field)
|
|
86
|
+
* before `authorize`, storage and relay, so a client can neither hide content
|
|
87
|
+
* by tagging it nor reveal content by retagging it. Without a hook the
|
|
88
|
+
* client's tag passes through and `authorize` must check it.
|
|
89
|
+
*/
|
|
90
|
+
type ResolveAudience = (ctx: ResolveAudienceContext) => string | undefined;
|
|
63
91
|
|
|
64
92
|
interface ApplyResult {
|
|
65
93
|
readonly accepted: WireSyncOp | null;
|
|
@@ -108,9 +136,13 @@ interface SyncHubOptions {
|
|
|
108
136
|
authorizeLayer?: AuthorizeLayer;
|
|
109
137
|
plugins?: readonly ServerSyncPlugin[];
|
|
110
138
|
canRead?: CanRead;
|
|
139
|
+
canReadOwnerId?: CanReadOwnerId;
|
|
140
|
+
resolveAudience?: ResolveAudience;
|
|
111
141
|
maxJsonDepth?: number;
|
|
112
142
|
presenceThrottleMs?: number;
|
|
113
143
|
maxPresenceLanes?: number;
|
|
144
|
+
/** Largest `presence.data` payload relayed, in UTF-8 bytes of its JSON encoding. */
|
|
145
|
+
maxPresenceBytes?: number;
|
|
114
146
|
/** Registry used to translate extension elements for legacy peers. */
|
|
115
147
|
elementRegistry?: ElementRegistry;
|
|
116
148
|
}
|
|
@@ -129,10 +161,13 @@ declare class SyncHub {
|
|
|
129
161
|
private readonly elementRegistry;
|
|
130
162
|
private readonly peerCapabilities;
|
|
131
163
|
private readonly canRead?;
|
|
164
|
+
private readonly canReadOwnerId?;
|
|
165
|
+
private readonly resolveAudience?;
|
|
132
166
|
private readonly memoryLayers;
|
|
133
167
|
private readonly maxJsonDepth;
|
|
134
168
|
private readonly presenceThrottleMs;
|
|
135
169
|
private readonly maxPresenceLanes;
|
|
170
|
+
private readonly maxPresenceBytes;
|
|
136
171
|
/**
|
|
137
172
|
* Presence throttle state keyed by connection, then by lane. A lane is the
|
|
138
173
|
* payload's `kind` (a non-empty string of at most 64 chars) or the reserved
|
|
@@ -171,6 +206,7 @@ declare class SyncHub {
|
|
|
171
206
|
private getLayerRecords;
|
|
172
207
|
private getLayerRecord;
|
|
173
208
|
private applyLayerRecord;
|
|
209
|
+
private mayReadOwnerId;
|
|
174
210
|
private mayRead;
|
|
175
211
|
private safePublish;
|
|
176
212
|
private relayToRoom;
|
|
@@ -180,7 +216,11 @@ declare class SyncHub {
|
|
|
180
216
|
* may reject the room operation that produced it.
|
|
181
217
|
*/
|
|
182
218
|
private sendToConnection;
|
|
183
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* Returns the wire frame for `op` translated for `capabilities`, or null
|
|
221
|
+
* when lossy. The server-stamped `ownerId` is a server-side authorization
|
|
222
|
+
* fact: it leaves the hub only for peers `canReadOwnerId` admits.
|
|
223
|
+
*/
|
|
184
224
|
private encodeForPeer;
|
|
185
225
|
/**
|
|
186
226
|
* Normalize registered legacy wire elements before authorization, storage,
|
|
@@ -200,6 +240,7 @@ declare class SyncHub {
|
|
|
200
240
|
private deliverToRoom;
|
|
201
241
|
private sendCorrection;
|
|
202
242
|
private onFanout;
|
|
243
|
+
private isPresenceWithinLimit;
|
|
203
244
|
private applyFanoutLayerOp;
|
|
204
245
|
close(): void;
|
|
205
246
|
}
|
|
@@ -220,12 +261,25 @@ declare class MemoryHubBackend implements HubBackend {
|
|
|
220
261
|
interface AuthInfo {
|
|
221
262
|
req: IncomingMessage;
|
|
222
263
|
room: string;
|
|
264
|
+
/**
|
|
265
|
+
* Bearer token resolved by `readBearerToken(req)`: a `fieldnotes-bearer.<token>`
|
|
266
|
+
* `Sec-WebSocket-Protocol` entry, else an `Authorization: Bearer` header, else the
|
|
267
|
+
* `token` URL query parameter (legacy; URLs land in proxy logs). `undefined` when
|
|
268
|
+
* none is present.
|
|
269
|
+
*/
|
|
270
|
+
token?: string;
|
|
223
271
|
}
|
|
224
272
|
interface AuthResult {
|
|
225
273
|
userId: string;
|
|
226
274
|
role?: string;
|
|
227
275
|
}
|
|
228
276
|
type Authenticate = (info: AuthInfo) => AuthResult | null | Promise<AuthResult | null>;
|
|
277
|
+
/**
|
|
278
|
+
* Resolves the bearer token of an upgrade request, preferring channels that stay
|
|
279
|
+
* out of access logs: `Sec-WebSocket-Protocol` bearer entry, then
|
|
280
|
+
* `Authorization: Bearer`, then the `token` query parameter.
|
|
281
|
+
*/
|
|
282
|
+
declare function readBearerToken(req: IncomingMessage): string | undefined;
|
|
229
283
|
|
|
230
284
|
interface CreateSyncServerOptions {
|
|
231
285
|
port?: number;
|
|
@@ -238,6 +292,8 @@ interface CreateSyncServerOptions {
|
|
|
238
292
|
authorizeLayer?: AuthorizeLayer;
|
|
239
293
|
plugins?: readonly ServerSyncPlugin[];
|
|
240
294
|
canRead?: CanRead;
|
|
295
|
+
canReadOwnerId?: CanReadOwnerId;
|
|
296
|
+
resolveAudience?: ResolveAudience;
|
|
241
297
|
heartbeatIntervalMs?: number;
|
|
242
298
|
maxMessageBytes?: number;
|
|
243
299
|
maxJsonDepth?: number;
|
|
@@ -245,8 +301,23 @@ interface CreateSyncServerOptions {
|
|
|
245
301
|
maxPendingAuthBytes?: number;
|
|
246
302
|
messagesPerSecond?: number;
|
|
247
303
|
messageBurst?: number;
|
|
304
|
+
/** Sustained inbound bytes per second per connection (token bucket). */
|
|
305
|
+
bytesPerSecond?: number;
|
|
306
|
+
/** Inbound byte spike allowance per connection; a frame larger than this is never admitted. */
|
|
307
|
+
byteBurst?: number;
|
|
248
308
|
presenceThrottleMs?: number;
|
|
249
309
|
maxPresenceLanes?: number;
|
|
310
|
+
maxPresenceBytes?: number;
|
|
311
|
+
/** Concurrent sockets per client address; `Infinity` disables the cap. */
|
|
312
|
+
maxConnectionsPerIp?: number;
|
|
313
|
+
/** Concurrent sockets per room, pending-auth sockets included; `Infinity` disables the cap. */
|
|
314
|
+
maxConnectionsPerRoom?: number;
|
|
315
|
+
/**
|
|
316
|
+
* Resolves the client address the per-IP cap keys on. Defaults to the socket's
|
|
317
|
+
* remote address; behind a trusted proxy read the forwarded header here.
|
|
318
|
+
* Returning `undefined` or `''` exempts the connection from the per-IP cap.
|
|
319
|
+
*/
|
|
320
|
+
clientAddress?: (req: IncomingMessage) => string | undefined;
|
|
250
321
|
shutdownGraceMs?: number;
|
|
251
322
|
/**
|
|
252
323
|
* Registry used to translate extension envelopes for legacy peers. Without
|
|
@@ -261,6 +332,15 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
|
261
332
|
close: () => Promise<void>;
|
|
262
333
|
};
|
|
263
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Room names the relay admits. Backends derive storage keys from the room
|
|
337
|
+
* name (`<prefix><room>:layers`, `<prefix><room>:fog:meta`, ...), so the
|
|
338
|
+
* alphabet excludes every separator a key builder uses and bounds the length
|
|
339
|
+
* before `authenticate` runs.
|
|
340
|
+
*/
|
|
341
|
+
declare const ROOM_NAME_PATTERN: RegExp;
|
|
342
|
+
declare function isValidRoomName(room: unknown): room is string;
|
|
343
|
+
|
|
264
344
|
interface HeartbeatSocket {
|
|
265
345
|
ping(): void;
|
|
266
346
|
terminate(): void;
|
|
@@ -275,4 +355,4 @@ interface Heartbeat {
|
|
|
275
355
|
}
|
|
276
356
|
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
277
357
|
|
|
278
|
-
export { type ApplyResult, type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, type ServerExtensionRegistry, type ServerNext, type ServerOpContext, type ServerSyncPlugin, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
|
|
358
|
+
export { type ApplyResult, type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type CanReadOwnerId, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type OwnerReadContext, ROOM_NAME_PATTERN, type ReadContext, type ResolveAudience, type ResolveAudienceContext, type ServerExtensionRegistry, type ServerNext, type ServerOpContext, type ServerSyncPlugin, SyncHub, type SyncHubOptions, createSyncServer, isValidRoomName, readBearerToken, startHeartbeat };
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
createCurrentCapabilities,
|
|
4
4
|
createLegacyCapabilities,
|
|
5
5
|
parseEnvelope,
|
|
6
|
+
isValidEnvelope,
|
|
6
7
|
isValidElement,
|
|
7
|
-
isValidWireElement,
|
|
8
8
|
isNewerLayerRecord,
|
|
9
9
|
translateOpForPeer
|
|
10
10
|
} from "@fieldnotes/sync";
|
|
@@ -130,6 +130,11 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
|
|
|
130
130
|
var DEFAULT_MESSAGES_PER_SECOND = 120;
|
|
131
131
|
var DEFAULT_MESSAGE_BURST = 240;
|
|
132
132
|
var DEFAULT_PRESENCE_THROTTLE_MS = 50;
|
|
133
|
+
var DEFAULT_MAX_PRESENCE_BYTES = 4 * 1024;
|
|
134
|
+
var DEFAULT_BYTES_PER_SECOND = 4 * 1024 * 1024;
|
|
135
|
+
var DEFAULT_BYTE_BURST = 8 * 1024 * 1024;
|
|
136
|
+
var DEFAULT_MAX_CONNECTIONS_PER_IP = 64;
|
|
137
|
+
var DEFAULT_MAX_CONNECTIONS_PER_ROOM = 256;
|
|
133
138
|
var DEFAULT_MAX_PRESENCE_LANES = 16;
|
|
134
139
|
function hasJsonDepthAtMost(message, maxDepth) {
|
|
135
140
|
let depth = 0;
|
|
@@ -162,29 +167,30 @@ var MessageRateLimiter = class {
|
|
|
162
167
|
}
|
|
163
168
|
tokens;
|
|
164
169
|
updatedAt;
|
|
165
|
-
|
|
170
|
+
/** Charges `cost` tokens (frames or bytes); a cost above `burst` never fits. */
|
|
171
|
+
take(now = Date.now(), cost = 1) {
|
|
166
172
|
const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1e3;
|
|
167
173
|
this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);
|
|
168
174
|
this.updatedAt = now;
|
|
169
|
-
if (this.tokens <
|
|
170
|
-
this.tokens -=
|
|
175
|
+
if (this.tokens < cost) return false;
|
|
176
|
+
this.tokens -= cost;
|
|
171
177
|
return true;
|
|
172
178
|
}
|
|
173
179
|
};
|
|
174
180
|
|
|
175
181
|
// src/sync-hub.ts
|
|
176
182
|
var HUB_FROM = "hub";
|
|
183
|
+
var utf8 = new TextEncoder();
|
|
184
|
+
function utf8ByteLength(text) {
|
|
185
|
+
return utf8.encode(text).byteLength;
|
|
186
|
+
}
|
|
177
187
|
function generateInstanceId() {
|
|
178
188
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function")
|
|
179
189
|
return crypto.randomUUID();
|
|
180
190
|
return `i-${Math.random().toString(36).slice(2)}`;
|
|
181
191
|
}
|
|
182
192
|
function isFanoutOp(op) {
|
|
183
|
-
|
|
184
|
-
const o = op;
|
|
185
|
-
if (o.kind === "upsert") return isValidWireElement(o.element);
|
|
186
|
-
if (o.kind === "remove") return typeof o.id === "string";
|
|
187
|
-
return o.kind === "clear";
|
|
193
|
+
return op.kind === "upsert" || op.kind === "remove" || op.kind === "clear";
|
|
188
194
|
}
|
|
189
195
|
var FALLBACK_PRESENCE_LANE = "";
|
|
190
196
|
var MAX_PRESENCE_LANE_LENGTH = 64;
|
|
@@ -197,9 +203,7 @@ function presenceLaneOf(data) {
|
|
|
197
203
|
return kind;
|
|
198
204
|
}
|
|
199
205
|
function isLayerOp(op) {
|
|
200
|
-
|
|
201
|
-
const k = op.kind;
|
|
202
|
-
return k === "layer-upsert" || k === "layer-remove";
|
|
206
|
+
return op.kind === "layer-upsert" || op.kind === "layer-remove";
|
|
203
207
|
}
|
|
204
208
|
function layerOpToRecord(op) {
|
|
205
209
|
return op.kind === "layer-upsert" ? { id: op.layer.id, version: op.version, editor: op.editor, definition: op.layer } : { id: op.id, version: op.version, editor: op.editor };
|
|
@@ -212,13 +216,22 @@ function layerRecordToOp(record) {
|
|
|
212
216
|
editor: record.editor
|
|
213
217
|
} : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
|
|
214
218
|
}
|
|
215
|
-
function
|
|
216
|
-
return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds]);
|
|
219
|
+
function encodingProfile(capabilities, revealOwner) {
|
|
220
|
+
return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds, revealOwner]);
|
|
221
|
+
}
|
|
222
|
+
function withoutOwnerId(element) {
|
|
223
|
+
if (element.ownerId === void 0) return element;
|
|
224
|
+
const rest = { ...element };
|
|
225
|
+
delete rest.ownerId;
|
|
226
|
+
return rest;
|
|
227
|
+
}
|
|
228
|
+
function stripOwnerId(op) {
|
|
229
|
+
if (op.kind === "upsert") return { ...op, element: withoutOwnerId(op.element) };
|
|
230
|
+
if (op.kind === "snapshot") return { ...op, elements: op.elements.map(withoutOwnerId) };
|
|
231
|
+
return op;
|
|
217
232
|
}
|
|
218
233
|
function isPresenceOp(op) {
|
|
219
|
-
|
|
220
|
-
const k = op.kind;
|
|
221
|
-
return k === "presence" || k === "presence-leave";
|
|
234
|
+
return op.kind === "presence" || op.kind === "presence-leave";
|
|
222
235
|
}
|
|
223
236
|
var SyncHub = class {
|
|
224
237
|
backend;
|
|
@@ -237,10 +250,13 @@ var SyncHub = class {
|
|
|
237
250
|
elementRegistry;
|
|
238
251
|
peerCapabilities = /* @__PURE__ */ new Map();
|
|
239
252
|
canRead;
|
|
253
|
+
canReadOwnerId;
|
|
254
|
+
resolveAudience;
|
|
240
255
|
memoryLayers = /* @__PURE__ */ new Map();
|
|
241
256
|
maxJsonDepth;
|
|
242
257
|
presenceThrottleMs;
|
|
243
258
|
maxPresenceLanes;
|
|
259
|
+
maxPresenceBytes;
|
|
244
260
|
/**
|
|
245
261
|
* Presence throttle state keyed by connection, then by lane. A lane is the
|
|
246
262
|
* payload's `kind` (a non-empty string of at most 64 chars) or the reserved
|
|
@@ -260,6 +276,8 @@ var SyncHub = class {
|
|
|
260
276
|
this.authorize = options.authorize;
|
|
261
277
|
this.authorizeLayer = options.authorizeLayer;
|
|
262
278
|
this.canRead = options.canRead;
|
|
279
|
+
this.canReadOwnerId = options.canReadOwnerId;
|
|
280
|
+
this.resolveAudience = options.resolveAudience;
|
|
263
281
|
this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
264
282
|
this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
|
|
265
283
|
const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
|
|
@@ -267,6 +285,11 @@ var SyncHub = class {
|
|
|
267
285
|
throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
|
|
268
286
|
}
|
|
269
287
|
this.maxPresenceLanes = Math.floor(maxPresenceLanes);
|
|
288
|
+
const maxPresenceBytes = options.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
|
|
289
|
+
if (!Number.isFinite(maxPresenceBytes) || maxPresenceBytes < 0) {
|
|
290
|
+
throw new RangeError("maxPresenceBytes must be a non-negative finite number");
|
|
291
|
+
}
|
|
292
|
+
this.maxPresenceBytes = maxPresenceBytes;
|
|
270
293
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
271
294
|
}
|
|
272
295
|
addConnection(conn) {
|
|
@@ -305,6 +328,7 @@ var SyncHub = class {
|
|
|
305
328
|
* forwards the same event to other instances on a best-effort basis.
|
|
306
329
|
*/
|
|
307
330
|
broadcastPresence(room, data) {
|
|
331
|
+
if (!this.isPresenceWithinLimit(data)) return 0;
|
|
308
332
|
const op = { kind: "presence", data };
|
|
309
333
|
const sent = this.relayToRoom(room, void 0, JSON.stringify({ from: HUB_FROM, op }));
|
|
310
334
|
this.safePublish(JSON.stringify({ o: this.instanceId, room, from: HUB_FROM, op }));
|
|
@@ -325,6 +349,7 @@ var SyncHub = class {
|
|
|
325
349
|
return Promise.resolve();
|
|
326
350
|
}
|
|
327
351
|
if (env.op.kind === "presence") {
|
|
352
|
+
if (!this.isPresenceWithinLimit(env.op.data)) return Promise.resolve();
|
|
328
353
|
this.schedulePresence(conn, env.op.data);
|
|
329
354
|
return Promise.resolve();
|
|
330
355
|
}
|
|
@@ -384,9 +409,22 @@ var SyncHub = class {
|
|
|
384
409
|
await this.deliverPluginResult(conn, result);
|
|
385
410
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
386
411
|
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
387
|
-
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
412
|
+
const needCurrent = (this.authorize || this.canRead || this.resolveAudience) && id !== void 0;
|
|
388
413
|
const storedCurrent = needCurrent ? await this.backend.get(conn.room, id) : void 0;
|
|
389
414
|
const current = storedCurrent ? this.normalizeElement(storedCurrent) ?? void 0 : void 0;
|
|
415
|
+
if (op.kind === "upsert" && this.resolveAudience) {
|
|
416
|
+
const audience = this.resolveAudience({
|
|
417
|
+
userId: conn.userId,
|
|
418
|
+
role: conn.role,
|
|
419
|
+
room: conn.room,
|
|
420
|
+
element: op.element,
|
|
421
|
+
currentElement: current
|
|
422
|
+
});
|
|
423
|
+
const element = { ...op.element };
|
|
424
|
+
if (audience === void 0) delete element.audience;
|
|
425
|
+
else element.audience = audience;
|
|
426
|
+
op = { kind: "upsert", element };
|
|
427
|
+
}
|
|
390
428
|
let outboundOp = op;
|
|
391
429
|
if (this.authorize) {
|
|
392
430
|
const allowed = await this.authorize({
|
|
@@ -547,6 +585,10 @@ var SyncHub = class {
|
|
|
547
585
|
}
|
|
548
586
|
map.set(record.id, record);
|
|
549
587
|
}
|
|
588
|
+
mayReadOwnerId(conn) {
|
|
589
|
+
if (!this.canReadOwnerId) return false;
|
|
590
|
+
return this.canReadOwnerId({ userId: conn.userId, role: conn.role, room: conn.room });
|
|
591
|
+
}
|
|
550
592
|
mayRead(conn, audience) {
|
|
551
593
|
if (!this.canRead) return true;
|
|
552
594
|
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
@@ -581,10 +623,11 @@ var SyncHub = class {
|
|
|
581
623
|
*/
|
|
582
624
|
sendToConnection(conn, from, op, encoded) {
|
|
583
625
|
const capabilities = this.peerCapabilities.get(conn.id) ?? createLegacyCapabilities();
|
|
584
|
-
const
|
|
626
|
+
const revealOwner = this.mayReadOwnerId(conn);
|
|
627
|
+
const profile = encoded ? encodingProfile(capabilities, revealOwner) : void 0;
|
|
585
628
|
let message = profile === void 0 ? void 0 : encoded?.get(profile);
|
|
586
629
|
if (message === void 0) {
|
|
587
|
-
message = this.encodeForPeer(from, op, capabilities);
|
|
630
|
+
message = this.encodeForPeer(from, op, capabilities, revealOwner);
|
|
588
631
|
if (profile !== void 0) encoded?.set(profile, message);
|
|
589
632
|
}
|
|
590
633
|
if (message === null) return false;
|
|
@@ -595,11 +638,15 @@ var SyncHub = class {
|
|
|
595
638
|
return false;
|
|
596
639
|
}
|
|
597
640
|
}
|
|
598
|
-
/**
|
|
599
|
-
|
|
641
|
+
/**
|
|
642
|
+
* Returns the wire frame for `op` translated for `capabilities`, or null
|
|
643
|
+
* when lossy. The server-stamped `ownerId` is a server-side authorization
|
|
644
|
+
* fact: it leaves the hub only for peers `canReadOwnerId` admits.
|
|
645
|
+
*/
|
|
646
|
+
encodeForPeer(from, op, capabilities, revealOwner) {
|
|
600
647
|
try {
|
|
601
648
|
const translated = translateOpForPeer(
|
|
602
|
-
op,
|
|
649
|
+
revealOwner ? op : stripOwnerId(op),
|
|
603
650
|
capabilities,
|
|
604
651
|
this.elementRegistry,
|
|
605
652
|
this.pluginRegistry.extensionDefinitions
|
|
@@ -779,6 +826,7 @@ var SyncHub = class {
|
|
|
779
826
|
if (correction) this.sendToConnection(conn, HUB_FROM, correction);
|
|
780
827
|
}
|
|
781
828
|
onFanout(payload) {
|
|
829
|
+
if (!hasJsonDepthAtMost(payload, this.maxJsonDepth)) return;
|
|
782
830
|
let env;
|
|
783
831
|
try {
|
|
784
832
|
env = JSON.parse(payload);
|
|
@@ -788,8 +836,11 @@ var SyncHub = class {
|
|
|
788
836
|
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
|
|
789
837
|
return;
|
|
790
838
|
if (env.o === this.instanceId) return;
|
|
791
|
-
const
|
|
839
|
+
const envelope = { from: env.from, op: env.op };
|
|
840
|
+
if (!isValidEnvelope(envelope)) return;
|
|
841
|
+
const op = envelope.op;
|
|
792
842
|
if (isPresenceOp(op)) {
|
|
843
|
+
if (op.kind === "presence" && !this.isPresenceWithinLimit(op.data)) return;
|
|
793
844
|
this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
|
|
794
845
|
return;
|
|
795
846
|
}
|
|
@@ -799,8 +850,16 @@ var SyncHub = class {
|
|
|
799
850
|
this.relayOpToRoom(env.room, void 0, env.from, op);
|
|
800
851
|
return;
|
|
801
852
|
}
|
|
802
|
-
|
|
803
|
-
if (
|
|
853
|
+
let plugin;
|
|
854
|
+
if (op.kind === "extension") {
|
|
855
|
+
const entry = this.pluginRegistry.extension(op.extensionKind);
|
|
856
|
+
if (!entry || !entry.kind.codec.validate(op.payload)) return;
|
|
857
|
+
plugin = entry.plugin;
|
|
858
|
+
} else {
|
|
859
|
+
plugin = this.pluginRegistry.ownerOf(op.kind);
|
|
860
|
+
}
|
|
861
|
+
if (plugin) {
|
|
862
|
+
const owner = plugin;
|
|
804
863
|
const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
|
|
805
864
|
const operation = previous.then(async () => {
|
|
806
865
|
const context = {
|
|
@@ -809,7 +868,7 @@ var SyncHub = class {
|
|
|
809
868
|
backend: this.backend,
|
|
810
869
|
backendPlugin: (key) => this.backend.getService?.(key)
|
|
811
870
|
};
|
|
812
|
-
const accepted =
|
|
871
|
+
const accepted = owner.applyFanout ? await owner.applyFanout(op, context) : op;
|
|
813
872
|
if (accepted) {
|
|
814
873
|
this.relayOpToRoom(env.room, void 0, env.from, accepted);
|
|
815
874
|
}
|
|
@@ -831,6 +890,9 @@ var SyncHub = class {
|
|
|
831
890
|
const prevExisted = env.existed === true;
|
|
832
891
|
this.deliverToRoom(env.room, void 0, env.from, runtimeOp, prevAudience, prevExisted);
|
|
833
892
|
}
|
|
893
|
+
isPresenceWithinLimit(data) {
|
|
894
|
+
return utf8ByteLength(JSON.stringify(data) ?? "") <= this.maxPresenceBytes;
|
|
895
|
+
}
|
|
834
896
|
async applyFanoutLayerOp(room, op) {
|
|
835
897
|
const record = layerOpToRecord(op);
|
|
836
898
|
const current = await this.getLayerRecord(room, record.id);
|
|
@@ -846,6 +908,23 @@ var SyncHub = class {
|
|
|
846
908
|
// src/create-sync-server.ts
|
|
847
909
|
import { WebSocketServer } from "ws";
|
|
848
910
|
|
|
911
|
+
// src/authenticate.ts
|
|
912
|
+
import { readBearerSubprotocol } from "@fieldnotes/sync";
|
|
913
|
+
function readBearerToken(req) {
|
|
914
|
+
const fromProtocol = readBearerSubprotocol(headerValue(req.headers["sec-websocket-protocol"]));
|
|
915
|
+
if (fromProtocol) return fromProtocol;
|
|
916
|
+
const authorization = headerValue(req.headers["authorization"]);
|
|
917
|
+
if (authorization) {
|
|
918
|
+
const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
|
|
919
|
+
if (match?.[1]) return match[1];
|
|
920
|
+
}
|
|
921
|
+
const query = new URL(req.url ?? "", "http://localhost").searchParams.get("token");
|
|
922
|
+
return query || void 0;
|
|
923
|
+
}
|
|
924
|
+
function headerValue(value) {
|
|
925
|
+
return Array.isArray(value) ? value.join(",") : value;
|
|
926
|
+
}
|
|
927
|
+
|
|
849
928
|
// src/heartbeat.ts
|
|
850
929
|
function startHeartbeat(wss, intervalMs) {
|
|
851
930
|
if (intervalMs <= 0) {
|
|
@@ -877,6 +956,9 @@ function startHeartbeat(wss, intervalMs) {
|
|
|
877
956
|
return { track, stop: () => clearInterval(interval) };
|
|
878
957
|
}
|
|
879
958
|
|
|
959
|
+
// src/create-sync-server.ts
|
|
960
|
+
import { BEARER_SUBPROTOCOL_PREFIX, SYNC_WS_SUBPROTOCOL } from "@fieldnotes/sync";
|
|
961
|
+
|
|
880
962
|
// src/shutdown.ts
|
|
881
963
|
var DEFAULT_SHUTDOWN_GRACE_MS = 5e3;
|
|
882
964
|
function drainWebSocketServer(wss, graceMs) {
|
|
@@ -911,16 +993,78 @@ function drainWebSocketServer(wss, graceMs) {
|
|
|
911
993
|
});
|
|
912
994
|
}
|
|
913
995
|
|
|
996
|
+
// src/room-name.ts
|
|
997
|
+
var ROOM_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
998
|
+
function isValidRoomName(room) {
|
|
999
|
+
return typeof room === "string" && ROOM_NAME_PATTERN.test(room);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
914
1002
|
// src/create-sync-server.ts
|
|
1003
|
+
var ConcurrencyCounter = class {
|
|
1004
|
+
constructor(limit) {
|
|
1005
|
+
this.limit = limit;
|
|
1006
|
+
}
|
|
1007
|
+
counts = /* @__PURE__ */ new Map();
|
|
1008
|
+
/** Reserves a slot for `key`; returns a release function, or null when the cap is reached. */
|
|
1009
|
+
acquire(key) {
|
|
1010
|
+
const current = this.counts.get(key) ?? 0;
|
|
1011
|
+
if (current >= this.limit) return null;
|
|
1012
|
+
this.counts.set(key, current + 1);
|
|
1013
|
+
let released = false;
|
|
1014
|
+
return () => {
|
|
1015
|
+
if (released) return;
|
|
1016
|
+
released = true;
|
|
1017
|
+
const remaining = (this.counts.get(key) ?? 1) - 1;
|
|
1018
|
+
if (remaining <= 0) this.counts.delete(key);
|
|
1019
|
+
else this.counts.set(key, remaining);
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
915
1023
|
function rawDataByteLength(data) {
|
|
916
1024
|
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
917
1025
|
return data.byteLength;
|
|
918
1026
|
}
|
|
1027
|
+
function requirePositiveFinite(name, value) {
|
|
1028
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1029
|
+
throw new RangeError(`${name} must be a positive finite number`);
|
|
1030
|
+
}
|
|
1031
|
+
return value;
|
|
1032
|
+
}
|
|
1033
|
+
function requirePositiveIntegerOrInfinity(name, value) {
|
|
1034
|
+
if (value !== Infinity && (!Number.isSafeInteger(value) || value <= 0)) {
|
|
1035
|
+
throw new RangeError(`${name} must be a positive safe integer or Infinity`);
|
|
1036
|
+
}
|
|
1037
|
+
return value;
|
|
1038
|
+
}
|
|
919
1039
|
function createSyncServer(options = {}) {
|
|
920
1040
|
const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
|
|
921
1041
|
if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
|
|
922
1042
|
throw new RangeError("shutdownGraceMs must be a non-negative finite number");
|
|
923
1043
|
}
|
|
1044
|
+
const messagesPerSecond = requirePositiveFinite(
|
|
1045
|
+
"messagesPerSecond",
|
|
1046
|
+
options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND
|
|
1047
|
+
);
|
|
1048
|
+
const messageBurst = requirePositiveFinite(
|
|
1049
|
+
"messageBurst",
|
|
1050
|
+
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
1051
|
+
);
|
|
1052
|
+
const bytesPerSecond = requirePositiveFinite(
|
|
1053
|
+
"bytesPerSecond",
|
|
1054
|
+
options.bytesPerSecond ?? DEFAULT_BYTES_PER_SECOND
|
|
1055
|
+
);
|
|
1056
|
+
const byteBurst = requirePositiveFinite("byteBurst", options.byteBurst ?? DEFAULT_BYTE_BURST);
|
|
1057
|
+
const maxConnectionsPerIp = requirePositiveIntegerOrInfinity(
|
|
1058
|
+
"maxConnectionsPerIp",
|
|
1059
|
+
options.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP
|
|
1060
|
+
);
|
|
1061
|
+
const maxConnectionsPerRoom = requirePositiveIntegerOrInfinity(
|
|
1062
|
+
"maxConnectionsPerRoom",
|
|
1063
|
+
options.maxConnectionsPerRoom ?? DEFAULT_MAX_CONNECTIONS_PER_ROOM
|
|
1064
|
+
);
|
|
1065
|
+
if (options.authorize && !options.authenticate) {
|
|
1066
|
+
throw new Error("createSyncServer: `authorize` requires an `authenticate` hook");
|
|
1067
|
+
}
|
|
924
1068
|
const hub = new SyncHub({
|
|
925
1069
|
backend: options.backend,
|
|
926
1070
|
fanout: options.fanout,
|
|
@@ -929,17 +1073,34 @@ function createSyncServer(options = {}) {
|
|
|
929
1073
|
authorizeLayer: options.authorizeLayer,
|
|
930
1074
|
plugins: options.plugins,
|
|
931
1075
|
canRead: options.canRead,
|
|
1076
|
+
canReadOwnerId: options.canReadOwnerId,
|
|
1077
|
+
resolveAudience: options.resolveAudience,
|
|
932
1078
|
maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
|
|
933
1079
|
presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
|
|
934
1080
|
maxPresenceLanes: options.maxPresenceLanes,
|
|
1081
|
+
maxPresenceBytes: options.maxPresenceBytes,
|
|
935
1082
|
elementRegistry: options.elementRegistry
|
|
936
1083
|
});
|
|
937
1084
|
const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
938
|
-
const
|
|
1085
|
+
const handleProtocols = (protocols) => {
|
|
1086
|
+
if (protocols.has(SYNC_WS_SUBPROTOCOL)) return SYNC_WS_SUBPROTOCOL;
|
|
1087
|
+
for (const protocol of protocols) {
|
|
1088
|
+
if (!protocol.startsWith(BEARER_SUBPROTOCOL_PREFIX)) return protocol;
|
|
1089
|
+
}
|
|
1090
|
+
return false;
|
|
1091
|
+
};
|
|
1092
|
+
const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes, handleProtocols }) : new WebSocketServer({
|
|
1093
|
+
port: options.port ?? 0,
|
|
1094
|
+
maxPayload: maxMessageBytes,
|
|
1095
|
+
handleProtocols
|
|
1096
|
+
});
|
|
939
1097
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
|
940
1098
|
let shuttingDown = false;
|
|
941
1099
|
let closePromise;
|
|
942
1100
|
let counter = 0;
|
|
1101
|
+
const perIp = new ConcurrencyCounter(maxConnectionsPerIp);
|
|
1102
|
+
const perRoom = new ConcurrencyCounter(maxConnectionsPerRoom);
|
|
1103
|
+
const clientAddress = options.clientAddress ?? ((req) => req.socket.remoteAddress);
|
|
943
1104
|
wss.on("connection", (ws, req) => {
|
|
944
1105
|
if (shuttingDown) {
|
|
945
1106
|
ws.close(1001, "server shutting down");
|
|
@@ -953,6 +1114,22 @@ function createSyncServer(options = {}) {
|
|
|
953
1114
|
ws.close(4400, "room required");
|
|
954
1115
|
return;
|
|
955
1116
|
}
|
|
1117
|
+
if (!isValidRoomName(room)) {
|
|
1118
|
+
ws.close(4400, "invalid room");
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const address = clientAddress(req);
|
|
1122
|
+
const releaseIp = address ? perIp.acquire(address) : () => void 0;
|
|
1123
|
+
if (!releaseIp) {
|
|
1124
|
+
ws.close(4429, "too many connections");
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const releaseRoom = perRoom.acquire(room);
|
|
1128
|
+
if (!releaseRoom) {
|
|
1129
|
+
releaseIp();
|
|
1130
|
+
ws.close(4429, "too many connections");
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
956
1133
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
957
1134
|
let state = "pending";
|
|
958
1135
|
let closed = false;
|
|
@@ -961,10 +1138,8 @@ function createSyncServer(options = {}) {
|
|
|
961
1138
|
let queuedBytes = 0;
|
|
962
1139
|
const maxPendingAuthMessages = options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;
|
|
963
1140
|
const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;
|
|
964
|
-
const limiter = new MessageRateLimiter(
|
|
965
|
-
|
|
966
|
-
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
967
|
-
);
|
|
1141
|
+
const limiter = new MessageRateLimiter(messagesPerSecond, messageBurst);
|
|
1142
|
+
const byteLimiter = new MessageRateLimiter(bytesPerSecond, byteBurst);
|
|
968
1143
|
const send = (m) => {
|
|
969
1144
|
try {
|
|
970
1145
|
ws.send(m);
|
|
@@ -979,7 +1154,8 @@ function createSyncServer(options = {}) {
|
|
|
979
1154
|
ws.close(1009, "message too large");
|
|
980
1155
|
return;
|
|
981
1156
|
}
|
|
982
|
-
|
|
1157
|
+
const now = Date.now();
|
|
1158
|
+
if (!limiter.take(now) || !byteLimiter.take(now, messageBytes)) {
|
|
983
1159
|
state = "rejected";
|
|
984
1160
|
ws.close(4408, "rate limit exceeded");
|
|
985
1161
|
return;
|
|
@@ -999,9 +1175,13 @@ function createSyncServer(options = {}) {
|
|
|
999
1175
|
});
|
|
1000
1176
|
ws.on("close", () => {
|
|
1001
1177
|
closed = true;
|
|
1178
|
+
releaseIp();
|
|
1179
|
+
releaseRoom();
|
|
1002
1180
|
if (admitted) hub.removeConnection(connId);
|
|
1003
1181
|
});
|
|
1004
|
-
Promise.resolve(
|
|
1182
|
+
Promise.resolve(
|
|
1183
|
+
options.authenticate ? options.authenticate({ req, room, token: readBearerToken(req) }) : { userId: connId }
|
|
1184
|
+
).then((result) => {
|
|
1005
1185
|
if (closed || state === "rejected" || shuttingDown) return;
|
|
1006
1186
|
if (!result) {
|
|
1007
1187
|
state = "rejected";
|
|
@@ -1041,8 +1221,11 @@ function createSyncServer(options = {}) {
|
|
|
1041
1221
|
export {
|
|
1042
1222
|
InMemoryHubFanout,
|
|
1043
1223
|
MemoryHubBackend,
|
|
1224
|
+
ROOM_NAME_PATTERN,
|
|
1044
1225
|
SyncHub,
|
|
1045
1226
|
createSyncServer,
|
|
1227
|
+
isValidRoomName,
|
|
1228
|
+
readBearerToken,
|
|
1046
1229
|
startHeartbeat
|
|
1047
1230
|
};
|
|
1048
1231
|
//# sourceMappingURL=index.js.map
|