Repository: dai-shi/waku
Pull request #1108 (2024-12-30 05:00:47)

feat(create-pages): api handling
This adds api route handling in createPages with tests

vercel[bot]: [vc]: #ID6PdTPD6Pgr/5uGxQ7mpcwqyIBby5tvVnv5Onv8Idw=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS9lcGJ5YmdoeUZzdmNDeEpaeUdHUkFhaEo0QTNZIiwicHJldmlld1VybCI6Indha3UtZ2l0LWZvcmstdHlsZXJzYXlzaGktY3JlYXRlLXBhZ2VzLWFwaS1kYWlzaGlzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyJ9XX0=
**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/epbybghyFsvcCxJZyGGRAahJ4A3Y)) | [Visit Preview](https://waku-git-fork-tylersayshi-create-pages-api-daishis-projects.vercel.app) | Dec 30, 2024 5:00am |


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/1108/builds/574493) or the icon next to each commit SHA.

Patch:
diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts
index a1467ec4..00a4819c 100644
--- a/packages/waku/src/router/create-pages.ts
+++ b/packages/waku/src/router/create-pages.ts
@@ -147,6 +147,15 @@ export type CreateLayout = <Path extends string>(
       },
 ) => void;
 
+type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
+
+export type CreateApi = <Path extends string>(params: {
+  path: Path;
+  mode: 'static' | 'dynamic';
+  method: Method;
+  handler: (req: Request) => Promise<Response>;
+}) => void;
+
 type RootItem = {
   render: 'static' | 'dynamic';
   component: FunctionComponent<{ children: ReactNode }>;
@@ -191,6 +200,7 @@ export const createPages = <
     createPage: CreatePage;
     createLayout: CreateLayout;
     createRoot: CreateRoot;
+    createApi: CreateApi;
   }) => Promise<AllPages>,
 ) => {
   let configured = false;
@@ -211,6 +221,15 @@ export const createPages = <
     string,
     [PathSpec, FunctionComponent<any>]
   >();
+  const apiPathMap = new Map<
+    string,
+    {
+      mode: 'static' | 'dynamic';
+      pathSpec: PathSpec;
+      method: Method;
+      handler: Parameters<CreateApi>[0]['handler'];
+    }
+  >();
   const staticComponentMap = new Map<string, FunctionComponent<any>>();
   let rootItem: RootItem | undefined = undefined;
   const noSsrSet = new WeakSet<PathSpec>();
@@ -223,6 +242,7 @@ export const createPages = <
     const allPaths = [
       ...dynamicPagePathMap.keys(),
       ...wildcardPagePathMap.keys(),
+      ...apiPathMap.keys(),
     ];
     for (const p of allPaths) {
       if (getPathMapping(parsePathWithSlug(p), path)) {
@@ -231,6 +251,15 @@ export const createPages = <
     }
   };
 
+  const pathExists = (path: string) => {
+    return (
+      staticPathMap.has(path) ||
+      dynamicPagePathMap.has(path) ||
+      wildcardPagePathMap.has(path) ||
+      apiPathMap.has(path)
+    );
+  };
+
   /** helper to get original static slug path */
   const getOriginalStaticPathSpec = (path: string) => {
     const staticPathSpec = staticPathMap.get(path);
@@ -256,6 +285,9 @@ export const createPages = <
     if (configured) {
       throw new Error('createPage no longer available');
     }
+    if (pathExists(page.path)) {
+      throw new Error(`Duplicated path: ${page.path}`);
+    }
 
     const pathSpec = parsePathWithSlug(page.path);
     if (page.unstable_disableSSR) {
@@ -320,14 +352,8 @@ export const createPages = <
         registerStaticComponent(id, WrappedComponent);
       }
     } else if (page.render === 'dynamic' && numWildcards === 0) {
-      if (dynamicPagePathMap.has(page.path)) {
-        throw new Error(`Duplicated dynamic path: ${page.path}`);
-      }
       dynamicPagePathMap.set(page.path, [pathSpec, page.component]);
     } else if (page.render === 'dynamic' && numWildcards === 1) {
-      if (wildcardPagePathMap.has(page.path)) {
-        throw new Error(`Duplicated dynamic path: ${page.path}`);
-      }
       wildcardPagePathMap.set(page.path, [pathSpec, page.component]);
     } else {
       throw new Error('Invalid page configuration');
@@ -353,6 +379,18 @@ export const createPages = <
     }
   };
 
+  const createApi: CreateApi = ({ path, mode, method, handler }) => {
+    if (configured) {
+      throw new Error('createApi no longer available');
+    }
+    if (apiPathMap.has(path)) {
+      throw new Error(`Duplicated api path: ${path}`);
+    }
+
+    const pathSpec = parsePathWithSlug(path);
+    apiPathMap.set(path, { mode, pathSpec, method, handler });
+  };
+
   const createRoot: CreateRoot = (root) => {
     if (configured) {
       throw new Error('createRoot no longer available');
@@ -370,7 +408,7 @@ export const createPages = <
   let ready: Promise<AllPages | void> | undefined;
   const configure = async () => {
     if (!configured && !ready) {
-      ready = fn({ createPage, createLayout, createRoot });
+      ready = fn({ createPage, createLayout, createRoot, createApi });
       await ready;
       configured = true;
     }
@@ -568,6 +606,40 @@ export const createPages = <
         ),
       };
     },
+    getApiConfig: async () => {
+      await configure();
+
+      return Array.from(apiPathMap.values()).map(({ pathSpec, mode }) => {
+        return {
+          path: pathSpec,
+          isStatic: mode === 'static',
+        };
+      });
+    },
+    handleApi: async (path, options) => {
+      await configure();
+      const routePath = getRoutePath(path);
+      if (!routePath) {
+        throw new Error('Route not found: ' + path);
+      }
+      const { handler } = apiPathMap.get(routePath)!;
+
+      const req = new Request(
+        new URL(
+          path,
+          // TODO consider if we should apply `Forwarded` header here
+          'http://localhost',
+        ),
+        options,
+      );
+      const res = await handler(req);
+
+      return {
+        ...(res.body ? { body: res.body } : {}),
+        headers: Object.fromEntries(res.headers.entries()),
+        status: res.status,
+      };
+    },
   });
 
   return definedRouter as typeof definedRouter & {


Test patch:
diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts
index aa46a7b7..1175752d 100644
--- a/packages/waku/tests/create-pages.test.ts
+++ b/packages/waku/tests/create-pages.test.ts
@@ -2,6 +2,7 @@ import { expect, vi, describe, it, beforeEach, assert } from 'vitest';
 import type { MockedFunction } from 'vitest';
 import { createPages } from '../src/router/create-pages.js';
 import type {
+  CreateApi,
   CreateLayout,
   CreatePage,
   HasSlugInPath,
@@ -264,6 +265,59 @@ describe('type tests', () => {
     });
   });
 
+  describe('createApi', () => {
+    it('static', () => {
+      const createApi: CreateApi = vi.fn();
+      // @ts-expect-error: mode is not valid
+      createApi({ path: '/', mode: 'foo', method: 'GET', handler: () => null });
+      createApi({
+        path: '/',
+        mode: 'static',
+        // @ts-expect-error: method is not valid
+        method: 'foo',
+        // @ts-expect-error: null is not valid
+        handler: () => null,
+      });
+      // @ts-expect-error: handler is not valid
+      createApi({ path: '/', mode: 'static', method: 'GET', handler: 123 });
+
+      // good
+      createApi({
+        path: '/',
+        mode: 'static',
+        method: 'GET',
+        handler: async () => {
+          return new Response('Hello World');
+        },
+      });
+    });
+    it('dynamic', () => {
+      const createApi: CreateApi = vi.fn();
+      // @ts-expect-error: mode & handler are not valid
+      createApi({ path: '/', mode: 'foo', method: 'GET', handler: () => null });
+      createApi({
+        path: '/foo',
+        mode: 'dynamic',
+        // @ts-expect-error: method is not valid
+        method: 'foo',
+        // @ts-expect-error: null is not valid
+        handler: () => null,
+      });
+      // @ts-expect-error: handler is not valid
+      createApi({ path: '/', mode: 'dynamic', method: 'GET', handler: 123 });
+
+      // good
+      createApi({
+        path: '/foo/[slug]',
+        mode: 'dynamic',
+        method: 'GET',
+        handler: async () => {
+          return new Response('Hello World');
+        },
+      });
+    });
+  });
+
   describe('createPages', () => {
     it('empty', () => {
       const mockedCreatePages: typeof createPages = vi.fn();
@@ -414,9 +468,13 @@ function injectedFunctions() {
   expect(defineRouterMock).toHaveBeenCalledTimes(1);
   assert(defineRouterMock.mock.calls[0]?.[0].getRouteConfig);
   assert(defineRouterMock.mock.calls[0]?.[0].handleRoute);
+  assert(defineRouterMock.mock.calls[0]?.[0].getApiConfig);
+  assert(defineRouterMock.mock.calls[0]?.[0].handleApi);
   return {
     getRouteConfig: defineRouterMock.mock.calls[0][0].getRouteConfig,
     handleRoute: defineRouterMock.mock.calls[0][0].handleRoute,
+    getApiConfig: defineRouterMock.mock.calls[0][0].getApiConfig,
+    handleApi: defineRouterMock.mock.calls[0][0].handleApi,
   };
 }
 
@@ -481,6 +539,73 @@ describe('createPages', () => {
     expect(Object.keys(route.elements)).toEqual(['root', 'page:/test']);
   });
 
+  it('creates a simple static api', async () => {
+    createPages(async ({ createApi }) => [
+      createApi({
+        path: '/test',
+        mode: 'static',
+        method: 'GET',
+        handler: async () => {
+          return new Response('Hello World');
+        },
+      }),
+    ]);
+    const { getApiConfig, handleApi } = injectedFunctions();
+    expect(await getApiConfig()).toEqual([
+      {
+        path: [{ type: 'literal', name: 'test' }],
+        isStatic: true,
+      },
+    ]);
+    const res = await handleApi('/test', {
+      method: 'GET',
+      headers: {},
+      body: null,
+    });
+    expect(res.headers).toEqual({
+      'content-type': 'text/plain;charset=UTF-8',
+    });
+    const respParsed = new Response(res.body);
+    const text = await respParsed.text();
+    expect(text).toEqual('Hello World');
+    expect(res.status).toEqual(200);
+  });
+
+  it('creates a simple dynamic api', async () => {
+    createPages(async ({ createApi }) => [
+      createApi({
+        path: '/test/[slug]',
+        mode: 'dynamic',
+        method: 'GET',
+        handler: async () => {
+          return new Response('Hello World');
+        },
+      }),
+    ]);
+    const { getApiConfig, handleApi } = injectedFunctions();
+    expect(await getApiConfig()).toEqual([
+      {
+        path: [
+          { type: 'literal', name: 'test' },
+          { type: 'group', name: 'slug' },
+        ],
+        isStatic: false,
+      },
+    ]);
+    const res = await handleApi('/test/foo', {
+      method: 'GET',
+      headers: {},
+      body: null,
+    });
+    expect(res.headers).toEqual({
+      'content-type': 'text/plain;charset=UTF-8',
+    });
+    const respParsed = new Response(res.body);
+    const text = await respParsed.text();
+    expect(text).toEqual('Hello World');
+    expect(res.status).toEqual(200);
+  });
+
   it('creates a simple static page with a layout', async () => {
     const TestPage = () => null;
     const TestLayout = ({ children }: PropsWithChildren) => children;
@@ -905,9 +1030,7 @@ describe('createPages', () => {
       }),
     ]);
     const { getRouteConfig } = injectedFunctions();
-    await expect(getRouteConfig).rejects.toThrowError(
-      'Duplicated dynamic path: /test',
-    );
+    await expect(getRouteConfig).rejects.toThrowError('Duplicated path: /test');
   });
 
   it('fails if duplicated static paths are registered', async () => {
@@ -924,9 +1047,7 @@ describe('createPages', () => {
       }),
     ]);
     const { getRouteConfig } = injectedFunctions();
-    await expect(getRouteConfig).rejects.toThrowError(
-      'Duplicated component for: test/page',
-    );
+    await expect(getRouteConfig).rejects.toThrowError('Duplicated path: /test');
   });
 
   it.fails(