Repository: dai-shi/waku
Pull request #951 (2024-10-06 03:26:49)

redesign rscPath encoding
Technically breaking.

vercel[bot]: [vc]: #90fVALLhA7pA0AkCKrF1ixmZdUxLkKfUpPNMHzts4A4=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS83OGYyYTY5OXVUdG5acGpuNHduZjE5Umtadjl0IiwicHJldmlld1VybCI6Indha3UtZ2l0LXJlZmluZS1yc2MtcGF0aC1kYWlzaGlzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **waku** | 🔄 Building ([Inspect](https://vercel.com/daishis-projects/waku/78f2a699uTtnZpjn4wnf19RkZv9t)) | [Visit Preview](https://waku-git-refine-rsc-path-daishis-projects.vercel.app) | Oct 6, 2024 3:26am |


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/dai-shi/waku/pr/951/builds/548352) or the icon next to each commit SHA.

Patch:
diff --git a/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts b/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
index 412781ef..138649a9 100644
--- a/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
+++ b/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
@@ -3,7 +3,6 @@ import { readdir, writeFile } from 'node:fs/promises';
 import { existsSync, readFileSync } from 'node:fs';
 import { SRC_ENTRIES, EXTENSIONS } from '../constants.js';
 import { joinPath } from '../utils/path.js';
-import { getRscPath } from '../../router/common.js';
 
 const SRC_PAGES = 'pages';
 
@@ -44,7 +43,7 @@ export function getImportModuleNames(filePaths: string[]): {
       identifier = `${identifier}_${moduleNameCount[identifier]}`;
     }
     try {
-      moduleNames[getRscPath(filePath)] = identifier;
+      moduleNames[filePath.replace(/^\//, '')] = identifier;
     } catch (e) {
       console.log(e);
     }
@@ -128,7 +127,7 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
 
         for (const filePath of filePaths) {
           // where to import the component from
-          const src = getRscPath(filePath);
+          const src = filePath.replace(/^\//, '');
           const hasGetConfig = fileExportsGetConfig(filePath);
 
           if (filePath.endsWith('/_layout.tsx')) {
diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts
index 8e03f4e9..fdde76a4 100644
--- a/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts
+++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts
@@ -599,7 +599,7 @@ export function rscTransformPlugin(
     }
     for (const [k, v] of Object.entries(opts.clientEntryFiles)) {
       if (v === id) {
-        return `@id/${k}.js`;
+        return k;
       }
     }
     throw new Error('client id not found: ' + id);
@@ -610,7 +610,7 @@ export function rscTransformPlugin(
     }
     for (const [k, v] of Object.entries(opts.serverEntryFiles)) {
       if (v === id) {
-        return `@id/${k}.js`;
+        return k;
       }
     }
     throw new Error('server id not found: ' + id);
diff --git a/packages/waku/src/lib/renderers/rsc-renderer.ts b/packages/waku/src/lib/renderers/rsc-renderer.ts
index a73a99af..21415724 100644
--- a/packages/waku/src/lib/renderers/rsc-renderer.ts
+++ b/packages/waku/src/lib/renderers/rsc-renderer.ts
@@ -20,10 +20,7 @@ export const SERVER_MODULE_MAP = {
 } as const;
 
 const resolveClientEntryForPrd = (id: string, config: { basePath: string }) => {
-  if (!id.startsWith('@id/')) {
-    throw new Error('Unexpected client entry in PRD');
-  }
-  return config.basePath + id.slice('@id/'.length);
+  return config.basePath + id + '.js';
 };
 
 export type RenderRscArgs = {
@@ -116,9 +113,7 @@ export async function renderRsc(
     {
       get(_target, encodedId: string) {
         const [fileId, name] = encodedId.split('#') as [string, string];
-        const id = fileId.startsWith('@id/')
-          ? fileId.slice('@id/'.length)
-          : filePathToFileURL(fileId);
+        const id = isDev ? filePathToFileURL(fileId) : fileId + '.js';
         return { id, chunks: [id], name, async: true };
       },
     },
@@ -221,10 +216,7 @@ export async function renderRsc(
     if (isDev) {
       mod = await opts.loadServerModuleRsc(filePathToFileURL(fileId));
     } else {
-      if (!fileId.startsWith('@id/')) {
-        throw new Error('Unexpected server entry in PRD');
-      }
-      mod = await loadModule(fileId.slice('@id/'.length));
+      mod = await loadModule(fileId + '.js');
     }
     const fn = mod[name] || mod;
     return renderWithContextWithFunc(context, fn, args);
diff --git a/packages/waku/src/lib/renderers/utils.ts b/packages/waku/src/lib/renderers/utils.ts
index 7b838608..7508c5bf 100644
--- a/packages/waku/src/lib/renderers/utils.ts
+++ b/packages/waku/src/lib/renderers/utils.ts
@@ -1,39 +1,46 @@
 // This file should not include Node specific code.
 
 export const encodeRscPath = (rscPath: string) => {
-  if (rscPath === '') {
-    return 'index.txt';
+  if (rscPath.startsWith('_')) {
+    throw new Error('rscPath must not start with `_`: ' + rscPath);
+  }
+  if (rscPath.endsWith('_')) {
+    throw new Error('rscPath must not end with `_`: ' + rscPath);
   }
-  if (rscPath === 'index') {
-    throw new Error('rscPath should not be `index`');
+  if (rscPath === '') {
+    rscPath = '_';
   }
   if (rscPath.startsWith('/')) {
-    throw new Error('rscPath should not start with `/`');
+    rscPath = '_' + rscPath;
   }
   if (rscPath.endsWith('/')) {
-    throw new Error('rscPath should not end with `/`');
+    rscPath += '_';
   }
   return rscPath + '.txt';
 };
 
-export const decodeRscPath = (encodedRscPath: string) => {
-  if (encodedRscPath === 'index.txt') {
-    return '';
+export const decodeRscPath = (rscPath: string) => {
+  if (!rscPath.endsWith('.txt')) {
+    const err = new Error('Invalid encoded rscPath');
+    (err as any).statusCode = 400;
+    throw err;
+  }
+  rscPath = rscPath.slice(0, -'.txt'.length);
+  if (rscPath.startsWith('_')) {
+    rscPath = rscPath.slice(1);
   }
-  if (encodedRscPath?.endsWith('.txt')) {
-    return encodedRscPath.slice(0, -'.txt'.length);
+  if (rscPath.endsWith('_')) {
+    rscPath = rscPath.slice(0, -1);
   }
-  const err = new Error('Invalid encoded rscPath');
-  (err as any).statusCode = 400;
-  throw err;
+  return rscPath;
 };
 
-const FUNC_PREFIX = 'FUNC_';
+const FUNC_PREFIX = 'F/';
 
 export const encodeFuncId = (funcId: string) => {
   const [file, name] = funcId.split('#') as [string, string];
   if (name.includes('/')) {
-    throw new Error('Unsupported function name');
+    throw new Error('Function name must not include `/`: ' + name);
   }
   return FUNC_PREFIX + file + '/' + name;
 };
diff --git a/packages/waku/src/router/client.ts b/packages/waku/src/router/client.ts
index 7c157479..e06ac82d 100644
--- a/packages/waku/src/router/client.ts
+++ b/packages/waku/src/router/client.ts
@@ -26,7 +26,7 @@ import type {
 import { fetchRsc, prefetchRsc, Root, Slot, useRefetch } from '../client.js';
 import {
   getComponentIds,
-  getRscPath,
+  encodeRoutePath,
   SHOULD_SKIP_ID,
   ROUTE_ID,
   HAS404_ID,
@@ -375,7 +375,7 @@ const InnerRouter = ({ routerData }: { routerData: RouterData }) => {
       if (componentIds.every((id) => skip.includes(id))) {
         return; // everything is skipped
       }
-      const rscPath = getRscPath(route.path);
+      const rscPath = encodeRoutePath(route.path);
       if (!skipRefetch) {
         refetch(rscPath, JSON.stringify({ query: route.query, skip }));
       }
@@ -406,7 +406,7 @@ const InnerRouter = ({ routerData }: { routerData: RouterData }) => {
       if (componentIds.every((id) => skip.includes(id))) {
         return; // everything is cached
       }
-      const rscPath = getRscPath(route.path);
+      const rscPath = encodeRoutePath(route.path);
       prefetchRsc(rscPath, JSON.stringify({ query: route.query, skip }));
       (globalThis as any).__WAKU_ROUTER_PREFETCH__?.(route.path);
     },
@@ -488,7 +488,7 @@ const DEFAULT_ROUTER_DATA: RouterData = [];
 
 export function Router({ routerData = DEFAULT_ROUTER_DATA }) {
   const route = parseRouteFromLocation();
-  const initialRscPath = getRscPath(route.path);
+  const initialRscPath = encodeRoutePath(route.path);
   const unstable_enhanceCreateData =
     (
       createData: (
@@ -501,7 +501,7 @@ export function Router({ routerData = DEFAULT_ROUTER_DATA }) {
       if (response.status === 404 && has404) {
         // HACK this is still an experimental logic. It's very fragile.
         // FIXME we should cache it if 404.txt is static.
-        return fetchRsc(getRscPath('/404'));
+        return fetchRsc(encodeRoutePath('/404'));
       }
       const data = createData(responsePromise);
       Promise.resolve(data)
diff --git a/packages/waku/src/router/common.ts b/packages/waku/src/router/common.ts
index ab4ef342..8434cef3 100644
--- a/packages/waku/src/router/common.ts
+++ b/packages/waku/src/router/common.ts
@@ -15,18 +15,29 @@ export function getComponentIds(path: string): readonly string[] {
   return Array.from(idSet);
 }
 
-export function getRscPath(path: string): string {
+const ROUTE_PREFIX = 'R';
+
+export function encodeRoutePath(path: string): string {
   if (!path.startsWith('/')) {
-    throw new Error('Path should start with `/`');
+    throw new Error('Path must start with `/`: ' + path);
+  }
+  if (path === '/') {
+    return ROUTE_PREFIX + '/_root';
   }
-  return path.slice(1);
+  if (path.endsWith('/')) {
+    throw new Error('Path must not end with `/`: ' + path);
+  }
+  return ROUTE_PREFIX + path;
 }
 
-export function parseRscPath(rscPath: string): string {
-  if (rscPath.startsWith('/')) {
+export function decodeRoutePath(rscPath: string): string {
+  if (!rscPath.startsWith(ROUTE_PREFIX)) {
     throw new Error('rscPath should not start with `/`');
   }
-  return '/' + rscPath;
+  if (rscPath === ROUTE_PREFIX + '/_root') {
+    return '/';
+  }
+  return rscPath.slice(ROUTE_PREFIX.length);
 }
 
 // It starts with "/" to avoid conflicting with normal component ids.
diff --git a/packages/waku/src/router/define-router.ts b/packages/waku/src/router/define-router.ts
index 9cb363f9..9c7e8edd 100644
--- a/packages/waku/src/router/define-router.ts
+++ b/packages/waku/src/router/define-router.ts
@@ -15,8 +15,8 @@ import type {
 import { Children, Slot } from '../client.js';
 import {
   getComponentIds,
-  getRscPath,
-  parseRscPath,
+  encodeRoutePath,
+  decodeRoutePath,
   SHOULD_SKIP_ID,
   ROUTE_ID,
   HAS404_ID,
@@ -120,7 +120,7 @@ export function unstable_defineRouter(
         };
   };
   const renderEntries: RenderEntries = async (rscPath, { rscParams }) => {
-    const pathname = parseRscPath(rscPath);
+    const pathname = decodeRoutePath(rscPath);
     const pathStatus = await existsPath(pathname);
     if (!pathStatus.found) {
       return null;
@@ -190,7 +190,7 @@ export function unstable_defineRouter(
           return;
         }
         const pathname = '/' + pathSpec.map(({ name }) => name).join('/');
-        const rscPath = getRscPath(pathname);
+        const rscPath = encodeRoutePath(pathname);
         path2moduleIds[pattern] = await unstable_collectClientModules(rscPath);
       }),
     );
@@ -210,7 +210,7 @@ globalThis.__WAKU_ROUTER_PREFETCH__ = (path) => {
       const entries: BuildConfig[number]['entries'] = [];
       if (pathSpec.every(({ type }) => type === 'literal')) {
         const pathname = '/' + pathSpec.map(({ name }) => name).join('/');
-        const rscPath = getRscPath(pathname);
+        const rscPath = encodeRoutePath(pathname);
         entries.push({ rscPath, isStatic });
       }
       buildConfig.push({
@@ -240,7 +240,7 @@ globalThis.__WAKU_ROUTER_PREFETCH__ = (path) => {
       }
     }
     const componentIds = getComponentIds(pathname);
-    const rscPath = getRscPath(pathname);
+    const rscPath = encodeRoutePath(pathname);
     const html = createElement(
       ServerRouter as FunctionComponent<
         Omit<ComponentProps<typeof ServerRouter>, 'children'>
@@ -266,6 +266,6 @@ export function unstable_rerenderRoute(
   query?: string,
   skip?: string[], // TODO this is too hard to use
 ) {
-  const rscPath = getRscPath(pathname);
+  const rscPath = encodeRoutePath(pathname);
   rerender(rscPath, { query, skip });
 }


Test patch:
diff --git a/e2e/fixtures/broken-links/public/serve.json b/e2e/fixtures/broken-links/public/serve.json
index 2b5d974d..b85a2a09 100644
--- a/e2e/fixtures/broken-links/public/serve.json
+++ b/e2e/fixtures/broken-links/public/serve.json
@@ -1,7 +1,7 @@
 {
   "redirects": [
     { "source": "/redirect", "destination": "/exists" },
-    { "source": "/RSC/redirect.txt", "destination": "/RSC/exists.txt" },
+    { "source": "/RSC/R/redirect.txt", "destination": "/RSC/R/exists.txt" },
     { "source": "/broken-redirect", "destination": "/broken" }
   ]
 }
diff --git a/e2e/fixtures/ssr-catch-error/src/middleware/validator.ts b/e2e/fixtures/ssr-catch-error/src/middleware/validator.ts
index 95c297b7..49d2fb9a 100644
--- a/e2e/fixtures/ssr-catch-error/src/middleware/validator.ts
+++ b/e2e/fixtures/ssr-catch-error/src/middleware/validator.ts
@@ -17,7 +17,7 @@ const validateMiddleware: Middleware = () => {
   return async (ctx, next) => {
     if (
       ctx.req.url.pathname === '/invalid' ||
-      ctx.req.url.pathname.startsWith(`/${rscBase}/invalid`)
+      ctx.req.url.pathname.startsWith(`/${rscBase}/R/invalid`)
     ) {
       ctx.res.status = 401;
       ctx.res.body = stringToStream('Unauthorized');
diff --git a/e2e/partial-builds.spec.ts b/e2e/partial-builds.spec.ts
index 082bf286..141f8606 100644
--- a/e2e/partial-builds.spec.ts
+++ b/e2e/partial-builds.spec.ts
@@ -39,13 +39,13 @@ test.describe(`partial builds`, () => {
 
   test('does not change pages that already exist', async () => {
     const htmlBefore = statSync(`${cwd}/dist/public/page/a/index.html`);
-    const rscBefore = statSync(`${cwd}/dist/public/RSC/page/a.txt`);
+    const rscBefore = statSync(`${cwd}/dist/public/RSC/R/page/a.txt`);
     execSync(`node ${waku} build --experimental-partial`, {
       cwd,
       env: { ...process.env, PAGES: 'a,b' },
     });
     const htmlAfter = statSync(`${cwd}/dist/public/page/a/index.html`);
-    const rscAfter = statSync(`${cwd}/dist/public/RSC/page/a.txt`);
+    const rscAfter = statSync(`${cwd}/dist/public/RSC/R/page/a.txt`);
     expect(htmlBefore.mtimeMs).toBe(htmlAfter.mtimeMs);
     expect(rscBefore.mtimeMs).toBe(rscAfter.mtimeMs);
   });
diff --git a/packages/waku/tests/rscPath.test.ts b/packages/waku/tests/rscPath.test.ts
new file mode 100644
index 00000000..af1777aa
--- /dev/null
+++ b/packages/waku/tests/rscPath.test.ts
@@ -0,0 +1,83 @@
+import { test, describe, expect } from 'vitest';
+import {
+  encodeRscPath,
+  decodeRscPath,
+  encodeFuncId,
+  decodeFuncId,
+} from '../src/lib/renderers/utils.js';
+import { encodeRoutePath, decodeRoutePath } from '../src/router/common.js';
+
+describe('encodeRscPath', () => {
+  test('encodes rscPath', () => {
+    expect(encodeRscPath('')).toBe('_.txt');
+    expect(encodeRscPath('foo')).toBe('foo.txt');
+    expect(encodeRscPath('foo/')).toBe('foo/_.txt');
+    expect(encodeRscPath('/foo')).toBe('_/foo.txt');
+    expect(encodeRscPath('/foo/')).toBe('_/foo/_.txt');
+  });
+
+  test('throws on invalid rscPath', () => {
+    expect(() => encodeRscPath('_foo')).toThrow();
+    expect(() => encodeRscPath('foo_')).toThrow();
+    expect(() => encodeRscPath('_foo_')).toThrow();
+  });
+});
+
+describe('decodeRscPath', () => {
+  test('decodes rscPath', () => {
+    expect(decodeRscPath('_.txt')).toBe('');
+    expect(decodeRscPath('foo.txt')).toBe('foo');
+    expect(decodeRscPath('foo/_.txt')).toBe('foo/');
+    expect(decodeRscPath('_/foo.txt')).toBe('/foo');
+    expect(decodeRscPath('_/foo/_.txt')).toBe('/foo/');
+  });
+
+  test('throws on invalid rscPath', () => {
+    expect(() => decodeRscPath('foo')).toThrow();
+  });
+});
+
+describe('encodeFuncId', () => {
+  test('encodes funcId', () => {
+    expect(encodeFuncId('foo#bar')).toBe('F/foo/bar');
+  });
+
+  test('throws on invalid funcId', () => {
+    expect(() => encodeFuncId('foo#bar/baz')).toThrow();
+  });
+});
+
+describe('decodeFuncId', () => {
+  test('decodes funcId', () => {
+    expect(decodeFuncId('F/foo/bar')).toBe('foo#bar');
+  });
+
+  test('returns null on invalid funcId', () => {
+    expect(decodeFuncId('foo/bar')).toBe(null);
+  });
+});
+
+describe('encodeRoutePath', () => {
+  test('encodes routePath', () => {
+    expect(encodeRoutePath('/')).toBe('R/_root');
+    expect(encodeRoutePath('/foo')).toBe('R/foo');
+    expect(encodeRoutePath('/foo/bar')).toBe('R/foo/bar');
+  });
+
+  test('throws on invalid routePath', () => {
+    expect(() => encodeRoutePath('foo')).toThrow();
+    expect(() => encodeRoutePath('/foo/')).toThrow();
+  });
+});
+
+describe('decodeRoutePath', () => {
+  test('decodes routePath', () => {
+    expect(decodeRoutePath('R/_root')).toBe('/');
+    expect(decodeRoutePath('R/foo')).toBe('/foo');
+    expect(decodeRoutePath('R/foo/bar')).toBe('/foo/bar');
+  });
+
+  test('throws on invalid routePath', () => {
+    expect(() => decodeRoutePath('foo')).toThrow();
+  });
+});