Skip to content
This repository was archived by the owner on Jul 6, 2025. It is now read-only.

Commit 1e322a7

Browse files
committed
Improve routing
1 parent 826962c commit 1e322a7

File tree

8 files changed

+173
-87
lines changed

8 files changed

+173
-87
lines changed

framework/react/router.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { FC, ReactElement, ReactNode } from "react";
22
import { Component, createElement, useContext, useEffect, useMemo, useState } from "react";
33
import { FetchError } from "../../lib/helpers.ts";
4-
import type { Route, RouteMeta, RouteModule } from "../../lib/route.ts";
4+
import type { Route, RouteMeta, RouteModule, Routes } from "../../lib/route.ts";
55
import { matchRoutes } from "../../lib/route.ts";
66
import { URLPatternCompat } from "../../lib/urlpattern.ts";
77
import type { SSRContext } from "../../server/types.ts";
@@ -244,19 +244,34 @@ export const forwardProps = (children?: ReactNode, props: Record<string, unknown
244244
return createElement(ForwardPropsContext.Provider, { value: { props } }, children);
245245
};
246246

247-
function loadRoutesFromTag(): Route[] {
247+
function loadRoutesFromTag(): Routes {
248248
const el = window.document?.getElementById("route-manifest");
249249
if (el) {
250250
try {
251251
const manifest = JSON.parse(el.innerText);
252252
if (Array.isArray(manifest.routes)) {
253-
return manifest.routes.map((meta: RouteMeta) => [new URLPatternCompat(meta.pattern), meta]);
253+
let _app: Route | undefined = undefined;
254+
let _404: Route | undefined = undefined;
255+
let _error: Route | undefined = undefined;
256+
const routes = manifest.routes.map((meta: RouteMeta) => {
257+
const { pattern } = meta;
258+
const route: Route = [new URLPatternCompat(pattern), meta];
259+
if (pattern.pathname === "/_app") {
260+
_app = route;
261+
} else if (pattern.pathname === "/_404") {
262+
_404 = route;
263+
} else if (pattern.pathname === "/_error") {
264+
_error = route;
265+
}
266+
return route;
267+
});
268+
return { routes, _app, _404, _error };
254269
}
255270
} catch (_e) {
256271
console.error("loadRoutesFromTag: invalid JSON");
257272
}
258273
}
259-
return [];
274+
return { routes: [] };
260275
}
261276

262277
function loadSSRModulesFromTag(): RouteModule[] {

lib/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export class FetchError extends Error {
2929

3030
/**
3131
* fix remote url to local path.
32-
* e.g. `https://esm.sh/[email protected]` -> `/-/esm.sh/[email protected]`
32+
* e.g. `https://esm.sh/[email protected]?dev` -> `/-/esm.sh/[email protected]?dev`
3333
*/
3434
export function toLocalPath(url: string): string {
3535
if (util.isLikelyHttpURL(url)) {

lib/route.ts

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -23,58 +23,71 @@ export type Route = readonly [
2323
meta: RouteMeta,
2424
];
2525

26+
export type Routes = {
27+
_404?: Route;
28+
_app?: Route;
29+
_error?: Route;
30+
routes: Route[];
31+
};
32+
2633
/** match routes against the given url */
27-
export function matchRoutes(url: URL, routes: Route[]): [ret: URLPatternResult, route: RouteMeta][] {
34+
export function matchRoutes(
35+
url: URL,
36+
{ routes, _app, _404 }: Routes,
37+
): [ret: URLPatternResult, route: RouteMeta][] {
2838
let { pathname } = url;
2939
if (pathname !== "/") {
3040
pathname = util.trimSuffix(url.pathname, "/");
3141
}
3242
const matches: [ret: URLPatternResult, route: RouteMeta][] = [];
3343
if (routes.length > 0) {
34-
routes.forEach(([pattern, meta]) => {
35-
const ret = pattern.exec({ host: url.host, pathname });
36-
if (ret) {
37-
matches.push([ret, meta]);
38-
// find the nesting index of the route
39-
if (meta.nesting && meta.pattern.pathname !== "/_app") {
40-
for (const [p, m] of routes) {
41-
const [_, name] = util.splitBy(m.pattern.pathname, "/", true);
42-
if (!name.startsWith(":")) {
43-
const ret = p.exec({ host: url.host, pathname: pathname + "/index" });
44-
if (ret) {
45-
matches.push([ret, m]);
46-
break;
47-
}
48-
}
49-
}
44+
// find the direct match
45+
for (const [pattern, meta] of routes) {
46+
const { pathname: pp } = meta.pattern;
47+
if (pp !== "/_app" && pp !== "/_404") {
48+
const ret = pattern.exec({ host: url.host, pathname });
49+
if (ret) {
50+
matches.push([ret, meta]);
51+
break;
5052
}
51-
} else if (meta.nesting) {
52-
const parts = util.splitPath(pathname);
53-
for (let i = parts.length - 1; i > 0; i--) {
54-
const pathname = "/" + parts.slice(0, i).join("/");
53+
}
54+
}
55+
if (matches.length > 0) {
56+
const directMatch = matches[matches.length - 1][1];
57+
const parts = util.splitPath(pathname);
58+
const nestRoutes = routes.filter(([_, m]) =>
59+
m.nesting && m.pattern.pathname !== "/_app" && directMatch.pattern.pathname.startsWith(m.pattern.pathname + "/")
60+
);
61+
// lookup nesting parent
62+
for (let i = parts.length - 1; i > 0; i--) {
63+
const pathname = "/" + parts.slice(0, i).join("/");
64+
for (const [pattern, meta] of nestRoutes) {
5565
const ret = pattern.exec({ host: url.host, pathname });
5666
if (ret) {
57-
matches.push([ret, meta]);
67+
matches.unshift([ret, meta]);
5868
break;
5969
}
6070
}
6171
}
62-
});
63-
if (matches.filter(([_, meta]) => !meta.nesting).length === 0) {
64-
for (const [_, meta] of routes) {
65-
if (meta.pattern.pathname === "/_404") {
66-
matches.push([createStaticURLPatternResult(url.host, "/_404"), meta]);
67-
break;
72+
73+
if (directMatch.nesting) {
74+
// find the nesting index of the route
75+
for (const [p, m] of routes) {
76+
if (m.pattern.pathname === directMatch.pattern.pathname + "/index") {
77+
const ret = p.exec({ host: url.host, pathname: pathname + "/index" });
78+
if (ret) {
79+
matches.push([ret, m]);
80+
break;
81+
}
82+
}
6883
}
6984
}
7085
}
71-
if (matches.length > 0) {
72-
for (const [_, meta] of routes) {
73-
if (meta.pattern.pathname === "/_app") {
74-
matches.unshift([createStaticURLPatternResult(url.host, "/_app"), meta]);
75-
break;
76-
}
77-
}
86+
if (_404 && (matches.length === 0 || matches.filter(([_, meta]) => !meta.nesting).length === 0)) {
87+
matches.push([createStaticURLPatternResult(url.host, "/_404"), _404[1]]);
88+
}
89+
if (_app && matches.length > 0) {
90+
matches.unshift([createStaticURLPatternResult(url.host, "/_app"), _app[1]]);
7891
}
7992
}
8093
return matches;

server/build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export async function build(
4747

4848
let routeFiles: [filename: string, exportNames: string[]][] = [];
4949
if (config?.routeFiles) {
50-
const routes = await initRoutes(config?.routeFiles);
50+
const { routes } = await initRoutes(config?.routeFiles);
5151
routeFiles = await Promise.all(routes.map(async ([_, { filename }]) => {
5252
const code = await Deno.readTextFile(filename);
5353
const exportNames = await parseExportNames(filename, code);

server/mod.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { readableStreamFromReader } from "https://deno.land/[email protected]/streams/
33
import { builtinModuleExts } from "../lib/helpers.ts";
44
import log from "../lib/log.ts";
55
import { getContentType } from "../lib/mime.ts";
6-
import type { Route } from "../lib/route.ts";
6+
import type { Routes } from "../lib/route.ts";
77
import util from "../lib/util.ts";
88
import { VERSION } from "../version.ts";
99
import { initModuleLoaders, loadImportMap, loadJSXConfig } from "./config.ts";
@@ -31,7 +31,7 @@ export const serve = (options: ServerOptions = {}) => {
3131
const importMapPromise = loadImportMap();
3232
const jsxConfigPromise = importMapPromise.then((importMap) => loadJSXConfig(importMap));
3333
const moduleLoadersPromise = importMapPromise.then((importMap) => initModuleLoaders(importMap));
34-
const routesPromise = config?.routeFiles ? initRoutes(config.routeFiles) : Promise.resolve([]);
34+
const routesPromise = config?.routeFiles ? initRoutes(config.routeFiles) : Promise.resolve({ routes: [] } as Routes);
3535
const buildHashPromise = Promise.all([jsxConfigPromise, importMapPromise]).then(([jsxConfig, importMap]) => {
3636
const buildArgs = JSON.stringify({ config, jsxConfig, importMap, isDev, VERSION });
3737
return util.computeHash("sha-1", buildArgs);
@@ -159,9 +159,9 @@ export const serve = (options: ServerOptions = {}) => {
159159
}
160160

161161
// request data
162-
const routes = (Reflect.get(globalThis, "__ALEPH_ROUTES") as Route[] | undefined) || await routesPromise;
163-
if (routes.length > 0) {
164-
for (const [pattern, { filename }] of routes) {
162+
const routes: Routes = Reflect.get(globalThis, "__ALEPH_ROUTES") || await routesPromise;
163+
if (routes.routes.length > 0) {
164+
for (const [pattern, { filename }] of routes.routes) {
165165
const ret = pattern.exec({ host, pathname });
166166
if (ret) {
167167
try {

server/renderer.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getAlephPkgUri } from "./config.ts";
66
import type { DependencyGraph } from "./graph.ts";
77
import { importRouteModule } from "./routing.ts";
88
import type { SSRContext } from "./types.ts";
9-
import type { Route, RouteModule } from "../lib/route.ts";
9+
import type { RouteModule, Routes } from "../lib/route.ts";
1010
import { matchRoutes } from "../lib/route.ts";
1111

1212
export type HTMLRewriterHandlers = {
@@ -15,7 +15,7 @@ export type HTMLRewriterHandlers = {
1515

1616
export type RenderOptions = {
1717
indexHtml: string;
18-
routes: Route[];
18+
routes: Routes;
1919
isDev: boolean;
2020
customHTMLRewriter: Map<string, HTMLRewriterHandlers>;
2121
ssr?: (ssr: SSRContext) => string | Promise<string>;
@@ -259,8 +259,8 @@ export default {
259259
if (commonHandler.handled) {
260260
return;
261261
}
262-
if (routes.length > 0) {
263-
const json = JSON.stringify({ routes: routes.map(([_, meta]) => meta) });
262+
if (routes.routes.length > 0) {
263+
const json = JSON.stringify({ routes: routes.routes.map(([_, meta]) => meta) });
264264
el.append(`<script id="route-manifest" type="application/json">${json}</script>`, {
265265
html: true,
266266
});
@@ -301,7 +301,7 @@ export default {
301301
async function initSSR(
302302
req: Request,
303303
ctx: Record<string, unknown>,
304-
routes: Route[],
304+
routes: Routes,
305305
): Promise<[url: URL, routeModules: RouteModule[], errorBoundaryModule: RouteModule | undefined]> {
306306
const url = new URL(req.url);
307307
const matches = matchRoutes(url, routes);
@@ -347,10 +347,8 @@ async function initSSR(
347347
return rmod;
348348
}));
349349
const routeModules = modules.filter(({ defaultExport }) => defaultExport !== undefined);
350-
const errorBoundaryRoute = routes.find(([_, meta]) => meta.pattern.pathname === "/_error");
351-
352-
if (errorBoundaryRoute) {
353-
const [_, meta] = errorBoundaryRoute;
350+
if (routes._error) {
351+
const [_, meta] = routes._error;
354352
const mod = await importRouteModule(meta.filename);
355353
if (mod.default !== undefined) {
356354
const errorBoundaryModule: RouteModule = {

server/routing.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { extname, globToRegExp, join } from "https://deno.land/[email protected]/path/mod.ts";
22
import { getFiles } from "../lib/fs.ts";
3-
import type { Route } from "../lib/route.ts";
3+
import type { Route, Routes } from "../lib/route.ts";
44
import { URLPatternCompat, type URLPatternInput } from "../lib/urlpattern.ts";
55
import util from "../lib/util.ts";
66
import type { DependencyGraph } from "./graph.ts";
@@ -34,8 +34,8 @@ export async function importRouteModule(filename: string) {
3434

3535
/* check if the filename is a route */
3636
export function isRouteFile(filename: string): boolean {
37-
const currentRoutes: Route[] | undefined = Reflect.get(globalThis, "__ALEPH_ROUTES");
38-
const index = currentRoutes?.findIndex(([_, meta]) => meta.filename === filename);
37+
const currentRoutes: Routes | undefined = Reflect.get(globalThis, "__ALEPH_ROUTES");
38+
const index = currentRoutes?.routes.findIndex(([_, meta]) => meta.filename === filename);
3939
if (index !== undefined && index !== -1) {
4040
return true;
4141
}
@@ -48,18 +48,32 @@ export function isRouteFile(filename: string): boolean {
4848
}
4949

5050
/** initialize routes from routes config */
51-
export async function initRoutes(config: string | RoutesConfig | RouteRegExp, cwd = Deno.cwd()): Promise<Route[]> {
51+
export async function initRoutes(
52+
config: string | RoutesConfig | RouteRegExp,
53+
cwd = Deno.cwd(),
54+
): Promise<Routes> {
5255
const reg = isRouteRegExp(config) ? config : toRouteRegExp(config);
5356
const files = await getFiles(join(cwd, reg.prefix));
5457
const routes: Route[] = [];
58+
let _app: Route | undefined = undefined;
59+
let _404: Route | undefined = undefined;
60+
let _error: Route | undefined = undefined;
5561
files.forEach((file) => {
5662
const filename = reg.prefix + file.slice(1);
5763
const pattern = reg.exec(filename);
5864
if (pattern) {
59-
routes.push([
65+
const route: Route = [
6066
new URLPatternCompat(pattern),
6167
{ pattern, filename },
62-
]);
68+
];
69+
routes.push(route);
70+
if (pattern.pathname === "/_app") {
71+
_app = route;
72+
} else if (pattern.pathname === "/_404") {
73+
_404 = route;
74+
} else if (pattern.pathname === "/_error") {
75+
_error = route;
76+
}
6377
}
6478
});
6579
if (routes.length > 0) {
@@ -76,8 +90,9 @@ export async function initRoutes(config: string | RoutesConfig | RouteRegExp, cw
7690
}
7791
});
7892
}
79-
Reflect.set(globalThis, "__ALEPH_ROUTES", routes);
80-
return routes;
93+
const ret = { routes, _404, _app, _error };
94+
Reflect.set(globalThis, "__ALEPH_ROUTES", ret);
95+
return ret;
8196
}
8297

8398
/** convert route config to `RouteRegExp` */
@@ -98,13 +113,13 @@ export function toRouteRegExp(config: string | RoutesConfig): RouteRegExp {
98113
exec: (filename: string): URLPatternInput | null => {
99114
if (reg.test(filename)) {
100115
const parts = util.splitPath(util.trimPrefix(filename, prefix)).map((part) => {
101-
// replace `/p/[...path]` to `/p/:path+`
102-
if (part.startsWith("[...") && part.startsWith("]") && part.length > 5) {
103-
return ":" + part.slice(4, -1) + "+";
116+
// replace `/blog/[...path]` to `/blog/:path+`
117+
if (part.startsWith("[...") && part.includes("]") && part.length > 5) {
118+
return ":" + part.slice(4).replace("]", "+");
104119
}
105120
// replace `/blog/[id]` to `/blog/:id`
106-
if (part.startsWith("[") && part.startsWith("]") && part.length > 2) {
107-
return ":" + part.slice(1, -1);
121+
if (part.startsWith("[") && part.includes("]") && part.length > 2) {
122+
return ":" + part.slice(1).replace("]", "");
108123
}
109124
// replace `/blog/$id` to `/blog/:id`
110125
if (part.startsWith("$") && part.length > 1) {
@@ -139,6 +154,6 @@ function getRouteOrder([_, meta]: Route): number {
139154
case "/_error":
140155
return 0;
141156
default:
142-
return filename.split("/").length;
157+
return filename.split("/").length + (pattern.pathname.split("/:").length - 1) * 0.01;
143158
}
144159
}

0 commit comments

Comments
 (0)