diff --git a/e2e/create-pages.spec.ts b/e2e/create-pages.spec.ts index b6872a95f..212e9e769 100644 --- a/e2e/create-pages.spec.ts +++ b/e2e/create-pages.spec.ts @@ -116,5 +116,32 @@ for (const mode of ['DEV', 'PRD'] as const) { ).toBeVisible(); ({ port, stopApp } = await startApp(mode)); }); + + test('api hi.txt', async () => { + const res = await fetch(`http://localhost:${port}/api/hi.txt`); + expect(res.status).toBe(200); + expect(await res.text()).toBe('hello from a text file!'); + }); + + test('api hi', async () => { + const res = await fetch(`http://localhost:${port}/api/hi`); + expect(res.status).toBe(200); + expect(await res.text()).toBe('hello world!'); + }); + + test('api empty', async () => { + const res = await fetch(`http://localhost:${port}/api/empty`); + expect(res.status).toBe(200); + expect(await res.text()).toBe(''); + }); + + test('api hi with POST', async () => { + const res = await fetch(`http://localhost:${port}/api/hi`, { + method: 'POST', + body: 'from the test!', + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe('POST to hello world! from the test!'); + }); }); } diff --git a/e2e/fixtures/create-pages/private/hi.txt b/e2e/fixtures/create-pages/private/hi.txt new file mode 100644 index 000000000..0ecb6f596 --- /dev/null +++ b/e2e/fixtures/create-pages/private/hi.txt @@ -0,0 +1 @@ +hello from a text file! \ No newline at end of file diff --git a/e2e/fixtures/create-pages/src/entries.tsx b/e2e/fixtures/create-pages/src/entries.tsx index 0ade82b8d..37b156585 100644 --- a/e2e/fixtures/create-pages/src/entries.tsx +++ b/e2e/fixtures/create-pages/src/entries.tsx @@ -8,9 +8,10 @@ import NestedBazPage from './components/NestedBazPage.js'; import NestedLayout from './components/NestedLayout.js'; import { DeeplyNestedLayout } from './components/DeeplyNestedLayout.js'; import ErrorPage from './components/ErrorPage.js'; +import { readFile } from 'node:fs/promises'; const pages: ReturnType = createPages( - async ({ createPage, createLayout }) => [ + async ({ createPage, createLayout, createApi }) => [ createLayout({ render: 'static', path: '/', @@ -99,6 +100,44 @@ const pages: ReturnType = createPages( path: '/404', component: () =>

Not Found

, }), + + createApi({ + path: '/api/hi.txt', + mode: 'static', + method: 'GET', + handler: async () => { + const hiTxt = await readFile('./private/hi.txt'); + return new Response(hiTxt); + }, + }), + + createApi({ + path: '/api/hi', + mode: 'dynamic', + method: 'GET', + handler: async () => { + return new Response('hello world!'); + }, + }), + + createApi({ + path: '/api/hi', + mode: 'dynamic', + method: 'POST', + handler: async (req) => { + const body = await req.text(); + return new Response(`POST to hello world! ${body}`); + }, + }), + + createApi({ + path: '/api/empty', + mode: 'static', + method: 'GET', + handler: async () => { + return new Response(null); + }, + }), ], ); diff --git a/e2e/fixtures/create-pages/src/main.tsx b/e2e/fixtures/create-pages/src/main.tsx deleted file mode 100644 index bddb502d5..000000000 --- a/e2e/fixtures/create-pages/src/main.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot, hydrateRoot } from 'react-dom/client'; -import { Router } from 'waku/router/client'; - -const rootElement = ( - - - -); - -if ((globalThis as any).__WAKU_HYDRATE__) { - hydrateRoot(document, rootElement); -} else { - createRoot(document as any).render(rootElement); -} diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts index d6768ad86..90d72b3cb 100644 --- a/packages/waku/src/router/create-pages.ts +++ b/packages/waku/src/router/create-pages.ts @@ -150,12 +150,21 @@ export type CreateLayout = ( type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; -export type CreateApi = (params: { - path: Path; - mode: 'static' | 'dynamic'; - method: Method; - handler: (req: Request) => Promise; -}) => void; +export type CreateApi = ( + params: + | { + path: Path; + mode: 'static'; + method: 'GET'; + handler: (req: Request) => Promise; + } + | { + path: Path; + mode: 'dynamic'; + method: Method; + handler: (req: Request) => Promise; + }, +) => void; type RootItem = { render: 'static' | 'dynamic'; @@ -227,27 +236,26 @@ export const createPages = < [PathSpec, FunctionComponent] >(); const apiPathMap = new Map< - string, + string, // `${method} ${path}` { mode: 'static' | 'dynamic'; pathSpec: PathSpec; - method: Method; handler: Parameters[0]['handler']; } >(); + const staticApiPaths = new Set(); const staticComponentMap = new Map>(); let rootItem: RootItem | undefined = undefined; const noSsrSet = new WeakSet(); /** helper to find dynamic path when slugs are used */ - const getRoutePath: (path: string) => string | undefined = (path) => { + const getPageRoutePath: (path: string) => string | undefined = (path) => { if (staticComponentMap.has(joinPath(path, 'page').slice(1))) { return path; } const allPaths = [ ...dynamicPagePathMap.keys(), ...wildcardPagePathMap.keys(), - ...apiPathMap.keys(), ]; for (const p of allPaths) { if (getPathMapping(parsePathWithSlug(p), path)) { @@ -256,12 +264,29 @@ export const createPages = < } }; - const pathExists = (path: string) => { + const getApiRoutePath: ( + path: string, + method: string, + ) => string | undefined = (path, method) => { + for (const pathKey of apiPathMap.keys()) { + const [m, p] = pathKey.split(' '); + if (m === method && getPathMapping(parsePathWithSlug(p!), path)) { + return p; + } + } + }; + + const pagePathExists = (path: string) => { + for (const pathKey of apiPathMap.keys()) { + const [_m, p] = pathKey.split(' '); + if (p === path) { + return true; + } + } return ( staticPathMap.has(path) || dynamicPagePathMap.has(path) || - wildcardPagePathMap.has(path) || - apiPathMap.has(path) + wildcardPagePathMap.has(path) ); }; @@ -290,7 +315,7 @@ export const createPages = < if (configured) { throw new Error('createPage no longer available'); } - if (pathExists(page.path)) { + if (pagePathExists(page.path)) { throw new Error(`Duplicated path: ${page.path}`); } @@ -388,12 +413,16 @@ export const createPages = < if (configured) { throw new Error('createApi no longer available'); } - if (apiPathMap.has(path)) { - throw new Error(`Duplicated api path: ${path}`); + if (apiPathMap.has(`${method} ${path}`)) { + throw new Error(`Duplicated api path+method: ${path} ${method}`); + } else if (mode === 'static' && staticApiPaths.has(path)) { + throw new Error('Static API Routes cannot share paths: ' + path); + } + if (mode === 'static') { + staticApiPaths.add(path); } - const pathSpec = parsePathWithSlug(path); - apiPathMap.set(path, { mode, pathSpec, method, handler }); + apiPathMap.set(`${method} ${path}`, { mode, pathSpec, handler }); }; const createRoot: CreateRoot = (root) => { @@ -530,7 +559,7 @@ export const createPages = < await configure(); // path without slugs - const routePath = getRoutePath(path); + const routePath = getPageRoutePath(path); if (!routePath) { throw new Error('Route not found: ' + path); } @@ -607,11 +636,11 @@ export const createPages = < }, handleApi: async (path, options) => { await configure(); - const routePath = getRoutePath(path); + const routePath = getApiRoutePath(path, options.method); if (!routePath) { - throw new Error('Route not found: ' + path); + throw new Error('API Route not found: ' + path); } - const { handler } = apiPathMap.get(routePath)!; + const { handler } = apiPathMap.get(`${options.method} ${routePath}`)!; const req = new Request( new URL(