rwsdk 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { type NavigationCache, type NavigationCacheStorage } from "./navigationCache.js";
2
2
  export type { NavigationCache, NavigationCacheStorage };
3
3
  export interface ClientNavigationOptions {
4
- onNavigate?: () => void;
4
+ onNavigate?: () => Promise<void> | void;
5
5
  scrollToTop?: boolean;
6
6
  scrollBehavior?: "auto" | "smooth" | "instant";
7
7
  cacheStorage?: NavigationCacheStorage;
@@ -9,6 +9,7 @@ export interface ClientNavigationOptions {
9
9
  export declare function validateClickEvent(event: MouseEvent, target: HTMLElement): boolean;
10
10
  export interface NavigateOptions {
11
11
  history?: "push" | "replace";
12
+ onNavigate?: () => Promise<void> | void;
12
13
  info?: {
13
14
  scrollToTop?: boolean;
14
15
  scrollBehavior?: "auto" | "smooth" | "instant";
@@ -32,6 +32,10 @@ export function validateClickEvent(event, target) {
32
32
  return true;
33
33
  }
34
34
  let IS_CLIENT_NAVIGATION = false;
35
+ // Scroll intent recorded at navigation time and applied post-commit in
36
+ // onHydrated, so the new scroll position aligns with the new DOM rather
37
+ // than flashing on top of the old one.
38
+ let pendingScroll = null;
35
39
  export async function navigate(href, options = { history: "push" }) {
36
40
  if (!IS_CLIENT_NAVIGATION) {
37
41
  window.location.href = href;
@@ -45,17 +49,14 @@ export async function navigate(href, options = { history: "push" }) {
45
49
  else {
46
50
  window.history.replaceState({ path: href }, "", url);
47
51
  }
48
- await globalThis.__rsc_callServer(null, null, "navigation");
49
52
  const scrollToTop = options.info?.scrollToTop ?? true;
50
- const scrollBehavior = options.info?.scrollBehavior ?? "instant";
51
- if (scrollToTop && history.scrollRestoration === "auto") {
52
- window.scrollTo({
53
- top: 0,
54
- left: 0,
55
- behavior: scrollBehavior,
56
- });
57
- saveScrollPosition(0, 0);
53
+ const scrollBehavior = (options.info?.scrollBehavior ??
54
+ "instant");
55
+ if (scrollToTop) {
56
+ pendingScroll = { x: 0, y: 0, behavior: scrollBehavior };
58
57
  }
58
+ await options.onNavigate?.();
59
+ await globalThis.__rsc_callServer(null, null, "navigation");
59
60
  }
60
61
  function saveScrollPosition(x, y) {
61
62
  window.history.replaceState({
@@ -64,6 +65,14 @@ function saveScrollPosition(x, y) {
64
65
  scrollY: y,
65
66
  }, "", window.location.href);
66
67
  }
68
+ function applyPendingScroll() {
69
+ if (!pendingScroll)
70
+ return;
71
+ const { x, y, behavior } = pendingScroll;
72
+ pendingScroll = null;
73
+ window.scrollTo({ top: y, left: x, behavior });
74
+ saveScrollPosition(x, y);
75
+ }
67
76
  /**
68
77
  * Initializes client-side navigation for Single Page App (SPA) behavior.
69
78
  *
@@ -107,7 +116,23 @@ function saveScrollPosition(x, y) {
107
116
  */
108
117
  export function initClientNavigation(opts = {}) {
109
118
  IS_CLIENT_NAVIGATION = true;
110
- history.scrollRestoration = "auto";
119
+ // Take manual control of scroll restoration. With "auto", the browser
120
+ // restores scroll immediately on popstate — before the RSC payload has
121
+ // committed — which causes the old DOM to flash at the new scroll offset.
122
+ history.scrollRestoration = "manual";
123
+ // If we're booting onto an entry that already has a saved scroll (e.g.
124
+ // a reload after scrolling, or a back-forward cache restore), queue that
125
+ // position so the first commit lands us where the user left off.
126
+ const bootState = window.history.state;
127
+ if (bootState &&
128
+ (typeof bootState.scrollX === "number" ||
129
+ typeof bootState.scrollY === "number")) {
130
+ pendingScroll = {
131
+ x: bootState.scrollX ?? 0,
132
+ y: bootState.scrollY ?? 0,
133
+ behavior: "instant",
134
+ };
135
+ }
111
136
  document.addEventListener("click", async function handleClickEvent(event) {
112
137
  if (!validateClickEvent(event, event.target)) {
113
138
  return;
@@ -116,11 +141,31 @@ export function initClientNavigation(opts = {}) {
116
141
  const el = event.target;
117
142
  const a = el.closest("a");
118
143
  const href = a?.getAttribute("href");
119
- await navigate(href);
144
+ await navigate(href, { history: "push", onNavigate: opts.onNavigate });
120
145
  }, true);
121
146
  window.addEventListener("popstate", async function handlePopState() {
147
+ const state = window.history.state ?? {};
148
+ pendingScroll = {
149
+ x: typeof state.scrollX === "number" ? state.scrollX : 0,
150
+ y: typeof state.scrollY === "number" ? state.scrollY : 0,
151
+ behavior: "instant",
152
+ };
153
+ await opts.onNavigate?.();
122
154
  await globalThis.__rsc_callServer(null, null, "navigation");
123
155
  });
156
+ // Persist the user's scroll position on the current history entry so
157
+ // that back/forward navigation can restore it accurately once the new
158
+ // RSC payload commits. Coalesced via rAF to avoid thrashing replaceState.
159
+ let scrollSaveScheduled = false;
160
+ window.addEventListener("scroll", () => {
161
+ if (scrollSaveScheduled)
162
+ return;
163
+ scrollSaveScheduled = true;
164
+ requestAnimationFrame(() => {
165
+ scrollSaveScheduled = false;
166
+ saveScrollPosition(window.scrollX, window.scrollY);
167
+ });
168
+ }, { passive: true });
124
169
  function handleResponse(response) {
125
170
  if (response.status >= 300 && response.status < 400) {
126
171
  const location = response.headers.get("Location");
@@ -142,6 +187,10 @@ export function initClientNavigation(opts = {}) {
142
187
  globalThis.__rsc_cacheStorage = opts.cacheStorage;
143
188
  }
144
189
  function onHydrated() {
190
+ // Apply any pending scroll intent now that React has committed the new
191
+ // DOM — this is what prevents the scroll flash on both link-click and
192
+ // popstate navigations.
193
+ applyPendingScroll();
145
194
  // After each RSC hydration/update, increment generation and evict old caches,
146
195
  // then warm the navigation cache based on any <link rel="x-prefetch"> tags
147
196
  // rendered for the current location.
@@ -75,6 +75,113 @@ describe("clientNavigation", () => {
75
75
  })).toBe(true);
76
76
  });
77
77
  });
78
+ // Regression tests for issue #1123: onNavigate callback was never called
79
+ // Root cause: commit c543ef7 extracted navigate() but dropped onNavigate wiring
80
+ describe("onNavigate callback (issue #1123 regression)", () => {
81
+ let capturedClickHandler = null;
82
+ let capturedPopstateHandler = null;
83
+ beforeEach(() => {
84
+ capturedClickHandler = null;
85
+ capturedPopstateHandler = null;
86
+ vi.clearAllMocks();
87
+ // Capture registered event listeners so we can invoke them manually
88
+ vi.stubGlobal("document", {
89
+ addEventListener: vi.fn((event, handler) => {
90
+ if (event === "click")
91
+ capturedClickHandler = handler;
92
+ }),
93
+ });
94
+ vi.stubGlobal("window", {
95
+ location: { href: "http://localhost/" },
96
+ addEventListener: vi.fn((event, handler) => {
97
+ if (event === "popstate")
98
+ capturedPopstateHandler = handler;
99
+ }),
100
+ history: {
101
+ scrollRestoration: "auto",
102
+ pushState: vi.fn(),
103
+ replaceState: vi.fn(),
104
+ state: {},
105
+ },
106
+ scrollTo: vi.fn(),
107
+ });
108
+ vi.stubGlobal("history", {
109
+ scrollRestoration: "auto",
110
+ pushState: vi.fn(),
111
+ replaceState: vi.fn(),
112
+ state: {},
113
+ });
114
+ vi.stubGlobal("URL", class {
115
+ constructor(path, base) {
116
+ this.href = base.replace(/\/$/, "") + path;
117
+ }
118
+ });
119
+ // Assign directly to globalThis without replacing it (avoids breaking Vitest internals)
120
+ globalThis.__rsc_callServer = vi.fn().mockResolvedValue(undefined);
121
+ });
122
+ it("onNavigate is called during link click navigation", async () => {
123
+ const onNavigate = vi.fn();
124
+ initClientNavigation({ onNavigate });
125
+ expect(capturedClickHandler).not.toBeNull();
126
+ const fakeAnchor = {
127
+ getAttribute: (attr) => (attr === "href" ? "/about" : null),
128
+ hasAttribute: () => false,
129
+ target: "",
130
+ closest: (sel) => (sel === "a" ? fakeAnchor : null),
131
+ };
132
+ const fakeTarget = {
133
+ closest: (sel) => (sel === "a" ? fakeAnchor : null),
134
+ };
135
+ const fakeClickEvent = {
136
+ button: 0,
137
+ ctrlKey: false,
138
+ metaKey: false,
139
+ shiftKey: false,
140
+ altKey: false,
141
+ target: fakeTarget,
142
+ preventDefault: vi.fn(),
143
+ };
144
+ await capturedClickHandler(fakeClickEvent);
145
+ expect(onNavigate).toHaveBeenCalled();
146
+ });
147
+ it("onNavigate is called during popstate navigation", async () => {
148
+ const onNavigate = vi.fn();
149
+ initClientNavigation({ onNavigate });
150
+ expect(capturedPopstateHandler).not.toBeNull();
151
+ await capturedPopstateHandler();
152
+ expect(onNavigate).toHaveBeenCalled();
153
+ });
154
+ it("onNavigate fires after pushState but before RSC fetch", async () => {
155
+ const callOrder = [];
156
+ const onNavigate = vi.fn(() => { callOrder.push("onNavigate"); });
157
+ globalThis.__rsc_callServer = vi.fn(() => {
158
+ callOrder.push("rscCallServer");
159
+ return Promise.resolve();
160
+ });
161
+ initClientNavigation({ onNavigate });
162
+ const fakeAnchor = {
163
+ getAttribute: (attr) => (attr === "href" ? "/about" : null),
164
+ hasAttribute: () => false,
165
+ target: "",
166
+ closest: (sel) => (sel === "a" ? fakeAnchor : null),
167
+ };
168
+ const fakeTarget = {
169
+ closest: (sel) => (sel === "a" ? fakeAnchor : null),
170
+ };
171
+ const fakeClickEvent = {
172
+ button: 0,
173
+ ctrlKey: false,
174
+ metaKey: false,
175
+ shiftKey: false,
176
+ altKey: false,
177
+ target: fakeTarget,
178
+ preventDefault: vi.fn(),
179
+ };
180
+ await capturedClickHandler(fakeClickEvent);
181
+ expect(callOrder).toEqual(["onNavigate", "rscCallServer"]);
182
+ expect(window.history.pushState).toHaveBeenCalled();
183
+ });
184
+ });
78
185
  describe("initClientNavigation", () => {
79
186
  beforeEach(() => {
80
187
  window.location.href = "http://localhost/";
@@ -101,4 +208,9 @@ describe("initClientNavigation", () => {
101
208
  expect(result).toBe(false);
102
209
  expect(window.location.href).toBe("http://localhost/");
103
210
  });
211
+ it("sets history.scrollRestoration to manual so the browser does not restore scroll before the RSC payload commits", () => {
212
+ history.scrollRestoration = "auto";
213
+ initClientNavigation();
214
+ expect(history.scrollRestoration).toBe("manual");
215
+ });
104
216
  });
@@ -1,6 +1,6 @@
1
1
  import "./shared";
2
2
  export interface ClientNavigationOptions {
3
- onNavigate?: () => void;
3
+ onNavigate?: () => Promise<void> | void;
4
4
  scrollToTop?: boolean;
5
5
  scrollBehavior?: "auto" | "smooth" | "instant";
6
6
  }
@@ -171,7 +171,10 @@ export function defineRoutes(routes) {
171
171
  return renderPage(requestInfo, Element, onError);
172
172
  }
173
173
  async function handleMiddlewareResult(result) {
174
- if (result instanceof Response) {
174
+ // NOTE: instanceof fails for cross-realm Responses (e.g. Response.json()
175
+ // in Cloudflare's vite dev mode constructs from a different realm's
176
+ // prototype). Fall back to constructor.name check.
177
+ if (result instanceof Response || result?.constructor?.name === 'Response') {
175
178
  return result;
176
179
  }
177
180
  if (result && React.isValidElement(result)) {
@@ -1097,6 +1097,157 @@ describe("defineRoutes - Request Handling Behavior", () => {
1097
1097
  expect(await response.text()).toBe("GET Response");
1098
1098
  });
1099
1099
  });
1100
+ describe("Cross-realm Response.json() handling", () => {
1101
+ // In Cloudflare's vite dev mode, Response.json() constructs from a
1102
+ // different realm's prototype, so `instanceof Response` fails.
1103
+ // handleMiddlewareResult must recognize these as valid Responses.
1104
+ //
1105
+ // We simulate this by calling Response.json() and then stripping the
1106
+ // prototype, which is exactly what a cross-realm boundary does: the
1107
+ // object is a real Response but its [[Prototype]] comes from a different
1108
+ // global, so `instanceof` against the local `Response` returns false.
1109
+ function crossRealmResponseJson(data, init) {
1110
+ // In workerd/vite dev mode, Response.json() constructs from a different
1111
+ // realm's Response. The resulting object has constructor.name === "Response"
1112
+ // but fails instanceof against the local Response global.
1113
+ //
1114
+ // We can't just swap the prototype in Node because V8's brand checks on
1115
+ // Response internal slots would throw. Instead, wrap a real Response.json()
1116
+ // result in a Proxy that fakes a foreign prototype chain — matching exactly
1117
+ // what workerd produces: instanceof false, constructor.name "Response",
1118
+ // all properties/methods functional.
1119
+ const real = Response.json(data, init);
1120
+ const foreignProto = { constructor: { name: "Response" } };
1121
+ const proxy = new Proxy(real, {
1122
+ get(target, prop, _receiver) {
1123
+ if (prop === "constructor")
1124
+ return foreignProto.constructor;
1125
+ const val = Reflect.get(target, prop, target);
1126
+ if (typeof val === "function")
1127
+ return val.bind(target);
1128
+ return val;
1129
+ },
1130
+ getPrototypeOf() {
1131
+ return foreignProto;
1132
+ },
1133
+ });
1134
+ // Sanity: must match the real workerd behavior
1135
+ if (proxy instanceof Response) {
1136
+ throw new Error("Test setup broken: cross-realm response should not pass instanceof");
1137
+ }
1138
+ // TS narrows proxy to `never` after the instanceof check above (it
1139
+ // assumes non-Response means no constructor), but the whole point is
1140
+ // that this object IS a Response from a foreign realm.
1141
+ if (proxy.constructor?.name !== "Response") {
1142
+ throw new Error("Test setup broken: constructor.name should be 'Response'");
1143
+ }
1144
+ return proxy;
1145
+ }
1146
+ it("should return cross-realm Response.json() from a POST route handler", async () => {
1147
+ const router = defineRoutes([
1148
+ route("/api/users/", {
1149
+ post: () => crossRealmResponseJson({ created: true }, { status: 201 }),
1150
+ }),
1151
+ ]);
1152
+ const deps = createMockDependencies();
1153
+ const request = new Request("http://localhost:3000/api/users/", {
1154
+ method: "POST",
1155
+ });
1156
+ deps.mockRequestInfo.request = request;
1157
+ const response = await router.handle({
1158
+ request,
1159
+ renderPage: deps.mockRenderPage,
1160
+ getRequestInfo: deps.getRequestInfo,
1161
+ onError: deps.onError,
1162
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
1163
+ rscActionHandler: deps.mockRscActionHandler,
1164
+ });
1165
+ expect(response.status).toBe(201);
1166
+ expect(await response.json()).toEqual({ created: true });
1167
+ });
1168
+ it("should short-circuit global middleware returning cross-realm Response.json()", async () => {
1169
+ const executionOrder = [];
1170
+ const authMiddleware = () => {
1171
+ executionOrder.push("authMiddleware");
1172
+ return crossRealmResponseJson({ error: "forbidden" }, { status: 403 });
1173
+ };
1174
+ const PageComponent = () => {
1175
+ executionOrder.push("PageComponent");
1176
+ return React.createElement("div", {}, "Page");
1177
+ };
1178
+ const router = defineRoutes([
1179
+ authMiddleware,
1180
+ route("/test/", PageComponent),
1181
+ ]);
1182
+ const deps = createMockDependencies();
1183
+ const request = new Request("http://localhost:3000/test/");
1184
+ deps.mockRequestInfo.request = request;
1185
+ const response = await router.handle({
1186
+ request,
1187
+ renderPage: deps.mockRenderPage,
1188
+ getRequestInfo: deps.getRequestInfo,
1189
+ onError: deps.onError,
1190
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
1191
+ rscActionHandler: deps.mockRscActionHandler,
1192
+ });
1193
+ expect(executionOrder).toEqual(["authMiddleware"]);
1194
+ expect(response.status).toBe(403);
1195
+ expect(await response.json()).toEqual({ error: "forbidden" });
1196
+ });
1197
+ it("should short-circuit route-specific middleware returning cross-realm Response.json()", async () => {
1198
+ const executionOrder = [];
1199
+ const checkRole = () => {
1200
+ executionOrder.push("checkRole");
1201
+ return crossRealmResponseJson({ error: "unauthorized" }, { status: 401 });
1202
+ };
1203
+ const PageComponent = () => {
1204
+ executionOrder.push("PageComponent");
1205
+ return React.createElement("div", {}, "Page");
1206
+ };
1207
+ const router = defineRoutes([
1208
+ route("/admin/", [checkRole, PageComponent]),
1209
+ ]);
1210
+ const deps = createMockDependencies();
1211
+ const request = new Request("http://localhost:3000/admin/");
1212
+ deps.mockRequestInfo.request = request;
1213
+ const response = await router.handle({
1214
+ request,
1215
+ renderPage: deps.mockRenderPage,
1216
+ getRequestInfo: deps.getRequestInfo,
1217
+ onError: deps.onError,
1218
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
1219
+ rscActionHandler: deps.mockRscActionHandler,
1220
+ });
1221
+ expect(executionOrder).toEqual(["checkRole"]);
1222
+ expect(response.status).toBe(401);
1223
+ expect(await response.json()).toEqual({ error: "unauthorized" });
1224
+ });
1225
+ it("should handle cross-realm Response.json() from an except handler", async () => {
1226
+ const errorHandler = except(() => {
1227
+ return crossRealmResponseJson({ error: "internal" }, { status: 500 });
1228
+ });
1229
+ const PageComponent = () => {
1230
+ throw new Error("boom");
1231
+ };
1232
+ const router = defineRoutes([
1233
+ errorHandler,
1234
+ route("/test/", PageComponent),
1235
+ ]);
1236
+ const deps = createMockDependencies();
1237
+ const request = new Request("http://localhost:3000/test/");
1238
+ deps.mockRequestInfo.request = request;
1239
+ const response = await router.handle({
1240
+ request,
1241
+ renderPage: deps.mockRenderPage,
1242
+ getRequestInfo: deps.getRequestInfo,
1243
+ onError: deps.onError,
1244
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
1245
+ rscActionHandler: deps.mockRscActionHandler,
1246
+ });
1247
+ expect(response.status).toBe(500);
1248
+ expect(await response.json()).toEqual({ error: "internal" });
1249
+ });
1250
+ });
1100
1251
  describe("Edge Cases", () => {
1101
1252
  it("should handle middleware-only apps with RSC actions", async () => {
1102
1253
  const executionOrder = [];
@@ -1,4 +1,4 @@
1
- import { RpcStub } from "capnweb";
1
+ import type { RpcStub } from "capnweb";
2
2
  import { DurableObject } from "cloudflare:workers";
3
3
  import type { RequestInfo } from "../runtime/requestInfo/types";
4
4
  export type SyncedStateValue = unknown;
@@ -9,9 +9,46 @@ 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 _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
- import { RpcTarget, newWorkersRpcResponse } from "capnweb";
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;
14
13
  import { DurableObject } from "cloudflare:workers";
14
+ import { loadCapnweb } from "./capnweb-loader.mjs";
15
+ let CoordinatorApiClass = null;
16
+ async function getCoordinatorApi() {
17
+ var _CoordinatorApi_coordinator, _CoordinatorApi_stub, _b;
18
+ const { RpcTarget, newWorkersRpcResponse } = await loadCapnweb();
19
+ if (!CoordinatorApiClass) {
20
+ CoordinatorApiClass = (_b = class CoordinatorApi extends RpcTarget {
21
+ constructor(coordinator, stub) {
22
+ super();
23
+ _CoordinatorApi_coordinator.set(this, void 0);
24
+ _CoordinatorApi_stub.set(this, void 0);
25
+ __classPrivateFieldSet(this, _CoordinatorApi_coordinator, coordinator, "f");
26
+ __classPrivateFieldSet(this, _CoordinatorApi_stub, stub, "f");
27
+ coordinator.setStub(stub);
28
+ }
29
+ _setStub(stub) {
30
+ __classPrivateFieldSet(this, _CoordinatorApi_stub, stub, "f");
31
+ __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").setStub(stub);
32
+ }
33
+ getState(key) {
34
+ return __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").getState(key);
35
+ }
36
+ setState(value, key) {
37
+ __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").setState(value, key);
38
+ }
39
+ subscribe(key, client) {
40
+ __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").subscribe(key, client);
41
+ }
42
+ unsubscribe(key, client) {
43
+ __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").unsubscribe(key, client);
44
+ }
45
+ },
46
+ _CoordinatorApi_coordinator = new WeakMap(),
47
+ _CoordinatorApi_stub = new WeakMap(),
48
+ _b);
49
+ }
50
+ return { CoordinatorApi: CoordinatorApiClass, newWorkersRpcResponse };
51
+ }
15
52
  /**
16
53
  * Durable Object that keeps shared state for multiple clients and notifies subscribers.
17
54
  */
@@ -141,6 +178,7 @@ export class SyncedStateServer extends DurableObject {
141
178
  }
142
179
  }
143
180
  async fetch(request) {
181
+ const { CoordinatorApi, newWorkersRpcResponse } = await getCoordinatorApi();
144
182
  // Create a placeholder stub - it will be set by the worker via _setStub
145
183
  const api = new CoordinatorApi(this, __classPrivateFieldGet(this, _SyncedStateServer_stub, "f") || {});
146
184
  return newWorkersRpcResponse(request, api);
@@ -166,31 +204,3 @@ _SyncedStateServer_subscribeHandler = { value: null };
166
204
  _SyncedStateServer_unsubscribeHandler = { value: null };
167
205
  _SyncedStateServer_namespace = { value: null };
168
206
  _SyncedStateServer_durableObjectName = { value: "syncedState" };
169
- class CoordinatorApi extends RpcTarget {
170
- constructor(coordinator, stub) {
171
- super();
172
- _CoordinatorApi_coordinator.set(this, void 0);
173
- _CoordinatorApi_stub.set(this, void 0);
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);
182
- }
183
- getState(key) {
184
- return __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").getState(key);
185
- }
186
- setState(value, key) {
187
- __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").setState(value, key);
188
- }
189
- subscribe(key, client) {
190
- __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").subscribe(key, client);
191
- }
192
- unsubscribe(key, client) {
193
- __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").unsubscribe(key, client);
194
- }
195
- }
196
- _CoordinatorApi_coordinator = new WeakMap(), _CoordinatorApi_stub = new WeakMap();
@@ -27,6 +27,7 @@ function makeMockClient() {
27
27
  return client;
28
28
  }
29
29
  import { getSyncedStateClient, onStatusChange, __testing, } from "../client-core";
30
+ const ENDPOINT = "wss://test.example.com/__synced-state";
30
31
  describe("client-core reconnection", () => {
31
32
  beforeEach(() => {
32
33
  vi.useFakeTimers();
@@ -49,51 +50,58 @@ describe("client-core reconnection", () => {
49
50
  __testing.statusListeners.clear();
50
51
  vi.useRealTimers();
51
52
  });
52
- it("registers onRpcBroken callback when creating a client", () => {
53
- getSyncedStateClient("wss://test.example.com/__synced-state");
53
+ it("registers onRpcBroken callback when creating a client", async () => {
54
+ getSyncedStateClient(ENDPOINT);
55
+ await __testing.warmUp(ENDPOINT);
54
56
  expect(mockClients).toHaveLength(1);
55
57
  expect(mockClients[0].onRpcBroken).toHaveBeenCalledOnce();
56
58
  });
57
- it("creates a new client after connection breaks", () => {
58
- getSyncedStateClient("wss://test.example.com/__synced-state");
59
+ it("creates a new client after connection breaks", async () => {
60
+ getSyncedStateClient(ENDPOINT);
61
+ await __testing.warmUp(ENDPOINT);
59
62
  expect(mockClients).toHaveLength(1);
60
63
  // Simulate connection break
61
64
  mockClients[0].simulateBreak();
62
65
  // Reconnect happens after backoff timer fires
63
66
  vi.runOnlyPendingTimers();
67
+ await __testing.warmUp(ENDPOINT);
64
68
  expect(mockClients).toHaveLength(2);
65
69
  });
66
- it("does not reconnect immediately — waits for backoff", () => {
67
- getSyncedStateClient("wss://test.example.com/__synced-state");
70
+ it("does not reconnect immediately — waits for backoff", async () => {
71
+ getSyncedStateClient(ENDPOINT);
72
+ await __testing.warmUp(ENDPOINT);
68
73
  mockClients[0].simulateBreak();
69
74
  // Before the timer fires, no new session yet
70
75
  expect(mockClients).toHaveLength(1);
71
76
  // After timer fires, reconnect happens
72
77
  vi.runOnlyPendingTimers();
78
+ await __testing.warmUp(ENDPOINT);
73
79
  expect(mockClients).toHaveLength(2);
74
80
  });
75
81
  it("re-subscribes active subscriptions after reconnect", async () => {
76
- const client = getSyncedStateClient("wss://test.example.com/__synced-state");
82
+ const client = getSyncedStateClient(ENDPOINT);
77
83
  const handler = vi.fn();
78
84
  await client.subscribe("counter", handler);
79
85
  // Simulate connection break
80
86
  mockClients[0].simulateBreak();
81
87
  vi.runOnlyPendingTimers();
88
+ await __testing.warmUp(ENDPOINT);
82
89
  // The new client should have subscribe called with the same key and handler
83
90
  const newClient = mockClients[1];
84
91
  expect(newClient.subscribe).toHaveBeenCalledWith("counter", handler);
85
92
  });
86
93
  it("fetches latest state for each subscription after reconnect", async () => {
87
- const client = getSyncedStateClient("wss://test.example.com/__synced-state");
94
+ const client = getSyncedStateClient(ENDPOINT);
88
95
  const handler = vi.fn();
89
96
  await client.subscribe("counter", handler);
90
97
  mockClients[0].simulateBreak();
91
98
  vi.runOnlyPendingTimers();
99
+ await __testing.warmUp(ENDPOINT);
92
100
  const newClient = mockClients[1];
93
101
  expect(newClient.getState).toHaveBeenCalledWith("counter");
94
102
  });
95
103
  it("calls handler with fetched state when value is not undefined", async () => {
96
- const client = getSyncedStateClient("wss://test.example.com/__synced-state");
104
+ const client = getSyncedStateClient(ENDPOINT);
97
105
  const handler = vi.fn();
98
106
  await client.subscribe("counter", handler);
99
107
  // Next client will return a value for getState
@@ -104,37 +112,42 @@ describe("client-core reconnection", () => {
104
112
  });
105
113
  mockClients[0].simulateBreak();
106
114
  vi.runOnlyPendingTimers();
115
+ await __testing.warmUp(ENDPOINT);
107
116
  // Allow the getState promise to resolve
108
117
  await vi.runAllTimersAsync();
109
118
  expect(handler).toHaveBeenCalledWith(42);
110
119
  });
111
120
  it("does not call handler when fetched state is undefined", async () => {
112
- const client = getSyncedStateClient("wss://test.example.com/__synced-state");
121
+ const client = getSyncedStateClient(ENDPOINT);
113
122
  const handler = vi.fn();
114
123
  await client.subscribe("counter", handler);
115
124
  handler.mockClear();
116
125
  mockClients[0].simulateBreak();
117
126
  vi.runOnlyPendingTimers();
127
+ await __testing.warmUp(ENDPOINT);
118
128
  // Default mock returns undefined for getState
119
129
  await vi.runAllTimersAsync();
120
130
  expect(handler).not.toHaveBeenCalled();
121
131
  });
122
132
  it("does not re-subscribe keys that were unsubscribed before reconnect", async () => {
123
- const client = getSyncedStateClient("wss://test.example.com/__synced-state");
133
+ const client = getSyncedStateClient(ENDPOINT);
124
134
  const handler = vi.fn();
125
135
  await client.subscribe("counter", handler);
126
136
  await client.unsubscribe("counter", handler);
127
137
  mockClients[0].simulateBreak();
128
138
  vi.runOnlyPendingTimers();
139
+ await __testing.warmUp(ENDPOINT);
129
140
  const newClient = mockClients[1];
130
141
  expect(newClient.subscribe).not.toHaveBeenCalled();
131
142
  });
132
- it("does not schedule multiple reconnects for the same endpoint", () => {
133
- getSyncedStateClient("wss://test.example.com/__synced-state");
143
+ it("does not schedule multiple reconnects for the same endpoint", async () => {
144
+ getSyncedStateClient(ENDPOINT);
145
+ await __testing.warmUp(ENDPOINT);
134
146
  // Fire broken twice rapidly
135
147
  mockClients[0].simulateBreak();
136
148
  mockClients[0].simulateBreak();
137
149
  vi.runOnlyPendingTimers();
150
+ await __testing.warmUp(ENDPOINT);
138
151
  // Should only have created one new session
139
152
  expect(mockClients).toHaveLength(2);
140
153
  });
@@ -148,20 +161,22 @@ describe("client-core reconnection", () => {
148
161
  expect(inRange(__testing.getBackoffMs(5), 22500, 30000)).toBe(true); // capped at 30000
149
162
  expect(inRange(__testing.getBackoffMs(10), 22500, 30000)).toBe(true); // still capped
150
163
  });
151
- it("returns cached client on second call for same endpoint", () => {
152
- const client1 = getSyncedStateClient("wss://test.example.com/__synced-state");
153
- const client2 = getSyncedStateClient("wss://test.example.com/__synced-state");
164
+ it("returns cached client on second call for same endpoint", async () => {
165
+ const client1 = getSyncedStateClient(ENDPOINT);
166
+ const client2 = getSyncedStateClient(ENDPOINT);
154
167
  expect(client1).toBe(client2);
168
+ await __testing.warmUp(ENDPOINT);
155
169
  expect(mockClients).toHaveLength(1);
156
170
  });
157
171
  it("re-subscribes multiple subscriptions after reconnect", async () => {
158
- const client = getSyncedStateClient("wss://test.example.com/__synced-state");
172
+ const client = getSyncedStateClient(ENDPOINT);
159
173
  const handler1 = vi.fn();
160
174
  const handler2 = vi.fn();
161
175
  await client.subscribe("counter", handler1);
162
176
  await client.subscribe("score", handler2);
163
177
  mockClients[0].simulateBreak();
164
178
  vi.runOnlyPendingTimers();
179
+ await __testing.warmUp(ENDPOINT);
165
180
  const newClient = mockClients[1];
166
181
  expect(newClient.subscribe).toHaveBeenCalledWith("counter", handler1);
167
182
  expect(newClient.subscribe).toHaveBeenCalledWith("score", handler2);
@@ -169,9 +184,9 @@ describe("client-core reconnection", () => {
169
184
  expect(newClient.getState).toHaveBeenCalledWith("score");
170
185
  });
171
186
  describe("onStatusChange", () => {
172
- const ENDPOINT = "wss://test.example.com/__synced-state";
173
- it("fires 'disconnected' immediately when connection breaks", () => {
187
+ it("fires 'disconnected' immediately when connection breaks", async () => {
174
188
  getSyncedStateClient(ENDPOINT);
189
+ await __testing.warmUp(ENDPOINT);
175
190
  const statusCb = vi.fn();
176
191
  onStatusChange(ENDPOINT, statusCb);
177
192
  mockClients[0].simulateBreak();
@@ -179,6 +194,7 @@ describe("client-core reconnection", () => {
179
194
  });
180
195
  it("fires 'reconnecting' then 'connected' when reconnect completes", async () => {
181
196
  getSyncedStateClient(ENDPOINT);
197
+ await __testing.warmUp(ENDPOINT);
182
198
  const statusCb = vi.fn();
183
199
  onStatusChange(ENDPOINT, statusCb);
184
200
  mockClients[0].simulateBreak();
@@ -191,6 +207,7 @@ describe("client-core reconnection", () => {
191
207
  });
192
208
  it("fires full lifecycle: disconnected → reconnecting → connected", async () => {
193
209
  getSyncedStateClient(ENDPOINT);
210
+ await __testing.warmUp(ENDPOINT);
194
211
  const statuses = [];
195
212
  onStatusChange(ENDPOINT, (s) => statuses.push(s));
196
213
  mockClients[0].simulateBreak();
@@ -198,16 +215,18 @@ describe("client-core reconnection", () => {
198
215
  await vi.runAllTimersAsync();
199
216
  expect(statuses).toEqual(["disconnected", "reconnecting", "connected"]);
200
217
  });
201
- it("returns an unsubscribe function that stops notifications", () => {
218
+ it("returns an unsubscribe function that stops notifications", async () => {
202
219
  getSyncedStateClient(ENDPOINT);
220
+ await __testing.warmUp(ENDPOINT);
203
221
  const statusCb = vi.fn();
204
222
  const unsub = onStatusChange(ENDPOINT, statusCb);
205
223
  unsub();
206
224
  mockClients[0].simulateBreak();
207
225
  expect(statusCb).not.toHaveBeenCalled();
208
226
  });
209
- it("supports multiple listeners on the same endpoint", () => {
227
+ it("supports multiple listeners on the same endpoint", async () => {
210
228
  getSyncedStateClient(ENDPOINT);
229
+ await __testing.warmUp(ENDPOINT);
211
230
  const cb1 = vi.fn();
212
231
  const cb2 = vi.fn();
213
232
  onStatusChange(ENDPOINT, cb1);
@@ -221,7 +240,7 @@ describe("client-core reconnection", () => {
221
240
  // REPRODUCTIONS: Failing tests demonstrating Copilot-flagged bugs
222
241
  // ============================================================
223
242
  describe("REPRO: bug reproductions", () => {
224
- it("BUG: status listener registered with relative URL never fires because reconnect uses the normalized absolute URL", () => {
243
+ it("BUG: status listener registered with relative URL never fires because reconnect uses the normalized absolute URL", async () => {
225
244
  // Stub window so relative URLs get normalized inside getSyncedStateClient
226
245
  vi.stubGlobal("window", {
227
246
  location: { protocol: "https:", host: "example.com" },
@@ -233,6 +252,7 @@ describe("client-core reconnection", () => {
233
252
  // the same (relative) string it passes to getSyncedStateClient.
234
253
  onStatusChange(RELATIVE, statusCb);
235
254
  getSyncedStateClient(RELATIVE);
255
+ await __testing.warmUp(RELATIVE);
236
256
  mockClients[0].simulateBreak();
237
257
  vi.runOnlyPendingTimers();
238
258
  // Expected: full lifecycle fires. Actual: nothing fires because
@@ -241,9 +261,9 @@ describe("client-core reconnection", () => {
241
261
  expect(statusCb).toHaveBeenCalledWith("disconnected");
242
262
  vi.unstubAllGlobals();
243
263
  });
244
- it("BUG: unsubscribing one of two instances of the same callback removes it for all", () => {
245
- const ENDPOINT = "wss://test.example.com/__synced-state";
264
+ it("BUG: unsubscribing one of two instances of the same callback removes it for all", async () => {
246
265
  getSyncedStateClient(ENDPOINT);
266
+ await __testing.warmUp(ENDPOINT);
247
267
  // Simulate two React components sharing the same onStatusChange
248
268
  // callback (the case when createSyncedStateHook({ onStatusChange })
249
269
  // is used by multiple component instances).
@@ -260,7 +280,6 @@ describe("client-core reconnection", () => {
260
280
  unsubB();
261
281
  });
262
282
  it("BUG: reconnect emits 'connected' and resets backoff even when subscribe() rejects", async () => {
263
- const ENDPOINT = "wss://test.example.com/__synced-state";
264
283
  const client = getSyncedStateClient(ENDPOINT);
265
284
  const handler = vi.fn();
266
285
  await client.subscribe("counter", handler);
@@ -0,0 +1 @@
1
+ export declare function loadCapnweb(): Promise<typeof import("capnweb")>;
@@ -0,0 +1,11 @@
1
+ let capnwebPromise = null;
2
+ export function loadCapnweb() {
3
+ if (!capnwebPromise) {
4
+ capnwebPromise = import("capnweb").catch(() => {
5
+ throw new Error('The "use-synced-state" feature requires the "capnweb" package, ' +
6
+ 'which is not installed. Install it with your package manager ' +
7
+ '(e.g. `npm install capnweb` or `pnpm add capnweb`).');
8
+ });
9
+ }
10
+ return capnwebPromise;
11
+ }
@@ -20,8 +20,9 @@ declare function getBackoffMs(attempt: number): number;
20
20
  declare function reconnect(endpoint: string, deadClient: SyncedStateClient): void;
21
21
  /**
22
22
  * Returns a cached client for the provided endpoint, creating it when necessary.
23
- * The client is wrapped to track subscriptions for cleanup on page reload.
24
- * On connection failure, automatically reconnects and re-subscribes.
23
+ * The returned client is a proxy that loads `capnweb` lazily on first method
24
+ * call consumers that never hit `use-synced-state` pay no import cost and
25
+ * don't need `capnweb` installed.
25
26
  * @param endpoint Endpoint to connect to.
26
27
  * @returns RPC client instance.
27
28
  */
@@ -52,5 +53,6 @@ export declare const __testing: {
52
53
  statusListeners: Map<string, StatusChangeCallback[]>;
53
54
  reconnect: typeof reconnect;
54
55
  getBackoffMs: typeof getBackoffMs;
56
+ warmUp(endpoint?: string): Promise<void>;
55
57
  };
56
58
  export {};
@@ -1,4 +1,4 @@
1
- import { newWebSocketRpcSession } from "capnweb";
1
+ import { loadCapnweb } from "./capnweb-loader.mjs";
2
2
  import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
3
3
  // Converts a relative endpoint like "/__synced-state" to an absolute
4
4
  // ws:// or wss:// URL so the same key is used by getSyncedStateClient,
@@ -12,6 +12,9 @@ function normalizeEndpoint(endpoint) {
12
12
  }
13
13
  // Map of endpoint URLs to their respective clients
14
14
  const clientCache = new Map();
15
+ // Tracks the promise of the underlying capnweb session per endpoint, exposed
16
+ // for tests so they can `await` the lazy load before making assertions.
17
+ const baseClientPromiseByEndpoint = new Map();
15
18
  const activeSubscriptions = new Set();
16
19
  // Status change listeners per endpoint. Uses an array rather than a Set so
17
20
  // that two components passing the same callback reference (e.g. via
@@ -126,8 +129,9 @@ function reconnect(endpoint, deadClient) {
126
129
  }
127
130
  /**
128
131
  * Returns a cached client for the provided endpoint, creating it when necessary.
129
- * The client is wrapped to track subscriptions for cleanup on page reload.
130
- * On connection failure, automatically reconnects and re-subscribes.
132
+ * The returned client is a proxy that loads `capnweb` lazily on first method
133
+ * call consumers that never hit `use-synced-state` pay no import cost and
134
+ * don't need `capnweb` installed.
131
135
  * @param endpoint Endpoint to connect to.
132
136
  * @returns RPC client instance.
133
137
  */
@@ -139,11 +143,25 @@ export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
139
143
  if (existingClient) {
140
144
  return existingClient;
141
145
  }
142
- const baseClient = newWebSocketRpcSession(endpoint);
143
- // Wrap the client using a Proxy to track subscriptions
144
- // The RPC client uses dynamic property access, so we can't use .bind()
145
- const wrappedClient = new Proxy(baseClient, {
146
- get(target, prop) {
146
+ let baseClientPromise = null;
147
+ let wrappedClient;
148
+ const getBaseClient = () => {
149
+ if (!baseClientPromise) {
150
+ baseClientPromise = loadCapnweb().then((mod) => {
151
+ const session = mod.newWebSocketRpcSession(endpoint);
152
+ if (typeof session.onRpcBroken === "function") {
153
+ session.onRpcBroken(() => {
154
+ reconnect(endpoint, wrappedClient);
155
+ });
156
+ }
157
+ return session;
158
+ });
159
+ baseClientPromiseByEndpoint.set(endpoint, baseClientPromise);
160
+ }
161
+ return baseClientPromise;
162
+ };
163
+ wrappedClient = new Proxy({}, {
164
+ get(_target, prop) {
147
165
  if (prop === "subscribe") {
148
166
  return async (key, handler) => {
149
167
  const subscription = {
@@ -152,7 +170,8 @@ export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
152
170
  client: wrappedClient,
153
171
  };
154
172
  activeSubscriptions.add(subscription);
155
- return target[prop](key, handler);
173
+ const base = await getBaseClient();
174
+ return base[prop](key, handler);
156
175
  };
157
176
  }
158
177
  if (prop === "unsubscribe") {
@@ -166,21 +185,26 @@ export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
166
185
  break;
167
186
  }
168
187
  }
169
- return target[prop](key, handler);
188
+ const base = await getBaseClient();
189
+ return base[prop](key, handler);
170
190
  };
171
191
  }
172
192
  // Pass through all other properties/methods
173
- return target[prop];
193
+ return async (...args) => {
194
+ const base = await getBaseClient();
195
+ return base[prop](...args);
196
+ };
174
197
  },
175
198
  });
176
- // Listen for connection failure and trigger reconnection
177
- if (typeof baseClient.onRpcBroken === "function") {
178
- baseClient.onRpcBroken(() => {
179
- reconnect(endpoint, wrappedClient);
180
- });
181
- }
182
199
  // Cache the client for this endpoint
183
200
  clientCache.set(endpoint, wrappedClient);
201
+ // Eagerly kick off the capnweb load so the underlying session (and its
202
+ // onRpcBroken handler) is ready as soon as possible, and reconnect flows
203
+ // that don't call methods on the new client still create the replacement
204
+ // session. Errors are swallowed here to avoid unhandled rejections — they
205
+ // still surface through subsequent method calls because the rejected
206
+ // promise remains cached.
207
+ void getBaseClient().catch(() => { });
184
208
  return wrappedClient;
185
209
  };
186
210
  /**
@@ -210,6 +234,7 @@ export const setSyncedStateClientForTesting = (client, endpoint = DEFAULT_SYNCED
210
234
  else {
211
235
  clientCache.delete(endpoint);
212
236
  }
237
+ baseClientPromiseByEndpoint.delete(endpoint);
213
238
  activeSubscriptions.clear();
214
239
  statusListeners.clear();
215
240
  // Clear any pending reconnection timers
@@ -228,4 +253,15 @@ export const __testing = {
228
253
  statusListeners,
229
254
  reconnect,
230
255
  getBackoffMs,
256
+ // Awaits the eagerly-kicked-off capnweb load for a cached client. Tests
257
+ // should `await __testing.warmUp(endpoint)` after `getSyncedStateClient`
258
+ // (or after a reconnect) when they need the underlying session to exist
259
+ // before asserting on it.
260
+ async warmUp(endpoint = DEFAULT_SYNCED_STATE_PATH) {
261
+ const normalized = normalizeEndpoint(endpoint);
262
+ const promise = baseClientPromiseByEndpoint.get(normalized);
263
+ if (promise) {
264
+ await promise.catch(() => { });
265
+ }
266
+ },
231
267
  };
@@ -9,91 +9,107 @@ 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_instances, _SyncedStateProxy_stub, _SyncedStateProxy_keyHandler, _SyncedStateProxy_requestInfo, _SyncedStateProxy_transformKey, _SyncedStateProxy_callHandler;
13
- import { RpcTarget, newWorkersRpcResponse } from "capnweb";
14
12
  import { env } from "cloudflare:workers";
15
13
  import { route } from "../runtime/entries/router";
16
14
  import { runWithRequestInfo } from "../runtime/requestInfo/worker";
15
+ import { loadCapnweb } from "./capnweb-loader.mjs";
17
16
  import { SyncedStateServer, } from "./SyncedStateServer.mjs";
18
17
  import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
19
18
  export { SyncedStateServer };
20
19
  const DEFAULT_SYNC_STATE_NAME = "syncedState";
21
- class SyncedStateProxy extends RpcTarget {
22
- constructor(stub, keyHandler, requestInfo) {
23
- super();
24
- _SyncedStateProxy_instances.add(this);
25
- _SyncedStateProxy_stub.set(this, void 0);
26
- _SyncedStateProxy_keyHandler.set(this, void 0);
27
- _SyncedStateProxy_requestInfo.set(this, void 0);
28
- __classPrivateFieldSet(this, _SyncedStateProxy_stub, stub, "f");
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
- }
35
- }
36
- async getState(key) {
37
- const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
38
- return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").getState(transformedKey);
39
- }
40
- async setState(value, key) {
41
- const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
42
- return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").setState(value, transformedKey);
43
- }
44
- async subscribe(key, 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);
54
- }
55
- async unsubscribe(key, 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
- }
20
+ let SyncedStateProxyClass = null;
21
+ async function getSyncedStateProxy() {
22
+ var _SyncedStateProxy_instances, _SyncedStateProxy_stub, _SyncedStateProxy_keyHandler, _SyncedStateProxy_requestInfo, _SyncedStateProxy_transformKey, _SyncedStateProxy_callHandler, _a;
23
+ const { RpcTarget, newWorkersRpcResponse } = await loadCapnweb();
24
+ if (!SyncedStateProxyClass) {
25
+ SyncedStateProxyClass = (_a = class SyncedStateProxy extends RpcTarget {
26
+ constructor(stub, keyHandler, requestInfo) {
27
+ super();
28
+ _SyncedStateProxy_instances.add(this);
29
+ _SyncedStateProxy_stub.set(this, void 0);
30
+ _SyncedStateProxy_keyHandler.set(this, void 0);
31
+ _SyncedStateProxy_requestInfo.set(this, void 0);
32
+ __classPrivateFieldSet(this, _SyncedStateProxy_stub, stub, "f");
33
+ __classPrivateFieldSet(this, _SyncedStateProxy_keyHandler, keyHandler, "f");
34
+ __classPrivateFieldSet(this, _SyncedStateProxy_requestInfo, requestInfo, "f");
35
+ // Set stub in DO instance so handlers can access it
36
+ if (stub && typeof stub._setStub === "function") {
37
+ void stub._setStub(stub);
38
+ }
39
+ }
40
+ async getState(key) {
41
+ const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
42
+ return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").getState(transformedKey);
43
+ }
44
+ async setState(value, key) {
45
+ const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
46
+ return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").setState(value, transformedKey);
47
+ }
48
+ async subscribe(key, client) {
49
+ const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
50
+ const subscribeHandler = SyncedStateServer.getSubscribeHandler();
51
+ if (subscribeHandler) {
52
+ __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_callHandler).call(this, subscribeHandler, transformedKey, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f"));
53
+ }
54
+ // dup the client if it is a function; otherwise, pass it as is;
55
+ // this is because the client is a WebSocketRpcSession, and we need to pass a new instance of the client to the DO;
56
+ const clientToPass = typeof client.dup === "function" ? client.dup() : client;
57
+ return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").subscribe(transformedKey, clientToPass);
58
+ }
59
+ async unsubscribe(key, client) {
60
+ const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
61
+ // Call unsubscribe handler before unsubscribe, similar to subscribe handler
62
+ // This ensures the handler is called even if the unsubscribe doesn't find a match
63
+ // or if the RPC call fails
64
+ const unsubscribeHandler = SyncedStateServer.getUnsubscribeHandler();
65
+ if (unsubscribeHandler) {
66
+ __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_callHandler).call(this, unsubscribeHandler, transformedKey, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f"));
67
+ }
68
+ try {
69
+ await __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").unsubscribe(transformedKey, client);
70
+ }
71
+ catch (error) {
72
+ // Ignore errors during unsubscribe - handler has already been called
73
+ // This prevents RPC stub disposal errors from propagating
74
+ }
75
+ }
76
+ },
77
+ _SyncedStateProxy_stub = new WeakMap(),
78
+ _SyncedStateProxy_keyHandler = new WeakMap(),
79
+ _SyncedStateProxy_requestInfo = new WeakMap(),
80
+ _SyncedStateProxy_instances = new WeakSet(),
81
+ _SyncedStateProxy_transformKey =
82
+ /**
83
+ * Transforms a key using the keyHandler, preserving async context so requestInfo.ctx is available.
84
+ */
85
+ async function _SyncedStateProxy_transformKey(key) {
86
+ if (!__classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f")) {
87
+ return key;
88
+ }
89
+ if (__classPrivateFieldGet(this, _SyncedStateProxy_requestInfo, "f")) {
90
+ // Preserve async context when calling keyHandler so requestInfo.ctx is available
91
+ return await runWithRequestInfo(__classPrivateFieldGet(this, _SyncedStateProxy_requestInfo, "f"), async () => await __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f")(key, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f")));
92
+ }
93
+ return await __classPrivateFieldGet(this, _SyncedStateProxy_keyHandler, "f").call(this, key, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f"));
94
+ },
95
+ _SyncedStateProxy_callHandler = function _SyncedStateProxy_callHandler(handler, key, stub) {
96
+ if (__classPrivateFieldGet(this, _SyncedStateProxy_requestInfo, "f")) {
97
+ // Preserve async context when calling handler so requestInfo.ctx is available
98
+ runWithRequestInfo(__classPrivateFieldGet(this, _SyncedStateProxy_requestInfo, "f"), () => {
99
+ handler(key, stub);
100
+ });
101
+ }
102
+ else {
103
+ handler(key, stub);
104
+ }
105
+ },
106
+ _a);
71
107
  }
108
+ return {
109
+ SyncedStateProxy: SyncedStateProxyClass,
110
+ newWorkersRpcResponse,
111
+ };
72
112
  }
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
- };
97
113
  /**
98
114
  * Registers routes that forward sync state requests to the configured Durable Object namespace.
99
115
  * @param getNamespace Function that returns the Durable Object namespace from the Worker env.
@@ -125,6 +141,7 @@ export const syncedStateRoutes = (getNamespace, options = {}) => {
125
141
  }
126
142
  const id = namespace.idFromName(resolvedRoomName);
127
143
  const coordinator = namespace.get(id);
144
+ const { SyncedStateProxy, newWorkersRpcResponse } = await getSyncedStateProxy();
128
145
  const proxy = new SyncedStateProxy(coordinator, keyHandler, requestInfo);
129
146
  return newWorkersRpcResponse(request, proxy);
130
147
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -201,6 +201,11 @@
201
201
  "vite": "^6.2.6 || 7.x",
202
202
  "wrangler": "^4.77.0"
203
203
  },
204
+ "peerDependenciesMeta": {
205
+ "capnweb": {
206
+ "optional": true
207
+ }
208
+ },
204
209
  "packageManager": "pnpm@10.31.0",
205
210
  "devDependencies": {
206
211
  "@cloudflare/vite-plugin": "1.31.0",