primate 0.30.2 → 0.31.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.30.2",
3
+ "version": "0.31.0",
4
4
  "description": "Polymorphic development platform",
5
5
  "homepage": "https://primatejs.com",
6
6
  "bugs": "https://github.com/primatejs/primate/issues",
@@ -19,7 +19,7 @@
19
19
  "directory": "packages/primate"
20
20
  },
21
21
  "dependencies": {
22
- "rcompat": "^0.9.4"
22
+ "rcompat": "^0.11.0"
23
23
  },
24
24
  "engines": {
25
25
  "node": ">=18"
package/src/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import crypto from "rcompat/crypto";
2
2
  import { tryreturn } from "rcompat/async";
3
- import { File } from "rcompat/fs";
3
+ import FS from "rcompat/fs";
4
4
  import { is } from "rcompat/invariant";
5
5
  import o from "rcompat/object";
6
6
  import { globify } from "rcompat/string";
@@ -28,8 +28,8 @@ const to_csp = (config_csp, assets, csp) => config_csp
28
28
 
29
29
  // use user-provided file or fall back to default
30
30
  const load = (base, page, fallback) =>
31
- tryreturn(_ => File.text(`${base.join(page)}`))
32
- .orelse(_ => File.text(`${base.join(fallback)}`));
31
+ tryreturn(_ => FS.File.text(`${base.join(page)}`))
32
+ .orelse(_ => FS.File.text(`${base.join(fallback)}`));
33
33
 
34
34
  const encoder = new TextEncoder();
35
35
 
@@ -81,7 +81,6 @@ export default async (log, root, config) => {
81
81
  secure,
82
82
  importmaps: {},
83
83
  assets: [],
84
- exports: [],
85
84
  path,
86
85
  root,
87
86
  log,
@@ -109,7 +108,7 @@ export default async (log, root, config) => {
109
108
 
110
109
  await Promise.all((await source.collect(filter)).map(async path => {
111
110
  const debased = path.debase(this.root).path.slice(1);
112
- const filename = File.join(directory, path.debase(source));
111
+ const filename = FS.File.join(directory, path.debase(source));
113
112
  const target = await target_base.join(filename.debase(directory));
114
113
  await target.directory.create();
115
114
  await (regexs.some(regex => regex.test(debased))
@@ -188,15 +187,10 @@ export default async (log, root, config) => {
188
187
  const head = tags[tag_name]({ code, type, inline: true, integrity });
189
188
  return { head, integrity: `'${integrity}'` };
190
189
  },
191
- async publish({ src, code, type = "", inline = false, copy = true }) {
192
- if (!inline && copy) {
193
- const base = this.runpath(this.get("location.client")).join(src);
194
- await base.directory.create();
195
- await base.write(code);
196
- }
190
+ async publish({ src, code, type = "", inline = false }) {
197
191
  if (inline || type === "style") {
198
192
  this.assets.push({
199
- src: File.join(http.static.root, src ?? "").path,
193
+ src: FS.File.join(http.static.root, src ?? "").path,
200
194
  code: inline ? code : "",
201
195
  type,
202
196
  inline,
@@ -211,9 +205,6 @@ export default async (log, root, config) => {
211
205
  { "style-src": [], "script-src": [] },
212
206
  );
213
207
  },
214
- export({ type, code }) {
215
- this.exports.push({ type, code });
216
- },
217
208
  register(extension, operations) {
218
209
  is(this.handlers[extension]).undefined(DoubleFileExtension.new(extension));
219
210
  this.handlers[extension] = operations.handle;
@@ -224,32 +215,5 @@ export default async (log, root, config) => {
224
215
  const prefix = algorithm.replace("-", _ => "");
225
216
  return `${prefix}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
226
217
  },
227
- async import(module, deep_import) {
228
- const parts = module.split("/");
229
- const path = [this.library, ...parts];
230
- const pkg = await File.resolve().join(...path, this.manifest).json();
231
- const exports = pkg.exports === undefined
232
- ? { [module]: `/${module}/${pkg.main}` }
233
- : o.transform(pkg.exports, entry => entry
234
- .filter(([, export$]) =>
235
- export$.browser?.[deep_import] !== undefined
236
- || export$.browser?.default !== undefined
237
- || export$.import !== undefined
238
- || export$.default !== undefined)
239
- .map(([key, value]) => [
240
- key.replace(".", deep_import === undefined
241
- ? module : `${module}/${deep_import}`),
242
- value.browser?.[deep_import]?.replace(".", `./${module}`)
243
- ?? value.browser?.default.replace(".", `./${module}`)
244
- ?? value.default?.replace(".", `./${module}`)
245
- ?? value.import?.replace(".", `./${module}`),
246
- ]));
247
- const dependency = File.resolve().join(...path);
248
- const target = this.runpath(this.get("location.client")).join(...path);
249
- await dependency.copy(target);
250
- this.importmaps = { ...o.valmap(exports, value =>
251
- File.join(this.get("http.static.root"), this.library, value).webpath()),
252
- ...this.importmaps };
253
- },
254
218
  };
255
219
  };
@@ -44,8 +44,9 @@ export default {
44
44
  server: "server",
45
45
  },
46
46
  build: {
47
+ name: "app",
47
48
  includes: [],
48
- index: "index.js",
49
+ excludes: [],
49
50
  transform: {
50
51
  paths: [],
51
52
  mapper: identity,
package/src/errors.js CHANGED
@@ -1,7 +1,7 @@
1
- import { File } from "rcompat/fs";
1
+ import FS from "rcompat/fs";
2
2
  import Logger from "./Logger.js";
3
3
 
4
- const json = await new File(import.meta.url).up(1).join("errors.json").json();
4
+ const json = await new FS.File(import.meta.url).up(1).join("errors.json").json();
5
5
 
6
6
  const errors = Logger.err(json.errors, json.module);
7
7
 
package/src/errors.json CHANGED
@@ -111,10 +111,20 @@
111
111
  "fix": "create a {0} route function at {2}.js",
112
112
  "level": "Info"
113
113
  },
114
+ "OptionalRoute": {
115
+ "message": "optional route {0} must be a leaf",
116
+ "fix": "move route to leaf (last) position in filesystem hierarchy",
117
+ "level": "Error"
118
+ },
114
119
  "ReservedTypeName": {
115
120
  "message": "reserved type name {0}",
116
121
  "fix": "do not use any reserved type names",
117
122
  "level": "Error"
123
+ },
124
+ "RestRoute": {
125
+ "message": "rest route {0} must be a leaf",
126
+ "fix": "move route to leaf (last) position in filesystem hierarchy",
127
+ "level": "Error"
118
128
  }
119
129
  }
120
130
  }
package/src/handlers.js CHANGED
@@ -1,6 +1,7 @@
1
- import { File } from "rcompat/fs";
1
+ import FS from "rcompat/fs";
2
2
  import { MediaType, Status } from "rcompat/http";
3
3
  import { identity } from "rcompat/function";
4
+ import { HTML } from "rcompat/string";
4
5
  import errors from "./errors.js";
5
6
 
6
7
  const handle = (mediatype, mapper = identity) => (body, options) => app =>
@@ -49,27 +50,41 @@ const error = (body = "Not Found", { status = Status.NOT_FOUND, page } = {}) =>
49
50
  const script_re = /(?<=<script)>(?<code>.*?)(?=<\/script>)/gus;
50
51
  const style_re = /(?<=<style)>(?<code>.*?)(?=<\/style>)/gus;
51
52
  const remove = /<(?<tag>script|style)>.*?<\/\k<tag>>/gus;
52
- const html = (name, options) => async app => {
53
- const component = await app.path.components.join(name).text();
53
+ const render = (component, props = {}) => {
54
+ const encoded = JSON.parse(HTML.escape(JSON.stringify(props)));
55
+ const keys = Object.keys(encoded);
56
+ const values = Object.values(encoded);
57
+ return new Function(...keys, `return \`${component}\`;`)(...values);
58
+ };
59
+ const html = (name, props, options = {}) => async app => {
60
+ const location = app.get("location");
61
+ const components = app.runpath(location.server, location.components);
62
+ const component = await components.join(name).text();
63
+ const { head: xhead = [], csp = {}, headers, ...rest } = options;
64
+ const { script_src: xscript_src = [], style_src: xstyle_src = [] } = csp;
54
65
  const scripts = await Promise.all([...component.matchAll(script_re)]
55
66
  .map(({ groups: { code } }) => app.inline(code, "module")));
56
67
  const styles = await Promise.all([...component.matchAll(style_re)]
57
68
  .map(({ groups: { code } }) => app.inline(code, "style")));
58
- const style_src = styles.map(asset => asset.integrity);
59
- const script_src = scripts.map(asset => asset.integrity);
69
+ const style_src = styles.map(asset => asset.integrity).concat(xstyle_src);
70
+ const script_src = scripts.map(asset => asset.integrity).concat(xscript_src);
71
+ const head = [...scripts, ...styles].map(asset => asset.head);
60
72
 
61
73
  return app.view({
62
- body: component.replaceAll(remove, _ => ""),
63
- head: [...scripts, ...styles].map(asset => asset.head).join("\n"),
64
- headers: app.headers({ "style-src": style_src, "script-src": script_src }),
65
- ...options,
74
+ body: render(component.replaceAll(remove, _ => ""), props),
75
+ head: [...head, ...xhead].join("\n"),
76
+ headers: {
77
+ ...app.headers({ "style-src": style_src, "script-src": script_src }),
78
+ ...headers,
79
+ },
80
+ ...rest,
66
81
  });
67
82
  };
68
83
  // }}}
69
84
  // {{{ view
70
85
  const extensions = ["fullExtension", "extension"];
71
86
  const view = (name, props, options) => (app, ...rest) => extensions
72
- .map(extension => app.extensions[new File(name)[extension]])
87
+ .map(extension => app.extensions[new FS.File(name)[extension]])
73
88
  .find(extension => extension?.handle)
74
89
  ?.handle(name, props, options)(app, ...rest)
75
90
  ?? errors.NoHandlerForComponent.throw(name);
@@ -1,4 +1,4 @@
1
- import { File } from "rcompat/fs";
1
+ import FS from "rcompat/fs";
2
2
 
3
3
  export default async (app, type, post = () => undefined) => {
4
4
  const includes = app.get("build.includes");
@@ -11,7 +11,7 @@ export default async (app, type, post = () => undefined) => {
11
11
  .map(async include => {
12
12
  const path = app.root.join(include);
13
13
  if (await path.exists()) {
14
- const target = File.join(type, include);
14
+ const target = FS.File.join(type, include);
15
15
  await app.stage(path, target);
16
16
  await post(target);
17
17
  }
@@ -2,7 +2,6 @@ export { default as init } from "./init.js";
2
2
  export { default as stage } from "./stage.js";
3
3
  export { default as register } from "./register.js";
4
4
  export { default as publish } from "./publish.js";
5
- export { default as bundle } from "./bundle.js";
6
5
  export { default as route } from "./route.js";
7
6
  export { default as handle } from "./handle.js";
8
7
  export { default as parse } from "./parse.js";
@@ -1,4 +1,4 @@
1
- import { Response, Status, MediaType } from "rcompat/http";
1
+ import { Response, Status, MediaType, fetch } from "rcompat/http";
2
2
  import { cascade, tryreturn } from "rcompat/async";
3
3
  import respond from "./respond.js";
4
4
  import { error as clientError } from "../handlers.js";
@@ -47,14 +47,15 @@ export default app => {
47
47
  let error_handler = app.error.default;
48
48
 
49
49
  return tryreturn(async _ => {
50
- const { path, guards, errors, layouts, handler } = await route(request);
50
+ const { body, path, guards, errors, layouts, handler } =
51
+ await route(request);
51
52
 
52
53
  error_handler = errors?.at(-1);
53
54
 
54
55
  const hooks = [...app.modules.route, guard(app, guards), last(handler)];
55
56
 
56
57
  // handle request
57
- const routed = await (await cascade(hooks))({ ...request, path });
58
+ const routed = await (await cascade(hooks))({ ...request, body, path });
58
59
 
59
60
  const $layouts = { layouts: await get_layouts(layouts, routed.request) };
60
61
  return respond(routed.response)(app, $layouts, routed.request);
@@ -94,6 +95,19 @@ export default app => {
94
95
  }
95
96
  return as_route(request);
96
97
  };
98
+ // first hook
99
+ const pass = (request, next) => next({
100
+ ...request,
101
+ pass(to) {
102
+ const { method, headers, body } = request.original;
103
+ const input = `${to}${request.url.pathname}`;
97
104
 
98
- return cascade(app.modules.handle, handle);
105
+ return fetch(input, { headers, method, body, duplex: "half" });
106
+ },
107
+ });
108
+ const hotreload = (request, next) => app.mode === "development"
109
+ ? app.build.proxy(request, next)
110
+ : next(request);
111
+
112
+ return cascade([pass, hotreload, ...app.modules.handle], handle);
99
113
  };
@@ -1,11 +1,5 @@
1
- import { URL, Body } from "rcompat/http";
1
+ import { URL } from "rcompat/http";
2
2
  import o from "rcompat/object";
3
- import { tryreturn } from "rcompat/async";
4
- import errors from "../errors.js";
5
-
6
- const get_body = (request, url) =>
7
- tryreturn(async _ => await Body.parse(request) ?? {})
8
- .orelse(error => errors.MismatchedBody.throw(url.pathname, error.message));
9
3
 
10
4
  export default app => async original => {
11
5
  const { headers } = original;
@@ -14,7 +8,6 @@ export default app => async original => {
14
8
  const cookies = headers.get("cookie");
15
9
 
16
10
  return { original, url,
17
- body: app.get("request.body.parse") ? await get_body(original, url) : {},
18
11
  ...o.valmap({
19
12
  query: [o.from(url.searchParams), url.search],
20
13
  headers: [o.from(headers), headers, false],
@@ -1,24 +1,3 @@
1
- import { File } from "rcompat/fs";
2
1
  import { cascade } from "rcompat/async";
3
- import o from "rcompat/object";
4
2
 
5
- const post = async app => {
6
- // after hook, publish a zero assumptions app.js (no css imports)
7
- const src = File.join(app.get("http.static.root"), app.get("build.index"));
8
-
9
- await app.publish({
10
- code: app.exports.filter(({ type }) => type === "script")
11
- .map(({ code }) => code).join(""),
12
- src,
13
- type: "module",
14
- });
15
-
16
- const imports = { ...app.importmaps, app: src.webpath() };
17
- const type = "importmap";
18
- await app.publish({ inline: true, code: o.stringify({ imports }), type });
19
-
20
- return app;
21
- };
22
-
23
- export default async app =>
24
- post(await (await cascade(app.modules.publish))(app));
3
+ export default async app => (await cascade(app.modules.publish))(app);
@@ -1,26 +1,18 @@
1
- import { File } from "rcompat/fs";
1
+ import FS from "rcompat/fs";
2
2
  import { cascade } from "rcompat/async";
3
3
  import copy_includes from "./copy_includes.js";
4
4
 
5
5
  const html = /^.*.html$/u;
6
- const defaults = new File(import.meta.url).up(2).join("defaults");
6
+ const defaults = new FS.File(import.meta.url).up(2).join("defaults");
7
7
 
8
8
  const pre = async app => {
9
- const location = app.get("location");
10
- const { pages, client, components } = location;
9
+ const pages = app.get("location.pages");
11
10
 
12
11
  // copy framework pages
13
12
  await app.stage(defaults, pages, html);
14
13
  // overwrite transformed pages to build
15
14
  await app.path.pages.exists() && await app.stage(app.path.pages, pages, html);
16
15
 
17
- if (await app.path.components.exists()) {
18
- // copy .js files from components to build/client/components, since
19
- // frontend frameworks handle non-js files
20
- const target = File.join(client, components);
21
- await app.stage(app.path.components, target, /^.*.js$/u);
22
- }
23
-
24
16
  return app;
25
17
  };
26
18
 
@@ -30,37 +22,19 @@ const post = async app => {
30
22
 
31
23
  if (await _static.exists()) {
32
24
  // copy static files to build/server/static
33
- await app.stage(_static, File.join(location.server, location.static));
34
-
35
- // copy static files to build/client/static
36
- await app.stage(_static, File.join(location.client, location.static));
25
+ await app.stage(_static, FS.File.join(location.server, location.static));
37
26
 
38
27
  // publish JavaScript and CSS files
39
- const imports = await File.collect(_static, /\.(?:js|css)$/u);
28
+ const imports = await FS.File.collect(_static, /\.(?:css)$/u);
40
29
  await Promise.all(imports.map(async file => {
41
- const code = await file.text();
42
30
  const src = file.debase(_static);
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}";` });
31
+ app.build.export(`import "./${location.static}${src}";`);
48
32
  }));
49
33
  }
50
34
 
51
35
  // copy additional subdirectories to build/server
52
36
  await copy_includes(app, location.server);
53
37
 
54
- // copy additional subdirectories to build/client
55
- const client = app.runpath(location.client);
56
- const root = app.get("http.static.root");
57
- await copy_includes(app, location.client, async to =>
58
- Promise.all((await to.collect(/\.js$/u)).map(async script => {
59
- const src = File.join(root, script.path.replace(client, _ => ""));
60
- await app.publish({ src, code: await script.text(), type: "module" });
61
- })),
62
- );
63
-
64
38
  const components = await app.path.components.collect();
65
39
 
66
40
  // from the build directory, compile to server and client
@@ -1,17 +1,17 @@
1
- import { Blob, s_streamable } from "rcompat/fs";
1
+ import FS from "rcompat/fs";
2
2
  import { URL, Response } from "rcompat/http";
3
3
  import { identity } from "rcompat/function";
4
+ import o from "rcompat/object";
4
5
  import { text, json, stream, redirect } from "primate";
5
6
  import errors from "../errors.js";
6
7
 
7
8
  const not_found = value => errors.InvalidBodyReturned.throw(value);
8
9
  const is_text = value => typeof value === "string";
9
- const is_non_null_object = value => typeof value === "object" && value !== null;
10
10
  const is_instance = of => value => value instanceof of;
11
11
  const is_response = is_instance(globalThis.Response);
12
12
  const is_fake_response = is_instance(Response);
13
13
  const is_streamable =
14
- value => value instanceof Blob || value?.streamable === s_streamable;
14
+ value => value instanceof FS.Blob || value?.streamable === FS.s_streamable;
15
15
 
16
16
  // [if, then]
17
17
  const guesses = [
@@ -19,7 +19,7 @@ const guesses = [
19
19
  [is_streamable, value => stream(value.stream())],
20
20
  [is_instance(ReadableStream), stream],
21
21
  [value => is_response(value) || is_fake_response(value), value => _ => value],
22
- [is_non_null_object, json],
22
+ [o.proper, json],
23
23
  [is_text, text],
24
24
  [not_found, identity],
25
25
  ];
@@ -1,48 +1,49 @@
1
1
  import o from "rcompat/object";
2
2
  import { tryreturn } from "rcompat/sync";
3
- import errors from "../errors.js";
3
+ import { Body } from "rcompat/http";
4
+ import $errors from "../errors.js";
4
5
  import validate from "../validate.js";
5
6
 
6
- // insensitive-case equal
7
- const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
7
+ const { MismatchedBody, MismatchedPath, NoRouteToPath } = $errors;
8
8
 
9
9
  const deroot = pathname => pathname.endsWith("/") && pathname !== "/"
10
10
  ? pathname.slice(0, -1) : pathname;
11
11
 
12
- export default app => {
13
- const { types, routes } = app;
14
- const location = app.get("location");
15
-
16
- const to_path = (route, pathname) => app.dispatch(o.from(Object
17
- .entries(route.pathname.exec(pathname)?.groups ?? {})
18
- .map(([name, value]) => [name.split("$"), value])
19
- .map(([[name, type], value]) =>
20
- [name, type === undefined ? value : validate(types[type], value, name)],
21
- )));
12
+ const parse_body = (request, url) =>
13
+ tryreturn(async _ => await Body.parse(request) ?? {})
14
+ .orelse(error => MismatchedBody.throw(url.pathname, error.message));
22
15
 
23
- const is_type = (groups, pathname) => Object.entries(groups ?? {})
24
- .filter(([name]) => name.includes("$"))
25
- .map(([name, value]) => [name.split("$"), value])
26
- .map(([[name, type], value]) =>
27
- tryreturn(_ => [name, validate(types[type], value, name)])
28
- .orelse(({ message }) => errors.MismatchedPath.throw(pathname, message)));
29
- const is_path = ({ route, pathname }) => {
30
- const result = route.pathname.exec(pathname);
31
- return result === null ? false : is_type(result.groups, pathname);
32
- };
33
- const is_method = ({ route, method, pathname }) => ieq(route.method, method)
34
- && is_path({ route, pathname });
35
- const find = (method, pathname) => routes.find(route =>
36
- is_method({ route, method, pathname }));
16
+ export default app => {
17
+ const $request_body_parse = app.get("request.body.parse");
18
+ const $location = app.get("location");
37
19
 
38
- const index = path => `${location.routes}${path === "/" ? "/index" : path}`;
20
+ const index = path => `${$location.routes}${path === "/" ? "/index" : path}`;
39
21
  // remove excess slashes
40
22
  const deslash = url => url.replaceAll(/\/{2,}/gu, _ => "/");
41
23
 
42
- return ({ original: { method }, url }) => {
24
+ return async ({ original, url }) => {
43
25
  const pathname = deroot(deslash(url.pathname));
44
- const route = find(method, pathname) ?? errors.NoRouteToPath
45
- .throw(method.toLowerCase(), pathname, index(pathname));
46
- return { ...route, path: to_path(route, pathname) };
26
+ const route = await app.router.match(original) ?? NoRouteToPath
27
+ .throw(original.method.toLowerCase(), pathname, index(pathname));
28
+ const { params } = route;
29
+ const untyped_path = Object.fromEntries(Object.entries(params)
30
+ .filter(([name]) => !name.includes("="))
31
+ .map(([key, value]) => [key, value]));
32
+ const typed_path = Object.fromEntries(Object.entries(params)
33
+ .filter(([name]) => name.includes("="))
34
+ .map(([name, value]) => [name.split("="), value])
35
+ .map(([[name, type], value]) =>
36
+ tryreturn(_ => {
37
+ validate(app.types[type], value, name);
38
+ return [name, value];
39
+ }).orelse(({ message }) => MismatchedPath.throw(pathname, message))));
40
+ const path = app.dispatch({ ...untyped_path, ...typed_path });
41
+ const local_parse_body = route.file.body?.parse ?? $request_body_parse;
42
+ const body = local_parse_body ? await parse_body(original, url) : null;
43
+ const { guards = [], errors = [], layouts = [] } = o.map(route.specials,
44
+ ([key, value]) => [`${key}s`, value.default]);
45
+ const handler = route.file.default[original.method.toLowerCase()];
46
+
47
+ return { body, path, guards, errors, layouts, handler };
47
48
  };
48
49
  };
@@ -1,3 +1,4 @@
1
+ import FS from "rcompat/fs";
1
2
  import { cascade } from "rcompat/async";
2
3
  import dispatch from "../dispatch.js";
3
4
  import * as loaders from "../loaders/exports.js";
@@ -15,34 +16,46 @@ const pre = async app => {
15
16
  };
16
17
 
17
18
  const post = async app => {
18
- const location = app.get("location");
19
+ const $location = app.get("location");
19
20
 
20
21
  // stage routes
21
- const double = doubled((await app.path.routes.collect())
22
- .map(path => path.debase(app.path.routes))
23
- .map(path => `${path}`.slice(1, -path.extension.length)));
24
- double && errors.DoubleRoute.throw(double);
25
-
26
22
  if (await app.path.routes.exists()) {
27
- await app.stage(app.path.routes, location.routes);
23
+ await app.stage(app.path.routes, $location.routes);
28
24
  }
29
25
  if (await app.path.types.exists()) {
30
- await app.stage(app.path.types, location.types);
26
+ await app.stage(app.path.types, $location.types);
31
27
  }
32
- const user_types = await loaders.types(app.log, app.runpath(location.types));
28
+ const user_types = await loaders.types(app.log, app.runpath($location.types));
33
29
  const types = { ...app.types, ...user_types };
34
30
 
35
- const staged = app.runpath(location.routes);
36
- for (const path of await staged.collect()) {
31
+ const directory = app.runpath($location.routes);
32
+ for (const path of await directory.collect()) {
37
33
  await app.extensions[path.extension]
38
- ?.route(staged, path.debase(`${staged}/`), types);
34
+ ?.route(directory, path.debase(`${directory}/`), types);
39
35
  }
40
- const routes = await loaders.routes(app);
41
- const layout = {
42
- depth: Math.max(...routes.map(({ layouts }) => layouts.length)) + 1,
43
- };
44
36
 
45
- return { ...app, types, routes, dispatch: dispatch(types), layout };
37
+ let router;
38
+
39
+ try {
40
+ router = await FS.Router.load({
41
+ directory,
42
+ specials: {
43
+ guard: { recursive: true },
44
+ error: { recursive: false },
45
+ layout: { recursive: true },
46
+ },
47
+ predicate(route, request) {
48
+ return route.default[request.method.toLowerCase()] !== undefined;
49
+ },
50
+ });
51
+ } catch (error) {
52
+ const { DoubleRoute, OptionalRoute, RestRoute } = FS.Router.Error;
53
+ error instanceof DoubleRoute && errors.DoubleRoute.throw(error.route);
54
+ error instanceof OptionalRoute && errors.OptionalRoute.throw(error.route);
55
+ error instanceof RestRoute && errors.RestRoute.throw(error.route);
56
+ }
57
+ const layout = { depth: router.depth("layout") };
58
+ return { ...app, types, dispatch: dispatch(types), layout, router };
46
59
  };
47
60
 
48
61
  export default async app =>
@@ -1,4 +1,4 @@
1
- import { File } from "rcompat/fs";
1
+ import FS from "rcompat/fs";
2
2
  import { identity } from "rcompat/function";
3
3
  import errors from "../errors.js";
4
4
 
@@ -11,13 +11,13 @@ const empty = log => (objects, name, path) =>
11
11
  export default async ({
12
12
  log,
13
13
  directory,
14
- filter = identity,
15
14
  name = "routes",
15
+ filter = identity,
16
16
  recursive = true,
17
17
  warn = true,
18
18
  } = {}) => {
19
19
  const objects = directory === undefined ? [] : await Promise.all(
20
- (await File.collect(directory, /^.*.js$/u, { recursive }))
20
+ (await FS.File.collect(directory, /^.*.js$/u, { recursive }))
21
21
  .filter(filter)
22
22
  .map(async file => [
23
23
  `${file}`.replace(directory, _ => "").slice(1, -ending.length),
@@ -1,3 +1,2 @@
1
1
  export { default as modules } from "./modules.js";
2
- export { default as routes } from "./routes.js";
3
2
  export { default as types } from "./types.js";
@@ -1,4 +1,4 @@
1
- import { File } from "rcompat/fs";
1
+ import FS from "rcompat/fs";
2
2
  import { is } from "rcompat/invariant";
3
3
  import { tryreturn } from "rcompat/sync";
4
4
  import errors from "../errors.js";
@@ -10,7 +10,7 @@ export default async (log, directory, load = fs) => {
10
10
  const types = (await load({ log, directory, name: "types", filter }))
11
11
  .map(([name, type]) => [name, type.default]);
12
12
 
13
- const resolve = name => File.join(directory, name);
13
+ const resolve = name => FS.File.join(directory, name);
14
14
  types.every(([name, type]) => tryreturn(_ => {
15
15
  is(type).object();
16
16
  is(type.base).string();
package/src/run.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { tryreturn } from "rcompat/async";
2
- import { File } from "rcompat/fs";
2
+ import FS from "rcompat/fs";
3
3
  import o from "rcompat/object";
4
4
  import { runtime } from "rcompat/meta";
5
5
  import app from "./app.js";
@@ -28,7 +28,8 @@ const get_config = async root => {
28
28
 
29
29
  export default async name => tryreturn(async _ => {
30
30
  // use module root if possible, fall back to current directory
31
- const root = await tryreturn(_ => File.root()).orelse(_ => File.resolve());
31
+ const root = await tryreturn(_ => FS.File.root())
32
+ .orelse(_ => FS.File.resolve());
32
33
  const config = await get_config(root);
33
34
  logger = new Logger(config.logger);
34
35
  await command(name)(await app(logger, root, config));
package/src/start.js CHANGED
@@ -2,15 +2,44 @@ import { serve, Response, Status } from "rcompat/http";
2
2
  import { tryreturn } from "rcompat/async";
3
3
  import { bold, blue, dim } from "rcompat/colors";
4
4
  import { resolve } from "rcompat/package";
5
+ import o from "rcompat/object";
6
+ import Build from "rcompat/build";
7
+ import FS from "rcompat/fs";
5
8
  import * as hooks from "./hooks/exports.js";
6
9
  import { print } from "./Logger.js";
7
10
 
8
- const base_hooks = ["init", "stage", "register", "publish", "bundle"];
11
+ const base_hooks = ["init", "stage", "register", "publish"];
9
12
 
10
- export default async (app$, mode = "development") => {
11
- app$.mode = mode;
12
- // run one-time hooks
13
- let app = app$;
13
+ const publish = async app => {
14
+ const location = app.get("location");
15
+ const http = app.get("http");
16
+ const client = app.runpath(app.get("location.client"));
17
+
18
+ const re = new RegExp(`${location.client}/app..*(?:js|css)$`, "u");
19
+ for (const path of await client.collect(re, { recursive: false })) {
20
+ const src = path.name;
21
+ const type = path.extension === ".css" ? "style" : "module";
22
+ await app.publish({ src, type });
23
+ if (path.extension === ".js") {
24
+ const imports = { app: FS.File.join(http.static.root, src).path };
25
+ await app.publish({
26
+ inline: true,
27
+ code: JSON.stringify({ imports }, null, 2),
28
+ type: "importmap",
29
+ });
30
+ }
31
+ }
32
+ };
33
+
34
+ export default async ($app, mode = "development") => {
35
+ let app = $app;
36
+
37
+ app.mode = mode;
38
+ app.build = new Build({
39
+ ...o.exclude(app.get("build"), ["includes", "index", "transform"]),
40
+ outdir: app.runpath(app.get("location.client")).path,
41
+ resolveDir: app.root.path,
42
+ }, mode);
14
43
 
15
44
  const primate = await resolve(import.meta.url);
16
45
  print(blue(bold(primate.name)), blue(primate.version), "in startup\n");
@@ -19,11 +48,14 @@ export default async (app$, mode = "development") => {
19
48
  app.log.info(`running ${dim(hook)} hooks`, { module: "primate" });
20
49
  app = await hooks[hook](app);
21
50
  }
22
-
51
+ // start the build
52
+ await app.build.start();
53
+ await publish(app);
23
54
  app.route = hooks.route(app);
24
55
  app.parse = hooks.parse(app);
56
+ const handle = await hooks.handle(app);
25
57
  app.server = await serve(async request =>
26
- tryreturn(async _ => (await hooks.handle(app))(await app.parse(request)))
58
+ tryreturn(async _ => handle(await app.parse(request)))
27
59
  .orelse(error => {
28
60
  app.log.auto(error);
29
61
  return new Response(null, { status: Status.INTERNAL_SERVER_ERROR });
@@ -1,3 +0,0 @@
1
- import { cascade } from "rcompat/async";
2
-
3
- export default async app => (await cascade(app.modules.bundle))(app);
@@ -1,6 +0,0 @@
1
- import load from "./load.js";
2
-
3
- export { default as routes } from "./routes.js";
4
- export const guards = await load("guard");
5
- export const errors = await load("error");
6
- export const layouts = await load("layout");
@@ -1,18 +0,0 @@
1
- import { File } from "rcompat/fs";
2
- import errors from "../../errors.js";
3
- import to_sorted from "../../to_sorted.js";
4
-
5
- export default type => async (log, directory, load) => {
6
- const filter = path => new RegExp(`^\\+${type}.js$`, "u").test(path.name);
7
-
8
- const replace = new RegExp(`\\+${type}`, "u");
9
- const objects = to_sorted((await load({ log, directory, filter, warn: false }))
10
- .map(([name, object]) => [name.replace(replace, () => ""), object]),
11
- ([a], [b]) => a.length - b.length);
12
-
13
- const resolve = name => File.join(directory, name, `+${type}.js`);
14
- objects.some(([name, value]) => typeof value.default !== "function"
15
- && errors.InvalidDefaultExport.throw(resolve(name)));
16
-
17
- return objects;
18
- };
@@ -1,25 +0,0 @@
1
- import { doubled } from "../common.js";
2
- import errors from "../../errors.js";
3
-
4
- const normalize = route => {
5
- let i = 0;
6
- // user/ -> user
7
- // user/{id=number}/{id2=number} -> user/{0}/{1}
8
- return (route.endsWith("/") ? route.slice(0, -1) : route)
9
- .replaceAll(/\{(?:\w*)(?:=\w+)?\}?/gu, _ => `{${i++}}`);
10
- };
11
-
12
- // index -> ""
13
- const deindex = path => path.endsWith("index") ?
14
- path.replace("index", "") : path;
15
-
16
- export default async (log, directory, load) => {
17
- const filter = path => /^[^+].*.js$/u.test(path.name);
18
- const routes = (await load({ log, directory, filter }))
19
- .map(([path, handler]) => [deindex(path), handler]);
20
-
21
- const double = doubled(routes.map(([route]) => normalize(route)));
22
- double && errors.DoubleRoute.throw(double);
23
-
24
- return routes;
25
- };
@@ -1,55 +0,0 @@
1
- import { tryreturn } from "rcompat/sync";
2
- import o from "rcompat/object";
3
- import { File } from "rcompat/fs";
4
- import { default as fs, doubled } from "./common.js";
5
- import * as get from "./routes/exports.js";
6
- import errors from "../errors.js";
7
-
8
- const { separator } = File;
9
-
10
- const valid_route = /^[\w\-[\]=/.]*$/u;
11
-
12
- const make = path => {
13
- !valid_route.test(new File(path).webpath()) && errors.InvalidPath.throw(path);
14
-
15
- const double = doubled(path.split(separator)
16
- .filter(part => part.startsWith("[") && part.endsWith("]"))
17
- .map(part => part.slice(1, part.indexOf("="))));
18
- double && errors.DoublePathParameter.throw(double, path);
19
-
20
- const route = path.replaceAll(/\[(?<named>.*?)\]/gu, (_, named) =>
21
- tryreturn(_ => {
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
- }).orelse(_ => errors.EmptyPathParameter.throw(named, path)));
26
-
27
- // normalize to unix
28
- return new RegExp(`^/${route.replaceAll(separator, "/")}$`, "u");
29
- };
30
-
31
- export default async (app, load = fs) => {
32
- const { log } = app;
33
- const directory = app.runpath(app.get("location.routes"));
34
- const filter = path => ([name]) => path.includes(name);
35
- const routes = o.from(await Promise.all(["guards", "errors", "layouts"]
36
- .map(async extra => [extra, await get[extra](log, directory, load)])));
37
-
38
- return (await get.routes(log, directory, load)).map(([path, imported]) => {
39
- const route = imported.default;
40
- if (route === undefined || Object.keys(route).length === 0) {
41
- errors.EmptyRouteFile.warn(log, directory.join(`${path}.js`).path);
42
- return [];
43
- }
44
- const filtered = filter(path);
45
-
46
- return Object.entries(route).map(([method, handler]) => ({
47
- method,
48
- handler,
49
- pathname: make(path.endsWith(separator) ? path.slice(0, -1) : path),
50
- guards: routes.guards.filter(filtered).map(([, guard]) => guard.default),
51
- errors: routes.errors.filter(filtered).map(([, error]) => error.default),
52
- layouts: routes.layouts.filter(filtered).map(([, layout]) => layout),
53
- }));
54
- }).flat();
55
- };