From 460c3ef2aeb58865f833bc69989721495894d254 Mon Sep 17 00:00:00 2001 From: Tyler <26290074+thegitduck@users.noreply.github.com> Date: Thu, 2 Jan 2025 12:43:48 -0500 Subject: [PATCH 1/5] feat(fs-router): api file support --- packages/waku/src/router/fs-router.ts | 167 ++++++++++++++------------ 1 file changed, 90 insertions(+), 77 deletions(-) diff --git a/packages/waku/src/router/fs-router.ts b/packages/waku/src/router/fs-router.ts index fd936dda4..0029bebf2 100644 --- a/packages/waku/src/router/fs-router.ts +++ b/packages/waku/src/router/fs-router.ts @@ -11,88 +11,101 @@ export function fsRouter( pages: string, ) { const platformObject = unstable_getPlatformObject(); - return createPages(async ({ createPage, createLayout, createRoot }) => { - let files: string[] | undefined = platformObject.buildData - ?.fsRouterFiles as string[] | undefined; - if (!files) { - // dev and build only - const [{ readdir }, { join, dirname, extname, sep }, { fileURLToPath }] = - await Promise.all([ + return createPages( + async ({ createPage, createLayout, createRoot, createApi }) => { + let files: string[] | undefined = platformObject.buildData + ?.fsRouterFiles as string[] | undefined; + if (!files) { + // dev and build only + const [ + { readdir }, + { join, dirname, extname, sep }, + { fileURLToPath }, + ] = await Promise.all([ import(/* @vite-ignore */ DO_NOT_BUNDLE + 'node:fs/promises'), import(/* @vite-ignore */ DO_NOT_BUNDLE + 'node:path'), import(/* @vite-ignore */ DO_NOT_BUNDLE + 'node:url'), ]); - const pagesDir = join(dirname(fileURLToPath(importMetaUrl)), pages); - files = await readdir(pagesDir, { - encoding: 'utf8', - recursive: true, - }); - files = files!.flatMap((file) => { - const myExt = extname(file); - const myExtIndex = EXTENSIONS.indexOf(myExt); - if (myExtIndex === -1) { - return []; - } - // HACK: replace "_slug_" to "[slug]" for build - file = file.replace(/(?<=^|\/|\\)_([^/]+)_(?=\/|\\|\.)/g, '[$1]'); - // For Windows - file = sep === '/' ? file : file.replace(/\\/g, '/'); - // HACK: resolve different extensions for build - const exts = [myExt, ...EXTENSIONS]; - exts.splice(myExtIndex + 1, 1); // remove the second myExt - for (const ext of exts) { - const f = file.slice(0, -myExt.length) + ext; - if (loadPage(f)) { - return [f]; - } - } - throw new Error('Failed to resolve ' + file); - }); - } - // build only - skip in dev - if (platformObject.buildOptions?.unstable_phase) { - platformObject.buildData ||= {}; - platformObject.buildData.fsRouterFiles = files; - } - for (const file of files) { - const mod = await loadPage(file); - const config = await mod.getConfig?.(); - const pathItems = file - .replace(/\.\w+$/, '') - .split('/') - .filter(Boolean); - const path = - '/' + - (['_layout', 'index', '_root'].includes(pathItems.at(-1)!) - ? pathItems.slice(0, -1) - : pathItems - ).join('/'); - if (pathItems.at(-1) === '[path]') { - throw new Error( - 'Page file cannot be named [path]. This will conflict with the path prop of the page component.', - ); - } else if (pathItems.at(-1) === '_layout') { - createLayout({ - path, - component: mod.default, - render: 'static', - ...config, + const pagesDir = join(dirname(fileURLToPath(importMetaUrl)), pages); + files = await readdir(pagesDir, { + encoding: 'utf8', + recursive: true, }); - } else if (pathItems.at(-1) === '_root') { - createRoot({ - component: mod.default, - render: 'static', - ...config, - }); - } else { - createPage({ - path, - component: mod.default, - render: 'dynamic', - ...config, + files = files!.flatMap((file) => { + const myExt = extname(file); + const myExtIndex = EXTENSIONS.indexOf(myExt); + if (myExtIndex === -1) { + return []; + } + // HACK: replace "_slug_" to "[slug]" for build + file = file.replace(/(?<=^|\/|\\)_([^/]+)_(?=\/|\\|\.)/g, '[$1]'); + // For Windows + file = sep === '/' ? file : file.replace(/\\/g, '/'); + // HACK: resolve different extensions for build + const exts = [myExt, ...EXTENSIONS]; + exts.splice(myExtIndex + 1, 1); // remove the second myExt + for (const ext of exts) { + const f = file.slice(0, -myExt.length) + ext; + if (loadPage(f)) { + return [f]; + } + } + throw new Error('Failed to resolve ' + file); }); } - } - return []; // TODO this type support for fsRouter pages - }); + // build only - skip in dev + if (platformObject.buildOptions?.unstable_phase) { + platformObject.buildData ||= {}; + platformObject.buildData.fsRouterFiles = files; + } + for (const file of files) { + const mod = await loadPage(file); + const config = await mod.getConfig?.(); + const pathItems = file + .replace(/\.\w+$/, '') + .split('/') + .filter(Boolean); + const path = + '/' + + (['_layout', 'index', '_root'].includes(pathItems.at(-1)!) + ? pathItems.slice(0, -1) + : pathItems + ).join('/'); + if (pathItems.at(-1) === '[path]') { + throw new Error( + 'Page file cannot be named [path]. This will conflict with the path prop of the page component.', + ); + } else if (pathItems.at(0) === 'api') { + createApi({ + path: pathItems.slice(1).join('/'), + mode: config.mode ?? 'dynamic', + method: config.method ?? 'GET', + handler: mod.default, + }); + } else if (pathItems.at(-1) === '_layout') { + createLayout({ + path, + component: mod.default, + render: 'static', + ...config, + }); + } else if (pathItems.at(-1) === '_root') { + createRoot({ + component: mod.default, + render: 'static', + ...config, + }); + } else { + createPage({ + path, + component: mod.default, + render: 'dynamic', + ...config, + }); + } + } + // HACK: to satisfy the return type, unused at runtime + return null as never; + }, + ); } From d1091c4ee59bdedddf45cf81ced6e3c146905a6d Mon Sep 17 00:00:00 2001 From: Tyler <26290074+thegitduck@users.noreply.github.com> Date: Thu, 2 Jan 2025 12:46:30 -0500 Subject: [PATCH 2/5] spread config object --- packages/waku/src/router/fs-router.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/waku/src/router/fs-router.ts b/packages/waku/src/router/fs-router.ts index 0029bebf2..9c2a91ef6 100644 --- a/packages/waku/src/router/fs-router.ts +++ b/packages/waku/src/router/fs-router.ts @@ -78,9 +78,10 @@ export function fsRouter( } else if (pathItems.at(0) === 'api') { createApi({ path: pathItems.slice(1).join('/'), - mode: config.mode ?? 'dynamic', - method: config.method ?? 'GET', + mode: 'dynamic', + method: 'GET', handler: mod.default, + ...config, }); } else if (pathItems.at(-1) === '_layout') { createLayout({ From cca3b8f7c8e27bffd1fb0fdd9f46c49791ed5ac5 Mon Sep 17 00:00:00 2001 From: Tyler <26290074+thegitduck@users.noreply.github.com> Date: Sun, 12 Jan 2025 21:27:00 -0800 Subject: [PATCH 3/5] refactor(create-pages): dynamic handlers and static handler --- e2e/fixtures/create-pages/src/entries.tsx | 21 ++++----- examples/21_create-pages/src/entries.tsx | 21 ++++----- packages/waku/src/router/create-pages.ts | 52 ++++++++++++++--------- packages/waku/tests/create-pages.test.ts | 29 +++++++------ 4 files changed, 64 insertions(+), 59 deletions(-) diff --git a/e2e/fixtures/create-pages/src/entries.tsx b/e2e/fixtures/create-pages/src/entries.tsx index 37b156585..4082e4391 100644 --- a/e2e/fixtures/create-pages/src/entries.tsx +++ b/e2e/fixtures/create-pages/src/entries.tsx @@ -114,19 +114,14 @@ const pages: ReturnType = createPages( 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}`); + handlers: { + GET: async () => { + return new Response('hello world!'); + }, + POST: async (req) => { + const body = await req.text(); + return new Response(`POST to hello world! ${body}`); + }, }, }), diff --git a/examples/21_create-pages/src/entries.tsx b/examples/21_create-pages/src/entries.tsx index b04b905d9..ee3994e80 100644 --- a/examples/21_create-pages/src/entries.tsx +++ b/examples/21_create-pages/src/entries.tsx @@ -134,19 +134,14 @@ const pages = createPages( createApi({ path: '/api/hi', mode: 'dynamic', - method: 'GET', - handler: async () => { - return new Response('hello world!'); - }, - }), - - createApi({ - path: '/api/hi', - method: 'POST', - mode: 'dynamic', - handler: async (req) => { - const name = await req.text(); - return new Response(`hello ${name}!`); + handlers: { + GET: async () => { + return new Response('hello world!'); + }, + POST: async (req) => { + const name = await req.text(); + return new Response(`hello ${name}!`); + }, }, }), diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts index 90d72b3cb..2fe9ed419 100644 --- a/packages/waku/src/router/create-pages.ts +++ b/packages/waku/src/router/create-pages.ts @@ -150,19 +150,20 @@ export type CreateLayout = ( type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; +type ApiHandler = (req: Request) => Promise; + export type CreateApi = ( params: | { - path: Path; mode: 'static'; + path: Path; method: 'GET'; - handler: (req: Request) => Promise; + handler: ApiHandler; } | { - path: Path; mode: 'dynamic'; - method: Method; - handler: (req: Request) => Promise; + path: Path; + handlers: Partial>; }, ) => void; @@ -240,10 +241,9 @@ export const createPages = < { mode: 'static' | 'dynamic'; pathSpec: PathSpec; - handler: Parameters[0]['handler']; + handlers: Partial>; } >(); - const staticApiPaths = new Set(); const staticComponentMap = new Map>(); let rootItem: RootItem | undefined = undefined; const noSsrSet = new WeakSet(); @@ -268,9 +268,8 @@ export const createPages = < 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)) { + for (const [p, v] of apiPathMap.entries()) { + if (method in v.handlers && getPathMapping(parsePathWithSlug(p!), path)) { return p; } } @@ -409,20 +408,27 @@ export const createPages = < } }; - const createApi: CreateApi = ({ path, mode, method, handler }) => { + const createApi: CreateApi = (options) => { if (configured) { throw new Error('createApi no longer available'); } - 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 (apiPathMap.has(options.path)) { + throw new Error(`Duplicated api path: ${options.path}`); } - if (mode === 'static') { - staticApiPaths.add(path); + const pathSpec = parsePathWithSlug(options.path); + if (options.mode === 'static') { + apiPathMap.set(options.path, { + mode: 'static', + pathSpec, + handlers: { GET: options.handler }, + }); + } else { + apiPathMap.set(options.path, { + mode: 'dynamic', + pathSpec, + handlers: options.handlers, + }); } - const pathSpec = parsePathWithSlug(path); - apiPathMap.set(`${method} ${path}`, { mode, pathSpec, handler }); }; const createRoot: CreateRoot = (root) => { @@ -640,7 +646,7 @@ export const createPages = < if (!routePath) { throw new Error('API Route not found: ' + path); } - const { handler } = apiPathMap.get(`${options.method} ${routePath}`)!; + const { handlers } = apiPathMap.get(routePath)!; const req = new Request( new URL( @@ -650,6 +656,12 @@ export const createPages = < ), options, ); + const handler = handlers[options.method as Method]; + if (!handler) { + throw new Error( + 'API method not found: ' + options.method + 'for path: ' + path, + ); + } const res = await handler(req); return { diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts index bcfc43c70..529d458ca 100644 --- a/packages/waku/tests/create-pages.test.ts +++ b/packages/waku/tests/create-pages.test.ts @@ -298,10 +298,10 @@ describe('type tests', () => { createApi({ path: '/foo', mode: 'dynamic', - // @ts-expect-error: method is not valid - method: 'foo', - // @ts-expect-error: null is not valid - handler: () => null, + handlers: { + // @ts-expect-error: null is not valid + GET: () => null, + }, }); // @ts-expect-error: handler is not valid createApi({ path: '/', mode: 'dynamic', method: 'GET', handler: 123 }); @@ -310,9 +310,10 @@ describe('type tests', () => { createApi({ path: '/foo/[slug]', mode: 'dynamic', - method: 'GET', - handler: async () => { - return new Response('Hello World'); + handlers: { + POST: async (req) => { + return new Response('Hello World ' + new URL(req.url).pathname); + }, }, }); }); @@ -578,9 +579,10 @@ describe('createPages pages and layouts', () => { createApi({ path: '/test/[slug]', mode: 'dynamic', - method: 'GET', - handler: async () => { - return new Response('Hello World'); + handlers: { + GET: async () => { + return new Response('Hello World'); + }, }, }), ]); @@ -1340,9 +1342,10 @@ describe('createPages api', () => { createApi({ path: '/test/[slug]', mode: 'dynamic', - method: 'GET', - handler: async (req) => { - return new Response('Hello World ' + req.url.split('/').at(-1)!); + handlers: { + GET: async (req) => { + return new Response('Hello World ' + req.url.split('/').at(-1)!); + }, }, }), ]); From 6a613dbcd0d2943969d09381795e41a45e500008 Mon Sep 17 00:00:00 2001 From: Tyler <26290074+thegitduck@users.noreply.github.com> Date: Sun, 12 Jan 2025 21:57:09 -0800 Subject: [PATCH 4/5] ready fs-router for createApi changes --- examples/11_fs-router/src/entries.tsx | 3 +- examples/11_fs-router/src/pages/api/hello.ts | 7 +++ packages/waku/src/lib/constants.ts | 2 + packages/waku/src/lib/types.ts | 3 ++ packages/waku/src/router/create-pages.ts | 3 +- packages/waku/src/router/fs-router.ts | 45 ++++++++++++++++---- 6 files changed, 52 insertions(+), 11 deletions(-) create mode 100644 examples/11_fs-router/src/pages/api/hello.ts diff --git a/examples/11_fs-router/src/entries.tsx b/examples/11_fs-router/src/entries.tsx index 8c749cfa3..c4d32be8e 100644 --- a/examples/11_fs-router/src/entries.tsx +++ b/examples/11_fs-router/src/entries.tsx @@ -8,6 +8,7 @@ declare global { export default fsRouter( import.meta.url, - (file: string) => import.meta.glob('./pages/**/*.tsx')[`./pages/${file}`]?.(), + (file: string) => + import.meta.glob('./pages/**/*.{tsx,ts}')[`./pages/${file}`]?.(), 'pages', ); diff --git a/examples/11_fs-router/src/pages/api/hello.ts b/examples/11_fs-router/src/pages/api/hello.ts new file mode 100644 index 000000000..0e9b75459 --- /dev/null +++ b/examples/11_fs-router/src/pages/api/hello.ts @@ -0,0 +1,7 @@ +export async function GET() { + return new Response('Hello from API!'); +} + +export async function POST(req: Request) { + return new Response(`Hello from API! ${new URL(req.url).pathname}`); +} diff --git a/packages/waku/src/lib/constants.ts b/packages/waku/src/lib/constants.ts index 69eeeb837..191083112 100644 --- a/packages/waku/src/lib/constants.ts +++ b/packages/waku/src/lib/constants.ts @@ -1,3 +1,5 @@ export const EXTENSIONS = ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs']; export const SRC_MAIN = 'main'; export const SRC_ENTRIES = 'entries'; +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods partial +export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const; diff --git a/packages/waku/src/lib/types.ts b/packages/waku/src/lib/types.ts index 3c1f6de0b..c68a04635 100644 --- a/packages/waku/src/lib/types.ts +++ b/packages/waku/src/lib/types.ts @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import type { Config } from '../config.js'; import type { PathSpec } from '../lib/utils/path.js'; +import type { METHODS } from './constants.js'; type Elements = Record; @@ -100,3 +101,5 @@ export type HandlerRes = { headers?: Record; status?: number; }; + +export type Method = (typeof METHODS)[number]; diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts index 2fe9ed419..3b2be6670 100644 --- a/packages/waku/src/router/create-pages.ts +++ b/packages/waku/src/router/create-pages.ts @@ -17,6 +17,7 @@ import type { } from './create-pages-utils/inferred-path-types.js'; import { Children, Slot } from '../minimal/client.js'; import { ErrorBoundary } from '../router/client.js'; +import type { Method } from 'waku/lib/types'; const sanitizeSlug = (slug: string) => slug.replace(/\./g, '').replace(/ /g, '-'); @@ -148,8 +149,6 @@ export type CreateLayout = ( }, ) => void; -type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; - type ApiHandler = (req: Request) => Promise; export type CreateApi = ( diff --git a/packages/waku/src/router/fs-router.ts b/packages/waku/src/router/fs-router.ts index 9c2a91ef6..19e63999a 100644 --- a/packages/waku/src/router/fs-router.ts +++ b/packages/waku/src/router/fs-router.ts @@ -1,7 +1,8 @@ import { unstable_getPlatformObject } from '../server.js'; import { createPages } from './create-pages.js'; -import { EXTENSIONS } from '../lib/constants.js'; +import { EXTENSIONS, METHODS } from '../lib/constants.js'; +import type { Method } from '../lib/types.js'; const DO_NOT_BUNDLE = ''; @@ -76,13 +77,41 @@ export function fsRouter( 'Page file cannot be named [path]. This will conflict with the path prop of the page component.', ); } else if (pathItems.at(0) === 'api') { - createApi({ - path: pathItems.slice(1).join('/'), - mode: 'dynamic', - method: 'GET', - handler: mod.default, - ...config, - }); + if (config?.render === 'static') { + if (Object.keys(mod).length !== 2 || !mod.GET) { + console.warn( + `API ${path} is invalid. For static API routes, only a single GET handler is supported.`, + ); + } + createApi({ + path: pathItems.join('/'), + mode: 'static', + method: 'GET', + handler: mod.GET, + }); + } else { + const validMethods = new Set(METHODS); + const handlers = Object.fromEntries( + Object.entries(mod).filter(([exportName]) => { + const isValidExport = + exportName === 'getConfig' || + validMethods.has(exportName as Method); + if (!isValidExport) { + console.warn( + `API ${path} has an invalid export: ${exportName}. Valid exports are: ${METHODS.join( + ', ', + )}`, + ); + } + return isValidExport && exportName !== 'getConfig'; + }), + ); + createApi({ + path: pathItems.join('/'), + mode: 'dynamic', + handlers, + }); + } } else if (pathItems.at(-1) === '_layout') { createLayout({ path, From 2b9fcf57097ec5bf7c3c95cc1e26ef39ded47909 Mon Sep 17 00:00:00 2001 From: Tyler <26290074+thegitduck@users.noreply.github.com> Date: Mon, 13 Jan 2025 10:17:04 -0800 Subject: [PATCH 5/5] move method --- packages/waku/src/lib/constants.ts | 2 -- packages/waku/src/lib/types.ts | 3 --- packages/waku/src/router/create-pages.ts | 5 ++++- packages/waku/src/router/fs-router.ts | 6 +++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/waku/src/lib/constants.ts b/packages/waku/src/lib/constants.ts index 191083112..69eeeb837 100644 --- a/packages/waku/src/lib/constants.ts +++ b/packages/waku/src/lib/constants.ts @@ -1,5 +1,3 @@ export const EXTENSIONS = ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs']; export const SRC_MAIN = 'main'; export const SRC_ENTRIES = 'entries'; -// https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods partial -export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const; diff --git a/packages/waku/src/lib/types.ts b/packages/waku/src/lib/types.ts index c68a04635..3c1f6de0b 100644 --- a/packages/waku/src/lib/types.ts +++ b/packages/waku/src/lib/types.ts @@ -2,7 +2,6 @@ import type { ReactNode } from 'react'; import type { Config } from '../config.js'; import type { PathSpec } from '../lib/utils/path.js'; -import type { METHODS } from './constants.js'; type Elements = Record; @@ -101,5 +100,3 @@ export type HandlerRes = { headers?: Record; status?: number; }; - -export type Method = (typeof METHODS)[number]; diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts index 3b2be6670..8bfdb0994 100644 --- a/packages/waku/src/router/create-pages.ts +++ b/packages/waku/src/router/create-pages.ts @@ -17,7 +17,10 @@ import type { } from './create-pages-utils/inferred-path-types.js'; import { Children, Slot } from '../minimal/client.js'; import { ErrorBoundary } from '../router/client.js'; -import type { Method } from 'waku/lib/types'; + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods partial +export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const; +export type Method = (typeof METHODS)[number]; const sanitizeSlug = (slug: string) => slug.replace(/\./g, '').replace(/ /g, '-'); diff --git a/packages/waku/src/router/fs-router.ts b/packages/waku/src/router/fs-router.ts index 19e63999a..92ac1f2b4 100644 --- a/packages/waku/src/router/fs-router.ts +++ b/packages/waku/src/router/fs-router.ts @@ -1,8 +1,8 @@ import { unstable_getPlatformObject } from '../server.js'; -import { createPages } from './create-pages.js'; +import { createPages, METHODS } from './create-pages.js'; +import type { Method } from './create-pages.js'; -import { EXTENSIONS, METHODS } from '../lib/constants.js'; -import type { Method } from '../lib/types.js'; +import { EXTENSIONS } from '../lib/constants.js'; const DO_NOT_BUNDLE = '';