primate 0.17.0 → 0.18.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": "primate",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",
@@ -16,7 +16,7 @@
16
16
  "directory": "packages/primate"
17
17
  },
18
18
  "dependencies": {
19
- "runtime-compat": "^0.16.3"
19
+ "runtime-compat": "^0.17.0"
20
20
  },
21
21
  "type": "module",
22
22
  "exports": "./src/exports.js"
package/src/Logger.js CHANGED
@@ -1,115 +1,126 @@
1
1
  import {assert, is} from "runtime-compat/dyndef";
2
+ import {blue, bold, green, red, yellow, dim} from "runtime-compat/colors";
2
3
 
3
- const colors = {
4
- black: msg => `\x1b[0m${msg}\x1b[0m`,
5
- bold: msg => `\x1b[1m${msg}\x1b[0m`,
6
- red: msg => `\x1b[31m${msg}\x1b[0m`,
7
- green: msg => `\x1b[32m${msg}\x1b[0m`,
8
- yellow: msg => `\x1b[33m${msg}\x1b[0m`,
9
- blue: msg => `\x1b[34m${msg}\x1b[0m`,
10
- gray: msg => `\x1b[2m${msg}\x1b[0m`,
4
+ const errors = {
5
+ Error: 0,
6
+ Warn: 1,
7
+ Info: 2,
11
8
  };
12
9
 
13
- const error = 0;
14
- const warn = 1;
15
- const info = 2;
16
-
17
- const Abort = class Abort extends Error {};
18
- // Error natively provided
19
- const Warn = class Warn extends Error {};
20
- const Info = class Info extends Error {};
21
-
22
- const levels = new Map([
23
- [Error, error],
24
- [Warn, warn],
25
- [Info, info],
26
- ]);
10
+ const print = (...messages) => process.stdout.write(messages.join(" "));
11
+ const bye = () => print(dim(yellow("~~ bye\n")));
12
+ const mark = (format, ...params) => params.reduce((formatted, param) =>
13
+ formatted.replace("%", bold(param)), format);
27
14
 
28
- const abort = message => {
29
- throw new Abort(message);
30
- };
15
+ const reference = "https://primatejs.com/reference/errors";
31
16
 
32
- const print = (...messages) => process.stdout.write(messages.join(" "));
17
+ const hyphenate = classCased => classCased
18
+ .split("")
19
+ .map(character => character
20
+ .replace(/[A-Z]/u, capital => `-${capital.toLowerCase()}`))
21
+ .join("")
22
+ .slice(1);
33
23
 
34
24
  const Logger = class Logger {
35
25
  #level; #trace;
36
26
 
37
- constructor({level = Error, trace = false} = {}) {
38
- assert(level !== undefined && levels.get(level) <= info);
27
+ static throwable(type, name, module) {
28
+ return {
29
+ throw(args = {}) {
30
+ const {message, level, fix} = type(args);
31
+ const error = new Error(mark(...message));
32
+ error.level = level;
33
+ error.fix = mark(...fix);
34
+ error.name = name;
35
+ error.module = module;
36
+ throw error;
37
+ },
38
+ warn(logger, ...args) {
39
+ const {message, level, fix} = type(...args);
40
+ const error = {level, message: mark(...message), fix: mark(...fix)};
41
+ logger.auto({...error, name, module});
42
+ },
43
+ };
44
+ }
45
+
46
+ constructor({level = errors.Error, trace = false} = {}) {
47
+ assert(level !== undefined && level <= errors.Info);
39
48
  is(trace).boolean();
40
49
  this.#level = level;
41
50
  this.#trace = trace;
42
51
  }
43
52
 
44
- static get colors() {
45
- return colors;
46
- }
47
-
48
53
  static print(...args) {
49
54
  print(...args);
50
55
  }
51
56
 
57
+ static get mark() {
58
+ return mark;
59
+ }
60
+
52
61
  static get Error() {
53
- return Error;
62
+ return errors.Error;
54
63
  }
55
64
 
56
65
  static get Warn() {
57
- return Warn;
66
+ return errors.Warn;
58
67
  }
59
68
 
60
69
  static get Info() {
61
- return Info;
70
+ return errors.Info;
62
71
  }
63
72
 
64
73
  get class() {
65
74
  return this.constructor;
66
75
  }
67
76
 
68
- #print(pre, error) {
69
- if (error instanceof Error) {
70
- print(colors.bold(pre), error.message, "\n");
71
- if (this.#trace) {
72
- console.log(error);
73
- }
74
- } else {
75
- print(colors.bold(pre), error, "\n");
77
+ #print(pre, color, message, {fix, module, name} = {}, error) {
78
+ print(pre, `${module !== undefined ? `${color(module)} ` : ""}${message}`, "\n");
79
+ if (fix && this.level >= errors.Warn) {
80
+ print(blue("++"), fix);
81
+ name && print(dim(`\n -> ${reference}/${module ?? "primate"}#${hyphenate(name)}`), "\n");
82
+ }
83
+ if (this.#trace && error) {
84
+ print(pre, color(module), "trace follows\n");
85
+ console.log(error);
76
86
  }
77
87
  }
78
88
 
79
89
  get level() {
80
- return levels.get(this.#level);
90
+ return this.#level;
81
91
  }
82
92
 
83
- info(message) {
84
- if (this.level >= levels.get(Info)) {
85
- this.#print(colors.green("--"), message);
93
+ info(message, args) {
94
+ if (this.level >= errors.Info) {
95
+ this.#print(green("--"), green, message, args);
86
96
  }
87
97
  }
88
98
 
89
- warn(message) {
90
- if (this.level >= levels.get(Warn)) {
91
- this.#print(colors.yellow("??"), message);
99
+ warn(message, args) {
100
+ if (this.level >= errors.Warn) {
101
+ this.#print(yellow("??"), yellow, message, args);
92
102
  }
93
103
  }
94
104
 
95
- error(message) {
96
- if (this.level >= levels.get(Error)) {
97
- this.#print(colors.red("!!"), message);
105
+ error(message, args, error) {
106
+ if (this.level >= errors.Warn) {
107
+ this.#print(red("!!"), red, message, args, error);
98
108
  }
99
109
  }
100
110
 
101
- auto(message) {
102
- if (message instanceof Info) {
103
- return this.info(message.message);
111
+ auto(error) {
112
+ const {level, message, ...args} = error;
113
+ if (level === errors.Info) {
114
+ return this.info(message, args, error);
104
115
  }
105
- if (message instanceof Warn) {
106
- return this.warn(message.message);
116
+ if (level === errors.Warn) {
117
+ return this.warn(message, args, error);
107
118
  }
108
119
 
109
- return this.error(message);
120
+ return this.error(message, args, error);
110
121
  }
111
122
  };
112
123
 
113
124
  export default Logger;
114
125
 
115
- export {colors, levels, print, abort, Abort};
126
+ export {print, bye};
package/src/app.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import crypto from "runtime-compat/crypto";
2
2
  import {File, Path} from "runtime-compat/fs";
3
+ import {bold, blue} from "runtime-compat/colors";
4
+ import errors from "./errors.js";
3
5
  import * as handlers from "./handlers/exports.js";
4
- import {abort} from "./Logger.js";
6
+ import * as hooks from "./hooks/exports.js";
5
7
 
6
8
  const qualify = (root, paths) =>
7
9
  Object.keys(paths).reduce((sofar, key) => {
@@ -33,12 +35,12 @@ const hash = async (string, algorithm = "sha-384") => {
33
35
  };
34
36
 
35
37
  export default async (config, root, log) => {
36
- const {name, version} = await src.up(1).join("package.json").json();
38
+ const {http} = config;
37
39
 
38
40
  // if ssl activated, resolve key and cert early
39
- if (config.http.ssl) {
40
- config.http.ssl.key = root.join(config.http.ssl.key);
41
- config.http.ssl.cert = root.join(config.http.ssl.cert);
41
+ if (http.ssl) {
42
+ http.ssl.key = root.join(http.ssl.key);
43
+ http.ssl.cert = root.join(http.ssl.cert);
42
44
  }
43
45
 
44
46
  const paths = qualify(root, config.paths);
@@ -53,22 +55,27 @@ export default async (config, root, log) => {
53
55
 
54
56
  const modules = config.modules === undefined ? [] : config.modules;
55
57
 
56
- modules.every(module => module.name !== undefined ||
57
- abort("all modules must have names"));
58
+ modules.every((module, n) => module.name !== undefined ||
59
+ errors.ModulesMustHaveNames.throw({n}));
58
60
 
59
- if (new Set(modules.map(module => module.name)).size !== modules.length) {
60
- abort("same module twice");
61
- }
61
+ new Set(modules.map(({name}) => name)).size !== modules.length &&
62
+ errors.DoubleModule.throw({
63
+ modules: modules.map(({name}) => name),
64
+ config: root.join("primate.config.js"),
65
+ });
62
66
 
63
- modules.every(module => Object.entries(module).length > 1) || (() => {
64
- log.warn("some modules haven't subscribed to any hooks");
65
- })();
67
+ const hookless = modules.filter(module =>
68
+ !Object.keys(module).some(key => Object.keys(hooks).includes(key)));
69
+ hookless.length > 0 && errors.ModuleHasNoHooks.warn(log, {hookless});
70
+
71
+ const {name, version} = await src.up(1).join("package.json").json();
66
72
 
67
73
  const app = {
68
74
  config,
69
75
  routes,
70
- secure: config.http?.ssl !== undefined,
71
- name, version,
76
+ secure: http?.ssl !== undefined,
77
+ name,
78
+ version,
72
79
  library: {},
73
80
  identifiers: {},
74
81
  replace(code) {
@@ -86,6 +93,25 @@ export default async (config, root, log) => {
86
93
  paths,
87
94
  root,
88
95
  log,
96
+ generateHeaders: () => {
97
+ const csp = Object.keys(http.csp).reduce((policy_string, key) =>
98
+ `${policy_string}${key} ${http.csp[key]};`, "");
99
+ const scripts = app.resources
100
+ .map(resource => `'${resource.integrity}'`).join(" ");
101
+ const _csp = scripts === "" ? csp : `${csp}script-src 'self' ${scripts};`;
102
+ // remove inline resources
103
+ for (let i = app.resources.length - 1; i >= 0; i--) {
104
+ const resource = app.resources[i];
105
+ if (resource.inline) {
106
+ app.resources.splice(i, 1);
107
+ }
108
+ }
109
+
110
+ return {
111
+ "Content-Security-Policy": _csp,
112
+ "Referrer-Policy": "same-origin",
113
+ };
114
+ },
89
115
  handlers: {...handlers},
90
116
  render: async ({body = "", head = ""} = {}) => {
91
117
  const html = await index(app);
@@ -111,7 +137,7 @@ export default async (config, root, log) => {
111
137
  // while integrity is only really needed for scripts, it is also later
112
138
  // used for the etag header
113
139
  const integrity = await hash(code);
114
- const _src = new Path(config.http.static.root).join(src ?? "");
140
+ const _src = new Path(http.static.root).join(src ?? "");
115
141
  app.resources.push({src: `${_src}`, code, type, inline, integrity});
116
142
  return integrity;
117
143
  },
@@ -129,11 +155,8 @@ export default async (config, root, log) => {
129
155
  },
130
156
  modules,
131
157
  };
132
- const {print, colors} = log.class;
133
- print(colors.blue(colors.bold(name)), colors.blue(version), "");
134
- const type = app.secure ? "https" : "http";
135
- const address = `${type}://${config.http.host}:${config.http.port}`;
136
- print(colors.gray(`at ${address}`), "\n");
158
+ log.class.print(blue(bold(name)), blue(version),
159
+ `at http${app.secure ? "s" : ""}://${http.host}:${http.port}\n`);
137
160
  // modules may load other modules
138
161
  await Promise.all(app.modules
139
162
  .filter(module => module.load !== undefined)
@@ -1,11 +1,8 @@
1
1
  import {default as dev} from "./dev.js";
2
2
  import {default as serve} from "./serve.js";
3
- import {default as build} from "./build.js";
4
- import {default as create} from "./create.js";
5
- import {default as help} from "./help.js";
6
3
 
7
- const commands = {dev, serve, build, create, help};
4
+ const commands = {dev, serve};
8
5
 
9
- const run = name => commands[name] ?? help;
6
+ const run = name => commands[name] ?? dev;
10
7
 
11
8
  export default name => name === undefined ? dev : run(name);
package/src/errors.js ADDED
@@ -0,0 +1,112 @@
1
+ import Logger from "./Logger.js";
2
+
3
+ export default Object.fromEntries(Object.entries({
4
+ CannotParseBody({body, contentType}) {
5
+ return {
6
+ message: ["cannot parse body % as %", body, contentType],
7
+ fix: ["use a different content type or fix body"],
8
+ level: Logger.Warn,
9
+ };
10
+ },
11
+ DoubleModule({modules, config}) {
12
+ const double = modules.find((module, i, array) =>
13
+ array.filter((_, j) => i !== j).includes(module));
14
+ return {
15
+ message: ["double module % in %", double, config],
16
+ fix: ["load % only once", double],
17
+ level: Logger.Error,
18
+ };
19
+ },
20
+ DoublePathParameter({path, double}) {
21
+ return {
22
+ message: ["double path parameter % in route %", double, path],
23
+ fix: ["disambiguate path parameters in route names"],
24
+ level: Logger.Error,
25
+ };
26
+ },
27
+ DoubleRoute({double}) {
28
+ return {
29
+ message: ["double route %", double],
30
+ fix: ["disambiguate route % and %", double, `${double}/index`],
31
+ level: Logger.Error,
32
+ };
33
+ },
34
+ EmptyRouteFile({config: {paths}, route}) {
35
+ return {
36
+ message: ["empty route file at %", `${paths.routes}/${route}.js`],
37
+ fix: ["add routes or remove file"],
38
+ level: Logger.Warn,
39
+ };
40
+ },
41
+ ErrorInConfigFile({config, message}) {
42
+ return {
43
+ message: ["error in config %", message],
44
+ fix: ["check errors in config file by running %", `node ${config}`],
45
+ level: Logger.Error,
46
+ };
47
+ },
48
+ ModuleHasNoHooks({hookless}) {
49
+ return {
50
+ message: ["module % has no hooks", hookless.join(", ")],
51
+ fix: ["ensure every module uses at least one hook or deactivate it"],
52
+ level: Logger.Warn,
53
+ };
54
+ },
55
+ ModulesMustHaveNames({n}) {
56
+ return {
57
+ message: ["modules must have names"],
58
+ fix: ["update module at index % and inform maintainer", n],
59
+ level: Logger.Error,
60
+ };
61
+ },
62
+ EmptyConfigFile({config}) {
63
+ return {
64
+ message: ["empty config file at %", config],
65
+ fix: ["add configuration options or remove file"],
66
+ level: Logger.Warn,
67
+ };
68
+ },
69
+ NoFileForPath({pathname, config: {paths}}) {
70
+ return {
71
+ message: ["no file for %", pathname],
72
+ fix: ["if unintentional create a file at %%", paths.static, pathname],
73
+ level: Logger.Info,
74
+ };
75
+ },
76
+ NoHandlerForExtension({name, ending}) {
77
+ return {
78
+ message: ["no handler for % extension", ending],
79
+ fix: ["add handler module for % files or remove %", `.${ending}`, name],
80
+ level: Logger.Error,
81
+ };
82
+ },
83
+ NoRouteToPath({method, pathname, config: {paths}}) {
84
+ const route = `${paths.routes}/${pathname === "/" ? "index" : ""}.js`;
85
+ return {
86
+ message: ["no % route to %", method, pathname],
87
+ fix: ["if unintentional create a route at %", route],
88
+ level: Logger.Info,
89
+ };
90
+ },
91
+ InvalidPathParameter({named, path}) {
92
+ 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"],
109
+ level: Logger.Error,
110
+ };
111
+ },
112
+ }).map(([name, error]) => [name, Logger.throwable(error, name, "primate")]));
@@ -1,8 +1,9 @@
1
- const _404 = "Not Found";
1
+ import {NotFound} from "../http-statuses.js";
2
2
 
3
- export default (body = _404, {status = 404} = {}) => async (app, headers) => [
4
- await app.render({body}), {
5
- status,
6
- headers: {...headers, "Content-Type": "text/html"},
7
- },
8
- ];
3
+ export default (body = "Not Found", {status = NotFound} = {}) =>
4
+ async (app, headers) => [
5
+ await app.render({body}), {
6
+ status,
7
+ headers: {...headers, "Content-Type": "text/html"},
8
+ },
9
+ ];
@@ -1,4 +1,6 @@
1
- export default (Location, {status = 302} = {}) => (_, headers) => [
1
+ import {Found} from "../http-statuses.js";
2
+
3
+ export default (Location, {status = Found} = {}) => (_, headers) => [
2
4
  /* no body */
3
5
  null, {
4
6
  status,
@@ -1,8 +1,8 @@
1
+ import errors from "../errors.js";
2
+
1
3
  export default (name, props, options) => async (app, headers) => {
2
4
  const ending = name.slice(name.lastIndexOf(".") + 1);
3
5
  const handler = app.handlers[ending];
4
- if (handler === undefined) {
5
- return app.log.error(new Error(`no handler for ${ending} components`));
6
- }
7
- return handler(name, {load: true, ...props}, options)(app, headers);
6
+ return handler?.(name, {load: true, ...props}, options)(app, headers)
7
+ ?? errors.NoHandlerForExtension.throw({name, ending});
8
8
  };
@@ -22,7 +22,7 @@ const pre = async app => {
22
22
  export default async (app, bundle) => {
23
23
  await pre(app);
24
24
  if (bundle) {
25
- app.log.info("running bundle hooks");
25
+ app.log.info("running bundle hooks", {module: "primate"});
26
26
  await [...filter("bundle", app.modules), _ => _]
27
27
  .reduceRight((acc, handler) => input => handler(input, acc))(app);
28
28
  }
@@ -1,7 +1,7 @@
1
1
  const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
2
 
3
3
  export default async app => {
4
- app.log.info("running compile hooks");
4
+ app.log.info("running compile hooks", {module: "primate"});
5
5
  await [...filter("compile", app.modules), _ => _]
6
6
  .reduceRight((acc, handler) => input => handler(input, acc))(app);
7
7
  };
@@ -4,3 +4,4 @@ export {default as publish} from "./publish.js";
4
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
+ export {default as parse} from "./parse.js";
@@ -1,4 +1,3 @@
1
- export {default as statuses} from "./http-statuses.js";
2
1
  export {default as mime} from "./mime.js";
3
2
  export {isResponse} from "./duck.js";
4
3
  export {default as respond} from "./respond.js";
@@ -1,58 +1,37 @@
1
- import {Response, URL} from "runtime-compat/http";
1
+ import {Response} from "runtime-compat/http";
2
2
  import {error as clientError} from "../handlers/exports.js";
3
- import {statuses, mime, isResponse, respond} from "./handle/exports.js";
4
- import fromNull from "../fromNull.js";
3
+ import {mime, isResponse, respond} from "./handle/exports.js";
4
+ import {invalid} from "./route.js";
5
+ import errors from "../errors.js";
6
+ import {OK} from "../http-statuses.js";
5
7
 
6
8
  const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
7
9
 
8
- const contents = {
9
- "application/x-www-form-urlencoded": body =>
10
- fromNull(Object.fromEntries(body.split("&").map(part => part.split("=")
11
- .map(subpart => decodeURIComponent(subpart).replaceAll("+", " "))))),
12
- "application/json": body => JSON.parse(body),
13
- };
14
-
15
- export default async app => {
10
+ export default app => {
16
11
  const {http} = app.config;
17
12
 
18
- const _respond = async request => {
19
- const csp = Object.keys(http.csp).reduce((policy_string, key) =>
20
- `${policy_string}${key} ${http.csp[key]};`, "");
21
- const scripts = app.resources
22
- .map(resource => `'${resource.integrity}'`).join(" ");
23
- const _csp = scripts === "" ? csp : `${csp}script-src 'self' ${scripts};`;
24
- // remove inline resources
25
- for (let i = app.resources.length - 1; i >= 0; i--) {
26
- const resource = app.resources[i];
27
- if (resource.inline) {
28
- app.resources.splice(i, 1);
29
- }
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;
30
17
  }
18
+ return respond(await app.route(request))(app, headers);
19
+ };
31
20
 
32
- const headers = {
33
- "Content-Security-Policy": _csp,
34
- "Referrer-Policy": "same-origin",
35
- };
21
+ const route = async request => {
22
+ const headers = app.generateHeaders();
36
23
 
37
24
  try {
38
- const modules = filter("route", app.modules);
39
- // app.route is the last module to be executed
40
- const handlers = [...modules, app.route].reduceRight((acc, handler) =>
41
- input => handler(input, acc));
42
- return await respond(await handlers(request))(app, headers);
25
+ const response = await _respond(request, headers);
26
+ return isResponse(response) ? response : new Response(...response);
43
27
  } catch (error) {
44
28
  app.log.auto(error);
45
- return clientError()(app, headers);
29
+ return new Response(...await clientError()(app, {}));
46
30
  }
47
31
  };
48
32
 
49
- const route = async request => {
50
- const response = await _respond(request);
51
- return isResponse(response) ? response : new Response(...response);
52
- };
53
-
54
33
  const staticResource = async file => new Response(file.readable, {
55
- status: statuses.OK,
34
+ status: OK,
56
35
  headers: {
57
36
  "Content-Type": mime(file.name),
58
37
  Etag: await file.modified,
@@ -64,7 +43,7 @@ export default async app => {
64
43
  !inline && src === request.url.pathname);
65
44
  if (published !== undefined) {
66
45
  return new Response(published.code, {
67
- status: statuses.OK,
46
+ status: OK,
68
47
  headers: {
69
48
  "Content-Type": mime(published.src),
70
49
  Etag: published.integrity,
@@ -88,67 +67,9 @@ export default async app => {
88
67
  };
89
68
 
90
69
  const handle = async request => {
91
- try {
92
- return await resource(request);
93
- } catch (error) {
94
- app.log.auto(error);
95
- return new Response(null, {status: statuses.InternalServerError});
96
- }
70
+ return await resource(request);
97
71
  };
98
72
 
99
- const parseContentType = (contentType, body) => {
100
- const type = contents[contentType];
101
- return type === undefined ? body : type(body);
102
- };
103
-
104
- const parseContent = (request, body) => {
105
- try {
106
- return parseContentType(request.headers.get("content-type"), body);
107
- } catch (error) {
108
- app.log.warn(error);
109
- return body;
110
- }
111
- };
112
-
113
- const decoder = new TextDecoder();
114
-
115
- const parseBody = async request => {
116
- if (request.body === null) {
117
- return null;
118
- }
119
- const reader = request.body.getReader();
120
- const chunks = [];
121
- let result;
122
- do {
123
- result = await reader.read();
124
- if (result.value !== undefined) {
125
- chunks.push(decoder.decode(result.value));
126
- }
127
- } while (!result.done);
128
-
129
- return parseContent(request, chunks.join());
130
- };
131
-
132
- const parseRequest = async request => {
133
- const cookies = request.headers.get("cookie");
134
- const _url = request.url;
135
- const url = new URL(_url.endsWith("/") ? _url.slice(0, -1) : _url);
136
-
137
- return {
138
- original: request,
139
- url,
140
- body: await parseBody(request),
141
- cookies: fromNull(cookies === null
142
- ? {}
143
- : Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
144
- headers: fromNull(Object.fromEntries(request.headers)),
145
- query: fromNull(Object.fromEntries(url.searchParams)),
146
- };
147
- };
148
-
149
- // handle is the last module to be executed
150
- const handlers = [...filter("handle", app.modules), handle]
73
+ return [...filter("handle", app.modules), handle]
151
74
  .reduceRight((acc, handler) => input => handler(input, acc));
152
-
153
- return async request => handlers(await parseRequest(request));
154
75
  };
@@ -0,0 +1,59 @@
1
+ import {URL} from "runtime-compat/http";
2
+ import fromNull from "../fromNull.js";
3
+ import errors from "../errors.js";
4
+
5
+ const contents = {
6
+ "application/x-www-form-urlencoded": body =>
7
+ fromNull(Object.fromEntries(body.split("&").map(part => part.split("=")
8
+ .map(subpart => decodeURIComponent(subpart).replaceAll("+", " "))))),
9
+ "application/json": body => JSON.parse(body),
10
+ };
11
+ const decoder = new TextDecoder();
12
+
13
+ export default async request => {
14
+ const parseContentType = (contentType, body) => {
15
+ const type = contents[contentType];
16
+ return type === undefined ? body : type(body);
17
+ };
18
+
19
+ const parseContent = async (request, body) => {
20
+ const contentType = request.headers.get("content-type");
21
+ try {
22
+ return parseContentType(contentType, body);
23
+ } catch (error) {
24
+ return errors.CannotParseBody.throw({body, contentType});
25
+ }
26
+ };
27
+
28
+ const parseBody = async request => {
29
+ if (request.body === null) {
30
+ return null;
31
+ }
32
+ const reader = request.body.getReader();
33
+ const chunks = [];
34
+ let result;
35
+ do {
36
+ result = await reader.read();
37
+ if (result.value !== undefined) {
38
+ chunks.push(decoder.decode(result.value));
39
+ }
40
+ } while (!result.done);
41
+
42
+ return parseContent(request, chunks.join());
43
+ };
44
+
45
+ const cookies = request.headers.get("cookie");
46
+ const _url = request.url;
47
+ const url = new URL(_url.endsWith("/") ? _url.slice(0, -1) : _url);
48
+
49
+ return {
50
+ original: request,
51
+ url,
52
+ body: await parseBody(request),
53
+ cookies: fromNull(cookies === null
54
+ ? {}
55
+ : Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
56
+ headers: fromNull(Object.fromEntries(request.headers)),
57
+ query: fromNull(Object.fromEntries(url.searchParams)),
58
+ };
59
+ };
@@ -1,32 +1,14 @@
1
- import handle from "./handle.js";
1
+ import parse from "./parse.js";
2
2
  import Logger from "../Logger.js";
3
3
 
4
- const app = {
5
- log: new Logger({
6
- level: Logger.Warn,
7
- }),
8
- config: {
9
- http: {},
10
- },
11
- modules: [{
12
- handle(request) {
13
- return request;
14
- },
15
- }],
16
- routes: [
17
- ["index", {get: () => "/"}],
18
- ["user", {get: () => "/user"}],
19
- ["users/{userId}a", {get: request => request}],
20
- ],
21
- };
4
+ const {mark} = Logger;
22
5
 
23
6
  const r = await (async () => {
24
7
  const p = "https://p.com";
25
8
  const request = (method, path = "/", options = {}) =>
26
9
  new Request(`${p}${path}`, {method, ...options});
27
- const handler = await handle(app);
28
10
  return Object.fromEntries(["get", "post", "put", "delete"].map(verb =>
29
- [verb, (...args) => handler(request(verb.toUpperCase(), ...args))]));
11
+ [verb, (...args) => parse(request(verb.toUpperCase(), ...args))]));
30
12
  })();
31
13
 
32
14
  export default test => {
@@ -35,12 +17,14 @@ export default test => {
35
17
  assert((await r.post("/")).body).null();
36
18
  });
37
19
  test.case("body is application/json", async assert => {
38
- assert((await r.post("/", {
39
- body: JSON.stringify({foo: "bar"}),
40
- headers: {
41
- "Content-Type": "application/json",
42
- },
43
- })).body).equals({foo: "bar"});
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));
44
28
  });
45
29
  test.case("body is application/x-www-form-urlencoded", async assert => {
46
30
  assert((await r.post("/", {
@@ -29,7 +29,7 @@ const post = async app => {
29
29
  };
30
30
 
31
31
  export default async app => {
32
- app.log.info("running publish hooks");
32
+ app.log.info("running publish hooks", {module: "primate"});
33
33
  await [...filter("publish", app.modules), _ => _]
34
34
  .reduceRight((acc, handler) => input => handler(input, acc))(app);
35
35
  await post(app);
@@ -1,7 +1,7 @@
1
1
  const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
2
 
3
3
  export default async app => {
4
- app.log.info("running register hooks");
4
+ app.log.info("running register hooks", {module: "primate"});
5
5
  await [...filter("register", app.modules), _ => _]
6
6
  .reduceRight((acc, handler) => input => handler(input, acc))(app);
7
7
  };
@@ -1,4 +1,6 @@
1
- import {default as Logger, abort} from "../Logger.js";
1
+ import errors from "../errors.js";
2
+
3
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
4
 
3
5
  // insensitive-case equal
4
6
  const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
@@ -7,11 +9,20 @@ const verbs = [
7
9
  // CRUD
8
10
  "post", "get", "put", "delete",
9
11
  // extended
10
- "connect", "options", "trace", "patch",
12
+ "connect", "options", "trace", "patch", "head",
11
13
  ];
12
14
 
13
- const toRoute = file => {
14
- const route = file
15
+ /* routes may not contain dots */
16
+ export const invalid = route => /\./u.test(route);
17
+ const toRoute = path => {
18
+ const double = path.split("/")
19
+ .filter(part => part.startsWith("{") && part.endsWith("}"))
20
+ .map(part => part.slice(1, part.indexOf(":")))
21
+ .find((part, i, array) =>
22
+ array.filter((_, j) => i !== j).includes(part));
23
+ double && errors.DoublePathParameter.throw({path, double});
24
+
25
+ const route = path
15
26
  // transform /index -> ""
16
27
  .replace("/index", "")
17
28
  // transform index -> ""
@@ -23,28 +34,32 @@ const toRoute = file => {
23
34
  const param = type === undefined ? name : `${name}$${type.slice(1)}`;
24
35
  return `(?<${param}>[^/]{1,}?)`;
25
36
  } catch (error) {
26
- abort(`invalid parameter "${named}"`);
37
+ return errors.InvalidPathParameter.throw({named, path});
27
38
  }
28
- })
29
- ;
30
- try {
31
- return new RegExp(`^/${route}$`, "u");
32
- } catch (error) {
33
- abort("same parameter twice");
34
- }
39
+ });
40
+
41
+ invalid(route) && errors.InvalidRouteName.throw({path});
42
+
43
+ return new RegExp(`^/${route}$`, "u");
35
44
  };
36
45
 
37
46
  const reentry = (object, mapper) =>
38
47
  Object.fromEntries(mapper(Object.entries(object ?? {})));
39
48
 
40
49
  export default app => {
41
- const {types = {}} = app;
42
- Object.entries(types).every(([name]) => /^(?:\w*)$/u.test(name) ||
43
- abort(`invalid type "${name}"`));
50
+ const double = app.routes
51
+ .map(([route]) => route
52
+ .replaceAll("/index", "")
53
+ .replaceAll(/\{(?<name>\w*)(?<_>:\w+)?\}?/gu, (_, name) => `{${name}}`))
54
+ .find((part, i, array) =>
55
+ array.filter((_, j) => i !== j).includes(part));
56
+
57
+ double && errors.DoubleRoute.throw({double});
58
+
44
59
  const routes = app.routes
45
60
  .map(([route, imported]) => {
46
- if (imported === undefined) {
47
- app.log.warn(`empty route file at ${route}.js`);
61
+ if (imported === undefined || Object.keys(imported).length === 0) {
62
+ errors.EmptyRouteFile.warn(app.log, {config: app.config, route});
48
63
  return [];
49
64
  }
50
65
 
@@ -53,10 +68,10 @@ export default app => {
53
68
  .filter(([verb]) => verbs.includes(verb))
54
69
  .map(([method, handler]) => ({method, handler, path}));
55
70
  }).flat();
56
- const paths = routes.map(({method, path}) => `${method}${path}`);
57
- if (new Set(paths).size !== paths.length) {
58
- abort("same route twice");
59
- }
71
+
72
+ const {types = {}} = app;
73
+ Object.entries(types).every(([name]) => /^(?:\w*)$/u.test(name) ||
74
+ errors.InvalidType.throw({name}));
60
75
 
61
76
  const isType = groups => Object
62
77
  .entries(groups ?? {})
@@ -74,15 +89,19 @@ export default app => {
74
89
  && isPath({route, path});
75
90
  const find = (method, path) => routes.find(route =>
76
91
  isMethod({route, method, path}));
92
+ const modules = filter("route", app.modules);
77
93
 
78
94
  return request => {
79
95
  const {original: {method}, url: {pathname}} = request;
80
- const verb = find(method, pathname) ?? (() => {
81
- throw new Logger.Warn(`no ${method} route to ${pathname}`);
82
- })();
96
+ const verb = find(method, pathname) ??
97
+ errors.NoRouteToPath.throw({method, pathname, config: app.config});
83
98
  const path = reentry(verb.path?.exec(pathname).groups,
84
99
  object => object.map(([key, value]) => [key.split("$")[0], value]));
85
100
 
86
- return verb.handler({...request, path});
101
+ // verb.handler is the last module to be executed
102
+ const handlers = [...modules, verb.handler].reduceRight((acc, handler) =>
103
+ input => handler(input, acc));
104
+
105
+ return handlers({...request, path});
87
106
  };
88
107
  };
@@ -1,6 +1,14 @@
1
+ import Logger from "../Logger.js";
1
2
  import route from "./route.js";
2
3
 
4
+ const {mark} = Logger;
5
+
3
6
  const app = {
7
+ config: {
8
+ paths: {
9
+ routes: "/routes",
10
+ },
11
+ },
4
12
  routes: [
5
13
  "index",
6
14
  "user",
@@ -36,9 +44,10 @@ export default test => {
36
44
  const r = pathname => {
37
45
  const original = new Request(`${p}${pathname}`, {method: "GET"});
38
46
  const {url} = original;
47
+ const end = -1;
39
48
  return router({
40
49
  original,
41
- url: new URL(url.endsWith("/") ? url.slice(0, -1) : url),
50
+ url: new URL(url.endsWith("/") ? url.slice(0, end) : url),
42
51
  });
43
52
  };
44
53
 
@@ -46,29 +55,58 @@ export default test => {
46
55
  match: (url, result) => {
47
56
  assert(r(url).url.pathname).equals(result ?? url);
48
57
  },
49
- fail: (url, result) =>
50
- assert(() => r(url)).throws(`no GET route to ${result ?? url}`),
58
+ fail: (url, result) => {
59
+ const throws = mark("no % route to %", "GET", result ?? url);
60
+ assert(() => r(url)).throws(throws);
61
+ },
51
62
  path: (url, result) => assert(r(url).path).equals(result),
52
63
  assert,
53
64
  }));
54
65
 
55
66
  const get = () => null;
56
- /* abort {{{ */
57
- test.case("must not contain the same route twice", ({assert}) => {
67
+ /* errors {{{ */
68
+ test.case("error DoubleRouted", ({assert}) => {
58
69
  const post = ["post", {get}];
59
- assert(() => route({routes: [post, post]})).throws("same route twice");
60
- });
61
- test.case("must not contain the same param twice", ({assert}) => {
62
- assert(() => route({routes: [["{userId}/{userId}", {get}]]}))
63
- .throws("same parameter twice");
64
- });
65
- test.case("must not contain invalid characters in params", ({assert}) => {
66
- assert(() => route({routes: [["{user$Id}", {get}]]}))
67
- .throws("invalid parameter \"user$Id\"");
68
- });
69
- test.case("must not contain invalid characters in types", ({assert}) => {
70
- assert(() => route({routes: [], types: {us$er: () => null}}))
71
- .throws("invalid type \"us$er\"");
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);
72
110
  });
73
111
  /* }}} */
74
112
 
@@ -0,0 +1,4 @@
1
+ export const OK = 200;
2
+ export const Found = 302;
3
+ export const NotFound = 404;
4
+ export const InternalServerError = 500;
package/src/run.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import {Path} from "runtime-compat/fs";
2
2
  import app from "./app.js";
3
+ import {default as Logger, bye} from "./Logger.js";
4
+ import extend from "./extend.js";
5
+ import errors from "./errors.js";
3
6
  import command from "./commands/exports.js";
4
- import {Abort, colors, print, default as Logger} from "./Logger.js";
5
7
  import defaults from "./defaults/primate.config.js";
6
- import extend from "./extend.js";
7
8
 
8
9
  const getRoot = async () => {
9
10
  try {
@@ -15,19 +16,21 @@ const getRoot = async () => {
15
16
  }
16
17
  };
17
18
 
18
- const configName = "primate.config.js";
19
+ const protologger = new Logger({level: Logger.Warn});
20
+
19
21
  const getConfig = async root => {
20
- const config = root.join(configName);
22
+ const name = "primate.config.js";
23
+ const config = root.join(name);
21
24
  if (await config.exists) {
22
25
  try {
23
- const imported = await import(config);
24
- if (imported.default === undefined) {
25
- print(`${colors.yellow("??")} ${configName} has no default export\n`);
26
- }
27
- return extend(defaults, imported.default);
28
- } catch (error) {
29
- print(`${colors.red("!!")} couldn't load config file\n`);
30
- throw error;
26
+ const imported = (await import(config)).default;
27
+
28
+ (imported === undefined || Object.keys(imported).length === 0) &&
29
+ errors.EmptyConfigFile.warn(protologger, {config});
30
+
31
+ return extend(defaults, imported);
32
+ } catch ({message}) {
33
+ return errors.ErrorInConfigFile.throw({message, config});
31
34
  }
32
35
  } else {
33
36
  return defaults;
@@ -36,11 +39,16 @@ const getConfig = async root => {
36
39
 
37
40
  export default async name => {
38
41
  const root = await getRoot();
39
- const config = await getConfig(root);
42
+ let logger = protologger;
40
43
  try {
41
- command(name)(await app(config, root, new Logger(config.logger)));
44
+ const config = await getConfig(root);
45
+ logger = new Logger(config.logger);
46
+ await command(name)(await app(config, root, new Logger(config.logger)));
42
47
  } catch (error) {
43
- if (error instanceof Abort) {
48
+ if (error.level === Logger.Error) {
49
+ logger.auto(error);
50
+ bye();
51
+ } else {
44
52
  throw error;
45
53
  }
46
54
  }
package/src/start.js CHANGED
@@ -1,5 +1,6 @@
1
- import {serve} from "runtime-compat/http";
2
- import {register, compile, publish, bundle, route, handle}
1
+ import {serve, Response} from "runtime-compat/http";
2
+ import {InternalServerError} from "./http-statuses.js";
3
+ import {register, compile, publish, bundle, route, handle, parse}
3
4
  from "./hooks/exports.js";
4
5
 
5
6
  export default async (app, operations = {}) => {
@@ -16,6 +17,15 @@ export default async (app, operations = {}) => {
16
17
  // bundle client-side code
17
18
  await bundle(app, operations?.bundle);
18
19
 
19
- // handle
20
- serve(await handle({route: route(app), ...app}), app.config.http);
20
+ const _route = route(app);
21
+
22
+ serve(async request => {
23
+ try {
24
+ // parse, handle
25
+ return await handle({...app, route: _route})(await parse(request));
26
+ } catch(error) {
27
+ app.log.auto(error);
28
+ return new Response(null, {status: InternalServerError});
29
+ }
30
+ }, app.config.http);
21
31
  };
@@ -1 +0,0 @@
1
- export {default} from "./serve.js";
@@ -1,42 +0,0 @@
1
- import {Path} from "runtime-compat/fs";
2
-
3
- const createModule = async app => {
4
- const space = 2;
5
- try {
6
- // will throw if cannot find a package.json up the filesystem hierarchy
7
- await Path.root();
8
- } catch (error) {
9
- const rootConfig = JSON.stringify({
10
- name: "primate-app",
11
- private: true,
12
- dependencies: {
13
- primate: `^${app.version}`,
14
- },
15
- scripts: {
16
- start: "npx primate",
17
- dev: "npx primate dev",
18
- serve: "npx primate serve",
19
- },
20
- type: "module",
21
- }, null, space);
22
- await Path.resolve().join("package.json").file.write(rootConfig);
23
- }
24
- };
25
-
26
- const createConfig = async app => {
27
- const name = "primate.config.js";
28
- const template = "export default {};";
29
- const root = (await Path.root()).join(name);
30
- if (await root.exists) {
31
- app.log.warn(`${root} already exists`);
32
- } else {
33
- await root.file.write(template);
34
- app.log.info(`created config at ${root}`);
35
- }
36
- };
37
-
38
- export default async app => {
39
- await createModule(app);
40
- await createConfig(app);
41
- };
42
-
@@ -1,3 +0,0 @@
1
- export default app => {
2
- app.log.info("available commands: create dev serve");
3
- };
@@ -1,5 +0,0 @@
1
- export default {
2
- OK: 200,
3
- Found: 302,
4
- InternalServerError: 500,
5
- };