diff --git a/examples/07_router/src/routes/foo/page.tsx b/examples/07_router/src/components/FooPage.tsx
similarity index 63%
rename from examples/07_router/src/routes/foo/page.tsx
rename to examples/07_router/src/components/FooPage.tsx
index 061f477c5..458f6f961 100644
--- a/examples/07_router/src/routes/foo/page.tsx
+++ b/examples/07_router/src/components/FooPage.tsx
@@ -1,4 +1,4 @@
-import { Counter } from '../../components/Counter.js';
+import { Counter } from './Counter.js';
const Foo = () => (
diff --git a/examples/07_router/src/routes/layout.tsx b/examples/07_router/src/components/HomeLayout.tsx
similarity index 84%
rename from examples/07_router/src/routes/layout.tsx
rename to examples/07_router/src/components/HomeLayout.tsx
index 979b5673e..ee5fdd0db 100644
--- a/examples/07_router/src/routes/layout.tsx
+++ b/examples/07_router/src/components/HomeLayout.tsx
@@ -41,6 +41,15 @@ const HomeLayout = ({ children }: { children: ReactNode }) => (
Bar
+
+ Baz
+
+
+ Nested / Foo
+
+
+ Nested / Bar
+
Nested / Baz
diff --git a/examples/07_router/src/routes/page.tsx b/examples/07_router/src/components/HomePage.tsx
similarity index 100%
rename from examples/07_router/src/routes/page.tsx
rename to examples/07_router/src/components/HomePage.tsx
diff --git a/examples/07_router/src/routes/nested/baz/page.tsx b/examples/07_router/src/components/NestedBazPage.tsx
similarity index 79%
rename from examples/07_router/src/routes/nested/baz/page.tsx
rename to examples/07_router/src/components/NestedBazPage.tsx
index f8b6ae245..ed7488f72 100644
--- a/examples/07_router/src/routes/nested/baz/page.tsx
+++ b/examples/07_router/src/components/NestedBazPage.tsx
@@ -1,5 +1,6 @@
const Baz = () => (
+
Nested
Baz
);
diff --git a/examples/07_router/src/routes/nested/qux/page.tsx b/examples/07_router/src/components/NestedQuxPage.tsx
similarity index 79%
rename from examples/07_router/src/routes/nested/qux/page.tsx
rename to examples/07_router/src/components/NestedQuxPage.tsx
index a91f5ecda..10da1dacc 100644
--- a/examples/07_router/src/routes/nested/qux/page.tsx
+++ b/examples/07_router/src/components/NestedQuxPage.tsx
@@ -1,5 +1,6 @@
const Qux = () => (
+
Nested
Qux
);
diff --git a/examples/07_router/src/entries.tsx b/examples/07_router/src/entries.tsx
index ef88ac541..6f9a89037 100644
--- a/examples/07_router/src/entries.tsx
+++ b/examples/07_router/src/entries.tsx
@@ -1,32 +1,84 @@
-import { defineRouter } from 'waku/router/server';
-
-const STATIC_PATHS = ['/', '/foo', '/bar', '/nested/baz', '/nested/qux'];
-
-export default defineRouter(
- // existsPath
- async (path: string) => (STATIC_PATHS.includes(path) ? 'static' : null),
- // getComponent (id is "**/layout" or "**/page")
- async (id, unstable_setShouldSkip) => {
- unstable_setShouldSkip({}); // always skip if possible
- switch (id) {
- case 'layout':
- return import('./routes/layout.js');
- case 'page':
- return import('./routes/page.js');
- case 'foo/page':
- return import('./routes/foo/page.js');
- case 'bar/page':
- return import('./routes/bar/page.js');
- case 'nested/layout':
- return import('./routes/nested/layout.js');
- case 'nested/baz/page':
- return import('./routes/nested/baz/page.js');
- case 'nested/qux/page':
- return import('./routes/nested/qux/page.js');
- default:
- return null;
- }
- },
- // getPathsForBuild
- async () => STATIC_PATHS,
-);
+import { lazy } from 'react';
+import { createPages } from 'waku/router/server';
+
+// The use of `lazy` is optional and you can use import statements too.
+const HomeLayout = lazy(() => import('./components/HomeLayout.js'));
+const HomePage = lazy(() => import('./components/HomePage.js'));
+const FooPage = lazy(() => import('./components/FooPage.js'));
+const BarPage = lazy(() => import('./components/BarPage.js'));
+const NestedBazPage = lazy(() => import('./components/NestedBazPage.js'));
+const NestedQuxPage = lazy(() => import('./components/NestedQuxPage.js'));
+
+export default createPages(async ({ createPage, createLayout }) => {
+ createLayout({
+ render: 'static',
+ path: '/',
+ component: HomeLayout,
+ });
+
+ createPage({
+ render: 'static',
+ path: '/',
+ component: HomePage,
+ });
+
+ createPage({
+ render: 'static',
+ path: '/foo',
+ component: FooPage,
+ });
+
+ createPage({
+ render: 'static',
+ path: '/bar',
+ component: BarPage,
+ });
+
+ createPage({
+ render: 'dynamic',
+ path: '/baz',
+ // Inline component is also possible.
+ component: () =>
Baz
,
+ });
+
+ createPage({
+ render: 'static',
+ path: '/nested/baz',
+ component: NestedBazPage,
+ });
+
+ createPage({
+ render: 'static',
+ path: '/nested/qux',
+ component: NestedQuxPage,
+ });
+
+ createPage({
+ render: 'static',
+ path: '/nested/[id]',
+ staticPaths: ['foo', 'bar'],
+ component: ({ id }: { id: string }) => (
+ <>
+
Nested
+
Static: {id}
+ >
+ ),
+ });
+
+ createPage({
+ render: 'dynamic',
+ path: '/nested/[id]',
+ component: ({ id }: { id: string }) => (
+ <>
+
Nested
+
Dynamic: {id}
+ >
+ ),
+ });
+
+ createPage({
+ render: 'dynamic',
+ path: '/any/[...all]', // `/[...all]` is impossible.
+ component: ({ all }: { all: string }) =>
Catch-all: {all}
,
+ });
+});
diff --git a/examples/07_router/src/routes/nested/layout.tsx b/examples/07_router/src/routes/nested/layout.tsx
deleted file mode 100644
index 8a10d1751..000000000
--- a/examples/07_router/src/routes/nested/layout.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { ReactNode } from 'react';
-
-import { Counter } from '../../components/Counter.js';
-
-const Nested = ({ children }: { children: ReactNode }) => (
-
-
Nested
-
- {children}
-
-);
-
-export default Nested;
diff --git a/packages/waku/src/client.ts b/packages/waku/src/client.ts
index 8cf4bffd7..5cbe0b6de 100644
--- a/packages/waku/src/client.ts
+++ b/packages/waku/src/client.ts
@@ -74,7 +74,7 @@ export const fetchRSC = cache(
encodeInput(input) +
(searchParamsString ? '?' + searchParamsString : '');
const response = prefetched[url] || fetch(url);
- delete prefetched[input];
+ delete prefetched[url];
const data = createFromFetch
>(
checkStatus(response),
options,
diff --git a/packages/waku/src/lib/handlers/dev-worker-api.ts b/packages/waku/src/lib/handlers/dev-worker-api.ts
index bdf1f0b0b..962dbe521 100644
--- a/packages/waku/src/lib/handlers/dev-worker-api.ts
+++ b/packages/waku/src/lib/handlers/dev-worker-api.ts
@@ -201,7 +201,6 @@ export async function renderRscWithWorker(
messageCallbacks.delete(id);
}
});
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ssr: _removed, ...copiedConfig } = rr.config as any; // HACK type
const copied = { ...rr, config: copiedConfig };
delete copied.stream;
diff --git a/packages/waku/src/lib/handlers/dev-worker-impl.ts b/packages/waku/src/lib/handlers/dev-worker-impl.ts
index c6b924218..7af2fbab3 100644
--- a/packages/waku/src/lib/handlers/dev-worker-impl.ts
+++ b/packages/waku/src/lib/handlers/dev-worker-impl.ts
@@ -29,8 +29,7 @@ if (HAS_MODULE_REGISTER) {
const controllerMap = new Map();
const handleRender = async (mesg: MessageReq & { type: 'render' }) => {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { id, type, hasModuleIdCallback, ...rest } = mesg;
+ const { id, type: _removed, hasModuleIdCallback, ...rest } = mesg;
const rr: RenderRequest = rest;
try {
const stream = new ReadableStream({
diff --git a/packages/waku/src/router/server.ts b/packages/waku/src/router/server.ts
index e1c9ae75b..f116f5990 100644
--- a/packages/waku/src/router/server.ts
+++ b/packages/waku/src/router/server.ts
@@ -13,6 +13,7 @@ import {
SHOULD_SKIP_ID,
} from './common.js';
import type { RouteProps, ShouldSkip } from './common.js';
+import { joinPath } from '../lib/utils/path.js';
const ShoudSkipComponent = ({ shouldSkip }: { shouldSkip: ShouldSkip }) =>
createElement('meta', {
@@ -57,7 +58,7 @@ export function defineRouter(
delete shouldSkip[id];
}
});
- const component = typeof mod === 'function' ? mod : mod?.default;
+ const component = mod && 'default' in mod ? mod.default : mod;
if (!component) {
return [];
}
@@ -136,3 +137,308 @@ globalThis.__WAKU_ROUTER_PREFETCH__ = (path) => {
return { renderEntries, getBuildConfig, getSsrConfig };
}
+
+// createPages API (a wrapper around defineRouter)
+
+// FIXME we should extract some functions (and type utils)
+// out of this file, and add unit tests for them.
+// Like: `src/router/utils.ts` and `src/router/utils.test.ts`
+
+type IsValidPathItem = T extends `/${infer _}`
+ ? false
+ : T extends '[]' | ''
+ ? false
+ : true;
+type IsValidPath = T extends `/${infer L}/${infer R}`
+ ? IsValidPathItem extends true
+ ? IsValidPath<`/${R}`>
+ : false
+ : T extends `/${infer U}`
+ ? IsValidPathItem
+ : false;
+type HasSlugInPath = T extends `/[${K}]/${infer _}`
+ ? true
+ : T extends `/${infer _}/${infer U}`
+ ? HasSlugInPath<`/${U}`, K>
+ : T extends `/[${K}]`
+ ? true
+ : false;
+type PathWithSlug = IsValidPath extends true
+ ? HasSlugInPath extends true
+ ? T
+ : never
+ : never;
+type PathWithoutSlug = T extends '/'
+ ? T
+ : IsValidPath extends true
+ ? HasSlugInPath extends true
+ ? never
+ : T
+ : never;
+
+type CreatePage = (
+ page:
+ | {
+ render: 'static';
+ path: PathWithoutSlug;
+ component: FunctionComponent;
+ }
+ | {
+ render: 'static';
+ path: PathWithSlug;
+ staticPaths: string[] | string[][];
+ component: FunctionComponent>;
+ }
+ | {
+ render: 'dynamic';
+ path: PathWithoutSlug;
+ component: FunctionComponent;
+ }
+ | {
+ render: 'dynamic';
+ path: PathWithSlug;
+ component: FunctionComponent>;
+ },
+) => void;
+
+type CreateLayout = (layout: {
+ render: 'static';
+ path: PathWithoutSlug;
+ component: FunctionComponent;
+}) => void;
+
+type ParsedPath = { name: string; isSlug: boolean; isWildcard: boolean }[];
+const parsePath = (path: string): ParsedPath =>
+ path
+ .replace(/^\//, '')
+ .split('/')
+ .map((name) => {
+ const isSlug = name.startsWith('[') && name.endsWith(']');
+ if (isSlug) {
+ name = name.slice(1, -1);
+ }
+ const isWildcard = name.startsWith('...');
+ if (isWildcard) {
+ name = name.slice(3);
+ }
+ return { name, isSlug, isWildcard };
+ });
+
+const getDynamicMapping = (parsedPath: ParsedPath, actual: string[]) => {
+ if (parsedPath.length !== actual.length) {
+ return null;
+ }
+ const mapping: Record = {};
+ for (let i = 0; i < parsedPath.length; i++) {
+ const { name, isSlug } = parsedPath[i]!;
+ if (isSlug) {
+ mapping[name] = actual[i]!;
+ } else {
+ if (name !== actual[i]) {
+ return null;
+ }
+ }
+ }
+ return mapping;
+};
+
+const getWildcardMapping = (parsedPath: ParsedPath, actual: string[]) => {
+ if (parsedPath.length > actual.length) {
+ return null;
+ }
+ const mapping: Record = {};
+ let wildcardStartIndex = -1;
+ for (let i = 0; i < parsedPath.length; i++) {
+ const { name, isSlug, isWildcard } = parsedPath[i]!;
+ if (isWildcard) {
+ wildcardStartIndex = i;
+ break;
+ } else if (isSlug) {
+ mapping[name] = actual[i]!;
+ } else {
+ if (name !== actual[i]) {
+ return null;
+ }
+ }
+ }
+ let wildcardEndIndex = -1;
+ for (let i = 0; i < parsedPath.length; i++) {
+ const { name, isSlug, isWildcard } = parsedPath[parsedPath.length - i - 1]!;
+ if (isWildcard) {
+ wildcardEndIndex = actual.length - i - 1;
+ break;
+ } else if (isSlug) {
+ mapping[name] = actual[actual.length - i - 1]!;
+ } else {
+ if (name !== actual[actual.length - i - 1]) {
+ return null;
+ }
+ }
+ }
+ if (wildcardStartIndex === -1 || wildcardEndIndex === -1) {
+ throw new Error('Invalid wildcard path');
+ }
+ mapping[parsedPath[wildcardStartIndex]!.name] = actual
+ .slice(wildcardStartIndex, wildcardEndIndex + 1)
+ .join('/');
+ return mapping;
+};
+
+export function createPages(
+ fn: (fns: {
+ createPage: CreatePage;
+ createLayout: CreateLayout;
+ }) => Promise,
+): ReturnType {
+ let configured = false;
+ const staticPathSet = new Set();
+ const dynamicPathMap = new Map<
+ string,
+ [ParsedPath, FunctionComponent]
+ >();
+ const wildcardPathMap = new Map<
+ string,
+ [ParsedPath, FunctionComponent]
+ >();
+ const staticComponentMap = new Map>();
+ const registerStaticComponent = (
+ id: string,
+ component: FunctionComponent,
+ ) => {
+ if (
+ staticComponentMap.has(id) &&
+ staticComponentMap.get(id) !== component
+ ) {
+ throw new Error(`Duplicated component for: ${id}`);
+ }
+ staticComponentMap.set(id, component);
+ };
+
+ const createPage: CreatePage = (page) => {
+ if (configured) {
+ throw new Error('no longer available');
+ }
+ const parsedPath = parsePath(page.path);
+ const numSlugs = parsedPath.filter(({ isSlug }) => isSlug).length;
+ const numWildcards = parsedPath.filter(
+ ({ isWildcard }) => isWildcard,
+ ).length;
+ if (page.render === 'static' && numSlugs === 0) {
+ staticPathSet.add(page.path);
+ const id = joinPath(page.path, 'page').replace(/^\//, '');
+ registerStaticComponent(id, page.component);
+ } else if (page.render === 'static' && numSlugs > 0 && numWildcards === 0) {
+ const staticPaths = (
+ page as {
+ staticPaths: string[] | string[][];
+ }
+ ).staticPaths.map((item) => (Array.isArray(item) ? item : [item]));
+ for (const staticPath of staticPaths) {
+ if (staticPath.length !== numSlugs) {
+ throw new Error('staticPaths does not match with slug pattern');
+ }
+ const mapping: Record = {};
+ let slugIndex = 0;
+ const pathItems = parsedPath.map(({ name, isSlug }) => {
+ if (isSlug) {
+ return (mapping[name] = staticPath[slugIndex++]!);
+ }
+ return name;
+ });
+ staticPathSet.add('/' + joinPath(...pathItems));
+ const id = joinPath(...pathItems, 'page');
+ const WrappedComponent = (props: Record) =>
+ createElement(page.component as any, { ...props, ...mapping });
+ registerStaticComponent(id, WrappedComponent);
+ }
+ } else if (page.render === 'dynamic' && numWildcards === 0) {
+ if (dynamicPathMap.has(page.path)) {
+ throw new Error(`Duplicated dynamic path: ${page.path}`);
+ }
+ dynamicPathMap.set(page.path, [parsedPath, page.component]);
+ } else if (page.render === 'dynamic' && numWildcards === 1) {
+ if (wildcardPathMap.has(page.path)) {
+ throw new Error(`Duplicated dynamic path: ${page.path}`);
+ }
+ wildcardPathMap.set(page.path, [parsedPath, page.component]);
+ } else {
+ throw new Error('Invalid page configuration');
+ }
+ };
+
+ const createLayout: CreateLayout = (layout) => {
+ if (configured) {
+ throw new Error('no longer available');
+ }
+ const id = joinPath(layout.path, 'layout').replace(/^\//, '');
+ registerStaticComponent(id, layout.component);
+ };
+
+ const ready = fn({ createPage, createLayout }).then(() => {
+ configured = true;
+ });
+
+ return defineRouter(
+ async (path: string) => {
+ await ready;
+ if (staticPathSet.has(path)) {
+ return 'static';
+ }
+ for (const [parsedPath] of dynamicPathMap.values()) {
+ const mapping = getDynamicMapping(parsedPath, path.split('/').slice(1));
+ if (mapping) {
+ return 'dynamic';
+ }
+ }
+ for (const [parsedPath] of wildcardPathMap.values()) {
+ const mapping = getWildcardMapping(
+ parsedPath,
+ path.split('/').slice(1),
+ );
+ if (mapping) {
+ return 'dynamic';
+ }
+ }
+ return null; // not found
+ },
+ async (id, unstable_setShouldSkip) => {
+ await ready;
+ const staticComponent = staticComponentMap.get(id);
+ if (staticComponent) {
+ unstable_setShouldSkip({});
+ return staticComponent;
+ }
+ for (const [parsedPath, Component] of dynamicPathMap.values()) {
+ const mapping = getDynamicMapping(
+ [...parsedPath, { name: 'page', isSlug: false, isWildcard: false }],
+ id.split('/'),
+ );
+ if (mapping) {
+ if (Object.keys(mapping).length === 0) {
+ unstable_setShouldSkip();
+ return Component;
+ }
+ const WrappedComponent = (props: Record) =>
+ createElement(Component, { ...props, ...mapping });
+ unstable_setShouldSkip();
+ return WrappedComponent;
+ }
+ }
+ for (const [parsedPath, Component] of wildcardPathMap.values()) {
+ const mapping = getWildcardMapping(
+ [...parsedPath, { name: 'page', isSlug: false, isWildcard: false }],
+ id.split('/'),
+ );
+ if (mapping) {
+ const WrappedComponent = (props: Record) =>
+ createElement(Component, { ...props, ...mapping });
+ unstable_setShouldSkip();
+ return WrappedComponent;
+ }
+ }
+ unstable_setShouldSkip({}); // negative cache
+ return null; // not found
+ },
+ async () => staticPathSet,
+ );
+}