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.
- package/dist/runtime/client/navigation.d.ts +2 -1
- package/dist/runtime/client/navigation.js +60 -11
- package/dist/runtime/client/navigation.test.js +112 -0
- package/dist/runtime/entries/types/client.d.ts +1 -1
- package/dist/runtime/lib/router.js +4 -1
- package/dist/runtime/lib/router.test.js +151 -0
- package/dist/use-synced-state/SyncedStateServer.d.mts +1 -1
- package/dist/use-synced-state/SyncedStateServer.mjs +40 -30
- package/dist/use-synced-state/__tests__/client-core.test.js +44 -25
- package/dist/use-synced-state/capnweb-loader.d.mts +1 -0
- package/dist/use-synced-state/capnweb-loader.mjs +11 -0
- package/dist/use-synced-state/client-core.d.ts +4 -2
- package/dist/use-synced-state/client-core.js +53 -17
- package/dist/use-synced-state/worker.mjs +93 -76
- package/package.json +6 -1
|
@@ -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 ??
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -171,7 +171,10 @@ export function defineRoutes(routes) {
|
|
|
171
171
|
return renderPage(requestInfo, Element, onError);
|
|
172
172
|
}
|
|
173
173
|
async function handleMiddlewareResult(result) {
|
|
174
|
-
|
|
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 = [];
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
153
|
-
const client2 = getSyncedStateClient(
|
|
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(
|
|
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
|
-
|
|
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
|
|
24
|
-
*
|
|
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 {
|
|
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
|
|
130
|
-
*
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
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",
|