rwsdk 0.0.85 → 0.0.86
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/README.md +3 -3
- package/dist/runtime/entries/no-react-server.d.ts +0 -0
- package/dist/runtime/entries/no-react-server.js +2 -0
- package/dist/runtime/entries/react-server-only.d.ts +0 -0
- package/dist/runtime/entries/react-server-only.js +2 -0
- package/dist/runtime/entries/ssr.d.ts +1 -0
- package/dist/runtime/entries/ssr.js +1 -0
- package/dist/runtime/imports/ssr.d.ts +5 -0
- package/dist/runtime/imports/ssr.js +20 -0
- package/dist/runtime/lib/router.d.ts +9 -2
- package/dist/runtime/lib/router.js +53 -5
- package/dist/runtime/register/ssr.d.ts +2 -0
- package/dist/runtime/register/ssr.js +13 -0
- package/dist/runtime/render/renderRscThenableToHtmlStream.d.ts +7 -0
- package/dist/runtime/render/renderRscThenableToHtmlStream.js +11 -0
- package/dist/runtime/ssrBridge.d.ts +3 -0
- package/dist/runtime/ssrBridge.js +12 -0
- package/dist/vite/normalizeModulePath.d.mts +1 -0
- package/dist/vite/normalizeModulePath.mjs +2 -0
- package/dist/vite/rscDirectivesPlugin.d.mts +5 -0
- package/dist/vite/rscDirectivesPlugin.mjs +81 -0
- package/dist/vite/ssrBridgePlugin.d.mts +5 -0
- package/dist/vite/ssrBridgePlugin.mjs +114 -0
- package/dist/vite/transformClientComponents.d.mts +8 -7
- package/dist/vite/transformClientComponents.mjs +61 -55
- package/dist/vite/transformClientComponents.test.d.mts +1 -0
- package/dist/vite/transformClientComponents.test.mjs +261 -0
- package/dist/vite/transformServerFunctions.d.mts +8 -0
- package/dist/vite/transformServerFunctions.mjs +199 -0
- package/dist/vite/transformServerFunctions.test.d.mts +1 -0
- package/dist/vite/transformServerFunctions.test.mjs +105 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
</a>
|
|
8
8
|
|
|
9
9
|
<h1>A React Framework for <a href="https://www.cloudflare.com/">Cloudflare</a>.</h1>
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
<p><b>It begins as a Vite plugin that unlocks SSR, React Server Components, Server Functions, and realtime features.</b></p>
|
|
12
12
|
|
|
13
13
|
<a href="https://rwsdk.com"><img alt="Redwood Inc. logo" src="https://img.shields.io/badge/MADE%20BY%20Redwood%20Inc.-000000.svg?style=for-the-badge&logo=Redwood&labelColor=000"></a>
|
|
@@ -37,13 +37,13 @@ It features:
|
|
|
37
37
|
Start a new project:
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
npx
|
|
40
|
+
npx create-rwsdk my-project-name
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
Install dependencies:
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
cd
|
|
46
|
+
cd my-project-name
|
|
47
47
|
pnpm install
|
|
48
48
|
```
|
|
49
49
|
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../register/ssr";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../register/ssr";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const ssrLoadModule: ((id: string) => Promise<any>) & import("lodash").MemoizedFunction;
|
|
2
|
+
export declare const ssrGetModuleExport: (id: string) => Promise<any>;
|
|
3
|
+
export declare const ssrWebpackRequire: ((id: string) => Promise<{
|
|
4
|
+
[x: string]: any;
|
|
5
|
+
}>) & import("lodash").MemoizedFunction;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import memoize from "lodash/memoize";
|
|
2
|
+
export const ssrLoadModule = memoize(async (id) => {
|
|
3
|
+
const { useClientLookup } = await import("virtual:use-client-lookup");
|
|
4
|
+
const moduleFn = useClientLookup[id];
|
|
5
|
+
if (!moduleFn) {
|
|
6
|
+
throw new Error(`No module found for '${id}' in module lookup for "use client" directive`);
|
|
7
|
+
}
|
|
8
|
+
return await moduleFn();
|
|
9
|
+
});
|
|
10
|
+
export const ssrGetModuleExport = async (id) => {
|
|
11
|
+
const [file, name] = id.split("#");
|
|
12
|
+
const module = await ssrLoadModule(file);
|
|
13
|
+
return module[name];
|
|
14
|
+
};
|
|
15
|
+
// context(justinvdm, 2 Dec 2024): re memoize(): React relies on the same promise instance being returned for the same id
|
|
16
|
+
export const ssrWebpackRequire = memoize(async (id) => {
|
|
17
|
+
const [file, name] = id.split("#");
|
|
18
|
+
const module = await ssrLoadModule(file);
|
|
19
|
+
return { [id]: module[name] };
|
|
20
|
+
});
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import
|
|
1
|
+
import React from "react";
|
|
2
2
|
import { RequestInfo } from "../requestInfo/types";
|
|
3
3
|
export type DocumentProps = RequestInfo & {
|
|
4
4
|
children: React.ReactNode;
|
|
5
5
|
};
|
|
6
|
+
export type LayoutProps = {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
requestInfo?: RequestInfo;
|
|
9
|
+
};
|
|
6
10
|
export type RwContext = {
|
|
7
11
|
nonce: string;
|
|
8
12
|
Document: React.FC<DocumentProps>;
|
|
9
13
|
rscPayload: boolean;
|
|
14
|
+
layouts?: React.FC<LayoutProps>[];
|
|
10
15
|
};
|
|
11
16
|
export type RouteMiddleware = (requestInfo: RequestInfo) => Response | Promise<Response> | void | Promise<void> | Promise<Response | void>;
|
|
12
17
|
type RouteFunction = (requestInfo: RequestInfo) => Response | Promise<Response>;
|
|
@@ -17,6 +22,7 @@ export type Route = RouteMiddleware | RouteDefinition | Array<Route>;
|
|
|
17
22
|
export type RouteDefinition = {
|
|
18
23
|
path: string;
|
|
19
24
|
handler: RouteHandler;
|
|
25
|
+
layouts?: React.FC<LayoutProps>[];
|
|
20
26
|
};
|
|
21
27
|
export declare function matchPath(routePath: string, requestPath: string): RequestInfo["params"] | null;
|
|
22
28
|
export declare function defineRoutes(routes: Route[]): {
|
|
@@ -31,7 +37,8 @@ export declare function defineRoutes(routes: Route[]): {
|
|
|
31
37
|
};
|
|
32
38
|
export declare function route(path: string, handler: RouteHandler): RouteDefinition;
|
|
33
39
|
export declare function index(handler: RouteHandler): RouteDefinition;
|
|
34
|
-
export declare function prefix(
|
|
40
|
+
export declare function prefix(prefixPath: string, routes: Route[]): Route[];
|
|
41
|
+
export declare function layout(LayoutComponent: React.FC<LayoutProps>, routes: Route[]): Route[];
|
|
35
42
|
export declare function render(Document: React.FC<DocumentProps>, routes: Route[],
|
|
36
43
|
/**
|
|
37
44
|
* @param options - Configuration options for rendering.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import React from "react";
|
|
1
2
|
import { isValidElementType } from "react-is";
|
|
2
3
|
export function matchPath(routePath, requestPath) {
|
|
3
4
|
// Check for invalid pattern: multiple colons in a segment (e.g., /:param1:param2/)
|
|
@@ -82,7 +83,7 @@ export function defineRoutes(routes) {
|
|
|
82
83
|
}
|
|
83
84
|
const params = matchPath(route.path, path);
|
|
84
85
|
if (params) {
|
|
85
|
-
match = { params, handler: route.handler };
|
|
86
|
+
match = { params, handler: route.handler, layouts: route.layouts };
|
|
86
87
|
break;
|
|
87
88
|
}
|
|
88
89
|
}
|
|
@@ -90,12 +91,14 @@ export function defineRoutes(routes) {
|
|
|
90
91
|
// todo(peterp, 2025-01-28): Allow the user to define their own "not found" route.
|
|
91
92
|
return new Response("Not Found", { status: 404 });
|
|
92
93
|
}
|
|
93
|
-
let { params, handler } = match;
|
|
94
|
+
let { params, handler, layouts } = match;
|
|
94
95
|
return runWithRequestInfoOverrides({ params }, async () => {
|
|
95
96
|
const handlers = Array.isArray(handler) ? handler : [handler];
|
|
96
97
|
for (const h of handlers) {
|
|
97
98
|
if (isRouteComponent(h)) {
|
|
98
|
-
|
|
99
|
+
const requestInfo = getRequestInfo();
|
|
100
|
+
const WrappedComponent = wrapWithLayouts(h, layouts || [], requestInfo);
|
|
101
|
+
return await renderPage(requestInfo, WrappedComponent, onError);
|
|
99
102
|
}
|
|
100
103
|
else {
|
|
101
104
|
const r = await h(getRequestInfo());
|
|
@@ -124,11 +127,56 @@ export function route(path, handler) {
|
|
|
124
127
|
export function index(handler) {
|
|
125
128
|
return route("/", handler);
|
|
126
129
|
}
|
|
127
|
-
export function prefix(
|
|
130
|
+
export function prefix(prefixPath, routes) {
|
|
128
131
|
return routes.map((r) => {
|
|
132
|
+
if (typeof r === "function") {
|
|
133
|
+
// Pass through middleware as-is
|
|
134
|
+
return r;
|
|
135
|
+
}
|
|
136
|
+
if (Array.isArray(r)) {
|
|
137
|
+
// Recursively process nested route arrays
|
|
138
|
+
return prefix(prefixPath, r);
|
|
139
|
+
}
|
|
140
|
+
// For RouteDefinition objects, update the path and preserve layouts
|
|
129
141
|
return {
|
|
130
|
-
path:
|
|
142
|
+
path: prefixPath + r.path,
|
|
131
143
|
handler: r.handler,
|
|
144
|
+
...(r.layouts && { layouts: r.layouts }),
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function wrapWithLayouts(Component, layouts = [], requestInfo) {
|
|
149
|
+
if (layouts.length === 0) {
|
|
150
|
+
return Component;
|
|
151
|
+
}
|
|
152
|
+
// Create nested layout structure - layouts[0] should be outermost, so use reduceRight
|
|
153
|
+
return layouts.reduceRight((WrappedComponent, Layout) => {
|
|
154
|
+
const Wrapped = (props) => {
|
|
155
|
+
const isClientComponent = Object.prototype.hasOwnProperty.call(Layout, "$$isClientReference");
|
|
156
|
+
return React.createElement(Layout, {
|
|
157
|
+
children: React.createElement(WrappedComponent, props),
|
|
158
|
+
// Only pass requestInfo to server components to avoid serialization issues
|
|
159
|
+
...(isClientComponent ? {} : { requestInfo }),
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
return Wrapped;
|
|
163
|
+
}, Component);
|
|
164
|
+
}
|
|
165
|
+
export function layout(LayoutComponent, routes) {
|
|
166
|
+
// Attach layouts directly to route definitions
|
|
167
|
+
return routes.map((route) => {
|
|
168
|
+
if (typeof route === "function") {
|
|
169
|
+
// Pass through middleware as-is
|
|
170
|
+
return route;
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(route)) {
|
|
173
|
+
// Recursively process nested route arrays
|
|
174
|
+
return layout(LayoutComponent, route);
|
|
175
|
+
}
|
|
176
|
+
// For RouteDefinition objects, prepend the layout so outer layouts come first
|
|
177
|
+
return {
|
|
178
|
+
...route,
|
|
179
|
+
layouts: [LayoutComponent, ...(route.layouts || [])],
|
|
132
180
|
};
|
|
133
181
|
});
|
|
134
182
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createServerReference as baseCreateServerReference } from "react-server-dom-webpack/client.edge";
|
|
2
|
+
const ssrCallServer = (id, args) => {
|
|
3
|
+
const action = registeredServerFunctions.get(id);
|
|
4
|
+
if (!action) {
|
|
5
|
+
throw new Error(`Server function ${id} not found`);
|
|
6
|
+
}
|
|
7
|
+
return action(args);
|
|
8
|
+
};
|
|
9
|
+
export const createServerReference = (id, name) => {
|
|
10
|
+
id = id + "#" + name;
|
|
11
|
+
return baseCreateServerReference(id, ssrCallServer);
|
|
12
|
+
};
|
|
13
|
+
export const registeredServerFunctions = new Map();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DocumentProps } from "../lib/router";
|
|
2
|
+
import { type RequestInfo } from "../requestInfo/types";
|
|
3
|
+
export declare const renderRscThenableToHtmlStream: ({ thenable, Document, requestInfo, }: {
|
|
4
|
+
thenable: any;
|
|
5
|
+
Document: React.FC<DocumentProps>;
|
|
6
|
+
requestInfo: RequestInfo;
|
|
7
|
+
}) => Promise<import("react-dom/server").ReactDOMServerReadableStream>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { use } from "react";
|
|
3
|
+
import { renderToReadableStream } from "react-dom/server.edge";
|
|
4
|
+
export const renderRscThenableToHtmlStream = async ({ thenable, Document, requestInfo, }) => {
|
|
5
|
+
const Component = () => {
|
|
6
|
+
return (_jsx(Document, { ...requestInfo, children: use(thenable).node }));
|
|
7
|
+
};
|
|
8
|
+
return await renderToReadableStream(_jsx(Component, {}), {
|
|
9
|
+
nonce: requestInfo.rw.nonce,
|
|
10
|
+
});
|
|
11
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// context(justinvdm, 28 May 2025): This is the "bridge" between the RSC side
|
|
2
|
+
// and and the SSR side, both run inside the same runtime environment. We have
|
|
3
|
+
// this separation so that they can each be processed with their own respective
|
|
4
|
+
// import conditions and bundling logic
|
|
5
|
+
//
|
|
6
|
+
// **NOTE:** Any time we need to import from SSR side in RSC side, we need to
|
|
7
|
+
// import it through this bridge module, using the bare import path
|
|
8
|
+
// `rwsdk/__ssr_bridge`. We have bundler logic (ssrBridgePlugin) that looks out
|
|
9
|
+
// for imports to it.
|
|
10
|
+
export { renderRscThenableToHtmlStream } from "./render/renderRscThenableToHtmlStream";
|
|
11
|
+
export { registeredServerFunctions } from "./register/ssr";
|
|
12
|
+
export { ssrLoadModule, ssrGetModuleExport, ssrWebpackRequire, } from "./imports/ssr";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const normalizeModulePath: (projectRootDir: string, modulePath: string) => string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import debug from "debug";
|
|
2
|
+
import { transformClientComponents } from "./transformClientComponents.mjs";
|
|
3
|
+
import { transformServerFunctions } from "./transformServerFunctions.mjs";
|
|
4
|
+
import { normalizeModulePath } from "./normalizeModulePath.mjs";
|
|
5
|
+
const log = debug("rwsdk:vite:rsc-directives-plugin");
|
|
6
|
+
const verboseLog = debug("verbose:rwsdk:vite:rsc-directives-plugin");
|
|
7
|
+
export const rscDirectivesPlugin = ({ projectRootDir, clientFiles, }) => ({
|
|
8
|
+
name: "rwsdk:rsc-directives",
|
|
9
|
+
async transform(code, id) {
|
|
10
|
+
verboseLog("Transform called for id=%s, environment=%s", id, this.environment.name);
|
|
11
|
+
const clientResult = await transformClientComponents(code, id, {
|
|
12
|
+
environmentName: this.environment.name,
|
|
13
|
+
clientFiles,
|
|
14
|
+
projectRootDir,
|
|
15
|
+
});
|
|
16
|
+
if (clientResult) {
|
|
17
|
+
log("Client component transformation successful for id=%s", id);
|
|
18
|
+
return {
|
|
19
|
+
code: clientResult.code,
|
|
20
|
+
map: clientResult.map,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const serverResult = transformServerFunctions(code, normalizeModulePath(projectRootDir, id), this.environment.name);
|
|
24
|
+
if (serverResult) {
|
|
25
|
+
log("Server function transformation successful for id=%s", id);
|
|
26
|
+
return {
|
|
27
|
+
code: serverResult.code,
|
|
28
|
+
map: serverResult.map,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
verboseLog("No transformation applied for id=%s", id);
|
|
32
|
+
},
|
|
33
|
+
configEnvironment(env, config) {
|
|
34
|
+
log("Configuring environment: env=%s", env);
|
|
35
|
+
config.optimizeDeps ??= {};
|
|
36
|
+
config.optimizeDeps.esbuildOptions ??= {};
|
|
37
|
+
config.optimizeDeps.esbuildOptions.plugins ??= [];
|
|
38
|
+
config.optimizeDeps.esbuildOptions.plugins.push({
|
|
39
|
+
name: "rsc-directives-esbuild-transform",
|
|
40
|
+
setup(build) {
|
|
41
|
+
log("Setting up esbuild plugin for environment: %s", env);
|
|
42
|
+
build.onLoad({ filter: /.*\.js$/ }, async (args) => {
|
|
43
|
+
verboseLog("Esbuild onLoad called for path=%s", args.path);
|
|
44
|
+
const fs = await import("node:fs/promises");
|
|
45
|
+
const path = await import("node:path");
|
|
46
|
+
let code;
|
|
47
|
+
try {
|
|
48
|
+
code = await fs.readFile(args.path, "utf-8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
verboseLog("Failed to read file: %s", args.path);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const clientResult = await transformClientComponents(code, args.path, {
|
|
55
|
+
environmentName: env,
|
|
56
|
+
clientFiles,
|
|
57
|
+
isEsbuild: true,
|
|
58
|
+
projectRootDir,
|
|
59
|
+
});
|
|
60
|
+
if (clientResult) {
|
|
61
|
+
log("Esbuild client component transformation successful for path=%s", args.path);
|
|
62
|
+
return {
|
|
63
|
+
contents: clientResult.code,
|
|
64
|
+
loader: path.extname(args.path).slice(1),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const serverResult = transformServerFunctions(code, normalizeModulePath(projectRootDir, args.path), env);
|
|
68
|
+
if (serverResult) {
|
|
69
|
+
log("Esbuild server function transformation successful for path=%s", args.path);
|
|
70
|
+
return {
|
|
71
|
+
contents: serverResult.code,
|
|
72
|
+
loader: path.extname(args.path).slice(1),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
verboseLog("Esbuild no transformation applied for path=%s", args.path);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
log("Environment configuration complete for env=%s", env);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import debug from "debug";
|
|
3
|
+
import { DIST_DIR } from "../lib/constants.mjs";
|
|
4
|
+
const log = debug("rwsdk:vite:ssr-bridge-plugin");
|
|
5
|
+
const verboseLog = debug("verbose:rwsdk:vite:ssr-bridge-plugin");
|
|
6
|
+
export const VIRTUAL_SSR_PREFIX = "virtual:rwsdk:ssr:";
|
|
7
|
+
export const ssrBridgePlugin = ({ projectRootDir, }) => {
|
|
8
|
+
const distSsrBridgePath = path.resolve(DIST_DIR, "ssrBridge.js");
|
|
9
|
+
log("Initializing SSR bridge plugin with distSsrBridgePath=%s", distSsrBridgePath);
|
|
10
|
+
let devServer;
|
|
11
|
+
let isDev = false;
|
|
12
|
+
const ssrBridgePlugin = {
|
|
13
|
+
name: "rwsdk:ssr-bridge",
|
|
14
|
+
enforce: "pre",
|
|
15
|
+
configureServer(server) {
|
|
16
|
+
devServer = server;
|
|
17
|
+
log("Configured dev server");
|
|
18
|
+
},
|
|
19
|
+
config(_, { command, isPreview }) {
|
|
20
|
+
isDev = !isPreview && command === "serve";
|
|
21
|
+
log("Config: command=%s, isPreview=%s, isDev=%s", command, isPreview, isDev);
|
|
22
|
+
},
|
|
23
|
+
configEnvironment(env, config) {
|
|
24
|
+
log("Configuring environment: env=%s", env);
|
|
25
|
+
if (env === "worker" || env === "ssr") {
|
|
26
|
+
// Configure esbuild to mark rwsdk/__ssr paths as external for worker environment
|
|
27
|
+
log("Configuring esbuild options for worker environment");
|
|
28
|
+
config.optimizeDeps ??= {};
|
|
29
|
+
config.optimizeDeps.esbuildOptions ??= {};
|
|
30
|
+
config.optimizeDeps.esbuildOptions.plugins ??= [];
|
|
31
|
+
config.optimizeDeps.include ??= [];
|
|
32
|
+
config.optimizeDeps.esbuildOptions.plugins.push({
|
|
33
|
+
name: "rwsdk-ssr-external",
|
|
34
|
+
setup(build) {
|
|
35
|
+
log("Setting up esbuild plugin to mark rwsdk/__ssr paths as external for worker");
|
|
36
|
+
build.onResolve({ filter: /.*$/ }, (args) => {
|
|
37
|
+
verboseLog("Esbuild onResolve called for path=%s, args=%O", args.path, args);
|
|
38
|
+
if (args.path === "rwsdk/__ssr_bridge") {
|
|
39
|
+
log("Marking as external: %s", args.path);
|
|
40
|
+
return {
|
|
41
|
+
path: args.path,
|
|
42
|
+
external: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
log("Worker environment esbuild configuration complete");
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
async resolveId(id) {
|
|
52
|
+
verboseLog("Resolving id=%s, environment=%s, isDev=%s", id, this.environment?.name, isDev);
|
|
53
|
+
if (isDev) {
|
|
54
|
+
// context(justinvdm, 27 May 2025): In dev, we need to dynamically load
|
|
55
|
+
// SSR modules, so we return the virtual id so that the dynamic loading
|
|
56
|
+
// can happen in load()
|
|
57
|
+
if (id.startsWith(VIRTUAL_SSR_PREFIX)) {
|
|
58
|
+
log("Returning virtual SSR id for dev: %s", id);
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
// context(justinvdm, 28 May 2025): The SSR bridge module is a special case -
|
|
62
|
+
// it is the entry point for all SSR modules, so to trigger the
|
|
63
|
+
// same dynamic loading logic as other SSR modules (as the case above),
|
|
64
|
+
// we return a virtual id
|
|
65
|
+
if (id === "rwsdk/__ssr_bridge" && this.environment.name === "worker") {
|
|
66
|
+
const virtualId = `${VIRTUAL_SSR_PREFIX}${id}`;
|
|
67
|
+
log("Bridge module case (dev): id=%s matches rwsdk/__ssr_bridge in worker environment, returning virtual id=%s", id, virtualId);
|
|
68
|
+
return virtualId;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// context(justinvdm, 27 May 2025): In builds, since all SSR import chains
|
|
73
|
+
// originate at SSR bridge module, we return the path to the already built
|
|
74
|
+
// SSR bridge bundle - SSR env builds it, worker build tries to resolve it
|
|
75
|
+
// here and uses it
|
|
76
|
+
if (id === "rwsdk/__ssr_bridge" && this.environment.name === "worker") {
|
|
77
|
+
log("Bridge module case (build): id=%s matches rwsdk/__ssr_bridge in worker environment, returning distSsrBridgePath=%s", id, distSsrBridgePath);
|
|
78
|
+
return distSsrBridgePath;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
verboseLog("No resolution for id=%s", id);
|
|
82
|
+
},
|
|
83
|
+
async load(id) {
|
|
84
|
+
verboseLog("Loading id=%s, isDev=%s, environment=%s", id, isDev, this.environment.name);
|
|
85
|
+
if (id.startsWith(VIRTUAL_SSR_PREFIX) &&
|
|
86
|
+
this.environment.name === "worker") {
|
|
87
|
+
const realId = id.slice(VIRTUAL_SSR_PREFIX.length);
|
|
88
|
+
log("Virtual SSR module load: id=%s, realId=%s", id, realId);
|
|
89
|
+
if (isDev) {
|
|
90
|
+
log("Dev mode: warming up and fetching SSR module for realPath=%s", realId);
|
|
91
|
+
await devServer?.environments.ssr.warmupRequest(realId);
|
|
92
|
+
const result = await devServer?.environments.ssr.fetchModule(realId);
|
|
93
|
+
if (!result) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const code = "code" in result ? result.code : undefined;
|
|
97
|
+
log("Fetched SSR module code length: %d", code?.length || 0);
|
|
98
|
+
// context(justinvdm, 27 May 2025): Prefix all imports in SSR modules so that they're separate in module graph from non-SSR
|
|
99
|
+
const transformedCode = `
|
|
100
|
+
;(async function(__vite_ssr_import__, __vite_ssr_dynamic_import__) {${code}})(
|
|
101
|
+
(id) => __vite_ssr_import__('/@id/${VIRTUAL_SSR_PREFIX}'+id),
|
|
102
|
+
(id) => __vite_ssr_dynamic_import__('/@id/${VIRTUAL_SSR_PREFIX}'+id),
|
|
103
|
+
);
|
|
104
|
+
`;
|
|
105
|
+
log("Transformed SSR module code length: %d", transformedCode.length);
|
|
106
|
+
log("Transformed SSR module code: %s", transformedCode);
|
|
107
|
+
return transformedCode;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
verboseLog("No load handling for id=%s", id);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return ssrBridgePlugin;
|
|
114
|
+
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
interface TransformContext {
|
|
2
|
+
environmentName: string;
|
|
3
|
+
clientFiles?: Set<string>;
|
|
4
|
+
isEsbuild?: boolean;
|
|
5
|
+
projectRootDir: string;
|
|
6
|
+
}
|
|
1
7
|
interface TransformResult {
|
|
2
8
|
code: string;
|
|
3
9
|
map?: any;
|
|
4
10
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
topLevelRoot?: string;
|
|
8
|
-
isEsbuild?: boolean;
|
|
9
|
-
}
|
|
10
|
-
export declare function transformClientComponents(code: string, id: string, env: TransformEnv): Promise<TransformResult | undefined>;
|
|
11
|
-
export type { TransformResult, TransformEnv };
|
|
11
|
+
export declare function transformClientComponents(code: string, id: string, ctx: TransformContext): Promise<TransformResult | undefined>;
|
|
12
|
+
export type { TransformContext, TransformResult };
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { Project, SyntaxKind, Node } from "ts-morph";
|
|
2
|
-
import MagicString from "magic-string";
|
|
3
2
|
import debug from "debug";
|
|
4
|
-
import {
|
|
5
|
-
const logVite = debug("rwsdk:transform-client-components:vite");
|
|
6
|
-
const logEsbuild = debug("rwsdk:transform-client-components:esbuild");
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
import { normalizeModulePath } from "./normalizeModulePath.mjs";
|
|
4
|
+
const logVite = debug("rwsdk:vite:transform-client-components:vite");
|
|
5
|
+
const logEsbuild = debug("rwsdk:vite:transform-client-components:esbuild");
|
|
6
|
+
const verboseLogVite = debug("verbose:rwsdk:vite:transform-client-components:vite");
|
|
7
|
+
const verboseLogEsbuild = debug("verbose:rwsdk:vite:transform-client-components:esbuild");
|
|
8
|
+
export async function transformClientComponents(code, id, ctx) {
|
|
9
|
+
const log = ctx.isEsbuild ? logEsbuild : logVite;
|
|
10
|
+
const verboseLog = ctx.isEsbuild ? verboseLogEsbuild : verboseLogVite;
|
|
11
|
+
log("Called transformClientComponents for id: id=%s, ctx: %O", id, ctx);
|
|
10
12
|
// 1. Skip if not in worker environment
|
|
11
|
-
if (
|
|
12
|
-
log("Skipping: not in worker environment (%s)",
|
|
13
|
+
if (ctx.environmentName !== "worker" && ctx.environmentName !== "ssr") {
|
|
14
|
+
log("Skipping: not in worker environment (%s)", ctx.environmentName);
|
|
13
15
|
return;
|
|
14
16
|
}
|
|
15
17
|
// 2. Only transform files that start with 'use client'
|
|
@@ -18,13 +20,12 @@ export async function transformClientComponents(code, id, env) {
|
|
|
18
20
|
cleanCode.startsWith("'use client'");
|
|
19
21
|
if (!hasUseClient) {
|
|
20
22
|
log("Skipping: no 'use client' directive in id=%s", id);
|
|
21
|
-
|
|
22
|
-
log(":VERBOSE: Returning code unchanged for id=%s:\n%s", id, code);
|
|
23
|
-
}
|
|
23
|
+
verboseLog(":VERBOSE: Returning code unchanged for id=%s:\n%s", id, code);
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
log("Processing 'use client' module: id=%s", id);
|
|
27
|
-
|
|
27
|
+
ctx.clientFiles?.add(normalizeModulePath(ctx.projectRootDir, id));
|
|
28
|
+
// Use ts-morph to collect all export info and perform transformations
|
|
28
29
|
const project = new Project({
|
|
29
30
|
useInMemoryFileSystem: true,
|
|
30
31
|
compilerOptions: {
|
|
@@ -46,7 +47,7 @@ export async function transformClientComponents(code, id, env) {
|
|
|
46
47
|
exportInfos.push({ local, exported, isDefault, statementIdx });
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
|
-
// Walk through statements in order
|
|
50
|
+
// Walk through statements in order to collect export information
|
|
50
51
|
const statements = sourceFile.getStatements();
|
|
51
52
|
statements.forEach((stmt, idx) => {
|
|
52
53
|
// export default function ...
|
|
@@ -108,64 +109,69 @@ export async function transformClientComponents(code, id, env) {
|
|
|
108
109
|
}
|
|
109
110
|
});
|
|
110
111
|
// 3. SSR files: just remove the directive
|
|
111
|
-
if (
|
|
112
|
-
log(":isEsbuild=%s: Handling SSR virtual module: %s", !!
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
if (ctx.environmentName === "ssr") {
|
|
113
|
+
log(":isEsbuild=%s: Handling SSR virtual module: %s", !!ctx.isEsbuild, id);
|
|
114
|
+
// Remove 'use client' directive using ts-morph
|
|
115
|
+
sourceFile
|
|
116
|
+
.getDescendantsOfKind(SyntaxKind.StringLiteral)
|
|
117
|
+
.forEach((node) => {
|
|
118
|
+
if (node.getText() === "'use client'" ||
|
|
119
|
+
node.getText() === '"use client"') {
|
|
120
|
+
const parentExpr = node.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
|
|
121
|
+
if (parentExpr) {
|
|
122
|
+
parentExpr.remove();
|
|
123
|
+
}
|
|
122
124
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
});
|
|
126
|
+
const emitOutput = sourceFile.getEmitOutput();
|
|
127
|
+
let sourceMap;
|
|
128
|
+
for (const outputFile of emitOutput.getOutputFiles()) {
|
|
129
|
+
if (outputFile.getFilePath().endsWith(".js.map")) {
|
|
130
|
+
sourceMap = JSON.parse(outputFile.getText());
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
verboseLog(":VERBOSE: SSR transformed code for %s:\n%s", id, sourceFile.getFullText());
|
|
134
|
+
return {
|
|
135
|
+
code: sourceFile.getFullText(),
|
|
136
|
+
map: sourceMap,
|
|
130
137
|
};
|
|
131
|
-
if (process.env.VERBOSE) {
|
|
132
|
-
log(":VERBOSE: SSR transformed code for %s:\n%s", id, transformed.code);
|
|
133
|
-
}
|
|
134
|
-
return transformed;
|
|
135
138
|
}
|
|
136
139
|
// 4. Non-SSR files: replace all implementation with registerClientReference logic
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
resultLines.push(ssrModuleImportLine);
|
|
140
|
+
// Clear the source file and rebuild it
|
|
141
|
+
sourceFile.removeText();
|
|
142
|
+
// Add import declaration
|
|
143
|
+
sourceFile.addImportDeclaration({
|
|
144
|
+
moduleSpecifier: "rwsdk/worker",
|
|
145
|
+
namedImports: [{ name: "registerClientReference" }],
|
|
146
|
+
});
|
|
145
147
|
// Add registerClientReference assignments for named exports in order
|
|
146
148
|
for (const info of exportInfos) {
|
|
147
|
-
log(":isEsbuild=%s: Registering client reference for named export: %s as %s", !!
|
|
148
|
-
|
|
149
|
+
log(":isEsbuild=%s: Registering client reference for named export: %s as %s", !!ctx.isEsbuild, info.local, info.exported);
|
|
150
|
+
sourceFile.addStatements(`const ${info.local} = registerClientReference("${id}", "${info.exported}");`);
|
|
149
151
|
}
|
|
150
152
|
// Add grouped export statement for named exports (preserving order and alias)
|
|
151
153
|
if (exportInfos.length > 0) {
|
|
152
154
|
const exportNames = exportInfos.map((e) => e.local === e.exported ? e.local : `${e.local} as ${e.exported}`);
|
|
153
|
-
log(":isEsbuild=%s: Exporting named exports: %O", !!
|
|
154
|
-
|
|
155
|
+
log(":isEsbuild=%s: Exporting named exports: %O", !!ctx.isEsbuild, exportNames);
|
|
156
|
+
sourceFile.addStatements(`export { ${exportNames.join(", ")} };`);
|
|
155
157
|
}
|
|
156
158
|
// Add default export if present
|
|
157
159
|
if (defaultExportInfo) {
|
|
158
|
-
log(":isEsbuild=%s: Registering client reference for default export: %s", !!
|
|
159
|
-
|
|
160
|
+
log(":isEsbuild=%s: Registering client reference for default export: %s", !!ctx.isEsbuild, defaultExportInfo.exported);
|
|
161
|
+
sourceFile.addStatements(`export default registerClientReference("${id}", "${defaultExportInfo.exported}");`);
|
|
160
162
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
const emitOutput = sourceFile.getEmitOutput();
|
|
164
|
+
let sourceMap;
|
|
165
|
+
for (const outputFile of emitOutput.getOutputFiles()) {
|
|
166
|
+
if (outputFile.getFilePath().endsWith(".js.map")) {
|
|
167
|
+
sourceMap = JSON.parse(outputFile.getText());
|
|
168
|
+
}
|
|
166
169
|
}
|
|
170
|
+
const finalResult = sourceFile.getFullText();
|
|
171
|
+
log(":isEsbuild=%s: Final transformed code for %s:\n%s", !!ctx.isEsbuild, id, finalResult);
|
|
172
|
+
verboseLog(":VERBOSE: Transformed code for %s:\n%s", id, finalResult);
|
|
167
173
|
return {
|
|
168
|
-
code: finalResult
|
|
169
|
-
map:
|
|
174
|
+
code: finalResult,
|
|
175
|
+
map: sourceMap,
|
|
170
176
|
};
|
|
171
177
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { transformClientComponents } from "./transformClientComponents.mjs";
|
|
3
|
+
describe("transformClientComponents", () => {
|
|
4
|
+
async function transform(code) {
|
|
5
|
+
const result = await transformClientComponents(code, "/test/file.tsx", {
|
|
6
|
+
environmentName: "worker",
|
|
7
|
+
projectRootDir: "/test",
|
|
8
|
+
});
|
|
9
|
+
return result?.code;
|
|
10
|
+
}
|
|
11
|
+
it("transforms arrow function component", async () => {
|
|
12
|
+
expect((await transform(`"use client"
|
|
13
|
+
|
|
14
|
+
export const Component = () => {
|
|
15
|
+
return jsx('div', { children: 'Hello' });
|
|
16
|
+
}
|
|
17
|
+
`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
18
|
+
const Component = registerClientReference("/test/file.tsx", "Component");
|
|
19
|
+
export { Component };
|
|
20
|
+
`);
|
|
21
|
+
});
|
|
22
|
+
it("transforms async arrow function component", async () => {
|
|
23
|
+
expect((await transform(`"use client"
|
|
24
|
+
|
|
25
|
+
export const Component = async () => {
|
|
26
|
+
return jsx('div', { children: 'Hello' });
|
|
27
|
+
}
|
|
28
|
+
`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
29
|
+
const Component = registerClientReference("/test/file.tsx", "Component");
|
|
30
|
+
export { Component };
|
|
31
|
+
`);
|
|
32
|
+
});
|
|
33
|
+
it("transforms function declaration component", async () => {
|
|
34
|
+
expect((await transform(`"use client"
|
|
35
|
+
|
|
36
|
+
export function Component() {
|
|
37
|
+
return jsx('div', { children: 'Hello' });
|
|
38
|
+
}`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
39
|
+
const Component = registerClientReference("/test/file.tsx", "Component");
|
|
40
|
+
export { Component };
|
|
41
|
+
`);
|
|
42
|
+
});
|
|
43
|
+
it("transforms default export arrow function component", async () => {
|
|
44
|
+
expect((await transform(`"use client"
|
|
45
|
+
|
|
46
|
+
export default () => {
|
|
47
|
+
return jsx('div', { children: 'Hello' });
|
|
48
|
+
}`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
49
|
+
export default registerClientReference("/test/file.tsx", "default");
|
|
50
|
+
`);
|
|
51
|
+
});
|
|
52
|
+
it("transforms default export function declaration component", async () => {
|
|
53
|
+
expect((await transform(`"use client"
|
|
54
|
+
|
|
55
|
+
export default function Component({ prop1, prop2 }) {
|
|
56
|
+
return jsx('div', { children: 'Hello' });
|
|
57
|
+
}`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
58
|
+
export default registerClientReference("/test/file.tsx", "default");
|
|
59
|
+
`);
|
|
60
|
+
});
|
|
61
|
+
it("transforms mixed export styles (inline, grouped, and default)", async () => {
|
|
62
|
+
expect((await transform(`"use client"
|
|
63
|
+
|
|
64
|
+
export const First = () => {
|
|
65
|
+
return jsx('div', { children: 'First' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const Second = () => {
|
|
69
|
+
return jsx('div', { children: 'Second' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Third() {
|
|
73
|
+
return jsx('div', { children: 'Third' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const Fourth = () => {
|
|
77
|
+
return jsx('div', { children: 'Fourth' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default function Main() {
|
|
81
|
+
return jsx('div', { children: 'Main' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { Second, Third }
|
|
85
|
+
export { Fourth as AnotherName }`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
86
|
+
const First = registerClientReference("/test/file.tsx", "First");
|
|
87
|
+
const Second = registerClientReference("/test/file.tsx", "Second");
|
|
88
|
+
const Third = registerClientReference("/test/file.tsx", "Third");
|
|
89
|
+
const Fourth = registerClientReference("/test/file.tsx", "AnotherName");
|
|
90
|
+
export { First, Second, Third, Fourth as AnotherName };
|
|
91
|
+
export default registerClientReference("/test/file.tsx", "default");
|
|
92
|
+
`);
|
|
93
|
+
});
|
|
94
|
+
it("transforms function declaration that is exported default separately", async () => {
|
|
95
|
+
expect((await transform(`
|
|
96
|
+
"use client"
|
|
97
|
+
|
|
98
|
+
function Component({ prop1, prop2 }) {
|
|
99
|
+
return jsx('div', { children: 'Hello' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default Component;`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
103
|
+
export default registerClientReference("/test/file.tsx", "default");
|
|
104
|
+
`);
|
|
105
|
+
});
|
|
106
|
+
it("Works in dev", async () => {
|
|
107
|
+
expect((await transform(`"use client"
|
|
108
|
+
import { jsxDEV } from "react/jsx-dev-runtime";
|
|
109
|
+
import { sendMessage } from "./functions";
|
|
110
|
+
import { useState } from "react";
|
|
111
|
+
import { consumeEventStream } from "rwsdk/client";
|
|
112
|
+
|
|
113
|
+
export function Chat() {
|
|
114
|
+
const [message, setMessage] = useState("");
|
|
115
|
+
const [reply, setReply] = useState("");
|
|
116
|
+
const onClick = async () => {
|
|
117
|
+
setReply("");
|
|
118
|
+
(await sendMessage(message)).pipeTo(
|
|
119
|
+
consumeEventStream({
|
|
120
|
+
onChunk: (event) => {
|
|
121
|
+
setReply(
|
|
122
|
+
(prev) => prev + (event.data === "[DONE]" ? "" : JSON.parse(event.data).response)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
return /* @__PURE__ */ jsxDEV("div", { children: [
|
|
129
|
+
/* @__PURE__ */ jsxDEV(
|
|
130
|
+
"input",
|
|
131
|
+
{
|
|
132
|
+
type: "text",
|
|
133
|
+
value: message,
|
|
134
|
+
placeholder: "Type a message...",
|
|
135
|
+
onChange: (e) => setMessage(e.target.value),
|
|
136
|
+
style: {
|
|
137
|
+
width: "80%",
|
|
138
|
+
padding: "10px",
|
|
139
|
+
marginRight: "8px",
|
|
140
|
+
borderRadius: "4px",
|
|
141
|
+
border: "1px solid #ccc"
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
void 0,
|
|
145
|
+
false,
|
|
146
|
+
{
|
|
147
|
+
fileName: "/Users/justin/rw/sdk/experiments/ai-stream/src/app/pages/Chat/Chat.tsx",
|
|
148
|
+
lineNumber: 28,
|
|
149
|
+
columnNumber: 7
|
|
150
|
+
},
|
|
151
|
+
this
|
|
152
|
+
),
|
|
153
|
+
/* @__PURE__ */ jsxDEV(
|
|
154
|
+
"button",
|
|
155
|
+
{
|
|
156
|
+
onClick,
|
|
157
|
+
style: {
|
|
158
|
+
padding: "10px 20px",
|
|
159
|
+
borderRadius: "4px",
|
|
160
|
+
border: "none",
|
|
161
|
+
backgroundColor: "#007bff",
|
|
162
|
+
color: "white",
|
|
163
|
+
cursor: "pointer"
|
|
164
|
+
},
|
|
165
|
+
children: "Send"
|
|
166
|
+
},
|
|
167
|
+
void 0,
|
|
168
|
+
false,
|
|
169
|
+
{
|
|
170
|
+
fileName: "/Users/justin/rw/sdk/experiments/ai-stream/src/app/pages/Chat/Chat.tsx",
|
|
171
|
+
lineNumber: 41,
|
|
172
|
+
columnNumber: 7
|
|
173
|
+
},
|
|
174
|
+
this
|
|
175
|
+
),
|
|
176
|
+
/* @__PURE__ */ jsxDEV("div", { children: reply }, void 0, false, {
|
|
177
|
+
fileName: "/Users/justin/rw/sdk/experiments/ai-stream/src/app/pages/Chat/Chat.tsx",
|
|
178
|
+
lineNumber: 54,
|
|
179
|
+
columnNumber: 7
|
|
180
|
+
}, this)
|
|
181
|
+
] }, void 0, true, {
|
|
182
|
+
fileName: "/Users/justin/rw/sdk/experiments/ai-stream/src/app/pages/Chat/Chat.tsx",
|
|
183
|
+
lineNumber: 27,
|
|
184
|
+
columnNumber: 5
|
|
185
|
+
}, this);
|
|
186
|
+
}
|
|
187
|
+
`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
188
|
+
const Chat = registerClientReference("/test/file.tsx", "Chat");
|
|
189
|
+
export { Chat };
|
|
190
|
+
`);
|
|
191
|
+
});
|
|
192
|
+
it("Does not transform when 'use client' is not directive", async () => {
|
|
193
|
+
expect(await transform(`const message = "use client";`)).toEqual(undefined);
|
|
194
|
+
});
|
|
195
|
+
it("properly handles export alias", async () => {
|
|
196
|
+
expect((await transform(`"use client"
|
|
197
|
+
|
|
198
|
+
const MyComponent = () => {
|
|
199
|
+
return jsx('div', { children: 'Hello' });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { MyComponent as CustomName }`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
203
|
+
const MyComponent = registerClientReference("/test/file.tsx", "CustomName");
|
|
204
|
+
export { MyComponent as CustomName };
|
|
205
|
+
`);
|
|
206
|
+
});
|
|
207
|
+
it("correctly processes multiple component exports", async () => {
|
|
208
|
+
expect((await transform(`"use client"
|
|
209
|
+
|
|
210
|
+
const First = () => jsx('div', { children: 'First' });
|
|
211
|
+
const Second = () => jsx('div', { children: 'Second' });
|
|
212
|
+
const Third = () => jsx('div', { children: 'Third' });
|
|
213
|
+
|
|
214
|
+
export { First, Second, Third }`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
215
|
+
const First = registerClientReference("/test/file.tsx", "First");
|
|
216
|
+
const Second = registerClientReference("/test/file.tsx", "Second");
|
|
217
|
+
const Third = registerClientReference("/test/file.tsx", "Third");
|
|
218
|
+
export { First, Second, Third };
|
|
219
|
+
`);
|
|
220
|
+
});
|
|
221
|
+
it("handles combination of JSX and non-JSX exports", async () => {
|
|
222
|
+
expect((await transform(`"use client"
|
|
223
|
+
|
|
224
|
+
const Component = () => jsx('div', {});
|
|
225
|
+
const data = { value: 42 };
|
|
226
|
+
const helper = () => console.log('helper');
|
|
227
|
+
|
|
228
|
+
export { Component, data, helper }`)) ?? "").toEqual(`import { registerClientReference } from "rwsdk/worker";
|
|
229
|
+
const Component = registerClientReference("/test/file.tsx", "Component");
|
|
230
|
+
const data = registerClientReference("/test/file.tsx", "data");
|
|
231
|
+
const helper = registerClientReference("/test/file.tsx", "helper");
|
|
232
|
+
export { Component, data, helper };
|
|
233
|
+
`);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
describe("transformClientComponents logic branches (from transformClientComponents.mts)", () => {
|
|
237
|
+
it("skips transformation if not in worker environment", async () => {
|
|
238
|
+
const code = '"use client"\nexport const foo = 1;';
|
|
239
|
+
const result = await transformClientComponents(code, "/test/file.tsx", {
|
|
240
|
+
environmentName: "browser",
|
|
241
|
+
projectRootDir: "/test",
|
|
242
|
+
});
|
|
243
|
+
expect(result).toBeUndefined();
|
|
244
|
+
});
|
|
245
|
+
it("returns code as-is if file does not start with 'use client'", async () => {
|
|
246
|
+
const code = "const foo = 1;";
|
|
247
|
+
const result = await transformClientComponents(code, "/test/file.tsx", {
|
|
248
|
+
environmentName: "worker",
|
|
249
|
+
projectRootDir: "/test",
|
|
250
|
+
});
|
|
251
|
+
expect(result).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
it("removes directive but does not transform if not a virtual SSR file", async () => {
|
|
254
|
+
const code = '"use client"\nexport const foo = 1;';
|
|
255
|
+
const result = await transformClientComponents(code, "/test/file.tsx", {
|
|
256
|
+
environmentName: "ssr",
|
|
257
|
+
projectRootDir: "/test",
|
|
258
|
+
});
|
|
259
|
+
expect(result?.code).toEqual("export const foo = 1;");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { SourceFile } from "ts-morph";
|
|
2
|
+
interface TransformResult {
|
|
3
|
+
code: string;
|
|
4
|
+
map?: any;
|
|
5
|
+
}
|
|
6
|
+
export declare const findExportedFunctions: (sourceFile: SourceFile) => Set<string>;
|
|
7
|
+
export declare const transformServerFunctions: (code: string, relativeId: string, environment: "client" | "worker" | "ssr") => TransformResult | undefined;
|
|
8
|
+
export type { TransformResult };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Project, SyntaxKind, Node } from "ts-morph";
|
|
2
|
+
import debug from "debug";
|
|
3
|
+
const log = debug("rwsdk:vite:transform-server-functions");
|
|
4
|
+
const verboseLog = debug("verbose:rwsdk:vite:transform-server-functions");
|
|
5
|
+
export const findExportedFunctions = (sourceFile) => {
|
|
6
|
+
verboseLog("Finding exported functions in source file");
|
|
7
|
+
const exportedFunctions = new Set();
|
|
8
|
+
const exportAssignments = sourceFile.getDescendantsOfKind(SyntaxKind.ExportAssignment);
|
|
9
|
+
for (const e of exportAssignments) {
|
|
10
|
+
const name = e.getExpression().getText();
|
|
11
|
+
if (name === "default") {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
exportedFunctions.add(name);
|
|
15
|
+
verboseLog("Found export assignment: %s", name);
|
|
16
|
+
}
|
|
17
|
+
const functionDeclarations = sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration);
|
|
18
|
+
for (const func of functionDeclarations) {
|
|
19
|
+
if (func.hasModifier(SyntaxKind.ExportKeyword)) {
|
|
20
|
+
const name = func.getName();
|
|
21
|
+
if (name) {
|
|
22
|
+
exportedFunctions.add(name);
|
|
23
|
+
verboseLog("Found exported function declaration: %s", name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const variableStatements = sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement);
|
|
28
|
+
for (const statement of variableStatements) {
|
|
29
|
+
if (statement.hasModifier(SyntaxKind.ExportKeyword)) {
|
|
30
|
+
const declarations = statement.getDeclarationList().getDeclarations();
|
|
31
|
+
for (const declaration of declarations) {
|
|
32
|
+
const initializer = declaration.getInitializer();
|
|
33
|
+
if (initializer && Node.isArrowFunction(initializer)) {
|
|
34
|
+
const name = declaration.getName();
|
|
35
|
+
if (name) {
|
|
36
|
+
exportedFunctions.add(name);
|
|
37
|
+
verboseLog("Found exported arrow function: %s", name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
log("Found %d exported functions: %O", exportedFunctions.size, Array.from(exportedFunctions));
|
|
44
|
+
return exportedFunctions;
|
|
45
|
+
};
|
|
46
|
+
export const transformServerFunctions = (code, relativeId, environment) => {
|
|
47
|
+
verboseLog("Transform server functions called for relativeId=%s, environment=%s", relativeId, environment);
|
|
48
|
+
const project = new Project({
|
|
49
|
+
useInMemoryFileSystem: true,
|
|
50
|
+
compilerOptions: {
|
|
51
|
+
sourceMap: true,
|
|
52
|
+
target: 2,
|
|
53
|
+
module: 1,
|
|
54
|
+
jsx: 2,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const sourceFile = project.createSourceFile("temp.tsx", code);
|
|
58
|
+
const firstString = sourceFile.getFirstDescendantByKind(SyntaxKind.StringLiteral);
|
|
59
|
+
if (!firstString) {
|
|
60
|
+
verboseLog("No string literals found, skipping transformation for relativeId=%s", relativeId);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (firstString?.getText().indexOf("use server") === -1 &&
|
|
64
|
+
firstString?.getStart() !== sourceFile.getStart()) {
|
|
65
|
+
verboseLog("No 'use server' directive found at start, skipping transformation for relativeId=%s", relativeId);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
log("Processing 'use server' module: relativeId=%s, environment=%s", relativeId, environment);
|
|
69
|
+
if (firstString) {
|
|
70
|
+
const parent = firstString.getParent();
|
|
71
|
+
if (parent && Node.isExpressionStatement(parent)) {
|
|
72
|
+
parent.replaceWithText("");
|
|
73
|
+
verboseLog("Removed 'use server' directive from relativeId=%s", relativeId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (environment === "ssr") {
|
|
77
|
+
log("Transforming for SSR environment: relativeId=%s", relativeId);
|
|
78
|
+
const ssrSourceFile = project.createSourceFile("ssr.tsx", "");
|
|
79
|
+
ssrSourceFile.addImportDeclaration({
|
|
80
|
+
moduleSpecifier: "rwsdk/__ssr",
|
|
81
|
+
namedImports: ["createServerReference"],
|
|
82
|
+
});
|
|
83
|
+
const exports = findExportedFunctions(sourceFile);
|
|
84
|
+
for (const name of exports) {
|
|
85
|
+
ssrSourceFile.addVariableStatement({
|
|
86
|
+
isExported: true,
|
|
87
|
+
declarations: [
|
|
88
|
+
{
|
|
89
|
+
name: name,
|
|
90
|
+
initializer: `createServerReference(${JSON.stringify(relativeId)}, ${JSON.stringify(name)})`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
log("Added SSR server reference for function: %s in relativeId=%s", name, relativeId);
|
|
95
|
+
}
|
|
96
|
+
const hadDefaultExport = !!sourceFile.getDefaultExportSymbol();
|
|
97
|
+
if (hadDefaultExport) {
|
|
98
|
+
ssrSourceFile.addExportAssignment({
|
|
99
|
+
expression: `createServerReference(${JSON.stringify(relativeId)}, "default")`,
|
|
100
|
+
isExportEquals: false,
|
|
101
|
+
});
|
|
102
|
+
log("Added SSR server reference for default export in relativeId=%s", relativeId);
|
|
103
|
+
}
|
|
104
|
+
const emitOutput = ssrSourceFile.getEmitOutput();
|
|
105
|
+
let sourceMap;
|
|
106
|
+
for (const outputFile of emitOutput.getOutputFiles()) {
|
|
107
|
+
if (outputFile.getFilePath().endsWith(".js.map")) {
|
|
108
|
+
sourceMap = JSON.parse(outputFile.getText());
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
log("SSR transformation complete for relativeId=%s", relativeId);
|
|
112
|
+
return {
|
|
113
|
+
code: ssrSourceFile.getFullText(),
|
|
114
|
+
map: sourceMap,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
else if (environment === "worker") {
|
|
118
|
+
log("Transforming for worker environment: relativeId=%s", relativeId);
|
|
119
|
+
sourceFile.addImportDeclaration({
|
|
120
|
+
moduleSpecifier: "rwsdk/worker",
|
|
121
|
+
namedImports: ["registerServerReference"],
|
|
122
|
+
});
|
|
123
|
+
const defaultExportSymbol = sourceFile.getDefaultExportSymbol();
|
|
124
|
+
const defaultExportDecl = defaultExportSymbol?.getDeclarations()[0];
|
|
125
|
+
let hasDefaultExport = false;
|
|
126
|
+
if (defaultExportDecl && Node.isFunctionDeclaration(defaultExportDecl)) {
|
|
127
|
+
hasDefaultExport = true;
|
|
128
|
+
defaultExportDecl.setIsDefaultExport(false);
|
|
129
|
+
defaultExportDecl.rename("__defaultServerFunction__");
|
|
130
|
+
sourceFile.addExportAssignment({
|
|
131
|
+
expression: "__defaultServerFunction__",
|
|
132
|
+
isExportEquals: false,
|
|
133
|
+
});
|
|
134
|
+
sourceFile.addStatements(`registerServerReference(__defaultServerFunction__, ${JSON.stringify(relativeId)}, "default")`);
|
|
135
|
+
log("Registered worker server reference for default export in relativeId=%s", relativeId);
|
|
136
|
+
}
|
|
137
|
+
const exports = findExportedFunctions(sourceFile);
|
|
138
|
+
for (const name of exports) {
|
|
139
|
+
if (name === "__defaultServerFunction__")
|
|
140
|
+
continue;
|
|
141
|
+
sourceFile.addStatements(`registerServerReference(${name}, ${JSON.stringify(relativeId)}, ${JSON.stringify(name)})`);
|
|
142
|
+
log("Registered worker server reference for function: %s in relativeId=%s", name, relativeId);
|
|
143
|
+
}
|
|
144
|
+
const emitOutput = sourceFile.getEmitOutput();
|
|
145
|
+
let sourceMap;
|
|
146
|
+
for (const outputFile of emitOutput.getOutputFiles()) {
|
|
147
|
+
if (outputFile.getFilePath().endsWith(".js.map")) {
|
|
148
|
+
sourceMap = JSON.parse(outputFile.getText());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
log("Worker transformation complete for relativeId=%s", relativeId);
|
|
152
|
+
return {
|
|
153
|
+
code: sourceFile.getFullText(),
|
|
154
|
+
map: sourceMap,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
else if (environment === "client") {
|
|
158
|
+
log("Transforming for client environment: relativeId=%s", relativeId);
|
|
159
|
+
const clientSourceFile = project.createSourceFile("client.tsx", "");
|
|
160
|
+
clientSourceFile.addImportDeclaration({
|
|
161
|
+
moduleSpecifier: "rwsdk/client",
|
|
162
|
+
namedImports: ["createServerReference"],
|
|
163
|
+
});
|
|
164
|
+
const exports = findExportedFunctions(sourceFile);
|
|
165
|
+
for (const name of exports) {
|
|
166
|
+
clientSourceFile.addVariableStatement({
|
|
167
|
+
isExported: true,
|
|
168
|
+
declarations: [
|
|
169
|
+
{
|
|
170
|
+
name: name,
|
|
171
|
+
initializer: `createServerReference(${JSON.stringify(relativeId)}, ${JSON.stringify(name)})`,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
log("Added client server reference for function: %s in relativeId=%s", name, relativeId);
|
|
176
|
+
}
|
|
177
|
+
const hadDefaultExport = !!sourceFile.getDefaultExportSymbol();
|
|
178
|
+
if (hadDefaultExport) {
|
|
179
|
+
clientSourceFile.addExportAssignment({
|
|
180
|
+
expression: `createServerReference(${JSON.stringify(relativeId)}, "default")`,
|
|
181
|
+
isExportEquals: false,
|
|
182
|
+
});
|
|
183
|
+
log("Added client server reference for default export in relativeId=%s", relativeId);
|
|
184
|
+
}
|
|
185
|
+
const emitOutput = clientSourceFile.getEmitOutput();
|
|
186
|
+
let sourceMap;
|
|
187
|
+
for (const outputFile of emitOutput.getOutputFiles()) {
|
|
188
|
+
if (outputFile.getFilePath().endsWith(".js.map")) {
|
|
189
|
+
sourceMap = JSON.parse(outputFile.getText());
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
log("Client transformation complete for relativeId=%s", relativeId);
|
|
193
|
+
return {
|
|
194
|
+
code: clientSourceFile.getFullText(),
|
|
195
|
+
map: sourceMap,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
verboseLog("No transformation applied for environment=%s, relativeId=%s", environment, relativeId);
|
|
199
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { transformServerFunctions } from "./transformServerFunctions.mjs";
|
|
3
|
+
describe("useServerPlugin", () => {
|
|
4
|
+
let COMMENT_CODE = `
|
|
5
|
+
// Comment
|
|
6
|
+
"use server";
|
|
7
|
+
|
|
8
|
+
export function sum() {
|
|
9
|
+
return 1 + 1;
|
|
10
|
+
}
|
|
11
|
+
`;
|
|
12
|
+
let MULTI_LINE_COMMENT_CODE = `
|
|
13
|
+
// Multi-line
|
|
14
|
+
// Comment
|
|
15
|
+
"use server";
|
|
16
|
+
|
|
17
|
+
export function sum() {
|
|
18
|
+
return 1 + 1
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
let COMMENT_BLOCK_CODE = `
|
|
22
|
+
/* Giant
|
|
23
|
+
* Comment
|
|
24
|
+
* Block
|
|
25
|
+
*/
|
|
26
|
+
"use server";
|
|
27
|
+
|
|
28
|
+
export function sum() {
|
|
29
|
+
return 1 + 1
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
let DEFAULT_EXPORT_CODE = `
|
|
33
|
+
"use server";
|
|
34
|
+
|
|
35
|
+
export default function sum() {
|
|
36
|
+
return 1 + 1;
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
let DEFAULT_AND_NAMED_EXPORTS_CODE = `
|
|
40
|
+
"use server";
|
|
41
|
+
|
|
42
|
+
export function sum() {
|
|
43
|
+
return 1 + 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function sum() {
|
|
47
|
+
return 1 + 2;
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
50
|
+
let NAMED_EXPORT_CODE = `
|
|
51
|
+
"use server";
|
|
52
|
+
|
|
53
|
+
export function sum() {
|
|
54
|
+
return 1 + 1
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
let ARROW_FUNCTION_EXPORT_CODE = `
|
|
58
|
+
"use server";
|
|
59
|
+
|
|
60
|
+
export const sum = () => {
|
|
61
|
+
return 1 + 1
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
let ASYNC_FUNCTION_EXPORT_CODE = `
|
|
65
|
+
"use server";
|
|
66
|
+
|
|
67
|
+
export async function sum() {
|
|
68
|
+
return 1 + 1
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
let IGNORE_NON_FUNCTION_EXPORT_CODE = `
|
|
72
|
+
"use server";
|
|
73
|
+
|
|
74
|
+
export const a = "string";
|
|
75
|
+
`;
|
|
76
|
+
const TEST_CASES = {
|
|
77
|
+
COMMENT_CODE,
|
|
78
|
+
MULTI_LINE_COMMENT_CODE,
|
|
79
|
+
COMMENT_BLOCK_CODE,
|
|
80
|
+
DEFAULT_EXPORT_CODE,
|
|
81
|
+
NAMED_EXPORT_CODE,
|
|
82
|
+
ARROW_FUNCTION_EXPORT_CODE,
|
|
83
|
+
ASYNC_FUNCTION_EXPORT_CODE,
|
|
84
|
+
IGNORE_NON_FUNCTION_EXPORT_CODE,
|
|
85
|
+
DEFAULT_AND_NAMED_EXPORTS_CODE,
|
|
86
|
+
};
|
|
87
|
+
describe("TRANSFORMS", () => {
|
|
88
|
+
for (const [key, CODE] of Object.entries(TEST_CASES)) {
|
|
89
|
+
describe(key, () => {
|
|
90
|
+
it(`CLIENT`, () => {
|
|
91
|
+
const result = transformServerFunctions(CODE, "/test.tsx", "client");
|
|
92
|
+
expect(result?.code).toMatchSnapshot();
|
|
93
|
+
});
|
|
94
|
+
it(`WORKER`, () => {
|
|
95
|
+
const result = transformServerFunctions(CODE, "/test.tsx", "worker");
|
|
96
|
+
expect(result?.code).toMatchSnapshot();
|
|
97
|
+
});
|
|
98
|
+
it(`SSR`, () => {
|
|
99
|
+
const result = transformServerFunctions(CODE, "/test.tsx", "ssr");
|
|
100
|
+
expect(result?.code).toMatchSnapshot();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|