rwsdk 1.2.8 → 1.2.10

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.
Files changed (38) hide show
  1. package/dist/lib/e2e/browser.d.mts +4 -0
  2. package/dist/lib/e2e/browser.mjs +58 -0
  3. package/dist/lib/e2e/constants.d.mts +2 -0
  4. package/dist/lib/e2e/constants.mjs +6 -0
  5. package/dist/lib/e2e/dev.mjs +7 -18
  6. package/dist/lib/e2e/release.d.mts +7 -0
  7. package/dist/lib/e2e/release.mjs +78 -2
  8. package/dist/lib/e2e/tarball.mjs +4 -1
  9. package/dist/lib/e2e/testHarness.d.mts +1 -7
  10. package/dist/lib/e2e/testHarness.mjs +30 -10
  11. package/dist/lib/smokeTests/browser.d.mts +1 -1
  12. package/dist/lib/smokeTests/browser.mjs +36 -30
  13. package/dist/lib/smokeTests/release.d.mts +8 -1
  14. package/dist/lib/smokeTests/release.mjs +54 -29
  15. package/dist/lib/smokeTests/runSmokeTests.mjs +1 -1
  16. package/dist/runtime/client/navigation.js +42 -61
  17. package/dist/runtime/client/navigation.test.js +145 -8
  18. package/dist/runtime/client/scrollRestoration.d.ts +25 -0
  19. package/dist/runtime/client/scrollRestoration.js +157 -0
  20. package/dist/runtime/client/scrollRestoration.test.d.ts +1 -0
  21. package/dist/runtime/client/scrollRestoration.test.js +93 -0
  22. package/dist/runtime/lib/router.d.ts +1 -0
  23. package/dist/runtime/lib/router.js +27 -2
  24. package/dist/runtime/lib/router.test.js +96 -0
  25. package/dist/scripts/worker-run.mjs +42 -8
  26. package/dist/use-synced-state/__tests__/SyncStateServer.test.mjs +14 -7
  27. package/dist/use-synced-state/__tests__/worker.test.mjs +41 -2
  28. package/dist/use-synced-state/worker.mjs +34 -3
  29. package/dist/vite/miniflareHMRPlugin.mjs +1 -1
  30. package/dist/vite/ssrBridgePlugin.d.mts +0 -1
  31. package/dist/vite/ssrBridgePlugin.mjs +17 -14
  32. package/dist/vite/ssrVirtualModule.d.mts +3 -0
  33. package/dist/vite/ssrVirtualModule.mjs +11 -0
  34. package/dist/vite/transformJsxScriptTagsPlugin.hook.test.d.mts +1 -0
  35. package/dist/vite/transformJsxScriptTagsPlugin.hook.test.mjs +73 -0
  36. package/dist/vite/transformJsxScriptTagsPlugin.mjs +5 -0
  37. package/dist/vite/transformJsxScriptTagsPlugin.test.mjs +4 -3
  38. package/package.json +2 -3
@@ -185,11 +185,28 @@ export function defineRoutes(routes) {
185
185
  function isExceptHandler(route) {
186
186
  return route.type === "except";
187
187
  }
188
+ function isPathInExceptScope(pathPattern, requestPath) {
189
+ const normalized = pathPattern.endsWith("/")
190
+ ? pathPattern
191
+ : pathPattern + "/";
192
+ const hasParams = normalized.includes(":") || normalized.includes("*");
193
+ if (hasParams) {
194
+ const wildcardPattern = normalized.slice(0, -1) + "/*";
195
+ return (matchPath(wildcardPattern, requestPath) !== null ||
196
+ matchPath(normalized.slice(0, -1), requestPath) !== null);
197
+ }
198
+ return (requestPath === normalized || requestPath.startsWith(normalized));
199
+ }
188
200
  async function executeExceptHandlers(error, startIndex) {
189
201
  // Search backwards from startIndex to find the most recent except handler
190
202
  for (let i = startIndex; i >= 0; i--) {
191
203
  const route = compiledRoutes[i];
192
204
  if (isExceptHandler(route)) {
205
+ const pattern = route.handler.pathPattern;
206
+ if (pattern &&
207
+ !isPathInExceptScope(pattern, getRequestInfo().path)) {
208
+ continue;
209
+ }
193
210
  try {
194
211
  const result = await route.handler.handler(error, getRequestInfo());
195
212
  const handled = await handleMiddlewareResult(result);
@@ -554,8 +571,16 @@ export function prefix(prefixPath, routes) {
554
571
  r !== null &&
555
572
  "__rwExcept" in r &&
556
573
  r.__rwExcept === true) {
557
- // Pass through ExceptHandler as-is
558
- return r;
574
+ const existing = r.pathPattern;
575
+ const combined = existing
576
+ ? joinPaths(normalizedPrefix, existing)
577
+ : normalizedPrefix;
578
+ const scoped = {
579
+ __rwExcept: true,
580
+ handler: r.handler,
581
+ pathPattern: combined,
582
+ };
583
+ return scoped;
559
584
  }
560
585
  if (Array.isArray(r)) {
561
586
  // Recursively process nested route arrays
@@ -1535,5 +1535,101 @@ describe("defineRoutes - Request Handling Behavior", () => {
1535
1535
  expect(response.status).toBe(500);
1536
1536
  expect(await response.text()).toBe(`Caught: ${errorMessage}`);
1537
1537
  });
1538
+ describe("prefix scoping", () => {
1539
+ const makePrefixApp = () => {
1540
+ const globalHandler = except(() => new Response("[GLOBAL]", {
1541
+ status: 500,
1542
+ }));
1543
+ const adminHandler = except(() => new Response("[ADMIN]", {
1544
+ status: 500,
1545
+ }));
1546
+ const router = defineRoutes([
1547
+ globalHandler,
1548
+ ...prefix("/admin", [
1549
+ adminHandler,
1550
+ route("/dashboard/", () => {
1551
+ throw new Error("admin dashboard error");
1552
+ }),
1553
+ ]),
1554
+ route("/", () => {
1555
+ throw new Error("home route error");
1556
+ }),
1557
+ ]);
1558
+ return router;
1559
+ };
1560
+ const handle = async (router, path) => {
1561
+ const deps = createMockDependencies();
1562
+ deps.mockRequestInfo.request = new Request(`http://localhost:3000${path}`);
1563
+ return router.handle({
1564
+ request: new Request(`http://localhost:3000${path}`),
1565
+ renderPage: deps.mockRenderPage,
1566
+ getRequestInfo: deps.getRequestInfo,
1567
+ onError: deps.onError,
1568
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
1569
+ rscActionHandler: deps.mockRscActionHandler,
1570
+ });
1571
+ };
1572
+ it("an except inside prefix should NOT catch errors from routes outside the prefix", async () => {
1573
+ const router = makePrefixApp();
1574
+ const response = await handle(router, "/");
1575
+ expect(await response.text()).toBe("[GLOBAL]");
1576
+ });
1577
+ it("an except inside prefix should catch errors from routes inside the prefix", async () => {
1578
+ const router = makePrefixApp();
1579
+ const response = await handle(router, "/admin/dashboard/");
1580
+ expect(await response.text()).toBe("[ADMIN]");
1581
+ });
1582
+ it("an except inside prefix that returns void should bubble to the global handler", async () => {
1583
+ const router = defineRoutes([
1584
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1585
+ ...prefix("/admin", [
1586
+ except(() => undefined),
1587
+ route("/dashboard/", () => {
1588
+ throw new Error("boom");
1589
+ }),
1590
+ ]),
1591
+ ]);
1592
+ const response = await handle(router, "/admin/dashboard/");
1593
+ expect(await response.text()).toBe("[GLOBAL]");
1594
+ });
1595
+ it("nested prefixes compose the scope path", async () => {
1596
+ const router = defineRoutes([
1597
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1598
+ ...prefix("/a", [
1599
+ ...prefix("/b", [
1600
+ except(() => new Response("[A/B]", { status: 500 })),
1601
+ route("/c/", () => {
1602
+ throw new Error("a/b/c error");
1603
+ }),
1604
+ ]),
1605
+ ]),
1606
+ route("/d/", () => {
1607
+ throw new Error("d error");
1608
+ }),
1609
+ ]);
1610
+ const inside = await handle(router, "/a/b/c/");
1611
+ expect(await inside.text()).toBe("[A/B]");
1612
+ const outside = await handle(router, "/d/");
1613
+ expect(await outside.text()).toBe("[GLOBAL]");
1614
+ });
1615
+ it("parameterized prefixes scope the except handler", async () => {
1616
+ const router = defineRoutes([
1617
+ except(() => new Response("[GLOBAL]", { status: 500 })),
1618
+ ...prefix("/users/:id", [
1619
+ except(() => new Response("[USER]", { status: 500 })),
1620
+ route("/profile/", () => {
1621
+ throw new Error("profile error");
1622
+ }),
1623
+ ]),
1624
+ route("/about/", () => {
1625
+ throw new Error("about error");
1626
+ }),
1627
+ ]);
1628
+ const inside = await handle(router, "/users/42/profile/");
1629
+ expect(await inside.text()).toBe("[USER]");
1630
+ const outside = await handle(router, "/about/");
1631
+ expect(await outside.text()).toBe("[GLOBAL]");
1632
+ });
1633
+ });
1538
1634
  });
1539
1635
  });
@@ -5,6 +5,7 @@ import path from "path";
5
5
  import { pathToFileURL } from "url";
6
6
  import * as vite from "vite";
7
7
  import { createLogger } from "vite";
8
+ import { checkServerUp } from "../lib/e2e/browser.mjs";
8
9
  const debug = dbg("rwsdk:worker-run");
9
10
  const main = async () => {
10
11
  process.env.RWSDK_WORKER_RUN = "1";
@@ -47,14 +48,47 @@ const main = async () => {
47
48
  });
48
49
  await server.listen();
49
50
  const fileUrl = pathToFileURL(scriptPath).href;
50
- const url = `http://localhost:${port}/__worker-run?script=${encodeURIComponent(fileUrl)}`;
51
- debug("Fetching %s", url);
52
- const response = await fetch(url, {
53
- headers: {
54
- "x-rwsdk-worker-run-token": token,
55
- },
56
- });
57
- debug("Response from worker: %s", response);
51
+ const readyUrl = await checkServerUp(`http://localhost:${port}`, "/__debug", 30, false);
52
+ const readyOrigin = new URL(readyUrl).origin;
53
+ const readyOriginUrl = new URL(readyOrigin);
54
+ const candidateBaseUrls = Array.from(new Set([
55
+ readyOrigin,
56
+ `${readyOriginUrl.protocol}//127.0.0.1:${readyOriginUrl.port}`,
57
+ `${readyOriginUrl.protocol}//[::1]:${readyOriginUrl.port}`,
58
+ ]));
59
+ let response;
60
+ let lastFetchError;
61
+ for (const candidateBaseUrl of candidateBaseUrls) {
62
+ const url = `${candidateBaseUrl}/__worker-run?script=${encodeURIComponent(fileUrl)}`;
63
+ const fetchAttempts = 5;
64
+ debug("Fetching %s", url);
65
+ for (let attempt = 0; attempt < fetchAttempts; attempt++) {
66
+ try {
67
+ response = await fetch(url, {
68
+ headers: {
69
+ "x-rwsdk-worker-run-token": token,
70
+ },
71
+ });
72
+ debug("Response from worker: %s", response);
73
+ break;
74
+ }
75
+ catch (error) {
76
+ lastFetchError = error;
77
+ debug("Fetch failed for %s on attempt %d/%d: %O", url, attempt + 1, fetchAttempts, error);
78
+ if (attempt < fetchAttempts - 1) {
79
+ await new Promise((resolve) => {
80
+ setTimeout(() => resolve(), 2000);
81
+ });
82
+ }
83
+ }
84
+ }
85
+ if (response) {
86
+ break;
87
+ }
88
+ }
89
+ if (!response) {
90
+ throw lastFetchError ?? new Error("worker-run fetch failed");
91
+ }
58
92
  if (!response.ok) {
59
93
  const errorText = await response.text();
60
94
  console.error(`Error: worker-run script failed with status ${response.status}.`);
@@ -6,12 +6,15 @@ vi.mock("cloudflare:workers", () => {
6
6
  });
7
7
  import { SyncedStateServer } from "../SyncedStateServer.mjs";
8
8
  const createStub = (onInvoke) => {
9
- const fn = Object.assign(async (value) => {
10
- await onInvoke(value);
11
- }, {
12
- dup: () => fn,
13
- });
14
- return fn;
9
+ const makeStub = () => {
10
+ const fn = Object.assign(async (value) => {
11
+ await onInvoke(value);
12
+ }, {
13
+ dup: () => makeStub(),
14
+ });
15
+ return fn;
16
+ };
17
+ return makeStub();
15
18
  };
16
19
  describe("SyncedStateServer", () => {
17
20
  it("notifies subscribers when state changes", async () => {
@@ -27,11 +30,15 @@ describe("SyncedStateServer", () => {
27
30
  });
28
31
  it("removes subscriptions on unsubscribe", () => {
29
32
  const coordinator = new SyncedStateServer({}, {});
30
- const stub = createStub(() => { });
33
+ const received = [];
34
+ const stub = createStub((value) => {
35
+ received.push(value);
36
+ });
31
37
  coordinator.subscribe("counter", stub);
32
38
  coordinator.unsubscribe("counter", stub);
33
39
  coordinator.setState(1, "counter");
34
40
  expect(coordinator.getState("counter")).toBe(1);
41
+ expect(received).toEqual([]);
35
42
  });
36
43
  it("drops failing subscribers", async () => {
37
44
  const coordinator = new SyncedStateServer({}, {});
@@ -9,10 +9,14 @@ vi.mock("capnweb", () => ({
9
9
  },
10
10
  newWorkersRpcResponse: vi.fn(),
11
11
  }));
12
- vi.mock("../runtime/entries/router", () => ({
12
+ vi.mock("../../runtime/entries/router", () => ({
13
13
  route: vi.fn((path, handler) => ({ path, handler })),
14
14
  }));
15
- import { SyncedStateServer } from "../SyncedStateServer.mjs";
15
+ vi.mock("../../runtime/requestInfo/worker", () => ({
16
+ runWithRequestInfo: (_requestInfo, fn) => fn(),
17
+ }));
18
+ import { newWorkersRpcResponse } from "capnweb";
19
+ import { syncedStateRoutes, SyncedStateServer } from "../worker.mjs";
16
20
  describe("SyncedStateProxy", () => {
17
21
  let mockCoordinator;
18
22
  beforeEach(() => {
@@ -20,6 +24,7 @@ describe("SyncedStateProxy", () => {
20
24
  });
21
25
  const mockStub = {};
22
26
  afterEach(() => {
27
+ vi.mocked(newWorkersRpcResponse).mockReset();
23
28
  SyncedStateServer.registerKeyHandler(async (key, stub) => key);
24
29
  });
25
30
  it("transforms keys before calling coordinator methods when handler is registered", async () => {
@@ -58,6 +63,40 @@ describe("SyncedStateProxy", () => {
58
63
  SyncedStateServer.registerKeyHandler(handler);
59
64
  await expect(handler("test", mockStub)).rejects.toThrow("Handler error");
60
65
  });
66
+ it("uses the subscribed duplicated RPC client when unsubscribing (#1207)", async () => {
67
+ const coordinator = {
68
+ _setStub: vi.fn(),
69
+ subscribe: vi.fn().mockResolvedValue(undefined),
70
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
71
+ };
72
+ const namespace = {
73
+ idFromName: vi.fn(() => "synced-state-id"),
74
+ get: vi.fn(() => coordinator),
75
+ };
76
+ let proxy;
77
+ vi.mocked(newWorkersRpcResponse).mockImplementation(async (_request, api) => {
78
+ proxy = api;
79
+ return new Response(null, { status: 204 });
80
+ });
81
+ SyncedStateServer.registerKeyHandler(async (key) => key);
82
+ const [baseRoute] = syncedStateRoutes(() => namespace);
83
+ await baseRoute.handler({
84
+ request: new Request("https://example.com/__synced-state"),
85
+ params: {},
86
+ });
87
+ const duplicatedClient = Object.assign(vi.fn(), {
88
+ dup: vi.fn(() => duplicatedClient),
89
+ });
90
+ const originalClient = Object.assign(vi.fn(), {
91
+ dup: vi.fn(() => duplicatedClient),
92
+ });
93
+ await proxy.subscribe("notifications", originalClient);
94
+ await proxy.unsubscribe("notifications", originalClient);
95
+ const subscribeClient = coordinator.subscribe.mock.calls[0][1];
96
+ const unsubscribeClient = coordinator.unsubscribe.mock.calls[0][1];
97
+ expect(subscribeClient).not.toBe(originalClient);
98
+ expect(unsubscribeClient).toBe(subscribeClient);
99
+ });
61
100
  it("handles async operations in handler", async () => {
62
101
  const handler = async (key, stub) => {
63
102
  await new Promise((resolve) => setTimeout(resolve, 5));
@@ -19,7 +19,7 @@ export { SyncedStateServer };
19
19
  const DEFAULT_SYNC_STATE_NAME = "syncedState";
20
20
  let SyncedStateProxyClass = null;
21
21
  async function getSyncedStateProxy() {
22
- var _SyncedStateProxy_instances, _SyncedStateProxy_stub, _SyncedStateProxy_keyHandler, _SyncedStateProxy_requestInfo, _SyncedStateProxy_transformKey, _SyncedStateProxy_callHandler, _a;
22
+ var _SyncedStateProxy_instances, _SyncedStateProxy_stub, _SyncedStateProxy_keyHandler, _SyncedStateProxy_requestInfo, _SyncedStateProxy_subscriptionClients, _SyncedStateProxy_transformKey, _SyncedStateProxy_callHandler, _a;
23
23
  const { RpcTarget, newWorkersRpcResponse } = await loadCapnweb();
24
24
  if (!SyncedStateProxyClass) {
25
25
  SyncedStateProxyClass = (_a = class SyncedStateProxy extends RpcTarget {
@@ -29,6 +29,9 @@ async function getSyncedStateProxy() {
29
29
  _SyncedStateProxy_stub.set(this, void 0);
30
30
  _SyncedStateProxy_keyHandler.set(this, void 0);
31
31
  _SyncedStateProxy_requestInfo.set(this, void 0);
32
+ // Map original RPC callbacks to the duplicated callbacks sent to the DO
33
+ // so unsubscribe uses the same identity that subscribe registered.
34
+ _SyncedStateProxy_subscriptionClients.set(this, new Map());
32
35
  __classPrivateFieldSet(this, _SyncedStateProxy_stub, stub, "f");
33
36
  __classPrivateFieldSet(this, _SyncedStateProxy_keyHandler, keyHandler, "f");
34
37
  __classPrivateFieldSet(this, _SyncedStateProxy_requestInfo, requestInfo, "f");
@@ -54,7 +57,24 @@ async function getSyncedStateProxy() {
54
57
  // dup the client if it is a function; otherwise, pass it as is;
55
58
  // this is because the client is a WebSocketRpcSession, and we need to pass a new instance of the client to the DO;
56
59
  const clientToPass = typeof client.dup === "function" ? client.dup() : client;
57
- return __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").subscribe(transformedKey, clientToPass);
60
+ let clientsForKey = __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").get(transformedKey);
61
+ if (!clientsForKey) {
62
+ clientsForKey = new Map();
63
+ __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").set(transformedKey, clientsForKey);
64
+ }
65
+ clientsForKey.set(client, clientToPass);
66
+ try {
67
+ return await __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").subscribe(transformedKey, clientToPass);
68
+ }
69
+ catch (error) {
70
+ if (clientsForKey.get(client) === clientToPass) {
71
+ clientsForKey.delete(client);
72
+ if (clientsForKey.size === 0) {
73
+ __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").delete(transformedKey);
74
+ }
75
+ }
76
+ throw error;
77
+ }
58
78
  }
59
79
  async unsubscribe(key, client) {
60
80
  const transformedKey = await __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_transformKey).call(this, key);
@@ -65,18 +85,29 @@ async function getSyncedStateProxy() {
65
85
  if (unsubscribeHandler) {
66
86
  __classPrivateFieldGet(this, _SyncedStateProxy_instances, "m", _SyncedStateProxy_callHandler).call(this, unsubscribeHandler, transformedKey, __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f"));
67
87
  }
88
+ const clientsForKey = __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").get(transformedKey);
89
+ const clientToPass = clientsForKey?.get(client) ?? client;
68
90
  try {
69
- await __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").unsubscribe(transformedKey, client);
91
+ await __classPrivateFieldGet(this, _SyncedStateProxy_stub, "f").unsubscribe(transformedKey, clientToPass);
70
92
  }
71
93
  catch (error) {
72
94
  // Ignore errors during unsubscribe - handler has already been called
73
95
  // This prevents RPC stub disposal errors from propagating
74
96
  }
97
+ finally {
98
+ if (clientsForKey && clientsForKey.get(client) === clientToPass) {
99
+ clientsForKey.delete(client);
100
+ if (clientsForKey.size === 0) {
101
+ __classPrivateFieldGet(this, _SyncedStateProxy_subscriptionClients, "f").delete(transformedKey);
102
+ }
103
+ }
104
+ }
75
105
  }
76
106
  },
77
107
  _SyncedStateProxy_stub = new WeakMap(),
78
108
  _SyncedStateProxy_keyHandler = new WeakMap(),
79
109
  _SyncedStateProxy_requestInfo = new WeakMap(),
110
+ _SyncedStateProxy_subscriptionClients = new WeakMap(),
80
111
  _SyncedStateProxy_instances = new WeakSet(),
81
112
  _SyncedStateProxy_transformKey =
82
113
  /**
@@ -7,7 +7,7 @@ import { normalizeModulePath } from "../lib/normalizeModulePath.mjs";
7
7
  import { hasDirective as sourceHasDirective } from "./hasDirective.mjs";
8
8
  import { invalidateModule } from "./invalidateModule.mjs";
9
9
  import { isJsFile } from "./isJsFile.mjs";
10
- import { VIRTUAL_SSR_PREFIX } from "./ssrBridgePlugin.mjs";
10
+ import { VIRTUAL_SSR_PREFIX } from "./ssrVirtualModule.mjs";
11
11
  const log = debug("rwsdk:vite:hmr-plugin");
12
12
  let hasErrored = false;
13
13
  const hasDirective = async (filepath, directive) => {
@@ -1,5 +1,4 @@
1
1
  import type { Plugin } from "vite";
2
- export declare const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
3
2
  export declare const ssrBridgePlugin: ({ clientFiles, serverFiles, }: {
4
3
  clientFiles: Set<string>;
5
4
  serverFiles: Set<string>;
@@ -3,8 +3,8 @@ import MagicString from "magic-string";
3
3
  import { INTERMEDIATE_SSR_BRIDGE_PATH } from "../lib/constants.mjs";
4
4
  import { externalModulesSet } from "./constants.mjs";
5
5
  import { findSsrImportCallSites } from "./findSsrSpecifiers.mjs";
6
+ import { isVirtualSsrModuleId, normalizeVirtualSsrModuleId, VIRTUAL_SSR_PREFIX, } from "./ssrVirtualModule.mjs";
6
7
  const log = debug("rwsdk:vite:ssr-bridge-plugin");
7
- export const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
8
8
  export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
9
9
  let devServer;
10
10
  let isDev = false;
@@ -63,10 +63,11 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
63
63
  process.env.VERBOSE &&
64
64
  log("Esbuild onResolve called for path=%s, args=%O", args.path, args);
65
65
  if (args.path === "rwsdk/__ssr_bridge" ||
66
- args.path.startsWith(VIRTUAL_SSR_PREFIX)) {
67
- log("Marking as external: %s", args.path);
66
+ isVirtualSsrModuleId(args.path)) {
67
+ const path = normalizeVirtualSsrModuleId(args.path) ?? args.path;
68
+ log("Marking as external: %s", path);
68
69
  return {
69
- path: args.path,
70
+ path,
70
71
  external: true,
71
72
  };
72
73
  }
@@ -98,14 +99,15 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
98
99
  // context(justinvdm, 27 May 2025): In dev, we need to dynamically load
99
100
  // SSR modules, so we return the virtual id so that the dynamic loading
100
101
  // can happen in load()
101
- if (id.startsWith(VIRTUAL_SSR_PREFIX)) {
102
- if (id.endsWith(".css")) {
103
- const newId = id + ".js";
102
+ const virtualSsrId = normalizeVirtualSsrModuleId(id);
103
+ if (virtualSsrId) {
104
+ if (virtualSsrId.endsWith(".css")) {
105
+ const newId = virtualSsrId + ".js";
104
106
  log("Virtual CSS module, adding .js suffix. old: %s, new: %s", id, newId);
105
107
  return newId;
106
108
  }
107
- log("Returning virtual SSR id for dev: %s", id);
108
- return id;
109
+ log("Returning virtual SSR id for dev: %s", virtualSsrId);
110
+ return virtualSsrId;
109
111
  }
110
112
  // context(justinvdm, 28 May 2025): The SSR bridge module is a special case -
111
113
  // it is the entry point for all SSR modules, so to trigger the
@@ -119,10 +121,11 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
119
121
  }
120
122
  else {
121
123
  // In build mode, the behavior depends on the build pass
122
- if (id.startsWith(VIRTUAL_SSR_PREFIX)) {
124
+ const virtualSsrId = normalizeVirtualSsrModuleId(id);
125
+ if (virtualSsrId) {
123
126
  if (this.environment.name === "worker") {
124
127
  log("Virtual SSR module case (build-worker pass): resolving to external");
125
- return { id, external: true };
128
+ return { id: virtualSsrId, external: true };
126
129
  }
127
130
  }
128
131
  if (id === "rwsdk/__ssr_bridge" && this.environment.name === "worker") {
@@ -141,9 +144,9 @@ export const ssrBridgePlugin = ({ clientFiles, serverFiles, }) => {
141
144
  }
142
145
  },
143
146
  async load(id) {
144
- if (id.startsWith(VIRTUAL_SSR_PREFIX) &&
145
- this.environment.name === "worker") {
146
- const realId = id.slice(VIRTUAL_SSR_PREFIX.length);
147
+ const virtualSsrId = normalizeVirtualSsrModuleId(id);
148
+ if (virtualSsrId && this.environment.name === "worker") {
149
+ const realId = virtualSsrId.slice(VIRTUAL_SSR_PREFIX.length);
147
150
  let idForFetch = realId.endsWith(".css.js")
148
151
  ? realId.slice(0, -3)
149
152
  : realId;
@@ -0,0 +1,3 @@
1
+ export declare const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
2
+ export declare function normalizeVirtualSsrModuleId(id: string): string | undefined;
3
+ export declare function isVirtualSsrModuleId(id: string): boolean;
@@ -0,0 +1,11 @@
1
+ const VITE_ID_PREFIX = "/@id/";
2
+ export const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
3
+ export function normalizeVirtualSsrModuleId(id) {
4
+ const normalizedId = id.startsWith(VITE_ID_PREFIX)
5
+ ? id.slice(VITE_ID_PREFIX.length)
6
+ : id;
7
+ return normalizedId.startsWith(VIRTUAL_SSR_PREFIX) ? normalizedId : undefined;
8
+ }
9
+ export function isVirtualSsrModuleId(id) {
10
+ return normalizeVirtualSsrModuleId(id) !== undefined;
11
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isVirtualSsrModuleId, normalizeVirtualSsrModuleId, VIRTUAL_SSR_PREFIX, } from "./ssrVirtualModule.mjs";
3
+ import { transformJsxScriptTagsPlugin } from "./transformJsxScriptTagsPlugin.mjs";
4
+ describe("isVirtualSsrModuleId", () => {
5
+ it.each([
6
+ [`${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`, true],
7
+ [`${VIRTUAL_SSR_PREFIX}rwsdk/__ssr_bridge`, true],
8
+ [`/@id/${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`, true],
9
+ [`/src/${VIRTUAL_SSR_PREFIX}Preview.tsx`, false],
10
+ ["/src/Preview.tsx", false],
11
+ ])("identifies %s as virtual SSR module id: %s", (id, expected) => {
12
+ expect(isVirtualSsrModuleId(id)).toBe(expected);
13
+ });
14
+ it("normalizes Vite /@id/ virtual SSR module URLs to bare module ids", () => {
15
+ expect(normalizeVirtualSsrModuleId(`/@id/${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`)).toBe(`${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`);
16
+ });
17
+ });
18
+ describe("transformJsxScriptTagsPlugin transform hook", () => {
19
+ function createPlugin() {
20
+ const clientEntryPoints = new Set();
21
+ const plugin = transformJsxScriptTagsPlugin({
22
+ clientEntryPoints,
23
+ projectRootDir: "/project/root/dir",
24
+ });
25
+ const configResolved = plugin.configResolved;
26
+ if (typeof configResolved === "function") {
27
+ configResolved.call({}, { command: "serve", base: "/" });
28
+ }
29
+ else {
30
+ configResolved?.handler.call({}, { command: "serve", base: "/" });
31
+ }
32
+ const transform = typeof plugin.transform === "function"
33
+ ? plugin.transform
34
+ : plugin.transform?.handler;
35
+ if (!transform) {
36
+ throw new Error("Expected transform hook to be defined");
37
+ }
38
+ return { clientEntryPoints, transform: transform };
39
+ }
40
+ it.each([
41
+ `${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`,
42
+ `/@id/${VIRTUAL_SSR_PREFIX}/src/Preview.tsx`,
43
+ ])("skips virtual SSR bridge module ids in worker: %s", async (id) => {
44
+ // Regression for #1210: virtual SSR bridge modules have .tsx ids but are
45
+ // already transformed by the ssr environment, so the worker transform must
46
+ // not run script-tag discovery on them again.
47
+ const { clientEntryPoints, transform } = createPlugin();
48
+ const ssrBridgeCode = `
49
+ import { jsx } from "react/jsx-runtime";
50
+ const __vite_ssr_import_0__ = await __vite_ssr_import__("react/jsx-runtime", { importedNames: ["jsx"] });
51
+ export function Preview() {
52
+ return jsx("script", { async: true, src: "https://example.com/a.js" });
53
+ }
54
+ `;
55
+ const result = await transform.call({ environment: { name: "worker" } }, ssrBridgeCode, id);
56
+ expect(result).toBeNull();
57
+ expect(clientEntryPoints.size).toBe(0);
58
+ });
59
+ it("still transforms non-bridge worker .tsx modules", async () => {
60
+ const { transform } = createPlugin();
61
+ const code = `
62
+ import { jsx } from "react/jsx-runtime";
63
+ export function Preview() {
64
+ return jsx("script", { async: true, src: "https://example.com/a.js" });
65
+ }
66
+ `;
67
+ const result = await transform.call({ environment: { name: "worker" } }, code, "/src/Preview.tsx");
68
+ expect(result).toBeTruthy();
69
+ expect(typeof result === "object" && result !== null && "code" in result
70
+ ? result.code
71
+ : "").toContain("nonce: requestInfo.rw.nonce");
72
+ });
73
+ });
@@ -2,6 +2,7 @@ import debug from "debug";
2
2
  import { Node, Project, SyntaxKind, } from "ts-morph";
3
3
  import { normalizeModulePath } from "../lib/normalizeModulePath.mjs";
4
4
  import { stripBase } from "../lib/stripBase.mjs";
5
+ import { isVirtualSsrModuleId } from "./ssrVirtualModule.mjs";
5
6
  const log = debug("rwsdk:vite:transform-jsx-script-tags");
6
7
  function transformAssetPath(importPath, projectRootDir, base) {
7
8
  if (process.env.VITE_IS_DEV_SERVER === "1") {
@@ -336,6 +337,10 @@ export const transformJsxScriptTagsPlugin = ({ clientEntryPoints, projectRootDir
336
337
  return null;
337
338
  }
338
339
  if (this.environment?.name === "worker" &&
340
+ // context(peterp, 29 May 2026): SSR bridge modules are already
341
+ // transformed by the `ssr` environment; rerunning script-tag discovery
342
+ // in the worker injects duplicate imports into virtual SSR modules.
343
+ !isVirtualSsrModuleId(id) &&
339
344
  id.endsWith(".tsx") &&
340
345
  hasJsxFunctions(code)) {
341
346
  log("Transforming JSX script tags in %s", id);
@@ -1,10 +1,11 @@
1
- import jsBeautify from "js-beautify";
1
+ import ts from "typescript";
2
2
  import { beforeEach, describe, expect, it } from "vitest";
3
3
  import stubEnvVars from "../lib/testUtils/stubEnvVars.mjs";
4
4
  import { transformJsxScriptTagsCode } from "./transformJsxScriptTagsPlugin.mjs";
5
- // Helper function to normalize code formatting for test comparisons
5
+ // Helper function to normalize code formatting for test comparisons.
6
6
  function normalizeCode(code) {
7
- return jsBeautify(code, { indent_size: 2 });
7
+ const source = ts.createSourceFile("test.tsx", code, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
8
+ return ts.createPrinter({ removeComments: false }).printFile(source).trim();
8
9
  }
9
10
  stubEnvVars();
10
11
  beforeEach(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -182,6 +182,7 @@
182
182
  "magic-string": "~0.30.21",
183
183
  "picocolors": "~1.1.1",
184
184
  "proper-lockfile": "~4.1.2",
185
+ "playwright-core": "1.60.0",
185
186
  "puppeteer-core": "~24.42.0",
186
187
  "react-is": "~19.2.6",
187
188
  "rsc-html-stream": "~0.0.7",
@@ -212,11 +213,9 @@
212
213
  "wrangler": "^4.85.0",
213
214
  "capnweb": "~0.5.0",
214
215
  "@types/debug": "~4.1.13",
215
- "@types/js-beautify": "~1.14.3",
216
216
  "@types/lodash": "~4.17.24",
217
217
  "@types/node": "~25.6.0",
218
218
  "@types/proper-lockfile": "~4.1.4",
219
- "js-beautify": "~1.15.4",
220
219
  "semver": "~7.7.4",
221
220
  "tsx": "~4.21.0",
222
221
  "typescript": "~6.0.3",