dynamodb-reactive 0.5.0 → 0.6.1

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.
@@ -11,6 +11,8 @@ import { HubSocket, HubPaths } from './hub.cjs';
11
11
  */
12
12
  /** Set by the route handler on requests it forwards to the hub; never trusted from clients. */
13
13
  declare const HUB_PRINCIPAL_HEADER = "x-reactive-principal";
14
+ /** Close code for a socket the presence sweep found silent. */
15
+ declare const HEARTBEAT_TIMEOUT_CLOSE_CODE = 4001;
14
16
  /** A hibernatable Workers WebSocket, as far as the hub needs it. */
15
17
  interface DurableObjectWebSocket extends HubSocket {
16
18
  close(code?: number, reason?: string): void;
@@ -25,6 +27,8 @@ interface DurableObjectStateLike {
25
27
  storage: {
26
28
  get<T>(key: string): Promise<T | undefined>;
27
29
  put<T>(key: string, value: T): Promise<void>;
30
+ /** Lets presence forget principals that left instead of storing `false`. */
31
+ delete?(key: string): Promise<boolean>;
28
32
  /** Needed for presence. */
29
33
  getAlarm?(): Promise<number | null>;
30
34
  /** Needed for presence. */
@@ -39,6 +43,10 @@ type WebSocketPairConstructor = new () => {
39
43
  0: DurableObjectWebSocket;
40
44
  1: DurableObjectWebSocket;
41
45
  };
46
+ /** A frame from the client that is not part of the protocol, such as `{ type: 'listening' }`. */
47
+ type HubClientFrame = Record<string, unknown> & {
48
+ type: string;
49
+ };
42
50
  interface DurableObjectHubOptions {
43
51
  paths: HubPaths;
44
52
  /** Storage key for a path's version. Defaults to `version:<path>`. */
@@ -50,17 +58,27 @@ interface DurableObjectHubOptions {
50
58
  */
51
59
  autoPong?: boolean;
52
60
  /**
53
- * Tracks whether these principals hold a live socket, so the hub can report a
54
- * worker as online without the worker polling over HTTP. Requires `autoPong`.
61
+ * Tracks whether principals hold a live socket, so the hub can report a
62
+ * worker or listener as online without it polling over HTTP. Requires `autoPong`.
55
63
  */
56
64
  presence?: HubPresenceOptions;
65
+ /** Runs after a socket is accepted. */
66
+ onOpen?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
67
+ /**
68
+ * Runs when a socket closes or errors. The socket may still be listed by
69
+ * `sockets()` during this call, so exclude it when counting.
70
+ */
71
+ onClose?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
72
+ /** Handles frames the protocol does not, such as application presence updates. */
73
+ onMessage?: (socket: DurableObjectWebSocket, principal: string, frame: HubClientFrame) => void | Promise<void>;
57
74
  /** Workers' `WebSocketPair`; injectable for tests. */
58
75
  WebSocketPair?: WebSocketPairConstructor;
59
76
  /** Creates a connection id; injectable for tests. */
60
77
  randomId?: () => string;
61
78
  }
62
79
  interface HubPresenceOptions {
63
- principals: readonly string[];
80
+ /** The principals to track, or a predicate for open-ended ones such as anonymous listeners. */
81
+ principals: readonly string[] | ((principal: string) => boolean);
64
82
  /**
65
83
  * A socket whose last `ping` is older than this is closed as dead, which is how
66
84
  * a peer that vanished without a close frame is noticed. Clients must ping more
@@ -77,7 +95,12 @@ interface HubPresenceOptions {
77
95
  /** Wires the hub core to a Durable Object's hibernatable WebSockets and storage. */
78
96
  declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: DurableObjectHubOptions): {
79
97
  hub: {
80
- message(socket: HubSocket, raw: string): void;
98
+ message(socket: HubSocket, raw: string): (Record<string, unknown> & {
99
+ type: string;
100
+ }) | undefined;
101
+ socketData<T = unknown>(socket: HubSocket): T | undefined;
102
+ setSocketData(socket: HubSocket, data: unknown): void;
103
+ principalOf(socket: HubSocket): string;
81
104
  subscribe(request: SubscribeRequest, principal: string): Promise<SnapshotResponse>;
82
105
  unsubscribe(request: UnsubscribeRequest, principal: string): void;
83
106
  notify(path: string): Promise<void>;
@@ -85,9 +108,17 @@ declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: Du
85
108
  };
86
109
  /** The Durable Object's `fetch`: WebSocket upgrades and actions from `hubClient`. */
87
110
  fetch(request: Request): Promise<Response>;
88
- webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): void;
111
+ webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): Promise<void>;
89
112
  webSocketClose(socket: DurableObjectWebSocket, code: number): Promise<void>;
90
113
  webSocketError(socket: DurableObjectWebSocket): Promise<void>;
114
+ /** Every accepted socket, including one whose close is being handled. */
115
+ sockets(): DurableObjectWebSocket[];
116
+ /** The principal a socket authenticated as. */
117
+ principalOf: (socket: DurableObjectWebSocket) => string;
118
+ /** Application data stored with a socket by `setSocketData`. */
119
+ socketData: <T = unknown>(socket: DurableObjectWebSocket) => T | undefined;
120
+ /** Stores application data with a socket; it survives hibernation and shares the 2 KB limit. */
121
+ setSocketData: (socket: DurableObjectWebSocket, data: unknown) => void;
91
122
  /** Whether `principal` holds a socket that has pinged within the presence timeout. */
92
123
  present(principal: string): boolean;
93
124
  /**
@@ -104,6 +135,22 @@ declare function hubClient(stub: HubStub): {
104
135
  notify: (path: string) => Promise<void>;
105
136
  publish: (path: string, patches: JsonPatch[]) => Promise<void>;
106
137
  };
138
+ interface RemoteHubOptions {
139
+ /** Absolute URL of the hub Worker's `routes.notify`. */
140
+ url: string;
141
+ /** The Worker's `notifySecret`. Keep it server-side. */
142
+ secret: string;
143
+ /** Injectable for tests. */
144
+ fetch?: typeof fetch;
145
+ }
146
+ /**
147
+ * Notifies a hub from another backend, such as an AWS Lambda or a Vercel route,
148
+ * after the data behind a path changes.
149
+ */
150
+ declare function remoteHub(options: RemoteHubOptions): {
151
+ notify: (path: string) => Promise<void>;
152
+ publish: (path: string, patches: JsonPatch[]) => Promise<void>;
153
+ };
107
154
  interface ReactiveRoutes {
108
155
  /** `POST` exchanges a credential for a signed ticket. */
109
156
  ticket: string;
@@ -111,19 +158,28 @@ interface ReactiveRoutes {
111
158
  socket: string;
112
159
  /** Reactive HTTP subscribe and unsubscribe, authenticated by the ticket as a bearer token. */
113
160
  reactive: string;
161
+ /** `POST { path, patches? }` from other backends, authenticated by `notifySecret`. */
162
+ notify?: string;
114
163
  }
115
164
  interface ReactiveRoutesOptions {
116
165
  routes: ReactiveRoutes;
117
166
  /** Ticket signing secret. Requests get 503 when it is not configured. */
118
167
  secret: string | undefined;
168
+ /** Bearer secret for `routes.notify`; the route answers 503 while it is unset. */
169
+ notifySecret?: string | undefined;
170
+ /**
171
+ * Browser origins allowed to call the ticket and reactive routes cross-origin,
172
+ * for a hub on a different host from the app.
173
+ */
174
+ allowedOrigins?: readonly string[];
119
175
  /** The hub Durable Object stub that serves these routes. */
120
176
  hub: () => HubStub;
121
177
  /** Returns the principal for a ticket request, or undefined to answer 401. */
122
178
  authenticate(request: Request): string | undefined | Promise<string | undefined>;
123
179
  ticketLifetimeSeconds?: number;
124
180
  }
125
- /** Serves the ticket, socket and reactive HTTP routes; `undefined` for any other path. */
181
+ /** Serves the ticket, socket, reactive and notify routes; `undefined` for any other path. */
126
182
  declare function handleReactiveRoutes(request: Request, options: ReactiveRoutesOptions): Promise<Response | undefined>;
127
183
 
128
- export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };
129
- export type { DurableObjectHub, DurableObjectHubOptions, DurableObjectStateLike, DurableObjectWebSocket, HubStub, ReactiveRoutes, ReactiveRoutesOptions };
184
+ export { HEARTBEAT_TIMEOUT_CLOSE_CODE, HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient, remoteHub };
185
+ export type { DurableObjectHub, DurableObjectHubOptions, DurableObjectStateLike, DurableObjectWebSocket, HubClientFrame, HubPresenceOptions, HubStub, ReactiveRoutes, ReactiveRoutesOptions, RemoteHubOptions };
@@ -11,6 +11,8 @@ import { HubSocket, HubPaths } from './hub.js';
11
11
  */
12
12
  /** Set by the route handler on requests it forwards to the hub; never trusted from clients. */
13
13
  declare const HUB_PRINCIPAL_HEADER = "x-reactive-principal";
14
+ /** Close code for a socket the presence sweep found silent. */
15
+ declare const HEARTBEAT_TIMEOUT_CLOSE_CODE = 4001;
14
16
  /** A hibernatable Workers WebSocket, as far as the hub needs it. */
15
17
  interface DurableObjectWebSocket extends HubSocket {
16
18
  close(code?: number, reason?: string): void;
@@ -25,6 +27,8 @@ interface DurableObjectStateLike {
25
27
  storage: {
26
28
  get<T>(key: string): Promise<T | undefined>;
27
29
  put<T>(key: string, value: T): Promise<void>;
30
+ /** Lets presence forget principals that left instead of storing `false`. */
31
+ delete?(key: string): Promise<boolean>;
28
32
  /** Needed for presence. */
29
33
  getAlarm?(): Promise<number | null>;
30
34
  /** Needed for presence. */
@@ -39,6 +43,10 @@ type WebSocketPairConstructor = new () => {
39
43
  0: DurableObjectWebSocket;
40
44
  1: DurableObjectWebSocket;
41
45
  };
46
+ /** A frame from the client that is not part of the protocol, such as `{ type: 'listening' }`. */
47
+ type HubClientFrame = Record<string, unknown> & {
48
+ type: string;
49
+ };
42
50
  interface DurableObjectHubOptions {
43
51
  paths: HubPaths;
44
52
  /** Storage key for a path's version. Defaults to `version:<path>`. */
@@ -50,17 +58,27 @@ interface DurableObjectHubOptions {
50
58
  */
51
59
  autoPong?: boolean;
52
60
  /**
53
- * Tracks whether these principals hold a live socket, so the hub can report a
54
- * worker as online without the worker polling over HTTP. Requires `autoPong`.
61
+ * Tracks whether principals hold a live socket, so the hub can report a
62
+ * worker or listener as online without it polling over HTTP. Requires `autoPong`.
55
63
  */
56
64
  presence?: HubPresenceOptions;
65
+ /** Runs after a socket is accepted. */
66
+ onOpen?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
67
+ /**
68
+ * Runs when a socket closes or errors. The socket may still be listed by
69
+ * `sockets()` during this call, so exclude it when counting.
70
+ */
71
+ onClose?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
72
+ /** Handles frames the protocol does not, such as application presence updates. */
73
+ onMessage?: (socket: DurableObjectWebSocket, principal: string, frame: HubClientFrame) => void | Promise<void>;
57
74
  /** Workers' `WebSocketPair`; injectable for tests. */
58
75
  WebSocketPair?: WebSocketPairConstructor;
59
76
  /** Creates a connection id; injectable for tests. */
60
77
  randomId?: () => string;
61
78
  }
62
79
  interface HubPresenceOptions {
63
- principals: readonly string[];
80
+ /** The principals to track, or a predicate for open-ended ones such as anonymous listeners. */
81
+ principals: readonly string[] | ((principal: string) => boolean);
64
82
  /**
65
83
  * A socket whose last `ping` is older than this is closed as dead, which is how
66
84
  * a peer that vanished without a close frame is noticed. Clients must ping more
@@ -77,7 +95,12 @@ interface HubPresenceOptions {
77
95
  /** Wires the hub core to a Durable Object's hibernatable WebSockets and storage. */
78
96
  declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: DurableObjectHubOptions): {
79
97
  hub: {
80
- message(socket: HubSocket, raw: string): void;
98
+ message(socket: HubSocket, raw: string): (Record<string, unknown> & {
99
+ type: string;
100
+ }) | undefined;
101
+ socketData<T = unknown>(socket: HubSocket): T | undefined;
102
+ setSocketData(socket: HubSocket, data: unknown): void;
103
+ principalOf(socket: HubSocket): string;
81
104
  subscribe(request: SubscribeRequest, principal: string): Promise<SnapshotResponse>;
82
105
  unsubscribe(request: UnsubscribeRequest, principal: string): void;
83
106
  notify(path: string): Promise<void>;
@@ -85,9 +108,17 @@ declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: Du
85
108
  };
86
109
  /** The Durable Object's `fetch`: WebSocket upgrades and actions from `hubClient`. */
87
110
  fetch(request: Request): Promise<Response>;
88
- webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): void;
111
+ webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): Promise<void>;
89
112
  webSocketClose(socket: DurableObjectWebSocket, code: number): Promise<void>;
90
113
  webSocketError(socket: DurableObjectWebSocket): Promise<void>;
114
+ /** Every accepted socket, including one whose close is being handled. */
115
+ sockets(): DurableObjectWebSocket[];
116
+ /** The principal a socket authenticated as. */
117
+ principalOf: (socket: DurableObjectWebSocket) => string;
118
+ /** Application data stored with a socket by `setSocketData`. */
119
+ socketData: <T = unknown>(socket: DurableObjectWebSocket) => T | undefined;
120
+ /** Stores application data with a socket; it survives hibernation and shares the 2 KB limit. */
121
+ setSocketData: (socket: DurableObjectWebSocket, data: unknown) => void;
91
122
  /** Whether `principal` holds a socket that has pinged within the presence timeout. */
92
123
  present(principal: string): boolean;
93
124
  /**
@@ -104,6 +135,22 @@ declare function hubClient(stub: HubStub): {
104
135
  notify: (path: string) => Promise<void>;
105
136
  publish: (path: string, patches: JsonPatch[]) => Promise<void>;
106
137
  };
138
+ interface RemoteHubOptions {
139
+ /** Absolute URL of the hub Worker's `routes.notify`. */
140
+ url: string;
141
+ /** The Worker's `notifySecret`. Keep it server-side. */
142
+ secret: string;
143
+ /** Injectable for tests. */
144
+ fetch?: typeof fetch;
145
+ }
146
+ /**
147
+ * Notifies a hub from another backend, such as an AWS Lambda or a Vercel route,
148
+ * after the data behind a path changes.
149
+ */
150
+ declare function remoteHub(options: RemoteHubOptions): {
151
+ notify: (path: string) => Promise<void>;
152
+ publish: (path: string, patches: JsonPatch[]) => Promise<void>;
153
+ };
107
154
  interface ReactiveRoutes {
108
155
  /** `POST` exchanges a credential for a signed ticket. */
109
156
  ticket: string;
@@ -111,19 +158,28 @@ interface ReactiveRoutes {
111
158
  socket: string;
112
159
  /** Reactive HTTP subscribe and unsubscribe, authenticated by the ticket as a bearer token. */
113
160
  reactive: string;
161
+ /** `POST { path, patches? }` from other backends, authenticated by `notifySecret`. */
162
+ notify?: string;
114
163
  }
115
164
  interface ReactiveRoutesOptions {
116
165
  routes: ReactiveRoutes;
117
166
  /** Ticket signing secret. Requests get 503 when it is not configured. */
118
167
  secret: string | undefined;
168
+ /** Bearer secret for `routes.notify`; the route answers 503 while it is unset. */
169
+ notifySecret?: string | undefined;
170
+ /**
171
+ * Browser origins allowed to call the ticket and reactive routes cross-origin,
172
+ * for a hub on a different host from the app.
173
+ */
174
+ allowedOrigins?: readonly string[];
119
175
  /** The hub Durable Object stub that serves these routes. */
120
176
  hub: () => HubStub;
121
177
  /** Returns the principal for a ticket request, or undefined to answer 401. */
122
178
  authenticate(request: Request): string | undefined | Promise<string | undefined>;
123
179
  ticketLifetimeSeconds?: number;
124
180
  }
125
- /** Serves the ticket, socket and reactive HTTP routes; `undefined` for any other path. */
181
+ /** Serves the ticket, socket, reactive and notify routes; `undefined` for any other path. */
126
182
  declare function handleReactiveRoutes(request: Request, options: ReactiveRoutesOptions): Promise<Response | undefined>;
127
183
 
128
- export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };
129
- export type { DurableObjectHub, DurableObjectHubOptions, DurableObjectStateLike, DurableObjectWebSocket, HubStub, ReactiveRoutes, ReactiveRoutesOptions };
184
+ export { HEARTBEAT_TIMEOUT_CLOSE_CODE, HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient, remoteHub };
185
+ export type { DurableObjectHub, DurableObjectHubOptions, DurableObjectStateLike, DurableObjectWebSocket, HubClientFrame, HubPresenceOptions, HubStub, ReactiveRoutes, ReactiveRoutesOptions, RemoteHubOptions };
@@ -129,20 +129,42 @@ function createReactiveHub(options) {
129
129
  return { fromVersion, toVersion };
130
130
  };
131
131
  return {
132
- /** Answers the client's `init` and `ping` frames. */
132
+ /**
133
+ * Answers the client's `init` and `ping` frames. Any other frame with a
134
+ * string `type` is returned for the application to handle.
135
+ */
133
136
  message(socket, raw) {
134
137
  let frame;
135
138
  try {
136
139
  frame = JSON.parse(raw);
137
140
  } catch {
138
- return;
141
+ return void 0;
139
142
  }
140
- const type = isRecord2(frame) ? frame.type : void 0;
141
- if (type === "init") {
143
+ if (!isRecord2(frame) || typeof frame.type !== "string") return void 0;
144
+ if (frame.type === "init") {
142
145
  send(socket, { type: "connected", connectionId: socketState(socket).connectionId });
143
- } else if (type === "ping") {
146
+ } else if (frame.type === "ping") {
144
147
  send(socket, { type: "pong" });
148
+ } else {
149
+ return frame;
150
+ }
151
+ return void 0;
152
+ },
153
+ /** The application data stored with a socket. */
154
+ socketData(socket) {
155
+ return socketState(socket).data;
156
+ },
157
+ /** Stores application data with a socket; it shares the attachment size limit. */
158
+ setSocketData(socket, data) {
159
+ const next = { ...socketState(socket), data };
160
+ if (new TextEncoder().encode(JSON.stringify(next)).length > maxAttachmentBytes) {
161
+ throw new ReactiveHubError("Socket data is too large");
145
162
  }
163
+ socket.serializeAttachment(next);
164
+ },
165
+ /** The principal a socket authenticated as. */
166
+ principalOf(socket) {
167
+ return socketState(socket).principal;
146
168
  },
147
169
  async subscribe(request, principal) {
148
170
  const handler = resolve(request.path);
@@ -231,6 +253,7 @@ function createReactiveHub(options) {
231
253
  // ../client/src/cloudflare.ts
232
254
  var HUB_PRINCIPAL_HEADER = "x-reactive-principal";
233
255
  var HUB_ACTION_URL = "https://reactive-hub/";
256
+ var PRESENCE_TAG = "presence";
234
257
  var PRESENCE_TAG_PREFIX = "presence:";
235
258
  var PRESENCE_KEY_PREFIX = "presence:";
236
259
  var DEFAULT_PRESENCE_TIMEOUT_MS = 9e4;
@@ -268,8 +291,14 @@ function createDurableObjectHub(ctx, options) {
268
291
  );
269
292
  }
270
293
  }
271
- const tracked = new Set(options.presence?.principals ?? []);
272
- const timeoutMs = options.presence?.timeoutMs ?? DEFAULT_PRESENCE_TIMEOUT_MS;
294
+ const presence = options.presence;
295
+ const timeoutMs = presence?.timeoutMs ?? DEFAULT_PRESENCE_TIMEOUT_MS;
296
+ const isTracked = (principal) => {
297
+ if (!presence) return false;
298
+ const { principals } = presence;
299
+ return typeof principals === "function" ? principals(principal) : principals.includes(principal);
300
+ };
301
+ const principalOf = (socket) => socket.deserializeAttachment().principal;
273
302
  const lastSeen = (socket) => Math.max(
274
303
  ctx.getWebSocketAutoResponseTimestamp?.(socket)?.getTime() ?? 0,
275
304
  socket.deserializeAttachment().connectedAt ?? 0
@@ -282,26 +311,25 @@ function createDurableObjectHub(ctx, options) {
282
311
  const present = liveSockets(principal, closing).length > 0;
283
312
  const key = PRESENCE_KEY_PREFIX + principal;
284
313
  if ((await ctx.storage.get(key) ?? false) === present) return;
285
- await ctx.storage.put(key, present);
286
- await options.presence?.onChange?.(principal, present);
314
+ if (present || !ctx.storage.delete) await ctx.storage.put(key, present);
315
+ else await ctx.storage.delete(key);
316
+ await presence?.onChange?.(principal, present);
287
317
  };
288
318
  const scheduleSweep = async () => {
289
319
  const { storage } = ctx;
290
320
  if (!storage.getAlarm || !storage.setAlarm) {
291
321
  throw new Error("Hub presence needs Durable Object alarms");
292
322
  }
293
- const seen = [...tracked].flatMap(
294
- (principal) => ctx.getWebSockets(PRESENCE_TAG_PREFIX + principal).map((socket) => lastSeen(socket))
295
- );
323
+ const seen = ctx.getWebSockets(PRESENCE_TAG).map((socket) => lastSeen(socket));
296
324
  if (seen.length === 0) return;
297
325
  const due = Math.min(...seen) + timeoutMs + 1;
298
326
  const current = await storage.getAlarm();
299
327
  if (current === null || current > due) await storage.setAlarm(due);
300
328
  };
301
329
  const closed = async (socket) => {
302
- if (tracked.size === 0) return;
303
- const { principal } = socket.deserializeAttachment();
304
- if (tracked.has(principal)) await reconcile(principal, socket);
330
+ const principal = principalOf(socket);
331
+ if (isTracked(principal)) await reconcile(principal, socket);
332
+ await options.onClose?.(socket, principal);
305
333
  };
306
334
  const accept = async (request) => {
307
335
  const principal = request.headers.get(HUB_PRINCIPAL_HEADER);
@@ -310,18 +338,19 @@ function createDurableObjectHub(ctx, options) {
310
338
  if (!Pair) throw new Error("WebSocketPair is not available in this runtime");
311
339
  const pair = new Pair();
312
340
  const connectionId = randomId();
313
- const isTracked = tracked.has(principal);
341
+ const tracked = isTracked(principal);
314
342
  ctx.acceptWebSocket(
315
343
  pair[1],
316
- isTracked ? [connectionId, PRESENCE_TAG_PREFIX + principal] : [connectionId]
344
+ tracked ? [connectionId, PRESENCE_TAG, PRESENCE_TAG_PREFIX + principal] : [connectionId]
317
345
  );
318
346
  pair[1].serializeAttachment(
319
- createHubSocketState(connectionId, principal, isTracked ? Date.now() : void 0)
347
+ createHubSocketState(connectionId, principal, tracked ? Date.now() : void 0)
320
348
  );
321
- if (isTracked) {
349
+ if (tracked) {
322
350
  await reconcile(principal);
323
351
  await scheduleSweep();
324
352
  }
353
+ await options.onOpen?.(pair[1], principal);
325
354
  return new Response(null, { status: 101, webSocket: pair[0] });
326
355
  };
327
356
  const act = async (action) => {
@@ -353,8 +382,10 @@ function createDurableObjectHub(ctx, options) {
353
382
  if (request.headers.get("upgrade") === "websocket") return accept(request);
354
383
  return act(await request.json());
355
384
  },
356
- webSocketMessage(socket, message) {
357
- if (typeof message === "string") hub.message(socket, message);
385
+ async webSocketMessage(socket, message) {
386
+ if (typeof message !== "string") return;
387
+ const frame = hub.message(socket, message);
388
+ if (frame && options.onMessage) await options.onMessage(socket, principalOf(socket), frame);
358
389
  },
359
390
  async webSocketClose(socket, code) {
360
391
  try {
@@ -370,6 +401,16 @@ function createDurableObjectHub(ctx, options) {
370
401
  }
371
402
  await closed(socket);
372
403
  },
404
+ /** Every accepted socket, including one whose close is being handled. */
405
+ sockets() {
406
+ return ctx.getWebSockets();
407
+ },
408
+ /** The principal a socket authenticated as. */
409
+ principalOf,
410
+ /** Application data stored with a socket by `setSocketData`. */
411
+ socketData: (socket) => hub.socketData(socket),
412
+ /** Stores application data with a socket; it survives hibernation and shares the 2 KB limit. */
413
+ setSocketData: (socket, data) => hub.setSocketData(socket, data),
373
414
  /** Whether `principal` holds a socket that has pinged within the presence timeout. */
374
415
  present(principal) {
375
416
  return liveSockets(principal).length > 0;
@@ -380,14 +421,18 @@ function createDurableObjectHub(ctx, options) {
380
421
  */
381
422
  async alarm() {
382
423
  const now = Date.now();
383
- for (const principal of tracked) {
384
- for (const socket of ctx.getWebSockets(PRESENCE_TAG_PREFIX + principal)) {
385
- if (now - lastSeen(socket) <= timeoutMs) continue;
386
- try {
387
- socket.close(HEARTBEAT_TIMEOUT_CLOSE_CODE, "Heartbeat timeout");
388
- } catch {
389
- }
424
+ const principals = /* @__PURE__ */ new Set();
425
+ for (const socket of ctx.getWebSockets(PRESENCE_TAG)) {
426
+ const principal = principalOf(socket);
427
+ principals.add(principal);
428
+ if (now - lastSeen(socket) <= timeoutMs) continue;
429
+ try {
430
+ socket.close(HEARTBEAT_TIMEOUT_CLOSE_CODE, "Heartbeat timeout");
431
+ } catch {
390
432
  }
433
+ await options.onClose?.(socket, principal);
434
+ }
435
+ for (const principal of principals) {
391
436
  await reconcile(principal);
392
437
  }
393
438
  await scheduleSweep();
@@ -408,24 +453,98 @@ function hubClient(stub) {
408
453
  publish: async (path, patches) => ok(await post({ action: "publish", path, patches }))
409
454
  };
410
455
  }
456
+ function remoteHub(options) {
457
+ const send2 = async (body) => {
458
+ const response = await (options.fetch ?? fetch)(options.url, {
459
+ method: "POST",
460
+ headers: { authorization: `Bearer ${options.secret}`, "content-type": "application/json" },
461
+ body: JSON.stringify(body)
462
+ });
463
+ if (!response.ok) throw new Error(`Hub notify failed: HTTP ${response.status}`);
464
+ };
465
+ return {
466
+ notify: (path) => send2({ path }),
467
+ publish: (path, patches) => send2({ path, patches })
468
+ };
469
+ }
470
+ function sameSecret(given, expected) {
471
+ const left = new TextEncoder().encode(given);
472
+ const right = new TextEncoder().encode(expected);
473
+ let difference = left.length ^ right.length;
474
+ for (let index = 0; index < right.length; index++) {
475
+ difference |= (left[index] ?? 0) ^ right[index];
476
+ }
477
+ return difference === 0;
478
+ }
479
+ function withCors(response, origin) {
480
+ if (!origin) return response;
481
+ const headers = new Headers(response.headers);
482
+ headers.set("access-control-allow-origin", origin);
483
+ headers.append("vary", "origin");
484
+ return new Response(response.body, {
485
+ status: response.status,
486
+ statusText: response.statusText,
487
+ headers
488
+ });
489
+ }
411
490
  async function handleReactiveRoutes(request, options) {
412
491
  const url = new URL(request.url);
413
492
  const { routes, secret } = options;
414
- if (![routes.ticket, routes.socket, routes.reactive].includes(url.pathname)) return void 0;
415
- if (!secret) return error("Reactive hub is not configured", 503);
416
- if (url.pathname === routes.ticket) {
493
+ const known = [routes.ticket, routes.socket, routes.reactive, routes.notify];
494
+ if (!known.includes(url.pathname)) return void 0;
495
+ const requestOrigin = request.headers.get("origin");
496
+ const corsOrigin = requestOrigin && options.allowedOrigins?.includes(requestOrigin) ? requestOrigin : null;
497
+ if (request.method === "OPTIONS" && url.pathname !== routes.socket) {
498
+ if (!corsOrigin) return new Response(null, { status: 403 });
499
+ return new Response(null, {
500
+ status: 204,
501
+ headers: {
502
+ "access-control-allow-origin": corsOrigin,
503
+ "access-control-allow-methods": "POST, OPTIONS",
504
+ "access-control-allow-headers": "authorization, content-type",
505
+ "access-control-max-age": "86400",
506
+ vary: "origin"
507
+ }
508
+ });
509
+ }
510
+ if (url.pathname === routes.notify) {
417
511
  if (request.method !== "POST") return error("Method not allowed", 405);
512
+ if (!options.notifySecret) return error("Hub notify is not configured", 503);
513
+ if (!sameSecret(bearerToken(request), options.notifySecret)) {
514
+ return error("Unauthorized", 401);
515
+ }
516
+ let body2;
517
+ try {
518
+ body2 = await request.json();
519
+ } catch {
520
+ return error("Invalid JSON", 400);
521
+ }
522
+ const { path, patches } = body2 ?? {};
523
+ if (typeof path !== "string" || patches !== void 0 && !Array.isArray(patches)) {
524
+ return error("Expected { path, patches? }", 400);
525
+ }
526
+ const client = hubClient(options.hub());
527
+ if (patches) await client.publish(path, patches);
528
+ else await client.notify(path);
529
+ return json({ type: "ok" });
530
+ }
531
+ if (!secret) return withCors(error("Reactive hub is not configured", 503), corsOrigin);
532
+ if (url.pathname === routes.ticket) {
533
+ if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
418
534
  const principal2 = await options.authenticate(request);
419
- if (!principal2) return error("Unauthorized", 401);
535
+ if (!principal2) return withCors(error("Unauthorized", 401), corsOrigin);
420
536
  const socketUrl = new URL(routes.socket, url);
421
537
  socketUrl.protocol = "wss:";
422
- return json(
423
- await createSignedClusterTicket({
424
- secret,
425
- principal: principal2,
426
- url: socketUrl.toString(),
427
- ...options.ticketLifetimeSeconds === void 0 ? {} : { lifetimeSeconds: options.ticketLifetimeSeconds }
428
- })
538
+ return withCors(
539
+ json(
540
+ await createSignedClusterTicket({
541
+ secret,
542
+ principal: principal2,
543
+ url: socketUrl.toString(),
544
+ ...options.ticketLifetimeSeconds === void 0 ? {} : { lifetimeSeconds: options.ticketLifetimeSeconds }
545
+ })
546
+ ),
547
+ corsOrigin
429
548
  );
430
549
  }
431
550
  if (url.pathname === routes.socket) {
@@ -438,19 +557,19 @@ async function handleReactiveRoutes(request, options) {
438
557
  forwarded.headers.set(HUB_PRINCIPAL_HEADER, principal2);
439
558
  return options.hub().fetch(forwarded);
440
559
  }
441
- if (request.method !== "POST") return error("Method not allowed", 405);
560
+ if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
442
561
  const principal = await verifySignedClusterTicket(bearerToken(request), secret);
443
- if (!principal) return error("Unauthorized", 401);
562
+ if (!principal) return withCors(error("Unauthorized", 401), corsOrigin);
444
563
  let body;
445
564
  try {
446
565
  body = await request.json();
447
566
  } catch {
448
- return error("Invalid JSON", 400);
567
+ return withCors(error("Invalid JSON", 400), corsOrigin);
449
568
  }
450
- if (!isHubRequest(body)) return error("Unsupported reactive request", 400);
451
- return hubClient(options.hub()).request(body, principal);
569
+ if (!isHubRequest(body)) return withCors(error("Unsupported reactive request", 400), corsOrigin);
570
+ return withCors(await hubClient(options.hub()).request(body, principal), corsOrigin);
452
571
  }
453
572
 
454
- export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };
573
+ export { HEARTBEAT_TIMEOUT_CLOSE_CODE, HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient, remoteHub };
455
574
  //# sourceMappingURL=cloudflare.js.map
456
575
  //# sourceMappingURL=cloudflare.js.map