primate 0.16.3 → 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.16.3",
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,98 +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
- // Error natively provided
18
- const Warn = class Warn extends Error {};
19
- const Info = class Info extends Error {};
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);
20
14
 
21
- const levels = new Map([
22
- [Error, error],
23
- [Warn, warn],
24
- [Info, info],
25
- ]);
15
+ const reference = "https://primatejs.com/reference/errors";
26
16
 
27
- 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);
28
23
 
29
24
  const Logger = class Logger {
30
25
  #level; #trace;
31
26
 
32
- constructor({level = Error, trace = false} = {}) {
33
- 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);
34
48
  is(trace).boolean();
35
49
  this.#level = level;
36
50
  this.#trace = trace;
37
51
  }
38
52
 
53
+ static print(...args) {
54
+ print(...args);
55
+ }
56
+
57
+ static get mark() {
58
+ return mark;
59
+ }
60
+
39
61
  static get Error() {
40
- return Error;
62
+ return errors.Error;
41
63
  }
42
64
 
43
65
  static get Warn() {
44
- return Warn;
66
+ return errors.Warn;
45
67
  }
46
68
 
47
69
  static get Info() {
48
- return Info;
70
+ return errors.Info;
49
71
  }
50
72
 
51
- #print(pre, error) {
52
- if (error instanceof Error) {
53
- print(colors.bold(pre), error.message, "\n");
54
- if (this.#trace) {
55
- console.log(error);
56
- }
57
- } else {
58
- print(colors.bold(pre), error, "\n");
73
+ get class() {
74
+ return this.constructor;
75
+ }
76
+
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);
59
86
  }
60
87
  }
61
88
 
62
89
  get level() {
63
- return levels.get(this.#level);
90
+ return this.#level;
64
91
  }
65
92
 
66
- info(message) {
67
- if (this.level >= levels.get(Info)) {
68
- this.#print(colors.green("--"), message);
93
+ info(message, args) {
94
+ if (this.level >= errors.Info) {
95
+ this.#print(green("--"), green, message, args);
69
96
  }
70
97
  }
71
98
 
72
- warn(message) {
73
- if (this.level >= levels.get(Warn)) {
74
- this.#print(colors.yellow("??"), message);
99
+ warn(message, args) {
100
+ if (this.level >= errors.Warn) {
101
+ this.#print(yellow("??"), yellow, message, args);
75
102
  }
76
103
  }
77
104
 
78
- error(message) {
79
- if (this.level >= levels.get(Error)) {
80
- this.#print(colors.red("!!"), message);
105
+ error(message, args, error) {
106
+ if (this.level >= errors.Warn) {
107
+ this.#print(red("!!"), red, message, args, error);
81
108
  }
82
109
  }
83
110
 
84
- auto(message) {
85
- if (message instanceof Info) {
86
- 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);
87
115
  }
88
- if (message instanceof Warn) {
89
- return this.warn(message.message);
116
+ if (level === errors.Warn) {
117
+ return this.warn(message, args, error);
90
118
  }
91
119
 
92
- return this.error(message);
120
+ return this.error(message, args, error);
93
121
  }
94
122
  };
95
123
 
96
124
  export default Logger;
97
125
 
98
- export {colors, levels, print};
126
+ export {print, bye};
package/src/app.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import crypto from "runtime-compat/crypto";
2
- import {is} from "runtime-compat/dyndef";
3
2
  import {File, Path} from "runtime-compat/fs";
4
- import extend from "./extend.js";
5
- import defaults from "./defaults/primate.config.js";
6
- import {colors, print, default as Logger} from "./Logger.js";
3
+ import {bold, blue} from "runtime-compat/colors";
4
+ import errors from "./errors.js";
7
5
  import * as handlers from "./handlers/exports.js";
6
+ import * as hooks from "./hooks/exports.js";
8
7
 
9
8
  const qualify = (root, paths) =>
10
9
  Object.keys(paths).reduce((sofar, key) => {
@@ -15,36 +14,6 @@ const qualify = (root, paths) =>
15
14
  return sofar;
16
15
  }, {});
17
16
 
18
- const configName = "primate.config.js";
19
-
20
- const getConfig = async (root, filename) => {
21
- const config = root.join(filename);
22
- if (await config.exists) {
23
- try {
24
- const imported = await import(config);
25
- if (imported.default === undefined) {
26
- print(`${colors.yellow("??")} ${configName} has no default export\n`);
27
- }
28
- return extend(defaults, imported.default);
29
- } catch (error) {
30
- print(`${colors.red("!!")} couldn't load config file\n`);
31
- throw error;
32
- }
33
- } else {
34
- return defaults;
35
- }
36
- };
37
-
38
- const getRoot = async () => {
39
- try {
40
- // use module root if possible
41
- return await Path.root();
42
- } catch (error) {
43
- // fall back to current directory
44
- return Path.resolve();
45
- }
46
- };
47
-
48
17
  const src = new Path(import.meta.url).up(1);
49
18
 
50
19
  const index = async app => {
@@ -65,23 +34,48 @@ const hash = async (string, algorithm = "sha-384") => {
65
34
  return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
66
35
  };
67
36
 
68
- export default async (filename = configName) => {
69
- is(filename).string();
70
- const root = await getRoot();
71
- const config = await getConfig(root, filename);
72
-
73
- const {name, version} = await src.up(1).join("package.json").json();
37
+ export default async (config, root, log) => {
38
+ const {http} = config;
74
39
 
75
40
  // if ssl activated, resolve key and cert early
76
- if (config.http.ssl) {
77
- config.http.ssl.key = root.join(config.http.ssl.key);
78
- 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);
79
44
  }
80
45
 
46
+ const paths = qualify(root, config.paths);
47
+
48
+ const ending = ".js";
49
+ const routes = paths.routes === undefined ? [] : await Promise.all(
50
+ (await Path.collect(paths.routes, /^.*.js$/u))
51
+ .map(async route => [
52
+ `${route}`.replace(paths.routes, "").slice(1, -ending.length),
53
+ (await import(route)).default,
54
+ ]));
55
+
56
+ const modules = config.modules === undefined ? [] : config.modules;
57
+
58
+ modules.every((module, n) => module.name !== undefined ||
59
+ errors.ModulesMustHaveNames.throw({n}));
60
+
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
+ });
66
+
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();
72
+
81
73
  const app = {
82
74
  config,
83
- secure: config.http?.ssl !== undefined,
84
- name, version,
75
+ routes,
76
+ secure: http?.ssl !== undefined,
77
+ name,
78
+ version,
85
79
  library: {},
86
80
  identifiers: {},
87
81
  replace(code) {
@@ -96,9 +90,28 @@ export default async (filename = configName) => {
96
90
  },
97
91
  resources: [],
98
92
  entrypoints: [],
99
- paths: qualify(root, config.paths),
93
+ paths,
100
94
  root,
101
- log: new Logger(config.logger),
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
+ },
102
115
  handlers: {...handlers},
103
116
  render: async ({body = "", head = ""} = {}) => {
104
117
  const html = await index(app);
@@ -124,7 +137,7 @@ export default async (filename = configName) => {
124
137
  // while integrity is only really needed for scripts, it is also later
125
138
  // used for the etag header
126
139
  const integrity = await hash(code);
127
- const _src = new Path(config.http.static.root).join(src ?? "");
140
+ const _src = new Path(http.static.root).join(src ?? "");
128
141
  app.resources.push({src: `${_src}`, code, type, inline, integrity});
129
142
  return integrity;
130
143
  },
@@ -140,12 +153,10 @@ export default async (filename = configName) => {
140
153
  ]));
141
154
  app.identifiers = {...exports, ...app.identifiers};
142
155
  },
143
- modules: [...config.modules],
156
+ modules,
144
157
  };
145
- print(colors.blue(colors.bold(name)), colors.blue(version), "");
146
- const type = app.secure ? "https" : "http";
147
- const address = `${type}://${config.http.host}:${config.http.port}`;
148
- 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`);
149
160
  // modules may load other modules
150
161
  await Promise.all(app.modules
151
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);
@@ -25,6 +25,7 @@ export default {
25
25
  public: "public",
26
26
  routes: "routes",
27
27
  components: "components",
28
+ types: "types",
28
29
  },
29
30
  modules: [],
30
31
  dist: "app",
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")]));
@@ -0,0 +1,9 @@
1
+ import {NotFound} from "../http-statuses.js";
2
+
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
+ ];
@@ -4,3 +4,4 @@ export {default as stream} from "./stream.js";
4
4
  export {default as redirect} from "./redirect.js";
5
5
  export {default as html} from "./html.js";
6
6
  export {default as view} from "./view.js";
7
+ export {default as error} from "./error.js";
@@ -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";