Repository: dai-shi/waku (ts)

Issue:
Is there any way to guarantee rsf{n} endpoints are stable across builds?
I noticed that in development, calling a server action resulted in a call to an endpoint like `/RSC/F/_/{filepath}/{functionName}.txt`; however, when running the production build, those endpoints are more like `/RSC/F/assets/rsf0/{functionName}.txt`.

That `rsf0` bit is generated here: https://github.com/dai-shi/waku/blob/c0e1f3f8dedcf118790fac419f0284249e8d9971/packages/waku/src/lib/builder/build.ts#L198

The `0` appears to be based on whatever vite chunk the function ends up in, and I can see multiple endpoints in my test build along the lines of `1`, `2`, etc.

Is there any way to guarantee the stability of these endpoints across builds? What happens if I produce a new build and a server function I was calling ends up in a different vite entry, but someone's client hasn't been refreshed yet and so the client ends up calling the wrong endpoint?

Pull request description:
fix(core): add hash to rsf{n}
close #1202

Patch:
diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts
index 4cc562d3..0b262472 100644
--- a/packages/waku/src/lib/builder/build.ts
+++ b/packages/waku/src/lib/builder/build.ts
@@ -101,12 +101,12 @@ const analyzeEntries = async (rootDir: string, config: ResolvedConfig) => {
   const wakuMinimalClientDist = decodeFilePathFromAbsolute(
     joinPath(fileURLToFilePath(import.meta.url), '../../../minimal/client.js'),
   );
-  const clientFileSet = new Set<string>([
-    wakuClientDist,
-    wakuMinimalClientDist,
+  const clientFileMap = new Map<string, string>([
+    // FIXME 'lib' should be the real hash
+    [wakuClientDist, 'lib'],
+    [wakuMinimalClientDist, 'lib'],
   ]);
-  const serverFileSet = new Set<string>();
-  const fileHashMap = new Map<string, string>();
+  const serverFileMap = new Map<string, string>();
   const moduleFileMap = new Map<string, string>(); // module id -> full path
   const pagesDirPath = joinPath(rootDir, config.srcDir, config.pagesDir);
   if (existsSync(pagesDirPath)) {
@@ -131,9 +131,8 @@ const analyzeEntries = async (rootDir: string, config: ResolvedConfig) => {
         plugins: [
           rscAnalyzePlugin({
             isClient: false,
-            clientFileSet,
-            serverFileSet,
-            fileHashMap,
+            clientFileMap,
+            serverFileMap,
           }),
           rscManagedPlugin({ ...config, addEntriesToInput: true }),
           ...deployPlugins(config),
@@ -161,8 +160,8 @@ const analyzeEntries = async (rootDir: string, config: ResolvedConfig) => {
     ),
   );
   const clientEntryFiles = Object.fromEntries(
-    Array.from(clientFileSet).map((fname, i) => [
-      `${DIST_ASSETS}/rsc${i}-${fileHashMap.get(fname) || 'lib'}`, // FIXME 'lib' is a workaround to avoid `undefined`
+    Array.from(clientFileMap).map(([fname, hash], i) => [
+      `${DIST_ASSETS}/rsc${i}-${hash}`,
       fname,
     ]),
   );
@@ -171,7 +170,7 @@ const analyzeEntries = async (rootDir: string, config: ResolvedConfig) => {
       {
         mode: 'production',
         plugins: [
-          rscAnalyzePlugin({ isClient: true, serverFileSet }),
+          rscAnalyzePlugin({ isClient: true, serverFileMap }),
           rscManagedPlugin({ ...config, addMainToInput: true }),
           ...deployPlugins(config),
         ],
@@ -194,8 +193,8 @@ const analyzeEntries = async (rootDir: string, config: ResolvedConfig) => {
     ),
   );
   const serverEntryFiles = Object.fromEntries(
-    Array.from(serverFileSet).map((fname, i) => [
-      `${DIST_ASSETS}/rsf${i}`,
+    Array.from(serverFileMap).map(([fname, hash], i) => [
+      `${DIST_ASSETS}/rsf${i}-${hash}`,
       fname,
     ]),
   );
diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-analyze.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-analyze.ts
index 8d284a72..104338b0 100644
--- a/packages/waku/src/lib/plugins/vite-plugin-rsc-analyze.ts
+++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-analyze.ts
@@ -70,13 +70,12 @@ export function rscAnalyzePlugin(
   opts:
     | {
         isClient: true;
-        serverFileSet: Set<string>;
+        serverFileMap: Map<string, string>;
       }
     | {
         isClient: false;
-        clientFileSet: Set<string>;
-        serverFileSet: Set<string>;
-        fileHashMap: Map<string, string>;
+        clientFileMap: Map<string, string>;
+        serverFileMap: Map<string, string>;
       },
 ): Plugin {
   const rscTransform = rscTransformPlugin({
@@ -96,25 +95,24 @@ export function rscAnalyzePlugin(
             item.expression.type === 'StringLiteral'
           ) {
             if (!opts.isClient && item.expression.value === 'use client') {
-              opts.clientFileSet.add(id);
-              opts.fileHashMap.set(id, await hash(code));
+              opts.clientFileMap.set(id, await hash(code));
             } else if (item.expression.value === 'use server') {
-              opts.serverFileSet.add(id);
+              opts.serverFileMap.set(id, await hash(code));
             }
           }
         }
         if (
           !opts.isClient &&
-          !opts.clientFileSet.has(id) &&
-          !opts.serverFileSet.has(id) &&
+          !opts.clientFileMap.has(id) &&
+          !opts.serverFileMap.has(id) &&
           code.includes('use server') &&
           containsServerFunction(mod)
         ) {
-          opts.serverFileSet.add(id);
+          opts.serverFileMap.set(id, await hash(code));
         }
       }
       // Avoid walking after the client boundary
-      if (!opts.isClient && opts.clientFileSet.has(id)) {
+      if (!opts.isClient && opts.clientFileMap.has(id)) {
         // TODO this isn't efficient. let's refactor it in the future.
         return (
           rscTransform as typeof rscTransform & { handler: undefined }


Test patch:
diff --git a/packages/waku/tests/vite-plugin-rsc-analyze.test.ts b/packages/waku/tests/vite-plugin-rsc-analyze.test.ts
index 0aa39034..5099fd97 100644
--- a/packages/waku/tests/vite-plugin-rsc-analyze.test.ts
+++ b/packages/waku/tests/vite-plugin-rsc-analyze.test.ts
@@ -39,9 +39,8 @@ async function runTest(
   expectedClientFileSet: Set<string>,
   expectedServerFileSet: Set<string>,
 ) {
-  const clientFileSet = new Set<string>();
-  const serverFileSet = new Set<string>();
-  const fileHashMap = new Map<string, string>();
+  const clientFileMap = new Map<string, string>();
+  const serverFileMap = new Map<string, string>();
   await build({
     root: root,
     logLevel: 'silent',
@@ -57,28 +56,27 @@ async function runTest(
       isClient
         ? rscAnalyzePlugin({
             isClient: true,
-            serverFileSet,
+            serverFileMap,
           })
         : rscAnalyzePlugin({
             isClient: false,
-            clientFileSet,
-            serverFileSet,
-            fileHashMap,
+            clientFileMap,
+            serverFileMap,
           }),
     ],
   });
   // remove the base path
-  [...clientFileSet].forEach((value) => {
-    clientFileSet.delete(value);
-    clientFileSet.add(path.relative(root, value));
+  [...clientFileMap].forEach(([value]) => {
+    clientFileMap.delete(value);
+    clientFileMap.set(path.relative(root, value), 'hash');
   });
-  [...serverFileSet].forEach((value) => {
-    serverFileSet.delete(value);
-    serverFileSet.add(path.relative(root, value));
+  [...serverFileMap].forEach(([value]) => {
+    serverFileMap.delete(value);
+    serverFileMap.set(path.relative(root, value), 'hash');
   });
 
-  expect(clientFileSet).toEqual(expectedClientFileSet);
-  expect(serverFileSet).toEqual(expectedServerFileSet);
+  expect(new Set(clientFileMap.keys())).toEqual(expectedClientFileSet);
+  expect(new Set(serverFileMap.keys())).toEqual(expectedServerFileSet);
 }
 
 describe('vite-plugin-rsc-analyze', () => {