@absolutejs/sync 1.15.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,5 +1,6 @@
1
1
  import type { RowKey } from '../engine/types';
2
2
  import type { MutationStorage } from './syncCollection';
3
+ import { type FrameSerializer } from '../serializer';
3
4
  export type SyncStoreStatus = 'connecting' | 'ready' | 'closed';
4
5
  export type SyncStoreState<Row> = {
5
6
  /** Visible rows: confirmed server state with pending optimistic edits applied. */
@@ -49,6 +50,11 @@ export type SyncStoreOptions<Row, M extends MutationMap> = {
49
50
  /** Persist the pending-mutation queue across reloads (offline). */
50
51
  storage?: MutationStorage;
51
52
  onError?: (error: unknown) => void;
53
+ /**
54
+ * Wire-format serializer (1.16.0). Defaults to `jsonSerializer`. MUST
55
+ * match the server's `syncSocket` serializer.
56
+ */
57
+ serializer?: FrameSerializer;
52
58
  };
53
59
  export type SyncStore<Row, M extends MutationMap> = {
54
60
  get: () => SyncStoreState<Row>;
@@ -1,5 +1,6 @@
1
1
  import type { PresenceHub, PresenceMember } from './presence';
2
2
  import type { SyncEngine } from './syncEngine';
3
+ import type { FrameSerializer } from '../serializer';
3
4
  /**
4
5
  * Wire protocol for the sync-engine WebSocket. One connection multiplexes many
5
6
  * collection subscriptions, each tagged with a client-chosen `id`.
@@ -80,16 +81,58 @@ export type SyncConnectionOptions = {
80
81
  engine: SyncEngine;
81
82
  /** Resolved auth context for this connection; passed to every subscribe. */
82
83
  ctx: unknown;
83
- /** Send a frame to the client (the transport serializes it). */
84
- send: (frame: ServerFrame) => void;
84
+ /**
85
+ * Send a frame to the client (the transport serializes it). May return
86
+ * a number — by convention `-1` signals transport-layer backpressure (the
87
+ * value Bun's `ws.send()` returns when the WS buffer is full). The
88
+ * connection tracks consecutive `-1` returns and surfaces them via
89
+ * `connection.stats().slowSendsRecent`. Legacy `void`-returning sends
90
+ * keep working unchanged.
91
+ *
92
+ * NOTE: 1.16.0 — `send` receives the typed `ServerFrame`. The connection
93
+ * does NOT pre-serialize; the WS adapter (`syncSocket`) wraps `send` to
94
+ * call `serializer.encodeServer(frame)` before `ws.send(...)`. This keeps
95
+ * the connection layer transport-agnostic.
96
+ */
97
+ send: (frame: ServerFrame) => void | number;
85
98
  /** Optional presence hub; enables the `presence-*` frames (see createPresenceHub). */
86
99
  presence?: PresenceHub;
100
+ /**
101
+ * Wire-format serializer (1.16.0). Defaults to `jsonSerializer` —
102
+ * the historical JSON-over-WS behavior. Both ends of the connection
103
+ * MUST use the same serializer; pair this option with the matching
104
+ * client-side `serializer` to opt into binary frames.
105
+ */
106
+ serializer?: FrameSerializer;
107
+ };
108
+ /**
109
+ * Connection-level operational counters surfaced via {@link SyncConnection.stats}.
110
+ * Pair with the WS adapter's own backpressure signal for end-to-end slow-client
111
+ * detection.
112
+ */
113
+ export type SyncConnectionStats = {
114
+ /** Active subscriptions on this connection. */
115
+ subscriptionCount: number;
116
+ /** Active presence-room memberships on this connection. */
117
+ presenceRoomCount: number;
118
+ /** Frames successfully sent (non-backpressure return) since the connection opened. */
119
+ framesSent: number;
120
+ /** Consecutive `-1` (backpressure) returns from `send` since the last successful send. */
121
+ slowSendsRecent: number;
87
122
  };
88
123
  export type SyncConnection = {
89
124
  /** Handle one client frame (a parsed object or a raw JSON string). */
90
125
  handle: (raw: unknown) => Promise<void>;
91
126
  /** Tear down every subscription on this connection (call on socket close). */
92
127
  close: () => void;
128
+ /**
129
+ * Point-in-time connection counters — subscription count, frames sent, and
130
+ * how many consecutive `send` calls came back with the transport's backpressure
131
+ * signal. Cheap; safe to call from a metering loop.
132
+ *
133
+ * Added in 1.14.0.
134
+ */
135
+ stats: () => SyncConnectionStats;
93
136
  };
94
137
  /**
95
138
  * The per-connection protocol handler — transport-agnostic glue between a single
@@ -100,4 +143,4 @@ export type SyncConnection = {
100
143
  * Pure (no WebSocket import) so it can be unit-tested with a fake `send`; the
101
144
  * Elysia `syncSocket` plugin is the thin adapter that feeds it socket events.
102
145
  */
103
- export declare const createSyncConnection: ({ engine, ctx, send, presence }: SyncConnectionOptions) => SyncConnection;
146
+ export declare const createSyncConnection: ({ engine, ctx, send: rawSend, presence, serializer }: SyncConnectionOptions) => SyncConnection;
@@ -1356,6 +1356,32 @@ class UnauthorizedError extends Error {
1356
1356
  }
1357
1357
  }
1358
1358
 
1359
+ class AbortError extends Error {
1360
+ constructor(reason) {
1361
+ super(reason ?? "Aborted");
1362
+ this.name = "AbortError";
1363
+ }
1364
+ }
1365
+ var checkAborted = (signal) => {
1366
+ if (signal?.aborted) {
1367
+ throw new AbortError(signal.reason instanceof Error ? signal.reason.message : typeof signal.reason === "string" ? signal.reason : "Aborted");
1368
+ }
1369
+ };
1370
+ var linkAbortToUnsubscribe = (signal, unsubscribe) => {
1371
+ if (signal === undefined)
1372
+ return;
1373
+ if (signal.aborted) {
1374
+ unsubscribe();
1375
+ return;
1376
+ }
1377
+ const handler = () => {
1378
+ try {
1379
+ unsubscribe();
1380
+ } catch {}
1381
+ };
1382
+ signal.addEventListener("abort", handler, { once: true });
1383
+ };
1384
+
1359
1385
  class SchemaError extends Error {
1360
1386
  constructor(table, fieldName) {
1361
1387
  super(`Schema violation on "${table}": invalid field "${fieldName}"`);
@@ -2218,29 +2244,35 @@ var createSyncEngine = (options = {}) => {
2218
2244
  registerSearch: (collection) => {
2219
2245
  registry.set(collection.name, collection);
2220
2246
  },
2221
- subscribe: async ({ collection, params, ctx, onDiff, since }) => {
2247
+ subscribe: async ({ collection, params, ctx, onDiff, since, signal }) => {
2248
+ checkAborted(signal);
2222
2249
  const registered = registry.get(collection);
2223
2250
  if (registered === undefined) {
2224
2251
  throw new Error(`Unknown collection "${collection}"`);
2225
2252
  }
2226
2253
  const typedOnDiff = onDiff;
2227
2254
  const subscribeSet = subsFor(collection);
2255
+ const wrapReturn = (sub) => {
2256
+ checkAborted(signal);
2257
+ linkAbortToUnsubscribe(signal, sub.unsubscribe);
2258
+ return sub;
2259
+ };
2228
2260
  const registeredKind = registered.kind;
2229
2261
  if (registeredKind === "join") {
2230
2262
  const joined = await subscribeJoin(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2231
- return joined;
2263
+ return wrapReturn(joined);
2232
2264
  }
2233
2265
  if (registeredKind === "graph") {
2234
2266
  const graphed = await subscribeGraph(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2235
- return graphed;
2267
+ return wrapReturn(graphed);
2236
2268
  }
2237
2269
  if (registeredKind === "reactive") {
2238
2270
  const reactived = await subscribeReactive(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2239
- return reactived;
2271
+ return wrapReturn(reactived);
2240
2272
  }
2241
2273
  if (registeredKind === "search") {
2242
2274
  const searched = await subscribeSearch(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2243
- return searched;
2275
+ return wrapReturn(searched);
2244
2276
  }
2245
2277
  const definition = registered;
2246
2278
  if (definition.authorize !== undefined) {
@@ -2282,31 +2314,35 @@ var createSyncEngine = (options = {}) => {
2282
2314
  subscribeSet.delete(subscription);
2283
2315
  };
2284
2316
  if (resuming) {
2285
- return {
2317
+ return wrapReturn({
2286
2318
  initial: [],
2287
2319
  catchup: buildCatchup(since, tables, key, boundMatch),
2288
2320
  version: atVersion,
2289
2321
  unsubscribe
2290
- };
2322
+ });
2291
2323
  }
2292
- return {
2324
+ return wrapReturn({
2293
2325
  initial: view.rows(),
2294
2326
  version: atVersion,
2295
2327
  unsubscribe
2296
- };
2328
+ });
2297
2329
  },
2298
- hydrate: async (collection, params, ctx) => {
2330
+ hydrate: async (collection, params, ctx, options2) => {
2331
+ const signal = options2?.signal;
2332
+ checkAborted(signal);
2299
2333
  const definition = registry.get(collection);
2300
2334
  if (definition === undefined) {
2301
2335
  throw new Error(`Unknown collection "${collection}"`);
2302
2336
  }
2303
2337
  if (definition.authorize !== undefined) {
2304
2338
  const allowed = await definition.authorize(params, ctx);
2339
+ checkAborted(signal);
2305
2340
  if (!allowed) {
2306
2341
  throw new UnauthorizedError(`hydrate collection "${collection}"`);
2307
2342
  }
2308
2343
  }
2309
2344
  const raw = [...await definition.hydrate(params, ctx)];
2345
+ checkAborted(signal);
2310
2346
  const tables = definition.tables ?? [collection];
2311
2347
  const scopedTable = tables.length === 1 ? tables[0] : undefined;
2312
2348
  const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
@@ -2953,15 +2989,43 @@ var mutateRoute = (engine, mutation, resolveContext = emptyContext) => {
2953
2989
  return result;
2954
2990
  };
2955
2991
  };
2992
+ // src/serializer.ts
2993
+ var jsonSerializer = {
2994
+ decode: (raw) => {
2995
+ if (typeof raw === "string") {
2996
+ try {
2997
+ return JSON.parse(raw);
2998
+ } catch {
2999
+ return null;
3000
+ }
3001
+ }
3002
+ if (raw instanceof Uint8Array) {
3003
+ try {
3004
+ return JSON.parse(new TextDecoder().decode(raw));
3005
+ } catch {
3006
+ return null;
3007
+ }
3008
+ }
3009
+ if (raw instanceof ArrayBuffer) {
3010
+ try {
3011
+ return JSON.parse(new TextDecoder().decode(new Uint8Array(raw)));
3012
+ } catch {
3013
+ return null;
3014
+ }
3015
+ }
3016
+ return raw;
3017
+ },
3018
+ encodeClient: (frame) => JSON.stringify(frame),
3019
+ encodeServer: (frame) => JSON.stringify(frame)
3020
+ };
3021
+
2956
3022
  // src/engine/connection.ts
2957
- var parseFrame = (raw) => {
3023
+ var parseFrame = (raw, serializer) => {
2958
3024
  let value = raw;
2959
- if (typeof value === "string") {
2960
- try {
2961
- value = JSON.parse(value);
2962
- } catch {
3025
+ if (typeof value === "string" || value instanceof Uint8Array || value instanceof ArrayBuffer) {
3026
+ value = serializer.decode(raw);
3027
+ if (value === null)
2963
3028
  return;
2964
- }
2965
3029
  }
2966
3030
  if (typeof value !== "object" || value === null) {
2967
3031
  return;
@@ -3006,11 +3070,23 @@ var parseFrame = (raw) => {
3006
3070
  var createSyncConnection = ({
3007
3071
  engine,
3008
3072
  ctx,
3009
- send,
3010
- presence
3073
+ send: rawSend,
3074
+ presence,
3075
+ serializer = jsonSerializer
3011
3076
  }) => {
3012
3077
  const subscriptions = new Map;
3013
3078
  const presenceRooms = new Map;
3079
+ let framesSent = 0;
3080
+ let slowSendsRecent = 0;
3081
+ const send = (frame) => {
3082
+ const ret = rawSend(frame);
3083
+ if (ret === -1) {
3084
+ slowSendsRecent += 1;
3085
+ } else {
3086
+ framesSent += 1;
3087
+ slowSendsRecent = 0;
3088
+ }
3089
+ };
3014
3090
  let pending = [];
3015
3091
  let pendingVersion;
3016
3092
  let flushScheduled = false;
@@ -3055,7 +3131,7 @@ var createSyncConnection = ({
3055
3131
  scheduleFlush();
3056
3132
  };
3057
3133
  const handle = async (raw) => {
3058
- const frame = parseFrame(raw);
3134
+ const frame = parseFrame(raw, serializer);
3059
3135
  if (frame === undefined) {
3060
3136
  send({ type: "error", message: "Malformed sync frame" });
3061
3137
  return;
@@ -3172,7 +3248,13 @@ var createSyncConnection = ({
3172
3248
  }
3173
3249
  presenceRooms.clear();
3174
3250
  };
3175
- return { handle, close };
3251
+ const stats = () => ({
3252
+ framesSent,
3253
+ presenceRoomCount: presenceRooms.size,
3254
+ slowSendsRecent,
3255
+ subscriptionCount: subscriptions.size
3256
+ });
3257
+ return { close, handle, stats };
3176
3258
  };
3177
3259
  export {
3178
3260
  syncCdc,
@@ -3222,5 +3304,5 @@ export {
3222
3304
  CdcConsumerSlowError
3223
3305
  };
3224
3306
 
3225
- //# debugId=61A7A6BDD6B4D12064756E2164756E21
3307
+ //# debugId=2B8B686DC5E953C364756E2164756E21
3226
3308
  //# sourceMappingURL=index.js.map