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 +3 -208
- package/package.json +1 -1
- package/src/Logger.js +18 -1
- package/src/app.js +30 -42
- package/src/defaults/primate.config.js +1 -0
- package/src/handlers/error.js +8 -0
- package/src/handlers/exports.js +1 -0
- package/src/hooks/handle.js +18 -15
- package/src/hooks/handle.spec.js +88 -0
- package/src/hooks/route.js +62 -43
- package/src/hooks/route.spec.js +143 -0
- package/src/run.js +44 -1
- package/src/start.js +2 -1
- package/src/handlers/http.js +0 -1
- package/src/handlers/http404.js +0 -6
package/README.md
CHANGED
|
@@ -1,210 +1,5 @@
|
|
|
1
|
-
# Primate
|
|
1
|
+
# Primate core framework
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This package contains the core framework code.
|
|
4
4
|
|
|
5
|
-
|
|
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
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 (
|
|
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
|
|
86
|
+
paths,
|
|
100
87
|
root,
|
|
101
|
-
log
|
|
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
|
|
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}`;
|
package/src/handlers/exports.js
CHANGED
package/src/hooks/handle.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
18
|
-
const {http} = config;
|
|
16
|
+
const {http} = app.config;
|
|
19
17
|
|
|
20
18
|
const _respond = async request => {
|
|
21
|
-
const csp = Object.keys(
|
|
22
|
-
`${policy_string}${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
|
-
//
|
|
43
|
-
const handlers = [...modules,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/src/hooks/route.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
30
|
+
try {
|
|
31
|
+
return new RegExp(`^/${route}$`, "u");
|
|
32
|
+
} catch (error) {
|
|
33
|
+
abort("same parameter twice");
|
|
34
|
+
}
|
|
28
35
|
};
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
20
|
+
serve(await handle({route: route(app), ...app}), app.config.http);
|
|
20
21
|
};
|
package/src/handlers/http.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {default as http404} from "./http404.js";
|