primate 0.21.3 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primate",
3
- "version": "0.21.3",
3
+ "version": "0.22.0",
4
4
  "description": "Expressive, minimal and extensible web framework",
5
5
  "homepage": "https://primatejs.com",
6
6
  "bugs": "https://github.com/primatejs/primate/issues",
@@ -18,7 +18,7 @@
18
18
  "directory": "packages/primate"
19
19
  },
20
20
  "dependencies": {
21
- "runtime-compat": "^0.24.1"
21
+ "runtime-compat": "^0.25.7"
22
22
  },
23
23
  "engines": {
24
24
  "node": ">=18.16"
package/src/app.js CHANGED
@@ -2,34 +2,28 @@ import crypto from "runtime-compat/crypto";
2
2
  import {tryreturn} from "runtime-compat/async";
3
3
  import {File, Path} from "runtime-compat/fs";
4
4
  import {bold, blue} from "runtime-compat/colors";
5
+ import {is} from "runtime-compat/dyndef";
5
6
  import {transform, valmap} from "runtime-compat/object";
7
+ import {globify} from "runtime-compat/string";
8
+ import errors from "./errors.js";
9
+ import {print} from "./Logger.js";
10
+ import dispatch from "./dispatch.js";
11
+ import to_sorted from "./to_sorted.js";
6
12
  import * as handlers from "./handlers/exports.js";
7
- import * as hooks from "./hooks/exports.js";
8
13
  import * as loaders from "./loaders/exports.js";
9
- import dispatch from "./dispatch.js";
10
- import {print} from "./Logger.js";
11
- import toSorted from "./toSorted.js";
12
14
 
13
- const base = new Path(import.meta.url).up(1);
15
+ const {DoubleFileExtension} = errors;
16
+
14
17
  // do not hard-depend on node
15
18
  const packager = import.meta.runtime?.packager ?? "package.json";
16
19
  const library = import.meta.runtime?.library ?? "node_modules";
17
20
 
18
- const fallback = (app, page) =>
19
- tryreturn(_ => base.join("defaults", page).text())
20
- .orelse(_ => base.join("defaults", app.config.pages.index).text());
21
-
22
21
  // use user-provided file or fall back to default
23
- const index = (app, page) =>
24
- tryreturn(_ => File.read(`${app.paths.pages.join(page)}`))
25
- .orelse(_ => fallback(app, page));
22
+ const index = (base, page, fallback) =>
23
+ tryreturn(_ => File.read(`${base.join(page)}`))
24
+ .orelse(_ => File.read(`${base.join(fallback)}`));
26
25
 
27
26
  const encoder = new TextEncoder();
28
- const hash = async (string, algorithm = "sha-384") => {
29
- const bytes = await crypto.subtle.digest(algorithm, encoder.encode(string));
30
- const algo = algorithm.replace("-", _ => "");
31
- return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
32
- };
33
27
 
34
28
  const attribute = attributes => Object.keys(attributes).length > 0
35
29
  ? " ".concat(Object.entries(attributes)
@@ -38,11 +32,13 @@ const attribute = attributes => Object.keys(attributes).length > 0
38
32
  const tag = ({name, attributes = {}, code = "", close = true}) =>
39
33
  `<${name}${attribute(attributes)}${close ? `>${code}</${name}>` : "/>"}`;
40
34
 
41
- export default async (config, root, log) => {
35
+ const base = new Path(import.meta.url).up(1);
36
+
37
+ export default async (log, root, config) => {
42
38
  const {http} = config;
43
39
  const secure = http?.ssl !== undefined;
44
40
  const {name, version} = await base.up(1).join(packager).json();
45
- const paths = valmap(config.paths, value => root.join(value));
41
+ const path = valmap(config.location, value => root.join(value));
46
42
 
47
43
  const at = `at http${secure ? "s" : ""}://${http.host}:${http.port}\n`;
48
44
  print(blue(bold(name)), blue(version), at);
@@ -53,45 +49,66 @@ export default async (config, root, log) => {
53
49
  http.ssl.cert = root.join(http.ssl.cert);
54
50
  }
55
51
 
56
- const types = await loaders.types(log, paths.types);
52
+ const types = await loaders.types(log, path.types);
53
+ const error = await path.routes.join("+error.js");
54
+ const routes = await loaders.routes(log, path.routes);
55
+ const modules = await loaders.modules(log, root, config);
57
56
 
58
- const app = {
59
- build: {
60
- paths: {
61
- client: paths.build.join("client"),
62
- server: paths.build.join("server"),
63
- components: paths.build.join("components"),
64
- },
65
- },
57
+ return {
66
58
  config,
67
59
  secure,
68
60
  name,
69
61
  version,
70
62
  importmaps: {},
71
63
  assets: [],
72
- entrypoints: [],
73
- paths,
64
+ exports: [],
65
+ path,
74
66
  root,
75
67
  log,
76
- async copy(source, target, filter = /^.*.js$/u) {
77
- const jss = await source.collect(filter);
78
- await Promise.all(jss.map(async js => {
79
- const file = await js.file.read();
80
- const to = await target.join(js.path.replace(source, ""));
68
+ error: {
69
+ default: await error.exists ? (await import(error)).default : undefined,
70
+ },
71
+ handlers: {...handlers},
72
+ types,
73
+ routes,
74
+ layout: {
75
+ depth: Math.max(...routes.map(({layouts}) => layouts.length)) + 1,
76
+ },
77
+ dispatch: dispatch(types),
78
+ modules,
79
+ packager,
80
+ library,
81
+ // copy files to build folder, potentially transforming them
82
+ async stage(source, directory, filter) {
83
+ const {paths, mapper} = this.config.build.transform;
84
+ is(paths).array();
85
+ is(mapper).function();
86
+
87
+ const regexs = paths.map(file => globify(file));
88
+ const target = this.runpath(directory);
89
+
90
+ await Promise.all((await source.collect(filter)).map(async path => {
91
+ const filename = new Path(directory).join(path.debase(source));
92
+ const to = await target.join(filename.debase(directory));
81
93
  await to.directory.file.create();
82
- await to.file.write(file);
94
+ if (regexs.some(regex => regex.test(filename))) {
95
+ const contents = mapper(await path.text());
96
+ await to.file.write(contents);
97
+ } else {
98
+ await path.file.copy(to);
99
+ }
83
100
  }));
84
101
  },
85
102
  headers() {
86
103
  const csp = Object.keys(http.csp).reduce((policy, key) =>
87
104
  `${policy}${key} ${http.csp[key]};`, "")
88
105
  .replace("script-src 'self'", `script-src 'self' ${
89
- app.assets
106
+ this.assets
90
107
  .filter(({type}) => type !== "style")
91
108
  .map(asset => `'${asset.integrity}'`).join(" ")
92
109
  } `)
93
110
  .replace("style-src 'self'", `style-src 'self' ${
94
- app.assets
111
+ this.assets
95
112
  .filter(({type}) => type === "style")
96
113
  .map(asset => `'${asset.integrity}'`).join(" ")
97
114
  } `);
@@ -101,9 +118,13 @@ export default async (config, root, log) => {
101
118
  "Referrer-Policy": "same-origin",
102
119
  };
103
120
  },
104
- handlers: {...handlers},
105
- async render({body = "", page} = {}) {
106
- const html = await index(app, page ?? config.pages.index);
121
+ runpath(...directories) {
122
+ return this.path.build.join(...directories);
123
+ },
124
+ async render({body = "", head = "", page = config.pages.index} = {}) {
125
+ const {location: {pages}} = this.config;
126
+
127
+ const html = await index(this.runpath(pages), page, config.pages.index);
107
128
  // inline: <script type integrity>...</script>
108
129
  // outline: <script type integrity src></script>
109
130
  const script = ({inline, code, type, integrity, src}) => inline
@@ -114,37 +135,53 @@ export default async (config, root, log) => {
114
135
  const style = ({inline, code, href, rel = "stylesheet"}) => inline
115
136
  ? tag({name: "style", code})
116
137
  : tag({name: "link", attributes: {rel, href}, close: false});
117
- const head = toSorted(this.assets,
138
+
139
+ const heads = head.concat("\n", to_sorted(this.assets,
118
140
  ({type}) => -1 * (type === "importmap"))
119
141
  .map(({src, code, type, inline, integrity}) =>
120
142
  type === "style"
121
143
  ? style({inline, code, href: src})
122
144
  : script({inline, code, type, integrity, src})
123
- ).join("\n");
145
+ ).join("\n"));
124
146
  // remove inline assets
125
147
  this.assets = this.assets.filter(({inline, type}) => !inline
126
148
  || type === "importmap");
127
- return html.replace("%body%", _ => body).replace("%head%", _ => head);
149
+ return html.replace("%body%", _ => body).replace("%head%", _ => heads);
128
150
  },
129
- async publish({src, code, type = "", inline = false}) {
130
- if (!inline) {
131
- const base = this.build.paths.client.join(src);
151
+ async publish({src, code, type = "", inline = false, copy = true}) {
152
+ if (!inline && copy) {
153
+ const base = this.runpath(this.config.location.client).join(src);
132
154
  await base.directory.file.create();
133
155
  await base.file.write(code);
134
156
  }
135
157
  if (inline || type === "style") {
136
- this.assets.push({src: new Path(http.static.root).join(src ?? "").path,
137
- code: inline ? code : "", type, inline, integrity: await hash(code)});
158
+ this.assets.push({
159
+ src: new Path(http.static.root).join(src ?? "").path,
160
+ code: inline ? code : "",
161
+ type,
162
+ inline,
163
+ integrity: await this.hash(code),
164
+ });
138
165
  }
139
166
  },
140
- bootstrap({type, code}) {
141
- this.entrypoints.push({type, code});
167
+ export({type, code}) {
168
+ this.exports.push({type, code});
169
+ },
170
+ register(extension, handler) {
171
+ is(this.handlers[extension]).undefined(DoubleFileExtension.new(extension));
172
+ this.handlers[extension] = handler;
173
+ },
174
+ async hash(data, algorithm = "sha-384") {
175
+ const bytes = await crypto.subtle.digest(algorithm, encoder.encode(data));
176
+ const prefix = algorithm.replace("-", _ => "");
177
+ return `${prefix}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
142
178
  },
143
179
  async import(module) {
144
- const {build: {modules}, http: {static: {root}}} = this.config;
180
+ const {http: {static: {root}}, location: {client}} = this.config;
181
+
145
182
  const parts = module.split("/");
146
- const path = [library, ...parts];
147
- const pkg = await Path.resolve().join(...path, packager).json();
183
+ const path = [this.library, ...parts];
184
+ const pkg = await Path.resolve().join(...path, this.packager).json();
148
185
  const exports = pkg.exports === undefined
149
186
  ? {[module]: `/${module}/${pkg.main}`}
150
187
  : transform(pkg.exports, entry => entry
@@ -158,27 +195,11 @@ export default async (config, root, log) => {
158
195
  ?? value.import?.replace(".", `./${module}`),
159
196
  ]));
160
197
  const dependency = Path.resolve().join(...path);
161
- const to = new Path(this.build.paths.client, modules, ...parts);
198
+ const to = new Path(this.runpath(client), this.library, ...parts);
162
199
  await dependency.file.copy(to);
163
200
  this.importmaps = {
164
- ...valmap(exports, value => new Path(root, modules, value).path),
201
+ ...valmap(exports, value => new Path(root, this.library, value).path),
165
202
  ...this.importmaps};
166
203
  },
167
- types,
168
- routes: await loaders.routes(log, paths.routes),
169
- dispatch: dispatch(types),
170
- };
171
-
172
- const error = await app.paths.routes.join("+error.js");
173
- const modules = await loaders.modules(app, root, config);
174
-
175
- return {...app,
176
- modules,
177
- error: {
178
- default: await error.exists ? (await import(error)).default : undefined,
179
- },
180
- layoutDepth: Math.max(...app.routes.map(({layouts}) => layouts.length)) + 1,
181
- route: hooks.route({...app, modules}),
182
- parse: hooks.parse(dispatch(types)),
183
204
  };
184
205
  };
@@ -1,3 +1,3 @@
1
1
  import start from "../start.js";
2
2
 
3
- export default app => start(app);
3
+ export default app => start(app, ["bundle"]);
@@ -1,3 +1,3 @@
1
1
  import start from "../start.js";
2
2
 
3
- export default app => start(app, {bundle: true});
3
+ export default app => start(app);
package/src/cwd.js ADDED
@@ -0,0 +1,3 @@
1
+ import {Path} from "runtime-compat/fs";
2
+
3
+ export default (meta, up = 1) => new Path(meta.url).up(up);
@@ -1,3 +1,4 @@
1
+ import {identity} from "runtime-compat/function";
1
2
  import Logger from "../Logger.js";
2
3
 
3
4
  export default {
@@ -27,20 +28,31 @@ export default {
27
28
  root: "/",
28
29
  },
29
30
  },
30
- paths: {
31
- build: "build",
31
+ location: {
32
+ // renderable components
32
33
  components: "components",
34
+ // HTML pages
33
35
  pages: "pages",
36
+ // hierarchical routes
34
37
  routes: "routes",
38
+ // static assets
35
39
  static: "static",
40
+ // runtime types
36
41
  types: "types",
42
+ // build environment
43
+ build: "build",
44
+ // client build
45
+ client: "client",
46
+ // server build
47
+ server: "server",
37
48
  },
38
49
  build: {
39
50
  includes: [],
40
- static: "static",
41
- app: "app",
42
- modules: "modules",
43
51
  index: "index.js",
52
+ transform: {
53
+ paths: [],
54
+ mapper: identity,
55
+ },
44
56
  },
45
57
  types: {
46
58
  explicit: false,
package/src/errors.json CHANGED
@@ -1,3 +1,5 @@
1
+
2
+
1
3
  {
2
4
  "module": "primate",
3
5
  "errors": {
@@ -6,6 +8,11 @@
6
8
  "fix": "use a different content type or fix body",
7
9
  "level": "Warn"
8
10
  },
11
+ "DoubleFileExtension": {
12
+ "message": "double file extension {0}",
13
+ "fix": "unload one of the two handlers registering the file extension",
14
+ "level": "Error"
15
+ },
9
16
  "DoubleModule": {
10
17
  "message": "double module {0} in {1}",
11
18
  "fix": "load {0} only once",
@@ -58,12 +65,12 @@
58
65
  },
59
66
  "MismatchedPath": {
60
67
  "message": "mismatched path {0}: {1}",
61
- "fix": "if unintentional, fix the type or the caller",
68
+ "fix": "fix the type or the caller",
62
69
  "level": "Info"
63
70
  },
64
71
  "MismatchedType": {
65
72
  "message": "mismatched type: {0}",
66
- "fix": "if unintentional, fix the type or the caller",
73
+ "fix": "fix the type or the caller",
67
74
  "level": "Info"
68
75
  },
69
76
  "ModuleHasNoHooks": {
@@ -88,7 +95,7 @@
88
95
  },
89
96
  "NoFileForPath": {
90
97
  "message": "no file for {0}",
91
- "fix": "if unintentional create a file at {1}{0}",
98
+ "fix": "create a file at {1}{0}",
92
99
  "level": "Info"
93
100
  },
94
101
  "NoHandlerForExtension": {
@@ -98,7 +105,7 @@
98
105
  },
99
106
  "NoRouteToPath": {
100
107
  "message": "no {0} route to {1}",
101
- "fix": "if unintentional create a {3} route function at {2}.js",
108
+ "fix": "create a {0} route function at {2}.js",
102
109
  "level": "Info"
103
110
  },
104
111
  "ReservedTypeName": {
@@ -2,22 +2,19 @@ import {Response, Status, MediaType} from "runtime-compat/http";
2
2
 
3
3
  const script = /(?<=<script)>(?<code>.*?)(?=<\/script>)/gus;
4
4
  const style = /(?<=<style)>(?<code>.*?)(?=<\/style>)/gus;
5
+ const remove = /<(?<tag>script|style)>.*?<\/\k<tag>>/gus;
6
+ const inline = true;
5
7
 
6
- const integrate = async (html, publish) => {
7
- await Promise.all([...html.matchAll(script)]
8
- .map(({groups: {code}}) => publish({code, inline: true})));
9
- await Promise.all([...html.matchAll(style)]
10
- .map(({groups: {code}}) => publish({code, type: "style", inline: true})));
11
- return html.replaceAll(/<(?<tag>script|style)>.*?<\/\k<tag>>/gus, _ => "");
12
- };
13
-
14
- export default (component, options = {}) => {
15
- const {status = Status.OK, partial = false, load = false} = options;
8
+ export default (name, options = {}) => {
9
+ const {status = Status.OK, partial = false} = options;
16
10
 
17
11
  return async app => {
18
- const body = await integrate(await load ?
19
- await app.paths.components.join(component).text() : component,
20
- app.publish);
12
+ const html = await app.path.components.join(name).text();
13
+ await Promise.all([...html.matchAll(script)]
14
+ .map(({groups: {code}}) => app.publish({code, inline})));
15
+ await Promise.all([...html.matchAll(style)]
16
+ .map(({groups: {code}}) => app.publish({code, type: "style", inline})));
17
+ const body = html.replaceAll(remove, _ => "");
21
18
  // needs to happen before app.render()
22
19
  const headers = app.headers();
23
20
 
@@ -1,8 +1,7 @@
1
- import errors from "../errors.js";
1
+ import errors from "../errors.js";
2
2
 
3
3
  export default (name, props, options) => async (app, ...rest) => {
4
- const ending = name.slice(name.lastIndexOf(".") + 1);
5
- const handler = app.handlers[ending];
6
- return handler?.(name, {load: true, ...props}, options)(app, ...rest)
7
- ?? errors.NoHandlerForExtension.throw(ending, name);
4
+ const extension = name.slice(name.lastIndexOf(".") + 1);
5
+ return app.handlers[extension]?.(name, props, options)(app, ...rest)
6
+ ?? errors.NoHandlerForExtension.throw(extension, name);
8
7
  };
@@ -1,18 +1,3 @@
1
- import {File} from "runtime-compat/fs";
1
+ import {cascade} from "runtime-compat/async";
2
2
 
3
- const pre = async app => {
4
- const {paths, config, build} = app;
5
- if (await paths.static.exists) {
6
- // copy static files to build/client/static
7
- await File.copy(paths.static, build.paths.client.join(config.build.static));
8
- }
9
- };
10
-
11
- export default async (app, bundle) => {
12
- await pre(app);
13
- if (bundle) {
14
- app.log.info("running bundle hooks", {module: "primate"});
15
- await [...app.modules.bundle, _ => _]
16
- .reduceRight((acc, handler) => input => handler(input, acc))(app);
17
- }
18
- };
3
+ export default app => cascade(app.modules.bundle)(app);
@@ -1,29 +1,39 @@
1
+ import {Path} from "runtime-compat/fs";
2
+ import {cascade} from "runtime-compat/async";
1
3
  import copy_includes from "./copy_includes.js";
4
+ import cwd from "../cwd.js";
5
+
6
+ const html = /^.*.html$/u;
7
+ const defaults = cwd(import.meta, 2).join("defaults");
2
8
 
3
9
  const pre = async app => {
4
- const {build, paths, config} = app;
10
+ const {config: {location}, path} = app;
5
11
 
6
12
  // remove build directory in case exists
7
- if (await paths.build.exists) {
8
- await paths.build.file.remove();
13
+ if (await path.build.exists) {
14
+ await path.build.file.remove();
9
15
  }
10
- await build.paths.server.file.create();
11
- await build.paths.components.file.create();
16
+ await Promise.all(["server", "client", "components", "pages"]
17
+ .map(directory => app.runpath(directory).file.create()));
18
+
19
+ // copy framework pages
20
+ await app.stage(defaults, location.pages, html);
21
+ // overwrite transformed pages to build
22
+ await path.pages.exists && await app.stage(path.pages, location.pages, html);
12
23
 
13
- if (await paths.components.exists) {
24
+ if (await path.components.exists) {
14
25
  // copy all files to build/components
15
- await app.copy(paths.components, build.paths.components, /^.*$/u);
16
- // copy .js files from components to build/server
17
- await app.copy(paths.components, build.paths.server.join(config.build.app));
26
+ await app.stage(path.components, location.components);
27
+ // copy .js files from components to build/server, since frontend
28
+ // frameworks handle non-js files
29
+ const to = Path.join(location.server, location.components);
30
+ await app.stage(path.components, to, /^.*.js$/u);
18
31
  }
19
32
 
20
33
  // copy additional subdirectories to build/server
21
- await copy_includes(app, "server");
22
- };
34
+ await copy_includes(app, location.server);
23
35
 
24
- export default async app => {
25
- await pre(app);
26
- app.log.info("running compile hooks", {module: "primate"});
27
- await [...app.modules.compile, _ => _]
28
- .reduceRight((acc, handler) => input => handler(input, acc))(app);
36
+ return app;
29
37
  };
38
+
39
+ export default async app => cascade(app.modules.compile)(await pre(app));
@@ -1,11 +1,11 @@
1
- const system = ["routes", "components", "build"];
1
+ import {Path} from "runtime-compat/fs";
2
2
 
3
3
  export default async (app, type, post = () => undefined) => {
4
4
  const {config} = app;
5
5
  const {build} = config;
6
6
  const {includes} = build;
7
7
 
8
- const reserved = system.concat(build.static, build.app, build.modules);
8
+ const reserved = Object.values(app.config.location);
9
9
 
10
10
  if (Array.isArray(includes)) {
11
11
  await Promise.all(includes
@@ -14,9 +14,8 @@ export default async (app, type, post = () => undefined) => {
14
14
  .map(async include => {
15
15
  const path = app.root.join(include);
16
16
  if (await path.exists) {
17
- const to = app.build.paths[type].join(include);
18
- await to.file.create();
19
- await app.copy(path, to);
17
+ const to = Path.join(type, include);
18
+ await app.stage(path, to);
20
19
  await post(to);
21
20
  }
22
21
  }));
@@ -1,3 +1,4 @@
1
+ export {default as init} from "./init.js";
1
2
  export {default as register} from "./register.js";
2
3
  export {default as compile} from "./compile.js";
3
4
  export {default as publish} from "./publish.js";
@@ -1,5 +1,5 @@
1
1
  import {Response, Status, MediaType} from "runtime-compat/http";
2
- import {tryreturn} from "runtime-compat/async";
2
+ import {cascade, tryreturn} from "runtime-compat/async";
3
3
  import {respond} from "./respond/exports.js";
4
4
  import {invalid} from "./route.js";
5
5
  import {error as clientError} from "../handlers/exports.js";
@@ -9,18 +9,18 @@ const {NoFileForPath} = _errors;
9
9
  const guardError = Symbol("guardError");
10
10
 
11
11
  export default app => {
12
- const {config: {http: {static: {root}}, build}, paths} = app;
12
+ const {config: {http: {static: {root}}}, location} = app;
13
13
 
14
- const route = async request => {
14
+ const as_route = async request => {
15
15
  const {pathname} = request.url;
16
16
  // if NoFileForPath is thrown, this will remain undefined
17
- let errorHandler = app.error.default;
17
+ let error_handler = app.error.default;
18
18
 
19
19
  return tryreturn(async _ => {
20
20
  const {path, guards, errors, layouts, handler} = invalid(pathname)
21
- ? NoFileForPath.throw(pathname, paths.static)
21
+ ? NoFileForPath.throw(pathname, location.static)
22
22
  : await app.route(request);
23
- errorHandler = errors?.at(-1);
23
+ error_handler = errors?.at(-1);
24
24
 
25
25
  // handle guards
26
26
  try {
@@ -43,21 +43,20 @@ export default app => {
43
43
  }
44
44
 
45
45
  // handle request
46
- const handlers = [...app.modules.route, handler]
47
- .reduceRight((next, last) => input => last(input, next));
48
- return (await respond(await handlers({...request, path})))(app, {
46
+ const response = cascade(app.modules.route, handler)({...request, path});
47
+ return (await respond(await response))(app, {
49
48
  layouts: await Promise.all(layouts.map(layout => layout(request))),
50
49
  }, request);
51
50
  }).orelse(async error => {
52
51
  app.log.auto(error);
53
52
 
54
53
  // the +error.js page itself could fail
55
- return tryreturn(_ => respond(errorHandler(request))(app, {}, request))
54
+ return tryreturn(_ => respond(error_handler(request))(app, {}, request))
56
55
  .orelse(_ => clientError()(app, {}, request));
57
56
  });
58
57
  };
59
58
 
60
- const asset = async file => new Response(file.readable, {
59
+ const as_asset = async file => new Response(file.readable, {
61
60
  status: Status.OK,
62
61
  headers: {
63
62
  "Content-Type": MediaType.resolve(file.name),
@@ -65,22 +64,23 @@ export default app => {
65
64
  },
66
65
  });
67
66
 
67
+ const client = app.runpath(app.config.location.client);
68
68
  const handle = async request => {
69
69
  const {pathname} = request.url;
70
70
  if (pathname.startsWith(root)) {
71
71
  const debased = pathname.replace(root, _ => "");
72
- const {client} = app.build.paths;
73
72
  // try static first
74
- const _static = client.join(build.static, debased);
75
- if (await _static.isFile) {
76
- return asset(_static.file);
73
+ const asset = app.path.build.join(app.config.location.static, debased);
74
+ if (await asset.isFile) {
75
+ return as_asset(asset.file);
76
+ }
77
+ const path = client.join(debased);
78
+ if (await path.isFile) {
79
+ return as_asset(path.file);
77
80
  }
78
- const _app = client.join(debased);
79
- return await _app.isFile ? asset(_app.file) : route(request);
80
81
  }
81
- return route(request);
82
+ return as_route(request);
82
83
  };
83
84
 
84
- return [...app.modules.handle, handle]
85
- .reduceRight((next, last) => input => last(input, next));
85
+ return cascade(app.modules.handle, handle);
86
86
  };
@@ -0,0 +1,3 @@
1
+ import {cascade} from "runtime-compat/async";
2
+
3
+ export default app => cascade(app.modules.init)(app);
@@ -6,10 +6,13 @@ import errors from "../errors.js";
6
6
 
7
7
  const {APPLICATION_FORM_URLENCODED, APPLICATION_JSON} = MediaType;
8
8
 
9
+ const {decodeURIComponent: decode} = globalThis;
10
+ const deslash = url => url.replaceAll(/(?<!http:)\/{2,}/gu, _ => "/");
11
+
9
12
  const contents = {
10
13
  [APPLICATION_FORM_URLENCODED]: body => from(body.split("&")
11
14
  .map(part => part.split("=")
12
- .map(subpart => decodeURIComponent(subpart).replaceAll("+", " ")))),
15
+ .map(subpart => decode(subpart).replaceAll("+", " ")))),
13
16
  [APPLICATION_JSON]: body => JSON.parse(body),
14
17
  };
15
18
 
@@ -19,7 +22,7 @@ const content = (type, body) =>
19
22
 
20
23
  export default dispatch => async original => {
21
24
  const {headers} = original;
22
- const url = new URL(original.url);
25
+ const url = new URL(deslash(decode(original.url)));
23
26
  const body = await stringify(original.body);
24
27
  const cookies = headers.get("cookie");
25
28
 
@@ -1,20 +1,26 @@
1
1
  import {Path} from "runtime-compat/fs";
2
- import {identity} from "runtime-compat/function";
2
+ import {cascade} from "runtime-compat/async";
3
3
  import copy_includes from "./copy_includes.js";
4
4
 
5
5
  const post = async app => {
6
- const {config, paths} = app;
7
- const build = config.build.app;
6
+ const {config: {location, http: {static: {root}}}, path} = app;
7
+
8
8
  {
9
9
  // after hook, publish a zero assumptions app.js (no css imports)
10
- const code = app.entrypoints.filter(({type}) => type === "script")
11
- .map(entrypoint => entrypoint.code).join("");
12
- const src = new Path(config.http.static.root, build, config.build.index);
13
- await app.publish({src, code, type: "module"});
14
-
15
- if (await paths.components.exists) {
16
- // copy .js files from components to build/server
17
- await app.copy(app.paths.components, app.build.paths.client.join(build));
10
+ const src = new Path(root, app.config.build.index);
11
+
12
+ await app.publish({
13
+ code: app.exports.filter(({type}) => type === "script")
14
+ .map(({code}) => code).join(""),
15
+ src,
16
+ type: "module",
17
+ });
18
+
19
+ if (await path.components.exists) {
20
+ // copy .js files from components to build/client, since frontend
21
+ // frameworks handle non-js files
22
+ const to = Path.join(location.client, location.components);
23
+ await app.stage(path.components, to, /^.*.js$/u);
18
24
  }
19
25
 
20
26
  const imports = {...app.importmaps, app: src.path};
@@ -25,34 +31,33 @@ const post = async app => {
25
31
  });
26
32
  }
27
33
 
28
- // copy JavaScript and CSS files from `app.paths.static`
29
- const imports = await Path.collect(app.paths.static, /\.(?:js|css)$/u);
30
- await Promise.all(imports.map(async file => {
31
- const code = await file.text();
32
- const src = file.name;
33
- const isCSS = file.extension === ".css";
34
- await app.publish({src: `${config.build.static}/${src}`, code,
35
- type: isCSS ? "style" : "module"});
36
- if (isCSS) {
37
- app.bootstrap({type: "style",
38
- code: `import "../${config.build.static}/${file.name}";`});
39
- }
40
- }));
34
+ if (await path.static.exists) {
35
+ // copy static files to build/static
36
+ await app.stage(path.static, location.static);
37
+
38
+ // publish JavaScript and CSS files
39
+ const imports = await Path.collect(path.static, /\.(?:js|css)$/u);
40
+ await Promise.all(imports.map(async file => {
41
+ const code = await file.text();
42
+ const src = `/${file.name}`;
43
+ const type = file.extension === ".css" ? "style" : "module";
44
+ // already copied in `app.stage`
45
+ await app.publish({src, code, type, copy: false});
46
+ type === "style" && app.export({type,
47
+ code: `import "../${location.static}${src}";`});
48
+ }));
49
+ }
41
50
 
42
- const source = `${app.build.paths.client}`;
43
- const {root} = app.config.http.static;
44
51
  // copy additional subdirectories to build/client
45
- await copy_includes(app, "client", async to =>
52
+ const client = app.runpath(location.client);
53
+ await copy_includes(app, location.client, async to =>
46
54
  Promise.all((await to.collect(/\.js$/u)).map(async script => {
47
- const src = new Path(root, script.path.replace(source, () => ""));
55
+ const src = new Path(root, script.path.replace(client, _ => ""));
48
56
  await app.publish({src, code: await script.text(), type: "module"});
49
57
  }))
50
58
  );
51
- };
52
59
 
53
- export default async app => {
54
- app.log.info("running publish hooks", {module: "primate"});
55
- await [...app.modules.publish, identity]
56
- .reduceRight((acc, handler) => input => handler(input, acc))(app);
57
- await post(app);
60
+ return app;
58
61
  };
62
+
63
+ export default async app => post(await cascade(app.modules.publish)(app));
@@ -1,5 +1,3 @@
1
- export default async app => {
2
- app.log.info("running register hooks", {module: "primate"});
3
- await [...app.modules.register, _ => _]
4
- .reduceRight((acc, handler) => input => handler(input, acc))(app);
5
- };
1
+ import {cascade} from "runtime-compat/async";
2
+
3
+ export default app => cascade(app.modules.register)(app);
@@ -1,4 +1,4 @@
1
- import {keymap} from "runtime-compat/object";
1
+ import {from} from "runtime-compat/object";
2
2
  import {tryreturn} from "runtime-compat/sync";
3
3
  import errors from "../errors.js";
4
4
  import validate from "../validate.js";
@@ -9,39 +9,45 @@ const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
9
9
  /* routes may not contain dots */
10
10
  export const invalid = route => /\./u.test(route);
11
11
 
12
+ const deroot = pathname => pathname.endsWith("/") && pathname !== "/"
13
+ ? pathname.slice(0, -1) : pathname;
14
+
12
15
  export default app => {
13
- const {types, routes, config: {explicit, paths}} = app;
16
+ const {types, routes, config: {types: {explicit}, location}} = app;
17
+
18
+ const to_path = (route, pathname) => app.dispatch(from(Object
19
+ .entries(route.pathname.exec(pathname)?.groups ?? {})
20
+ .map(([name, value]) =>
21
+ [types[name] === undefined || explicit ? name : `${name}$${name}`, value])
22
+ .map(([name, value]) => [name.split("$"), value])
23
+ .map(([[name, type], value]) =>
24
+ [name, type === undefined ? value : validate(types[type], value, name)]
25
+ )));
14
26
 
15
- const isType = (groups, pathname) => Object
27
+ const is_type = (groups, pathname) => Object
16
28
  .entries(groups ?? {})
17
29
  .map(([name, value]) =>
18
30
  [types[name] === undefined || explicit ? name : `${name}$${name}`, value])
19
31
  .filter(([name]) => name.includes("$"))
20
- .map(([name, value]) => [name.split("$")[1], value])
21
- .every(([name, value]) =>
22
- tryreturn(_ => validate(types[name], value, name))
32
+ .map(([name, value]) => [name.split("$"), value])
33
+ .map(([[name, type], value]) =>
34
+ tryreturn(_ => [name, validate(types[type], value, name)])
23
35
  .orelse(({message}) => errors.MismatchedPath.throw(pathname, message)));
24
- const isPath = ({route, pathname}) => {
36
+ const is_path = ({route, pathname}) => {
25
37
  const result = route.pathname.exec(pathname);
26
- return result === null ? false : isType(result.groups, pathname);
38
+ return result === null ? false : is_type(result.groups, pathname);
27
39
  };
28
- const isMethod = ({route, method, pathname}) => ieq(route.method, method)
29
- && isPath({route, pathname});
40
+ const is_method = ({route, method, pathname}) => ieq(route.method, method)
41
+ && is_path({route, pathname});
30
42
  const find = (method, pathname) => routes.find(route =>
31
- isMethod({route, method, pathname}));
43
+ is_method({route, method, pathname}));
32
44
 
33
- const index = path => `${paths.routes}${path === "/" ? "/index" : path}`;
34
- const deroot = pathname => pathname.endsWith("/") && pathname !== "/"
35
- ? pathname.slice(0, -1) : pathname;
45
+ const index = path => `${location.routes}${path === "/" ? "/index" : path}`;
36
46
 
37
47
  return ({original: {method}, url}) => {
38
48
  const pathname = deroot(url.pathname);
39
49
  const route = find(method, pathname) ?? errors.NoRouteToPath
40
- .throw(method, pathname, index(pathname), method.toLowerCase());
41
-
42
- const path = app.dispatch(keymap(route.pathname?.exec(pathname).groups,
43
- key => key.split("$")[0]));
44
-
45
- return {...route, path};
50
+ .throw(method.toLowerCase(), pathname, index(pathname));
51
+ return {...route, path: to_path(route, pathname)};
46
52
  };
47
53
  };
@@ -1,8 +1,6 @@
1
- import {identity} from "runtime-compat/function";
1
+ import {cascade} from "runtime-compat/async";
2
2
 
3
3
  export default async (app, server) => {
4
4
  app.log.info("running serve hooks", {module: "primate"});
5
- await [...app.modules.serve, identity]
6
- .reduceRight((next, previous) =>
7
- input => previous(input, next))({...app, server});
5
+ await cascade(app.modules.serve)({...app, server});
8
6
  };
@@ -4,13 +4,10 @@ import errors from "../errors.js";
4
4
 
5
5
  const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
6
6
 
7
- const load = (app, modules = []) => {
8
- return modules.map(module =>
9
- [module, load(app, module.load?.(app) ?? [])]
10
- ).flat();
11
- };
7
+ const load = (modules = []) => modules.map(module =>
8
+ [module, load(module.load?.() ?? [])]).flat();
12
9
 
13
- export default async (app, root, config) => {
10
+ export default async (log, root, config) => {
14
11
  const modules = config.modules ?? [];
15
12
 
16
13
  Array.isArray(modules) || errors.ModulesMustBeArray.throw("modules");
@@ -23,17 +20,12 @@ export default async (app, root, config) => {
23
20
  errors.DoubleModule.throw(doubled(names), root.join("primate.config.js"));
24
21
 
25
22
  const hookless = modules.filter(module => !Object.keys(module).some(key =>
26
- [...Object.keys(hooks), "load", "init"].includes(key)));
27
- hookless.length > 0 && errors.ModuleHasNoHooks.warn(app.log,
23
+ [...Object.keys(hooks), "load"].includes(key)));
24
+ hookless.length > 0 && errors.ModuleHasNoHooks.warn(log,
28
25
  hookless.map(({name}) => name).join(", "));
29
26
 
30
27
  // collect modules
31
- const loaded = load(app, modules).flat(2);
32
-
33
- // initialize modules
34
- await Promise.all(loaded
35
- .filter(module => module.init !== undefined)
36
- .map(module => module.init(app)));
28
+ const loaded = load(modules).flat(2);
37
29
 
38
30
  return Object.fromEntries(Object.keys(hooks)
39
31
  .map(hook => [hook, filter(hook, loaded)]));
@@ -1,12 +1,12 @@
1
1
  import {Path} from "runtime-compat/fs";
2
2
  import errors from "../../errors.js";
3
- import toSorted from "../../toSorted.js";
3
+ import to_sorted from "../../to_sorted.js";
4
4
 
5
5
  export default type => async (log, directory, load) => {
6
6
  const filter = path => new RegExp(`^\\+${type}.js$`, "u").test(path.name);
7
7
 
8
8
  const replace = new RegExp(`\\+${type}`, "u");
9
- const objects = toSorted((await load({log, directory, filter, warn: false}))
9
+ const objects = to_sorted((await load({log, directory, filter, warn: false}))
10
10
  .map(([name, object]) => [name.replace(replace, () => ""), object]),
11
11
  ([a], [b]) => a.length - b.length);
12
12
 
@@ -1,5 +1,5 @@
1
1
  import {tryreturn} from "runtime-compat/sync";
2
- import {from, filter, valmap} from "runtime-compat/object";
2
+ import {from} from "runtime-compat/object";
3
3
  import errors from "../errors.js";
4
4
  import {invalid} from "../hooks/route.js";
5
5
  import {default as fs, doubled} from "./common.js";
package/src/run.js CHANGED
@@ -10,7 +10,7 @@ import defaults from "./defaults/primate.config.js";
10
10
  let logger = new Logger({level: Logger.Warn});
11
11
  const {runtime = "node"} = import.meta;
12
12
 
13
- const getConfig = async root => {
13
+ const get_config = async root => {
14
14
  const name = "primate.config.js";
15
15
  const config = root.join(name);
16
16
  return await config.exists
@@ -26,19 +26,16 @@ const getConfig = async root => {
26
26
  : defaults;
27
27
  };
28
28
 
29
- export default async name => {
30
- try {
31
- // use module root if possible, fall back to current directory
32
- const root = await tryreturn(_ => Path.root()).orelse(_ => Path.resolve());
33
- const config = await getConfig(root);
34
- logger = new Logger(config.logger);
35
- await command(name)(await app(config, root, logger));
36
- } catch (error) {
37
- if (error.level === Logger.Error) {
38
- logger.auto(error);
39
- bye();
40
- } else {
41
- throw error;
42
- }
29
+ export default async name => tryreturn(async _ => {
30
+ // use module root if possible, fall back to current directory
31
+ const root = await tryreturn(_ => Path.root()).orelse(_ => Path.resolve());
32
+ const config = await get_config(root);
33
+ logger = new Logger(config.logger);
34
+ await command(name)(await app(logger, root, config));
35
+ }).orelse(error => {
36
+ if (error.level === Logger.Error) {
37
+ logger.auto(error);
38
+ return bye();
43
39
  }
44
- };
40
+ throw error;
41
+ });
package/src/start.js CHANGED
@@ -1,21 +1,18 @@
1
1
  import {serve, Response, Status} from "runtime-compat/http";
2
- import {tryreturn} from "runtime-compat/async";
3
- import {identity} from "runtime-compat/function";
2
+ import {cascade, tryreturn} from "runtime-compat/async";
4
3
  import * as hooks from "./hooks/exports.js";
5
4
 
6
- export default async (app, operations = {}) => {
7
- // register handlers
8
- await hooks.register({...app, register(name, handler) {
9
- app.handlers[name] = handler;
10
- }});
5
+ export default async (app$, deactivated = []) => {
6
+ // run one-time hooks
7
+ const app = await cascade(["init", "register", "compile", "publish", "bundle"]
8
+ .filter(hook => !deactivated.includes(hook))
9
+ .map(hook => async (input, next) => {
10
+ app$.log.info(`running ${hook} hooks`, {module: "primate"});
11
+ return next(await hooks[hook](input));
12
+ }))(app$);
11
13
 
12
- // compile server-side code
13
- await hooks.compile(app);
14
- // publish client-side code
15
- await hooks.publish(app);
16
-
17
- // bundle client-side code
18
- await hooks.bundle(app, operations?.bundle);
14
+ app.route = hooks.route(app);
15
+ app.parse = hooks.parse(app.dispatch);
19
16
 
20
17
  const server = await serve(async request =>
21
18
  tryreturn(async _ => hooks.handle(app)(await app.parse(request)))
@@ -25,8 +22,5 @@ export default async (app, operations = {}) => {
25
22
  }),
26
23
  app.config.http);
27
24
 
28
- await [...app.modules.serve, identity]
29
- .reduceRight((acc, handler) => input => handler(input, acc))({
30
- ...app, server,
31
- });
25
+ await cascade(app.modules.serve)({...app, server});
32
26
  };
File without changes