primate 0.17.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.
@@ -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,5 @@ 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";
8
+ export {default as serve} from "./serve.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,36 @@
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
- }
30
- }
13
+ const _respond = async (request, 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);
18
+ };
31
19
 
32
- const headers = {
33
- "Content-Security-Policy": _csp,
34
- "Referrer-Policy": "same-origin",
35
- };
20
+ const route = async request => {
21
+ const headers = app.generateHeaders();
36
22
 
37
23
  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);
24
+ const response = await _respond(request, headers);
25
+ return isResponse(response) ? response : new Response(...response);
43
26
  } catch (error) {
44
27
  app.log.auto(error);
45
- return clientError()(app, headers);
28
+ return new Response(...await clientError()(app, {}));
46
29
  }
47
30
  };
48
31
 
49
- const route = async request => {
50
- const response = await _respond(request);
51
- return isResponse(response) ? response : new Response(...response);
52
- };
53
-
54
32
  const staticResource = async file => new Response(file.readable, {
55
- status: statuses.OK,
33
+ status: OK,
56
34
  headers: {
57
35
  "Content-Type": mime(file.name),
58
36
  Etag: await file.modified,
@@ -64,7 +42,7 @@ export default async app => {
64
42
  !inline && src === request.url.pathname);
65
43
  if (published !== undefined) {
66
44
  return new Response(published.code, {
67
- status: statuses.OK,
45
+ status: OK,
68
46
  headers: {
69
47
  "Content-Type": mime(published.src),
70
48
  Etag: published.integrity,
@@ -87,68 +65,6 @@ export default async app => {
87
65
  return route(request);
88
66
  };
89
67
 
90
- 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
- }
97
- };
98
-
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]
68
+ return [...filter("handle", app.modules), resource]
151
69
  .reduceRight((acc, handler) => input => handler(input, acc));
152
-
153
- return async request => handlers(await parseRequest(request));
154
70
  };
@@ -0,0 +1,59 @@
1
+ import {URL} from "runtime-compat/http";
2
+ import errors from "../errors.js";
3
+
4
+ const contents = {
5
+ "application/x-www-form-urlencoded": body =>
6
+ Object.fromEntries(body.split("&").map(part => part.split("=")
7
+ .map(subpart => decodeURIComponent(subpart).replaceAll("+", " ")))),
8
+ "application/json": body => JSON.parse(body),
9
+ };
10
+ const decoder = new TextDecoder();
11
+
12
+ export default dispatch => async request => {
13
+ const parseContentType = (contentType, body) => {
14
+ const type = contents[contentType];
15
+ return type === undefined ? body : type(body);
16
+ };
17
+
18
+ const parseContent = async (request, body) => {
19
+ const contentType = request.headers.get("content-type");
20
+ try {
21
+ return parseContentType(contentType, body);
22
+ } catch (error) {
23
+ return errors.CannotParseBody.throw({body, contentType});
24
+ }
25
+ };
26
+
27
+ const parseBody = async request => {
28
+ if (request.body === null) {
29
+ return null;
30
+ }
31
+ const reader = request.body.getReader();
32
+ const chunks = [];
33
+ let result;
34
+ do {
35
+ result = await reader.read();
36
+ if (result.value !== undefined) {
37
+ chunks.push(decoder.decode(result.value));
38
+ }
39
+ } while (!result.done);
40
+
41
+ return parseContent(request, chunks.join());
42
+ };
43
+
44
+ const cookies = request.headers.get("cookie");
45
+ const _url = request.url;
46
+ const url = new URL(_url.endsWith("/") ? _url.slice(0, -1) : _url);
47
+
48
+ const body = await parseBody(request);
49
+ return {
50
+ original: request,
51
+ url,
52
+ body: dispatch(body),
53
+ cookies: dispatch(cookies === null
54
+ ? {}
55
+ : Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
56
+ headers: dispatch(Object.fromEntries(request.headers)),
57
+ query: dispatch(Object.fromEntries(url.searchParams)),
58
+ };
59
+ };
@@ -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,17 +1,21 @@
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();
5
- // HTTP verbs
6
- const verbs = [
7
- // CRUD
8
- "post", "get", "put", "delete",
9
- // extended
10
- "connect", "options", "trace", "patch",
11
- ];
12
-
13
- const toRoute = file => {
14
- const route = file
7
+
8
+ /* routes may not contain dots */
9
+ export const invalid = route => /\./u.test(route);
10
+ const toRoute = path => {
11
+ const double = path.split("/")
12
+ .filter(part => part.startsWith("{") && part.endsWith("}"))
13
+ .map(part => part.slice(1, part.indexOf(":")))
14
+ .find((part, i, array) =>
15
+ array.filter((_, j) => i !== j).includes(part));
16
+ double && errors.DoublePathParameter.throw({path, double});
17
+
18
+ const route = path
15
19
  // transform /index -> ""
16
20
  .replace("/index", "")
17
21
  // transform index -> ""
@@ -23,66 +27,81 @@ const toRoute = file => {
23
27
  const param = type === undefined ? name : `${name}$${type.slice(1)}`;
24
28
  return `(?<${param}>[^/]{1,}?)`;
25
29
  } catch (error) {
26
- abort(`invalid parameter "${named}"`);
30
+ return errors.InvalidPathParameter.throw({named, path});
27
31
  }
28
- })
29
- ;
30
- try {
31
- return new RegExp(`^/${route}$`, "u");
32
- } catch (error) {
33
- abort("same parameter twice");
34
- }
32
+ });
33
+
34
+ invalid(route) && errors.InvalidRouteName.throw({path});
35
+
36
+ return new RegExp(`^/${route}$`, "u");
35
37
  };
36
38
 
37
39
  const reentry = (object, mapper) =>
38
40
  Object.fromEntries(mapper(Object.entries(object ?? {})));
39
41
 
40
42
  export default app => {
41
- const {types = {}} = app;
42
- Object.entries(types).every(([name]) => /^(?:\w*)$/u.test(name) ||
43
- abort(`invalid type "${name}"`));
43
+ const double = app.routes
44
+ .map(([route]) => route
45
+ .replaceAll("/index", "")
46
+ .replaceAll(/\{(?<name>\w*)(?<_>:\w+)?\}?/gu, (_, name) => `{${name}}`))
47
+ .find((part, i, array) =>
48
+ array.filter((_, j) => i !== j).includes(part));
49
+
50
+ double && errors.DoubleRoute.throw({double});
51
+
44
52
  const routes = app.routes
45
53
  .map(([route, imported]) => {
46
- if (imported === undefined) {
47
- app.log.warn(`empty route file at ${route}.js`);
54
+ if (imported === undefined || Object.keys(imported).length === 0) {
55
+ errors.EmptyRouteFile.warn(app.log, {config: app.config, route});
48
56
  return [];
49
57
  }
50
58
 
51
- const path = toRoute(route);
52
59
  return Object.entries(imported)
53
- .filter(([verb]) => verbs.includes(verb))
54
- .map(([method, handler]) => ({method, handler, path}));
60
+ .map(([method, handler]) => ({method, handler, path: toRoute(route)}));
55
61
  }).flat();
56
- const paths = routes.map(({method, path}) => `${method}${path}`);
57
- if (new Set(paths).size !== paths.length) {
58
- abort("same route twice");
59
- }
60
62
 
61
- const isType = groups => Object
63
+ const {types = {}} = app;
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
62
72
  .entries(groups ?? {})
63
73
  .map(([name, value]) =>
64
- [types[name] === undefined ? name : `${name}$${name}`, value])
74
+ [types[name] === undefined || explicit ? name : `${name}$${name}`, value])
65
75
  .filter(([name]) => name.includes("$"))
66
76
  .map(([name, value]) => [name.split("$")[1], value])
67
- .every(([name, value]) => types?.[name](value) === true)
68
- ;
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
+ });
69
84
  const isPath = ({route, path}) => {
70
85
  const result = route.path.exec(path);
71
- return result === null ? false : isType(result.groups);
86
+ return result === null ? false : isType(result.groups, path);
72
87
  };
73
88
  const isMethod = ({route, method, path}) => ieq(route.method, method)
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
- })();
83
- const path = reentry(verb.path?.exec(pathname).groups,
84
- object => object.map(([key, value]) => [key.split("$")[0], value]));
96
+ const verb = find(method, pathname) ??
97
+ errors.NoRouteToPath.throw({method, pathname, config: app.config});
98
+ const path = app.dispatch(reentry(verb.path?.exec(pathname).groups,
99
+ object => object.map(([key, value]) => [key.split("$")[0], value])));
100
+
101
+ // verb.handler is the last module to be executed
102
+ const handlers = [...modules, verb.handler].reduceRight((acc, handler) =>
103
+ input => handler(input, acc));
85
104
 
86
- return verb.handler({...request, path});
105
+ return handlers({...request, path});
87
106
  };
88
107
  };
@@ -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
+ };
@@ -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,21 +1,36 @@
1
- import {serve} from "runtime-compat/http";
2
- import {register, compile, publish, bundle, route, handle}
3
- from "./hooks/exports.js";
1
+ import {serve, Response} from "runtime-compat/http";
2
+ import {InternalServerError} from "./http-statuses.js";
3
+ import * as hooks from "./hooks/exports.js";
4
+
5
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
4
6
 
5
7
  export default async (app, operations = {}) => {
6
8
  // register handlers
7
- await register({...app, register(name, handler) {
9
+ await hooks.register({...app, register(name, handler) {
8
10
  app.handlers[name] = handler;
9
11
  }});
10
12
 
11
13
  // compile server-side code
12
- await compile(app);
14
+ await hooks.compile(app);
13
15
  // publish client-side code
14
- await publish(app);
16
+ await hooks.publish(app);
15
17
 
16
18
  // bundle client-side code
17
- await bundle(app, operations?.bundle);
19
+ await hooks.bundle(app, operations?.bundle);
20
+
21
+ const server = await serve(async request => {
22
+ try {
23
+ // parse, handle
24
+ return await hooks.handle(app)(await app.parse(request));
25
+ } catch(error) {
26
+ console.log("TEST2");
27
+ app.log.auto(error);
28
+ return new Response(null, {status: InternalServerError});
29
+ }
30
+ }, app.config.http);
18
31
 
19
- // handle
20
- serve(await handle({route: route(app), ...app}), app.config.http);
32
+ await [...filter("serve", app.modules), _ => _]
33
+ .reduceRight((acc, handler) => input => handler(input, acc))({
34
+ ...app, server,
35
+ });
21
36
  };
@@ -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
- };
package/src/fromNull.js DELETED
@@ -1 +0,0 @@
1
- export default object => Object.assign(Object.create(null), object);
@@ -1,5 +0,0 @@
1
- export default {
2
- OK: 200,
3
- Found: 302,
4
- InternalServerError: 500,
5
- };
@@ -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
- };