@lemoncloud/chatic-sockets-lib 0.2.0 → 0.3.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.
Files changed (66) hide show
  1. package/dist/client-socket-v2/create-client-socket-v2.js +5 -0
  2. package/dist/client-socket-v2/gateways/auth-gateway.d.ts +6 -0
  3. package/dist/client-socket-v2/gateways/auth-gateway.js +11 -0
  4. package/dist/client-socket-v2/gateways/channel-gateway.d.ts +38 -0
  5. package/dist/client-socket-v2/gateways/channel-gateway.js +24 -0
  6. package/dist/client-socket-v2/gateways/chat-gateway.d.ts +22 -0
  7. package/dist/client-socket-v2/gateways/chat-gateway.js +16 -0
  8. package/dist/client-socket-v2/gateways/cloud-gateway.d.ts +18 -0
  9. package/dist/client-socket-v2/gateways/cloud-gateway.js +14 -0
  10. package/dist/client-socket-v2/gateways/place-gateway.d.ts +18 -0
  11. package/dist/client-socket-v2/gateways/place-gateway.js +14 -0
  12. package/dist/client-socket-v2/gateways/profile-gateway.d.ts +18 -0
  13. package/dist/client-socket-v2/gateways/profile-gateway.js +14 -0
  14. package/dist/client-socket-v2/gateways/user-gateway.d.ts +38 -0
  15. package/dist/client-socket-v2/gateways/user-gateway.js +18 -0
  16. package/dist/client-socket-v2/index.d.ts +13 -0
  17. package/dist/client-socket-v2/index.js +12 -0
  18. package/dist/client-socket-v2/plans/channel-sync-plan.d.ts +33 -0
  19. package/dist/client-socket-v2/plans/channel-sync-plan.js +66 -0
  20. package/dist/client-socket-v2/plans/device-sync-plan.d.ts +5 -1
  21. package/dist/client-socket-v2/plans/device-sync-plan.js +6 -3
  22. package/dist/client-socket-v2/plans/place-sync-plan.d.ts +33 -0
  23. package/dist/client-socket-v2/plans/place-sync-plan.js +67 -0
  24. package/dist/client-socket-v2/plans/profile-sync-plan.d.ts +33 -0
  25. package/dist/client-socket-v2/plans/profile-sync-plan.js +67 -0
  26. package/dist/client-socket-v2/socket-runtime.d.ts +5 -1
  27. package/dist/client-socket-v2/socket-runtime.js +5 -1
  28. package/dist/client-socket-v2/socket-transport.d.ts +14 -4
  29. package/dist/client-socket-v2/socket-transport.js +58 -60
  30. package/dist/client-socket-v2/sync-scheduler.d.ts +15 -1
  31. package/dist/client-socket-v2/sync-scheduler.js +89 -12
  32. package/dist/client-socket-v2/types.d.ts +38 -7
  33. package/dist/lib/channel/types.d.ts +191 -0
  34. package/dist/lib/channel/types.js +2 -0
  35. package/dist/lib/chat/types.d.ts +79 -0
  36. package/dist/lib/chat/types.js +2 -0
  37. package/dist/lib/cloud/types.d.ts +54 -0
  38. package/dist/lib/cloud/types.js +2 -0
  39. package/dist/lib/device/contracts.d.ts +2 -0
  40. package/dist/lib/device/types.d.ts +1 -0
  41. package/dist/lib/place/types.d.ts +53 -0
  42. package/dist/lib/place/types.js +2 -0
  43. package/dist/lib/profile/types.d.ts +55 -0
  44. package/dist/lib/profile/types.js +2 -0
  45. package/dist/lib/socket-actions.d.ts +291 -0
  46. package/dist/lib/socket-actions.js +209 -0
  47. package/dist/lib/socket-inputs.d.ts +15 -0
  48. package/dist/lib/socket-inputs.js +8 -0
  49. package/dist/lib/sockets/types.d.ts +27 -0
  50. package/dist/lib/sockets/types.js +17 -0
  51. package/dist/lib/types.d.ts +2 -0
  52. package/dist/lib/user/types.d.ts +102 -0
  53. package/dist/lib/user/types.js +2 -0
  54. package/dist/modules/chat/model.d.ts +107 -0
  55. package/dist/modules/chat/model.js +28 -0
  56. package/dist/modules/chat/types.d.ts +65 -0
  57. package/dist/modules/chat/types.js +55 -0
  58. package/dist/modules/chat/views.d.ts +50 -0
  59. package/dist/modules/chat/views.js +23 -0
  60. package/dist/modules/sockets/model.d.ts +204 -0
  61. package/dist/modules/sockets/model.js +28 -0
  62. package/dist/modules/sockets/types.d.ts +88 -0
  63. package/dist/modules/sockets/types.js +78 -0
  64. package/dist/modules/sockets/views.d.ts +67 -0
  65. package/dist/modules/sockets/views.js +23 -0
  66. package/package.json +4 -2
@@ -0,0 +1,33 @@
1
+ import type { ProfileView } from '@lemoncloud/chatic-socials-api';
2
+ import type { SocketMessage } from '../../lib/types';
3
+ import type { DomainSyncContext, DomainSyncPlan, SyncBackoffOptions, SyncFailureInfo, SyncTargetDescriptor } from '../types';
4
+ export interface ProfileSyncTarget extends SyncTargetDescriptor {
5
+ type: 'profile';
6
+ }
7
+ export interface ProfileSyncSnapshot {
8
+ id?: string;
9
+ updatedAt?: number;
10
+ view?: ProfileView;
11
+ }
12
+ export interface ProfileSyncPlanOptions {
13
+ intervalMs?: number;
14
+ idleBackoff?: SyncBackoffOptions;
15
+ resetSnapshotOnConnected?: boolean;
16
+ onUpdate?: (target: ProfileSyncTarget, view: ProfileView, previous?: ProfileSyncSnapshot) => void;
17
+ onRemove?: (target: ProfileSyncTarget, previous?: ProfileSyncSnapshot) => void;
18
+ }
19
+ /** Single-profile sync: polls `profile.get`, applies on `updatedAt` change, re-pulls on a `profile.sync` nudge. */
20
+ export declare class ProfileSyncPlan implements DomainSyncPlan<ProfileSyncTarget> {
21
+ private readonly options;
22
+ readonly domain = "profile";
23
+ readonly idleBackoff: SyncBackoffOptions;
24
+ constructor(options?: ProfileSyncPlanOptions);
25
+ supports: (target: SyncTargetDescriptor) => target is ProfileSyncTarget;
26
+ getKey: (target: ProfileSyncTarget) => string;
27
+ getIntervalMs: (target: ProfileSyncTarget) => number;
28
+ onConnected: (target: ProfileSyncTarget, ctx: DomainSyncContext) => void;
29
+ run: (target: ProfileSyncTarget, ctx: DomainSyncContext) => Promise<void>;
30
+ onTrigger: (target: ProfileSyncTarget, message: SocketMessage<any>, ctx: DomainSyncContext) => Promise<void>;
31
+ onStopped: (target: ProfileSyncTarget, _info: SyncFailureInfo, ctx: DomainSyncContext) => void;
32
+ updateLocalState: (target: ProfileSyncTarget, snapshot: unknown, ctx: DomainSyncContext) => void;
33
+ }
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.ProfileSyncPlan = void 0;
13
+ /** Single-profile sync: polls `profile.get`, applies on `updatedAt` change, re-pulls on a `profile.sync` nudge. */
14
+ class ProfileSyncPlan {
15
+ constructor(options = {}) {
16
+ var _a;
17
+ this.options = options;
18
+ this.domain = 'profile';
19
+ this.supports = (target) => (target === null || target === void 0 ? void 0 : target.type) === 'profile';
20
+ this.getKey = (target) => { var _a; return `profile:${(_a = target === null || target === void 0 ? void 0 : target.id) !== null && _a !== void 0 ? _a : ''}`; };
21
+ this.getIntervalMs = (target) => { var _a, _b; return (_b = (_a = target.intervalMs) !== null && _a !== void 0 ? _a : this.options.intervalMs) !== null && _b !== void 0 ? _b : 2000; };
22
+ this.onConnected = (target, ctx) => {
23
+ if (this.options.resetSnapshotOnConnected === false)
24
+ return;
25
+ ctx.writeSnapshot(target, undefined);
26
+ };
27
+ this.run = (target, ctx) => __awaiter(this, void 0, void 0, function* () {
28
+ var _b, _c, _d, _e;
29
+ if (!target.id)
30
+ return;
31
+ const prev = ctx.readSnapshot(target);
32
+ const input = { id: target.id };
33
+ // profile.get 응답은 socials 소유(registry response=unknown) → ProfileView 로 단언
34
+ const view = (yield ctx.client.request('profile.get', input));
35
+ const nextUpdatedAt = typeof (view === null || view === void 0 ? void 0 : view.updatedAt) === 'number' ? view.updatedAt : undefined;
36
+ const prevUpdatedAt = typeof (prev === null || prev === void 0 ? void 0 : prev.updatedAt) === 'number' ? prev.updatedAt : undefined;
37
+ if (prevUpdatedAt === nextUpdatedAt && (prev === null || prev === void 0 ? void 0 : prev.view))
38
+ return;
39
+ const next = {
40
+ id: `${(_c = (_b = view === null || view === void 0 ? void 0 : view.id) !== null && _b !== void 0 ? _b : target.id) !== null && _c !== void 0 ? _c : ''}` || undefined,
41
+ updatedAt: nextUpdatedAt,
42
+ view,
43
+ };
44
+ ctx.writeSnapshot(target, next);
45
+ (_e = (_d = this.options).onUpdate) === null || _e === void 0 ? void 0 : _e.call(_d, target, view, prev);
46
+ });
47
+ this.onTrigger = (target, message, ctx) => __awaiter(this, void 0, void 0, function* () {
48
+ const data = ((message === null || message === void 0 ? void 0 : message.data) || {});
49
+ if (target.id && (data === null || data === void 0 ? void 0 : data.id) && target.id !== data.id)
50
+ return;
51
+ yield this.run(target, ctx);
52
+ });
53
+ this.onStopped = (target, _info, ctx) => {
54
+ var _a, _b;
55
+ const prev = ctx.readSnapshot(target);
56
+ (_b = (_a = this.options).onRemove) === null || _b === void 0 ? void 0 : _b.call(_a, target, prev);
57
+ };
58
+ this.updateLocalState = (target, snapshot, ctx) => {
59
+ var _a, _b;
60
+ const prev = ctx.readSnapshot(target);
61
+ const patch = (snapshot || {});
62
+ ctx.writeSnapshot(target, Object.assign(Object.assign(Object.assign({}, prev), patch), { view: (_a = patch.view) !== null && _a !== void 0 ? _a : prev === null || prev === void 0 ? void 0 : prev.view, updatedAt: (_b = patch.updatedAt) !== null && _b !== void 0 ? _b : prev === null || prev === void 0 ? void 0 : prev.updatedAt }));
63
+ };
64
+ this.idleBackoff = (_a = options.idleBackoff) !== null && _a !== void 0 ? _a : { factor: 2, maxMs: 60000 };
65
+ }
66
+ }
67
+ exports.ProfileSyncPlan = ProfileSyncPlan;
@@ -1,7 +1,8 @@
1
1
  import { AutoReconnectController } from './reconnect-controller';
2
2
  import { ConnectionRotationController } from './connection-rotation-controller';
3
3
  import { KeepAliveLoop } from './keep-alive-loop';
4
- import type { ClientSocketRuntime, ClientSocketV2, DomainSyncPlan, KeepAliveLoopControl, ReconnectController, SharedTimerScheduler, SyncScheduler, SyncTargetDescriptor } from './types';
4
+ import { type SyncBackoffOptions } from './sync-scheduler';
5
+ import type { ClientSocketRuntime, ClientSocketV2, DomainSyncPlan, KeepAliveLoopControl, ReconnectController, SharedTimerScheduler, SyncFailurePolicy, SyncScheduler, SyncTargetDescriptor } from './types';
5
6
  export interface ClientSocketRuntimeOptions {
6
7
  client: ClientSocketV2;
7
8
  scheduler?: SyncScheduler;
@@ -10,6 +11,9 @@ export interface ClientSocketRuntimeOptions {
10
11
  rotation?: ConnectionRotationController;
11
12
  timerScheduler?: SharedTimerScheduler;
12
13
  syncPlans?: DomainSyncPlan<any>[];
14
+ syncBackoff?: SyncBackoffOptions;
15
+ syncIdleBackoff?: SyncBackoffOptions;
16
+ failurePolicy?: SyncFailurePolicy;
13
17
  keepAliveOptions?: Omit<ConstructorParameters<typeof KeepAliveLoop>[0], 'client' | 'timerScheduler'>;
14
18
  reconnectOptions?: Omit<ConstructorParameters<typeof AutoReconnectController>[0], 'client'>;
15
19
  rotationOptions?: Omit<ConstructorParameters<typeof ConnectionRotationController>[0], 'client' | 'reconnect'>;
@@ -34,12 +34,16 @@ class SocketRuntime {
34
34
  this.stopAllSync = () => this.scheduler.stopAll();
35
35
  this.listSyncTargets = () => this.scheduler.list();
36
36
  this.updateLocalSnapshot = (target, snapshot) => this.scheduler.updateLocalSnapshot(target, snapshot);
37
- this.timerScheduler = (_b = (_a = options.timerScheduler) !== null && _a !== void 0 ? _a : options.client.timerScheduler) !== null && _b !== void 0 ? _b : new shared_timer_scheduler_1.InMemorySharedTimerScheduler();
37
+ this.timerScheduler =
38
+ (_b = (_a = options.timerScheduler) !== null && _a !== void 0 ? _a : options.client.timerScheduler) !== null && _b !== void 0 ? _b : new shared_timer_scheduler_1.InMemorySharedTimerScheduler();
38
39
  this.scheduler =
39
40
  (_c = options.scheduler) !== null && _c !== void 0 ? _c : new sync_scheduler_1.DomainSyncScheduler({
40
41
  client: options.client,
41
42
  plans: (_d = options.syncPlans) !== null && _d !== void 0 ? _d : [],
42
43
  timerScheduler: this.timerScheduler,
44
+ backoff: options.syncBackoff,
45
+ idleBackoff: options.syncIdleBackoff,
46
+ failurePolicy: options.failurePolicy,
43
47
  });
44
48
  this.keepAlive =
45
49
  (_e = options.keepAlive) !== null && _e !== void 0 ? _e : (options.keepAliveOptions
@@ -3,13 +3,21 @@ export interface WebSocketTransportOptions {
3
3
  /** connect() open 이벤트 대기 timeout. 만료 시 socket close + Promise reject. 0이면 무한 대기. 기본 10000 */
4
4
  connectTimeoutMs?: number;
5
5
  }
6
+ /**
7
+ * `SocketTransport` adapter over lemon-model's owned WebSocket network.
8
+ *
9
+ * Raw network concerns (socket creation, OPEN send guard, actual close, raw event mapping) are owned by
10
+ * `OwnedWebSocketNetwork`. This adapter only translates that single-shot network into chatic's reconnectable,
11
+ * code-carrying, 4-event / 5-state lifecycle. The connect timeout stays here because it belongs to the
12
+ * reconnect/backoff policy, not the raw transport.
13
+ */
6
14
  export declare class WebSocketTransport implements SocketTransport {
7
15
  readonly url: string;
8
16
  readonly protocols?: string | string[];
9
- private readonly socketFactory;
17
+ private readonly socketFactory?;
10
18
  private _state;
11
- private socket?;
12
- private unbinders;
19
+ private network?;
20
+ private networkUnsubs;
13
21
  private readonly listeners;
14
22
  private readonly connectTimeoutMs;
15
23
  private connecting?;
@@ -21,5 +29,7 @@ export declare class WebSocketTransport implements SocketTransport {
21
29
  disconnect: (code?: number, reason?: string) => Promise<void>;
22
30
  send: (raw: string) => void;
23
31
  private emit;
24
- private cleanupSocket;
32
+ /** bridge chatic's `SocketLike` factory to the owned network's `WebSocketClosable` factory */
33
+ private buildSocketFactory;
34
+ private cleanupNetwork;
25
35
  }
@@ -10,30 +10,24 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.WebSocketTransport = void 0;
13
- const common_1 = require("./common");
14
- const OPEN_STATE = 1;
15
- const bindSocketListener = (socket, type, listener) => {
16
- if (socket.addEventListener) {
17
- socket.addEventListener(type, listener);
18
- return () => { var _a; return (_a = socket.removeEventListener) === null || _a === void 0 ? void 0 : _a.call(socket, type, listener); };
19
- }
20
- const key = `on${type}`;
21
- const prev = socket[key];
22
- socket[key] = listener;
23
- return () => {
24
- if (socket[key] === listener)
25
- socket[key] = prev !== null && prev !== void 0 ? prev : null;
26
- };
27
- };
13
+ const lemon_model_1 = require("lemon-model");
28
14
  const DEFAULT_CONNECT_TIMEOUT_MS = 10000;
15
+ /**
16
+ * `SocketTransport` adapter over lemon-model's owned WebSocket network.
17
+ *
18
+ * Raw network concerns (socket creation, OPEN send guard, actual close, raw event mapping) are owned by
19
+ * `OwnedWebSocketNetwork`. This adapter only translates that single-shot network into chatic's reconnectable,
20
+ * code-carrying, 4-event / 5-state lifecycle. The connect timeout stays here because it belongs to the
21
+ * reconnect/backoff policy, not the raw transport.
22
+ */
29
23
  class WebSocketTransport {
30
- constructor(url, protocols, socketFactory = context => new globalThis.WebSocket(context.url, context.protocols), options) {
24
+ constructor(url, protocols, socketFactory, options) {
31
25
  var _a;
32
26
  this.url = url;
33
27
  this.protocols = protocols;
34
28
  this.socketFactory = socketFactory;
35
29
  this._state = 'idle';
36
- this.unbinders = [];
30
+ this.networkUnsubs = [];
37
31
  this.listeners = new Map();
38
32
  this.on = (type, listener) => {
39
33
  var _a;
@@ -51,17 +45,23 @@ class WebSocketTransport {
51
45
  return;
52
46
  if (this.connecting)
53
47
  return this.connecting;
54
- this.cleanupSocket();
48
+ this.cleanupNetwork();
55
49
  this._state = 'connecting';
56
- const socket = this.socketFactory({ url: this.url, protocols: this.protocols });
57
- this.socket = socket;
50
+ const network = (0, lemon_model_1.createOwnedWebSocketNetwork)({
51
+ url: this.url,
52
+ protocols: this.protocols,
53
+ socketFactory: this.buildSocketFactory(),
54
+ connectTimeoutMs: 0, // connect timeout is owned here (reconnect policy), not by the network
55
+ });
56
+ this.network = network;
58
57
  this.connecting = new Promise((resolve, reject) => {
59
- let connectSettled = false;
58
+ var _a;
59
+ let settled = false;
60
60
  let timeoutTimer;
61
61
  const settleConnect = (handler) => {
62
- if (connectSettled)
62
+ if (settled)
63
63
  return;
64
- connectSettled = true;
64
+ settled = true;
65
65
  if (timeoutTimer)
66
66
  clearTimeout(timeoutTimer);
67
67
  this.connecting = undefined;
@@ -71,75 +71,73 @@ class WebSocketTransport {
71
71
  this.cancelConnecting = error => settleConnect(() => reject(error));
72
72
  if (this.connectTimeoutMs > 0) {
73
73
  timeoutTimer = setTimeout(() => {
74
- if (connectSettled)
75
- return;
76
74
  const error = new Error(`408 CONNECT TIMEOUT - WebSocketTransport.connect() after ${this.connectTimeoutMs}ms`);
77
75
  settleConnect(() => reject(error));
78
- try {
79
- socket.close(1000, 'connect-timeout');
80
- }
81
- catch (_a) {
82
- /** ignore — best-effort cleanup */
83
- }
76
+ network.close(1000, 'connect-timeout');
84
77
  }, this.connectTimeoutMs);
85
78
  }
86
- this.unbinders = [
87
- bindSocketListener(socket, 'open', () => {
88
- this._state = 'connected';
89
- this.emit('open', undefined);
90
- settleConnect(() => resolve());
91
- }),
92
- bindSocketListener(socket, 'close', event => {
93
- this._state = 'closed';
94
- this.emit('close', {
95
- code: event === null || event === void 0 ? void 0 : event.code,
96
- reason: event === null || event === void 0 ? void 0 : event.reason,
97
- wasClean: event === null || event === void 0 ? void 0 : event.wasClean,
98
- });
99
- settleConnect(() => resolve());
100
- }),
101
- bindSocketListener(socket, 'error', event => {
79
+ const unsubOpen = (_a = network.onOpen) === null || _a === void 0 ? void 0 : _a.call(network, () => {
80
+ this._state = 'connected';
81
+ this.emit('open', undefined);
82
+ settleConnect(() => resolve());
83
+ });
84
+ this.networkUnsubs = [
85
+ network.onMessage(raw => this.emit('message', { data: raw })),
86
+ network.onError((event, context) => {
102
87
  var _a;
103
- const error = (_a = event === null || event === void 0 ? void 0 : event.error) !== null && _a !== void 0 ? _a : new Error(`socket transport error`);
88
+ if (context.scope === 'ownedWebSocket.close') {
89
+ const close = event;
90
+ this._state = 'closed';
91
+ this.emit('close', { code: close === null || close === void 0 ? void 0 : close.code, reason: close === null || close === void 0 ? void 0 : close.reason, wasClean: close === null || close === void 0 ? void 0 : close.wasClean });
92
+ settleConnect(() => resolve()); // close-before-open resolves connect (reconnect drives off state)
93
+ return;
94
+ }
95
+ const error = (_a = event === null || event === void 0 ? void 0 : event.error) !== null && _a !== void 0 ? _a : (event instanceof Error ? event : new Error(`socket transport error`));
104
96
  this.emit('error', { error });
105
97
  if (this._state !== 'connected')
106
98
  this._state = 'closed';
107
99
  settleConnect(() => reject(error));
108
100
  }),
109
- bindSocketListener(socket, 'message', event => {
110
- this.emit('message', { data: (0, common_1.asString)(event === null || event === void 0 ? void 0 : event.data, '') });
111
- }),
112
101
  ];
102
+ if (unsubOpen)
103
+ this.networkUnsubs.push(unsubOpen);
113
104
  });
114
105
  return this.connecting;
115
106
  });
116
107
  this.disconnect = (code, reason) => __awaiter(this, void 0, void 0, function* () {
117
108
  var _b;
118
- if (!this.socket) {
109
+ if (!this.network) {
119
110
  this._state = 'closed';
120
111
  return;
121
112
  }
122
113
  this._state = 'closing';
123
- const socket = this.socket;
124
- socket.close(code, reason);
114
+ const network = this.network;
115
+ network.close(code, reason);
125
116
  (_b = this.cancelConnecting) === null || _b === void 0 ? void 0 : _b.call(this, new Error(`499 CLIENT CLOSED REQUEST - disconnect during connecting`));
126
- this.cleanupSocket();
117
+ this.cleanupNetwork();
127
118
  this._state = 'closed';
128
119
  this.emit('close', { code, reason, wasClean: true });
129
120
  });
130
121
  this.send = (raw) => {
131
- if (!this.socket || this.socket.readyState !== undefined && this.socket.readyState !== OPEN_STATE) {
122
+ if (!this.network || this.network.readyState !== 'open') {
132
123
  throw new Error(`503 SOCKET NOT CONNECTED - WebSocketTransport.send()`);
133
124
  }
134
- this.socket.send(raw);
125
+ this.network.send(raw);
135
126
  };
136
127
  this.emit = (type, event) => {
137
128
  var _a;
138
129
  (_a = this.listeners.get(type)) === null || _a === void 0 ? void 0 : _a.forEach(listener => listener(event));
139
130
  };
140
- this.cleanupSocket = () => {
141
- this.unbinders.splice(0).forEach(unbind => unbind());
142
- this.socket = undefined;
131
+ /** bridge chatic's `SocketLike` factory to the owned network's `WebSocketClosable` factory */
132
+ this.buildSocketFactory = () => {
133
+ const factory = this.socketFactory;
134
+ if (!factory)
135
+ return undefined;
136
+ return context => factory({ url: context.url, protocols: context.protocols });
137
+ };
138
+ this.cleanupNetwork = () => {
139
+ this.networkUnsubs.splice(0).forEach(unsub => unsub());
140
+ this.network = undefined;
143
141
  };
144
142
  this.connectTimeoutMs = (_a = options === null || options === void 0 ? void 0 : options.connectTimeoutMs) !== null && _a !== void 0 ? _a : DEFAULT_CONNECT_TIMEOUT_MS;
145
143
  }
@@ -1,10 +1,16 @@
1
- import type { ClientSocketV2, DomainSyncPlan, SharedTimerScheduler, SyncScheduler, SyncTargetDescriptor } from './types';
1
+ import type { ClientSocketV2, DomainSyncPlan, SharedTimerScheduler, SyncBackoffOptions, SyncFailurePolicy, SyncScheduler, SyncTargetDescriptor } from './types';
2
+ export type { SyncBackoffOptions } from './types';
2
3
  export interface SyncSchedulerOptions {
3
4
  client: ClientSocketV2;
4
5
  plans: DomainSyncPlan<any>[];
5
6
  now?: () => number;
6
7
  timerScheduler?: SharedTimerScheduler;
7
8
  timerPrefix?: string;
9
+ backoff?: SyncBackoffOptions;
10
+ idleBackoff?: SyncBackoffOptions;
11
+ failurePolicy?: SyncFailurePolicy;
12
+ jitterRatio?: number;
13
+ random?: () => number;
8
14
  }
9
15
  export declare class DomainSyncScheduler implements SyncScheduler {
10
16
  private readonly options;
@@ -14,6 +20,11 @@ export declare class DomainSyncScheduler implements SyncScheduler {
14
20
  private readonly now;
15
21
  private readonly timerScheduler?;
16
22
  private readonly timerPrefix;
23
+ private readonly backoff;
24
+ private readonly idleBackoff?;
25
+ private readonly failurePolicy;
26
+ private readonly jitterRatio;
27
+ private readonly random;
17
28
  private readonly unsubs;
18
29
  constructor(options: SyncSchedulerOptions);
19
30
  start: (target: SyncTargetDescriptor) => void;
@@ -30,7 +41,10 @@ export declare class DomainSyncScheduler implements SyncScheduler {
30
41
  private clearTimer;
31
42
  private scheduleNow;
32
43
  private scheduleNext;
44
+ private computeInterval;
45
+ private jitter;
33
46
  private toTimerKey;
34
47
  private runEntry;
48
+ private handleRunFailure;
35
49
  private handleTrigger;
36
50
  }
@@ -10,9 +10,16 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.DomainSyncScheduler = void 0;
13
+ const defaultClassify = (error) => {
14
+ var _a;
15
+ const msg = error instanceof Error ? error.message : `${error !== null && error !== void 0 ? error : ''}`;
16
+ const code = (_a = /^\s*(\d{3})\b/.exec(msg)) === null || _a === void 0 ? void 0 : _a[1];
17
+ return code === '403' || code === '404' ? 'gone' : 'transient';
18
+ };
19
+ const DEFAULT_IDLE_BACKOFF = { factor: 2, maxMs: 60000 };
13
20
  class DomainSyncScheduler {
14
21
  constructor(options) {
15
- var _a, _b;
22
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
16
23
  this.options = options;
17
24
  this.targets = new Map();
18
25
  this.snapshots = new Map();
@@ -95,12 +102,17 @@ class DomainSyncScheduler {
95
102
  const key = entry ? entry.plan.getKey(entry.target) : this.resolvePlan(target).getKey(target);
96
103
  yield this.runEntry(key);
97
104
  }),
105
+ stop: target => this.stop(target),
98
106
  });
99
107
  this.handleConnected = () => __awaiter(this, void 0, void 0, function* () {
100
- var _c, _d;
108
+ var _k, _l;
101
109
  const ctx = this.buildContext();
102
110
  for (const [key, entry] of this.targets.entries()) {
103
- yield ((_d = (_c = entry.plan).onConnected) === null || _d === void 0 ? void 0 : _d.call(_c, entry.target, ctx));
111
+ // reconnect = fresh start
112
+ entry.failures = 0;
113
+ entry.goneStreak = 0;
114
+ entry.idleStreak = 0;
115
+ yield ((_l = (_k = entry.plan).onConnected) === null || _l === void 0 ? void 0 : _l.call(_k, entry.target, ctx));
104
116
  this.scheduleNow(key);
105
117
  }
106
118
  });
@@ -133,13 +145,28 @@ class DomainSyncScheduler {
133
145
  this.clearTimer(entry);
134
146
  if (this.options.client.state !== 'connected')
135
147
  return;
136
- const intervalMs = (_d = (_a = entry.target.intervalMs) !== null && _a !== void 0 ? _a : (_c = (_b = entry.plan).getIntervalMs) === null || _c === void 0 ? void 0 : _c.call(_b, entry.target)) !== null && _d !== void 0 ? _d : 5000;
148
+ const baseMs = (_d = (_a = entry.target.intervalMs) !== null && _a !== void 0 ? _a : (_c = (_b = entry.plan).getIntervalMs) === null || _c === void 0 ? void 0 : _c.call(_b, entry.target)) !== null && _d !== void 0 ? _d : 5000;
149
+ const intervalMs = this.computeInterval(entry, baseMs);
137
150
  if (this.timerScheduler) {
138
151
  this.timerScheduler.schedule(this.toTimerKey(key), intervalMs, () => this.runEntry(key));
139
152
  return;
140
153
  }
141
154
  entry.timer = setTimeout(() => void this.runEntry(key), intervalMs);
142
155
  };
156
+ this.computeInterval = (entry, baseMs) => {
157
+ var _a, _b, _c, _d, _e, _f;
158
+ const failures = (_a = entry.failures) !== null && _a !== void 0 ? _a : 0;
159
+ if (failures > 0)
160
+ return this.jitter(Math.min(baseMs * Math.pow(this.backoff.factor, failures), this.backoff.maxMs));
161
+ const idle = (_c = (_b = entry.plan.idleBackoff) !== null && _b !== void 0 ? _b : this.idleBackoff) !== null && _c !== void 0 ? _c : DEFAULT_IDLE_BACKOFF;
162
+ const idleFactor = Math.max(1, (_d = idle === null || idle === void 0 ? void 0 : idle.factor) !== null && _d !== void 0 ? _d : 1);
163
+ const idleStreak = (_e = entry.idleStreak) !== null && _e !== void 0 ? _e : 0;
164
+ if (idleFactor > 1 && idleStreak > 0) {
165
+ return this.jitter(Math.min(baseMs * Math.pow(idleFactor, idleStreak), (_f = idle === null || idle === void 0 ? void 0 : idle.maxMs) !== null && _f !== void 0 ? _f : 60000));
166
+ }
167
+ return baseMs;
168
+ };
169
+ this.jitter = (ms) => this.jitterRatio > 0 ? Math.max(0, Math.round(ms * (1 + (this.random() * 2 - 1) * this.jitterRatio))) : ms;
143
170
  this.toTimerKey = (key) => `${this.timerPrefix}${key}`;
144
171
  this.runEntry = (key) => __awaiter(this, void 0, void 0, function* () {
145
172
  const entry = this.targets.get(key);
@@ -148,33 +175,83 @@ class DomainSyncScheduler {
148
175
  if (entry.inFlight)
149
176
  return;
150
177
  const ctx = this.buildContext();
151
- entry.inFlight = Promise.resolve(entry.plan.run(entry.target, ctx))
152
- .catch(() => undefined)
153
- .then(() => {
178
+ // no snapshot write during run = unchanged = idle
179
+ const before = this.snapshots.get(key);
180
+ entry.inFlight = Promise.resolve(entry.plan.run(entry.target, ctx)).then(() => {
181
+ var _a;
154
182
  entry.inFlight = undefined;
183
+ entry.failures = 0;
184
+ entry.goneStreak = 0;
185
+ const changed = this.snapshots.get(key) !== before;
186
+ entry.idleStreak = changed ? 0 : ((_a = entry.idleStreak) !== null && _a !== void 0 ? _a : 0) + 1;
155
187
  this.scheduleNext(key);
188
+ }, error => {
189
+ entry.inFlight = undefined;
190
+ this.handleRunFailure(key, error, ctx);
156
191
  });
157
192
  yield entry.inFlight;
158
193
  });
194
+ this.handleRunFailure = (key, error, ctx) => {
195
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
196
+ const entry = this.targets.get(key);
197
+ if (!entry)
198
+ return;
199
+ const plan = (_a = entry.plan.failurePolicy) !== null && _a !== void 0 ? _a : {};
200
+ const sched = this.failurePolicy;
201
+ const classify = (_c = (_b = plan.classify) !== null && _b !== void 0 ? _b : sched.classify) !== null && _c !== void 0 ? _c : defaultClassify;
202
+ const stopAfter = (_e = (_d = plan.stopAfter) !== null && _d !== void 0 ? _d : sched.stopAfter) !== null && _e !== void 0 ? _e : 2;
203
+ const decide = (_g = (_f = plan.decide) !== null && _f !== void 0 ? _f : sched.decide) !== null && _g !== void 0 ? _g : ((info) => (info.kind === 'gone' && info.goneStreak >= stopAfter ? 'stop' : 'retry'));
204
+ const kind = classify(error, entry.target);
205
+ entry.failures = ((_h = entry.failures) !== null && _h !== void 0 ? _h : 0) + 1;
206
+ entry.goneStreak = kind === 'gone' ? ((_j = entry.goneStreak) !== null && _j !== void 0 ? _j : 0) + 1 : 0;
207
+ entry.idleStreak = 0;
208
+ const info = {
209
+ target: Object.assign({}, entry.target),
210
+ error,
211
+ kind,
212
+ failures: entry.failures,
213
+ goneStreak: entry.goneStreak,
214
+ };
215
+ const decision = decide(info);
216
+ if (decision === 'stop') {
217
+ (_l = (_k = entry.plan).onStopped) === null || _l === void 0 ? void 0 : _l.call(_k, entry.target, info, ctx);
218
+ this.stop(entry.target);
219
+ return;
220
+ }
221
+ if (decision === 'keep') {
222
+ entry.failures = 0;
223
+ entry.goneStreak = 0;
224
+ }
225
+ this.scheduleNext(key);
226
+ };
159
227
  this.handleTrigger = (message) => __awaiter(this, void 0, void 0, function* () {
160
- var _e;
161
- const type = `${(_e = message === null || message === void 0 ? void 0 : message.type) !== null && _e !== void 0 ? _e : ''}`;
228
+ var _m;
229
+ const type = `${(_m = message === null || message === void 0 ? void 0 : message.type) !== null && _m !== void 0 ? _m : ''}`;
162
230
  if (!type.endsWith('.sync'))
163
231
  return;
164
- const targets = [...this.targets.values()];
232
+ const entries = [...this.targets.entries()];
165
233
  const ctx = this.buildContext();
166
- yield Promise.all(targets.map((entry) => __awaiter(this, void 0, void 0, function* () {
234
+ yield Promise.all(entries.map(([key, entry]) => __awaiter(this, void 0, void 0, function* () {
167
235
  if (!entry.plan.onTrigger)
168
236
  return;
169
237
  if (!type.startsWith(`${entry.target.type}.`))
170
238
  return;
171
- yield entry.plan.onTrigger(entry.target, message, ctx);
239
+ // nudge resets idle backoff (best-effort)
240
+ entry.idleStreak = 0;
241
+ yield entry.plan.onTrigger(entry.target, message, ctx).catch(() => undefined);
242
+ if (!entry.inFlight)
243
+ this.scheduleNext(key);
172
244
  })));
173
245
  });
174
246
  this.plans = [...(options.plans || [])];
175
247
  this.now = (_a = options.now) !== null && _a !== void 0 ? _a : (() => Date.now());
176
248
  this.timerScheduler = options.timerScheduler;
177
249
  this.timerPrefix = (_b = options.timerPrefix) !== null && _b !== void 0 ? _b : 'sync:';
250
+ this.backoff = { factor: Math.max(1, (_d = (_c = options.backoff) === null || _c === void 0 ? void 0 : _c.factor) !== null && _d !== void 0 ? _d : 2), maxMs: (_f = (_e = options.backoff) === null || _e === void 0 ? void 0 : _e.maxMs) !== null && _f !== void 0 ? _f : 30000 };
251
+ this.idleBackoff = options.idleBackoff;
252
+ this.failurePolicy = (_g = options.failurePolicy) !== null && _g !== void 0 ? _g : {};
253
+ this.jitterRatio = Math.max(0, (_h = options.jitterRatio) !== null && _h !== void 0 ? _h : 0.1);
254
+ this.random = (_j = options.random) !== null && _j !== void 0 ? _j : Math.random;
178
255
  this.unsubs.push(options.client.onState(event => {
179
256
  if (event.next === 'connected')
180
257
  void this.handleConnected();
@@ -2,20 +2,27 @@ import type { ResolveSocketPacketType, SocketMessage, SocketPacketInputType, Soc
2
2
  import type { DeviceBootstrapInput } from '../lib/device/types';
3
3
  export declare type ClientSocketState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
4
4
  export interface SocketLike {
5
- readonly readyState?: number;
5
+ readonly readyState: number;
6
6
  send(data: string): void;
7
7
  close(code?: number, reason?: string): void;
8
- addEventListener?(type: 'open' | 'close' | 'error' | 'message', listener: (event?: any) => void): void;
9
- removeEventListener?(type: 'open' | 'close' | 'error' | 'message', listener: (event?: any) => void): void;
10
- onopen?: ((event?: any) => void) | null;
11
- onclose?: ((event?: any) => void) | null;
12
- onerror?: ((event?: any) => void) | null;
13
- onmessage?: ((event?: any) => void) | null;
8
+ addEventListener(type: 'open' | 'close' | 'error' | 'message', listener: (event?: any) => void, options?: {
9
+ once?: boolean;
10
+ }): void;
11
+ removeEventListener(type: 'open' | 'close' | 'error' | 'message', listener: (event?: any) => void): void;
14
12
  }
15
13
  export interface SocketFactoryContext {
16
14
  url: string;
17
15
  protocols?: string | string[];
18
16
  }
17
+ /** context for the pre-parse raw ownership filter */
18
+ export interface ClientSocketInboundFilterContext {
19
+ raw: string;
20
+ }
21
+ /** context for the post-parse message ownership filter */
22
+ export interface ClientSocketMessageFilterContext<T = any> {
23
+ raw: string;
24
+ message: SocketMessage<T>;
25
+ }
19
26
  export interface SocketTransportEventMap {
20
27
  open: void;
21
28
  close: {
@@ -64,6 +71,8 @@ export interface ClientSocketOptions {
64
71
  protocols?: string | string[];
65
72
  device?: DeviceBootstrapInput | null;
66
73
  socketFactory?: (context: SocketFactoryContext) => SocketLike;
74
+ shouldHandleRaw?: (context: ClientSocketInboundFilterContext) => boolean;
75
+ shouldHandleMessage?: (context: ClientSocketMessageFilterContext) => boolean;
67
76
  aliases?: Partial<Record<SocketPacketInputType | string, SocketPacketType>>;
68
77
  timerScheduler?: SharedTimerScheduler;
69
78
  requestTimeoutMs?: number;
@@ -114,21 +123,43 @@ export interface SyncTargetDescriptor {
114
123
  intervalMs?: number;
115
124
  meta?: Record<string, unknown>;
116
125
  }
126
+ export declare type SyncFailureKind = 'gone' | 'transient';
127
+ export declare type SyncFailureDecision = 'stop' | 'keep' | 'retry';
128
+ export interface SyncFailureInfo {
129
+ target: SyncTargetDescriptor;
130
+ error: unknown;
131
+ kind: SyncFailureKind;
132
+ failures: number;
133
+ goneStreak: number;
134
+ }
135
+ export interface SyncFailurePolicy {
136
+ classify?(error: unknown, target: SyncTargetDescriptor): SyncFailureKind;
137
+ decide?(info: SyncFailureInfo): SyncFailureDecision;
138
+ stopAfter?: number;
139
+ }
140
+ export interface SyncBackoffOptions {
141
+ factor?: number;
142
+ maxMs?: number;
143
+ }
117
144
  export interface DomainSyncContext {
118
145
  client: ClientSocketV2;
119
146
  now(): number;
120
147
  readSnapshot<T = unknown>(target: SyncTargetDescriptor): T | undefined;
121
148
  writeSnapshot<T = unknown>(target: SyncTargetDescriptor, snapshot: T | undefined): void;
122
149
  requestResync(target: SyncTargetDescriptor): Promise<void>;
150
+ stop(target: SyncTargetDescriptor): void;
123
151
  }
124
152
  export interface DomainSyncPlan<TTarget extends SyncTargetDescriptor = SyncTargetDescriptor> {
125
153
  readonly domain: string;
154
+ readonly failurePolicy?: SyncFailurePolicy;
155
+ readonly idleBackoff?: SyncBackoffOptions;
126
156
  supports(target: SyncTargetDescriptor): target is TTarget;
127
157
  getKey(target: TTarget): string;
128
158
  getIntervalMs?(target: TTarget): number;
129
159
  onConnected?(target: TTarget, ctx: DomainSyncContext): Promise<void> | void;
130
160
  run(target: TTarget, ctx: DomainSyncContext): Promise<void>;
131
161
  onTrigger?(target: TTarget, message: SocketMessage<any>, ctx: DomainSyncContext): Promise<void>;
162
+ onStopped?(target: TTarget, info: SyncFailureInfo, ctx: DomainSyncContext): void;
132
163
  updateLocalState?(target: TTarget, snapshot: unknown, ctx: DomainSyncContext): void;
133
164
  }
134
165
  export interface SyncScheduler {