Skip to content

Commit 563cfaa

Browse files
ardatangithub-actions[bot]
andauthoredJan 2, 2023
Use URLPattern for routing (#241)
* Use `URLPattern` for routing * chore(dependencies): updated changesets for modified dependencies * Multiple query params * Ponyfill URLPattern * Go * chore(dependencies): updated changesets for modified dependencies * / * Remove itty-router completely * Prettier * Go * Go * Use Error Handling * Go * YAS * Go2 Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent b8c6eac commit 563cfaa

21 files changed

+360
-34
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@whatwg-node/fetch': patch
3+
---
4+
dependencies updates:
5+
- Added dependency [`urlpattern-polyfill@^6.0.2` ↗︎](https://www.npmjs.com/package/urlpattern-polyfill/v/6.0.2) (to `dependencies`)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@whatwg-node/router': patch
3+
---
4+
dependencies updates:
5+
- Removed dependency [`itty-router@3.0.11` ↗︎](https://www.npmjs.com/package/itty-router/v/3.0.11) (from `dependencies`)

‎.changeset/big-goats-taste.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@whatwg-node/router': minor
3+
'@whatwg-node/fetch': minor
4+
---
5+
6+
Drop itty-router in favor of new URLPattern in the fetch ponyfill

‎.prettierignore

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ dist
22
CHANGELOG.md
33
.next
44
[...slug].js
5+
.changeset

‎e2e/deno/import-map.json

-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
"@e2e/shared-server": "../../e2e/shared-server/src/index.ts",
88
"@e2e/shared-scripts": "../../e2e/shared-scripts/src/utils.ts",
99
"@pulumi/pulumi": "npm:@pulumi/pulumi",
10-
"itty-router": "npm:itty-router",
11-
"itty-router-extras": "npm:itty-router-extras",
1210
"child_process": "https://deno.land/std@0.165.0/node/child_process.ts",
1311
"fs": "https://deno.land/std@0.165.0/node/fs.ts",
1412
"util": "https://deno.land/std@0.165.0/node/util.ts"

‎e2e/shared-server/package.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"version": "0.0.3",
44
"private": true,
55
"dependencies": {
6-
"@whatwg-node/router": "0.0.3",
7-
"itty-router": "3.0.11"
6+
"@whatwg-node/router": "0.0.3"
87
}
98
}

‎e2e/shared-server/src/index.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { createRouter, Response, DefaultServerAdapterContext } from '@whatwg-node/router';
1+
import { createRouter, Response, DefaultServerAdapterContext, withErrorHandling } from '@whatwg-node/router';
22

33
export function createTestServerAdapter<TServerContext = DefaultServerAdapterContext>(base?: string) {
4-
const app = createRouter<TServerContext>({ base });
4+
const app = createRouter<TServerContext>({
5+
base,
6+
plugins: [withErrorHandling as any],
7+
});
58

69
app.get(
710
'/greetings/:name',

‎packages/fetch/dist/create-node-ponyfill.js

+6
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ module.exports = function createNodePonyfill(opts = {}) {
2626
ponyfills.TransformStream = globalThis.TransformStream;
2727
ponyfills.Blob = globalThis.Blob;
2828
ponyfills.crypto = globalThis.crypto;
29+
ponyfills.URLPattern = globalThis.URLPattern;
2930

3031
if (!ponyfills.AbortController) {
3132
const abortControllerModule = require("abort-controller");
@@ -91,6 +92,11 @@ module.exports = function createNodePonyfill(opts = {}) {
9192
ponyfills.crypto = new cryptoPonyfill.Crypto();
9293
}
9394

95+
if (!ponyfills.URLPattern) {
96+
const urlPatternModule = require('urlpattern-polyfill');
97+
ponyfills.URLPattern = urlPatternModule.URLPattern;
98+
}
99+
94100
// If any of classes of Fetch API is missing, we need to ponyfill them.
95101
if (!ponyfills.fetch ||
96102
!ponyfills.Request ||

‎packages/fetch/dist/deno-ponyfill.ts

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const crypto = globalThis.crypto;
1313
const btoa = globalThis.btoa;
1414
const TextDecoder = globalThis.TextDecoder;
1515
const TextEncoder = globalThis.TextEncoder;
16+
const URLPattern = (globalThis as any).URLPattern;
1617

1718
export const createFetch = () => globalThis;
1819
export {
@@ -31,4 +32,5 @@ export {
3132
btoa,
3233
TextDecoder,
3334
TextEncoder,
35+
URLPattern,
3436
};

‎packages/fetch/dist/global-ponyfill.js

+5
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,9 @@ module.exports.crypto = globalThis.crypto;
1313
module.exports.btoa = globalThis.btoa;
1414
module.exports.TextEncoder = globalThis.TextEncoder;
1515
module.exports.TextDecoder = globalThis.TextDecoder;
16+
module.exports.URLPattern = globalThis.URLPattern;
17+
if (!module.exports.URLPattern) {
18+
const urlPatternModule = require('urlpattern-polyfill');
19+
module.exports.URLPattern = urlPatternModule.URLPattern;
20+
}
1621
module.exports.createFetch = () => globalThis;

‎packages/fetch/dist/index.d.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/// <reference lib="dom" />
2+
/// <reference types="urlpattern-polyfill" />
23

34
declare const _fetch: typeof fetch;
45
declare const _Request: typeof Request;
@@ -15,6 +16,7 @@ declare const _crypto: typeof crypto;
1516
declare const _btoa: typeof btoa;
1617
declare const _TextEncoder: typeof TextEncoder;
1718
declare const _TextDecoder: typeof TextDecoder;
19+
declare const _URLPattern: typeof URLPattern;
1820

1921
declare module "@whatwg-node/fetch" {
2022
export const fetch: typeof _fetch;
@@ -32,6 +34,7 @@ declare module "@whatwg-node/fetch" {
3234
export const btoa: typeof _btoa;
3335
export const TextDecoder: typeof _TextDecoder;
3436
export const TextEncoder: typeof _TextEncoder;
37+
export const URLPattern: typeof _URLPattern;
3538
export interface FormDataLimits {
3639
/* Max field name size (in bytes). Default: 100. */
3740
fieldNameSize?: number;
@@ -63,7 +66,8 @@ declare module "@whatwg-node/fetch" {
6366
crypto: typeof _crypto,
6467
btoa: typeof _btoa,
6568
TextEncoder: typeof _TextEncoder,
66-
TextDecoder: typeof _TextDecoder
69+
TextDecoder: typeof _TextDecoder,
70+
URLPattern: typeof _URLPattern,
6771
});
6872
}
6973

‎packages/fetch/dist/node-ponyfill.js

+1
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ module.exports.crypto = ponyfills.crypto;
1717
module.exports.btoa = ponyfills.btoa;
1818
module.exports.TextEncoder = ponyfills.TextEncoder;
1919
module.exports.TextDecoder = ponyfills.TextDecoder;
20+
module.exports.URLPattern = ponyfills.URLPattern;
2021

2122
exports.createFetch = createNodePonyfill;

‎packages/fetch/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"formdata-node": "^4.3.1",
2626
"node-fetch": "^2.6.7",
2727
"undici": "^5.12.0",
28-
"web-streams-polyfill": "^3.2.0"
28+
"web-streams-polyfill": "^3.2.0",
29+
"urlpattern-polyfill": "^6.0.2"
2930
},
3031
"publishConfig": {
3132
"access": "public"

‎packages/router/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
"dependencies": {
3737
"@whatwg-node/fetch": "0.5.4",
3838
"@whatwg-node/server": "0.5.1",
39-
"itty-router": "3.0.11",
4039
"tslib": "^2.3.1"
4140
}
4241
}

‎packages/router/src/createRouter.ts

+104-17
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,120 @@
11
import { createServerAdapter, type DefaultServerAdapterContext } from '@whatwg-node/server';
2-
import { Router as IttyRouter } from 'itty-router';
3-
import { Request as DefaultRequestCtor } from '@whatwg-node/fetch';
4-
import type { Router, RouterBaseObject } from './types';
2+
import { Request as DefaultRequestCtor, URLPattern } from '@whatwg-node/fetch';
3+
import type { HTTPMethod, RouteMethodKey, Router, RouterBaseObject, RouterHandler, RouterRequest } from './types';
54

65
interface RouterOptions<TServerContext = DefaultServerAdapterContext> {
76
base?: string;
87
RequestCtor?: typeof Request;
98
plugins?: Array<(router: RouterBaseObject<TServerContext>) => RouterBaseObject<TServerContext>>;
109
}
1110

11+
const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'] as HTTPMethod[];
12+
1213
export function createRouter<TServerContext = DefaultServerAdapterContext>(
1314
options?: RouterOptions<TServerContext>
1415
): Router<TServerContext> {
15-
let ittyRouter = IttyRouter({
16-
base: options?.base,
17-
}) as unknown as RouterBaseObject<TServerContext>;
18-
ittyRouter.all!('*', request => {
19-
let parsedUrl: URL;
20-
Object.defineProperty(request, 'parsedUrl', {
21-
get() {
22-
if (!parsedUrl) {
23-
parsedUrl = new URL(request.url);
16+
const routesByMethod = new Map<HTTPMethod, Map<URLPattern, RouterHandler<TServerContext>[]>>();
17+
function addHandlersToMethod(method: HTTPMethod, path: string, ...handlers: RouterHandler<TServerContext>[]) {
18+
let methodPatternMaps = routesByMethod.get(method);
19+
if (!methodPatternMaps) {
20+
methodPatternMaps = new Map();
21+
routesByMethod.set(method, methodPatternMaps);
22+
}
23+
const basePath = options?.base || '/';
24+
let fullPath = '';
25+
if (basePath === '/') {
26+
fullPath = path;
27+
} else if (path === '/') {
28+
fullPath = basePath;
29+
} else {
30+
fullPath = `${basePath}${path}`;
31+
}
32+
const pattern = new URLPattern({ pathname: fullPath });
33+
methodPatternMaps.set(pattern, handlers);
34+
}
35+
async function handleRequest(request: Request, context: TServerContext) {
36+
const method = request.method as HTTPMethod;
37+
let _parsedUrl: URL;
38+
function getParsedUrl() {
39+
if (!_parsedUrl) {
40+
_parsedUrl = new URL(request.url);
41+
}
42+
return _parsedUrl;
43+
}
44+
const methodPatternMaps = routesByMethod.get(method);
45+
if (methodPatternMaps) {
46+
const queryProxy = new Proxy(
47+
{},
48+
{
49+
get(_, prop) {
50+
const parsedUrl = getParsedUrl();
51+
const allQueries = parsedUrl.searchParams.getAll(prop.toString());
52+
return allQueries.length === 1 ? allQueries[0] : allQueries;
53+
},
54+
has(_, prop) {
55+
const parsedUrl = getParsedUrl();
56+
return parsedUrl.searchParams.has(prop.toString());
57+
},
58+
}
59+
);
60+
for (const [pattern, handlers] of methodPatternMaps) {
61+
const match = pattern.exec(request.url);
62+
if (match) {
63+
const routerRequest = new Proxy(request, {
64+
get(target, prop) {
65+
if (prop === 'parsedUrl') {
66+
return getParsedUrl();
67+
}
68+
if (prop === 'params') {
69+
return match.pathname.groups;
70+
}
71+
if (prop === 'query') {
72+
return queryProxy;
73+
}
74+
const targetProp = target[prop];
75+
if (typeof targetProp === 'function') {
76+
return targetProp.bind(target);
77+
}
78+
return targetProp;
79+
},
80+
has(target, prop) {
81+
return prop in target || prop === 'parsedUrl' || prop === 'params' || prop === 'query';
82+
},
83+
}) as RouterRequest;
84+
for (const handler of handlers) {
85+
const result = await handler(routerRequest as RouterRequest, context);
86+
if (result) {
87+
return result;
88+
}
89+
}
90+
}
91+
}
92+
}
93+
}
94+
let routerBaseObject = new Proxy({} as RouterBaseObject<TServerContext>, {
95+
get(_, prop) {
96+
if (prop === 'handle') {
97+
return handleRequest;
98+
}
99+
const method = prop.toString().toLowerCase() as RouteMethodKey;
100+
return function routeMethodKeyFn(
101+
this: RouterBaseObject<TServerContext>,
102+
path: string,
103+
...handlers: RouterHandler<TServerContext>[]
104+
) {
105+
if (method === 'all') {
106+
for (const httpMethod of HTTP_METHODS) {
107+
addHandlersToMethod(httpMethod, path, ...handlers);
108+
}
109+
} else {
110+
addHandlersToMethod(method.toUpperCase() as HTTPMethod, path, ...handlers);
24111
}
25-
return parsedUrl;
26-
},
27-
});
112+
return this;
113+
};
114+
},
28115
});
29116
options?.plugins?.forEach(plugin => {
30-
ittyRouter = plugin(ittyRouter);
117+
routerBaseObject = plugin(routerBaseObject);
31118
});
32-
return createServerAdapter(ittyRouter, options?.RequestCtor || DefaultRequestCtor);
119+
return createServerAdapter(routerBaseObject, options?.RequestCtor || DefaultRequestCtor);
33120
}

‎packages/router/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export * from './types';
22
export * from './createRouter';
3-
export { Response } from '@whatwg-node/fetch';
3+
export { Response, URLPattern } from '@whatwg-node/fetch';
44
export { withErrorHandling, withCORS, DefaultServerAdapterContext } from '@whatwg-node/server';

‎packages/router/src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export interface RouterRequest extends Request {
66
method: HTTPMethod;
77
parsedUrl: URL;
88
params: Record<string, string>;
9-
query: Record<string, string>;
9+
query: Record<string, string | string[]>;
1010
}
1111

1212
export type RouteMethodKey = Lowercase<HTTPMethod> | 'all';

‎packages/router/tests/router.spec.ts

+202
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,208 @@ describe('Router', () => {
1717
const json = await response.json();
1818
expect(json.message).toBe('Hello /greetings/John!');
1919
});
20+
it('should process parameters in the path', async () => {
21+
const router = createRouter();
22+
router.get(
23+
'/greetings/:name',
24+
request =>
25+
new Response(
26+
JSON.stringify({
27+
message: `Hello ${request.params.name}!`,
28+
})
29+
)
30+
);
31+
const response = await router.fetch('http://localhost/greetings/John');
32+
const json = await response.json();
33+
expect(json.message).toBe('Hello John!');
34+
});
35+
it('should process query parameters', async () => {
36+
const router = createRouter();
37+
router.get(
38+
'/greetings',
39+
request =>
40+
new Response(
41+
JSON.stringify({
42+
message: `Hello ${request.query.name}!`,
43+
})
44+
)
45+
);
46+
const response = await router.fetch('http://localhost/greetings?name=John');
47+
const json = await response.json();
48+
expect(json.message).toBe('Hello John!');
49+
});
50+
it('should process multiple handlers for the same route', async () => {
51+
const router = createRouter();
52+
router.get(
53+
'/greetings',
54+
(request: any) => {
55+
request.message = 'Hello';
56+
},
57+
(request: any) => {
58+
request.message += ` ${request.query.name}!`;
59+
return new Response(JSON.stringify({ message: request.message }));
60+
}
61+
);
62+
const response = await router.fetch('http://localhost/greetings?name=John');
63+
const json = await response.json();
64+
expect(json.message).toBe('Hello John!');
65+
});
66+
67+
it('can match multiple routes if earlier handlers do not return (as middleware)', async () => {
68+
const router = createRouter();
69+
router.get(
70+
'/greetings',
71+
(request: any) => {
72+
request.message = 'Hello';
73+
},
74+
(request: any) => {
75+
request.message += ` to you`;
76+
}
77+
);
78+
router.get('/greetings', (request: any) => {
79+
request.message += ` ${request.query.name}!`;
80+
return new Response(JSON.stringify({ message: request.message }));
81+
});
82+
const response = await router.fetch('http://localhost/greetings?name=John');
83+
const json = await response.json();
84+
expect(json.message).toBe('Hello to you John!');
85+
});
86+
it('can pull route params from the basepath as well', async () => {
87+
const router = createRouter({ base: '/api' });
88+
router.get(
89+
'/greetings/:name',
90+
request =>
91+
new Response(
92+
JSON.stringify({
93+
message: `Hello ${request.params.name}!`,
94+
})
95+
)
96+
);
97+
const response = await router.fetch('http://localhost/api/greetings/John');
98+
const json = await response.json();
99+
expect(json.message).toBe('Hello John!');
100+
});
101+
102+
it('can handle nested routers', async () => {
103+
const router = createRouter();
104+
const nested = createRouter({
105+
base: '/api',
106+
});
107+
nested.get(
108+
'/greetings/:name',
109+
request =>
110+
new Response(
111+
JSON.stringify({
112+
message: `Hello ${request.params.name}!`,
113+
})
114+
)
115+
);
116+
router.get('/api/*', nested);
117+
const response = await router.fetch('http://localhost/api/greetings/John');
118+
const json = await response.json();
119+
expect(json.message).toBe('Hello John!');
120+
});
121+
122+
it('can get query params', async () => {
123+
const router = createRouter();
124+
router.get(
125+
'/foo',
126+
request =>
127+
new Response(
128+
JSON.stringify({
129+
cat: request.query.cat,
130+
foo: request.query.foo,
131+
missing: request.query.missing,
132+
})
133+
)
134+
);
135+
const response = await router.fetch('https://foo.com/foo?cat=dog&foo=bar&foo=baz&missing=');
136+
const json = await response.json();
137+
expect(json).toMatchObject({ cat: 'dog', foo: ['bar', 'baz'], missing: '' });
138+
});
139+
it('supports "/" with base', async () => {
140+
const router = createRouter({
141+
base: '/api',
142+
});
143+
router.get(
144+
'/',
145+
() =>
146+
new Response(
147+
JSON.stringify({
148+
message: `Hello Root!`,
149+
})
150+
)
151+
);
152+
const response = await router.fetch('http://localhost/api');
153+
const json = await response.json();
154+
expect(json.message).toBe('Hello Root!');
155+
});
156+
it('supports "/" without base', async () => {
157+
const router = createRouter();
158+
router.get(
159+
'/',
160+
() =>
161+
new Response(
162+
JSON.stringify({
163+
message: `Hello Root!`,
164+
})
165+
)
166+
);
167+
const response = await router.fetch('http://localhost');
168+
const json = await response.json();
169+
expect(json.message).toBe('Hello Root!');
170+
});
171+
it('supports "/" in the base', async () => {
172+
const router = createRouter({
173+
base: '/',
174+
});
175+
router.get(
176+
'/greetings',
177+
() =>
178+
new Response(
179+
JSON.stringify({
180+
message: `Hello World!`,
181+
})
182+
)
183+
);
184+
const response = await router.fetch('http://localhost/greetings');
185+
const json = await response.json();
186+
expect(json.message).toBe('Hello World!');
187+
});
188+
it('supports "/" both in the base and in the route', async () => {
189+
const router = createRouter({
190+
base: '/',
191+
});
192+
router.get(
193+
'/',
194+
() =>
195+
new Response(
196+
JSON.stringify({
197+
message: `Hello World!`,
198+
})
199+
)
200+
);
201+
const response = await router.fetch('http://localhost');
202+
const json = await response.json();
203+
expect(json.message).toBe('Hello World!');
204+
});
205+
it('handles POST bodies', async () => {
206+
const router = createRouter();
207+
router.post('/greetings', async request => {
208+
const json = await request.json();
209+
return new Response(
210+
JSON.stringify({
211+
message: `Hello ${json.name}!`,
212+
})
213+
);
214+
});
215+
const response = await router.fetch('http://localhost/greetings', {
216+
method: 'POST',
217+
body: JSON.stringify({ name: 'John' }),
218+
});
219+
const json = await response.json();
220+
expect(json.message).toBe('Hello John!');
221+
});
20222
});
21223
describe('withErrorHandling', () => {
22224
it('should return 500 when error is thrown', async () => {

‎packages/server/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * from './createServerAdapter';
22
export * from './types';
33
export * from './utils';
4-
export * from './middlewares/withCORS';
4+
export * from './middlewares/withCors';
55
export * from './middlewares/withErrorHandling';
66
export { Response } from '@whatwg-node/fetch';

‎yarn.lock

+7-5
Original file line numberDiff line numberDiff line change
@@ -4940,11 +4940,6 @@ istanbul-reports@^3.1.3:
49404940
html-escaper "^2.0.0"
49414941
istanbul-lib-report "^3.0.0"
49424942

4943-
itty-router@3.0.11:
4944-
version "3.0.11"
4945-
resolved "https://registry.yarnpkg.com/itty-router/-/itty-router-3.0.11.tgz#256c2ef0a12721a839656f49db640d40ed2fb308"
4946-
integrity sha512-vWsoHBi2CmU15YzyUeHjRfjdySL2jqZQKA9jP1LXkBcLJAo0KQNVlQMvhtzG0mzABhVYifeBF97UkrrpuTCWYQ==
4947-
49484943
jest-changed-files@^29.2.0:
49494944
version "29.2.0"
49504945
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.2.0.tgz#b6598daa9803ea6a4dce7968e20ab380ddbee289"
@@ -7495,6 +7490,13 @@ urlpattern-polyfill@^4.0.3:
74957490
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-4.0.3.tgz#c1fa7a73eb4e6c6a1ffb41b24cf31974f7392d3b"
74967491
integrity sha512-DOE84vZT2fEcl9gqCUTcnAw5ZY5Id55ikUcziSUntuEFL3pRvavg5kwDmTEUJkeCHInTlV/HexFomgYnzO5kdQ==
74977492

7493+
urlpattern-polyfill@^6.0.2:
7494+
version "6.0.2"
7495+
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-6.0.2.tgz#a193fe773459865a2a5c93b246bb794b13d07256"
7496+
integrity sha512-5vZjFlH9ofROmuWmXM9yj2wljYKgWstGwe8YTyiqM7hVum/g9LyCizPZtb3UqsuppVwety9QJmfc42VggLpTgg==
7497+
dependencies:
7498+
braces "^3.0.2"
7499+
74987500
util-promisify@^2.1.0:
74997501
version "2.1.0"
75007502
resolved "https://registry.yarnpkg.com/util-promisify/-/util-promisify-2.1.0.tgz#3c2236476c4d32c5ff3c47002add7c13b9a82a53"

0 commit comments

Comments
 (0)
Please sign in to comment.