@absolutejs/sync 1.14.0 → 1.16.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.
@@ -1,6 +1,23 @@
1
1
  import { Elysia } from 'elysia';
2
+ import type { SyncConnectionStats } from './connection';
2
3
  import type { PresenceHub } from './presence';
3
4
  import type { SyncEngine } from './syncEngine';
5
+ import type { FrameSerializer } from '../serializer';
6
+ /**
7
+ * Diagnostic surfaced via {@link SyncSocketOptions.onSlow} when a connection
8
+ * trips the WS backpressure threshold. The host can log, kick, or charge the
9
+ * tenant extra via the meter.
10
+ */
11
+ export type SlowConnectionEvent = {
12
+ /** Stable per-connection id from Elysia's `ws.id`. */
13
+ wsId: string;
14
+ /** Bytes the WS currently has queued waiting to send. */
15
+ bufferedAmount: number;
16
+ /** Per-connection counters at the moment of detection. */
17
+ stats: SyncConnectionStats;
18
+ /** Why the event fired. */
19
+ reason: 'buffer-threshold' | 'send-backpressure';
20
+ };
4
21
  export type SyncSocketOptions = {
5
22
  /** The sync engine whose collections this socket serves. */
6
23
  engine: SyncEngine;
@@ -15,19 +32,56 @@ export type SyncSocketOptions = {
15
32
  * collection's `authorize`/`hydrate`/`match`. Defaults to an empty object.
16
33
  */
17
34
  resolveContext?: (data: Record<string, unknown>) => unknown | Promise<unknown>;
35
+ /**
36
+ * Bytes threshold for the per-connection WS send buffer. When
37
+ * `ws.getBufferedAmount()` exceeds this, `onSlow` fires once per
38
+ * crossing. Default `Infinity` (disabled).
39
+ *
40
+ * Added in 1.14.0.
41
+ */
42
+ maxBufferedBytes?: number;
43
+ /**
44
+ * Fired when the per-connection WS buffer crosses `maxBufferedBytes`, OR
45
+ * when `ws.send()` returns `-1` (Bun's backpressure signal). The signal
46
+ * re-arms once the WS reports `drain`. Pair with `closeOnSlow: true` to
47
+ * kick slow clients automatically, or use this hook to charge the
48
+ * tenant extra via `@absolutejs/metering`.
49
+ *
50
+ * Added in 1.14.0.
51
+ */
52
+ onSlow?: (event: SlowConnectionEvent) => void | Promise<void>;
53
+ /**
54
+ * Close the WS the first time a connection crosses `maxBufferedBytes`
55
+ * (or the `-1` send threshold). Default `false`. Client will reconnect
56
+ * and re-hydrate.
57
+ *
58
+ * Added in 1.14.0.
59
+ */
60
+ closeOnSlow?: boolean;
61
+ /**
62
+ * Wire-format serializer (1.16.0). Default `jsonSerializer` —
63
+ * preserves the pre-1.16 behavior. Both ends of the connection MUST
64
+ * use the same serializer; opt into a binary one (msgpack, cbor, or
65
+ * a custom layout) on BOTH this plugin AND the client to cut the
66
+ * bandwidth + parse CPU.
67
+ */
68
+ serializer?: FrameSerializer;
18
69
  };
19
70
  /**
20
- * Elysia WebSocket plugin for the Tier 3 sync engine. One socket multiplexes any
21
- * number of collection subscriptions: the client sends `subscribe`/`unsubscribe`
22
- * frames and receives `snapshot`/`diff`/`error` frames (see
23
- * {@link createSyncConnection}). Mount it once and drive `engine.applyChange`
24
- * from your mutations.
71
+ * Elysia WebSocket plugin for the sync engine. One socket multiplexes any
72
+ * number of collection subscriptions: the client sends `subscribe` /
73
+ * `unsubscribe` frames and receives `snapshot` / `diff` / `error` frames
74
+ * (see {@link createSyncConnection}). Mount once and drive
75
+ * `engine.applyChange` from your mutations.
25
76
  *
26
77
  * Uses Elysia's first-class `.ws()` rather than a hand-rolled stream — the
27
- * bidirectional channel carries both subscriptions and (later) mutations, and
78
+ * bidirectional channel carries both subscriptions and mutations, and
28
79
  * `ws.send` serializes frames for us.
80
+ *
81
+ * 1.14.0 adds WS-layer slow-client detection — see `maxBufferedBytes` /
82
+ * `onSlow` / `closeOnSlow`.
29
83
  */
30
- export declare const syncSocket: ({ engine, path, resolveContext, presence }: SyncSocketOptions) => Elysia<"", {
84
+ export declare const syncSocket: ({ engine, path, resolveContext, presence, maxBufferedBytes, onSlow, closeOnSlow, serializer }: SyncSocketOptions) => Elysia<"", {
31
85
  decorator: {};
32
86
  store: {};
33
87
  derive: {};
@@ -27,6 +27,16 @@ export type CrdtFields<Row> = {
27
27
  export declare class UnauthorizedError extends Error {
28
28
  constructor(subject: string);
29
29
  }
30
+ /**
31
+ * Thrown by `engine.subscribe` / `engine.hydrate` (1.15.0+) when the caller's
32
+ * `AbortSignal` fires before the operation reaches a `Subscription` /
33
+ * resolved value. The `name` is `'AbortError'` to match the DOM-standard
34
+ * spelling so existing `catch (error) { if (error.name === 'AbortError') ... }`
35
+ * patterns work unchanged.
36
+ */
37
+ export declare class AbortError extends Error {
38
+ constructor(reason?: string);
39
+ }
30
40
  /**
31
41
  * Thrown when a mutation's write fails its table's schema (see
32
42
  * {@link defineSchema}). The message names the offending field.
@@ -50,6 +60,19 @@ export type SubscribeArgs<T, P, Ctx> = {
50
60
  * snapshot.
51
61
  */
52
62
  since?: number;
63
+ /**
64
+ * Cancellation handle (1.15.0). Two effects:
65
+ * 1. If the signal is already aborted when `subscribe` is called, the
66
+ * engine throws {@link AbortError} immediately — no authorize, no
67
+ * hydrate, no subscription.
68
+ * 2. If the signal fires AFTER the subscription is live, the engine
69
+ * auto-calls `unsubscribe()`. The consumer never has to thread two
70
+ * handles for the same lifetime.
71
+ *
72
+ * Backwards-compatible — omit `signal` and the engine behaves exactly
73
+ * as in pre-1.15.0.
74
+ */
75
+ signal?: AbortSignal;
53
76
  };
54
77
  export type Subscription<T> = {
55
78
  /** The result set at subscribe time — a snapshot (empty when resuming). */
@@ -97,8 +120,14 @@ export type SyncEngine = {
97
120
  * One-shot read: authorize and return a collection's current rows without
98
121
  * subscribing. Powers an Eden-typed HTTP hydrate route (and SSR). Rejects
99
122
  * with {@link UnauthorizedError} on deny.
123
+ *
124
+ * Pass `options.signal` (1.15.0+) to cancel the operation mid-flight —
125
+ * the engine throws {@link AbortError} after the next await point if
126
+ * the signal has fired.
100
127
  */
101
- hydrate: (collection: string, params: unknown, ctx: unknown) => Promise<unknown[]>;
128
+ hydrate: (collection: string, params: unknown, ctx: unknown, options?: {
129
+ signal?: AbortSignal;
130
+ }) => Promise<unknown[]>;
102
131
  /**
103
132
  * Feed a committed change to `table` into the engine, fanning the resulting
104
133
  * diff to every live subscription of every collection that reads that table.
package/dist/index.d.ts CHANGED
@@ -5,7 +5,9 @@ export type { ReactiveEvent, ReactiveHub, ReactiveListener } from './reactiveHub
5
5
  export { sync } from './plugin';
6
6
  export type { SyncPluginOptions, SyncRequestContext } from './plugin';
7
7
  export { syncSocket } from './engine/socket';
8
- export type { SyncSocketOptions } from './engine/socket';
8
+ export type { SlowConnectionEvent, SyncSocketOptions } from './engine/socket';
9
+ export { jsonSerializer } from './serializer';
10
+ export type { FrameSerializer } from './serializer';
9
11
  export { syncCdc } from './engine/cdc';
10
12
  export type { SyncCdcOptions } from './engine/cdc';
11
13
  export { syncDevtools } from './devtools';
package/dist/index.js CHANGED
@@ -187,15 +187,43 @@ var sync = ({
187
187
  // src/engine/socket.ts
188
188
  import { Elysia as Elysia2 } from "elysia";
189
189
 
190
+ // src/serializer.ts
191
+ var jsonSerializer = {
192
+ decode: (raw) => {
193
+ if (typeof raw === "string") {
194
+ try {
195
+ return JSON.parse(raw);
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+ if (raw instanceof Uint8Array) {
201
+ try {
202
+ return JSON.parse(new TextDecoder().decode(raw));
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+ if (raw instanceof ArrayBuffer) {
208
+ try {
209
+ return JSON.parse(new TextDecoder().decode(new Uint8Array(raw)));
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+ return raw;
215
+ },
216
+ encodeClient: (frame) => JSON.stringify(frame),
217
+ encodeServer: (frame) => JSON.stringify(frame)
218
+ };
219
+
190
220
  // src/engine/connection.ts
191
- var parseFrame = (raw) => {
221
+ var parseFrame = (raw, serializer) => {
192
222
  let value = raw;
193
- if (typeof value === "string") {
194
- try {
195
- value = JSON.parse(value);
196
- } catch {
223
+ if (typeof value === "string" || value instanceof Uint8Array || value instanceof ArrayBuffer) {
224
+ value = serializer.decode(raw);
225
+ if (value === null)
197
226
  return;
198
- }
199
227
  }
200
228
  if (typeof value !== "object" || value === null) {
201
229
  return;
@@ -240,11 +268,23 @@ var parseFrame = (raw) => {
240
268
  var createSyncConnection = ({
241
269
  engine,
242
270
  ctx,
243
- send,
244
- presence
271
+ send: rawSend,
272
+ presence,
273
+ serializer = jsonSerializer
245
274
  }) => {
246
275
  const subscriptions = new Map;
247
276
  const presenceRooms = new Map;
277
+ let framesSent = 0;
278
+ let slowSendsRecent = 0;
279
+ const send = (frame) => {
280
+ const ret = rawSend(frame);
281
+ if (ret === -1) {
282
+ slowSendsRecent += 1;
283
+ } else {
284
+ framesSent += 1;
285
+ slowSendsRecent = 0;
286
+ }
287
+ };
248
288
  let pending = [];
249
289
  let pendingVersion;
250
290
  let flushScheduled = false;
@@ -289,7 +329,7 @@ var createSyncConnection = ({
289
329
  scheduleFlush();
290
330
  };
291
331
  const handle = async (raw) => {
292
- const frame = parseFrame(raw);
332
+ const frame = parseFrame(raw, serializer);
293
333
  if (frame === undefined) {
294
334
  send({ type: "error", message: "Malformed sync frame" });
295
335
  return;
@@ -406,7 +446,13 @@ var createSyncConnection = ({
406
446
  }
407
447
  presenceRooms.clear();
408
448
  };
409
- return { handle, close };
449
+ const stats = () => ({
450
+ framesSent,
451
+ presenceRoomCount: presenceRooms.size,
452
+ slowSendsRecent,
453
+ subscriptionCount: subscriptions.size
454
+ });
455
+ return { close, handle, stats };
410
456
  };
411
457
 
412
458
  // src/engine/socket.ts
@@ -414,27 +460,76 @@ var syncSocket = ({
414
460
  engine,
415
461
  path = "/sync/ws",
416
462
  resolveContext,
417
- presence
463
+ presence,
464
+ maxBufferedBytes,
465
+ onSlow,
466
+ closeOnSlow = false,
467
+ serializer = jsonSerializer
418
468
  }) => {
419
469
  const connections = new Map;
470
+ const threshold = maxBufferedBytes ?? Infinity;
471
+ const fireSlow = (event) => {
472
+ if (!onSlow)
473
+ return;
474
+ try {
475
+ const ret = onSlow(event);
476
+ if (ret && typeof ret.then === "function") {
477
+ ret.catch((error) => {
478
+ console.error("[sync/socket] onSlow rejected:", error);
479
+ });
480
+ }
481
+ } catch (error) {
482
+ console.error("[sync/socket] onSlow threw:", error);
483
+ }
484
+ };
420
485
  return new Elysia2({ name: "@absolutejs/sync/socket" }).ws(path, {
421
486
  async open(ws) {
422
487
  const ctx = resolveContext ? await resolveContext(ws.data) : {};
423
- connections.set(ws.id, createSyncConnection({
424
- engine,
425
- ctx,
426
- presence,
427
- send: (frame) => {
428
- ws.send(frame);
429
- }
430
- }));
488
+ const bunWs = ws;
489
+ const tracked = {
490
+ connection: createSyncConnection({
491
+ engine,
492
+ ctx,
493
+ presence,
494
+ serializer,
495
+ send: (frame) => {
496
+ const payload = serializer.encodeServer(frame);
497
+ const ret = bunWs.send(typeof payload === "string" ? payload : payload);
498
+ const buffered = bunWs.getBufferedAmount?.() ?? 0;
499
+ const overBuffer = buffered > threshold;
500
+ const backpressure = ret === -1;
501
+ if ((overBuffer || backpressure) && !tracked.slowSignaled) {
502
+ tracked.slowSignaled = true;
503
+ fireSlow({
504
+ bufferedAmount: buffered,
505
+ reason: backpressure ? "send-backpressure" : "buffer-threshold",
506
+ stats: tracked.connection.stats(),
507
+ wsId: bunWs.id
508
+ });
509
+ if (closeOnSlow)
510
+ bunWs.close?.();
511
+ }
512
+ return ret;
513
+ }
514
+ }),
515
+ slowSignaled: false
516
+ };
517
+ connections.set(bunWs.id, tracked);
431
518
  },
432
519
  async message(ws, message) {
433
- await connections.get(ws.id)?.handle(message);
520
+ await connections.get(ws.id)?.connection.handle(message);
521
+ },
522
+ drain(ws) {
523
+ const tracked = connections.get(ws.id);
524
+ if (tracked)
525
+ tracked.slowSignaled = false;
434
526
  },
435
527
  close(ws) {
436
- connections.get(ws.id)?.close();
437
- connections.delete(ws.id);
528
+ const tracked = connections.get(ws.id);
529
+ if (tracked) {
530
+ tracked.connection.close();
531
+ connections.delete(ws.id);
532
+ }
438
533
  }
439
534
  });
440
535
  };
@@ -994,6 +1089,32 @@ class UnauthorizedError extends Error {
994
1089
  }
995
1090
  }
996
1091
 
1092
+ class AbortError extends Error {
1093
+ constructor(reason) {
1094
+ super(reason ?? "Aborted");
1095
+ this.name = "AbortError";
1096
+ }
1097
+ }
1098
+ var checkAborted = (signal) => {
1099
+ if (signal?.aborted) {
1100
+ throw new AbortError(signal.reason instanceof Error ? signal.reason.message : typeof signal.reason === "string" ? signal.reason : "Aborted");
1101
+ }
1102
+ };
1103
+ var linkAbortToUnsubscribe = (signal, unsubscribe) => {
1104
+ if (signal === undefined)
1105
+ return;
1106
+ if (signal.aborted) {
1107
+ unsubscribe();
1108
+ return;
1109
+ }
1110
+ const handler = () => {
1111
+ try {
1112
+ unsubscribe();
1113
+ } catch {}
1114
+ };
1115
+ signal.addEventListener("abort", handler, { once: true });
1116
+ };
1117
+
997
1118
  class SchemaError extends Error {
998
1119
  constructor(table, fieldName) {
999
1120
  super(`Schema violation on "${table}": invalid field "${fieldName}"`);
@@ -1856,29 +1977,35 @@ var createSyncEngine = (options = {}) => {
1856
1977
  registerSearch: (collection) => {
1857
1978
  registry.set(collection.name, collection);
1858
1979
  },
1859
- subscribe: async ({ collection, params, ctx, onDiff, since }) => {
1980
+ subscribe: async ({ collection, params, ctx, onDiff, since, signal }) => {
1981
+ checkAborted(signal);
1860
1982
  const registered = registry.get(collection);
1861
1983
  if (registered === undefined) {
1862
1984
  throw new Error(`Unknown collection "${collection}"`);
1863
1985
  }
1864
1986
  const typedOnDiff = onDiff;
1865
1987
  const subscribeSet = subsFor(collection);
1988
+ const wrapReturn = (sub) => {
1989
+ checkAborted(signal);
1990
+ linkAbortToUnsubscribe(signal, sub.unsubscribe);
1991
+ return sub;
1992
+ };
1866
1993
  const registeredKind = registered.kind;
1867
1994
  if (registeredKind === "join") {
1868
1995
  const joined = await subscribeJoin(collection, registered, params, ctx, typedOnDiff, subscribeSet);
1869
- return joined;
1996
+ return wrapReturn(joined);
1870
1997
  }
1871
1998
  if (registeredKind === "graph") {
1872
1999
  const graphed = await subscribeGraph(collection, registered, params, ctx, typedOnDiff, subscribeSet);
1873
- return graphed;
2000
+ return wrapReturn(graphed);
1874
2001
  }
1875
2002
  if (registeredKind === "reactive") {
1876
2003
  const reactived = await subscribeReactive(collection, registered, params, ctx, typedOnDiff, subscribeSet);
1877
- return reactived;
2004
+ return wrapReturn(reactived);
1878
2005
  }
1879
2006
  if (registeredKind === "search") {
1880
2007
  const searched = await subscribeSearch(collection, registered, params, ctx, typedOnDiff, subscribeSet);
1881
- return searched;
2008
+ return wrapReturn(searched);
1882
2009
  }
1883
2010
  const definition = registered;
1884
2011
  if (definition.authorize !== undefined) {
@@ -1920,31 +2047,35 @@ var createSyncEngine = (options = {}) => {
1920
2047
  subscribeSet.delete(subscription);
1921
2048
  };
1922
2049
  if (resuming) {
1923
- return {
2050
+ return wrapReturn({
1924
2051
  initial: [],
1925
2052
  catchup: buildCatchup(since, tables, key, boundMatch),
1926
2053
  version: atVersion,
1927
2054
  unsubscribe
1928
- };
2055
+ });
1929
2056
  }
1930
- return {
2057
+ return wrapReturn({
1931
2058
  initial: view.rows(),
1932
2059
  version: atVersion,
1933
2060
  unsubscribe
1934
- };
2061
+ });
1935
2062
  },
1936
- hydrate: async (collection, params, ctx) => {
2063
+ hydrate: async (collection, params, ctx, options2) => {
2064
+ const signal = options2?.signal;
2065
+ checkAborted(signal);
1937
2066
  const definition = registry.get(collection);
1938
2067
  if (definition === undefined) {
1939
2068
  throw new Error(`Unknown collection "${collection}"`);
1940
2069
  }
1941
2070
  if (definition.authorize !== undefined) {
1942
2071
  const allowed = await definition.authorize(params, ctx);
2072
+ checkAborted(signal);
1943
2073
  if (!allowed) {
1944
2074
  throw new UnauthorizedError(`hydrate collection "${collection}"`);
1945
2075
  }
1946
2076
  }
1947
2077
  const raw = [...await definition.hydrate(params, ctx)];
2078
+ checkAborted(signal);
1948
2079
  const tables = definition.tables ?? [collection];
1949
2080
  const scopedTable = tables.length === 1 ? tables[0] : undefined;
1950
2081
  const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
@@ -2754,10 +2885,11 @@ export {
2754
2885
  syncDevtools,
2755
2886
  syncCdc,
2756
2887
  sync,
2888
+ jsonSerializer,
2757
2889
  createWriteBehindCache,
2758
2890
  createReactiveHub,
2759
2891
  createPresenceHub
2760
2892
  };
2761
2893
 
2762
- //# debugId=202C826A4AA9A02264756E2164756E21
2894
+ //# debugId=B96A4C67BAC0D9FB64756E2164756E21
2763
2895
  //# sourceMappingURL=index.js.map