Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fs-router): api file support #1117

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
3 changes: 2 additions & 1 deletion examples/11_fs-router/src/entries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
7 changes: 7 additions & 0 deletions examples/11_fs-router/src/pages/api/hello.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
6 changes: 4 additions & 2 deletions packages/waku/src/router/create-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import type {
import { Children, Slot } from '../minimal/client.js';
import { ErrorBoundary } from '../router/client.js';

// 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, '-');

Expand Down Expand Up @@ -148,8 +152,6 @@ export type CreateLayout = <Path extends string>(
},
) => void;

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

type ApiHandler = (req: Request) => Promise<Response>;

export type CreateApi = <Path extends string>(
Expand Down
199 changes: 121 additions & 78 deletions packages/waku/src/router/fs-router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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 } from '../lib/constants.js';

Expand All @@ -11,88 +12,130 @@ 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') {
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,
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;
},
);
}
Loading