@server/next 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "A fully-fledged web server with routing, file uploads, sessions, static files, schema validation, websockets, testing, etc.",
5
5
  "homepage": "https://server-js.com/",
6
6
  "repository": "https://github.com/franciscop/server-next.git",
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "scripts": {
21
21
  "start": "bun test --watch",
22
- "lint": "npx @biomejs/biome lint ./src --skip=lint/style/noParameterAssign",
23
- "test": "bun test",
22
+ "lint": "npx @biomejs/biome lint ./src --skip=lint/style/noParameterAssign --skip=lint/suspicious/noExplicitAny --skip=lint/suspicious/noConfusingVoidType",
23
+ "test": "bun test && check-dts ./src/**",
24
24
  "test:jest": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
25
25
  },
26
26
  "keywords": [
@@ -29,15 +29,15 @@
29
29
  "server.js"
30
30
  ],
31
31
  "type": "module",
32
- "types": "index.d.ts",
33
32
  "main": "src/index.js",
33
+ "types": "src/index.d.ts",
34
34
  "files": [
35
35
  "src/",
36
- "index.d.ts",
37
36
  "jsx-dev-runtime.js"
38
37
  ],
39
38
  "devDependencies": {
40
39
  "argon2": "^0.40.3",
40
+ "check-dts": "^0.8.2",
41
41
  "jest": "^29.7.0",
42
42
  "polystore": "^0.14.1"
43
43
  },
package/src/auth/index.js CHANGED
@@ -15,17 +15,41 @@ const middle = async (ctx) => {
15
15
  if (ctx.options.auth.provider.includes("github")) {
16
16
  if (!env.GITHUB_ID) throw new Error("GITHUB_ID not defined");
17
17
  if (!env.GITHUB_SECRET) throw new Error("GITHUB_SECRET not defined");
18
- ctx.app.get("/auth/logout", logout);
19
- ctx.app.get("/auth/login/github", providers.github.login);
20
- ctx.app.get("/auth/callback/github", providers.github.callback);
18
+ ctx.app.get(
19
+ "/auth/logout",
20
+ { tags: "Auth", title: "Github logout" },
21
+ logout,
22
+ );
23
+ ctx.app.get(
24
+ "/auth/login/github",
25
+ { tags: "Auth" },
26
+ providers.github.login,
27
+ );
28
+ ctx.app.get(
29
+ "/auth/callback/github",
30
+ { tags: "Auth", title: "Github callback" },
31
+ providers.github.callback,
32
+ );
21
33
  }
22
34
 
23
35
  if (ctx.options.auth.provider.includes("email")) {
24
- ctx.app.post("/auth/logout", logout);
25
- ctx.app.post("/auth/register/email", providers.email.register);
26
- ctx.app.post("/auth/login/email", providers.email.login);
27
- ctx.app.put("/auth/password/email", providers.email.password);
28
- ctx.app.put("/auth/reset/email", providers.email.password);
36
+ ctx.app.post("/auth/logout", { tags: "Auth" }, logout);
37
+ ctx.app.post(
38
+ "/auth/register/email",
39
+ { tags: "Auth" },
40
+ providers.email.register,
41
+ );
42
+ ctx.app.post(
43
+ "/auth/login/email",
44
+ { tags: "Auth" },
45
+ providers.email.login,
46
+ );
47
+ ctx.app.put(
48
+ "/auth/password/email",
49
+ { tags: "Auth" },
50
+ providers.email.password,
51
+ );
52
+ ctx.app.put("/auth/reset/email", { tags: "Auth" }, providers.email.reset);
29
53
  }
30
54
  }
31
55
  };
@@ -61,7 +61,7 @@ const createSession = async (user, ctx) => {
61
61
  throw new Error("Unknown auth type");
62
62
  };
63
63
 
64
- async function login(ctx) {
64
+ async function emailLogin(ctx) {
65
65
  const { email, password } = ctx.body;
66
66
  if (!email) throw ServerError.LOGIN_NO_EMAIL();
67
67
  if (!/@/.test(email)) throw ServerError.LOGIN_INVALID_EMAIL();
@@ -78,7 +78,7 @@ async function login(ctx) {
78
78
  return createSession(user, ctx);
79
79
  }
80
80
 
81
- async function register(ctx) {
81
+ async function emailRegister(ctx) {
82
82
  const { email, password, ...data } = ctx.body;
83
83
  if (!email) throw ServerError.REGISTER_NO_EMAIL();
84
84
  if (!/@/.test(email)) throw ServerError.REGISTER_INVALID_EMAIL();
@@ -101,7 +101,7 @@ async function register(ctx) {
101
101
  return createSession(user, ctx);
102
102
  }
103
103
 
104
- async function reset(ctx) {
104
+ async function emailResetPassword(ctx) {
105
105
  // const reset = ctx.options.store.prefix("reset:");
106
106
  // // Already resetting
107
107
  // if (ctx.body.token) {
@@ -123,7 +123,7 @@ async function reset(ctx) {
123
123
  // }
124
124
  }
125
125
 
126
- async function password(ctx) {
126
+ async function emailUpdatePassword(ctx) {
127
127
  const { previous, updated } = ctx.body;
128
128
 
129
129
  const fullUser = await ctx.options.auth.store.get(ctx.auth.user);
@@ -137,4 +137,9 @@ async function password(ctx) {
137
137
  return 200;
138
138
  }
139
139
 
140
- export default { login, register, reset, password };
140
+ export default {
141
+ login: emailLogin,
142
+ register: emailRegister,
143
+ reset: emailResetPassword,
144
+ password: emailUpdatePassword,
145
+ };
@@ -25,7 +25,7 @@ const oauth = async (code) => {
25
25
  };
26
26
  };
27
27
 
28
- const login = (ctx) => {
28
+ const login = function githubLogin(ctx) {
29
29
  return redirect(
30
30
  `https://github.com/login/oauth/authorize?client_id=${env.GITHUB_ID}&scope=user:email`,
31
31
  );
@@ -82,5 +82,12 @@ export default function config(options = {}) {
82
82
  }
83
83
  }
84
84
 
85
+ // OpenAPI
86
+ if (options.openapi) {
87
+ if (options.openapi === true) {
88
+ options.openapi = {};
89
+ }
90
+ }
91
+
85
92
  return options;
86
93
  }
@@ -45,8 +45,7 @@ const randomId = (size = 16) => {
45
45
  return id;
46
46
  };
47
47
 
48
- export default function createId(source) {
49
- const size = 16;
48
+ export default function createId(source, size = 16) {
50
49
  if (source) return hash(source, size);
51
50
  return randomId(size);
52
51
  }
@@ -16,6 +16,7 @@ const altAttrs = {
16
16
  // "" and 0 are valid children, false and null and undefined are not
17
17
  const isValidChild = (child) => child || child === "" || child === 0;
18
18
 
19
+ const escapeCSS = (value) => String(value).replace(/[<>&"'`]/g, "\\$&");
19
20
  const minifyCss = (str) =>
20
21
  str
21
22
  .replace(/\s+/g, " ")
@@ -42,7 +43,7 @@ export const jsx = (tag, { children, ...props }) => {
42
43
  children = () => src;
43
44
  }
44
45
  if (tag === "style" && children && typeof children === "string") {
45
- const src = minifyCss(children);
46
+ const src = minifyCss(escapeCSS(children));
46
47
  children = () => src;
47
48
  }
48
49
  if (props.dangerouslySetInnerHTML)
@@ -1,5 +1,3 @@
1
- type Bucket = {};
2
-
3
1
  type Method =
4
2
  | "socket"
5
3
  | "get"
@@ -10,25 +8,85 @@ type Method =
10
8
  | "delete"
11
9
  | "options";
12
10
 
11
+ type Store = {
12
+ get: (key: string) => Promise<any>;
13
+ set: (
14
+ key: string,
15
+ value: any,
16
+ { expires }?: { expires?: string | number },
17
+ ) => Promise<any>;
18
+ };
19
+
20
+ type Bucket = any;
21
+
22
+ type Auth = {
23
+ type: "cookie" | "token";
24
+ provider: "github" | "email";
25
+ };
26
+ type AuthString = `${Auth["type"]}:${Auth["provider"]}`;
27
+
28
+ type Domain = `https://${string}/`;
29
+ type Origin = boolean | "*" | Domain | Domain[];
30
+ type Cors = {
31
+ origin: Origin;
32
+ methods: string;
33
+ headers: string;
34
+ credentials?: boolean;
35
+ };
36
+
13
37
  type ServerOptions = {
14
38
  port?: number;
15
39
  views?: string | Bucket;
16
40
  public?: string | Bucket;
17
41
  uploads?: string | Bucket;
18
- auth?: string | { type: string; provider: string };
42
+ cors?: boolean | Origin | Cors;
43
+ auth?: AuthString | Auth;
44
+ store?: Store;
19
45
  };
20
46
 
21
47
  type ExtractPathParams<Path extends string> =
22
- Path extends `${string}:${infer Param}/${infer Rest}`
23
- ? Param | ExtractPathParams<`/${Rest}`>
24
- : Path extends `${string}:${infer Param}`
25
- ? Param
26
- : never;
48
+ Path extends `${string}:${infer Param}(${infer Type})?/${infer Rest}`
49
+ ? `${Param}:${Type}?` | ExtractPathParams<`/${Rest}`>
50
+ : Path extends `${string}:${infer Param}(${infer Type})?`
51
+ ? `${Param}:${Type}?`
52
+ : Path extends `${string}:${infer Param}(${infer Type})/${infer Rest}`
53
+ ? `${Param}:${Type}` | ExtractPathParams<`/${Rest}`>
54
+ : Path extends `${string}:${infer Param}(${infer Type})`
55
+ ? `${Param}:${Type}`
56
+ : Path extends `${string}:${infer Param}?/${infer Rest}`
57
+ ? `${Param}?` | ExtractPathParams<`/${Rest}`>
58
+ : Path extends `${string}:${infer Param}?`
59
+ ? `${Param}?`
60
+ : Path extends `${string}:${infer Param}/${infer Rest}`
61
+ ? Param | ExtractPathParams<`/${Rest}`>
62
+ : Path extends `${string}:${infer Param}`
63
+ ? Param
64
+ : never;
65
+
66
+ type ParamTypeMap = {
67
+ string: string;
68
+ number: number;
69
+ boolean: boolean;
70
+ };
71
+
72
+ type InferParamType<T extends string> = T extends keyof ParamTypeMap
73
+ ? ParamTypeMap[T]
74
+ : string;
27
75
 
28
76
  type ParamsToObject<Params extends string> = {
29
- [K in Params as K extends `${infer Key}?`
77
+ [K in Params as K extends `${infer Key}:${infer Type}?`
30
78
  ? Key
31
- : K]: K extends `${infer Key}?` ? string | undefined : string;
79
+ : K extends `${infer Key}:${infer Type}`
80
+ ? Key
81
+ : K extends `${infer Key}?`
82
+ ? Key
83
+ : K]: K extends `${infer Key}:${infer Type}?`
84
+ ? InferParamType<Type> | undefined
85
+ : K extends `${infer Key}:${infer Type}`
86
+ ? InferParamType<Type>
87
+ : K extends `${infer Key}?`
88
+ ? string | undefined
89
+ : string;
32
90
  };
33
91
 
34
92
  type PathToParams<Path extends string> = ParamsToObject<
@@ -44,7 +102,7 @@ type Context<Path extends string = string> = {
44
102
  body?: any;
45
103
  url: URL & {
46
104
  params: Simplify<PathToParams<Path>>; // Simplify here
47
- query: {};
105
+ query: { [key: string]: string };
48
106
  };
49
107
  options: ServerOptions;
50
108
  };
@@ -55,18 +113,11 @@ type ContentType = "application/json" | "text/plain" | (string & {});
55
113
 
56
114
  // (src: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers)
57
115
  type Headers = {
58
- "Cache-Control"?: string;
59
- "Content-Type"?: ContentType;
60
- Server?: string;
61
- "Set-Cookie"?: string;
62
- "Content-Length"?: string;
63
- Location?: string;
64
-
65
116
  "cache-control"?: string;
66
117
  "content-type"?: ContentType;
67
- server?: string;
68
118
  "set-cookie"?: string;
69
119
  "content-length"?: string;
120
+ server?: string;
70
121
  location?: string;
71
122
 
72
123
  [key: string]: string | undefined;
@@ -77,11 +128,11 @@ type InlineReply =
77
128
  | { body: Body; headers?: Headers }
78
129
  | string
79
130
  | number
80
- | void;
131
+ | undefined;
81
132
 
82
133
  type Middleware<Path extends string = string> = (
83
134
  ctx: Context<Path>,
84
- ) => InlineReply;
135
+ ) => InlineReply | void;
85
136
 
86
137
  declare interface Router {
87
138
  get<Path extends string>(path: Path, ...middle: Middleware<Path>[]): this;
@@ -94,6 +145,21 @@ declare interface Router {
94
145
  }
95
146
 
96
147
  declare interface Server {
148
+ /**
149
+ * Launch the server with the optional configuration:
150
+ *
151
+ * ```js
152
+ * export default server({
153
+ * port: 3000,
154
+ * public: './public',
155
+ * store: kv(new Map()),
156
+ * })
157
+ * ```
158
+ *
159
+ * **[→ Getting Started](https://react-test.dev/documentation#attr)**
160
+ *
161
+ * **[→ Options Docs](https://react-test.dev/documentation#attr)**
162
+ */
97
163
  (options?: ServerOptions): this;
98
164
 
99
165
  socket(path: string, ...middle: Middleware[]): this;
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  parseHeaders,
12
12
  } from "./helpers/index.js";
13
13
 
14
- import { assets, auth, timer } from "./middle/index.js";
14
+ import { assets, auth, timer, openapi } from "./middle/index.js";
15
15
 
16
16
  // Export the reply helpers
17
17
  export * from "./reply.js";
@@ -21,7 +21,6 @@ export { default as ServerError } from "./ServerError.js";
21
21
  // Allow to create a sub-router
22
22
  export { default as router } from "./router.js";
23
23
 
24
- // #region server()
25
24
  export default function server(options = {}) {
26
25
  // Make it so that the exported one is a prototype of function()
27
26
  if (!(this instanceof server)) {
@@ -76,6 +75,10 @@ export default function server(options = {}) {
76
75
 
77
76
  this.use(timer);
78
77
  this.use(assets);
78
+ if (this.opts.openapi) {
79
+ const path = this.opts.openapi.path || "/docs";
80
+ this.get(path, openapi);
81
+ }
79
82
  if (this.opts.auth) {
80
83
  this.use(auth({ options: this.opts, app: this }));
81
84
  }
@@ -0,0 +1,28 @@
1
+ import server, { headers, router } from ".";
2
+
3
+ const users = router
4
+ .post("/users", (ctx) => {
5
+ console.log(ctx.url.pathname);
6
+ console.log(ctx.url.query);
7
+ console.log(ctx.url.params); // No .id
8
+ })
9
+ .get("/users/:id?", (ctx) => {
10
+ console.log(ctx.url.params.id);
11
+ })
12
+ .del("/users/:id", (ctx) => {
13
+ console.log(ctx.url.params.id);
14
+ });
15
+
16
+ server()
17
+ .get("/", (ctx) => {
18
+ console.log(ctx.method);
19
+ console.log(ctx.url);
20
+ console.log(ctx.headers);
21
+ console.log(ctx.cookies);
22
+ console.log(ctx.body);
23
+ return headers();
24
+ })
25
+ .router(users)
26
+ .post("/", () => {
27
+ return 201;
28
+ });
@@ -1,6 +1,7 @@
1
1
  import authMod from "../auth/index.js";
2
2
  import assets from "./assets.js";
3
3
  import timer from "./timer.js";
4
+ import openapi from "./openapi.js";
4
5
 
5
6
  const auth = authMod.middle;
6
- export { assets, auth, timer };
7
+ export { assets, auth, timer, openapi };
@@ -0,0 +1,146 @@
1
+ import fsp from "node:fs/promises";
2
+
3
+ const entities = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
4
+ const encode = (str = "") => {
5
+ if (typeof str === "number") str = String(str);
6
+ if (typeof str !== "string") return ""; // nullify not-strings
7
+ return str.replace(/[&<>"]/g, (tag) => entities[tag]);
8
+ };
9
+
10
+ const getConfig = (routes) => {
11
+ const config = routes.find(
12
+ (r) =>
13
+ typeof r !== "string" && typeof r !== "function" && typeof r === "object",
14
+ );
15
+ if (!config) return {};
16
+ if (config.tags) {
17
+ if (typeof config.tags === "string") {
18
+ config.tags = config.tags.split(/\s*\,\s*/g);
19
+ }
20
+ if (!Array.isArray(config.tags)) {
21
+ throw new Error("invalid tags", config.tags);
22
+ }
23
+ config.tags = config.tags.map((t) => t.trim());
24
+ }
25
+ return config;
26
+ };
27
+
28
+ const pkg = await fsp
29
+ .readFile("package.json", "utf-8")
30
+ .then((data) => JSON.parse(data))
31
+ .catch(() => ({}));
32
+
33
+ const getTag = (name, fn) => {
34
+ const found = fn
35
+ .toString()
36
+ .split("\n")
37
+ .filter((l) => /\s+\/\/\s/.test(l))
38
+ .map((l) => l.trim().replace("// ", ""))
39
+ .find((l) => l.startsWith(name));
40
+ if (!found) return "";
41
+ return encode(found.replace(name, "").trim());
42
+ };
43
+
44
+ const getDescription = (fn) => getTag("@description", fn) || "";
45
+ const getReturn = (fn) => getTag("@returns", fn) || "200 OK";
46
+
47
+ const generateOpenApiPaths = (handlers) => {
48
+ const paths = {};
49
+
50
+ for (const [method, routes] of Object.entries(handlers)) {
51
+ for (const route of routes) {
52
+ const [_, path, fn, meta] = [
53
+ route[0],
54
+ route[1],
55
+ route.find((p) => typeof p === "function"),
56
+ route.find((p) => typeof p === "object"),
57
+ ];
58
+
59
+ const config = getConfig(route);
60
+
61
+ if (typeof path !== "string" || path === "*" || !fn) continue;
62
+
63
+ // Normalize path (convert ":id" to "{id}" for OpenAPI)
64
+ const normalizedPath = path.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
65
+
66
+ if (!paths[normalizedPath]) {
67
+ paths[normalizedPath] = {};
68
+ }
69
+
70
+ const getTitle = (fn) => {
71
+ if (!fn.name) return null;
72
+ // Well, we shouldn't really rely on these, e.g. automatic names from export default
73
+ const wrongNames = ["default"];
74
+ if (wrongNames.includes(fn.name)) return null;
75
+ if (fn.name.length <= 3) return null;
76
+ if (fn.name.includes("_")) return fn.name.replaceAll("_", " ");
77
+ const name = fn.name
78
+ .split(/(?=[A-Z])/)
79
+ .join(" ")
80
+ .toLowerCase();
81
+ return name[0].toUpperCase() + name.slice(1);
82
+ };
83
+
84
+ paths[normalizedPath][method] = {
85
+ tags: config.tags,
86
+ summary:
87
+ config.title ||
88
+ getTitle(fn) ||
89
+ getTag("@title", fn) ||
90
+ `${method.toUpperCase()} ${path}`,
91
+ description: getDescription(fn),
92
+ responses: {
93
+ 200: {
94
+ description: getReturn(fn),
95
+ },
96
+ },
97
+ ...(meta
98
+ ? {
99
+ parameters: Object.entries(meta).map(([key, value]) => ({
100
+ name: key,
101
+ in: "query",
102
+ required: false,
103
+ schema: { type: typeof value },
104
+ example: value,
105
+ })),
106
+ }
107
+ : {}),
108
+ };
109
+ }
110
+ }
111
+
112
+ return paths;
113
+ };
114
+
115
+ export default async (ctx) => {
116
+ const domain = pkg.homepage || ctx.url.origin;
117
+ const openApi = {
118
+ openapi: "3.0.0",
119
+ info: {
120
+ title: pkg.name || "API Documentation",
121
+ version: pkg.version || "1.0.0",
122
+ description: pkg.description || "",
123
+ },
124
+ servers: domain ? [{ url: domain }] : [],
125
+ paths: generateOpenApiPaths(ctx.app.handlers),
126
+ };
127
+
128
+ const configuration = ctx.options.openapi.scalar || {};
129
+
130
+ return `
131
+ <!doctype html>
132
+ <html>
133
+ <head>
134
+ <title>API Reference</title>
135
+ <meta charset="utf-8" />
136
+ <meta
137
+ name="viewport"
138
+ content="width=device-width, initial-scale=1" />
139
+ <style>.open-api-client-button {display: none!important;}</style>
140
+ </head>
141
+ <body>
142
+ <script id="api-reference" type="application/json" data-configuration="${encode(JSON.stringify(configuration))}">${JSON.stringify(openApi, null, 2)}</script>
143
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
144
+ </body>
145
+ </html> `;
146
+ };
@@ -1,6 +1,4 @@
1
- // import { Readable } from "node:stream";
2
-
3
- import { cors, createCookies, createId } from "./helpers/index.js";
1
+ import { cors, createId } from "./helpers/index.js";
4
2
  import { json } from "./reply.js";
5
3
  import ServerError from "./ServerError.js";
6
4
 
@@ -15,11 +15,24 @@ export default function pathPattern(pattern, path) {
15
15
  const patt = pattParts[i] || "";
16
16
  const part = pathParts[i] || "";
17
17
  const last = pattParts[pattParts.length - 1];
18
- const key = patt.replace(/^:/, "").replace(/\?$/, "");
18
+ const key = patt
19
+ .replace(/^:/, "")
20
+ .replace(/\?$/, "")
21
+ .replace(/\(\w*\)/, "");
19
22
  if (patt === part) continue;
20
23
  if (patt.endsWith("?") && !part) continue;
21
24
  if (patt.startsWith(":")) {
22
25
  params[key] = part;
26
+ if (/\(\w*\)/.test(patt)) {
27
+ if (patt.includes("(number)")) {
28
+ const value = Number(part);
29
+ params[key] = Number.isNaN(value) ? undefined : value;
30
+ }
31
+ if (patt.includes("(date)")) {
32
+ const value = new Date(part);
33
+ params[key] = Number.isNaN(value) ? undefined : value;
34
+ }
35
+ }
23
36
  continue;
24
37
  }
25
38
  if ((!patt && last === "*" && part) || (patt === "*" && part)) {
@@ -25,20 +25,45 @@ describe("pathPattern.js", () => {
25
25
  expect(pathPattern("/hello/", "/hello/John")).toEqual(null);
26
26
  });
27
27
 
28
- it("can capture simple groups", () => {
28
+ it("can capture simple params", () => {
29
29
  expect(pathPattern("/:hello", "/john")).toEqual({ hello: "john" });
30
30
  expect(pathPattern("/hello/:there", "/hello/john")).toEqual({
31
31
  there: "john",
32
32
  });
33
33
  });
34
34
 
35
+ it("will parse the params as numbers", () => {
36
+ expect(pathPattern("/:id(number)", "/25")).toEqual({ id: 25 });
37
+ expect(pathPattern("/:id(number)", "/25.5")).toEqual({ id: 25.5 });
38
+ expect(pathPattern("/users/:id(number)", "/users/25")).toEqual({
39
+ id: 25,
40
+ });
41
+ expect(pathPattern("/:id(date)", "/2015-01-01")).toEqual({
42
+ id: new Date("2015-01-01"),
43
+ });
44
+ expect(pathPattern("/report/:id(date)", "/report/2015-01-01")).toEqual({
45
+ id: new Date("2015-01-01"),
46
+ });
47
+ });
48
+
49
+ it("will still match, but not parse it if it's an invalid number", () => {
50
+ expect(pathPattern("/:id(number)", "/hi")).toEqual({});
51
+ expect(pathPattern("/users/:id(number)", "/users/hi")).toEqual({});
52
+ });
53
+
35
54
  it("requires a part for the asterisk", () => {
36
55
  expect(pathPattern("/hello/:there/*", "/hello/John")).toEqual(null);
37
56
  });
38
57
 
39
58
  it("can make a part optional", () => {
40
- // expect(pathPattern("/:name?", "/")).toEqual({});
59
+ expect(pathPattern("/:name?", "/")).toEqual({});
41
60
  expect(pathPattern("/hello/:name?", "/hello/")).toEqual({});
61
+ expect(pathPattern("/:id(number)?", "/")).toEqual({});
62
+ expect(pathPattern("/:id(date)?", "/")).toEqual({});
63
+ expect(pathPattern("/:id(number)?", "/25")).toEqual({ id: 25 });
64
+ expect(pathPattern("/:id(date)?", "/2015-01-01")).toEqual({
65
+ id: new Date("2015-01-01"),
66
+ });
42
67
  expect(pathPattern("/:name?", "/john")).toEqual({ name: "john" });
43
68
  expect(pathPattern("/:name/*?", "/john")).toEqual({ name: "john" });
44
69
  });