primate 0.18.0 → 0.19.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,12 +1,13 @@
1
1
  {
2
2
  "name": "primate",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Expressive, minimal and extensible web framework",
5
5
  "homepage": "https://primatejs.com",
6
6
  "bugs": "https://github.com/primatejs/primate/issues",
7
7
  "license": "MIT",
8
8
  "files": [
9
- "src/**",
9
+ "src/**/*.js",
10
+ "src/defaults/index.html",
10
11
  "!src/**/*.spec.js"
11
12
  ],
12
13
  "bin": "src/bin.js",
package/src/Logger.js CHANGED
@@ -80,10 +80,7 @@ const Logger = class Logger {
80
80
  print(blue("++"), fix);
81
81
  name && print(dim(`\n -> ${reference}/${module ?? "primate"}#${hyphenate(name)}`), "\n");
82
82
  }
83
- if (this.#trace && error) {
84
- print(pre, color(module), "trace follows\n");
85
- console.log(error);
86
- }
83
+ this.#trace && error && console.log(error);
87
84
  }
88
85
 
89
86
  get level() {
package/src/app.js CHANGED
@@ -4,6 +4,7 @@ import {bold, blue} from "runtime-compat/colors";
4
4
  import errors from "./errors.js";
5
5
  import * as handlers from "./handlers/exports.js";
6
6
  import * as hooks from "./hooks/exports.js";
7
+ import dispatch from "./dispatch.js";
7
8
 
8
9
  const qualify = (root, paths) =>
9
10
  Object.keys(paths).reduce((sofar, key) => {
@@ -14,16 +15,17 @@ const qualify = (root, paths) =>
14
15
  return sofar;
15
16
  }, {});
16
17
 
17
- const src = new Path(import.meta.url).up(1);
18
+ const base = new Path(import.meta.url).up(1);
19
+ const defaultLayout = "index.html";
18
20
 
19
- const index = async app => {
20
- const name = "index.html";
21
+ const index = async (app, layout = defaultLayout) => {
22
+ const name = layout;
21
23
  try {
22
24
  // user-provided file
23
- return await File.read(`${app.paths.static.join(name)}`);
25
+ return await File.read(`${app.paths.layouts.join(name)}`);
24
26
  } catch (error) {
25
27
  // fallback
26
- return src.join("defaults", name).text();
28
+ return base.join("defaults", defaultLayout).text();
27
29
  }
28
30
  };
29
31
 
@@ -34,6 +36,13 @@ const hash = async (string, algorithm = "sha-384") => {
34
36
  return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
35
37
  };
36
38
 
39
+ const attribute = attributes => Object.keys(attributes).length > 0 ?
40
+ " ".concat(Object.entries(attributes)
41
+ .map(([key, value]) => `${key}="${value}"`).join(" "))
42
+ : "";
43
+ const tag = ({name, attributes = {}, code = "", close = true}) =>
44
+ `<${name}${attribute(attributes)}${close ? `>${code}</${name}>` : "/>"}`;
45
+
37
46
  export default async (config, root, log) => {
38
47
  const {http} = config;
39
48
 
@@ -52,6 +61,17 @@ export default async (config, root, log) => {
52
61
  `${route}`.replace(paths.routes, "").slice(1, -ending.length),
53
62
  (await import(route)).default,
54
63
  ]));
64
+ const types = Object.fromEntries(
65
+ paths.types === undefined ? [] : await Promise.all(
66
+ (await Path.collect(paths.types , /^.*.js$/u))
67
+ /* accept only lowercase-first files in type filename */
68
+ .filter(path => /^[a-z]/u.test(path.name))
69
+ .map(async type => [
70
+ `${type}`.replace(paths.types, "").slice(1, -ending.length),
71
+ (await import(type)).default,
72
+ ])));
73
+ Object.entries(types).some(([name, type]) =>
74
+ typeof type !== "function" && errors.InvalidType.throw({name}));
55
75
 
56
76
  const modules = config.modules === undefined ? [] : config.modules;
57
77
 
@@ -68,7 +88,7 @@ export default async (config, root, log) => {
68
88
  !Object.keys(module).some(key => Object.keys(hooks).includes(key)));
69
89
  hookless.length > 0 && errors.ModuleHasNoHooks.warn(log, {hookless});
70
90
 
71
- const {name, version} = await src.up(1).join("package.json").json();
91
+ const {name, version} = await base.up(1).join("package.json").json();
72
92
 
73
93
  const app = {
74
94
  config,
@@ -97,6 +117,7 @@ export default async (config, root, log) => {
97
117
  const csp = Object.keys(http.csp).reduce((policy_string, key) =>
98
118
  `${policy_string}${key} ${http.csp[key]};`, "");
99
119
  const scripts = app.resources
120
+ .filter(({type}) => type !== "style")
100
121
  .map(resource => `'${resource.integrity}'`).join(" ");
101
122
  const _csp = scripts === "" ? csp : `${csp}script-src 'self' ${scripts};`;
102
123
  // remove inline resources
@@ -113,19 +134,23 @@ export default async (config, root, log) => {
113
134
  };
114
135
  },
115
136
  handlers: {...handlers},
116
- render: async ({body = "", head = ""} = {}) => {
117
- const html = await index(app);
118
- const heads = app.resources.map(({src, code, type, inline, integrity}) => {
119
- const tag = type === "style" ? "link" : "script";
120
- const pre = type === "style"
121
- ? `<${tag} rel="stylesheet"`
122
- : `<${tag} type="${type}" integrity="${integrity}"`;
123
- const middle = type === "style"
124
- ? ` href="${src}">`
125
- : ` src="${src}">`;
126
- const post = type === "style" ? "" : `</${tag}>`;
127
- return inline ? `${pre}>${code}${post}` : `${pre}${middle}${post}`;
128
- }).join("\n");
137
+ render: async ({body = "", head = "", layout} = {}) => {
138
+ const html = await index(app, layout);
139
+ // inline: <script type integrity>...</script>
140
+ // outline: <script type integrity src></script>
141
+ const script = ({inline, code, type, integrity, src}) => inline
142
+ ? tag({name: "script", attributes: {type, integrity}, code})
143
+ : tag({name: "script", attributes: {type, integrity, src}});
144
+ // inline: <style>...</style>
145
+ // outline: <link rel="stylesheet" href/>
146
+ const style = ({inline, code, href, rel = "stylesheet"}) => inline
147
+ ? tag({name: "style", code})
148
+ : tag({name: "link", attributes: {rel, href}, close: false});
149
+ const heads = app.resources.map(({src, code, type, inline, integrity}) =>
150
+ type === "style"
151
+ ? style({inline, code, href: src})
152
+ : script({inline, code, type, integrity, src})
153
+ ).join("\n");
129
154
  return html
130
155
  .replace("%body%", () => body)
131
156
  .replace("%head%", () => `${head}${heads}`);
@@ -134,8 +159,6 @@ export default async (config, root, log) => {
134
159
  if (type === "module") {
135
160
  code = app.replace(code);
136
161
  }
137
- // while integrity is only really needed for scripts, it is also later
138
- // used for the etag header
139
162
  const integrity = await hash(code);
140
163
  const _src = new Path(http.static.root).join(src ?? "");
141
164
  app.resources.push({src: `${_src}`, code, type, inline, integrity});
@@ -154,6 +177,7 @@ export default async (config, root, log) => {
154
177
  app.identifiers = {...exports, ...app.identifiers};
155
178
  },
156
179
  modules,
180
+ types,
157
181
  };
158
182
  log.class.print(blue(bold(name)), blue(version),
159
183
  `at http${app.secure ? "s" : ""}://${http.host}:${http.port}\n`);
@@ -164,5 +188,8 @@ export default async (config, root, log) => {
164
188
  app.modules.push(dependent);
165
189
  }})));
166
190
 
191
+ app.route = hooks.route({...app, dispatch: dispatch(types)});
192
+ app.parse = hooks.parse(dispatch(types));
193
+
167
194
  return app;
168
195
  };
@@ -10,6 +10,7 @@ export default {
10
10
  port: 6161,
11
11
  csp: {
12
12
  "default-src": "'self'",
13
+ "style-src": "'self'",
13
14
  "object-src": "'none'",
14
15
  "frame-ancestors": "'none'",
15
16
  "form-action": "'self'",
@@ -21,6 +22,7 @@ export default {
21
22
  },
22
23
  },
23
24
  paths: {
25
+ layouts: "layouts",
24
26
  static: "static",
25
27
  public: "public",
26
28
  routes: "routes",
@@ -29,4 +31,7 @@ export default {
29
31
  },
30
32
  modules: [],
31
33
  dist: "app",
34
+ types: {
35
+ explicit: false,
36
+ },
32
37
  };
@@ -0,0 +1,24 @@
1
+ import {is, maybe} from "runtime-compat/dyndef";
2
+ import errors from "./errors.js";
3
+
4
+ export default (patches = {}) => value => {
5
+ is(patches.get).undefined();
6
+ return Object.assign(Object.create(null), {
7
+ ...Object.fromEntries(Object.entries(patches).map(([name, patch]) =>
8
+ [name, property => {
9
+ is(property).defined(`\`${name}\` called without property`);
10
+ try {
11
+ return patch(value[property], property);
12
+ } catch (error) {
13
+ errors.MismatchedType.throw({message: error.message});
14
+ }
15
+ }])),
16
+ get(property) {
17
+ maybe(property).string();
18
+ if (property !== undefined) {
19
+ return value[property];
20
+ }
21
+ return value;
22
+ },
23
+ });
24
+ };
package/src/errors.js CHANGED
@@ -45,9 +45,52 @@ export default Object.fromEntries(Object.entries({
45
45
  level: Logger.Error,
46
46
  };
47
47
  },
48
+ InvalidPathParameter({named, path}) {
49
+ return {
50
+ message: ["invalid path parameter % in route %", named, path],
51
+ fix: ["use only latin letters and decimal digits in path parameters"],
52
+ level: Logger.Error,
53
+ };
54
+ },
55
+ InvalidRouteName({path}) {
56
+ return {
57
+ message: ["invalid route name %", path],
58
+ fix: ["do not use dots in route names"],
59
+ level: Logger.Error,
60
+ };
61
+ },
62
+ InvalidType({name}) {
63
+ return {
64
+ message: ["invalid type %", name],
65
+ fix: ["use only functions for the default export of types"],
66
+ level: Logger.Error,
67
+ };
68
+ },
69
+ InvalidTypeName({name}) {
70
+ return {
71
+ message: ["invalid type name %", name],
72
+ fix: ["use only latin letters and decimal digits in types"],
73
+ level: Logger.Error,
74
+ };
75
+ },
76
+ MismatchedPath({path, message}) {
77
+ return {
78
+ message: [`mismatched % path: ${message}`, path],
79
+ fix: ["if unintentional, fix the type or the caller"],
80
+ level: Logger.Info,
81
+ };
82
+ },
83
+ MismatchedType({message}) {
84
+ return {
85
+ message: [`mismatched type: ${message}`],
86
+ fix: ["if unintentional, fix the type or the caller"],
87
+ level: Logger.Info,
88
+ };
89
+ },
48
90
  ModuleHasNoHooks({hookless}) {
91
+ const modules = hookless.map(({name}) => name).join(", ");
49
92
  return {
50
- message: ["module % has no hooks", hookless.join(", ")],
93
+ message: ["module % has no hooks", modules],
51
94
  fix: ["ensure every module uses at least one hook or deactivate it"],
52
95
  level: Logger.Warn,
53
96
  };
@@ -81,31 +124,17 @@ export default Object.fromEntries(Object.entries({
81
124
  };
82
125
  },
83
126
  NoRouteToPath({method, pathname, config: {paths}}) {
84
- const route = `${paths.routes}/${pathname === "/" ? "index" : ""}.js`;
127
+ const route = `${paths.routes}${pathname === "" ? "index" : pathname}.js`;
85
128
  return {
86
129
  message: ["no % route to %", method, pathname],
87
130
  fix: ["if unintentional create a route at %", route],
88
131
  level: Logger.Info,
89
132
  };
90
133
  },
91
- InvalidPathParameter({named, path}) {
134
+ ReservedTypeName({name}) {
92
135
  return {
93
- message: ["invalid path parameter % in route %", named, path],
94
- fix: ["use only latin letters and decimal digits in path parameters"],
95
- level: Logger.Error,
96
- };
97
- },
98
- InvalidRouteName({path}) {
99
- return {
100
- message: ["invalid route name %", path],
101
- fix: ["do not use dots in route names"],
102
- level: Logger.Error,
103
- };
104
- },
105
- InvalidType({name}) {
106
- return {
107
- message: ["invalid type %", name],
108
- fix: ["use only latin letters and decimal digits in types"],
136
+ message: ["type name % is reserved", name],
137
+ fix: ["do not use any reserved type names"],
109
138
  level: Logger.Error,
110
139
  };
111
140
  },
@@ -1,11 +1,33 @@
1
- export default (component, flags = {}) => {
2
- const {status = 200, partial = false, load = false} = flags;
1
+ const script = /(?<=<script)>(?<code>.*?)(?=<\/script>)/gus;
2
+ const style = /(?<=<style)>(?<code>.*?)(?=<\/style>)/gus;
3
+
4
+ const integrate = async (html, publish, headers) => {
5
+ const scripts = await Promise.all([...html.matchAll(script)]
6
+ .map(({groups: {code}}) => publish({code, inline: true})));
7
+ for (const integrity of scripts) {
8
+ headers["Content-Security-Policy"] = headers["Content-Security-Policy"]
9
+ .replace("script-src 'self' ", `script-src 'self' '${integrity}' `);
10
+ }
11
+ const styles = await Promise.all([...html.matchAll(style)]
12
+ .map(({groups: {code}}) => publish({code, type: "style", inline: true})));
13
+ for (const integrity of styles) {
14
+ headers["Content-Security-Policy"] = headers["Content-Security-Policy"]
15
+ .replace("style-src 'self'", `style-src 'self' '${integrity}' `);
16
+ }
17
+ return html
18
+ .replaceAll(/<script>.*?<\/script>/gus, () => "")
19
+ .replaceAll(/<style>.*?<\/style>/gus, () => "");
20
+ };
21
+
22
+ export default (component, options = {}) => {
23
+ const {status = 200, partial = false, load = false, layout} = options;
3
24
 
4
25
  return async (app, headers) => {
5
- const body = load ?
6
- await app.paths.components.join(component).text() : component;
26
+ const body = await integrate(await load ?
27
+ await app.paths.components.join(component).text() : component,
28
+ app.publish, headers);
7
29
 
8
- return [partial ? body : await app.render({body}), {
30
+ return [partial ? body : await app.render({body, layout}), {
9
31
  status,
10
32
  headers: {...headers, "Content-Type": "text/html"},
11
33
  }];
@@ -5,3 +5,4 @@ export {default as bundle} from "./bundle.js";
5
5
  export {default as route} from "./route.js";
6
6
  export {default as handle} from "./handle.js";
7
7
  export {default as parse} from "./parse.js";
8
+ export {default as serve} from "./serve.js";
@@ -11,11 +11,10 @@ export default app => {
11
11
  const {http} = app.config;
12
12
 
13
13
  const _respond = async (request, headers) => {
14
- if (invalid(request.url.pathname)) {
15
- errors.NoFileForPath.throw({pathname: request.url.pathname, config: app.config});
16
- return;
17
- }
18
- return respond(await app.route(request))(app, headers);
14
+ const {pathname} = request.url;
15
+ return invalid(pathname)
16
+ ? errors.NoFileForPath.throw({pathname, config: app.config})
17
+ : (await respond(await app.route(request)))(app, headers);
19
18
  };
20
19
 
21
20
  const route = async request => {
@@ -66,10 +65,6 @@ export default app => {
66
65
  return route(request);
67
66
  };
68
67
 
69
- const handle = async request => {
70
- return await resource(request);
71
- };
72
-
73
- return [...filter("handle", app.modules), handle]
68
+ return [...filter("handle", app.modules), resource]
74
69
  .reduceRight((acc, handler) => input => handler(input, acc));
75
70
  };
@@ -1,16 +1,15 @@
1
1
  import {URL} from "runtime-compat/http";
2
- import fromNull from "../fromNull.js";
3
2
  import errors from "../errors.js";
4
3
 
5
4
  const contents = {
6
5
  "application/x-www-form-urlencoded": body =>
7
- fromNull(Object.fromEntries(body.split("&").map(part => part.split("=")
8
- .map(subpart => decodeURIComponent(subpart).replaceAll("+", " "))))),
6
+ Object.fromEntries(body.split("&").map(part => part.split("=")
7
+ .map(subpart => decodeURIComponent(subpart).replaceAll("+", " ")))),
9
8
  "application/json": body => JSON.parse(body),
10
9
  };
11
10
  const decoder = new TextDecoder();
12
11
 
13
- export default async request => {
12
+ export default dispatch => async request => {
14
13
  const parseContentType = (contentType, body) => {
15
14
  const type = contents[contentType];
16
15
  return type === undefined ? body : type(body);
@@ -46,14 +45,15 @@ export default async request => {
46
45
  const _url = request.url;
47
46
  const url = new URL(_url.endsWith("/") ? _url.slice(0, -1) : _url);
48
47
 
48
+ const body = await parseBody(request);
49
49
  return {
50
50
  original: request,
51
51
  url,
52
- body: await parseBody(request),
53
- cookies: fromNull(cookies === null
52
+ body: dispatch(body),
53
+ cookies: dispatch(cookies === null
54
54
  ? {}
55
55
  : Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
56
- headers: fromNull(Object.fromEntries(request.headers)),
57
- query: fromNull(Object.fromEntries(url.searchParams)),
56
+ headers: dispatch(Object.fromEntries(request.headers)),
57
+ query: dispatch(Object.fromEntries(url.searchParams)),
58
58
  };
59
59
  };
@@ -4,13 +4,6 @@ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
4
4
 
5
5
  // insensitive-case equal
6
6
  const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
7
- // HTTP verbs
8
- const verbs = [
9
- // CRUD
10
- "post", "get", "put", "delete",
11
- // extended
12
- "connect", "options", "trace", "patch", "head",
13
- ];
14
7
 
15
8
  /* routes may not contain dots */
16
9
  export const invalid = route => /\./u.test(route);
@@ -63,27 +56,34 @@ export default app => {
63
56
  return [];
64
57
  }
65
58
 
66
- const path = toRoute(route);
67
59
  return Object.entries(imported)
68
- .filter(([verb]) => verbs.includes(verb))
69
- .map(([method, handler]) => ({method, handler, path}));
60
+ .map(([method, handler]) => ({method, handler, path: toRoute(route)}));
70
61
  }).flat();
71
62
 
72
63
  const {types = {}} = app;
73
- Object.entries(types).every(([name]) => /^(?:\w*)$/u.test(name) ||
74
- errors.InvalidType.throw({name}));
75
-
76
- const isType = groups => Object
64
+ Object.entries(types).some(([name]) => /^(?:[^\W_]*)$/u.test(name) ||
65
+ errors.InvalidTypeName.throw({name}));
66
+ const reserved = ["get", "raw"];
67
+ Object.entries(types).some(([name]) => reserved.includes(name) &&
68
+ errors.ReservedTypeName.throw({name}));
69
+
70
+ const {explicit} = app.config.types;
71
+ const isType = (groups, path) => Object
77
72
  .entries(groups ?? {})
78
73
  .map(([name, value]) =>
79
- [types[name] === undefined ? name : `${name}$${name}`, value])
74
+ [types[name] === undefined || explicit ? name : `${name}$${name}`, value])
80
75
  .filter(([name]) => name.includes("$"))
81
76
  .map(([name, value]) => [name.split("$")[1], value])
82
- .every(([name, value]) => types?.[name](value) === true)
83
- ;
77
+ .every(([name, value]) => {
78
+ try {
79
+ return types?.[name](value) === true;
80
+ } catch (error) {
81
+ return errors.MismatchedPath.throw({message: error.message, path});
82
+ }
83
+ });
84
84
  const isPath = ({route, path}) => {
85
85
  const result = route.path.exec(path);
86
- return result === null ? false : isType(result.groups);
86
+ return result === null ? false : isType(result.groups, path);
87
87
  };
88
88
  const isMethod = ({route, method, path}) => ieq(route.method, method)
89
89
  && isPath({route, path});
@@ -95,8 +95,8 @@ export default app => {
95
95
  const {original: {method}, url: {pathname}} = request;
96
96
  const verb = find(method, pathname) ??
97
97
  errors.NoRouteToPath.throw({method, pathname, config: app.config});
98
- const path = reentry(verb.path?.exec(pathname).groups,
99
- object => object.map(([key, value]) => [key.split("$")[0], value]));
98
+ const path = app.dispatch(reentry(verb.path?.exec(pathname).groups,
99
+ object => object.map(([key, value]) => [key.split("$")[0], value])));
100
100
 
101
101
  // verb.handler is the last module to be executed
102
102
  const handlers = [...modules, verb.handler].reduceRight((acc, handler) =>
@@ -0,0 +1,8 @@
1
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
+
3
+ export default async (app, server) => {
4
+ app.log.info("running serve hooks", {module: "primate"});
5
+ await [...filter("serve", app.modules), _ => _]
6
+ .reduceRight((acc, handler) => input =>
7
+ handler(input, acc))({...app, server});
8
+ };
package/src/start.js CHANGED
@@ -1,31 +1,36 @@
1
1
  import {serve, Response} from "runtime-compat/http";
2
2
  import {InternalServerError} from "./http-statuses.js";
3
- import {register, compile, publish, bundle, route, handle, parse}
4
- from "./hooks/exports.js";
3
+ import * as hooks from "./hooks/exports.js";
4
+
5
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
5
6
 
6
7
  export default async (app, operations = {}) => {
7
8
  // register handlers
8
- await register({...app, register(name, handler) {
9
+ await hooks.register({...app, register(name, handler) {
9
10
  app.handlers[name] = handler;
10
11
  }});
11
12
 
12
13
  // compile server-side code
13
- await compile(app);
14
+ await hooks.compile(app);
14
15
  // publish client-side code
15
- await publish(app);
16
+ await hooks.publish(app);
16
17
 
17
18
  // bundle client-side code
18
- await bundle(app, operations?.bundle);
19
-
20
- const _route = route(app);
19
+ await hooks.bundle(app, operations?.bundle);
21
20
 
22
- serve(async request => {
21
+ const server = await serve(async request => {
23
22
  try {
24
23
  // parse, handle
25
- return await handle({...app, route: _route})(await parse(request));
24
+ return await hooks.handle(app)(await app.parse(request));
26
25
  } catch(error) {
26
+ console.log("TEST2");
27
27
  app.log.auto(error);
28
28
  return new Response(null, {status: InternalServerError});
29
29
  }
30
30
  }, app.config.http);
31
+
32
+ await [...filter("serve", app.modules), _ => _]
33
+ .reduceRight((acc, handler) => input => handler(input, acc))({
34
+ ...app, server,
35
+ });
31
36
  };
package/src/fromNull.js DELETED
@@ -1 +0,0 @@
1
- export default object => Object.assign(Object.create(null), object);
@@ -1,13 +0,0 @@
1
- import mime from "./mime.js";
2
-
3
- export default test => {
4
- test.case("match", assert => {
5
- assert(mime("/app.js")).equals("text/javascript");
6
- });
7
- test.case("no extension", assert => {
8
- assert(mime("/app")).equals("application/octet-stream");
9
- });
10
- test.case("unknown extension", assert => {
11
- assert(mime("/app.unknown")).equals("application/octet-stream");
12
- });
13
- };
@@ -1,12 +0,0 @@
1
- import respond from "./respond.js";
2
-
3
- export default test => {
4
- test.case("guess URL", assert => {
5
- const url = "https://primatejs.com/";
6
- const status = 302;
7
- const [body, options] = respond(new URL(url))();
8
- assert(body).null();
9
- assert(options.status).equals(status);
10
- assert(options.headers.Location).equals(url);
11
- });
12
- };
@@ -1,72 +0,0 @@
1
- import parse from "./parse.js";
2
- import Logger from "../Logger.js";
3
-
4
- const {mark} = Logger;
5
-
6
- const r = await (async () => {
7
- const p = "https://p.com";
8
- const request = (method, path = "/", options = {}) =>
9
- new Request(`${p}${path}`, {method, ...options});
10
- return Object.fromEntries(["get", "post", "put", "delete"].map(verb =>
11
- [verb, (...args) => parse(request(verb.toUpperCase(), ...args))]));
12
- })();
13
-
14
- export default test => {
15
- test.case("no body => null", async assert => {
16
- assert((await r.get("/")).body).null();
17
- assert((await r.post("/")).body).null();
18
- });
19
- test.case("body is application/json", async assert => {
20
- const body = JSON.stringify({foo: "bar"});
21
- const contentType = "application/json";
22
- const headers = {"Content-Type": contentType};
23
- assert((await r.post("/", {body, headers})).body).equals({foo: "bar"});
24
-
25
- const faulty = `${body}%`;
26
- assert(() => r.post("/", {body: faulty, headers}))
27
- .throws(mark("cannot parse body % as %", faulty, contentType));
28
- });
29
- test.case("body is application/x-www-form-urlencoded", async assert => {
30
- assert((await r.post("/", {
31
- body: encodeURI("foo=bar &bar=baz"),
32
- headers: {
33
- "Content-Type": "application/x-www-form-urlencoded",
34
- },
35
- })).body).equals({foo: "bar ", bar: "baz"});
36
- });
37
- test.case("no query => {}", async assert => {
38
- assert((await r.get("/")).query).equals({});
39
- });
40
- test.case("query", async assert => {
41
- assert((await r.get("/?key=value")).query).equals({key: "value"});
42
- });
43
- test.case("no cookies => {}", async assert => {
44
- assert((await r.get("/")).cookies).equals({});
45
- });
46
- test.case("cookies", async assert => {
47
- assert((await r.get("/?key=value", {
48
- headers: {
49
- Cookie: "key=value;key2=value2",
50
- },
51
- })).cookies).equals({key: "value", key2: "value2"});
52
- });
53
- test.case("no headers => {}", async assert => {
54
- assert((await r.get("/")).headers).equals({});
55
- });
56
- test.case("headers", async assert => {
57
- assert((await r.get("/?key=value", {
58
- headers: {
59
- "X-User": "Donald",
60
- },
61
- })).headers).equals({"x-user": "Donald"});
62
- });
63
- test.case("cookies double as headers", async assert => {
64
- const response = await r.get("/?key=value", {
65
- headers: {
66
- Cookie: "key=value",
67
- },
68
- });
69
- assert(response.headers).equals({cookie: "key=value"});
70
- assert(response.cookies).equals({key: "value"});
71
- });
72
- };
@@ -1,181 +0,0 @@
1
- import Logger from "../Logger.js";
2
- import route from "./route.js";
3
-
4
- const {mark} = Logger;
5
-
6
- const app = {
7
- config: {
8
- paths: {
9
- routes: "/routes",
10
- },
11
- },
12
- routes: [
13
- "index",
14
- "user",
15
- "users/{userId}a",
16
- "comments/{commentId:comment}",
17
- "users/{userId}/comments/{commentId}",
18
- "users/{userId:user}/comments/{commentId}/a",
19
- "users/{userId:user}/comments/{commentId:comment}/b",
20
- "users/{_userId}/comments/{commentId}/d",
21
- "users/{_userId}/comments/{_commentId}/e",
22
- "comments2/{_commentId}",
23
- "users2/{_userId}/{commentId}",
24
- "users3/{_userId}/{_commentId:_commentId}",
25
- "users4/{_userId}/{_commentId}",
26
- "users5/{truthy}",
27
- "{uuid}/{Uuid}/{UUID}",
28
- ].map(pathname => [pathname, {get: request => request}]),
29
- types: {
30
- user: id => /^\d*$/u.test(id),
31
- comment: id => /^\d*$/u.test(id),
32
- _userId: id => /^\d*$/u.test(id),
33
- _commentId: id => /^\d*$/u.test(id),
34
- truthy: () => 1,
35
- uuid: _ => _ === "uuid",
36
- Uuid: _ => _ === "Uuid",
37
- UUID: _ => _ === "UUID",
38
- },
39
- };
40
-
41
- export default test => {
42
- const router = route(app);
43
- const p = "https://p.com";
44
- const r = pathname => {
45
- const original = new Request(`${p}${pathname}`, {method: "GET"});
46
- const {url} = original;
47
- const end = -1;
48
- return router({
49
- original,
50
- url: new URL(url.endsWith("/") ? url.slice(0, end) : url),
51
- });
52
- };
53
-
54
- test.reassert(assert => ({
55
- match: (url, result) => {
56
- assert(r(url).url.pathname).equals(result ?? url);
57
- },
58
- fail: (url, result) => {
59
- const throws = mark("no % route to %", "GET", result ?? url);
60
- assert(() => r(url)).throws(throws);
61
- },
62
- path: (url, result) => assert(r(url).path).equals(result),
63
- assert,
64
- }));
65
-
66
- const get = () => null;
67
- /* errors {{{ */
68
- test.case("error DoubleRouted", ({assert}) => {
69
- const post = ["post", {get}];
70
- const throws = mark("double route %", "post");
71
- assert(() => route({routes: [post, post]})).throws(throws);
72
- });
73
- test.case("error DoublePathParameter", ({assert}) => {
74
- const path = "{user}/{user}";
75
- const throws = mark("double path parameter % in route %", "user", path);
76
- assert(() => route({routes: [[path, {get}]]})).throws(throws);
77
- });
78
- test.case("error EmptyRoutefile", ({assert}) => {
79
- const path = "user";
80
- const throws = mark("empty route file at %", `/routes/${path}.js`);
81
- const base = {
82
- log: {
83
- auto(error) {
84
- throw error;
85
- },
86
- },
87
- config: {
88
- paths: {
89
- routes: "/routes",
90
- },
91
- },
92
- };
93
- assert(() => route({...base, routes: [[path, undefined]]})).throws(throws);
94
- assert(() => route({...base, routes: [[path, {}]]})).throws(throws);
95
- });
96
- test.case("error InvalidRouteName", ({assert}) => {
97
- const post = ["po.st", {get}];
98
- const throws = mark("invalid route name %", "po.st");
99
- assert(() => route({routes: [post], types: {}})).throws(throws);
100
- });
101
- test.case("error InvalidParameter", ({assert}) => {
102
- const path = "{us$er}";
103
- const throws = mark("invalid path parameter % in route %", "us$er", path);
104
- assert(() => route({routes: [[path, {get}]]})).throws(throws);
105
- });
106
- test.case("error InvalidType", ({assert}) => {
107
- const throws = mark("invalid type %", "us$er");
108
- const types = {us$er: () => false};
109
- assert(() => route({routes: [], types})).throws(throws);
110
- });
111
- /* }}} */
112
-
113
- test.case("index route", ({match}) => {
114
- match("/");
115
- });
116
- test.case("simple route", ({match}) => {
117
- match("/user");
118
- });
119
- test.case("param match/fail", ({match, fail}) => {
120
- match("/users/1a");
121
- match("/users/aa");
122
- match("/users/ba?key=value", "/users/ba");
123
- fail("/user/1a");
124
- fail("/users/a");
125
- fail("/users/aA");
126
- fail("/users//a");
127
- fail("/users/?a", "/users/");
128
- });
129
- test.case("no params", ({path}) => {
130
- path("/", {});
131
- });
132
- test.case("single param", ({path}) => {
133
- path("/users/1a", {userId: "1"});
134
- });
135
- test.case("params", ({path, fail}) => {
136
- path("/users/1/comments/2", {userId: "1", commentId: "2"});
137
- path("/users/1/comments/2/b", {userId: "1", commentId: "2"});
138
- fail("/users/d/comments/2/b");
139
- fail("/users/1/comments/d/b");
140
- fail("/users/d/comments/d/b");
141
- });
142
- test.case("single typed param", ({path, fail}) => {
143
- path("/comments/1", {commentId: "1"});
144
- fail("/comments/ ", "/comments");
145
- fail("/comments/1d");
146
- });
147
- test.case("mixed untyped and typed params", ({path, fail}) => {
148
- path("/users/1/comments/2/a", {userId: "1", commentId: "2"});
149
- fail("/users/d/comments/2/a");
150
- });
151
- test.case("single implicit typed param", ({path, fail}) => {
152
- path("/comments2/1", {_commentId: "1"});
153
- fail("/comments2/d");
154
- });
155
- test.case("mixed implicit and untyped params", ({path, fail}) => {
156
- path("/users2/1/2", {_userId: "1", commentId: "2"});
157
- fail("/users2/d/2");
158
- fail("/users2/d");
159
- });
160
- test.case("mixed implicit and explicit params", ({path, fail}) => {
161
- path("/users3/1/2", {_userId: "1", _commentId: "2"});
162
- fail("/users3/d/2");
163
- fail("/users3/1/d");
164
- fail("/users3");
165
- });
166
- test.case("implicit params", ({path, fail}) => {
167
- path("/users4/1/2", {_userId: "1", _commentId: "2"});
168
- fail("/users4/d/2");
169
- fail("/users4/1/d");
170
- fail("/users4");
171
- });
172
- test.case("fail not strictly true implicit params", ({fail}) => {
173
- fail("/users5/any");
174
- });
175
- test.case("different case params", ({path, fail}) => {
176
- path("/uuid/Uuid/UUID", {uuid: "uuid", Uuid: "Uuid", UUID: "UUID"});
177
- fail("/uuid/uuid/uuid");
178
- fail("/Uuid/UUID/uuid");
179
- fail("/UUID/uuid/Uuid");
180
- });
181
- };