primate 0.18.0 → 0.19.1
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 +3 -2
- package/src/Logger.js +1 -4
- package/src/app.js +50 -23
- package/src/defaults/primate.config.js +5 -0
- package/src/dispatch.js +24 -0
- package/src/errors.js +48 -19
- package/src/handlers/html.js +27 -5
- package/src/hooks/exports.js +1 -0
- package/src/hooks/handle.js +5 -10
- package/src/hooks/parse.js +8 -8
- package/src/hooks/route.js +20 -20
- package/src/hooks/serve.js +8 -0
- package/src/start.js +15 -10
- package/src/fromNull.js +0 -1
- package/src/hooks/handle/mime.spec.js +0 -13
- package/src/hooks/handle/respond.spec.js +0 -12
- package/src/hooks/parse.spec.js +0 -72
- package/src/hooks/route.spec.js +0 -181
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Expressive, minimal and extensible web framework",
|
|
5
5
|
"homepage": "https://primatejs.com",
|
|
6
6
|
"bugs": "https://github.com/primatejs/primate/issues",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"files": [
|
|
9
|
-
"src
|
|
9
|
+
"src/**/*.js",
|
|
10
|
+
"src/defaults/index.html",
|
|
10
11
|
"!src/**/*.spec.js"
|
|
11
12
|
],
|
|
12
13
|
"bin": "src/bin.js",
|
package/src/Logger.js
CHANGED
|
@@ -80,10 +80,7 @@ const Logger = class Logger {
|
|
|
80
80
|
print(blue("++"), fix);
|
|
81
81
|
name && print(dim(`\n -> ${reference}/${module ?? "primate"}#${hyphenate(name)}`), "\n");
|
|
82
82
|
}
|
|
83
|
-
|
|
84
|
-
print(pre, color(module), "trace follows\n");
|
|
85
|
-
console.log(error);
|
|
86
|
-
}
|
|
83
|
+
this.#trace && error && console.log(error);
|
|
87
84
|
}
|
|
88
85
|
|
|
89
86
|
get level() {
|
package/src/app.js
CHANGED
|
@@ -4,6 +4,7 @@ import {bold, blue} from "runtime-compat/colors";
|
|
|
4
4
|
import errors from "./errors.js";
|
|
5
5
|
import * as handlers from "./handlers/exports.js";
|
|
6
6
|
import * as hooks from "./hooks/exports.js";
|
|
7
|
+
import dispatch from "./dispatch.js";
|
|
7
8
|
|
|
8
9
|
const qualify = (root, paths) =>
|
|
9
10
|
Object.keys(paths).reduce((sofar, key) => {
|
|
@@ -14,16 +15,17 @@ const qualify = (root, paths) =>
|
|
|
14
15
|
return sofar;
|
|
15
16
|
}, {});
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
+
const base = new Path(import.meta.url).up(1);
|
|
19
|
+
const defaultLayout = "index.html";
|
|
18
20
|
|
|
19
|
-
const index = async app => {
|
|
20
|
-
const name =
|
|
21
|
+
const index = async (app, layout = defaultLayout) => {
|
|
22
|
+
const name = layout;
|
|
21
23
|
try {
|
|
22
24
|
// user-provided file
|
|
23
|
-
return await File.read(`${app.paths.
|
|
25
|
+
return await File.read(`${app.paths.layouts.join(name)}`);
|
|
24
26
|
} catch (error) {
|
|
25
27
|
// fallback
|
|
26
|
-
return
|
|
28
|
+
return base.join("defaults", defaultLayout).text();
|
|
27
29
|
}
|
|
28
30
|
};
|
|
29
31
|
|
|
@@ -34,6 +36,13 @@ const hash = async (string, algorithm = "sha-384") => {
|
|
|
34
36
|
return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
|
|
35
37
|
};
|
|
36
38
|
|
|
39
|
+
const attribute = attributes => Object.keys(attributes).length > 0 ?
|
|
40
|
+
" ".concat(Object.entries(attributes)
|
|
41
|
+
.map(([key, value]) => `${key}="${value}"`).join(" "))
|
|
42
|
+
: "";
|
|
43
|
+
const tag = ({name, attributes = {}, code = "", close = true}) =>
|
|
44
|
+
`<${name}${attribute(attributes)}${close ? `>${code}</${name}>` : "/>"}`;
|
|
45
|
+
|
|
37
46
|
export default async (config, root, log) => {
|
|
38
47
|
const {http} = config;
|
|
39
48
|
|
|
@@ -52,6 +61,17 @@ export default async (config, root, log) => {
|
|
|
52
61
|
`${route}`.replace(paths.routes, "").slice(1, -ending.length),
|
|
53
62
|
(await import(route)).default,
|
|
54
63
|
]));
|
|
64
|
+
const types = Object.fromEntries(
|
|
65
|
+
paths.types === undefined ? [] : await Promise.all(
|
|
66
|
+
(await Path.collect(paths.types , /^.*.js$/u))
|
|
67
|
+
/* accept only lowercase-first files in type filename */
|
|
68
|
+
.filter(path => /^[a-z]/u.test(path.name))
|
|
69
|
+
.map(async type => [
|
|
70
|
+
`${type}`.replace(paths.types, "").slice(1, -ending.length),
|
|
71
|
+
(await import(type)).default,
|
|
72
|
+
])));
|
|
73
|
+
Object.entries(types).some(([name, type]) =>
|
|
74
|
+
typeof type !== "function" && errors.InvalidType.throw({name}));
|
|
55
75
|
|
|
56
76
|
const modules = config.modules === undefined ? [] : config.modules;
|
|
57
77
|
|
|
@@ -64,11 +84,11 @@ export default async (config, root, log) => {
|
|
|
64
84
|
config: root.join("primate.config.js"),
|
|
65
85
|
});
|
|
66
86
|
|
|
67
|
-
const hookless = modules.filter(module =>
|
|
68
|
-
|
|
87
|
+
const hookless = modules.filter(module => !Object.keys(module).some(key =>
|
|
88
|
+
[...Object.keys(hooks), "load"].includes(key)));
|
|
69
89
|
hookless.length > 0 && errors.ModuleHasNoHooks.warn(log, {hookless});
|
|
70
90
|
|
|
71
|
-
const {name, version} = await
|
|
91
|
+
const {name, version} = await base.up(1).join("package.json").json();
|
|
72
92
|
|
|
73
93
|
const app = {
|
|
74
94
|
config,
|
|
@@ -97,6 +117,7 @@ export default async (config, root, log) => {
|
|
|
97
117
|
const csp = Object.keys(http.csp).reduce((policy_string, key) =>
|
|
98
118
|
`${policy_string}${key} ${http.csp[key]};`, "");
|
|
99
119
|
const scripts = app.resources
|
|
120
|
+
.filter(({type}) => type !== "style")
|
|
100
121
|
.map(resource => `'${resource.integrity}'`).join(" ");
|
|
101
122
|
const _csp = scripts === "" ? csp : `${csp}script-src 'self' ${scripts};`;
|
|
102
123
|
// remove inline resources
|
|
@@ -113,19 +134,23 @@ export default async (config, root, log) => {
|
|
|
113
134
|
};
|
|
114
135
|
},
|
|
115
136
|
handlers: {...handlers},
|
|
116
|
-
render: async ({body = "", head = ""} = {}) => {
|
|
117
|
-
const html = await index(app);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
render: async ({body = "", head = "", layout} = {}) => {
|
|
138
|
+
const html = await index(app, layout);
|
|
139
|
+
// inline: <script type integrity>...</script>
|
|
140
|
+
// outline: <script type integrity src></script>
|
|
141
|
+
const script = ({inline, code, type, integrity, src}) => inline
|
|
142
|
+
? tag({name: "script", attributes: {type, integrity}, code})
|
|
143
|
+
: tag({name: "script", attributes: {type, integrity, src}});
|
|
144
|
+
// inline: <style>...</style>
|
|
145
|
+
// outline: <link rel="stylesheet" href/>
|
|
146
|
+
const style = ({inline, code, href, rel = "stylesheet"}) => inline
|
|
147
|
+
? tag({name: "style", code})
|
|
148
|
+
: tag({name: "link", attributes: {rel, href}, close: false});
|
|
149
|
+
const heads = app.resources.map(({src, code, type, inline, integrity}) =>
|
|
150
|
+
type === "style"
|
|
151
|
+
? style({inline, code, href: src})
|
|
152
|
+
: script({inline, code, type, integrity, src})
|
|
153
|
+
).join("\n");
|
|
129
154
|
return html
|
|
130
155
|
.replace("%body%", () => body)
|
|
131
156
|
.replace("%head%", () => `${head}${heads}`);
|
|
@@ -134,8 +159,6 @@ export default async (config, root, log) => {
|
|
|
134
159
|
if (type === "module") {
|
|
135
160
|
code = app.replace(code);
|
|
136
161
|
}
|
|
137
|
-
// while integrity is only really needed for scripts, it is also later
|
|
138
|
-
// used for the etag header
|
|
139
162
|
const integrity = await hash(code);
|
|
140
163
|
const _src = new Path(http.static.root).join(src ?? "");
|
|
141
164
|
app.resources.push({src: `${_src}`, code, type, inline, integrity});
|
|
@@ -154,6 +177,7 @@ export default async (config, root, log) => {
|
|
|
154
177
|
app.identifiers = {...exports, ...app.identifiers};
|
|
155
178
|
},
|
|
156
179
|
modules,
|
|
180
|
+
types,
|
|
157
181
|
};
|
|
158
182
|
log.class.print(blue(bold(name)), blue(version),
|
|
159
183
|
`at http${app.secure ? "s" : ""}://${http.host}:${http.port}\n`);
|
|
@@ -164,5 +188,8 @@ export default async (config, root, log) => {
|
|
|
164
188
|
app.modules.push(dependent);
|
|
165
189
|
}})));
|
|
166
190
|
|
|
191
|
+
app.route = hooks.route({...app, dispatch: dispatch(types)});
|
|
192
|
+
app.parse = hooks.parse(dispatch(types));
|
|
193
|
+
|
|
167
194
|
return app;
|
|
168
195
|
};
|
|
@@ -10,6 +10,7 @@ export default {
|
|
|
10
10
|
port: 6161,
|
|
11
11
|
csp: {
|
|
12
12
|
"default-src": "'self'",
|
|
13
|
+
"style-src": "'self'",
|
|
13
14
|
"object-src": "'none'",
|
|
14
15
|
"frame-ancestors": "'none'",
|
|
15
16
|
"form-action": "'self'",
|
|
@@ -21,6 +22,7 @@ export default {
|
|
|
21
22
|
},
|
|
22
23
|
},
|
|
23
24
|
paths: {
|
|
25
|
+
layouts: "layouts",
|
|
24
26
|
static: "static",
|
|
25
27
|
public: "public",
|
|
26
28
|
routes: "routes",
|
|
@@ -29,4 +31,7 @@ export default {
|
|
|
29
31
|
},
|
|
30
32
|
modules: [],
|
|
31
33
|
dist: "app",
|
|
34
|
+
types: {
|
|
35
|
+
explicit: false,
|
|
36
|
+
},
|
|
32
37
|
};
|
package/src/dispatch.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {is, maybe} from "runtime-compat/dyndef";
|
|
2
|
+
import errors from "./errors.js";
|
|
3
|
+
|
|
4
|
+
export default (patches = {}) => value => {
|
|
5
|
+
is(patches.get).undefined();
|
|
6
|
+
return Object.assign(Object.create(null), {
|
|
7
|
+
...Object.fromEntries(Object.entries(patches).map(([name, patch]) =>
|
|
8
|
+
[name, property => {
|
|
9
|
+
is(property).defined(`\`${name}\` called without property`);
|
|
10
|
+
try {
|
|
11
|
+
return patch(value[property], property);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
errors.MismatchedType.throw({message: error.message});
|
|
14
|
+
}
|
|
15
|
+
}])),
|
|
16
|
+
get(property) {
|
|
17
|
+
maybe(property).string();
|
|
18
|
+
if (property !== undefined) {
|
|
19
|
+
return value[property];
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
};
|
package/src/errors.js
CHANGED
|
@@ -45,9 +45,52 @@ export default Object.fromEntries(Object.entries({
|
|
|
45
45
|
level: Logger.Error,
|
|
46
46
|
};
|
|
47
47
|
},
|
|
48
|
+
InvalidPathParameter({named, path}) {
|
|
49
|
+
return {
|
|
50
|
+
message: ["invalid path parameter % in route %", named, path],
|
|
51
|
+
fix: ["use only latin letters and decimal digits in path parameters"],
|
|
52
|
+
level: Logger.Error,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
InvalidRouteName({path}) {
|
|
56
|
+
return {
|
|
57
|
+
message: ["invalid route name %", path],
|
|
58
|
+
fix: ["do not use dots in route names"],
|
|
59
|
+
level: Logger.Error,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
InvalidType({name}) {
|
|
63
|
+
return {
|
|
64
|
+
message: ["invalid type %", name],
|
|
65
|
+
fix: ["use only functions for the default export of types"],
|
|
66
|
+
level: Logger.Error,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
InvalidTypeName({name}) {
|
|
70
|
+
return {
|
|
71
|
+
message: ["invalid type name %", name],
|
|
72
|
+
fix: ["use only latin letters and decimal digits in types"],
|
|
73
|
+
level: Logger.Error,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
MismatchedPath({path, message}) {
|
|
77
|
+
return {
|
|
78
|
+
message: [`mismatched % path: ${message}`, path],
|
|
79
|
+
fix: ["if unintentional, fix the type or the caller"],
|
|
80
|
+
level: Logger.Info,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
MismatchedType({message}) {
|
|
84
|
+
return {
|
|
85
|
+
message: [`mismatched type: ${message}`],
|
|
86
|
+
fix: ["if unintentional, fix the type or the caller"],
|
|
87
|
+
level: Logger.Info,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
48
90
|
ModuleHasNoHooks({hookless}) {
|
|
91
|
+
const modules = hookless.map(({name}) => name).join(", ");
|
|
49
92
|
return {
|
|
50
|
-
message: ["module % has no hooks",
|
|
93
|
+
message: ["module % has no hooks", modules],
|
|
51
94
|
fix: ["ensure every module uses at least one hook or deactivate it"],
|
|
52
95
|
level: Logger.Warn,
|
|
53
96
|
};
|
|
@@ -81,31 +124,17 @@ export default Object.fromEntries(Object.entries({
|
|
|
81
124
|
};
|
|
82
125
|
},
|
|
83
126
|
NoRouteToPath({method, pathname, config: {paths}}) {
|
|
84
|
-
const route = `${paths.routes}
|
|
127
|
+
const route = `${paths.routes}${pathname === "" ? "index" : pathname}.js`;
|
|
85
128
|
return {
|
|
86
129
|
message: ["no % route to %", method, pathname],
|
|
87
130
|
fix: ["if unintentional create a route at %", route],
|
|
88
131
|
level: Logger.Info,
|
|
89
132
|
};
|
|
90
133
|
},
|
|
91
|
-
|
|
134
|
+
ReservedTypeName({name}) {
|
|
92
135
|
return {
|
|
93
|
-
message: ["
|
|
94
|
-
fix: ["
|
|
95
|
-
level: Logger.Error,
|
|
96
|
-
};
|
|
97
|
-
},
|
|
98
|
-
InvalidRouteName({path}) {
|
|
99
|
-
return {
|
|
100
|
-
message: ["invalid route name %", path],
|
|
101
|
-
fix: ["do not use dots in route names"],
|
|
102
|
-
level: Logger.Error,
|
|
103
|
-
};
|
|
104
|
-
},
|
|
105
|
-
InvalidType({name}) {
|
|
106
|
-
return {
|
|
107
|
-
message: ["invalid type %", name],
|
|
108
|
-
fix: ["use only latin letters and decimal digits in types"],
|
|
136
|
+
message: ["type name % is reserved", name],
|
|
137
|
+
fix: ["do not use any reserved type names"],
|
|
109
138
|
level: Logger.Error,
|
|
110
139
|
};
|
|
111
140
|
},
|
package/src/handlers/html.js
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
const script = /(?<=<script)>(?<code>.*?)(?=<\/script>)/gus;
|
|
2
|
+
const style = /(?<=<style)>(?<code>.*?)(?=<\/style>)/gus;
|
|
3
|
+
|
|
4
|
+
const integrate = async (html, publish, headers) => {
|
|
5
|
+
const scripts = await Promise.all([...html.matchAll(script)]
|
|
6
|
+
.map(({groups: {code}}) => publish({code, inline: true})));
|
|
7
|
+
for (const integrity of scripts) {
|
|
8
|
+
headers["Content-Security-Policy"] = headers["Content-Security-Policy"]
|
|
9
|
+
.replace("script-src 'self' ", `script-src 'self' '${integrity}' `);
|
|
10
|
+
}
|
|
11
|
+
const styles = await Promise.all([...html.matchAll(style)]
|
|
12
|
+
.map(({groups: {code}}) => publish({code, type: "style", inline: true})));
|
|
13
|
+
for (const integrity of styles) {
|
|
14
|
+
headers["Content-Security-Policy"] = headers["Content-Security-Policy"]
|
|
15
|
+
.replace("style-src 'self'", `style-src 'self' '${integrity}' `);
|
|
16
|
+
}
|
|
17
|
+
return html
|
|
18
|
+
.replaceAll(/<script>.*?<\/script>/gus, () => "")
|
|
19
|
+
.replaceAll(/<style>.*?<\/style>/gus, () => "");
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default (component, options = {}) => {
|
|
23
|
+
const {status = 200, partial = false, load = false, layout} = options;
|
|
3
24
|
|
|
4
25
|
return async (app, headers) => {
|
|
5
|
-
const body = load ?
|
|
6
|
-
await app.paths.components.join(component).text() : component
|
|
26
|
+
const body = await integrate(await load ?
|
|
27
|
+
await app.paths.components.join(component).text() : component,
|
|
28
|
+
app.publish, headers);
|
|
7
29
|
|
|
8
|
-
return [partial ? body : await app.render({body}), {
|
|
30
|
+
return [partial ? body : await app.render({body, layout}), {
|
|
9
31
|
status,
|
|
10
32
|
headers: {...headers, "Content-Type": "text/html"},
|
|
11
33
|
}];
|
package/src/hooks/exports.js
CHANGED
package/src/hooks/handle.js
CHANGED
|
@@ -11,11 +11,10 @@ export default app => {
|
|
|
11
11
|
const {http} = app.config;
|
|
12
12
|
|
|
13
13
|
const _respond = async (request, headers) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return respond(await app.route(request))(app, headers);
|
|
14
|
+
const {pathname} = request.url;
|
|
15
|
+
return invalid(pathname)
|
|
16
|
+
? errors.NoFileForPath.throw({pathname, config: app.config})
|
|
17
|
+
: (await respond(await app.route(request)))(app, headers);
|
|
19
18
|
};
|
|
20
19
|
|
|
21
20
|
const route = async request => {
|
|
@@ -66,10 +65,6 @@ export default app => {
|
|
|
66
65
|
return route(request);
|
|
67
66
|
};
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
return await resource(request);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
return [...filter("handle", app.modules), handle]
|
|
68
|
+
return [...filter("handle", app.modules), resource]
|
|
74
69
|
.reduceRight((acc, handler) => input => handler(input, acc));
|
|
75
70
|
};
|
package/src/hooks/parse.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import {URL} from "runtime-compat/http";
|
|
2
|
-
import fromNull from "../fromNull.js";
|
|
3
2
|
import errors from "../errors.js";
|
|
4
3
|
|
|
5
4
|
const contents = {
|
|
6
5
|
"application/x-www-form-urlencoded": body =>
|
|
7
|
-
|
|
8
|
-
.map(subpart => decodeURIComponent(subpart).replaceAll("+", " "))))
|
|
6
|
+
Object.fromEntries(body.split("&").map(part => part.split("=")
|
|
7
|
+
.map(subpart => decodeURIComponent(subpart).replaceAll("+", " ")))),
|
|
9
8
|
"application/json": body => JSON.parse(body),
|
|
10
9
|
};
|
|
11
10
|
const decoder = new TextDecoder();
|
|
12
11
|
|
|
13
|
-
export default async request => {
|
|
12
|
+
export default dispatch => async request => {
|
|
14
13
|
const parseContentType = (contentType, body) => {
|
|
15
14
|
const type = contents[contentType];
|
|
16
15
|
return type === undefined ? body : type(body);
|
|
@@ -46,14 +45,15 @@ export default async request => {
|
|
|
46
45
|
const _url = request.url;
|
|
47
46
|
const url = new URL(_url.endsWith("/") ? _url.slice(0, -1) : _url);
|
|
48
47
|
|
|
48
|
+
const body = await parseBody(request);
|
|
49
49
|
return {
|
|
50
50
|
original: request,
|
|
51
51
|
url,
|
|
52
|
-
body:
|
|
53
|
-
cookies:
|
|
52
|
+
body: dispatch(body),
|
|
53
|
+
cookies: dispatch(cookies === null
|
|
54
54
|
? {}
|
|
55
55
|
: Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
|
|
56
|
-
headers:
|
|
57
|
-
query:
|
|
56
|
+
headers: dispatch(Object.fromEntries(request.headers)),
|
|
57
|
+
query: dispatch(Object.fromEntries(url.searchParams)),
|
|
58
58
|
};
|
|
59
59
|
};
|
package/src/hooks/route.js
CHANGED
|
@@ -4,13 +4,6 @@ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
|
|
|
4
4
|
|
|
5
5
|
// insensitive-case equal
|
|
6
6
|
const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
|
|
7
|
-
// HTTP verbs
|
|
8
|
-
const verbs = [
|
|
9
|
-
// CRUD
|
|
10
|
-
"post", "get", "put", "delete",
|
|
11
|
-
// extended
|
|
12
|
-
"connect", "options", "trace", "patch", "head",
|
|
13
|
-
];
|
|
14
7
|
|
|
15
8
|
/* routes may not contain dots */
|
|
16
9
|
export const invalid = route => /\./u.test(route);
|
|
@@ -63,27 +56,34 @@ export default app => {
|
|
|
63
56
|
return [];
|
|
64
57
|
}
|
|
65
58
|
|
|
66
|
-
const path = toRoute(route);
|
|
67
59
|
return Object.entries(imported)
|
|
68
|
-
.
|
|
69
|
-
.map(([method, handler]) => ({method, handler, path}));
|
|
60
|
+
.map(([method, handler]) => ({method, handler, path: toRoute(route)}));
|
|
70
61
|
}).flat();
|
|
71
62
|
|
|
72
63
|
const {types = {}} = app;
|
|
73
|
-
Object.entries(types).
|
|
74
|
-
errors.
|
|
75
|
-
|
|
76
|
-
|
|
64
|
+
Object.entries(types).some(([name]) => /^(?:[^\W_]*)$/u.test(name) ||
|
|
65
|
+
errors.InvalidTypeName.throw({name}));
|
|
66
|
+
const reserved = ["get", "raw"];
|
|
67
|
+
Object.entries(types).some(([name]) => reserved.includes(name) &&
|
|
68
|
+
errors.ReservedTypeName.throw({name}));
|
|
69
|
+
|
|
70
|
+
const {explicit} = app.config.types;
|
|
71
|
+
const isType = (groups, path) => Object
|
|
77
72
|
.entries(groups ?? {})
|
|
78
73
|
.map(([name, value]) =>
|
|
79
|
-
[types[name] === undefined ? name : `${name}$${name}`, value])
|
|
74
|
+
[types[name] === undefined || explicit ? name : `${name}$${name}`, value])
|
|
80
75
|
.filter(([name]) => name.includes("$"))
|
|
81
76
|
.map(([name, value]) => [name.split("$")[1], value])
|
|
82
|
-
.every(([name, value]) =>
|
|
83
|
-
|
|
77
|
+
.every(([name, value]) => {
|
|
78
|
+
try {
|
|
79
|
+
return types?.[name](value) === true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return errors.MismatchedPath.throw({message: error.message, path});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
84
|
const isPath = ({route, path}) => {
|
|
85
85
|
const result = route.path.exec(path);
|
|
86
|
-
return result === null ? false : isType(result.groups);
|
|
86
|
+
return result === null ? false : isType(result.groups, path);
|
|
87
87
|
};
|
|
88
88
|
const isMethod = ({route, method, path}) => ieq(route.method, method)
|
|
89
89
|
&& isPath({route, path});
|
|
@@ -95,8 +95,8 @@ export default app => {
|
|
|
95
95
|
const {original: {method}, url: {pathname}} = request;
|
|
96
96
|
const verb = find(method, pathname) ??
|
|
97
97
|
errors.NoRouteToPath.throw({method, pathname, config: app.config});
|
|
98
|
-
const path = reentry(verb.path?.exec(pathname).groups,
|
|
99
|
-
object => object.map(([key, value]) => [key.split("$")[0], value]));
|
|
98
|
+
const path = app.dispatch(reentry(verb.path?.exec(pathname).groups,
|
|
99
|
+
object => object.map(([key, value]) => [key.split("$")[0], value])));
|
|
100
100
|
|
|
101
101
|
// verb.handler is the last module to be executed
|
|
102
102
|
const handlers = [...modules, verb.handler].reduceRight((acc, handler) =>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
|
|
2
|
+
|
|
3
|
+
export default async (app, server) => {
|
|
4
|
+
app.log.info("running serve hooks", {module: "primate"});
|
|
5
|
+
await [...filter("serve", app.modules), _ => _]
|
|
6
|
+
.reduceRight((acc, handler) => input =>
|
|
7
|
+
handler(input, acc))({...app, server});
|
|
8
|
+
};
|
package/src/start.js
CHANGED
|
@@ -1,31 +1,36 @@
|
|
|
1
1
|
import {serve, Response} from "runtime-compat/http";
|
|
2
2
|
import {InternalServerError} from "./http-statuses.js";
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import * as hooks from "./hooks/exports.js";
|
|
4
|
+
|
|
5
|
+
const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
|
|
5
6
|
|
|
6
7
|
export default async (app, operations = {}) => {
|
|
7
8
|
// register handlers
|
|
8
|
-
await register({...app, register(name, handler) {
|
|
9
|
+
await hooks.register({...app, register(name, handler) {
|
|
9
10
|
app.handlers[name] = handler;
|
|
10
11
|
}});
|
|
11
12
|
|
|
12
13
|
// compile server-side code
|
|
13
|
-
await compile(app);
|
|
14
|
+
await hooks.compile(app);
|
|
14
15
|
// publish client-side code
|
|
15
|
-
await publish(app);
|
|
16
|
+
await hooks.publish(app);
|
|
16
17
|
|
|
17
18
|
// bundle client-side code
|
|
18
|
-
await bundle(app, operations?.bundle);
|
|
19
|
-
|
|
20
|
-
const _route = route(app);
|
|
19
|
+
await hooks.bundle(app, operations?.bundle);
|
|
21
20
|
|
|
22
|
-
serve(async request => {
|
|
21
|
+
const server = await serve(async request => {
|
|
23
22
|
try {
|
|
24
23
|
// parse, handle
|
|
25
|
-
return await handle(
|
|
24
|
+
return await hooks.handle(app)(await app.parse(request));
|
|
26
25
|
} catch(error) {
|
|
26
|
+
console.log("TEST2");
|
|
27
27
|
app.log.auto(error);
|
|
28
28
|
return new Response(null, {status: InternalServerError});
|
|
29
29
|
}
|
|
30
30
|
}, app.config.http);
|
|
31
|
+
|
|
32
|
+
await [...filter("serve", app.modules), _ => _]
|
|
33
|
+
.reduceRight((acc, handler) => input => handler(input, acc))({
|
|
34
|
+
...app, server,
|
|
35
|
+
});
|
|
31
36
|
};
|
package/src/fromNull.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export default object => Object.assign(Object.create(null), object);
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import mime from "./mime.js";
|
|
2
|
-
|
|
3
|
-
export default test => {
|
|
4
|
-
test.case("match", assert => {
|
|
5
|
-
assert(mime("/app.js")).equals("text/javascript");
|
|
6
|
-
});
|
|
7
|
-
test.case("no extension", assert => {
|
|
8
|
-
assert(mime("/app")).equals("application/octet-stream");
|
|
9
|
-
});
|
|
10
|
-
test.case("unknown extension", assert => {
|
|
11
|
-
assert(mime("/app.unknown")).equals("application/octet-stream");
|
|
12
|
-
});
|
|
13
|
-
};
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import respond from "./respond.js";
|
|
2
|
-
|
|
3
|
-
export default test => {
|
|
4
|
-
test.case("guess URL", assert => {
|
|
5
|
-
const url = "https://primatejs.com/";
|
|
6
|
-
const status = 302;
|
|
7
|
-
const [body, options] = respond(new URL(url))();
|
|
8
|
-
assert(body).null();
|
|
9
|
-
assert(options.status).equals(status);
|
|
10
|
-
assert(options.headers.Location).equals(url);
|
|
11
|
-
});
|
|
12
|
-
};
|
package/src/hooks/parse.spec.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import parse from "./parse.js";
|
|
2
|
-
import Logger from "../Logger.js";
|
|
3
|
-
|
|
4
|
-
const {mark} = Logger;
|
|
5
|
-
|
|
6
|
-
const r = await (async () => {
|
|
7
|
-
const p = "https://p.com";
|
|
8
|
-
const request = (method, path = "/", options = {}) =>
|
|
9
|
-
new Request(`${p}${path}`, {method, ...options});
|
|
10
|
-
return Object.fromEntries(["get", "post", "put", "delete"].map(verb =>
|
|
11
|
-
[verb, (...args) => parse(request(verb.toUpperCase(), ...args))]));
|
|
12
|
-
})();
|
|
13
|
-
|
|
14
|
-
export default test => {
|
|
15
|
-
test.case("no body => null", async assert => {
|
|
16
|
-
assert((await r.get("/")).body).null();
|
|
17
|
-
assert((await r.post("/")).body).null();
|
|
18
|
-
});
|
|
19
|
-
test.case("body is application/json", async assert => {
|
|
20
|
-
const body = JSON.stringify({foo: "bar"});
|
|
21
|
-
const contentType = "application/json";
|
|
22
|
-
const headers = {"Content-Type": contentType};
|
|
23
|
-
assert((await r.post("/", {body, headers})).body).equals({foo: "bar"});
|
|
24
|
-
|
|
25
|
-
const faulty = `${body}%`;
|
|
26
|
-
assert(() => r.post("/", {body: faulty, headers}))
|
|
27
|
-
.throws(mark("cannot parse body % as %", faulty, contentType));
|
|
28
|
-
});
|
|
29
|
-
test.case("body is application/x-www-form-urlencoded", async assert => {
|
|
30
|
-
assert((await r.post("/", {
|
|
31
|
-
body: encodeURI("foo=bar &bar=baz"),
|
|
32
|
-
headers: {
|
|
33
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
34
|
-
},
|
|
35
|
-
})).body).equals({foo: "bar ", bar: "baz"});
|
|
36
|
-
});
|
|
37
|
-
test.case("no query => {}", async assert => {
|
|
38
|
-
assert((await r.get("/")).query).equals({});
|
|
39
|
-
});
|
|
40
|
-
test.case("query", async assert => {
|
|
41
|
-
assert((await r.get("/?key=value")).query).equals({key: "value"});
|
|
42
|
-
});
|
|
43
|
-
test.case("no cookies => {}", async assert => {
|
|
44
|
-
assert((await r.get("/")).cookies).equals({});
|
|
45
|
-
});
|
|
46
|
-
test.case("cookies", async assert => {
|
|
47
|
-
assert((await r.get("/?key=value", {
|
|
48
|
-
headers: {
|
|
49
|
-
Cookie: "key=value;key2=value2",
|
|
50
|
-
},
|
|
51
|
-
})).cookies).equals({key: "value", key2: "value2"});
|
|
52
|
-
});
|
|
53
|
-
test.case("no headers => {}", async assert => {
|
|
54
|
-
assert((await r.get("/")).headers).equals({});
|
|
55
|
-
});
|
|
56
|
-
test.case("headers", async assert => {
|
|
57
|
-
assert((await r.get("/?key=value", {
|
|
58
|
-
headers: {
|
|
59
|
-
"X-User": "Donald",
|
|
60
|
-
},
|
|
61
|
-
})).headers).equals({"x-user": "Donald"});
|
|
62
|
-
});
|
|
63
|
-
test.case("cookies double as headers", async assert => {
|
|
64
|
-
const response = await r.get("/?key=value", {
|
|
65
|
-
headers: {
|
|
66
|
-
Cookie: "key=value",
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
assert(response.headers).equals({cookie: "key=value"});
|
|
70
|
-
assert(response.cookies).equals({key: "value"});
|
|
71
|
-
});
|
|
72
|
-
};
|
package/src/hooks/route.spec.js
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import Logger from "../Logger.js";
|
|
2
|
-
import route from "./route.js";
|
|
3
|
-
|
|
4
|
-
const {mark} = Logger;
|
|
5
|
-
|
|
6
|
-
const app = {
|
|
7
|
-
config: {
|
|
8
|
-
paths: {
|
|
9
|
-
routes: "/routes",
|
|
10
|
-
},
|
|
11
|
-
},
|
|
12
|
-
routes: [
|
|
13
|
-
"index",
|
|
14
|
-
"user",
|
|
15
|
-
"users/{userId}a",
|
|
16
|
-
"comments/{commentId:comment}",
|
|
17
|
-
"users/{userId}/comments/{commentId}",
|
|
18
|
-
"users/{userId:user}/comments/{commentId}/a",
|
|
19
|
-
"users/{userId:user}/comments/{commentId:comment}/b",
|
|
20
|
-
"users/{_userId}/comments/{commentId}/d",
|
|
21
|
-
"users/{_userId}/comments/{_commentId}/e",
|
|
22
|
-
"comments2/{_commentId}",
|
|
23
|
-
"users2/{_userId}/{commentId}",
|
|
24
|
-
"users3/{_userId}/{_commentId:_commentId}",
|
|
25
|
-
"users4/{_userId}/{_commentId}",
|
|
26
|
-
"users5/{truthy}",
|
|
27
|
-
"{uuid}/{Uuid}/{UUID}",
|
|
28
|
-
].map(pathname => [pathname, {get: request => request}]),
|
|
29
|
-
types: {
|
|
30
|
-
user: id => /^\d*$/u.test(id),
|
|
31
|
-
comment: id => /^\d*$/u.test(id),
|
|
32
|
-
_userId: id => /^\d*$/u.test(id),
|
|
33
|
-
_commentId: id => /^\d*$/u.test(id),
|
|
34
|
-
truthy: () => 1,
|
|
35
|
-
uuid: _ => _ === "uuid",
|
|
36
|
-
Uuid: _ => _ === "Uuid",
|
|
37
|
-
UUID: _ => _ === "UUID",
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export default test => {
|
|
42
|
-
const router = route(app);
|
|
43
|
-
const p = "https://p.com";
|
|
44
|
-
const r = pathname => {
|
|
45
|
-
const original = new Request(`${p}${pathname}`, {method: "GET"});
|
|
46
|
-
const {url} = original;
|
|
47
|
-
const end = -1;
|
|
48
|
-
return router({
|
|
49
|
-
original,
|
|
50
|
-
url: new URL(url.endsWith("/") ? url.slice(0, end) : url),
|
|
51
|
-
});
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
test.reassert(assert => ({
|
|
55
|
-
match: (url, result) => {
|
|
56
|
-
assert(r(url).url.pathname).equals(result ?? url);
|
|
57
|
-
},
|
|
58
|
-
fail: (url, result) => {
|
|
59
|
-
const throws = mark("no % route to %", "GET", result ?? url);
|
|
60
|
-
assert(() => r(url)).throws(throws);
|
|
61
|
-
},
|
|
62
|
-
path: (url, result) => assert(r(url).path).equals(result),
|
|
63
|
-
assert,
|
|
64
|
-
}));
|
|
65
|
-
|
|
66
|
-
const get = () => null;
|
|
67
|
-
/* errors {{{ */
|
|
68
|
-
test.case("error DoubleRouted", ({assert}) => {
|
|
69
|
-
const post = ["post", {get}];
|
|
70
|
-
const throws = mark("double route %", "post");
|
|
71
|
-
assert(() => route({routes: [post, post]})).throws(throws);
|
|
72
|
-
});
|
|
73
|
-
test.case("error DoublePathParameter", ({assert}) => {
|
|
74
|
-
const path = "{user}/{user}";
|
|
75
|
-
const throws = mark("double path parameter % in route %", "user", path);
|
|
76
|
-
assert(() => route({routes: [[path, {get}]]})).throws(throws);
|
|
77
|
-
});
|
|
78
|
-
test.case("error EmptyRoutefile", ({assert}) => {
|
|
79
|
-
const path = "user";
|
|
80
|
-
const throws = mark("empty route file at %", `/routes/${path}.js`);
|
|
81
|
-
const base = {
|
|
82
|
-
log: {
|
|
83
|
-
auto(error) {
|
|
84
|
-
throw error;
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
config: {
|
|
88
|
-
paths: {
|
|
89
|
-
routes: "/routes",
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
assert(() => route({...base, routes: [[path, undefined]]})).throws(throws);
|
|
94
|
-
assert(() => route({...base, routes: [[path, {}]]})).throws(throws);
|
|
95
|
-
});
|
|
96
|
-
test.case("error InvalidRouteName", ({assert}) => {
|
|
97
|
-
const post = ["po.st", {get}];
|
|
98
|
-
const throws = mark("invalid route name %", "po.st");
|
|
99
|
-
assert(() => route({routes: [post], types: {}})).throws(throws);
|
|
100
|
-
});
|
|
101
|
-
test.case("error InvalidParameter", ({assert}) => {
|
|
102
|
-
const path = "{us$er}";
|
|
103
|
-
const throws = mark("invalid path parameter % in route %", "us$er", path);
|
|
104
|
-
assert(() => route({routes: [[path, {get}]]})).throws(throws);
|
|
105
|
-
});
|
|
106
|
-
test.case("error InvalidType", ({assert}) => {
|
|
107
|
-
const throws = mark("invalid type %", "us$er");
|
|
108
|
-
const types = {us$er: () => false};
|
|
109
|
-
assert(() => route({routes: [], types})).throws(throws);
|
|
110
|
-
});
|
|
111
|
-
/* }}} */
|
|
112
|
-
|
|
113
|
-
test.case("index route", ({match}) => {
|
|
114
|
-
match("/");
|
|
115
|
-
});
|
|
116
|
-
test.case("simple route", ({match}) => {
|
|
117
|
-
match("/user");
|
|
118
|
-
});
|
|
119
|
-
test.case("param match/fail", ({match, fail}) => {
|
|
120
|
-
match("/users/1a");
|
|
121
|
-
match("/users/aa");
|
|
122
|
-
match("/users/ba?key=value", "/users/ba");
|
|
123
|
-
fail("/user/1a");
|
|
124
|
-
fail("/users/a");
|
|
125
|
-
fail("/users/aA");
|
|
126
|
-
fail("/users//a");
|
|
127
|
-
fail("/users/?a", "/users/");
|
|
128
|
-
});
|
|
129
|
-
test.case("no params", ({path}) => {
|
|
130
|
-
path("/", {});
|
|
131
|
-
});
|
|
132
|
-
test.case("single param", ({path}) => {
|
|
133
|
-
path("/users/1a", {userId: "1"});
|
|
134
|
-
});
|
|
135
|
-
test.case("params", ({path, fail}) => {
|
|
136
|
-
path("/users/1/comments/2", {userId: "1", commentId: "2"});
|
|
137
|
-
path("/users/1/comments/2/b", {userId: "1", commentId: "2"});
|
|
138
|
-
fail("/users/d/comments/2/b");
|
|
139
|
-
fail("/users/1/comments/d/b");
|
|
140
|
-
fail("/users/d/comments/d/b");
|
|
141
|
-
});
|
|
142
|
-
test.case("single typed param", ({path, fail}) => {
|
|
143
|
-
path("/comments/1", {commentId: "1"});
|
|
144
|
-
fail("/comments/ ", "/comments");
|
|
145
|
-
fail("/comments/1d");
|
|
146
|
-
});
|
|
147
|
-
test.case("mixed untyped and typed params", ({path, fail}) => {
|
|
148
|
-
path("/users/1/comments/2/a", {userId: "1", commentId: "2"});
|
|
149
|
-
fail("/users/d/comments/2/a");
|
|
150
|
-
});
|
|
151
|
-
test.case("single implicit typed param", ({path, fail}) => {
|
|
152
|
-
path("/comments2/1", {_commentId: "1"});
|
|
153
|
-
fail("/comments2/d");
|
|
154
|
-
});
|
|
155
|
-
test.case("mixed implicit and untyped params", ({path, fail}) => {
|
|
156
|
-
path("/users2/1/2", {_userId: "1", commentId: "2"});
|
|
157
|
-
fail("/users2/d/2");
|
|
158
|
-
fail("/users2/d");
|
|
159
|
-
});
|
|
160
|
-
test.case("mixed implicit and explicit params", ({path, fail}) => {
|
|
161
|
-
path("/users3/1/2", {_userId: "1", _commentId: "2"});
|
|
162
|
-
fail("/users3/d/2");
|
|
163
|
-
fail("/users3/1/d");
|
|
164
|
-
fail("/users3");
|
|
165
|
-
});
|
|
166
|
-
test.case("implicit params", ({path, fail}) => {
|
|
167
|
-
path("/users4/1/2", {_userId: "1", _commentId: "2"});
|
|
168
|
-
fail("/users4/d/2");
|
|
169
|
-
fail("/users4/1/d");
|
|
170
|
-
fail("/users4");
|
|
171
|
-
});
|
|
172
|
-
test.case("fail not strictly true implicit params", ({fail}) => {
|
|
173
|
-
fail("/users5/any");
|
|
174
|
-
});
|
|
175
|
-
test.case("different case params", ({path, fail}) => {
|
|
176
|
-
path("/uuid/Uuid/UUID", {uuid: "uuid", Uuid: "Uuid", UUID: "UUID"});
|
|
177
|
-
fail("/uuid/uuid/uuid");
|
|
178
|
-
fail("/Uuid/UUID/uuid");
|
|
179
|
-
fail("/UUID/uuid/Uuid");
|
|
180
|
-
});
|
|
181
|
-
};
|