rwsdk 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.d.ts +1 -1
- package/dist/runtime/lib/router.js +4 -1
- package/dist/runtime/lib/router.test.js +194 -1
- 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.d.ts +1 -0
- package/dist/use-synced-state/__tests__/client-core.test.js +303 -0
- 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 +30 -1
- package/dist/use-synced-state/client-core.js +173 -14
- package/dist/use-synced-state/client.d.ts +2 -2
- package/dist/use-synced-state/client.js +1 -1
- package/dist/use-synced-state/useSyncedState.d.ts +2 -0
- package/dist/use-synced-state/useSyncedState.js +5 -1
- 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
|
});
|
|
@@ -146,7 +146,7 @@ export declare const wrapHandlerToThrowResponses: <T extends RequestInfo = Reque
|
|
|
146
146
|
* route("/post/:id", ({ params }) => <BlogPost id={params.id} />),
|
|
147
147
|
* ])
|
|
148
148
|
*/
|
|
149
|
-
export declare function layout<
|
|
149
|
+
export declare function layout<TLayout extends RequestInfo = RequestInfo, Routes extends readonly Route<any>[] = readonly Route<any>[]>(LayoutComponent: React.FC<LayoutProps<TLayout>>, routes: Routes): Routes;
|
|
150
150
|
/**
|
|
151
151
|
* Wraps routes with a Document component and configures rendering options.
|
|
152
152
|
*
|
|
@@ -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)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { defineRoutes, except, layout, matchPath, prefix, render, route, } from "./router";
|
|
3
|
+
import { defineRoutes, except, index, layout, matchPath, prefix, render, route, } from "./router";
|
|
4
4
|
describe("matchPath", () => {
|
|
5
5
|
// Test case 1: Static paths
|
|
6
6
|
it("should match static paths", () => {
|
|
@@ -793,6 +793,48 @@ describe("defineRoutes - Request Handling Behavior", () => {
|
|
|
793
793
|
});
|
|
794
794
|
expect(await response.text()).toBe("Rendered with layouts");
|
|
795
795
|
});
|
|
796
|
+
it("should accept child routes whose params are a superset of the layout's params", async () => {
|
|
797
|
+
let capturedLayoutProps = null;
|
|
798
|
+
let capturedRouteParams = null;
|
|
799
|
+
// Layout only needs { orgId }
|
|
800
|
+
const AppLayout = (props) => {
|
|
801
|
+
capturedLayoutProps = props;
|
|
802
|
+
return React.createElement("div", { className: "app-layout" }, props.children);
|
|
803
|
+
};
|
|
804
|
+
const OrgIndex = () => {
|
|
805
|
+
return React.createElement("div", {}, "Org Index");
|
|
806
|
+
};
|
|
807
|
+
// Schedule needs { orgId, date } — a superset
|
|
808
|
+
const Schedule = (requestInfo) => {
|
|
809
|
+
capturedRouteParams = requestInfo.params;
|
|
810
|
+
return React.createElement("div", {}, `${requestInfo.params.orgId} / ${requestInfo.params.date}`);
|
|
811
|
+
};
|
|
812
|
+
const routes = prefix("/:orgId", [
|
|
813
|
+
layout(AppLayout, [
|
|
814
|
+
index(OrgIndex),
|
|
815
|
+
prefix("/schedule", [
|
|
816
|
+
route("/:date", Schedule),
|
|
817
|
+
]),
|
|
818
|
+
]),
|
|
819
|
+
]);
|
|
820
|
+
const router = defineRoutes([...routes]);
|
|
821
|
+
const deps = createMockDependencies();
|
|
822
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/acme/schedule/2025-01-15/");
|
|
823
|
+
const request = new Request("http://localhost:3000/acme/schedule/2025-01-15/");
|
|
824
|
+
const response = await router.handle({
|
|
825
|
+
request,
|
|
826
|
+
renderPage: deps.mockRenderPage,
|
|
827
|
+
getRequestInfo: deps.getRequestInfo,
|
|
828
|
+
onError: deps.onError,
|
|
829
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
830
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
831
|
+
});
|
|
832
|
+
expect(response.status).toBe(200);
|
|
833
|
+
expect(deps.mockRequestInfo.params).toEqual({
|
|
834
|
+
orgId: "acme",
|
|
835
|
+
date: "2025-01-15",
|
|
836
|
+
});
|
|
837
|
+
});
|
|
796
838
|
});
|
|
797
839
|
describe("Parameter Extraction", () => {
|
|
798
840
|
it("should extract path parameters and make them available in request info", async () => {
|
|
@@ -1055,6 +1097,157 @@ describe("defineRoutes - Request Handling Behavior", () => {
|
|
|
1055
1097
|
expect(await response.text()).toBe("GET Response");
|
|
1056
1098
|
});
|
|
1057
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
|
+
});
|
|
1058
1251
|
describe("Edge Cases", () => {
|
|
1059
1252
|
it("should handle middleware-only apps with RSC actions", async () => {
|
|
1060
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();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|