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(create-pages): api handling #1108

Merged
merged 14 commits into from
Jan 1, 2025
87 changes: 80 additions & 7 deletions packages/waku/src/router/create-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ export type CreateLayout = <Path extends string>(
},
) => void;

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind, but any reason for this choice?

Copy link
Contributor Author

@tylersayshi tylersayshi Jan 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping just to make this typesafe, but happy to add more methods or remove it if it gets in the way


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 }>;
Expand Down Expand Up @@ -191,6 +200,7 @@ export const createPages = <
createPage: CreatePage;
createLayout: CreateLayout;
createRoot: CreateRoot;
createApi: CreateApi;
}) => Promise<AllPages>,
) => {
let configured = false;
Expand All @@ -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>();
Expand All @@ -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)) {
Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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;
}
Expand Down Expand Up @@ -568,6 +606,41 @@ 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,
options.headers['host']
? `https://${options.headers['host']}`
: 'http://localhost',
dai-shi marked this conversation as resolved.
Show resolved Hide resolved
),
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 & {
Expand Down
131 changes: 125 additions & 6 deletions packages/waku/tests/create-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { MockedFunction } from 'vitest';
import { createPages } from '../src/router/create-pages.js';
import type {
CreateApi,
CreateLayout,
CreatePage,
HasSlugInPath,
Expand Down Expand Up @@ -264,6 +265,59 @@
});
});

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();
Expand Down Expand Up @@ -414,9 +468,13 @@
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,
};
}

Expand Down Expand Up @@ -481,6 +539,71 @@
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', {

Check failure on line 560 in packages/waku/tests/create-pages.test.ts

View workflow job for this annotation

GitHub Actions / Test on (Node 18.17.0)

Argument of type '{ method: string; headers: {}; }' is not assignable to parameter of type '{ body: ReadableStream<any> | null; headers: Readonly<Record<string, string>>; method: string; }'.

Check failure on line 560 in packages/waku/tests/create-pages.test.ts

View workflow job for this annotation

GitHub Actions / Test on (Node 22.7.0)

Argument of type '{ method: string; headers: {}; }' is not assignable to parameter of type '{ body: ReadableStream<any> | null; headers: Readonly<Record<string, string>>; method: string; }'.
method: 'GET',
headers: {},
});
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', {

Check failure on line 594 in packages/waku/tests/create-pages.test.ts

View workflow job for this annotation

GitHub Actions / Test on (Node 18.17.0)

Argument of type '{ method: string; headers: {}; }' is not assignable to parameter of type '{ body: ReadableStream<any> | null; headers: Readonly<Record<string, string>>; method: string; }'.

Check failure on line 594 in packages/waku/tests/create-pages.test.ts

View workflow job for this annotation

GitHub Actions / Test on (Node 22.7.0)

Argument of type '{ method: string; headers: {}; }' is not assignable to parameter of type '{ body: ReadableStream<any> | null; headers: Readonly<Record<string, string>>; method: string; }'.
method: 'GET',
headers: {},
});
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;
Expand Down Expand Up @@ -905,9 +1028,7 @@
}),
]);
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 () => {
Expand All @@ -924,9 +1045,7 @@
}),
]);
const { getRouteConfig } = injectedFunctions();
await expect(getRouteConfig).rejects.toThrowError(
'Duplicated component for: test/page',
);
await expect(getRouteConfig).rejects.toThrowError('Duplicated path: /test');
});

it.fails(
Expand Down
Loading