primate 0.16.2 → 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/README.md CHANGED
@@ -1,210 +1,5 @@
1
- # Primate
1
+ # Primate core framework
2
2
 
3
- Expressive, minimal and extensible web framework
3
+ This package contains the core framework code.
4
4
 
5
- ## Getting started
6
-
7
- Run `npx -y primate@latest create` to create a project structure.
8
-
9
- Create a route in `routes/index.js`
10
-
11
- ```js
12
- export default {
13
- get() {
14
- return "Hello, world!";
15
- },
16
- };
17
- ```
18
-
19
- Run `npm i && npm start` and visit http://localhost:6161 in your browser.
20
-
21
- ## Table of Contents
22
-
23
- - [Serving content](#serving-content)
24
- - [Plain text](#plain-text)
25
- - [JSON](#json)
26
- - [Streams](#streams)
27
- - [Response](#response)
28
- - [HTML](#html)
29
- - [Routing](#routing)
30
- - [Basic](#basic)
31
- - [The request object](#the-request-object)
32
- - [Accessing the request body](#accessing-the-request-body)
33
- - [Parameterized routes](#parameterized-routes)
34
- - [Explicit handlers](#explicit-handlers)
35
-
36
- ## Serving content
37
-
38
- Create a file in `routes/index.js` to handle the special `/` route.
39
-
40
- ### Plain text
41
-
42
- ```js
43
- // routes/index.js handles the `/` route
44
- export default {
45
- get() {
46
- // strings are served as plain text
47
- return "Donald";
48
- },
49
- };
50
-
51
- ```
52
-
53
- ### JSON
54
-
55
- ```js
56
- // routes/index.js handles the `/` route
57
- export default {
58
- get() {
59
- // proper JavaScript objects are served as JSON
60
- return [
61
- {name: "Donald"},
62
- {name: "Ryan"},
63
- ];
64
- },
65
- };
66
-
67
- ```
68
-
69
- ### Streams
70
-
71
- ```js
72
- import {File} from "runtime-compat/filesystem";
73
-
74
- // routes/index.js handles the `/` route
75
- export default {
76
- get() {
77
- // ReadableStream or Blob objects are streamed to the client
78
- return new File("users.json");
79
- },
80
- };
81
-
82
- ```
83
-
84
- ### Response
85
-
86
- ```js
87
- import {Response} from "runtime-compat/http";
88
-
89
- // routes/index.js handles the `/` route
90
- export default {
91
- get() {
92
- // use a Response object for custom response status
93
- return new Response("created!", {status: 201});
94
- },
95
- };
96
-
97
- ```
98
-
99
- ### HTML
100
-
101
- ```js
102
- import {html} from "primate";
103
-
104
- // routes/index.js handles the `/` route
105
- export default {
106
- get() {
107
- // to serve HTML, import and use the html handler
108
- return html("<p>Hello, world!</p>");
109
- },
110
- };
111
-
112
- ```
113
-
114
- ## Routing
115
-
116
- Primate uses filesystem-based routes. Every path a client accesses is mapped to
117
- a route under `routes`.
118
-
119
- * `index.js` handles the root route (`/`)
120
- * `post.js` handles the `/post` route
121
- * `post/{postId}.js` handles a parameterized route where `{postId}` can
122
- be mapped to anything, such as `/post/1`
123
-
124
- ### Basic
125
-
126
- ```js
127
- import {redirect} from "primate";
128
-
129
- // routes/site/login.js handles the `/site/login` route
130
- export default {
131
- get() {
132
- // strings are served as plain text
133
- return "Hello, world!";
134
- },
135
- // other HTTP verbs are also available
136
- post() {
137
- return redirect("/");
138
- },
139
- };
140
-
141
- ```
142
-
143
- ### The request object
144
-
145
- ```js
146
- // routes/site/login.js handles the `/site/login` route
147
- export default {
148
- get(request) {
149
- // will serve `["site", "login"]` as JSON
150
- return request.path;
151
- },
152
- };
153
-
154
- ```
155
-
156
- ### Accessing the request body
157
-
158
- For requests containing a body, Primate will attempt to parse the body according
159
- to the content type sent along the request. Currently supported are
160
- `application/x-www-form-urlencoded` (typically for form submission) and
161
- `application/json`.
162
-
163
- ```js
164
- // routes/site/login.js handles the `/site/login` route
165
- export default {
166
- get(request) {
167
- return `username submitted: ${request.body.username}`;
168
- },
169
- };
170
-
171
- ```
172
-
173
- ### Parameterized routes
174
-
175
- ```js
176
- // routes/user/{userId}.js handles all routes of the sort `/user/{userId}`
177
- // where {userId} can be anything
178
- export default {
179
- get(request) {
180
- return `user id: ${request.named.userId}`;
181
- },
182
- };
183
-
184
- ```
185
-
186
- ### Explicit handlers
187
-
188
- Often we can figure out the content type to respond with based on the return
189
- type from the handler. For other cases, we need to use an explicit handler.
190
-
191
- ```js
192
- import {redirect} from "primate";
193
-
194
- // routes/source.js handles the `/source` route
195
- export default {
196
- get() {
197
- return redirect("/target");
198
- },
199
- };
200
-
201
- ```
202
-
203
- ## Resources
204
-
205
- * Website: https://primatejs.com
206
- * IRC: Join the `#primate` channel on `irc.libera.chat`.
207
-
208
- ## License
209
-
210
- MIT
5
+ See the [Getting started][getting-started] guide for documentation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primate",
3
- "version": "0.16.2",
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,20 +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.url;
135
+ const url = new URL(_url.endsWith("/") ? _url.slice(0, -1) : _url);
134
136
 
135
137
  return {
136
- request,
137
- url: new URL(request.url),
138
+ original: request,
139
+ url,
138
140
  body: await parseBody(request),
139
141
  cookies: fromNull(cookies === null
140
142
  ? {}
141
143
  : Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
142
144
  headers: fromNull(Object.fromEntries(request.headers)),
145
+ query: fromNull(Object.fromEntries(url.searchParams)),
143
146
  };
144
147
  };
145
148
 
@@ -147,5 +150,5 @@ export default async app => {
147
150
  const handlers = [...filter("handle", app.modules), handle]
148
151
  .reduceRight((acc, handler) => input => handler(input, acc));
149
152
 
150
- serve(async request => handlers(await parseRequest(request)), config.http);
153
+ return async request => handlers(await parseRequest(request));
151
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
- ];