rwsdk 1.0.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -231,10 +231,15 @@ export function createDeployment() {
231
231
  ? match[1]
232
232
  : Math.random().toString(36).substring(2, 15);
233
233
  const deployResult = await runRelease(projectDir, projectDir, resourceUniqueKey);
234
+ // A fresh *.workers.dev subdomain can return 200 with Cloudflare's
235
+ // "There is nothing here yet" placeholder before the worker code
236
+ // propagates globally. Wait until the response body contains the
237
+ // rwsdk-rendered marker so tests don't run against the placeholder.
234
238
  await poll(async () => {
235
239
  try {
236
240
  const response = await fetch(deployResult.url);
237
- return response.status > 0;
241
+ const body = await response.text();
242
+ return body.includes("__RWSDK_CONTEXT");
238
243
  }
239
244
  catch (e) {
240
245
  return false;
@@ -75,9 +75,9 @@ export async function createSmokeTestStylesheets(targetDir) {
75
75
  const urlStylesPath = join(appDir, "smokeTestUrlStyles.css");
76
76
  log("Creating smokeTestUrlStyles.css at: %s", urlStylesPath);
77
77
  await fs.writeFile(urlStylesPath, smokeTestUrlStylesCssTemplate);
78
- // Modify Document.tsx to include the URL stylesheet using CSS URL import
79
- const documentPath = join(appDir, "Document.tsx");
80
- log("Modifying Document.tsx to include URL stylesheet at: %s", documentPath);
78
+ // Modify document.tsx to include the URL stylesheet using CSS URL import
79
+ const documentPath = join(appDir, "document.tsx");
80
+ log("Modifying document.tsx to include URL stylesheet at: %s", documentPath);
81
81
  try {
82
82
  const documentContent = await fs.readFile(documentPath, "utf-8");
83
83
  const s = new MagicString(documentContent);
@@ -90,14 +90,14 @@ export async function createSmokeTestStylesheets(targetDir) {
90
90
  if (headTagEnd !== -1) {
91
91
  s.appendLeft(headTagEnd, ' <link rel="stylesheet" href={smokeTestUrlStyles} />\n');
92
92
  await fs.writeFile(documentPath, s.toString(), "utf-8");
93
- log("Successfully modified Document.tsx with CSS URL import pattern");
93
+ log("Successfully modified document.tsx with CSS URL import pattern");
94
94
  }
95
95
  else {
96
- log("Could not find </head> tag in Document.tsx");
96
+ log("Could not find </head> tag in document.tsx");
97
97
  }
98
98
  }
99
99
  catch (e) {
100
- log("Could not modify Document.tsx: %s", e);
100
+ log("Could not modify document.tsx: %s", e);
101
101
  }
102
102
  log("Smoke test stylesheets created successfully");
103
103
  }
@@ -9,6 +9,35 @@ export { $expect, deleteD1Database, deleteWorker, isRelatedToTest, listD1Databas
9
9
  export async function runRelease(cwd, projectDir, resourceUniqueKey) {
10
10
  return runE2ERelease(cwd, projectDir, resourceUniqueKey);
11
11
  }
12
+ async function waitForDeploymentContent(baseUrl, { timeoutMs = 60_000, intervalMs = 2_000, } = {}) {
13
+ const marker = "__RWSDK_CONTEXT";
14
+ const deadline = Date.now() + timeoutMs;
15
+ let attempt = 0;
16
+ let lastStatus;
17
+ let lastBytes = 0;
18
+ while (Date.now() < deadline) {
19
+ attempt += 1;
20
+ try {
21
+ const res = await fetch(baseUrl);
22
+ const body = await res.text();
23
+ lastStatus = res.status;
24
+ lastBytes = body.length;
25
+ if (body.includes(marker)) {
26
+ log("Deployment content verified at %s after %d attempt(s)", baseUrl, attempt);
27
+ console.log(`✅ Deployment content ready at ${baseUrl} (attempt ${attempt})`);
28
+ return;
29
+ }
30
+ log("Attempt %d: %s returned %d (%d bytes), no app marker yet", attempt, baseUrl, res.status, body.length);
31
+ }
32
+ catch (err) {
33
+ log("Attempt %d: fetch failed for %s: %O", attempt, baseUrl, err);
34
+ }
35
+ await setTimeout(intervalMs);
36
+ }
37
+ throw new Error(`Deployment at ${baseUrl} did not serve app content within ${timeoutMs}ms ` +
38
+ `(last status ${lastStatus ?? "n/a"}, ${lastBytes} bytes). ` +
39
+ `Likely Cloudflare *.workers.dev propagation still in progress.`);
40
+ }
12
41
  /**
13
42
  * Runs tests against the production deployment
14
43
  */
@@ -23,6 +52,12 @@ export async function runReleaseTest(artifactDir, resources, browserPath, headle
23
52
  await setTimeout(1000);
24
53
  // DRY: check both root and custom path
25
54
  await checkServerUp(url, "/");
55
+ // A fresh *.workers.dev subdomain can return 200 with Cloudflare's
56
+ // "There is nothing here yet" placeholder before the worker code is
57
+ // globally propagated. Poll the URL until the response body contains
58
+ // an rwsdk-rendered marker so we don't run the browser tests against
59
+ // the placeholder.
60
+ await waitForDeploymentContent(url);
26
61
  // Now run the tests with the custom path
27
62
  const testUrl = new URL("/__smoke_test", url).toString();
28
63
  await checkUrl(testUrl, artifactDir, browserPath, headless, bail, skipClient, "Production", realtime, resources.targetDir, // Add target directory parameter
@@ -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<T extends RequestInfo = RequestInfo, Routes extends readonly Route<T>[] = readonly Route<T>[]>(LayoutComponent: React.FC<LayoutProps<T>>, routes: Routes): Routes;
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
  *
@@ -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 () => {
@@ -11,6 +11,34 @@ type WrappedServerFunction<TArgs extends any[] = any[], TResult = any> = {
11
11
  (...args: TArgs): Promise<TResult>;
12
12
  method?: "GET" | "POST";
13
13
  };
14
+ export type ServerFunctionWrap = (fn: Function, args: any[], type: "action" | "query") => Promise<any>;
15
+ /**
16
+ * Register a wrapper that runs around every server action and query handler.
17
+ *
18
+ * Call this once in your worker entry point. The wrapper receives the main
19
+ * handler function, its arguments, and the type ("action" or "query").
20
+ * Interruptors run *outside* the wrapper.
21
+ *
22
+ * Throws if called more than once.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { registerServerFunctionWrap } from "rwsdk/worker";
27
+ * import * as Sentry from "@sentry/cloudflare";
28
+ *
29
+ * registerServerFunctionWrap((fn, args, type) =>
30
+ * Sentry.startSpan(
31
+ * { name: fn.name, op: `function.rsc_${type}` },
32
+ * () => fn(...args)
33
+ * )
34
+ * );
35
+ * ```
36
+ */
37
+ export declare function registerServerFunctionWrap(wrap: ServerFunctionWrap): void;
38
+ /**
39
+ * @internal Reset the global wrap — only for tests.
40
+ */
41
+ export declare function __resetServerFunctionWrap(): void;
14
42
  /**
15
43
  * Wrap a function to be used as a server query.
16
44
  *
@@ -1,4 +1,40 @@
1
1
  import { requestInfo } from "./requestInfo/worker";
2
+ let globalWrap;
3
+ /**
4
+ * Register a wrapper that runs around every server action and query handler.
5
+ *
6
+ * Call this once in your worker entry point. The wrapper receives the main
7
+ * handler function, its arguments, and the type ("action" or "query").
8
+ * Interruptors run *outside* the wrapper.
9
+ *
10
+ * Throws if called more than once.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { registerServerFunctionWrap } from "rwsdk/worker";
15
+ * import * as Sentry from "@sentry/cloudflare";
16
+ *
17
+ * registerServerFunctionWrap((fn, args, type) =>
18
+ * Sentry.startSpan(
19
+ * { name: fn.name, op: `function.rsc_${type}` },
20
+ * () => fn(...args)
21
+ * )
22
+ * );
23
+ * ```
24
+ */
25
+ export function registerServerFunctionWrap(wrap) {
26
+ if (globalWrap) {
27
+ throw new Error("registerServerFunctionWrap() has already been called. " +
28
+ "Only one wrapper can be registered.");
29
+ }
30
+ globalWrap = wrap;
31
+ }
32
+ /**
33
+ * @internal Reset the global wrap — only for tests.
34
+ */
35
+ export function __resetServerFunctionWrap() {
36
+ globalWrap = undefined;
37
+ }
2
38
  function createServerFunction(fns, mainFn, options) {
3
39
  const wrapped = async (...args) => {
4
40
  const { request, ctx } = requestInfo;
@@ -11,6 +47,9 @@ function createServerFunction(fns, mainFn, options) {
11
47
  return result;
12
48
  }
13
49
  }
50
+ if (globalWrap) {
51
+ return globalWrap(mainFn, args, options?.__type ?? "action");
52
+ }
14
53
  return mainFn(...args);
15
54
  };
16
55
  wrapped.method = options?.method ?? "POST";
@@ -45,7 +84,7 @@ export function serverQuery(fnsOrFn, options) {
45
84
  mainFn = fnsOrFn;
46
85
  }
47
86
  const method = options?.method ?? "GET"; // Default to GET for query
48
- const wrapped = createServerFunction(fns, mainFn, { ...options, method });
87
+ const wrapped = createServerFunction(fns, mainFn, { ...options, method, __type: "query" });
49
88
  wrapped.method = method;
50
89
  return wrapped;
51
90
  }
@@ -78,7 +117,7 @@ export function serverAction(fnsOrFn, options) {
78
117
  mainFn = fnsOrFn;
79
118
  }
80
119
  const method = options?.method ?? "POST"; // Default to POST for action
81
- const wrapped = createServerFunction(fns, mainFn, { ...options, method });
120
+ const wrapped = createServerFunction(fns, mainFn, { ...options, method, __type: "action" });
82
121
  wrapped.method = method;
83
122
  return wrapped;
84
123
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ let mockRequestInfo;
3
+ vi.mock("./requestInfo/worker", () => ({
4
+ get requestInfo() {
5
+ return mockRequestInfo;
6
+ },
7
+ }));
8
+ import { serverAction, serverQuery, registerServerFunctionWrap, __resetServerFunctionWrap, } from "./server";
9
+ describe("registerServerFunctionWrap", () => {
10
+ beforeEach(() => {
11
+ __resetServerFunctionWrap();
12
+ mockRequestInfo = {
13
+ request: new Request("https://test.example/"),
14
+ ctx: {},
15
+ };
16
+ });
17
+ it("wraps serverAction handlers", async () => {
18
+ const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
19
+ registerServerFunctionWrap(wrapSpy);
20
+ const action = serverAction(async function createThing(name) {
21
+ return `created ${name}`;
22
+ });
23
+ const result = await action("foo");
24
+ expect(wrapSpy).toHaveBeenCalledOnce();
25
+ expect(wrapSpy.mock.calls[0][2]).toBe("action");
26
+ expect(result).toBe("created foo");
27
+ });
28
+ it("wraps serverQuery handlers", async () => {
29
+ const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
30
+ registerServerFunctionWrap(wrapSpy);
31
+ const query = serverQuery(async function getThings() {
32
+ return [1, 2, 3];
33
+ });
34
+ const result = await query();
35
+ expect(wrapSpy).toHaveBeenCalledOnce();
36
+ expect(wrapSpy.mock.calls[0][2]).toBe("query");
37
+ expect(result).toEqual([1, 2, 3]);
38
+ });
39
+ it("passes the main function and args to the wrapper", async () => {
40
+ const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
41
+ registerServerFunctionWrap(wrapSpy);
42
+ const action = serverAction(async function multiply(a, b) {
43
+ return a * b;
44
+ });
45
+ await action(3, 7);
46
+ const [fn, args] = wrapSpy.mock.calls[0];
47
+ expect(fn.name).toBe("multiply");
48
+ expect(args).toEqual([3, 7]);
49
+ });
50
+ it("interruptors run outside the wrapper", async () => {
51
+ const order = [];
52
+ registerServerFunctionWrap((fn, args, _type) => {
53
+ order.push("wrap:start");
54
+ const result = fn(...args);
55
+ order.push("wrap:end");
56
+ return result;
57
+ });
58
+ const interruptor = async () => {
59
+ order.push("interruptor");
60
+ };
61
+ const action = serverAction([
62
+ interruptor,
63
+ async function doWork() {
64
+ order.push("handler");
65
+ return "done";
66
+ },
67
+ ]);
68
+ await action();
69
+ expect(order).toEqual([
70
+ "interruptor",
71
+ "wrap:start",
72
+ "handler",
73
+ "wrap:end",
74
+ ]);
75
+ });
76
+ it("wrapper is not called when an interruptor short-circuits", async () => {
77
+ const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
78
+ registerServerFunctionWrap(wrapSpy);
79
+ const action = serverAction([
80
+ async () => new Response("blocked", { status: 403 }),
81
+ async function neverCalled() {
82
+ return "nope";
83
+ },
84
+ ]);
85
+ const result = await action();
86
+ expect(wrapSpy).not.toHaveBeenCalled();
87
+ expect(result).toBeInstanceOf(Response);
88
+ });
89
+ it("throws if called more than once", () => {
90
+ registerServerFunctionWrap(async (fn, args) => fn(...args));
91
+ expect(() => {
92
+ registerServerFunctionWrap(async (fn, args) => fn(...args));
93
+ }).toThrow("registerServerFunctionWrap() has already been called");
94
+ });
95
+ it("without registration, handlers work normally", async () => {
96
+ const action = serverAction(async function echo(msg) {
97
+ return msg;
98
+ });
99
+ const result = await action("hello");
100
+ expect(result).toBe("hello");
101
+ });
102
+ it("wrapper can transform the return value", async () => {
103
+ registerServerFunctionWrap(async (fn, args, _type) => {
104
+ const result = await fn(...args);
105
+ return { wrapped: true, result };
106
+ });
107
+ const action = serverAction(async function getValue() {
108
+ return 42;
109
+ });
110
+ const result = await action();
111
+ expect(result).toEqual({ wrapped: true, result: 42 });
112
+ });
113
+ it("works with array-style serverAction (interruptors + handler)", async () => {
114
+ const wrapSpy = vi.fn((fn, args, _type) => fn(...args));
115
+ registerServerFunctionWrap(wrapSpy);
116
+ const action = serverAction([
117
+ async ({ ctx }) => {
118
+ ctx.authed = true;
119
+ },
120
+ async function save(data) {
121
+ return `saved ${data}`;
122
+ },
123
+ ]);
124
+ const result = await action("test");
125
+ expect(result).toBe("saved test");
126
+ expect(wrapSpy).toHaveBeenCalledOnce();
127
+ const [fn] = wrapSpy.mock.calls[0];
128
+ expect(fn.name).toBe("save");
129
+ });
130
+ });
@@ -0,0 +1,284 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi, } from "vitest";
2
+ const mockClients = [];
3
+ const { newWebSocketRpcSession } = vi.hoisted(() => {
4
+ return {
5
+ newWebSocketRpcSession: vi.fn(),
6
+ };
7
+ });
8
+ vi.mock("capnweb", () => ({
9
+ newWebSocketRpcSession,
10
+ }));
11
+ function makeMockClient() {
12
+ let brokenCb = null;
13
+ const client = {
14
+ getState: vi.fn().mockResolvedValue(undefined),
15
+ setState: vi.fn().mockResolvedValue(undefined),
16
+ subscribe: vi.fn().mockResolvedValue(undefined),
17
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
18
+ onRpcBroken: vi.fn((cb) => {
19
+ brokenCb = cb;
20
+ }),
21
+ // Helper to simulate a connection break from tests
22
+ simulateBreak(error = new Error("connection lost")) {
23
+ brokenCb?.(error);
24
+ },
25
+ };
26
+ mockClients.push(client);
27
+ return client;
28
+ }
29
+ import { getSyncedStateClient, onStatusChange, __testing, } from "../client-core";
30
+ describe("client-core reconnection", () => {
31
+ beforeEach(() => {
32
+ vi.useFakeTimers();
33
+ mockClients.length = 0;
34
+ newWebSocketRpcSession.mockReset();
35
+ newWebSocketRpcSession.mockImplementation(() => makeMockClient());
36
+ // Clear all internal state between tests
37
+ __testing.clientCache.clear();
38
+ __testing.activeSubscriptions.clear();
39
+ for (const [, state] of __testing.backoffState) {
40
+ if (state.timer !== null)
41
+ clearTimeout(state.timer);
42
+ }
43
+ __testing.backoffState.clear();
44
+ });
45
+ afterEach(() => {
46
+ __testing.clientCache.clear();
47
+ __testing.activeSubscriptions.clear();
48
+ __testing.backoffState.clear();
49
+ __testing.statusListeners.clear();
50
+ vi.useRealTimers();
51
+ });
52
+ it("registers onRpcBroken callback when creating a client", () => {
53
+ getSyncedStateClient("wss://test.example.com/__synced-state");
54
+ expect(mockClients).toHaveLength(1);
55
+ expect(mockClients[0].onRpcBroken).toHaveBeenCalledOnce();
56
+ });
57
+ it("creates a new client after connection breaks", () => {
58
+ getSyncedStateClient("wss://test.example.com/__synced-state");
59
+ expect(mockClients).toHaveLength(1);
60
+ // Simulate connection break
61
+ mockClients[0].simulateBreak();
62
+ // Reconnect happens after backoff timer fires
63
+ vi.runOnlyPendingTimers();
64
+ expect(mockClients).toHaveLength(2);
65
+ });
66
+ it("does not reconnect immediately — waits for backoff", () => {
67
+ getSyncedStateClient("wss://test.example.com/__synced-state");
68
+ mockClients[0].simulateBreak();
69
+ // Before the timer fires, no new session yet
70
+ expect(mockClients).toHaveLength(1);
71
+ // After timer fires, reconnect happens
72
+ vi.runOnlyPendingTimers();
73
+ expect(mockClients).toHaveLength(2);
74
+ });
75
+ it("re-subscribes active subscriptions after reconnect", async () => {
76
+ const client = getSyncedStateClient("wss://test.example.com/__synced-state");
77
+ const handler = vi.fn();
78
+ await client.subscribe("counter", handler);
79
+ // Simulate connection break
80
+ mockClients[0].simulateBreak();
81
+ vi.runOnlyPendingTimers();
82
+ // The new client should have subscribe called with the same key and handler
83
+ const newClient = mockClients[1];
84
+ expect(newClient.subscribe).toHaveBeenCalledWith("counter", handler);
85
+ });
86
+ it("fetches latest state for each subscription after reconnect", async () => {
87
+ const client = getSyncedStateClient("wss://test.example.com/__synced-state");
88
+ const handler = vi.fn();
89
+ await client.subscribe("counter", handler);
90
+ mockClients[0].simulateBreak();
91
+ vi.runOnlyPendingTimers();
92
+ const newClient = mockClients[1];
93
+ expect(newClient.getState).toHaveBeenCalledWith("counter");
94
+ });
95
+ it("calls handler with fetched state when value is not undefined", async () => {
96
+ const client = getSyncedStateClient("wss://test.example.com/__synced-state");
97
+ const handler = vi.fn();
98
+ await client.subscribe("counter", handler);
99
+ // Next client will return a value for getState
100
+ newWebSocketRpcSession.mockImplementationOnce(() => {
101
+ const c = makeMockClient();
102
+ c.getState.mockResolvedValue(42);
103
+ return c;
104
+ });
105
+ mockClients[0].simulateBreak();
106
+ vi.runOnlyPendingTimers();
107
+ // Allow the getState promise to resolve
108
+ await vi.runAllTimersAsync();
109
+ expect(handler).toHaveBeenCalledWith(42);
110
+ });
111
+ it("does not call handler when fetched state is undefined", async () => {
112
+ const client = getSyncedStateClient("wss://test.example.com/__synced-state");
113
+ const handler = vi.fn();
114
+ await client.subscribe("counter", handler);
115
+ handler.mockClear();
116
+ mockClients[0].simulateBreak();
117
+ vi.runOnlyPendingTimers();
118
+ // Default mock returns undefined for getState
119
+ await vi.runAllTimersAsync();
120
+ expect(handler).not.toHaveBeenCalled();
121
+ });
122
+ it("does not re-subscribe keys that were unsubscribed before reconnect", async () => {
123
+ const client = getSyncedStateClient("wss://test.example.com/__synced-state");
124
+ const handler = vi.fn();
125
+ await client.subscribe("counter", handler);
126
+ await client.unsubscribe("counter", handler);
127
+ mockClients[0].simulateBreak();
128
+ vi.runOnlyPendingTimers();
129
+ const newClient = mockClients[1];
130
+ expect(newClient.subscribe).not.toHaveBeenCalled();
131
+ });
132
+ it("does not schedule multiple reconnects for the same endpoint", () => {
133
+ getSyncedStateClient("wss://test.example.com/__synced-state");
134
+ // Fire broken twice rapidly
135
+ mockClients[0].simulateBreak();
136
+ mockClients[0].simulateBreak();
137
+ vi.runOnlyPendingTimers();
138
+ // Should only have created one new session
139
+ expect(mockClients).toHaveLength(2);
140
+ });
141
+ it("uses exponential backoff with jitter and a 30s cap", () => {
142
+ // Backoff is base * (0.75..1.25) due to ±25% jitter, so we check ranges
143
+ const inRange = (val, min, max) => val >= min && val <= max;
144
+ expect(inRange(__testing.getBackoffMs(0), 750, 1250)).toBe(true); // base 1000
145
+ expect(inRange(__testing.getBackoffMs(1), 1500, 2500)).toBe(true); // base 2000
146
+ expect(inRange(__testing.getBackoffMs(2), 3000, 5000)).toBe(true); // base 4000
147
+ expect(inRange(__testing.getBackoffMs(3), 6000, 10000)).toBe(true); // base 8000
148
+ expect(inRange(__testing.getBackoffMs(5), 22500, 30000)).toBe(true); // capped at 30000
149
+ expect(inRange(__testing.getBackoffMs(10), 22500, 30000)).toBe(true); // still capped
150
+ });
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");
154
+ expect(client1).toBe(client2);
155
+ expect(mockClients).toHaveLength(1);
156
+ });
157
+ it("re-subscribes multiple subscriptions after reconnect", async () => {
158
+ const client = getSyncedStateClient("wss://test.example.com/__synced-state");
159
+ const handler1 = vi.fn();
160
+ const handler2 = vi.fn();
161
+ await client.subscribe("counter", handler1);
162
+ await client.subscribe("score", handler2);
163
+ mockClients[0].simulateBreak();
164
+ vi.runOnlyPendingTimers();
165
+ const newClient = mockClients[1];
166
+ expect(newClient.subscribe).toHaveBeenCalledWith("counter", handler1);
167
+ expect(newClient.subscribe).toHaveBeenCalledWith("score", handler2);
168
+ expect(newClient.getState).toHaveBeenCalledWith("counter");
169
+ expect(newClient.getState).toHaveBeenCalledWith("score");
170
+ });
171
+ describe("onStatusChange", () => {
172
+ const ENDPOINT = "wss://test.example.com/__synced-state";
173
+ it("fires 'disconnected' immediately when connection breaks", () => {
174
+ getSyncedStateClient(ENDPOINT);
175
+ const statusCb = vi.fn();
176
+ onStatusChange(ENDPOINT, statusCb);
177
+ mockClients[0].simulateBreak();
178
+ expect(statusCb).toHaveBeenCalledWith("disconnected");
179
+ });
180
+ it("fires 'reconnecting' then 'connected' when reconnect completes", async () => {
181
+ getSyncedStateClient(ENDPOINT);
182
+ const statusCb = vi.fn();
183
+ onStatusChange(ENDPOINT, statusCb);
184
+ mockClients[0].simulateBreak();
185
+ statusCb.mockClear();
186
+ vi.runOnlyPendingTimers();
187
+ await vi.runAllTimersAsync();
188
+ expect(statusCb).toHaveBeenCalledTimes(2);
189
+ expect(statusCb).toHaveBeenNthCalledWith(1, "reconnecting");
190
+ expect(statusCb).toHaveBeenNthCalledWith(2, "connected");
191
+ });
192
+ it("fires full lifecycle: disconnected → reconnecting → connected", async () => {
193
+ getSyncedStateClient(ENDPOINT);
194
+ const statuses = [];
195
+ onStatusChange(ENDPOINT, (s) => statuses.push(s));
196
+ mockClients[0].simulateBreak();
197
+ vi.runOnlyPendingTimers();
198
+ await vi.runAllTimersAsync();
199
+ expect(statuses).toEqual(["disconnected", "reconnecting", "connected"]);
200
+ });
201
+ it("returns an unsubscribe function that stops notifications", () => {
202
+ getSyncedStateClient(ENDPOINT);
203
+ const statusCb = vi.fn();
204
+ const unsub = onStatusChange(ENDPOINT, statusCb);
205
+ unsub();
206
+ mockClients[0].simulateBreak();
207
+ expect(statusCb).not.toHaveBeenCalled();
208
+ });
209
+ it("supports multiple listeners on the same endpoint", () => {
210
+ getSyncedStateClient(ENDPOINT);
211
+ const cb1 = vi.fn();
212
+ const cb2 = vi.fn();
213
+ onStatusChange(ENDPOINT, cb1);
214
+ onStatusChange(ENDPOINT, cb2);
215
+ mockClients[0].simulateBreak();
216
+ expect(cb1).toHaveBeenCalledWith("disconnected");
217
+ expect(cb2).toHaveBeenCalledWith("disconnected");
218
+ });
219
+ });
220
+ // ============================================================
221
+ // REPRODUCTIONS: Failing tests demonstrating Copilot-flagged bugs
222
+ // ============================================================
223
+ describe("REPRO: bug reproductions", () => {
224
+ it("BUG: status listener registered with relative URL never fires because reconnect uses the normalized absolute URL", () => {
225
+ // Stub window so relative URLs get normalized inside getSyncedStateClient
226
+ vi.stubGlobal("window", {
227
+ location: { protocol: "https:", host: "example.com" },
228
+ addEventListener: () => { },
229
+ });
230
+ const RELATIVE = "/__synced-state";
231
+ const statusCb = vi.fn();
232
+ // This mirrors what useSyncedState.ts does: register listener with
233
+ // the same (relative) string it passes to getSyncedStateClient.
234
+ onStatusChange(RELATIVE, statusCb);
235
+ getSyncedStateClient(RELATIVE);
236
+ mockClients[0].simulateBreak();
237
+ vi.runOnlyPendingTimers();
238
+ // Expected: full lifecycle fires. Actual: nothing fires because
239
+ // reconnect notifies using "wss://example.com/__synced-state"
240
+ // but the listener is stored under "/__synced-state".
241
+ expect(statusCb).toHaveBeenCalledWith("disconnected");
242
+ vi.unstubAllGlobals();
243
+ });
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";
246
+ getSyncedStateClient(ENDPOINT);
247
+ // Simulate two React components sharing the same onStatusChange
248
+ // callback (the case when createSyncedStateHook({ onStatusChange })
249
+ // is used by multiple component instances).
250
+ const sharedCallback = vi.fn();
251
+ const unsubA = onStatusChange(ENDPOINT, sharedCallback);
252
+ const unsubB = onStatusChange(ENDPOINT, sharedCallback);
253
+ // Component A unmounts
254
+ unsubA();
255
+ // Component B is still mounted and should still receive updates
256
+ mockClients[0].simulateBreak();
257
+ // Expected: callback fires once (for B). Actual: fires zero times
258
+ // because Set.delete removed the single shared entry.
259
+ expect(sharedCallback).toHaveBeenCalledWith("disconnected");
260
+ unsubB();
261
+ });
262
+ it("BUG: reconnect emits 'connected' and resets backoff even when subscribe() rejects", async () => {
263
+ const ENDPOINT = "wss://test.example.com/__synced-state";
264
+ const client = getSyncedStateClient(ENDPOINT);
265
+ const handler = vi.fn();
266
+ await client.subscribe("counter", handler);
267
+ // Next client's subscribe will reject
268
+ newWebSocketRpcSession.mockImplementationOnce(() => {
269
+ const c = makeMockClient();
270
+ c.subscribe.mockRejectedValue(new Error("subscribe failed"));
271
+ return c;
272
+ });
273
+ const statuses = [];
274
+ onStatusChange(ENDPOINT, (s) => statuses.push(s));
275
+ mockClients[0].simulateBreak();
276
+ vi.runOnlyPendingTimers();
277
+ await vi.runAllTimersAsync();
278
+ // Expected: on failure, we should NOT claim connected and NOT reset backoff.
279
+ // Actual: "connected" fires and backoff resets to 0 despite the failure.
280
+ expect(statuses).not.toContain("connected");
281
+ expect(__testing.backoffState.get(ENDPOINT)?.attempt ?? 0).toBeGreaterThan(0);
282
+ });
283
+ });
284
+ });
@@ -1,12 +1,27 @@
1
+ export type SyncedStateStatus = "connected" | "disconnected" | "reconnecting";
2
+ export type StatusChangeCallback = (status: SyncedStateStatus) => void;
1
3
  export type SyncedStateClient = {
2
4
  getState(key: string): Promise<unknown>;
3
5
  setState(value: unknown, key: string): Promise<void>;
4
6
  subscribe(key: string, handler: (value: unknown) => void): Promise<void>;
5
7
  unsubscribe(key: string, handler: (value: unknown) => void): Promise<void>;
6
8
  };
9
+ type Subscription = {
10
+ key: string;
11
+ handler: (value: unknown) => void;
12
+ client: SyncedStateClient;
13
+ };
14
+ /**
15
+ * Registers a callback that fires when the connection status changes for an endpoint.
16
+ * Returns an unsubscribe function.
17
+ */
18
+ export declare const onStatusChange: (endpoint: string, callback: StatusChangeCallback) => (() => void);
19
+ declare function getBackoffMs(attempt: number): number;
20
+ declare function reconnect(endpoint: string, deadClient: SyncedStateClient): void;
7
21
  /**
8
22
  * Returns a cached client for the provided endpoint, creating it when necessary.
9
23
  * The client is wrapped to track subscriptions for cleanup on page reload.
24
+ * On connection failure, automatically reconnects and re-subscribes.
10
25
  * @param endpoint Endpoint to connect to.
11
26
  * @returns RPC client instance.
12
27
  */
@@ -27,3 +42,15 @@ export declare const initSyncedStateClient: (options?: {
27
42
  * @param endpoint Endpoint associated with the injected client.
28
43
  */
29
44
  export declare const setSyncedStateClientForTesting: (client: SyncedStateClient | null, endpoint?: string) => void;
45
+ export declare const __testing: {
46
+ activeSubscriptions: Set<Subscription>;
47
+ clientCache: Map<string, SyncedStateClient>;
48
+ backoffState: Map<string, {
49
+ attempt: number;
50
+ timer: ReturnType<typeof setTimeout> | null;
51
+ }>;
52
+ statusListeners: Map<string, StatusChangeCallback[]>;
53
+ reconnect: typeof reconnect;
54
+ getBackoffMs: typeof getBackoffMs;
55
+ };
56
+ export {};
@@ -1,8 +1,64 @@
1
1
  import { newWebSocketRpcSession } from "capnweb";
2
2
  import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
3
+ // Converts a relative endpoint like "/__synced-state" to an absolute
4
+ // ws:// or wss:// URL so the same key is used by getSyncedStateClient,
5
+ // onStatusChange, and reconnect notifications.
6
+ function normalizeEndpoint(endpoint) {
7
+ if (endpoint.startsWith("/") && typeof window !== "undefined") {
8
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
9
+ return `${protocol}//${window.location.host}${endpoint}`;
10
+ }
11
+ return endpoint;
12
+ }
3
13
  // Map of endpoint URLs to their respective clients
4
14
  const clientCache = new Map();
5
15
  const activeSubscriptions = new Set();
16
+ // Status change listeners per endpoint. Uses an array rather than a Set so
17
+ // that two components passing the same callback reference (e.g. via
18
+ // createSyncedStateHook({ onStatusChange })) are tracked as two separate
19
+ // registrations — unsubscribing one must not cancel the other.
20
+ const statusListeners = new Map();
21
+ function notifyStatusChange(endpoint, status) {
22
+ const listeners = statusListeners.get(endpoint);
23
+ if (listeners) {
24
+ // Snapshot so unsubscribes fired by callbacks don't skip entries.
25
+ for (const cb of [...listeners]) {
26
+ cb(status);
27
+ }
28
+ }
29
+ }
30
+ /**
31
+ * Registers a callback that fires when the connection status changes for an endpoint.
32
+ * Returns an unsubscribe function.
33
+ */
34
+ export const onStatusChange = (endpoint, callback) => {
35
+ const normalized = normalizeEndpoint(endpoint);
36
+ let listeners = statusListeners.get(normalized);
37
+ if (!listeners) {
38
+ listeners = [];
39
+ statusListeners.set(normalized, listeners);
40
+ }
41
+ listeners.push(callback);
42
+ return () => {
43
+ const idx = listeners.indexOf(callback);
44
+ if (idx !== -1) {
45
+ listeners.splice(idx, 1);
46
+ }
47
+ if (listeners.length === 0) {
48
+ statusListeners.delete(normalized);
49
+ }
50
+ };
51
+ };
52
+ // Tracks per-endpoint reconnection backoff state
53
+ const backoffState = new Map();
54
+ const BASE_BACKOFF_MS = 1000;
55
+ const MAX_BACKOFF_MS = 30000;
56
+ function getBackoffMs(attempt) {
57
+ const base = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
58
+ // Add ±25% jitter to avoid thundering herd on server restart
59
+ const jittered = base * (0.75 + Math.random() * 0.5);
60
+ return Math.round(Math.min(jittered, MAX_BACKOFF_MS));
61
+ }
6
62
  // Set up beforeunload handler to unsubscribe all active subscriptions
7
63
  if (typeof window !== "undefined") {
8
64
  const handleBeforeUnload = () => {
@@ -22,18 +78,62 @@ if (typeof window !== "undefined") {
22
78
  };
23
79
  window.addEventListener("beforeunload", handleBeforeUnload);
24
80
  }
81
+ function reconnect(endpoint, deadClient) {
82
+ // Don't schedule multiple reconnects for the same endpoint
83
+ const state = backoffState.get(endpoint) ?? { attempt: 0, timer: null };
84
+ if (state.timer !== null) {
85
+ return;
86
+ }
87
+ notifyStatusChange(endpoint, "disconnected");
88
+ const delayMs = getBackoffMs(state.attempt);
89
+ state.timer = setTimeout(() => {
90
+ state.timer = null;
91
+ state.attempt++;
92
+ backoffState.set(endpoint, state);
93
+ notifyStatusChange(endpoint, "reconnecting");
94
+ // Evict the dead client so getSyncedStateClient creates a fresh one
95
+ clientCache.delete(endpoint);
96
+ const newClient = getSyncedStateClient(endpoint);
97
+ // Re-subscribe everything that was on the dead client. Kick off both
98
+ // subscribe() and getState() synchronously so callers see the calls
99
+ // happen inside the timer tick, but only confirm "connected" once the
100
+ // subscribe promises resolve — otherwise a rejected resubscription
101
+ // would be masked as a successful reconnect.
102
+ const subscribePromises = [];
103
+ for (const sub of activeSubscriptions) {
104
+ if (sub.client === deadClient) {
105
+ sub.client = newClient;
106
+ subscribePromises.push(newClient.subscribe(sub.key, sub.handler));
107
+ void newClient.getState(sub.key).then((val) => {
108
+ if (val !== undefined) {
109
+ sub.handler(val);
110
+ }
111
+ });
112
+ }
113
+ }
114
+ Promise.all(subscribePromises).then(() => {
115
+ backoffState.set(endpoint, { attempt: 0, timer: null });
116
+ notifyStatusChange(endpoint, "connected");
117
+ }, () => {
118
+ // Resubscription failed — leave the attempt counter elevated so
119
+ // the next reconnect uses a longer backoff, and emit disconnected
120
+ // again. A subsequent onRpcBroken from the new (likely dead)
121
+ // client will drive the next retry.
122
+ notifyStatusChange(endpoint, "disconnected");
123
+ });
124
+ }, delayMs);
125
+ backoffState.set(endpoint, state);
126
+ }
25
127
  /**
26
128
  * Returns a cached client for the provided endpoint, creating it when necessary.
27
129
  * The client is wrapped to track subscriptions for cleanup on page reload.
130
+ * On connection failure, automatically reconnects and re-subscribes.
28
131
  * @param endpoint Endpoint to connect to.
29
132
  * @returns RPC client instance.
30
133
  */
31
134
  export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
32
135
  // Convert relative endpoint to absolute URL for environments like WKWebView
33
- if (endpoint.startsWith("/") && typeof window !== "undefined") {
34
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
35
- endpoint = `${protocol}//${window.location.host}${endpoint}`;
36
- }
136
+ endpoint = normalizeEndpoint(endpoint);
37
137
  // Return existing client if already cached for this endpoint
38
138
  const existingClient = clientCache.get(endpoint);
39
139
  if (existingClient) {
@@ -73,6 +173,12 @@ export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
73
173
  return target[prop];
74
174
  },
75
175
  });
176
+ // Listen for connection failure and trigger reconnection
177
+ if (typeof baseClient.onRpcBroken === "function") {
178
+ baseClient.onRpcBroken(() => {
179
+ reconnect(endpoint, wrappedClient);
180
+ });
181
+ }
76
182
  // Cache the client for this endpoint
77
183
  clientCache.set(endpoint, wrappedClient);
78
184
  return wrappedClient;
@@ -105,4 +211,21 @@ export const setSyncedStateClientForTesting = (client, endpoint = DEFAULT_SYNCED
105
211
  clientCache.delete(endpoint);
106
212
  }
107
213
  activeSubscriptions.clear();
214
+ statusListeners.clear();
215
+ // Clear any pending reconnection timers
216
+ for (const [, state] of backoffState) {
217
+ if (state.timer !== null) {
218
+ clearTimeout(state.timer);
219
+ }
220
+ }
221
+ backoffState.clear();
222
+ };
223
+ // Exported for testing only
224
+ export const __testing = {
225
+ activeSubscriptions,
226
+ clientCache,
227
+ backoffState,
228
+ statusListeners,
229
+ reconnect,
230
+ getBackoffMs,
108
231
  };
@@ -1,3 +1,3 @@
1
1
  export { getSyncedStateClient, initSyncedStateClient, setSyncedStateClientForTesting, } from "./client-core.js";
2
- export type { SyncedStateClient } from "./client-core.js";
3
- export { useSyncedState } from "./useSyncedState.js";
2
+ export type { SyncedStateClient, SyncedStateStatus, StatusChangeCallback, } from "./client-core.js";
3
+ export { useSyncedState, createSyncedStateHook } from "./useSyncedState.js";
@@ -1,4 +1,4 @@
1
1
  // Re-export everything from client-core to maintain the public API
2
2
  export { getSyncedStateClient, initSyncedStateClient, setSyncedStateClientForTesting, } from "./client-core.js";
3
3
  // Re-export useSyncedState (no circular dependency since useSyncedState imports from client-core, not client)
4
- export { useSyncedState } from "./useSyncedState.js";
4
+ export { useSyncedState, createSyncedStateHook } from "./useSyncedState.js";
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { type StatusChangeCallback } from "./client-core.js";
2
3
  type HookDeps = {
3
4
  useState: typeof React.useState;
4
5
  useEffect: typeof React.useEffect;
@@ -10,6 +11,7 @@ export type CreateSyncedStateHookOptions = {
10
11
  url?: string;
11
12
  roomId?: string;
12
13
  hooks?: HookDeps;
14
+ onStatusChange?: StatusChangeCallback;
13
15
  };
14
16
  /**
15
17
  * Builds a `useSyncedState` hook configured with optional endpoint and hook overrides.
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { getSyncedStateClient } from "./client-core.js";
2
+ import { getSyncedStateClient, onStatusChange, } from "./client-core.js";
3
3
  import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
4
4
  const defaultDeps = {
5
5
  useState: React.useState,
@@ -48,8 +48,12 @@ export const createSyncedStateHook = (options = {}) => {
48
48
  }
49
49
  });
50
50
  void client.subscribe(key, handleUpdate);
51
+ const unsubscribeStatus = options.onStatusChange
52
+ ? onStatusChange(resolvedUrl, options.onStatusChange)
53
+ : undefined;
51
54
  return () => {
52
55
  isActive = false;
56
+ unsubscribeStatus?.();
53
57
  // Call unsubscribe when component unmounts
54
58
  // Page reloads are handled by the beforeunload event listener in client-core.ts
55
59
  void client.unsubscribe(key, handleUpdate).catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.0.9",
3
+ "version": "1.2.0",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {