primate 0.11.0 → 0.13.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.
Files changed (60) hide show
  1. package/LICENSE +0 -2
  2. package/README.md +52 -99
  3. package/eslint.config.js +1 -0
  4. package/exports.js +1 -3
  5. package/module.json +10 -5
  6. package/package.json +14 -17
  7. package/readme/extensions/handlers/html/user.js +13 -0
  8. package/readme/extensions/handlers/htmx/user-index.html +4 -0
  9. package/readme/extensions/handlers/htmx/user.js +23 -0
  10. package/readme/extensions/handlers/redirect/user.js +6 -0
  11. package/readme/{domains → extensions/modules/domains}/fields.js +3 -4
  12. package/readme/{domains → extensions/modules/domains}/predicates.js +3 -3
  13. package/readme/{domains → extensions/modules/domains}/short-field-notation.js +3 -3
  14. package/readme/routing/basic.js +1 -1
  15. package/readme/routing/explicit-handlers.js +4 -0
  16. package/readme/routing/sharing-logic-across-requests.js +2 -2
  17. package/readme/serving-content/html.js +10 -11
  18. package/readme/serving-content/json.js +2 -2
  19. package/readme/serving-content/plain-text.js +1 -1
  20. package/readme/serving-content/response.js +6 -0
  21. package/readme/serving-content/streams.js +1 -1
  22. package/readme/template.md +135 -0
  23. package/scripts/docs.sh +7 -0
  24. package/src/bin.js +2 -0
  25. package/src/bundle.js +14 -7
  26. package/src/compile.js +5 -0
  27. package/src/config.js +73 -0
  28. package/src/duck.js +4 -0
  29. package/src/extend.spec.js +19 -27
  30. package/src/handlers/exports.js +5 -0
  31. package/src/handlers/html.js +18 -0
  32. package/src/handlers/http404.js +6 -4
  33. package/src/handlers/json.js +6 -4
  34. package/src/handlers/redirect.js +7 -0
  35. package/src/handlers/stream.js +6 -4
  36. package/src/handlers/text.js +6 -11
  37. package/src/http-statuses.js +5 -0
  38. package/src/index.html +8 -0
  39. package/src/log.js +7 -4
  40. package/src/mimes.js +12 -0
  41. package/src/register.js +5 -0
  42. package/src/respond.js +24 -0
  43. package/src/route.js +15 -28
  44. package/src/run.js +10 -9
  45. package/src/serve.js +25 -12
  46. package/README.template.md +0 -190
  47. package/bin/primate.js +0 -5
  48. package/readme/getting-started/generate-ssl.sh +0 -1
  49. package/readme/getting-started/lay-out-app.sh +0 -1
  50. package/readme/getting-started/site-index.html +0 -1
  51. package/readme/getting-started/site.js +0 -3
  52. package/src/conf.js +0 -30
  53. package/src/http-statuses.json +0 -5
  54. package/src/mimes.json +0 -12
  55. package/src/preset/stores/default.js +0 -2
  56. /package/readme/{serving-content → extensions/handlers/html}/user-index.html +0 -0
  57. /package/readme/{modules → extensions/modules}/configure.js +0 -0
  58. /package/readme/{modules → extensions/modules}/domains/configure.js +0 -0
  59. /package/readme/{getting-started/hello.js → getting-started.js} +0 -0
  60. /package/src/{preset/primate.conf.js → primate.config.js} +0 -0
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ npx -y embedme\
3
+ --source-root readme\
4
+ --strip-embed-comment\
5
+ --stdout readme/template.md\
6
+ > README.md
7
+
package/src/bin.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ (await import("./run.js")).default();
package/src/bundle.js CHANGED
@@ -1,14 +1,21 @@
1
- import {File} from "runtime-compat/filesystem";
1
+ import {File} from "runtime-compat/fs";
2
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
3
 
3
- export default async env => {
4
+ const makePublic = async env => {
4
5
  const {paths} = env;
6
+
7
+ // remove public directory in case exists
8
+ if (await paths.public.exists) {
9
+ await paths.public.file.remove();
10
+ }
11
+ await paths.public.file.create();
12
+
5
13
  if (await paths.static.exists) {
6
- // remove public directory in case exists
7
- if (await paths.public.exists) {
8
- await paths.public.file.remove();
9
- }
10
- await paths.public.file.create();
11
14
  // copy static files to public
12
15
  await File.copy(paths.static, paths.public);
13
16
  }
14
17
  };
18
+
19
+ export default async env => await makePublic(env) &&
20
+ [...filter("bundle", env.modules), _ => _].reduceRight((acc, handler) =>
21
+ input => handler(input, acc))(env);
package/src/compile.js ADDED
@@ -0,0 +1,5 @@
1
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
+
3
+ export default async env =>
4
+ [...filter("compile", env.modules), _ => _].reduceRight((acc, handler) =>
5
+ input => handler(input, acc))(env);
package/src/config.js ADDED
@@ -0,0 +1,73 @@
1
+ import {File, Path} from "runtime-compat/fs";
2
+ import {is} from "runtime-compat/dyndef";
3
+ import cache from "./cache.js";
4
+ import extend from "./extend.js";
5
+ import defaults from "./primate.config.js";
6
+ import * as log from "./log.js";
7
+ import * as handlers from "./handlers/exports.js";
8
+ import package_json from "../package.json" assert {type: "json"};
9
+
10
+ const qualify = (root, paths) =>
11
+ Object.keys(paths).reduce((sofar, key) => {
12
+ const value = paths[key];
13
+ sofar[key] = typeof value === "string"
14
+ ? new Path(root, value)
15
+ : qualify(`${root}/${key}`, value);
16
+ return sofar;
17
+ }, {});
18
+
19
+ const getConfig = async (root, filename) => {
20
+ try {
21
+ return extend(defaults, (await import(root.join(filename))).default);
22
+ } catch (error) {
23
+ return defaults;
24
+ }
25
+ };
26
+
27
+ const getRoot = async () => {
28
+ try {
29
+ // use module root if possible
30
+ return await Path.root();
31
+ } catch (error) {
32
+ // fall back to current directory
33
+ return Path.resolve();
34
+ }
35
+ };
36
+
37
+ const index = async env => {
38
+ const name = "index.html";
39
+ try {
40
+ // user-provided file
41
+ return await File.read(`${env.paths.static.join(name)}`);
42
+ } catch (error) {
43
+ // fallback
44
+ return new Path(import.meta.url).directory.join(name).file.read();
45
+ }
46
+ };
47
+
48
+ export default async (filename = "primate.config.js") => {
49
+ is(filename).string();
50
+ const root = await getRoot();
51
+ const config = await getConfig(root, filename);
52
+
53
+ const env = {
54
+ ...config,
55
+ paths: qualify(root, config.paths),
56
+ root,
57
+ log: {...log, error: error => log.error(error, config)},
58
+ register: (name, handler) => {
59
+ env.handlers[name] = handler;
60
+ },
61
+ handlers: {...handlers},
62
+ render: async html => (await index(env)).replace("%body%", () => html),
63
+ };
64
+ env.log.info(`${package_json.name} \x1b[34m${package_json.version}\x1b[0m`);
65
+ const modules = await Promise.all(config.modules.map(module => module(env)));
66
+ // modules may load other modules
67
+ const loads = await Promise.all(modules
68
+ .filter(module => module.load !== undefined)
69
+ .map(module => module.load()(env)));
70
+
71
+ return cache("config", filename, () => ({...env,
72
+ modules: modules.concat(loads)}));
73
+ };
package/src/duck.js ADDED
@@ -0,0 +1,4 @@
1
+ import {Headers} from "runtime-compat/http";
2
+
3
+ export const isResponse = value =>
4
+ value.body !== undefined && value.headers instanceof Headers;
@@ -40,20 +40,20 @@ export default test => {
40
40
  });
41
41
 
42
42
  test.case("one property of a subobject", assert => {
43
- const base = {key: {"subkey": "subvalue"}};
44
- const extension = {key: {"subkey": "subvalue 2"}};
43
+ const base = {key: {subkey: "subvalue"}};
44
+ const extension = {key: {subkey: "subvalue 2"}};
45
45
  assert(extend(base, extension)).equals(extension);
46
46
  });
47
47
 
48
48
  test.case("two properties of a subobject, one replaced", assert => {
49
- const base = {key: {subkey: "subvalue", subkey2: "subvalue2"}};
50
- const extension = {key: {subkey: "subvalue 2"}};
51
- const extended = {key: {subkey: "subvalue 2", subkey2: "subvalue2"}};
52
- assert(extend(base, extension)).equals(extended);
53
- });
49
+ const base = {key: {subkey: "subvalue", subkey2: "subvalue2"}};
50
+ const extension = {key: {subkey: "subvalue 2"}};
51
+ const extended = {key: {subkey: "subvalue 2", subkey2: "subvalue2"}};
52
+ assert(extend(base, extension)).equals(extended);
53
+ });
54
54
 
55
- test.case("configuration enhancement", assert => {
56
- const default_conf = {
55
+ test.case("config enhancement", assert => {
56
+ const base = {
57
57
  base: "/",
58
58
  debug: false,
59
59
  defaults: {
@@ -61,16 +61,14 @@ export default test => {
61
61
  context: "guest",
62
62
  },
63
63
  paths: {
64
- client: "client",
65
- data: {
66
- domains: "domains",
67
- stores: "stores",
68
- },
69
64
  public: "public",
65
+ static: "static",
66
+ routes: "routes",
67
+ components: "components",
70
68
  },
71
69
  };
72
70
 
73
- const additional_conf = {
71
+ const additional = {
74
72
  debug: true,
75
73
  environment: "testing",
76
74
  defaults: {
@@ -78,11 +76,7 @@ export default test => {
78
76
  mode: "operational",
79
77
  },
80
78
  paths: {
81
- client: "client_logic",
82
- data: {
83
- stores: "storage",
84
- drivers: "drivers",
85
- },
79
+ client: "client",
86
80
  },
87
81
  };
88
82
 
@@ -96,16 +90,14 @@ export default test => {
96
90
  mode: "operational",
97
91
  },
98
92
  paths: {
99
- client: "client_logic",
100
- data: {
101
- domains: "domains",
102
- drivers: "drivers",
103
- stores: "storage",
104
- },
93
+ client: "client",
105
94
  public: "public",
95
+ static: "static",
96
+ routes: "routes",
97
+ components: "components",
106
98
  },
107
99
  };
108
100
 
109
- assert(extend(default_conf, additional_conf)).equals(extended);
101
+ assert(extend(base, additional)).equals(extended);
110
102
  });
111
103
  };
@@ -0,0 +1,5 @@
1
+ export {default as text} from "./text.js";
2
+ export {default as json} from "./json.js";
3
+ export {default as stream} from "./stream.js";
4
+ export {default as redirect} from "./redirect.js";
5
+ export {default as html} from "./html.js";
@@ -0,0 +1,18 @@
1
+ const getContent = async (env, name) => {
2
+ try {
3
+ return await env.paths.components.join(`${name}.html`).file.read();
4
+ } catch (error) {
5
+ throw new Error(`cannot load component at ${name}.html`);
6
+ }
7
+ };
8
+
9
+ export default (content, {status = 200, partial = false, adhoc = false} = {}) =>
10
+ async (env, headers) => {
11
+ const html = adhoc ? content : await getContent(env, content);
12
+ return [
13
+ partial ? html : await env.render(html), {
14
+ status,
15
+ headers: {...headers, "Content-Type": "text/html"},
16
+ },
17
+ ];
18
+ };
@@ -1,4 +1,6 @@
1
- export default () => () => ["Page not found", {
2
- status: 404,
3
- headers: {"Content-Type": "text/html"},
4
- }];
1
+ export default (body = "Not found") => (_, headers) => [
2
+ body, {
3
+ status: 404,
4
+ headers: {...headers, "Content-Type": "text/html"},
5
+ },
6
+ ];
@@ -1,4 +1,6 @@
1
- export default (_, ...keys) => async () => [JSON.stringify(await keys[0]), {
2
- status: 200,
3
- headers: {"Content-Type": "application/json"},
4
- }];
1
+ export default (body, {status = 200} = {}) => (_, headers) => [
2
+ JSON.stringify(body), {
3
+ status,
4
+ headers: {...headers, "Content-Type": "application/json"},
5
+ },
6
+ ];
@@ -0,0 +1,7 @@
1
+ export default (Location, {status = 302} = {}) => (_, headers) => [
2
+ /* no body */
3
+ null, {
4
+ status,
5
+ headers: {...headers, Location},
6
+ },
7
+ ];
@@ -1,4 +1,6 @@
1
- export default (_, ...keys) => async () => [await keys[0], {
2
- status: 200,
3
- headers: {"Content-Type": "application/octet-stream"},
4
- }];
1
+ export default (body, {status = 200} = {}) => (_, headers) => [
2
+ body, {
3
+ status,
4
+ headers: {...headers, "Content-Type": "application/octet-stream"},
5
+ },
6
+ ];
@@ -1,11 +1,6 @@
1
- const last = -1;
2
-
3
- export default (strings, ...keys) => async () => {
4
- const awaitedKeys = await Promise.all(keys);
5
- const body = strings
6
- .slice(0, last)
7
- .map((string, i) => string + awaitedKeys[i])
8
- .join("") + strings[strings.length + last];
9
-
10
- return [body, {status: 200, headers: {"Content-Type": "text/plain"}}];
11
- };
1
+ export default (body, {status = 200} = {}) => (_, headers) => [
2
+ body, {
3
+ status,
4
+ headers: {...headers, "Content-Type": "text/plain"},
5
+ },
6
+ ];
@@ -0,0 +1,5 @@
1
+ export default {
2
+ OK: 200,
3
+ Found: 302,
4
+ InternalServerError: 500,
5
+ };
package/src/index.html ADDED
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Primate app</title>
5
+ <meta charset="utf-8" />
6
+ </head>
7
+ <body>%body%</body>
8
+ </html>
package/src/log.js CHANGED
@@ -19,8 +19,11 @@ const log = new Proxy(Log, {
19
19
  log.paint(colors[property] ?? reset, message).paint(reset, " ")),
20
20
  });
21
21
 
22
- export default {
23
- info: (...args) => log.green("[info]").reset(...args).nl(),
24
- warn: (...args) => log.yellow("[warn]").reset(...args).nl(),
25
- error: (...args) => log.red("[error]").reset(...args).nl(),
22
+ export const info = (...args) => log.green("[info]").reset(...args).nl();
23
+
24
+ export const warn = (...args) => log.yellow("[warn]").reset(...args).nl();
25
+
26
+ export const error = (originalError, env) => {
27
+ log.red("[error]").reset(originalError.message).nl();
28
+ env.debug && console.log(originalError);
26
29
  };
package/src/mimes.js ADDED
@@ -0,0 +1,12 @@
1
+ export default {
2
+ binary: "application/octet-stream",
3
+ css: "text/css",
4
+ html: "text/html",
5
+ jpg: "image/jpeg",
6
+ js: "text/javascript",
7
+ json: "application/json",
8
+ png: "image/png",
9
+ svg: "image/svg+xml",
10
+ woff2: "font/woff2",
11
+ webp: "image/webp",
12
+ };
@@ -0,0 +1,5 @@
1
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
+
3
+ export default async env =>
4
+ [...filter("register", env.modules), _ => _].reduceRight((acc, handler) =>
5
+ input => handler(input, acc))(env);
package/src/respond.js ADDED
@@ -0,0 +1,24 @@
1
+ import {Blob} from "runtime-compat/fs";
2
+ import {text, json, stream} from "./handlers/exports.js";
3
+ import {isResponse as isResponseDuck} from "./duck.js";
4
+ import RouteError from "./errors/Route.js";
5
+
6
+ const isText = value => {
7
+ if (typeof value === "string") {
8
+ return text(value);
9
+ }
10
+ throw new RouteError(`no handler found for ${value}`);
11
+ };
12
+
13
+ const isNonNullObject = value => typeof value === "object" && value !== null;
14
+ const isObject = value => isNonNullObject(value)
15
+ ? json(value) : isText(value);
16
+ const isResponse = value => isResponseDuck(value)
17
+ ? () => value : isObject(value);
18
+ const isStream = value => value instanceof ReadableStream
19
+ ? stream(value) : isResponse(value);
20
+ const isBlob = value => value instanceof Blob
21
+ ? stream(value) : isStream(value);
22
+ const guess = value => isBlob(value);
23
+
24
+ export default result => typeof result === "function" ? result : guess(result);
package/src/route.js CHANGED
@@ -1,29 +1,17 @@
1
- import {ReadableStream} from "runtime-compat/streams";
2
- import {Path, File} from "runtime-compat/filesystem";
1
+ import {Path} from "runtime-compat/fs";
3
2
  import {is} from "runtime-compat/dyndef";
4
- import text from "./handlers/text.js";
5
- import json from "./handlers/json.js";
6
- import stream from "./handlers/stream.js";
7
3
  import RouteError from "./errors/Route.js";
8
4
 
9
- const isText = value => {
10
- if (typeof value === "string") {
11
- return text`${value}`;
12
- }
13
- throw new RouteError(`no handler found for ${value}`);
14
- };
15
- const isObject = value => typeof value === "object" && value !== null
16
- ? json`${value}` : isText(value);
17
- const isStream = value => value instanceof ReadableStream
18
- ? stream`${value}` : isObject(value);
19
- const isFile = value => value instanceof File
20
- ? stream`${value}` : isStream(value);
21
- const guess = value => isFile(value);
22
-
23
5
  // insensitive-case equal
24
6
  const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
25
-
26
- export default async definitions => {
7
+ // HTTP verbs
8
+ const verbs = [
9
+ // CRUD
10
+ "post", "get", "put", "delete",
11
+ // extended
12
+ "delete", "connect", "options", "trace", "patch",
13
+ ];
14
+ export default async (definitions, handlers) => {
27
15
  const aliases = [];
28
16
  const routes = [];
29
17
  const expand = path => aliases.reduce((expanded, {key, value}) =>
@@ -43,11 +31,11 @@ export default async definitions => {
43
31
  ieq(route.method, method) && route.path.test(path)) ?? fallback;
44
32
 
45
33
  const router = {
34
+ ...Object.fromEntries(verbs.map(verb =>
35
+ [verb, (path, callback) => add(verb, path, callback)])),
46
36
  map: (path, callback) => add("map", path, callback),
47
- get: (path, callback) => add("get", path, callback),
48
- post: (path, callback) => add("post", path, callback),
49
37
  alias: (key, value) => aliases.push({key, value}),
50
- process: async request => {
38
+ route: async request => {
51
39
  const {method} = request.original;
52
40
  const url = new URL(`https://primatejs.com${request.pathname}`);
53
41
  const {pathname, searchParams} = url;
@@ -58,15 +46,14 @@ export default async definitions => {
58
46
  const path = pathname.split("/").filter(part => part !== "");
59
47
  const named = verb.path?.exec(pathname)?.groups ?? {};
60
48
 
61
- const result = await verb.handler(await find("map", pathname)
49
+ return verb.handler(await find("map", pathname)
62
50
  .handler({...request, pathname, params, path, named}));
63
-
64
- return typeof result === "function" ? result : guess(result);
65
51
  },
66
52
  };
67
53
  if (await definitions.exists) {
68
54
  const files = (await Path.list(definitions)).map(route => import(route));
69
- await Promise.all(files.map(async route => (await route).default(router)));
55
+ await Promise.all(files.map(async route =>
56
+ (await route).default(router, handlers)));
70
57
  }
71
58
  return router;
72
59
  };
package/src/run.js CHANGED
@@ -1,13 +1,14 @@
1
- import serve from "./serve.js";
2
- import route from "./route.js";
1
+ import config from "./config.js";
2
+ import register from "./register.js";
3
+ import compile from "./compile.js";
3
4
  import bundle from "./bundle.js";
5
+ import route from "./route.js";
6
+ import serve from "./serve.js";
4
7
 
5
- const extract = (modules, key) => modules.flatMap(module => module[key] ?? []);
6
-
7
- export default async env => {
8
- const {paths} = env;
9
- const router = await route(paths.routes);
8
+ export default async () => {
9
+ const env = await config();
10
+ await register(env);
11
+ await compile(env);
10
12
  await bundle(env);
11
-
12
- await serve({router, ...env, modules: extract(env.modules ?? [], "serve")});
13
+ serve({router: await route(env.paths.routes, env.handlers), ...env});
13
14
  };
package/src/serve.js CHANGED
@@ -1,12 +1,16 @@
1
- import {Path} from "runtime-compat/filesystem";
1
+ import {Path} from "runtime-compat/fs";
2
2
  import {serve, Response} from "runtime-compat/http";
3
- import statuses from "./http-statuses.json" assert {type: "json"};
4
- import mimes from "./mimes.json" assert {type: "json"};
3
+ import statuses from "./http-statuses.js";
4
+ import mimes from "./mimes.js";
5
5
  import {http404} from "./handlers/http.js";
6
+ import {isResponse} from "./duck.js";
7
+ import respond from "./respond.js";
6
8
 
7
9
  const regex = /\.([a-z1-9]*)$/u;
8
10
  const mime = filename => mimes[filename.match(regex)[1]] ?? mimes.binary;
9
11
 
12
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
13
+
10
14
  const contents = {
11
15
  "application/x-www-form-urlencoded": body =>
12
16
  Object.fromEntries(body.split("&").map(part => part.split("=")
@@ -15,8 +19,7 @@ const contents = {
15
19
  };
16
20
 
17
21
  export default env => {
18
- const route = async request => {
19
- let result;
22
+ const _respond = async request => {
20
23
  const csp = Object.keys(env.http.csp).reduce((policy_string, key) =>
21
24
  `${policy_string}${key} ${env.http.csp[key]};`, "");
22
25
  const headers = {
@@ -25,12 +28,21 @@ export default env => {
25
28
  };
26
29
 
27
30
  try {
28
- result = await (await env.router.process(request))(env, headers);
31
+ const {router} = env;
32
+ const modules = filter("route", env.modules);
33
+ // handle is the last module to be executed
34
+ const handlers = [...modules, router.route].reduceRight((acc, handler) =>
35
+ input => handler(input, acc));
36
+ return await respond(await handlers(request))(env, headers);
29
37
  } catch (error) {
30
- env.error(error.message);
31
- result = http404(env, headers)``;
38
+ env.log.error(error);
39
+ return http404()(env, headers);
32
40
  }
33
- return new Response(...result);
41
+ };
42
+
43
+ const route = async request => {
44
+ const response = await _respond(request);
45
+ return isResponse(response) ? response : new Response(...response);
34
46
  };
35
47
 
36
48
  const resource = async file => new Response(file.readable, {
@@ -50,7 +62,7 @@ export default env => {
50
62
  try {
51
63
  return await _serve(request);
52
64
  } catch (error) {
53
- env.error(error.message);
65
+ env.log.error(error);
54
66
  return new Response(null, {status: statuses.InternalServerError});
55
67
  }
56
68
  };
@@ -60,7 +72,8 @@ export default env => {
60
72
  return type === undefined ? body : type(body);
61
73
  };
62
74
 
63
- const {http, modules} = env;
75
+ const {http} = env;
76
+ const modules = filter("serve", env.modules);
64
77
 
65
78
  // handle is the last module to be executed
66
79
  const handlers = [...modules, handle].reduceRight((acc, handler) =>
@@ -87,5 +100,5 @@ export default env => {
87
100
  return handlers({original: request, pathname: pathname + search, body});
88
101
  }, http);
89
102
 
90
- env.info(`running on ${http.host}:${http.port}`);
103
+ env.log.info(`running on ${http.host}:${http.port}`);
91
104
  };