dynamodb-reactive 0.4.0 → 0.6.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 (45) hide show
  1. package/README.md +93 -51
  2. package/dist/{client-Zb67giI5.d.ts → client-BPAct-Dz.d.ts} +19 -0
  3. package/dist/client.cjs +68 -55
  4. package/dist/client.cjs.map +1 -1
  5. package/dist/client.d.cts +1 -1
  6. package/dist/client.d.ts +1 -1
  7. package/dist/client.js +68 -55
  8. package/dist/client.js.map +1 -1
  9. package/dist/cloudflare.cjs +223 -36
  10. package/dist/cloudflare.cjs.map +1 -1
  11. package/dist/cloudflare.d.cts +78 -6
  12. package/dist/cloudflare.d.ts +78 -6
  13. package/dist/cloudflare.js +223 -36
  14. package/dist/cloudflare.js.map +1 -1
  15. package/dist/cluster.cjs +81 -66
  16. package/dist/cluster.cjs.map +1 -1
  17. package/dist/cluster.js +81 -66
  18. package/dist/cluster.js.map +1 -1
  19. package/dist/core.cjs +1 -3
  20. package/dist/core.cjs.map +1 -1
  21. package/dist/core.js +1 -3
  22. package/dist/core.js.map +1 -1
  23. package/dist/hub.cjs +35 -10
  24. package/dist/hub.cjs.map +1 -1
  25. package/dist/hub.d.cts +18 -3
  26. package/dist/hub.d.ts +18 -3
  27. package/dist/hub.js +35 -10
  28. package/dist/hub.js.map +1 -1
  29. package/dist/index.cjs +1 -3
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +1 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/infra.cjs +19 -33
  34. package/dist/infra.cjs.map +1 -1
  35. package/dist/infra.js +19 -33
  36. package/dist/infra.js.map +1 -1
  37. package/dist/react.cjs +71 -65
  38. package/dist/react.cjs.map +1 -1
  39. package/dist/react.js +71 -65
  40. package/dist/react.js.map +1 -1
  41. package/dist/server.cjs +46 -139
  42. package/dist/server.cjs.map +1 -1
  43. package/dist/server.js +46 -139
  44. package/dist/server.js.map +1 -1
  45. package/package.json +22 -24
@@ -6,7 +6,8 @@ import { HubSocket, HubPaths } from './hub.cjs';
6
6
  * takes the Durable Object's `ctx` instead of extending `DurableObject`, so this
7
7
  * entry imports nothing from `cloudflare:workers` and builds like every other
8
8
  * entry. Your Durable Object class owns its name and migrations and delegates
9
- * `fetch`, `webSocketMessage`, `webSocketClose` and `webSocketError` to it.
9
+ * `fetch`, `webSocketMessage`, `webSocketClose` and `webSocketError` to it, plus
10
+ * `alarm` when it tracks presence.
10
11
  */
11
12
  /** Set by the route handler on requests it forwards to the hub; never trusted from clients. */
12
13
  declare const HUB_PRINCIPAL_HEADER = "x-reactive-principal";
@@ -19,9 +20,17 @@ interface DurableObjectStateLike {
19
20
  acceptWebSocket(socket: DurableObjectWebSocket, tags?: string[]): void;
20
21
  getWebSockets(tag?: string): DurableObjectWebSocket[];
21
22
  setWebSocketAutoResponse?(pair?: unknown): void;
23
+ /** When the runtime last answered this socket's `ping` itself; needed for presence. */
24
+ getWebSocketAutoResponseTimestamp?(socket: DurableObjectWebSocket): Date | null;
22
25
  storage: {
23
26
  get<T>(key: string): Promise<T | undefined>;
24
27
  put<T>(key: string, value: T): Promise<void>;
28
+ /** Lets presence forget principals that left instead of storing `false`. */
29
+ delete?(key: string): Promise<boolean>;
30
+ /** Needed for presence. */
31
+ getAlarm?(): Promise<number | null>;
32
+ /** Needed for presence. */
33
+ setAlarm?(scheduledTime: number): Promise<void>;
25
34
  };
26
35
  }
27
36
  /** Anything with a Durable Object stub's `fetch`. */
@@ -32,6 +41,10 @@ type WebSocketPairConstructor = new () => {
32
41
  0: DurableObjectWebSocket;
33
42
  1: DurableObjectWebSocket;
34
43
  };
44
+ /** A frame from the client that is not part of the protocol, such as `{ type: 'listening' }`. */
45
+ type HubClientFrame = Record<string, unknown> & {
46
+ type: string;
47
+ };
35
48
  interface DurableObjectHubOptions {
36
49
  paths: HubPaths;
37
50
  /** Storage key for a path's version. Defaults to `version:<path>`. */
@@ -42,15 +55,50 @@ interface DurableObjectHubOptions {
42
55
  * Defaults to true.
43
56
  */
44
57
  autoPong?: boolean;
58
+ /**
59
+ * Tracks whether principals hold a live socket, so the hub can report a
60
+ * worker or listener as online without it polling over HTTP. Requires `autoPong`.
61
+ */
62
+ presence?: HubPresenceOptions;
63
+ /** Runs after a socket is accepted. */
64
+ onOpen?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
65
+ /**
66
+ * Runs when a socket closes or errors. The socket may still be listed by
67
+ * `sockets()` during this call, so exclude it when counting.
68
+ */
69
+ onClose?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
70
+ /** Handles frames the protocol does not, such as application presence updates. */
71
+ onMessage?: (socket: DurableObjectWebSocket, principal: string, frame: HubClientFrame) => void | Promise<void>;
45
72
  /** Workers' `WebSocketPair`; injectable for tests. */
46
73
  WebSocketPair?: WebSocketPairConstructor;
47
74
  /** Creates a connection id; injectable for tests. */
48
75
  randomId?: () => string;
49
76
  }
77
+ interface HubPresenceOptions {
78
+ /** The principals to track, or a predicate for open-ended ones such as anonymous listeners. */
79
+ principals: readonly string[] | ((principal: string) => boolean);
80
+ /**
81
+ * A socket whose last `ping` is older than this is closed as dead, which is how
82
+ * a peer that vanished without a close frame is noticed. Clients must ping more
83
+ * often than this. @default 90000
84
+ */
85
+ timeoutMs?: number;
86
+ /**
87
+ * Called when a principal gains its first live socket or loses its last one,
88
+ * for example to `notify` a status path. The last reported state is kept in
89
+ * storage under `presence:<principal>`, so each change is reported once.
90
+ */
91
+ onChange?: (principal: string, present: boolean) => void | Promise<void>;
92
+ }
50
93
  /** Wires the hub core to a Durable Object's hibernatable WebSockets and storage. */
51
94
  declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: DurableObjectHubOptions): {
52
95
  hub: {
53
- message(socket: HubSocket, raw: string): void;
96
+ message(socket: HubSocket, raw: string): (Record<string, unknown> & {
97
+ type: string;
98
+ }) | undefined;
99
+ socketData<T = unknown>(socket: HubSocket): T | undefined;
100
+ setSocketData(socket: HubSocket, data: unknown): void;
101
+ principalOf(socket: HubSocket): string;
54
102
  subscribe(request: SubscribeRequest, principal: string): Promise<SnapshotResponse>;
55
103
  unsubscribe(request: UnsubscribeRequest, principal: string): void;
56
104
  notify(path: string): Promise<void>;
@@ -58,9 +106,24 @@ declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: Du
58
106
  };
59
107
  /** The Durable Object's `fetch`: WebSocket upgrades and actions from `hubClient`. */
60
108
  fetch(request: Request): Promise<Response>;
61
- webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): void;
62
- webSocketClose(socket: DurableObjectWebSocket, code: number): void;
63
- webSocketError(socket: DurableObjectWebSocket): void;
109
+ webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): Promise<void>;
110
+ webSocketClose(socket: DurableObjectWebSocket, code: number): Promise<void>;
111
+ webSocketError(socket: DurableObjectWebSocket): Promise<void>;
112
+ /** Every accepted socket, including one whose close is being handled. */
113
+ sockets(): DurableObjectWebSocket[];
114
+ /** The principal a socket authenticated as. */
115
+ principalOf: (socket: DurableObjectWebSocket) => string;
116
+ /** Application data stored with a socket by `setSocketData`. */
117
+ socketData: <T = unknown>(socket: DurableObjectWebSocket) => T | undefined;
118
+ /** Stores application data with a socket; it survives hibernation and shares the 2 KB limit. */
119
+ setSocketData: (socket: DurableObjectWebSocket, data: unknown) => void;
120
+ /** Whether `principal` holds a socket that has pinged within the presence timeout. */
121
+ present(principal: string): boolean;
122
+ /**
123
+ * The Durable Object's `alarm` when presence is tracked: closes silent
124
+ * sockets, reports principals that lost their last one, and reschedules.
125
+ */
126
+ alarm(): Promise<void>;
64
127
  };
65
128
  type DurableObjectHub = ReturnType<typeof createDurableObjectHub>;
66
129
  /** Calls a hub Durable Object from the Worker. */
@@ -77,18 +140,27 @@ interface ReactiveRoutes {
77
140
  socket: string;
78
141
  /** Reactive HTTP subscribe and unsubscribe, authenticated by the ticket as a bearer token. */
79
142
  reactive: string;
143
+ /** `POST { path, patches? }` from other backends, authenticated by `notifySecret`. */
144
+ notify?: string;
80
145
  }
81
146
  interface ReactiveRoutesOptions {
82
147
  routes: ReactiveRoutes;
83
148
  /** Ticket signing secret. Requests get 503 when it is not configured. */
84
149
  secret: string | undefined;
150
+ /** Bearer secret for `routes.notify`; the route answers 503 while it is unset. */
151
+ notifySecret?: string | undefined;
152
+ /**
153
+ * Browser origins allowed to call the ticket and reactive routes cross-origin,
154
+ * for a hub on a different host from the app.
155
+ */
156
+ allowedOrigins?: readonly string[];
85
157
  /** The hub Durable Object stub that serves these routes. */
86
158
  hub: () => HubStub;
87
159
  /** Returns the principal for a ticket request, or undefined to answer 401. */
88
160
  authenticate(request: Request): string | undefined | Promise<string | undefined>;
89
161
  ticketLifetimeSeconds?: number;
90
162
  }
91
- /** Serves the ticket, socket and reactive HTTP routes; `undefined` for any other path. */
163
+ /** Serves the ticket, socket, reactive and notify routes; `undefined` for any other path. */
92
164
  declare function handleReactiveRoutes(request: Request, options: ReactiveRoutesOptions): Promise<Response | undefined>;
93
165
 
94
166
  export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };
@@ -6,7 +6,8 @@ import { HubSocket, HubPaths } from './hub.js';
6
6
  * takes the Durable Object's `ctx` instead of extending `DurableObject`, so this
7
7
  * entry imports nothing from `cloudflare:workers` and builds like every other
8
8
  * entry. Your Durable Object class owns its name and migrations and delegates
9
- * `fetch`, `webSocketMessage`, `webSocketClose` and `webSocketError` to it.
9
+ * `fetch`, `webSocketMessage`, `webSocketClose` and `webSocketError` to it, plus
10
+ * `alarm` when it tracks presence.
10
11
  */
11
12
  /** Set by the route handler on requests it forwards to the hub; never trusted from clients. */
12
13
  declare const HUB_PRINCIPAL_HEADER = "x-reactive-principal";
@@ -19,9 +20,17 @@ interface DurableObjectStateLike {
19
20
  acceptWebSocket(socket: DurableObjectWebSocket, tags?: string[]): void;
20
21
  getWebSockets(tag?: string): DurableObjectWebSocket[];
21
22
  setWebSocketAutoResponse?(pair?: unknown): void;
23
+ /** When the runtime last answered this socket's `ping` itself; needed for presence. */
24
+ getWebSocketAutoResponseTimestamp?(socket: DurableObjectWebSocket): Date | null;
22
25
  storage: {
23
26
  get<T>(key: string): Promise<T | undefined>;
24
27
  put<T>(key: string, value: T): Promise<void>;
28
+ /** Lets presence forget principals that left instead of storing `false`. */
29
+ delete?(key: string): Promise<boolean>;
30
+ /** Needed for presence. */
31
+ getAlarm?(): Promise<number | null>;
32
+ /** Needed for presence. */
33
+ setAlarm?(scheduledTime: number): Promise<void>;
25
34
  };
26
35
  }
27
36
  /** Anything with a Durable Object stub's `fetch`. */
@@ -32,6 +41,10 @@ type WebSocketPairConstructor = new () => {
32
41
  0: DurableObjectWebSocket;
33
42
  1: DurableObjectWebSocket;
34
43
  };
44
+ /** A frame from the client that is not part of the protocol, such as `{ type: 'listening' }`. */
45
+ type HubClientFrame = Record<string, unknown> & {
46
+ type: string;
47
+ };
35
48
  interface DurableObjectHubOptions {
36
49
  paths: HubPaths;
37
50
  /** Storage key for a path's version. Defaults to `version:<path>`. */
@@ -42,15 +55,50 @@ interface DurableObjectHubOptions {
42
55
  * Defaults to true.
43
56
  */
44
57
  autoPong?: boolean;
58
+ /**
59
+ * Tracks whether principals hold a live socket, so the hub can report a
60
+ * worker or listener as online without it polling over HTTP. Requires `autoPong`.
61
+ */
62
+ presence?: HubPresenceOptions;
63
+ /** Runs after a socket is accepted. */
64
+ onOpen?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
65
+ /**
66
+ * Runs when a socket closes or errors. The socket may still be listed by
67
+ * `sockets()` during this call, so exclude it when counting.
68
+ */
69
+ onClose?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
70
+ /** Handles frames the protocol does not, such as application presence updates. */
71
+ onMessage?: (socket: DurableObjectWebSocket, principal: string, frame: HubClientFrame) => void | Promise<void>;
45
72
  /** Workers' `WebSocketPair`; injectable for tests. */
46
73
  WebSocketPair?: WebSocketPairConstructor;
47
74
  /** Creates a connection id; injectable for tests. */
48
75
  randomId?: () => string;
49
76
  }
77
+ interface HubPresenceOptions {
78
+ /** The principals to track, or a predicate for open-ended ones such as anonymous listeners. */
79
+ principals: readonly string[] | ((principal: string) => boolean);
80
+ /**
81
+ * A socket whose last `ping` is older than this is closed as dead, which is how
82
+ * a peer that vanished without a close frame is noticed. Clients must ping more
83
+ * often than this. @default 90000
84
+ */
85
+ timeoutMs?: number;
86
+ /**
87
+ * Called when a principal gains its first live socket or loses its last one,
88
+ * for example to `notify` a status path. The last reported state is kept in
89
+ * storage under `presence:<principal>`, so each change is reported once.
90
+ */
91
+ onChange?: (principal: string, present: boolean) => void | Promise<void>;
92
+ }
50
93
  /** Wires the hub core to a Durable Object's hibernatable WebSockets and storage. */
51
94
  declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: DurableObjectHubOptions): {
52
95
  hub: {
53
- message(socket: HubSocket, raw: string): void;
96
+ message(socket: HubSocket, raw: string): (Record<string, unknown> & {
97
+ type: string;
98
+ }) | undefined;
99
+ socketData<T = unknown>(socket: HubSocket): T | undefined;
100
+ setSocketData(socket: HubSocket, data: unknown): void;
101
+ principalOf(socket: HubSocket): string;
54
102
  subscribe(request: SubscribeRequest, principal: string): Promise<SnapshotResponse>;
55
103
  unsubscribe(request: UnsubscribeRequest, principal: string): void;
56
104
  notify(path: string): Promise<void>;
@@ -58,9 +106,24 @@ declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: Du
58
106
  };
59
107
  /** The Durable Object's `fetch`: WebSocket upgrades and actions from `hubClient`. */
60
108
  fetch(request: Request): Promise<Response>;
61
- webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): void;
62
- webSocketClose(socket: DurableObjectWebSocket, code: number): void;
63
- webSocketError(socket: DurableObjectWebSocket): void;
109
+ webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): Promise<void>;
110
+ webSocketClose(socket: DurableObjectWebSocket, code: number): Promise<void>;
111
+ webSocketError(socket: DurableObjectWebSocket): Promise<void>;
112
+ /** Every accepted socket, including one whose close is being handled. */
113
+ sockets(): DurableObjectWebSocket[];
114
+ /** The principal a socket authenticated as. */
115
+ principalOf: (socket: DurableObjectWebSocket) => string;
116
+ /** Application data stored with a socket by `setSocketData`. */
117
+ socketData: <T = unknown>(socket: DurableObjectWebSocket) => T | undefined;
118
+ /** Stores application data with a socket; it survives hibernation and shares the 2 KB limit. */
119
+ setSocketData: (socket: DurableObjectWebSocket, data: unknown) => void;
120
+ /** Whether `principal` holds a socket that has pinged within the presence timeout. */
121
+ present(principal: string): boolean;
122
+ /**
123
+ * The Durable Object's `alarm` when presence is tracked: closes silent
124
+ * sockets, reports principals that lost their last one, and reschedules.
125
+ */
126
+ alarm(): Promise<void>;
64
127
  };
65
128
  type DurableObjectHub = ReturnType<typeof createDurableObjectHub>;
66
129
  /** Calls a hub Durable Object from the Worker. */
@@ -77,18 +140,27 @@ interface ReactiveRoutes {
77
140
  socket: string;
78
141
  /** Reactive HTTP subscribe and unsubscribe, authenticated by the ticket as a bearer token. */
79
142
  reactive: string;
143
+ /** `POST { path, patches? }` from other backends, authenticated by `notifySecret`. */
144
+ notify?: string;
80
145
  }
81
146
  interface ReactiveRoutesOptions {
82
147
  routes: ReactiveRoutes;
83
148
  /** Ticket signing secret. Requests get 503 when it is not configured. */
84
149
  secret: string | undefined;
150
+ /** Bearer secret for `routes.notify`; the route answers 503 while it is unset. */
151
+ notifySecret?: string | undefined;
152
+ /**
153
+ * Browser origins allowed to call the ticket and reactive routes cross-origin,
154
+ * for a hub on a different host from the app.
155
+ */
156
+ allowedOrigins?: readonly string[];
85
157
  /** The hub Durable Object stub that serves these routes. */
86
158
  hub: () => HubStub;
87
159
  /** Returns the principal for a ticket request, or undefined to answer 401. */
88
160
  authenticate(request: Request): string | undefined | Promise<string | undefined>;
89
161
  ticketLifetimeSeconds?: number;
90
162
  }
91
- /** Serves the ticket, socket and reactive HTTP routes; `undefined` for any other path. */
163
+ /** Serves the ticket, socket, reactive and notify routes; `undefined` for any other path. */
92
164
  declare function handleReactiveRoutes(request: Request, options: ReactiveRoutesOptions): Promise<Response | undefined>;
93
165
 
94
166
  export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };
@@ -39,7 +39,11 @@ async function createSignedClusterTicket(options) {
39
39
  encoder.encode(JSON.stringify({ p: options.principal, exp: expiresAt }))
40
40
  );
41
41
  const signature = new Uint8Array(
42
- await crypto.subtle.sign("HMAC", await hmacKey(options.secret, "sign"), encoder.encode(payload))
42
+ await crypto.subtle.sign(
43
+ "HMAC",
44
+ await hmacKey(options.secret, "sign"),
45
+ encoder.encode(payload)
46
+ )
43
47
  );
44
48
  return parseClusterTicket({
45
49
  token: `${payload}.${base64UrlEncode(signature)}`,
@@ -48,8 +52,10 @@ async function createSignedClusterTicket(options) {
48
52
  });
49
53
  }
50
54
  async function verifySignedClusterTicket(token, secret, now = Date.now()) {
51
- const [payload, signature, extra] = token.split(".");
52
- if (!payload || !signature || extra !== void 0) return void 0;
55
+ const parts = token.split(".");
56
+ if (parts.length !== 2) return void 0;
57
+ const [payload, signature] = parts;
58
+ if (!payload || !signature) return void 0;
53
59
  try {
54
60
  const valid = await crypto.subtle.verify(
55
61
  "HMAC",
@@ -87,8 +93,13 @@ function bearerToken(request) {
87
93
  const match = /^Bearer ([^\s]+)$/u.exec(request.headers.get("authorization") ?? "");
88
94
  return match?.[1] ?? "";
89
95
  }
90
- function createHubSocketState(connectionId, principal) {
91
- return { connectionId, principal, subscriptions: [] };
96
+ function createHubSocketState(connectionId, principal, connectedAt) {
97
+ return {
98
+ connectionId,
99
+ principal,
100
+ subscriptions: [],
101
+ ...connectedAt === void 0 ? {} : { connectedAt }
102
+ };
92
103
  }
93
104
  function socketState(socket) {
94
105
  const state = socket.deserializeAttachment();
@@ -118,20 +129,42 @@ function createReactiveHub(options) {
118
129
  return { fromVersion, toVersion };
119
130
  };
120
131
  return {
121
- /** 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
+ */
122
136
  message(socket, raw) {
123
137
  let frame;
124
138
  try {
125
139
  frame = JSON.parse(raw);
126
140
  } catch {
127
- return;
141
+ return void 0;
128
142
  }
129
- const type = isRecord2(frame) ? frame.type : void 0;
130
- if (type === "init") {
143
+ if (!isRecord2(frame) || typeof frame.type !== "string") return void 0;
144
+ if (frame.type === "init") {
131
145
  send(socket, { type: "connected", connectionId: socketState(socket).connectionId });
132
- } else if (type === "ping") {
146
+ } else if (frame.type === "ping") {
133
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");
134
162
  }
163
+ socket.serializeAttachment(next);
164
+ },
165
+ /** The principal a socket authenticated as. */
166
+ principalOf(socket) {
167
+ return socketState(socket).principal;
135
168
  },
136
169
  async subscribe(request, principal) {
137
170
  const handler = resolve(request.path);
@@ -149,9 +182,7 @@ function createReactiveHub(options) {
149
182
  const next = {
150
183
  ...state,
151
184
  subscriptions: [
152
- ...state.subscriptions.filter(
153
- (entry) => entry.subscriptionId !== request.subscriptionId
154
- ),
185
+ ...state.subscriptions.filter((entry) => entry.subscriptionId !== request.subscriptionId),
155
186
  {
156
187
  subscriptionId: request.subscriptionId,
157
188
  generation: request.generation,
@@ -222,6 +253,11 @@ function createReactiveHub(options) {
222
253
  // ../client/src/cloudflare.ts
223
254
  var HUB_PRINCIPAL_HEADER = "x-reactive-principal";
224
255
  var HUB_ACTION_URL = "https://reactive-hub/";
256
+ var PRESENCE_TAG = "presence";
257
+ var PRESENCE_TAG_PREFIX = "presence:";
258
+ var PRESENCE_KEY_PREFIX = "presence:";
259
+ var DEFAULT_PRESENCE_TIMEOUT_MS = 9e4;
260
+ var HEARTBEAT_TIMEOUT_CLOSE_CODE = 4001;
225
261
  function json(value, status = 200) {
226
262
  return Response.json(value, { status, headers: { "cache-control": "no-store" } });
227
263
  }
@@ -255,15 +291,66 @@ function createDurableObjectHub(ctx, options) {
255
291
  );
256
292
  }
257
293
  }
258
- const accept = (request) => {
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;
302
+ const lastSeen = (socket) => Math.max(
303
+ ctx.getWebSocketAutoResponseTimestamp?.(socket)?.getTime() ?? 0,
304
+ socket.deserializeAttachment().connectedAt ?? 0
305
+ );
306
+ const liveSockets = (principal, closing) => {
307
+ const now = Date.now();
308
+ return ctx.getWebSockets(PRESENCE_TAG_PREFIX + principal).filter((socket) => socket !== closing && now - lastSeen(socket) <= timeoutMs);
309
+ };
310
+ const reconcile = async (principal, closing) => {
311
+ const present = liveSockets(principal, closing).length > 0;
312
+ const key = PRESENCE_KEY_PREFIX + principal;
313
+ if ((await ctx.storage.get(key) ?? false) === present) return;
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);
317
+ };
318
+ const scheduleSweep = async () => {
319
+ const { storage } = ctx;
320
+ if (!storage.getAlarm || !storage.setAlarm) {
321
+ throw new Error("Hub presence needs Durable Object alarms");
322
+ }
323
+ const seen = ctx.getWebSockets(PRESENCE_TAG).map((socket) => lastSeen(socket));
324
+ if (seen.length === 0) return;
325
+ const due = Math.min(...seen) + timeoutMs + 1;
326
+ const current = await storage.getAlarm();
327
+ if (current === null || current > due) await storage.setAlarm(due);
328
+ };
329
+ const closed = async (socket) => {
330
+ const principal = principalOf(socket);
331
+ if (isTracked(principal)) await reconcile(principal, socket);
332
+ await options.onClose?.(socket, principal);
333
+ };
334
+ const accept = async (request) => {
259
335
  const principal = request.headers.get(HUB_PRINCIPAL_HEADER);
260
336
  if (!principal) return error("Unauthorized", 401);
261
337
  const Pair = options.WebSocketPair ?? workersGlobal("WebSocketPair");
262
338
  if (!Pair) throw new Error("WebSocketPair is not available in this runtime");
263
339
  const pair = new Pair();
264
340
  const connectionId = randomId();
265
- ctx.acceptWebSocket(pair[1], [connectionId]);
266
- pair[1].serializeAttachment(createHubSocketState(connectionId, principal));
341
+ const tracked = isTracked(principal);
342
+ ctx.acceptWebSocket(
343
+ pair[1],
344
+ tracked ? [connectionId, PRESENCE_TAG, PRESENCE_TAG_PREFIX + principal] : [connectionId]
345
+ );
346
+ pair[1].serializeAttachment(
347
+ createHubSocketState(connectionId, principal, tracked ? Date.now() : void 0)
348
+ );
349
+ if (tracked) {
350
+ await reconcile(principal);
351
+ await scheduleSweep();
352
+ }
353
+ await options.onOpen?.(pair[1], principal);
267
354
  return new Response(null, { status: 101, webSocket: pair[0] });
268
355
  };
269
356
  const act = async (action) => {
@@ -295,20 +382,60 @@ function createDurableObjectHub(ctx, options) {
295
382
  if (request.headers.get("upgrade") === "websocket") return accept(request);
296
383
  return act(await request.json());
297
384
  },
298
- webSocketMessage(socket, message) {
299
- 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);
300
389
  },
301
- webSocketClose(socket, code) {
390
+ async webSocketClose(socket, code) {
302
391
  try {
303
392
  socket.close(code === 1005 ? 1e3 : code, "closed");
304
393
  } catch {
305
394
  }
395
+ await closed(socket);
306
396
  },
307
- webSocketError(socket) {
397
+ async webSocketError(socket) {
308
398
  try {
309
399
  socket.close(1011, "error");
310
400
  } catch {
311
401
  }
402
+ await closed(socket);
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),
414
+ /** Whether `principal` holds a socket that has pinged within the presence timeout. */
415
+ present(principal) {
416
+ return liveSockets(principal).length > 0;
417
+ },
418
+ /**
419
+ * The Durable Object's `alarm` when presence is tracked: closes silent
420
+ * sockets, reports principals that lost their last one, and reschedules.
421
+ */
422
+ async alarm() {
423
+ const now = Date.now();
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 {
432
+ }
433
+ await options.onClose?.(socket, principal);
434
+ }
435
+ for (const principal of principals) {
436
+ await reconcile(principal);
437
+ }
438
+ await scheduleSweep();
312
439
  }
313
440
  };
314
441
  }
@@ -326,24 +453,84 @@ function hubClient(stub) {
326
453
  publish: async (path, patches) => ok(await post({ action: "publish", path, patches }))
327
454
  };
328
455
  }
456
+ function sameSecret(given, expected) {
457
+ const left = new TextEncoder().encode(given);
458
+ const right = new TextEncoder().encode(expected);
459
+ let difference = left.length ^ right.length;
460
+ for (let index = 0; index < right.length; index++) {
461
+ difference |= (left[index] ?? 0) ^ right[index];
462
+ }
463
+ return difference === 0;
464
+ }
465
+ function withCors(response, origin) {
466
+ if (!origin) return response;
467
+ const headers = new Headers(response.headers);
468
+ headers.set("access-control-allow-origin", origin);
469
+ headers.append("vary", "origin");
470
+ return new Response(response.body, {
471
+ status: response.status,
472
+ statusText: response.statusText,
473
+ headers
474
+ });
475
+ }
329
476
  async function handleReactiveRoutes(request, options) {
330
477
  const url = new URL(request.url);
331
478
  const { routes, secret } = options;
332
- if (![routes.ticket, routes.socket, routes.reactive].includes(url.pathname)) return void 0;
333
- if (!secret) return error("Reactive hub is not configured", 503);
334
- if (url.pathname === routes.ticket) {
479
+ const known = [routes.ticket, routes.socket, routes.reactive, routes.notify];
480
+ if (!known.includes(url.pathname)) return void 0;
481
+ const requestOrigin = request.headers.get("origin");
482
+ const corsOrigin = requestOrigin && options.allowedOrigins?.includes(requestOrigin) ? requestOrigin : null;
483
+ if (request.method === "OPTIONS" && url.pathname !== routes.socket) {
484
+ if (!corsOrigin) return new Response(null, { status: 403 });
485
+ return new Response(null, {
486
+ status: 204,
487
+ headers: {
488
+ "access-control-allow-origin": corsOrigin,
489
+ "access-control-allow-methods": "POST, OPTIONS",
490
+ "access-control-allow-headers": "authorization, content-type",
491
+ "access-control-max-age": "86400",
492
+ vary: "origin"
493
+ }
494
+ });
495
+ }
496
+ if (url.pathname === routes.notify) {
335
497
  if (request.method !== "POST") return error("Method not allowed", 405);
498
+ if (!options.notifySecret) return error("Hub notify is not configured", 503);
499
+ if (!sameSecret(bearerToken(request), options.notifySecret)) {
500
+ return error("Unauthorized", 401);
501
+ }
502
+ let body2;
503
+ try {
504
+ body2 = await request.json();
505
+ } catch {
506
+ return error("Invalid JSON", 400);
507
+ }
508
+ const { path, patches } = body2 ?? {};
509
+ if (typeof path !== "string" || patches !== void 0 && !Array.isArray(patches)) {
510
+ return error("Expected { path, patches? }", 400);
511
+ }
512
+ const client = hubClient(options.hub());
513
+ if (patches) await client.publish(path, patches);
514
+ else await client.notify(path);
515
+ return json({ type: "ok" });
516
+ }
517
+ if (!secret) return withCors(error("Reactive hub is not configured", 503), corsOrigin);
518
+ if (url.pathname === routes.ticket) {
519
+ if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
336
520
  const principal2 = await options.authenticate(request);
337
- if (!principal2) return error("Unauthorized", 401);
521
+ if (!principal2) return withCors(error("Unauthorized", 401), corsOrigin);
338
522
  const socketUrl = new URL(routes.socket, url);
339
523
  socketUrl.protocol = "wss:";
340
- return json(
341
- await createSignedClusterTicket({
342
- secret,
343
- principal: principal2,
344
- url: socketUrl.toString(),
345
- ...options.ticketLifetimeSeconds === void 0 ? {} : { lifetimeSeconds: options.ticketLifetimeSeconds }
346
- })
524
+ return withCors(
525
+ json(
526
+ await createSignedClusterTicket({
527
+ secret,
528
+ principal: principal2,
529
+ url: socketUrl.toString(),
530
+ ...options.ticketLifetimeSeconds === void 0 ? {} : { lifetimeSeconds: options.ticketLifetimeSeconds }
531
+ })
532
+ ),
533
+ corsOrigin
347
534
  );
348
535
  }
349
536
  if (url.pathname === routes.socket) {
@@ -356,17 +543,17 @@ async function handleReactiveRoutes(request, options) {
356
543
  forwarded.headers.set(HUB_PRINCIPAL_HEADER, principal2);
357
544
  return options.hub().fetch(forwarded);
358
545
  }
359
- if (request.method !== "POST") return error("Method not allowed", 405);
546
+ if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
360
547
  const principal = await verifySignedClusterTicket(bearerToken(request), secret);
361
- if (!principal) return error("Unauthorized", 401);
548
+ if (!principal) return withCors(error("Unauthorized", 401), corsOrigin);
362
549
  let body;
363
550
  try {
364
551
  body = await request.json();
365
552
  } catch {
366
- return error("Invalid JSON", 400);
553
+ return withCors(error("Invalid JSON", 400), corsOrigin);
367
554
  }
368
- if (!isHubRequest(body)) return error("Unsupported reactive request", 400);
369
- return hubClient(options.hub()).request(body, principal);
555
+ if (!isHubRequest(body)) return withCors(error("Unsupported reactive request", 400), corsOrigin);
556
+ return withCors(await hubClient(options.hub()).request(body, principal), corsOrigin);
370
557
  }
371
558
 
372
559
  export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };