rwsdk 1.0.0-beta.42 → 1.0.0-beta.43

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.
@@ -13,7 +13,11 @@ export declare const fetchTransport: Transport;
13
13
  * making the page interactive. Call this from your client entry point.
14
14
  *
15
15
  * @param transport - Custom transport for server communication (defaults to fetchTransport)
16
- * @param hydrateRootOptions - Options passed to React's hydrateRoot
16
+ * @param hydrateRootOptions - Options passed to React's `hydrateRoot`. Supports all React hydration options including:
17
+ * - `onUncaughtError`: Handler for uncaught errors (async errors, event handler errors).
18
+ * If not provided, defaults to logging errors to console.
19
+ * - `onCaughtError`: Handler for errors caught by error boundaries
20
+ * - `onRecoverableError`: Handler for recoverable errors
17
21
  * @param handleResponse - Custom response handler for navigation errors (navigation GETs)
18
22
  * @param onHydrationUpdate - Callback invoked after a new RSC payload has been committed on the client
19
23
  * @param onActionResponse - Optional hook invoked when an action returns a Response;
@@ -34,6 +38,23 @@ export declare const fetchTransport: Transport;
34
38
  * initClient({ handleResponse });
35
39
  *
36
40
  * @example
41
+ * // With error handling
42
+ * initClient({
43
+ * hydrateRootOptions: {
44
+ * onUncaughtError: (error, errorInfo) => {
45
+ * console.error("Uncaught error:", error);
46
+ * // Send to monitoring service
47
+ * sendToSentry(error, errorInfo);
48
+ * },
49
+ * onCaughtError: (error, errorInfo) => {
50
+ * console.error("Caught error:", error);
51
+ * // Handle errors from error boundaries
52
+ * sendToSentry(error, errorInfo);
53
+ * },
54
+ * },
55
+ * });
56
+ *
57
+ * @example
37
58
  * // With custom React hydration options
38
59
  * initClient({
39
60
  * hydrateRootOptions: {
@@ -105,7 +105,11 @@ export const fetchTransport = (transportContext) => {
105
105
  * making the page interactive. Call this from your client entry point.
106
106
  *
107
107
  * @param transport - Custom transport for server communication (defaults to fetchTransport)
108
- * @param hydrateRootOptions - Options passed to React's hydrateRoot
108
+ * @param hydrateRootOptions - Options passed to React's `hydrateRoot`. Supports all React hydration options including:
109
+ * - `onUncaughtError`: Handler for uncaught errors (async errors, event handler errors).
110
+ * If not provided, defaults to logging errors to console.
111
+ * - `onCaughtError`: Handler for errors caught by error boundaries
112
+ * - `onRecoverableError`: Handler for recoverable errors
109
113
  * @param handleResponse - Custom response handler for navigation errors (navigation GETs)
110
114
  * @param onHydrationUpdate - Callback invoked after a new RSC payload has been committed on the client
111
115
  * @param onActionResponse - Optional hook invoked when an action returns a Response;
@@ -126,6 +130,23 @@ export const fetchTransport = (transportContext) => {
126
130
  * initClient({ handleResponse });
127
131
  *
128
132
  * @example
133
+ * // With error handling
134
+ * initClient({
135
+ * hydrateRootOptions: {
136
+ * onUncaughtError: (error, errorInfo) => {
137
+ * console.error("Uncaught error:", error);
138
+ * // Send to monitoring service
139
+ * sendToSentry(error, errorInfo);
140
+ * },
141
+ * onCaughtError: (error, errorInfo) => {
142
+ * console.error("Caught error:", error);
143
+ * // Handle errors from error boundaries
144
+ * sendToSentry(error, errorInfo);
145
+ * },
146
+ * },
147
+ * });
148
+ *
149
+ * @example
129
150
  * // With custom React hydration options
130
151
  * initClient({
131
152
  * hydrateRootOptions: {
@@ -1,17 +1,32 @@
1
1
  import { RpcStub } from "capnweb";
2
2
  import { DurableObject } from "cloudflare:workers";
3
+ import type { RequestInfo } from "../runtime/requestInfo/types";
3
4
  export type SyncedStateValue = unknown;
4
- type OnSetHandler = (key: string, value: SyncedStateValue) => void;
5
- type OnGetHandler = (key: string, value: SyncedStateValue | undefined) => void;
5
+ type OnSetHandler = (key: string, value: SyncedStateValue, stub: DurableObjectStub<SyncedStateServer>) => void;
6
+ type OnGetHandler = (key: string, value: SyncedStateValue | undefined, stub: DurableObjectStub<SyncedStateServer>) => void;
7
+ type OnKeyHandler = (key: string, stub: DurableObjectStub<SyncedStateServer>) => Promise<string>;
8
+ type OnRoomHandler = (roomId: string | undefined, requestInfo: RequestInfo | null) => Promise<string>;
9
+ type OnSubscribeHandler = (key: string, stub: DurableObjectStub<SyncedStateServer>) => void;
10
+ type OnUnsubscribeHandler = (key: string, stub: DurableObjectStub<SyncedStateServer>) => void;
6
11
  /**
7
12
  * Durable Object that keeps shared state for multiple clients and notifies subscribers.
8
13
  */
9
14
  export declare class SyncedStateServer extends DurableObject {
10
15
  #private;
11
- static registerKeyHandler(handler: (key: string) => Promise<string>): void;
12
- static getKeyHandler(): ((key: string) => Promise<string>) | null;
16
+ static registerKeyHandler(handler: OnKeyHandler | null): void;
17
+ static getKeyHandler(): OnKeyHandler | null;
18
+ static registerRoomHandler(handler: OnRoomHandler | null): void;
19
+ static getRoomHandler(): OnRoomHandler | null;
20
+ static registerNamespace(namespace: DurableObjectNamespace<SyncedStateServer>, durableObjectName?: string): void;
21
+ static getNamespace(): DurableObjectNamespace<SyncedStateServer> | null;
22
+ static getDurableObjectName(): string;
23
+ setStub(stub: DurableObjectStub<SyncedStateServer>): void;
13
24
  static registerSetStateHandler(handler: OnSetHandler | null): void;
14
25
  static registerGetStateHandler(handler: OnGetHandler | null): void;
26
+ static registerSubscribeHandler(handler: OnSubscribeHandler | null): void;
27
+ static registerUnsubscribeHandler(handler: OnUnsubscribeHandler | null): void;
28
+ static getSubscribeHandler(): OnSubscribeHandler | null;
29
+ static getUnsubscribeHandler(): OnUnsubscribeHandler | null;
15
30
  getState(key: string): SyncedStateValue;
16
31
  setState(value: SyncedStateValue, key: string): void;
17
32
  subscribe(key: string, client: RpcStub<(value: SyncedStateValue) => void>): void;
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _a, _SyncedStateServer_keyHandler, _SyncedStateServer_setStateHandler, _SyncedStateServer_getStateHandler, _SyncedStateServer_stateStore, _SyncedStateServer_subscriptions, _SyncedStateServer_subscriptionRefs, _CoordinatorApi_coordinator;
12
+ var _SyncedStateServer_instances, _a, _SyncedStateServer_keyHandler, _SyncedStateServer_roomHandler, _SyncedStateServer_setStateHandler, _SyncedStateServer_getStateHandler, _SyncedStateServer_subscribeHandler, _SyncedStateServer_unsubscribeHandler, _SyncedStateServer_namespace, _SyncedStateServer_durableObjectName, _SyncedStateServer_stub, _SyncedStateServer_stateStore, _SyncedStateServer_subscriptions, _SyncedStateServer_subscriptionRefs, _SyncedStateServer_getStubForHandlers, _CoordinatorApi_coordinator, _CoordinatorApi_stub;
13
13
  import { RpcTarget, newWorkersRpcResponse } from "capnweb";
14
14
  import { DurableObject } from "cloudflare:workers";
15
15
  /**
@@ -18,6 +18,8 @@ import { DurableObject } from "cloudflare:workers";
18
18
  export class SyncedStateServer extends DurableObject {
19
19
  constructor() {
20
20
  super(...arguments);
21
+ _SyncedStateServer_instances.add(this);
22
+ _SyncedStateServer_stub.set(this, null);
21
23
  _SyncedStateServer_stateStore.set(this, new Map());
22
24
  _SyncedStateServer_subscriptions.set(this, new Map());
23
25
  _SyncedStateServer_subscriptionRefs.set(this, new Map());
@@ -28,23 +30,62 @@ export class SyncedStateServer extends DurableObject {
28
30
  static getKeyHandler() {
29
31
  return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_keyHandler);
30
32
  }
33
+ static registerRoomHandler(handler) {
34
+ __classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServer_roomHandler);
35
+ }
36
+ static getRoomHandler() {
37
+ return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_roomHandler);
38
+ }
39
+ static registerNamespace(namespace, durableObjectName) {
40
+ __classPrivateFieldSet(_a, _a, namespace, "f", _SyncedStateServer_namespace);
41
+ if (durableObjectName) {
42
+ __classPrivateFieldSet(_a, _a, durableObjectName, "f", _SyncedStateServer_durableObjectName);
43
+ }
44
+ }
45
+ static getNamespace() {
46
+ return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_namespace);
47
+ }
48
+ static getDurableObjectName() {
49
+ return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_durableObjectName);
50
+ }
51
+ setStub(stub) {
52
+ __classPrivateFieldSet(this, _SyncedStateServer_stub, stub, "f");
53
+ }
31
54
  static registerSetStateHandler(handler) {
32
55
  __classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServer_setStateHandler);
33
56
  }
34
57
  static registerGetStateHandler(handler) {
35
58
  __classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServer_getStateHandler);
36
59
  }
60
+ static registerSubscribeHandler(handler) {
61
+ __classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServer_subscribeHandler);
62
+ }
63
+ static registerUnsubscribeHandler(handler) {
64
+ __classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServer_unsubscribeHandler);
65
+ }
66
+ static getSubscribeHandler() {
67
+ return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_subscribeHandler);
68
+ }
69
+ static getUnsubscribeHandler() {
70
+ return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_unsubscribeHandler);
71
+ }
37
72
  getState(key) {
38
73
  const value = __classPrivateFieldGet(this, _SyncedStateServer_stateStore, "f").get(key);
39
74
  if (__classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_getStateHandler)) {
40
- __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_getStateHandler).call(_a, key, value);
75
+ const stub = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getStubForHandlers).call(this);
76
+ if (stub) {
77
+ __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_getStateHandler).call(_a, key, value, stub);
78
+ }
41
79
  }
42
80
  return value;
43
81
  }
44
82
  setState(value, key) {
45
83
  __classPrivateFieldGet(this, _SyncedStateServer_stateStore, "f").set(key, value);
46
84
  if (__classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_setStateHandler)) {
47
- __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_setStateHandler).call(_a, key, value);
85
+ const stub = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getStubForHandlers).call(this);
86
+ if (stub) {
87
+ __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_setStateHandler).call(_a, key, value, stub);
88
+ }
48
89
  }
49
90
  const subscribers = __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").get(key);
50
91
  if (!subscribers) {
@@ -78,7 +119,9 @@ export class SyncedStateServer extends DurableObject {
78
119
  if (!__classPrivateFieldGet(this, _SyncedStateServer_subscriptionRefs, "f").has(key)) {
79
120
  __classPrivateFieldGet(this, _SyncedStateServer_subscriptionRefs, "f").set(key, new Map());
80
121
  }
81
- const duplicate = client.dup();
122
+ const duplicate = typeof client.dup === "function"
123
+ ? client.dup()
124
+ : client;
82
125
  __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").get(key).add(duplicate);
83
126
  __classPrivateFieldGet(this, _SyncedStateServer_subscriptionRefs, "f").get(key).set(client, duplicate);
84
127
  }
@@ -98,19 +141,44 @@ export class SyncedStateServer extends DurableObject {
98
141
  }
99
142
  }
100
143
  async fetch(request) {
101
- const api = new CoordinatorApi(this);
144
+ // Create a placeholder stub - it will be set by the worker via _setStub
145
+ const api = new CoordinatorApi(this, __classPrivateFieldGet(this, _SyncedStateServer_stub, "f") || {});
102
146
  return newWorkersRpcResponse(request, api);
103
147
  }
104
148
  }
105
- _a = SyncedStateServer, _SyncedStateServer_stateStore = new WeakMap(), _SyncedStateServer_subscriptions = new WeakMap(), _SyncedStateServer_subscriptionRefs = new WeakMap();
149
+ _a = SyncedStateServer, _SyncedStateServer_stub = new WeakMap(), _SyncedStateServer_stateStore = new WeakMap(), _SyncedStateServer_subscriptions = new WeakMap(), _SyncedStateServer_subscriptionRefs = new WeakMap(), _SyncedStateServer_instances = new WeakSet(), _SyncedStateServer_getStubForHandlers = function _SyncedStateServer_getStubForHandlers() {
150
+ // If we have a stub already, use it
151
+ if (__classPrivateFieldGet(this, _SyncedStateServer_stub, "f")) {
152
+ return __classPrivateFieldGet(this, _SyncedStateServer_stub, "f");
153
+ }
154
+ // Otherwise, try to get a stub from the registered namespace using our own ID
155
+ const namespace = __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_namespace);
156
+ if (namespace) {
157
+ return namespace.get(this.ctx.id);
158
+ }
159
+ return null;
160
+ };
106
161
  _SyncedStateServer_keyHandler = { value: null };
162
+ _SyncedStateServer_roomHandler = { value: null };
107
163
  _SyncedStateServer_setStateHandler = { value: null };
108
164
  _SyncedStateServer_getStateHandler = { value: null };
165
+ _SyncedStateServer_subscribeHandler = { value: null };
166
+ _SyncedStateServer_unsubscribeHandler = { value: null };
167
+ _SyncedStateServer_namespace = { value: null };
168
+ _SyncedStateServer_durableObjectName = { value: "syncedState" };
109
169
  class CoordinatorApi extends RpcTarget {
110
- constructor(coordinator) {
170
+ constructor(coordinator, stub) {
111
171
  super();
112
172
  _CoordinatorApi_coordinator.set(this, void 0);
173
+ _CoordinatorApi_stub.set(this, void 0);
113
174
  __classPrivateFieldSet(this, _CoordinatorApi_coordinator, coordinator, "f");
175
+ __classPrivateFieldSet(this, _CoordinatorApi_stub, stub, "f");
176
+ coordinator.setStub(stub);
177
+ }
178
+ // Internal method to set the stub - called from worker
179
+ _setStub(stub) {
180
+ __classPrivateFieldSet(this, _CoordinatorApi_stub, stub, "f");
181
+ __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").setStub(stub);
114
182
  }
115
183
  getState(key) {
116
184
  return __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").getState(key);
@@ -125,4 +193,4 @@ class CoordinatorApi extends RpcTarget {
125
193
  __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").unsubscribe(key, client);
126
194
  }
127
195
  }
128
- _CoordinatorApi_coordinator = new WeakMap();
196
+ _CoordinatorApi_coordinator = new WeakMap(), _CoordinatorApi_stub = new WeakMap();
@@ -46,9 +46,12 @@ describe("SyncedStateServer", () => {
46
46
  });
47
47
  it("invokes registered onSet handler", () => {
48
48
  const coordinator = new SyncedStateServer({}, {});
49
+ const mockStub = {};
50
+ coordinator.setStub(mockStub);
49
51
  const calls = [];
50
- SyncedStateServer.registerSetStateHandler((key, value) => {
52
+ SyncedStateServer.registerSetStateHandler((key, value, stub) => {
51
53
  calls.push({ key, value });
54
+ expect(stub).toBe(mockStub);
52
55
  });
53
56
  coordinator.setState(2, "counter");
54
57
  expect(calls).toEqual([{ key: "counter", value: 2 }]);
@@ -56,9 +59,12 @@ describe("SyncedStateServer", () => {
56
59
  });
57
60
  it("invokes registered onGet handler", () => {
58
61
  const coordinator = new SyncedStateServer({}, {});
62
+ const mockStub = {};
63
+ coordinator.setStub(mockStub);
59
64
  const calls = [];
60
- SyncedStateServer.registerGetStateHandler((key, value) => {
65
+ SyncedStateServer.registerGetStateHandler((key, value, stub) => {
61
66
  calls.push({ key, value });
67
+ expect(stub).toBe(mockStub);
62
68
  });
63
69
  coordinator.setState(4, "counter");
64
70
  expect(coordinator.getState("counter")).toBe(4);
@@ -66,43 +72,44 @@ describe("SyncedStateServer", () => {
66
72
  SyncedStateServer.registerGetStateHandler(null);
67
73
  });
68
74
  describe("registerKeyHandler", () => {
75
+ const mockStub = {};
69
76
  afterEach(() => {
70
- SyncedStateServer.registerKeyHandler(async (key) => key);
77
+ SyncedStateServer.registerKeyHandler(async (key, stub) => key);
71
78
  });
72
79
  it("stores and retrieves the registered handler", async () => {
73
- const handler = async (key) => `transformed:${key}`;
80
+ const handler = async (key, stub) => `transformed:${key}`;
74
81
  SyncedStateServer.registerKeyHandler(handler);
75
82
  const retrievedHandler = SyncedStateServer.getKeyHandler();
76
83
  expect(retrievedHandler).toBe(handler);
77
84
  });
78
85
  it("transforms keys using the registered handler", async () => {
79
- const handler = async (key) => `user:123:${key}`;
86
+ const handler = async (key, stub) => `user:123:${key}`;
80
87
  SyncedStateServer.registerKeyHandler(handler);
81
- const result = await handler("counter");
88
+ const result = await handler("counter", mockStub);
82
89
  expect(result).toBe("user:123:counter");
83
90
  });
84
91
  it("returns null when no handler is registered", () => {
85
- SyncedStateServer.registerKeyHandler(async (key) => key);
92
+ SyncedStateServer.registerKeyHandler(async (key, stub) => key);
86
93
  const handler = SyncedStateServer.getKeyHandler();
87
94
  expect(handler).not.toBeNull();
88
95
  });
89
96
  it("allows handler to be async", async () => {
90
- const handler = async (key) => {
97
+ const handler = async (key, stub) => {
91
98
  await new Promise((resolve) => setTimeout(resolve, 10));
92
99
  return `async:${key}`;
93
100
  };
94
101
  SyncedStateServer.registerKeyHandler(handler);
95
- const result = await handler("test");
102
+ const result = await handler("test", mockStub);
96
103
  expect(result).toBe("async:test");
97
104
  });
98
105
  it("handler receives the correct key parameter", async () => {
99
106
  let receivedKey = "";
100
- const handler = async (key) => {
107
+ const handler = async (key, stub) => {
101
108
  receivedKey = key;
102
109
  return key;
103
110
  };
104
111
  SyncedStateServer.registerKeyHandler(handler);
105
- await handler("myKey");
112
+ await handler("myKey", mockStub);
106
113
  expect(receivedKey).toBe("myKey");
107
114
  });
108
115
  });
@@ -18,52 +18,53 @@ describe("SyncedStateProxy", () => {
18
18
  beforeEach(() => {
19
19
  mockCoordinator = new SyncedStateServer({}, {});
20
20
  });
21
+ const mockStub = {};
21
22
  afterEach(() => {
22
- SyncedStateServer.registerKeyHandler(async (key) => key);
23
+ SyncedStateServer.registerKeyHandler(async (key, stub) => key);
23
24
  });
24
25
  it("transforms keys before calling coordinator methods when handler is registered", async () => {
25
- const handler = async (key) => `transformed:${key}`;
26
+ const handler = async (key, stub) => `transformed:${key}`;
26
27
  SyncedStateServer.registerKeyHandler(handler);
27
- const transformedKey = await handler("counter");
28
+ const transformedKey = await handler("counter", mockStub);
28
29
  expect(transformedKey).toBe("transformed:counter");
29
30
  mockCoordinator.setState(5, transformedKey);
30
31
  const value = mockCoordinator.getState(transformedKey);
31
32
  expect(value).toBe(5);
32
33
  });
33
34
  it("does not transform keys when no handler is registered", () => {
34
- SyncedStateServer.registerKeyHandler(async (key) => key);
35
+ SyncedStateServer.registerKeyHandler(async (key, stub) => key);
35
36
  const handler = SyncedStateServer.getKeyHandler();
36
37
  expect(handler).not.toBeNull();
37
38
  });
38
39
  it("passes through original key when handler returns it unchanged", async () => {
39
- const handler = async (key) => key;
40
+ const handler = async (key, stub) => key;
40
41
  SyncedStateServer.registerKeyHandler(handler);
41
- const result = await handler("counter");
42
+ const result = await handler("counter", mockStub);
42
43
  expect(result).toBe("counter");
43
44
  });
44
45
  it("handler can scope keys per user", async () => {
45
- const handler = async (key) => {
46
+ const handler = async (key, stub) => {
46
47
  const userId = "user123";
47
48
  return `user:${userId}:${key}`;
48
49
  };
49
50
  SyncedStateServer.registerKeyHandler(handler);
50
- const result = await handler("settings");
51
+ const result = await handler("settings", mockStub);
51
52
  expect(result).toBe("user:user123:settings");
52
53
  });
53
54
  it("allows errors from handler to propagate", async () => {
54
- const handler = async (_key) => {
55
+ const handler = async (_key, stub) => {
55
56
  throw new Error("Handler error");
56
57
  };
57
58
  SyncedStateServer.registerKeyHandler(handler);
58
- await expect(handler("test")).rejects.toThrow("Handler error");
59
+ await expect(handler("test", mockStub)).rejects.toThrow("Handler error");
59
60
  });
60
61
  it("handles async operations in handler", async () => {
61
- const handler = async (key) => {
62
+ const handler = async (key, stub) => {
62
63
  await new Promise((resolve) => setTimeout(resolve, 5));
63
64
  return `async:${key}`;
64
65
  };
65
66
  SyncedStateServer.registerKeyHandler(handler);
66
- const result = await handler("data");
67
+ const result = await handler("data", mockStub);
67
68
  expect(result).toBe("async:data");
68
69
  });
69
70
  });
@@ -6,12 +6,14 @@ export type SyncedStateClient = {
6
6
  };
7
7
  /**
8
8
  * Returns a cached client for the provided endpoint, creating it when necessary.
9
+ * The client is wrapped to track subscriptions for cleanup on page reload.
9
10
  * @param endpoint Endpoint to connect to.
10
11
  * @returns RPC client instance.
11
12
  */
12
13
  export declare const getSyncedStateClient: (endpoint?: string) => SyncedStateClient;
13
14
  /**
14
15
  * Initializes and caches an RPC client instance for the sync state endpoint.
16
+ * The client is wrapped to track subscriptions for cleanup on page reload.
15
17
  * @param options Optional endpoint override.
16
18
  * @returns Cached client instance or `null` when running without `window`.
17
19
  */
@@ -20,6 +22,7 @@ export declare const initSyncedStateClient: (options?: {
20
22
  }) => SyncedStateClient | null;
21
23
  /**
22
24
  * Injects a client instance for tests and updates the cached endpoint.
25
+ * Also clears the subscription registry for test isolation.
23
26
  * @param client Stub client instance or `null` to clear the cache.
24
27
  * @param endpoint Endpoint associated with the injected client.
25
28
  */
@@ -1,39 +1,103 @@
1
1
  import { newWebSocketRpcSession } from "capnweb";
2
2
  import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
3
- let cachedClient = null;
4
- let cachedEndpoint = DEFAULT_SYNCED_STATE_PATH;
3
+ // Map of endpoint URLs to their respective clients
4
+ const clientCache = new Map();
5
+ const activeSubscriptions = new Set();
6
+ // Set up beforeunload handler to unsubscribe all active subscriptions
7
+ if (typeof window !== "undefined") {
8
+ const handleBeforeUnload = () => {
9
+ if (activeSubscriptions.size === 0) {
10
+ return;
11
+ }
12
+ // Unsubscribe all active subscriptions
13
+ // Use a synchronous approach where possible, but don't block page unload
14
+ const subscriptions = Array.from(activeSubscriptions);
15
+ activeSubscriptions.clear();
16
+ // Fire-and-forget unsubscribe calls - we can't await during beforeunload
17
+ for (const { key, handler, client } of subscriptions) {
18
+ void client.unsubscribe(key, handler).catch(() => {
19
+ // Ignore errors during page unload - the connection will be closed anyway
20
+ });
21
+ }
22
+ };
23
+ window.addEventListener("beforeunload", handleBeforeUnload);
24
+ }
5
25
  /**
6
26
  * Returns a cached client for the provided endpoint, creating it when necessary.
27
+ * The client is wrapped to track subscriptions for cleanup on page reload.
7
28
  * @param endpoint Endpoint to connect to.
8
29
  * @returns RPC client instance.
9
30
  */
10
- export const getSyncedStateClient = (endpoint = cachedEndpoint) => {
11
- if (cachedClient && endpoint === cachedEndpoint) {
12
- return cachedClient;
31
+ export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
32
+ // Return existing client if already cached for this endpoint
33
+ const existingClient = clientCache.get(endpoint);
34
+ if (existingClient) {
35
+ return existingClient;
13
36
  }
14
- cachedEndpoint = endpoint;
15
- cachedClient = newWebSocketRpcSession(cachedEndpoint);
16
- return cachedClient;
37
+ const baseClient = newWebSocketRpcSession(endpoint);
38
+ // Wrap the client using a Proxy to track subscriptions
39
+ // The RPC client uses dynamic property access, so we can't use .bind()
40
+ const wrappedClient = new Proxy(baseClient, {
41
+ get(target, prop) {
42
+ if (prop === "subscribe") {
43
+ return async (key, handler) => {
44
+ const subscription = {
45
+ key,
46
+ handler,
47
+ client: wrappedClient,
48
+ };
49
+ activeSubscriptions.add(subscription);
50
+ return target[prop](key, handler);
51
+ };
52
+ }
53
+ if (prop === "unsubscribe") {
54
+ return async (key, handler) => {
55
+ // Find and remove the subscription
56
+ for (const sub of activeSubscriptions) {
57
+ if (sub.key === key &&
58
+ sub.handler === handler &&
59
+ sub.client === wrappedClient) {
60
+ activeSubscriptions.delete(sub);
61
+ break;
62
+ }
63
+ }
64
+ return target[prop](key, handler);
65
+ };
66
+ }
67
+ // Pass through all other properties/methods
68
+ return target[prop];
69
+ },
70
+ });
71
+ // Cache the client for this endpoint
72
+ clientCache.set(endpoint, wrappedClient);
73
+ return wrappedClient;
17
74
  };
18
75
  /**
19
76
  * Initializes and caches an RPC client instance for the sync state endpoint.
77
+ * The client is wrapped to track subscriptions for cleanup on page reload.
20
78
  * @param options Optional endpoint override.
21
79
  * @returns Cached client instance or `null` when running without `window`.
22
80
  */
23
81
  export const initSyncedStateClient = (options = {}) => {
24
- cachedEndpoint = options.endpoint ?? DEFAULT_SYNCED_STATE_PATH;
82
+ const endpoint = options.endpoint ?? DEFAULT_SYNCED_STATE_PATH;
25
83
  if (typeof window === "undefined") {
26
84
  return null;
27
85
  }
28
- cachedClient = newWebSocketRpcSession(cachedEndpoint);
29
- return cachedClient;
86
+ // Use getSyncedStateClient which now handles caching via Map
87
+ return getSyncedStateClient(endpoint);
30
88
  };
31
89
  /**
32
90
  * Injects a client instance for tests and updates the cached endpoint.
91
+ * Also clears the subscription registry for test isolation.
33
92
  * @param client Stub client instance or `null` to clear the cache.
34
93
  * @param endpoint Endpoint associated with the injected client.
35
94
  */
36
95
  export const setSyncedStateClientForTesting = (client, endpoint = DEFAULT_SYNCED_STATE_PATH) => {
37
- cachedClient = client;
38
- cachedEndpoint = endpoint;
96
+ if (client) {
97
+ clientCache.set(endpoint, client);
98
+ }
99
+ else {
100
+ clientCache.delete(endpoint);
101
+ }
102
+ activeSubscriptions.clear();
39
103
  };
@@ -8,6 +8,7 @@ type HookDeps = {
8
8
  type Setter<T> = (value: T | ((previous: T) => T)) => void;
9
9
  export type CreateSyncedStateHookOptions = {
10
10
  url?: string;
11
+ roomId?: string;
11
12
  hooks?: HookDeps;
12
13
  };
13
14
  /**
@@ -15,6 +16,6 @@ export type CreateSyncedStateHookOptions = {
15
16
  * @param options Optional overrides for endpoint and React primitives.
16
17
  * @returns Hook that syncs state through the sync state service.
17
18
  */
18
- export declare const createSyncedStateHook: (options?: CreateSyncedStateHookOptions) => <T>(initialValue: T, key: string) => [T, Setter<T>];
19
- export declare const useSyncedState: <T>(initialValue: T, key: string) => [T, Setter<T>];
19
+ export declare const createSyncedStateHook: (options?: CreateSyncedStateHookOptions) => <T>(initialValue: T, key: string, roomId?: string | undefined) => [T, Setter<T>];
20
+ export declare const useSyncedState: <T>(initialValue: T, key: string, roomId?: string | undefined) => [T, Setter<T>];
20
21
  export {};
@@ -13,13 +13,14 @@ const defaultDeps = {
13
13
  * @returns Hook that syncs state through the sync state service.
14
14
  */
15
15
  export const createSyncedStateHook = (options = {}) => {
16
- const resolvedUrl = options.url ?? DEFAULT_SYNCED_STATE_PATH;
16
+ const basePath = options.url ?? DEFAULT_SYNCED_STATE_PATH;
17
17
  const deps = options.hooks ?? defaultDeps;
18
18
  const { useState, useEffect, useRef, useCallback } = deps;
19
- return function useSyncedState(initialValue, key) {
19
+ return function useSyncedState(initialValue, key, roomId = options.roomId) {
20
20
  if (typeof window === "undefined" && !options.hooks) {
21
21
  return [initialValue, () => { }];
22
22
  }
23
+ const resolvedUrl = roomId ? `${basePath}/${roomId}` : basePath;
23
24
  const client = getSyncedStateClient(resolvedUrl);
24
25
  const [value, setValue] = useState(initialValue);
25
26
  const valueRef = useRef(value);
@@ -49,7 +50,12 @@ export const createSyncedStateHook = (options = {}) => {
49
50
  void client.subscribe(key, handleUpdate);
50
51
  return () => {
51
52
  isActive = false;
52
- void client.unsubscribe(key, handleUpdate);
53
+ // Call unsubscribe when component unmounts
54
+ // Page reloads are handled by the beforeunload event listener in client-core.ts
55
+ void client.unsubscribe(key, handleUpdate).catch((error) => {
56
+ // Log but don't throw - cleanup should not prevent unmounting
57
+ console.error("[useSyncedState] Error during unsubscribe:", error);
58
+ });
53
59
  };
54
60
  }, [client, key, setValue, valueRef]);
55
61
  return [value, setSyncValue];
@@ -1,3 +1,4 @@
1
+ import type { RequestInfo } from "../runtime/requestInfo/types";
1
2
  import { SyncedStateServer } from "./SyncedStateServer.mjs";
2
3
  export { SyncedStateServer };
3
4
  export type SyncedStateRouteOptions = {
@@ -10,4 +11,4 @@ export type SyncedStateRouteOptions = {
10
11
  * @param options Optional overrides for base path and object name.
11
12
  * @returns Router entries for the sync state API.
12
13
  */
13
- export declare const syncedStateRoutes: (getNamespace: (env: Cloudflare.Env) => DurableObjectNamespace<SyncedStateServer>, options?: SyncedStateRouteOptions) => import("../runtime/lib/router.js").RouteDefinition<`/${string}`, import("../runtime/worker.js").RequestInfo<any, import("../runtime/worker.js").DefaultAppContext>>[];
14
+ export declare const syncedStateRoutes: (getNamespace: (env: Cloudflare.Env) => DurableObjectNamespace<SyncedStateServer>, options?: SyncedStateRouteOptions) => import("../runtime/lib/router.js").RouteDefinition<`/${string}`, RequestInfo<any, import("../runtime/requestInfo/types").DefaultAppContext>>[];
@@ -9,40 +9,91 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _SyncedStateProxy_stub, _SyncedStateProxy_keyHandler;
12
+ var _SyncedStateProxy_instances, _SyncedStateProxy_stub, _SyncedStateProxy_keyHandler, _SyncedStateProxy_requestInfo, _SyncedStateProxy_transformKey, _SyncedStateProxy_callHandler;
13
13
  import { RpcTarget, newWorkersRpcResponse } from "capnweb";
14
14
  import { env } from "cloudflare:workers";
15
15
  import { route } from "../runtime/entries/router";
16
+ import { runWithRequestInfo } from "../runtime/requestInfo/worker";
16
17
  import { SyncedStateServer, } from "./SyncedStateServer.mjs";
17
18
  import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
18
19
  export { SyncedStateServer };
19
20
  const DEFAULT_SYNC_STATE_NAME = "syncedState";
20
21
  class SyncedStateProxy extends RpcTarget {
21
- constructor(stub, keyHandler) {
22
+ constructor(stub, keyHandler, requestInfo) {
22
23
  super();
24
+ _SyncedStateProxy_instances.add(this);
23
25
  _SyncedStateProxy_stub.set(this, void 0);
24
26
  _SyncedStateProxy_keyHandler.set(this, void 0);
27
+ _SyncedStateProxy_requestInfo.set(this, void 0);
25
28
  __classPrivateFieldSet(this, _SyncedStateProxy_stub, stub, "f");
26
29
  __classPrivateFieldSet(this, _SyncedStateProxy_keyHandler, keyHandler, "f");
30
+ __classPrivateFieldSet(this, _SyncedStateProxy_requestInfo, requestInfo, "f");
31
+ // Set stub in DO instance so handlers can access it
32
+ if (stub && typeof stub._setStub === "function") {
33
+ void stub._setStub(stub);
34
+ }
27
35
  }
28
36
  async getState(key) {
29
- const transformedKey = __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f") ? await __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f").call(this, key) : key;
37
+ const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
30
38
  return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").getState(transformedKey);
31
39
  }
32
40
  async setState(value, key) {
33
- const transformedKey = __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f") ? await __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f").call(this, key) : key;
41
+ const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
34
42
  return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").setState(value, transformedKey);
35
43
  }
36
44
  async subscribe(key, client) {
37
- const transformedKey = __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f") ? await __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f").call(this, key) : key;
38
- return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").subscribe(transformedKey, client);
45
+ const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
46
+ const subscribeHandler = SyncedStateServer.getSubscribeHandler();
47
+ if (subscribeHandler) {
48
+ __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_callHandler).call(this, subscribeHandler, transformedKey, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f"));
49
+ }
50
+ // dup the client if it is a function; otherwise, pass it as is;
51
+ // this is because the client is a WebSocketRpcSession, and we need to pass a new instance of the client to the DO;
52
+ const clientToPass = typeof client.dup === "function" ? client.dup() : client;
53
+ return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").subscribe(transformedKey, clientToPass);
39
54
  }
40
55
  async unsubscribe(key, client) {
41
- const transformedKey = __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f") ? await __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f").call(this, key) : key;
42
- return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").unsubscribe(transformedKey, client);
56
+ const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
57
+ // Call unsubscribe handler before unsubscribe, similar to subscribe handler
58
+ // This ensures the handler is called even if the unsubscribe doesn't find a match
59
+ // or if the RPC call fails
60
+ const unsubscribeHandler = SyncedStateServer.getUnsubscribeHandler();
61
+ if (unsubscribeHandler) {
62
+ __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_callHandler).call(this, unsubscribeHandler, transformedKey, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f"));
63
+ }
64
+ try {
65
+ await __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").unsubscribe(transformedKey, client);
66
+ }
67
+ catch (error) {
68
+ // Ignore errors during unsubscribe - handler has already been called
69
+ // This prevents RPC stub disposal errors from propagating
70
+ }
43
71
  }
44
72
  }
45
- _SyncedStateProxy_stub = new WeakMap(), _SyncedStateProxy_keyHandler = new WeakMap();
73
+ _SyncedStateProxy_stub = new WeakMap(), _SyncedStateProxy_keyHandler = new WeakMap(), _SyncedStateProxy_requestInfo = new WeakMap(), _SyncedStateProxy_instances = new WeakSet(), _SyncedStateProxy_transformKey =
74
+ /**
75
+ * Transforms a key using the keyHandler, preserving async context so requestInfo.ctx is available.
76
+ */
77
+ async function _SyncedStateProxy_transformKey(key) {
78
+ if (!__classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f")) {
79
+ return key;
80
+ }
81
+ if (__classPrivateFieldGet(this, _SyncedStateProxy_requestInfo, "f")) {
82
+ // Preserve async context when calling keyHandler so requestInfo.ctx is available
83
+ return await runWithRequestInfo(__classPrivateFieldGet(this, _SyncedStateProxy_requestInfo, "f"), async () => await __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f")(key, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f")));
84
+ }
85
+ return await __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f").call(this, key, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f"));
86
+ }, _SyncedStateProxy_callHandler = function _SyncedStateProxy_callHandler(handler, key, stub) {
87
+ if (__classPrivateFieldGet(this, _SyncedStateProxy_requestInfo, "f")) {
88
+ // Preserve async context when calling handler so requestInfo.ctx is available
89
+ runWithRequestInfo(__classPrivateFieldGet(this, _SyncedStateProxy_requestInfo, "f"), () => {
90
+ handler(key, stub);
91
+ });
92
+ }
93
+ else {
94
+ handler(key, stub);
95
+ }
96
+ };
46
97
  /**
47
98
  * Registers routes that forward sync state requests to the configured Durable Object namespace.
48
99
  * @param getNamespace Function that returns the Durable Object namespace from the Worker env.
@@ -52,18 +103,33 @@ _SyncedStateProxy_stub = new WeakMap(), _SyncedStateProxy_keyHandler = new WeakM
52
103
  export const syncedStateRoutes = (getNamespace, options = {}) => {
53
104
  const basePath = options.basePath ?? DEFAULT_SYNCED_STATE_PATH;
54
105
  const durableObjectName = options.durableObjectName ?? DEFAULT_SYNC_STATE_NAME;
55
- const forwardRequest = async (request) => {
106
+ const forwardRequest = async (request, requestInfo) => {
107
+ const namespace = getNamespace(env);
108
+ // Register the namespace and DO name so handlers can access it
109
+ SyncedStateServer.registerNamespace(namespace, durableObjectName);
56
110
  const keyHandler = SyncedStateServer.getKeyHandler();
111
+ const roomHandler = SyncedStateServer.getRoomHandler();
112
+ // Get the room ID from the URL parameter, or undefined if not present
113
+ const idParam = requestInfo.params?.id;
114
+ // Resolve the room name using the roomHandler if present, otherwise use the param or default
115
+ let resolvedRoomName;
116
+ if (roomHandler) {
117
+ resolvedRoomName = await runWithRequestInfo(requestInfo, async () => await roomHandler(idParam, requestInfo));
118
+ }
119
+ else {
120
+ resolvedRoomName = idParam ?? durableObjectName;
121
+ }
57
122
  if (!keyHandler) {
58
- const namespace = getNamespace(env);
59
- const id = namespace.idFromName(durableObjectName);
123
+ const id = namespace.idFromName(resolvedRoomName);
60
124
  return namespace.get(id).fetch(request);
61
125
  }
62
- const namespace = getNamespace(env);
63
- const id = namespace.idFromName(durableObjectName);
126
+ const id = namespace.idFromName(resolvedRoomName);
64
127
  const coordinator = namespace.get(id);
65
- const proxy = new SyncedStateProxy(coordinator, keyHandler);
128
+ const proxy = new SyncedStateProxy(coordinator, keyHandler, requestInfo);
66
129
  return newWorkersRpcResponse(request, proxy);
67
130
  };
68
- return [route(basePath, ({ request }) => forwardRequest(request))];
131
+ return [
132
+ route(basePath, (requestInfo) => forwardRequest(requestInfo.request, requestInfo)),
133
+ route(basePath + "/:id", (requestInfo) => forwardRequest(requestInfo.request, requestInfo)),
134
+ ];
69
135
  };
@@ -288,6 +288,7 @@ function SidebarGroup() { return jsx("div", {}); }
288
288
  function SidebarGroupLabel() { return jsx("div", {}); }
289
289
  function SidebarGroupAction() { return jsx("div", {}); }
290
290
  function SidebarGroupContent() { return jsx("div", {}); }
291
+ function SidebarGroupContent() { return jsx("div", {}); }
291
292
  function SidebarMenu() { return jsx("div", {}); }
292
293
  function SidebarMenuItem() { return jsx("div", {}); }
293
294
  function SidebarMenuButton() { return jsx("div", {}); }
@@ -326,6 +327,37 @@ const SidebarSeparator = registerClientReference(SSRModule, "/test/file.tsx", "S
326
327
  const SidebarTrigger = registerClientReference(SSRModule, "/test/file.tsx", "SidebarTrigger");
327
328
  const useSidebar = registerClientReference(SSRModule, "/test/file.tsx", "useSidebar");
328
329
  export { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, useSidebar };
330
+ `);
331
+ });
332
+ it("does not transform inlined functions (Issue #471)", async () => {
333
+ const code = `"use client"
334
+
335
+ export function Stars({ level }) {
336
+ const renderStars = (level) => {
337
+ return level;
338
+ }
339
+ return renderStars(level);
340
+ }`;
341
+ expect((await transform(code)) ?? "").toEqual(`import { ssrLoadModule } from "rwsdk/__ssr_bridge";
342
+ import { registerClientReference } from "rwsdk/worker";
343
+ const SSRModule = await ssrLoadModule("/test/file.tsx");
344
+ const Stars = registerClientReference(SSRModule, "/test/file.tsx", "Stars");
345
+ export { Stars };
346
+ `);
347
+ });
348
+ it("does not transform inlined functions in default export (Issue #471)", async () => {
349
+ const code = `"use client"
350
+
351
+ export default function Stars({ level }) {
352
+ const renderStars = (level) => {
353
+ return level;
354
+ }
355
+ return renderStars(level);
356
+ }`;
357
+ expect((await transform(code)) ?? "").toEqual(`import { ssrLoadModule } from "rwsdk/__ssr_bridge";
358
+ import { registerClientReference } from "rwsdk/worker";
359
+ const SSRModule = await ssrLoadModule("/test/file.tsx");
360
+ export default registerClientReference(SSRModule, "/test/file.tsx", "default");
329
361
  `);
330
362
  });
331
363
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.0.0-beta.42",
3
+ "version": "1.0.0-beta.43",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,13 +18,16 @@
18
18
  "build:watch": "npm run build -- --watch",
19
19
  "release": "./scripts/release.sh",
20
20
  "test": "vitest --run",
21
+ "bench": "vitest bench --run",
22
+ "bench:baseline": "vitest bench --run --outputJson=benchmarks/router-bench-baseline.json",
23
+ "bench:compare": "vitest bench --run --compare=benchmarks/router-bench-baseline.json",
21
24
  "debug:sync": "tsx ./src/scripts/debug-sync.mts",
22
25
  "smoke-test": "tsx ./src/scripts/smoke-test.mts"
23
26
  },
24
27
  "exports": {
25
28
  "./vite": {
26
- "default": "./dist/vite/index.mjs",
27
- "types": "./dist/vite/index.d.mts"
29
+ "types": "./dist/vite/index.d.mts",
30
+ "default": "./dist/vite/index.mjs"
28
31
  },
29
32
  "./worker": {
30
33
  "react-server": "./dist/runtime/entries/worker.js",