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 +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 -16
- 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/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,21 +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
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
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";
|