primate 0.16.3 → 0.17.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.17.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",
package/src/Logger.js CHANGED
@@ -14,6 +14,7 @@ const error = 0;
14
14
  const warn = 1;
15
15
  const info = 2;
16
16
 
17
+ const Abort = class Abort extends Error {};
17
18
  // Error natively provided
18
19
  const Warn = class Warn extends Error {};
19
20
  const Info = class Info extends Error {};
@@ -24,6 +25,10 @@ const levels = new Map([
24
25
  [Info, info],
25
26
  ]);
26
27
 
28
+ const abort = message => {
29
+ throw new Abort(message);
30
+ };
31
+
27
32
  const print = (...messages) => process.stdout.write(messages.join(" "));
28
33
 
29
34
  const Logger = class Logger {
@@ -36,6 +41,14 @@ const Logger = class Logger {
36
41
  this.#trace = trace;
37
42
  }
38
43
 
44
+ static get colors() {
45
+ return colors;
46
+ }
47
+
48
+ static print(...args) {
49
+ print(...args);
50
+ }
51
+
39
52
  static get Error() {
40
53
  return Error;
41
54
  }
@@ -48,6 +61,10 @@ const Logger = class Logger {
48
61
  return Info;
49
62
  }
50
63
 
64
+ get class() {
65
+ return this.constructor;
66
+ }
67
+
51
68
  #print(pre, error) {
52
69
  if (error instanceof Error) {
53
70
  print(colors.bold(pre), error.message, "\n");
@@ -95,4 +112,4 @@ const Logger = class Logger {
95
112
 
96
113
  export default Logger;
97
114
 
98
- export {colors, levels, print};
115
+ export {colors, levels, print, abort, Abort};
package/src/app.js CHANGED
@@ -1,10 +1,7 @@
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";
7
3
  import * as handlers from "./handlers/exports.js";
4
+ import {abort} from "./Logger.js";
8
5
 
9
6
  const qualify = (root, paths) =>
10
7
  Object.keys(paths).reduce((sofar, key) => {
@@ -15,36 +12,6 @@ const qualify = (root, paths) =>
15
12
  return sofar;
16
13
  }, {});
17
14
 
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
15
  const src = new Path(import.meta.url).up(1);
49
16
 
50
17
  const index = async app => {
@@ -65,11 +32,7 @@ const hash = async (string, algorithm = "sha-384") => {
65
32
  return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
66
33
  };
67
34
 
68
- export default async (filename = configName) => {
69
- is(filename).string();
70
- const root = await getRoot();
71
- const config = await getConfig(root, filename);
72
-
35
+ export default async (config, root, log) => {
73
36
  const {name, version} = await src.up(1).join("package.json").json();
74
37
 
75
38
  // if ssl activated, resolve key and cert early
@@ -78,8 +41,32 @@ export default async (filename = configName) => {
78
41
  config.http.ssl.cert = root.join(config.http.ssl.cert);
79
42
  }
80
43
 
44
+ const paths = qualify(root, config.paths);
45
+
46
+ const ending = ".js";
47
+ const routes = paths.routes === undefined ? [] : await Promise.all(
48
+ (await Path.collect(paths.routes, /^.*.js$/u))
49
+ .map(async route => [
50
+ `${route}`.replace(paths.routes, "").slice(1, -ending.length),
51
+ (await import(route)).default,
52
+ ]));
53
+
54
+ const modules = config.modules === undefined ? [] : config.modules;
55
+
56
+ modules.every(module => module.name !== undefined ||
57
+ abort("all modules must have names"));
58
+
59
+ if (new Set(modules.map(module => module.name)).size !== modules.length) {
60
+ abort("same module twice");
61
+ }
62
+
63
+ modules.every(module => Object.entries(module).length > 1) || (() => {
64
+ log.warn("some modules haven't subscribed to any hooks");
65
+ })();
66
+
81
67
  const app = {
82
68
  config,
69
+ routes,
83
70
  secure: config.http?.ssl !== undefined,
84
71
  name, version,
85
72
  library: {},
@@ -96,9 +83,9 @@ export default async (filename = configName) => {
96
83
  },
97
84
  resources: [],
98
85
  entrypoints: [],
99
- paths: qualify(root, config.paths),
86
+ paths,
100
87
  root,
101
- log: new Logger(config.logger),
88
+ log,
102
89
  handlers: {...handlers},
103
90
  render: async ({body = "", head = ""} = {}) => {
104
91
  const html = await index(app);
@@ -140,8 +127,9 @@ export default async (filename = configName) => {
140
127
  ]));
141
128
  app.identifiers = {...exports, ...app.identifiers};
142
129
  },
143
- modules: [...config.modules],
130
+ modules,
144
131
  };
132
+ const {print, colors} = log.class;
145
133
  print(colors.blue(colors.bold(name)), colors.blue(version), "");
146
134
  const type = app.secure ? "https" : "http";
147
135
  const address = `${type}://${config.http.host}:${config.http.port}`;
@@ -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",
@@ -0,0 +1,8 @@
1
+ const _404 = "Not Found";
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
+ ];
@@ -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,6 +1,5 @@
1
- import {Path} from "runtime-compat/fs";
2
- import {serve, Response, URL} from "runtime-compat/http";
3
- import {http404} from "../handlers/http.js";
1
+ import {Response, URL} from "runtime-compat/http";
2
+ import {error as clientError} from "../handlers/exports.js";
4
3
  import {statuses, mime, isResponse, respond} from "./handle/exports.js";
5
4
  import fromNull from "../fromNull.js";
6
5
 
@@ -14,12 +13,11 @@ const contents = {
14
13
  };
15
14
 
16
15
  export default async app => {
17
- const {config} = app;
18
- const {http} = config;
16
+ const {http} = app.config;
19
17
 
20
18
  const _respond = async request => {
21
- const csp = Object.keys(config.http.csp).reduce((policy_string, key) =>
22
- `${policy_string}${key} ${config.http.csp[key]};`, "");
19
+ const csp = Object.keys(http.csp).reduce((policy_string, key) =>
20
+ `${policy_string}${key} ${http.csp[key]};`, "");
23
21
  const scripts = app.resources
24
22
  .map(resource => `'${resource.integrity}'`).join(" ");
25
23
  const _csp = scripts === "" ? csp : `${csp}script-src 'self' ${scripts};`;
@@ -37,15 +35,14 @@ export default async app => {
37
35
  };
38
36
 
39
37
  try {
40
- const {router} = app;
41
38
  const modules = filter("route", app.modules);
42
- // handle is the last module to be executed
43
- const handlers = [...modules, router.route].reduceRight((acc, handler) =>
39
+ // app.route is the last module to be executed
40
+ const handlers = [...modules, app.route].reduceRight((acc, handler) =>
44
41
  input => handler(input, acc));
45
42
  return await respond(await handlers(request))(app, headers);
46
43
  } catch (error) {
47
44
  app.log.auto(error);
48
- return http404()(app, headers);
45
+ return clientError()(app, headers);
49
46
  }
50
47
  };
51
48
 
@@ -116,6 +113,9 @@ export default async app => {
116
113
  const decoder = new TextDecoder();
117
114
 
118
115
  const parseBody = async request => {
116
+ if (request.body === null) {
117
+ return null;
118
+ }
119
119
  const reader = request.body.getReader();
120
120
  const chunks = [];
121
121
  let result;
@@ -126,21 +126,23 @@ export default async app => {
126
126
  }
127
127
  } while (!result.done);
128
128
 
129
- return chunks.length === 0 ? null : parseContent(request, chunks.join());
129
+ return parseContent(request, chunks.join());
130
130
  };
131
131
 
132
132
  const parseRequest = async request => {
133
133
  const cookies = request.headers.get("cookie");
134
- const {url} = request;
134
+ const _url = request.url;
135
+ const url = new URL(_url.endsWith("/") ? _url.slice(0, -1) : _url);
135
136
 
136
137
  return {
137
- request,
138
- url: new URL(url.endsWith("/") ? url.slice(0, -1) : url),
138
+ original: request,
139
+ url,
139
140
  body: await parseBody(request),
140
141
  cookies: fromNull(cookies === null
141
142
  ? {}
142
143
  : Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
143
144
  headers: fromNull(Object.fromEntries(request.headers)),
145
+ query: fromNull(Object.fromEntries(url.searchParams)),
144
146
  };
145
147
  };
146
148
 
@@ -148,5 +150,5 @@ export default async app => {
148
150
  const handlers = [...filter("handle", app.modules), handle]
149
151
  .reduceRight((acc, handler) => input => handler(input, acc));
150
152
 
151
- serve(async request => handlers(await parseRequest(request)), config.http);
153
+ return async request => handlers(await parseRequest(request));
152
154
  };
@@ -0,0 +1,88 @@
1
+ import handle from "./handle.js";
2
+ import Logger from "../Logger.js";
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
+ };
22
+
23
+ const r = await (async () => {
24
+ const p = "https://p.com";
25
+ const request = (method, path = "/", options = {}) =>
26
+ new Request(`${p}${path}`, {method, ...options});
27
+ const handler = await handle(app);
28
+ return Object.fromEntries(["get", "post", "put", "delete"].map(verb =>
29
+ [verb, (...args) => handler(request(verb.toUpperCase(), ...args))]));
30
+ })();
31
+
32
+ export default test => {
33
+ test.case("no body => null", async assert => {
34
+ assert((await r.get("/")).body).null();
35
+ assert((await r.post("/")).body).null();
36
+ });
37
+ 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"});
44
+ });
45
+ test.case("body is application/x-www-form-urlencoded", async assert => {
46
+ assert((await r.post("/", {
47
+ body: encodeURI("foo=bar &bar=baz"),
48
+ headers: {
49
+ "Content-Type": "application/x-www-form-urlencoded",
50
+ },
51
+ })).body).equals({foo: "bar ", bar: "baz"});
52
+ });
53
+ test.case("no query => {}", async assert => {
54
+ assert((await r.get("/")).query).equals({});
55
+ });
56
+ test.case("query", async assert => {
57
+ assert((await r.get("/?key=value")).query).equals({key: "value"});
58
+ });
59
+ test.case("no cookies => {}", async assert => {
60
+ assert((await r.get("/")).cookies).equals({});
61
+ });
62
+ test.case("cookies", async assert => {
63
+ assert((await r.get("/?key=value", {
64
+ headers: {
65
+ Cookie: "key=value;key2=value2",
66
+ },
67
+ })).cookies).equals({key: "value", key2: "value2"});
68
+ });
69
+ test.case("no headers => {}", async assert => {
70
+ assert((await r.get("/")).headers).equals({});
71
+ });
72
+ test.case("headers", async assert => {
73
+ assert((await r.get("/?key=value", {
74
+ headers: {
75
+ "X-User": "Donald",
76
+ },
77
+ })).headers).equals({"x-user": "Donald"});
78
+ });
79
+ test.case("cookies double as headers", async assert => {
80
+ const response = await r.get("/?key=value", {
81
+ headers: {
82
+ Cookie: "key=value",
83
+ },
84
+ });
85
+ assert(response.headers).equals({cookie: "key=value"});
86
+ assert(response.cookies).equals({key: "value"});
87
+ });
88
+ };
@@ -1,6 +1,4 @@
1
- import {Path} from "runtime-compat/fs";
2
- import {Logger} from "primate";
3
- import fromNull from "../fromNull.js";
1
+ import {default as Logger, abort} from "../Logger.js";
4
2
 
5
3
  // insensitive-case equal
6
4
  const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
@@ -9,61 +7,82 @@ const verbs = [
9
7
  // CRUD
10
8
  "post", "get", "put", "delete",
11
9
  // extended
12
- "delete", "connect", "options", "trace", "patch",
10
+ "connect", "options", "trace", "patch",
13
11
  ];
14
12
 
15
13
  const toRoute = file => {
16
- const ending = -3;
17
14
  const route = file
18
- // remove ending
19
- .slice(0, ending)
20
15
  // transform /index -> ""
21
16
  .replace("/index", "")
22
17
  // transform index -> ""
23
18
  .replace("index", "")
24
19
  // prepare for regex
25
- .replaceAll(/\{(?<named>.*)\}/gu, (_, name) => `(?<${name}>.*?)`)
20
+ .replaceAll(/\{(?<named>.*?)\}/gu, (_, named) => {
21
+ try {
22
+ const {name, type} = /^(?<name>\w*)(?<type>:\w+)?$/u.exec(named).groups;
23
+ const param = type === undefined ? name : `${name}$${type.slice(1)}`;
24
+ return `(?<${param}>[^/]{1,}?)`;
25
+ } catch (error) {
26
+ abort(`invalid parameter "${named}"`);
27
+ }
28
+ })
26
29
  ;
27
- return new RegExp(`^/${route}$`, "u");
30
+ try {
31
+ return new RegExp(`^/${route}$`, "u");
32
+ } catch (error) {
33
+ abort("same parameter twice");
34
+ }
28
35
  };
29
36
 
30
- export default async app => {
31
- const routes = (await Promise.all(
32
- (await Path.collect(app.paths.routes, /^.*.js$/u))
33
- .map(async route => {
34
- const imported = (await import(route)).default;
35
- const file = `${route}`.replace(app.paths.routes, "").slice(1);
36
- if (imported === undefined) {
37
- app.log.warn(`empty route file at ${file}`);
38
- return [];
39
- }
37
+ const reentry = (object, mapper) =>
38
+ Object.fromEntries(mapper(Object.entries(object ?? {})));
40
39
 
41
- const path = toRoute(file);
42
- return Object.entries(imported)
43
- .filter(([verb]) => verbs.includes(verb))
44
- .map(([method, handler]) => ({method, handler, path}));
45
- }))).flat();
46
- const find = (method, path) => routes.find(route =>
47
- ieq(route.method, method) && route.path.test(path));
40
+ export default app => {
41
+ const {types = {}} = app;
42
+ Object.entries(types).every(([name]) => /^(?:\w*)$/u.test(name) ||
43
+ abort(`invalid type "${name}"`));
44
+ const routes = app.routes
45
+ .map(([route, imported]) => {
46
+ if (imported === undefined) {
47
+ app.log.warn(`empty route file at ${route}.js`);
48
+ return [];
49
+ }
50
+
51
+ const path = toRoute(route);
52
+ return Object.entries(imported)
53
+ .filter(([verb]) => verbs.includes(verb))
54
+ .map(([method, handler]) => ({method, handler, path}));
55
+ }).flat();
56
+ const paths = routes.map(({method, path}) => `${method}${path}`);
57
+ if (new Set(paths).size !== paths.length) {
58
+ abort("same route twice");
59
+ }
48
60
 
49
- const router = {
50
- async route({request, url, ...rest}) {
51
- const {method} = request;
52
- const {pathname, searchParams} = url;
53
- const verb = find(method, pathname) ?? (() => {
54
- throw new Logger.Warn(`no ${method} route to ${pathname}`);
55
- })();
61
+ const isType = groups => Object
62
+ .entries(groups ?? {})
63
+ .map(([name, value]) =>
64
+ [types[name] === undefined ? name : `${name}$${name}`, value])
65
+ .filter(([name]) => name.includes("$"))
66
+ .map(([name, value]) => [name.split("$")[1], value])
67
+ .every(([name, value]) => types?.[name](value) === true)
68
+ ;
69
+ const isPath = ({route, path}) => {
70
+ const result = route.path.exec(path);
71
+ return result === null ? false : isType(result.groups);
72
+ };
73
+ const isMethod = ({route, method, path}) => ieq(route.method, method)
74
+ && isPath({route, path});
75
+ const find = (method, path) => routes.find(route =>
76
+ isMethod({route, method, path}));
56
77
 
57
- const data = {
58
- request,
59
- url,
60
- path: verb.path?.exec(pathname)?.groups ?? Object.create(null),
61
- query: fromNull(Object.fromEntries(searchParams)),
62
- ...rest,
63
- };
78
+ return request => {
79
+ 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]));
64
85
 
65
- return verb.handler(data);
66
- },
86
+ return verb.handler({...request, path});
67
87
  };
68
- return router;
69
88
  };
@@ -0,0 +1,143 @@
1
+ import route from "./route.js";
2
+
3
+ const app = {
4
+ routes: [
5
+ "index",
6
+ "user",
7
+ "users/{userId}a",
8
+ "comments/{commentId:comment}",
9
+ "users/{userId}/comments/{commentId}",
10
+ "users/{userId:user}/comments/{commentId}/a",
11
+ "users/{userId:user}/comments/{commentId:comment}/b",
12
+ "users/{_userId}/comments/{commentId}/d",
13
+ "users/{_userId}/comments/{_commentId}/e",
14
+ "comments2/{_commentId}",
15
+ "users2/{_userId}/{commentId}",
16
+ "users3/{_userId}/{_commentId:_commentId}",
17
+ "users4/{_userId}/{_commentId}",
18
+ "users5/{truthy}",
19
+ "{uuid}/{Uuid}/{UUID}",
20
+ ].map(pathname => [pathname, {get: request => request}]),
21
+ types: {
22
+ user: id => /^\d*$/u.test(id),
23
+ comment: id => /^\d*$/u.test(id),
24
+ _userId: id => /^\d*$/u.test(id),
25
+ _commentId: id => /^\d*$/u.test(id),
26
+ truthy: () => 1,
27
+ uuid: _ => _ === "uuid",
28
+ Uuid: _ => _ === "Uuid",
29
+ UUID: _ => _ === "UUID",
30
+ },
31
+ };
32
+
33
+ export default test => {
34
+ const router = route(app);
35
+ const p = "https://p.com";
36
+ const r = pathname => {
37
+ const original = new Request(`${p}${pathname}`, {method: "GET"});
38
+ const {url} = original;
39
+ return router({
40
+ original,
41
+ url: new URL(url.endsWith("/") ? url.slice(0, -1) : url),
42
+ });
43
+ };
44
+
45
+ test.reassert(assert => ({
46
+ match: (url, result) => {
47
+ assert(r(url).url.pathname).equals(result ?? url);
48
+ },
49
+ fail: (url, result) =>
50
+ assert(() => r(url)).throws(`no GET route to ${result ?? url}`),
51
+ path: (url, result) => assert(r(url).path).equals(result),
52
+ assert,
53
+ }));
54
+
55
+ const get = () => null;
56
+ /* abort {{{ */
57
+ test.case("must not contain the same route twice", ({assert}) => {
58
+ 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\"");
72
+ });
73
+ /* }}} */
74
+
75
+ test.case("index route", ({match}) => {
76
+ match("/");
77
+ });
78
+ test.case("simple route", ({match}) => {
79
+ match("/user");
80
+ });
81
+ test.case("param match/fail", ({match, fail}) => {
82
+ match("/users/1a");
83
+ match("/users/aa");
84
+ match("/users/ba?key=value", "/users/ba");
85
+ fail("/user/1a");
86
+ fail("/users/a");
87
+ fail("/users/aA");
88
+ fail("/users//a");
89
+ fail("/users/?a", "/users/");
90
+ });
91
+ test.case("no params", ({path}) => {
92
+ path("/", {});
93
+ });
94
+ test.case("single param", ({path}) => {
95
+ path("/users/1a", {userId: "1"});
96
+ });
97
+ test.case("params", ({path, fail}) => {
98
+ path("/users/1/comments/2", {userId: "1", commentId: "2"});
99
+ path("/users/1/comments/2/b", {userId: "1", commentId: "2"});
100
+ fail("/users/d/comments/2/b");
101
+ fail("/users/1/comments/d/b");
102
+ fail("/users/d/comments/d/b");
103
+ });
104
+ test.case("single typed param", ({path, fail}) => {
105
+ path("/comments/1", {commentId: "1"});
106
+ fail("/comments/ ", "/comments");
107
+ fail("/comments/1d");
108
+ });
109
+ test.case("mixed untyped and typed params", ({path, fail}) => {
110
+ path("/users/1/comments/2/a", {userId: "1", commentId: "2"});
111
+ fail("/users/d/comments/2/a");
112
+ });
113
+ test.case("single implicit typed param", ({path, fail}) => {
114
+ path("/comments2/1", {_commentId: "1"});
115
+ fail("/comments2/d");
116
+ });
117
+ test.case("mixed implicit and untyped params", ({path, fail}) => {
118
+ path("/users2/1/2", {_userId: "1", commentId: "2"});
119
+ fail("/users2/d/2");
120
+ fail("/users2/d");
121
+ });
122
+ test.case("mixed implicit and explicit params", ({path, fail}) => {
123
+ path("/users3/1/2", {_userId: "1", _commentId: "2"});
124
+ fail("/users3/d/2");
125
+ fail("/users3/1/d");
126
+ fail("/users3");
127
+ });
128
+ test.case("implicit params", ({path, fail}) => {
129
+ path("/users4/1/2", {_userId: "1", _commentId: "2"});
130
+ fail("/users4/d/2");
131
+ fail("/users4/1/d");
132
+ fail("/users4");
133
+ });
134
+ test.case("fail not strictly true implicit params", ({fail}) => {
135
+ fail("/users5/any");
136
+ });
137
+ test.case("different case params", ({path, fail}) => {
138
+ path("/uuid/Uuid/UUID", {uuid: "uuid", Uuid: "Uuid", UUID: "UUID"});
139
+ fail("/uuid/uuid/uuid");
140
+ fail("/Uuid/UUID/uuid");
141
+ fail("/UUID/uuid/Uuid");
142
+ });
143
+ };
package/src/run.js CHANGED
@@ -1,4 +1,47 @@
1
+ import {Path} from "runtime-compat/fs";
1
2
  import app from "./app.js";
2
3
  import command from "./commands/exports.js";
4
+ import {Abort, colors, print, default as Logger} from "./Logger.js";
5
+ import defaults from "./defaults/primate.config.js";
6
+ import extend from "./extend.js";
3
7
 
4
- export default async name => command(name)(await app());
8
+ const getRoot = async () => {
9
+ try {
10
+ // use module root if possible
11
+ return await Path.root();
12
+ } catch (error) {
13
+ // fall back to current directory
14
+ return Path.resolve();
15
+ }
16
+ };
17
+
18
+ const configName = "primate.config.js";
19
+ const getConfig = async root => {
20
+ const config = root.join(configName);
21
+ if (await config.exists) {
22
+ 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;
31
+ }
32
+ } else {
33
+ return defaults;
34
+ }
35
+ };
36
+
37
+ export default async name => {
38
+ const root = await getRoot();
39
+ const config = await getConfig(root);
40
+ try {
41
+ command(name)(await app(config, root, new Logger(config.logger)));
42
+ } catch (error) {
43
+ if (error instanceof Abort) {
44
+ throw error;
45
+ }
46
+ }
47
+ };
package/src/start.js CHANGED
@@ -1,3 +1,4 @@
1
+ import {serve} from "runtime-compat/http";
1
2
  import {register, compile, publish, bundle, route, handle}
2
3
  from "./hooks/exports.js";
3
4
 
@@ -16,5 +17,5 @@ export default async (app, operations = {}) => {
16
17
  await bundle(app, operations?.bundle);
17
18
 
18
19
  // handle
19
- await handle({router: await route(app), ...app});
20
+ serve(await handle({route: route(app), ...app}), app.config.http);
20
21
  };
@@ -1 +0,0 @@
1
- export {default as http404} from "./http404.js";
@@ -1,6 +0,0 @@
1
- export default (body = "Not found") => (_, headers) => [
2
- body, {
3
- status: 404,
4
- headers: {...headers, "Content-Type": "text/html"},
5
- },
6
- ];