primate 0.15.6 → 0.16.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 (43) hide show
  1. package/README.md +2 -3
  2. package/package.json +8 -13
  3. package/src/Logger.js +5 -5
  4. package/src/app.js +157 -0
  5. package/src/commands/create.js +8 -8
  6. package/src/commands/dev.js +1 -1
  7. package/src/commands/help.js +2 -2
  8. package/src/commands/serve.js +1 -1
  9. package/src/{primate.config.js → defaults/primate.config.js} +6 -2
  10. package/src/exports.js +4 -0
  11. package/src/fromNull.js +1 -0
  12. package/src/handlers/html.js +12 -8
  13. package/src/handlers/view.js +8 -9
  14. package/src/hooks/bundle.js +29 -0
  15. package/src/hooks/compile.js +7 -0
  16. package/src/hooks/exports.js +6 -0
  17. package/src/hooks/handle/exports.js +4 -0
  18. package/src/{mimes.js → hooks/handle/mime.js} +7 -1
  19. package/src/hooks/handle/mime.spec.js +13 -0
  20. package/src/{respond.js → hooks/handle/respond.js} +6 -4
  21. package/src/hooks/handle/respond.spec.js +12 -0
  22. package/src/{serve.js → hooks/handle.js} +59 -47
  23. package/src/hooks/publish.js +36 -0
  24. package/src/hooks/register.js +7 -0
  25. package/src/hooks/route.js +69 -0
  26. package/src/run.js +2 -2
  27. package/src/start.js +13 -20
  28. package/LICENSE +0 -19
  29. package/src/bundle.js +0 -23
  30. package/src/cache.js +0 -17
  31. package/src/compile.js +0 -5
  32. package/src/config.js +0 -111
  33. package/src/errors/Info.js +0 -1
  34. package/src/errors/InternalServer.js +0 -1
  35. package/src/errors/Predicate.js +0 -1
  36. package/src/errors/Route.js +0 -1
  37. package/src/errors.js +0 -2
  38. package/src/publish.js +0 -5
  39. package/src/register.js +0 -5
  40. package/src/route.js +0 -63
  41. /package/src/{index.html → defaults/index.html} +0 -0
  42. /package/src/{duck.js → hooks/handle/duck.js} +0 -0
  43. /package/src/{http-statuses.js → hooks/handle/http-statuses.js} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Primate
2
2
 
3
- Expressive, minimal and extensible framework for JavaScript.
3
+ Expressive, minimal and extensible web framework
4
4
 
5
5
  ## Getting started
6
6
 
@@ -14,10 +14,9 @@ export default {
14
14
  return "Hello, world!";
15
15
  },
16
16
  };
17
-
18
17
  ```
19
18
 
20
- Run `npm i && npm start` and visit `localhost:6161` in your browser.
19
+ Run `npm i && npm start` and visit http://localhost:6161 in your browser.
21
20
 
22
21
  ## Table of Contents
23
22
 
package/package.json CHANGED
@@ -1,27 +1,22 @@
1
1
  {
2
2
  "name": "primate",
3
- "version": "0.15.6",
4
- "description": "Expressive, minimal and extensible framework for JavaScript",
3
+ "version": "0.16.0",
4
+ "description": "Expressive, minimal and extensible web framework",
5
5
  "homepage": "https://primatejs.com",
6
6
  "bugs": "https://github.com/primatejs/primate/issues",
7
7
  "license": "MIT",
8
8
  "files": [
9
9
  "src/**",
10
- "!src/**/*.spec.js",
11
- "!readme/**"
10
+ "!src/**/*.spec.js"
12
11
  ],
13
12
  "bin": "src/bin.js",
14
- "repository": "https://github.com/primatejs/primate",
15
- "scripts": {
16
- "docs": "scripts/docs.sh",
17
- "test": "npx debris",
18
- "lint": "npx eslint ."
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/primatejs/primate",
16
+ "directory": "packages/primate"
19
17
  },
20
18
  "dependencies": {
21
- "runtime-compat": "^0.15.0"
22
- },
23
- "devDependencies": {
24
- "maximin": "^0.1.2"
19
+ "runtime-compat": "^0.16.3"
25
20
  },
26
21
  "type": "module",
27
22
  "exports": "./src/exports.js"
package/src/Logger.js CHANGED
@@ -27,13 +27,13 @@ const levels = new Map([
27
27
  const print = (...messages) => process.stdout.write(messages.join(" "));
28
28
 
29
29
  const Logger = class Logger {
30
- #level; #traceStack;
30
+ #level; #trace;
31
31
 
32
- constructor({level = Error, traceStack = false} = {}) {
32
+ constructor({level = Error, trace = false} = {}) {
33
33
  assert(level !== undefined && levels.get(level) <= info);
34
- is(traceStack).boolean();
34
+ is(trace).boolean();
35
35
  this.#level = level;
36
- this.#traceStack = traceStack;
36
+ this.#trace = trace;
37
37
  }
38
38
 
39
39
  static get Error() {
@@ -51,7 +51,7 @@ const Logger = class Logger {
51
51
  #print(pre, error) {
52
52
  if (error instanceof Error) {
53
53
  print(colors.bold(pre), error.message, "\n");
54
- if (this.#traceStack) {
54
+ if (this.#trace) {
55
55
  console.log(error);
56
56
  }
57
57
  } else {
package/src/app.js ADDED
@@ -0,0 +1,157 @@
1
+ import crypto from "runtime-compat/crypto";
2
+ import {is} from "runtime-compat/dyndef";
3
+ import {File, Path} from "runtime-compat/fs";
4
+ import extend from "./extend.js";
5
+ import defaults from "./defaults/primate.config.js";
6
+ import {colors, print, default as Logger} from "./Logger.js";
7
+ import * as handlers from "./handlers/exports.js";
8
+
9
+ const qualify = (root, paths) =>
10
+ Object.keys(paths).reduce((sofar, key) => {
11
+ const value = paths[key];
12
+ sofar[key] = typeof value === "string"
13
+ ? new Path(root, value)
14
+ : qualify(`${root}/${key}`, value);
15
+ return sofar;
16
+ }, {});
17
+
18
+ const configName = "primate.config.js";
19
+
20
+ const getConfig = async (root, filename) => {
21
+ const config = root.join(filename);
22
+ if (await config.exists) {
23
+ try {
24
+ const imported = await import(config);
25
+ if (imported.default === undefined) {
26
+ print(`${colors.yellow("??")} ${configName} has no default export\n`);
27
+ }
28
+ return extend(defaults, imported.default);
29
+ } catch (error) {
30
+ print(`${colors.red("!!")} couldn't load config file\n`);
31
+ throw error;
32
+ }
33
+ } else {
34
+ return defaults;
35
+ }
36
+ };
37
+
38
+ const getRoot = async () => {
39
+ try {
40
+ // use module root if possible
41
+ return await Path.root();
42
+ } catch (error) {
43
+ // fall back to current directory
44
+ return Path.resolve();
45
+ }
46
+ };
47
+
48
+ const src = new Path(import.meta.url).up(1);
49
+
50
+ const index = async app => {
51
+ const name = "index.html";
52
+ try {
53
+ // user-provided file
54
+ return await File.read(`${app.paths.static.join(name)}`);
55
+ } catch (error) {
56
+ // fallback
57
+ return src.join("defaults", name).text();
58
+ }
59
+ };
60
+
61
+ const hash = async (string, algorithm = "sha-384") => {
62
+ const encoder = new TextEncoder();
63
+ const bytes = await crypto.subtle.digest(algorithm, encoder.encode(string));
64
+ const algo = algorithm.replace("-", () => "");
65
+ return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
66
+ };
67
+
68
+ export default async (filename = configName) => {
69
+ is(filename).string();
70
+ const root = await getRoot();
71
+ const config = await getConfig(root, filename);
72
+
73
+ const {name, version} = await src.up(1).join("package.json").json();
74
+
75
+ // if ssl activated, resolve key and cert early
76
+ if (config.http.ssl) {
77
+ config.http.ssl.key = root.join(config.http.ssl.key);
78
+ config.http.ssl.cert = root.join(config.http.ssl.cert);
79
+ }
80
+
81
+ const app = {
82
+ config,
83
+ secure: config.http?.ssl !== undefined,
84
+ name, version,
85
+ library: {},
86
+ identifiers: {},
87
+ replace(code) {
88
+ const joined = Object.keys(app.identifiers).join("|");
89
+ const re = `(?<=import (?:.*) from ['|"])(${joined})(?=['|"])`;
90
+ return code.replaceAll(new RegExp(re, "gus"), (_, p1) => {
91
+ if (app.library[p1] === undefined) {
92
+ app.library[p1] = app.identifiers[p1];
93
+ }
94
+ return app.identifiers[p1];
95
+ });
96
+ },
97
+ resources: [],
98
+ entrypoints: [],
99
+ paths: qualify(root, config.paths),
100
+ root,
101
+ log: new Logger(config.logger),
102
+ handlers: {...handlers},
103
+ render: async ({body = "", head = ""} = {}) => {
104
+ const html = await index(app);
105
+ const heads = app.resources.map(({src, code, type, inline, integrity}) => {
106
+ const tag = type === "style" ? "link" : "script";
107
+ const pre = type === "style"
108
+ ? `<${tag} rel="stylesheet"`
109
+ : `<${tag} type="${type}" integrity="${integrity}"`;
110
+ const middle = type === "style"
111
+ ? ` href="${src}">`
112
+ : ` src="${src}">`;
113
+ const post = type === "style" ? "" : `</${tag}>`;
114
+ return inline ? `${pre}>${code}${post}` : `${pre}${middle}${post}`;
115
+ }).join("\n");
116
+ return html
117
+ .replace("%body%", () => body)
118
+ .replace("%head%", () => `${head}${heads}`);
119
+ },
120
+ publish: async ({src, code, type = "", inline = false}) => {
121
+ if (type === "module") {
122
+ code = app.replace(code);
123
+ }
124
+ // while integrity is only really needed for scripts, it is also later
125
+ // used for the etag header
126
+ const integrity = await hash(code);
127
+ const _src = new Path(config.http.static.root).join(src ?? "");
128
+ app.resources.push({src: `${_src}`, code, type, inline, integrity});
129
+ return integrity;
130
+ },
131
+ bootstrap: ({type, code}) => {
132
+ app.entrypoints.push({type, code});
133
+ },
134
+ resolve: (pkg, name) => {
135
+ const exports = Object.fromEntries(Object.entries(pkg.exports)
136
+ .filter(([, _export]) => _export.import !== undefined)
137
+ .map(([key, value]) => [
138
+ key.replace(".", name),
139
+ value.import.replace(".", `./${name}`),
140
+ ]));
141
+ app.identifiers = {...exports, ...app.identifiers};
142
+ },
143
+ modules: [...config.modules],
144
+ };
145
+ print(colors.blue(colors.bold(name)), colors.blue(version), "");
146
+ const type = app.secure ? "https" : "http";
147
+ const address = `${type}://${config.http.host}:${config.http.port}`;
148
+ print(colors.gray(`at ${address}`), "\n");
149
+ // modules may load other modules
150
+ await Promise.all(app.modules
151
+ .filter(module => module.load !== undefined)
152
+ .map(module => module.load({...app, load(dependent) {
153
+ app.modules.push(dependent);
154
+ }})));
155
+
156
+ return app;
157
+ };
@@ -1,6 +1,6 @@
1
1
  import {Path} from "runtime-compat/fs";
2
2
 
3
- const createModule = async env => {
3
+ const createModule = async app => {
4
4
  const space = 2;
5
5
  try {
6
6
  // will throw if cannot find a package.json up the filesystem hierarchy
@@ -10,7 +10,7 @@ const createModule = async env => {
10
10
  name: "primate-app",
11
11
  private: true,
12
12
  dependencies: {
13
- primate: `^${env.version}`,
13
+ primate: `^${app.version}`,
14
14
  },
15
15
  scripts: {
16
16
  start: "npx primate",
@@ -23,20 +23,20 @@ const createModule = async env => {
23
23
  }
24
24
  };
25
25
 
26
- const createConfig = async env => {
26
+ const createConfig = async app => {
27
27
  const name = "primate.config.js";
28
28
  const template = "export default {};";
29
29
  const root = (await Path.root()).join(name);
30
30
  if (await root.exists) {
31
- env.log.warn(`${root} already exists`);
31
+ app.log.warn(`${root} already exists`);
32
32
  } else {
33
33
  await root.file.write(template);
34
- env.log.info(`created config at ${root}`);
34
+ app.log.info(`created config at ${root}`);
35
35
  }
36
36
  };
37
37
 
38
- export default async env => {
39
- await createModule(env);
40
- await createConfig(env);
38
+ export default async app => {
39
+ await createModule(app);
40
+ await createConfig(app);
41
41
  };
42
42
 
@@ -1,3 +1,3 @@
1
1
  import start from "../start.js";
2
2
 
3
- export default async env => start(env);
3
+ export default app => start(app);
@@ -1,3 +1,3 @@
1
- export default env => {
2
- env.log.info("available commands: create dev serve");
1
+ export default app => {
2
+ app.log.info("available commands: create dev serve");
3
3
  };
@@ -1,3 +1,3 @@
1
1
  import start from "../start.js";
2
2
 
3
- export default async env => start(env, {bundle: true});
3
+ export default app => start(app, {bundle: true});
@@ -1,4 +1,4 @@
1
- import {Logger} from "primate";
1
+ import Logger from "../Logger.js";
2
2
 
3
3
  export default {
4
4
  base: "/",
@@ -15,10 +15,14 @@ export default {
15
15
  "form-action": "'self'",
16
16
  "base-uri": "'self'",
17
17
  },
18
+ static: {
19
+ root: "/",
20
+ pure: false,
21
+ },
18
22
  },
19
23
  paths: {
20
- public: "public",
21
24
  static: "static",
25
+ public: "public",
22
26
  routes: "routes",
23
27
  components: "components",
24
28
  },
package/src/exports.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import run from "./run.js";
2
2
 
3
3
  export * from "./handlers/exports.js";
4
+
4
5
  export {default as Logger} from "./Logger.js";
6
+
7
+ export {URL, Response} from "runtime-compat/http";
8
+
5
9
  export default command => run(command);
@@ -0,0 +1 @@
1
+ export default object => Object.assign(Object.create(null), object);
@@ -1,9 +1,13 @@
1
- export default (body, {status = 200, partial = false} = {}) =>
2
- async (env, headers) => {
3
- return [
4
- partial ? body : await env.render({body}), {
5
- status,
6
- headers: {...headers, "Content-Type": "text/html"},
7
- },
8
- ];
1
+ export default (component, flags = {}) => {
2
+ const {status = 200, partial = false, load = false} = flags;
3
+
4
+ return async (app, headers) => {
5
+ const body = load ?
6
+ await app.paths.components.join(component).text() : component;
7
+
8
+ return [partial ? body : await app.render({body}), {
9
+ status,
10
+ headers: {...headers, "Content-Type": "text/html"},
11
+ }];
9
12
  };
13
+ };
@@ -1,9 +1,8 @@
1
- export default (name, props, options) =>
2
- async (env, headers) => {
3
- const ending = name.slice(name.lastIndexOf(".") + 1);
4
- const handler = env.handlers[ending];
5
- if (handler === undefined) {
6
- return env.log.error(new Error(`no handler for ${ending} components`));
7
- }
8
- return handler(name, props, options)(env, headers);
9
- };
1
+ export default (name, props, options) => async (app, headers) => {
2
+ const ending = name.slice(name.lastIndexOf(".") + 1);
3
+ const handler = app.handlers[ending];
4
+ if (handler === undefined) {
5
+ return app.log.error(new Error(`no handler for ${ending} components`));
6
+ }
7
+ return handler(name, {load: true, ...props}, options)(app, headers);
8
+ };
@@ -0,0 +1,29 @@
1
+ import {File} from "runtime-compat/fs";
2
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
3
+
4
+ const pre = async app => {
5
+ const {paths} = app;
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
+
13
+ if (await paths.static.exists) {
14
+ // copy static files to public
15
+ const filter = file => app.config.http.static.pure
16
+ ? true
17
+ : !file.endsWith(".js") && !file.endsWith(".css");
18
+ await File.copy(paths.static, paths.public, filter);
19
+ }
20
+ };
21
+
22
+ export default async (app, bundle) => {
23
+ await pre(app);
24
+ if (bundle) {
25
+ app.log.info("running bundle hooks");
26
+ await [...filter("bundle", app.modules), _ => _]
27
+ .reduceRight((acc, handler) => input => handler(input, acc))(app);
28
+ }
29
+ };
@@ -0,0 +1,7 @@
1
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
+
3
+ export default async app => {
4
+ app.log.info("running compile hooks");
5
+ await [...filter("compile", app.modules), _ => _]
6
+ .reduceRight((acc, handler) => input => handler(input, acc))(app);
7
+ };
@@ -0,0 +1,6 @@
1
+ export {default as register} from "./register.js";
2
+ export {default as compile} from "./compile.js";
3
+ export {default as publish} from "./publish.js";
4
+ export {default as bundle} from "./bundle.js";
5
+ export {default as route} from "./route.js";
6
+ export {default as handle} from "./handle.js";
@@ -0,0 +1,4 @@
1
+ export {default as statuses} from "./http-statuses.js";
2
+ export {default as mime} from "./mime.js";
3
+ export {isResponse} from "./duck.js";
4
+ export {default as respond} from "./respond.js";
@@ -1,12 +1,18 @@
1
- export default {
1
+ const mimes = {
2
2
  binary: "application/octet-stream",
3
3
  css: "text/css",
4
4
  html: "text/html",
5
5
  jpg: "image/jpeg",
6
6
  js: "text/javascript",
7
+ mjs: "text/javascript",
7
8
  json: "application/json",
8
9
  png: "image/png",
9
10
  svg: "image/svg+xml",
10
11
  woff2: "font/woff2",
11
12
  webp: "image/webp",
12
13
  };
14
+
15
+ const regex = /\.(?<extension>[a-z1-9]*)$/u;
16
+ const match = filename => filename.match(regex)?.groups.extension;
17
+
18
+ export default filename => mimes[match(filename)] ?? mimes.binary;
@@ -0,0 +1,13 @@
1
+ import mime from "./mime.js";
2
+
3
+ export default test => {
4
+ test.case("match", assert => {
5
+ assert(mime("/app.js")).equals("text/javascript");
6
+ });
7
+ test.case("no extension", assert => {
8
+ assert(mime("/app")).equals("application/octet-stream");
9
+ });
10
+ test.case("unknown extension", assert => {
11
+ assert(mime("/app.unknown")).equals("application/octet-stream");
12
+ });
13
+ };
@@ -1,13 +1,13 @@
1
1
  import {Blob} from "runtime-compat/fs";
2
- import {text, json, stream} from "./handlers/exports.js";
2
+ import {URL} from "runtime-compat/http";
3
+ import {text, json, stream, redirect} from "primate";
3
4
  import {isResponse as isResponseDuck} from "./duck.js";
4
- import RouteError from "./errors/Route.js";
5
5
 
6
6
  const isText = value => {
7
7
  if (typeof value === "string") {
8
8
  return text(value);
9
9
  }
10
- throw new RouteError(`no handler found for ${value}`);
10
+ throw new Error(`no handler found for ${value}`);
11
11
  };
12
12
 
13
13
  const isNonNullObject = value => typeof value === "object" && value !== null;
@@ -19,6 +19,8 @@ const isStream = value => value instanceof ReadableStream
19
19
  ? stream(value) : isResponse(value);
20
20
  const isBlob = value => value instanceof Blob
21
21
  ? stream(value) : isStream(value);
22
- const guess = value => isBlob(value);
22
+ const isURL = value => value instanceof URL
23
+ ? redirect(value.href) : isBlob(value);
24
+ const guess = value => isURL(value);
23
25
 
24
26
  export default result => typeof result === "function" ? result : guess(result);
@@ -0,0 +1,12 @@
1
+ import respond from "./respond.js";
2
+
3
+ export default test => {
4
+ test.case("guess URL", assert => {
5
+ const url = "https://primatejs.com/";
6
+ const status = 302;
7
+ const [body, options] = respond(new URL(url))();
8
+ assert(body).null();
9
+ assert(options.status).equals(status);
10
+ assert(options.headers.Location).equals(url);
11
+ });
12
+ };
@@ -1,36 +1,33 @@
1
1
  import {Path} from "runtime-compat/fs";
2
- import {serve, Response} from "runtime-compat/http";
3
- import statuses from "./http-statuses.js";
4
- import mimes from "./mimes.js";
5
- import {http404} from "./handlers/http.js";
6
- import {isResponse} from "./duck.js";
7
- import respond from "./respond.js";
8
- import {colors, print} from "./Logger.js";
9
-
10
- const regex = /\.([a-z1-9]*)$/u;
11
- const mime = filename => mimes[filename.match(regex)[1]] ?? mimes.binary;
2
+ import {serve, Response, URL} from "runtime-compat/http";
3
+ import {http404} from "../handlers/http.js";
4
+ import {statuses, mime, isResponse, respond} from "./handle/exports.js";
5
+ import fromNull from "../fromNull.js";
12
6
 
13
7
  const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
14
8
 
15
9
  const contents = {
16
10
  "application/x-www-form-urlencoded": body =>
17
- Object.fromEntries(body.split("&").map(part => part.split("=")
18
- .map(subpart => decodeURIComponent(subpart).replaceAll("+", " ")))),
11
+ fromNull(Object.fromEntries(body.split("&").map(part => part.split("=")
12
+ .map(subpart => decodeURIComponent(subpart).replaceAll("+", " "))))),
19
13
  "application/json": body => JSON.parse(body),
20
14
  };
21
15
 
22
- export default env => {
16
+ export default async app => {
17
+ const {config} = app;
18
+ const {http} = config;
19
+
23
20
  const _respond = async request => {
24
- const csp = Object.keys(env.http.csp).reduce((policy_string, key) =>
25
- `${policy_string}${key} ${env.http.csp[key]};`, "");
26
- const scripts = env.resources
21
+ const csp = Object.keys(config.http.csp).reduce((policy_string, key) =>
22
+ `${policy_string}${key} ${config.http.csp[key]};`, "");
23
+ const scripts = app.resources
27
24
  .map(resource => `'${resource.integrity}'`).join(" ");
28
25
  const _csp = scripts === "" ? csp : `${csp}script-src 'self' ${scripts};`;
29
26
  // remove inline resources
30
- for (let i = env.resources.length - 1; i >= 0; i--) {
31
- const resource = env.resources[i];
27
+ for (let i = app.resources.length - 1; i >= 0; i--) {
28
+ const resource = app.resources[i];
32
29
  if (resource.inline) {
33
- env.resources.splice(i, 1);
30
+ app.resources.splice(i, 1);
34
31
  }
35
32
  }
36
33
 
@@ -40,15 +37,15 @@ export default env => {
40
37
  };
41
38
 
42
39
  try {
43
- const {router} = env;
44
- const modules = filter("route", env.modules);
40
+ const {router} = app;
41
+ const modules = filter("route", app.modules);
45
42
  // handle is the last module to be executed
46
43
  const handlers = [...modules, router.route].reduceRight((acc, handler) =>
47
44
  input => handler(input, acc));
48
- return await respond(await handlers({request, env}))(env, headers);
45
+ return await respond(await handlers(request))(app, headers);
49
46
  } catch (error) {
50
- env.log.auto(error);
51
- return http404()(env, headers);
47
+ app.log.auto(error);
48
+ return http404()(app, headers);
52
49
  }
53
50
  };
54
51
 
@@ -57,7 +54,7 @@ export default env => {
57
54
  return isResponse(response) ? response : new Response(...response);
58
55
  };
59
56
 
60
- const resource = async file => new Response(file.readable, {
57
+ const staticResource = async file => new Response(file.readable, {
61
58
  status: statuses.OK,
62
59
  headers: {
63
60
  "Content-Type": mime(file.name),
@@ -66,8 +63,8 @@ export default env => {
66
63
  });
67
64
 
68
65
  const publishedResource = request => {
69
- const published = env.resources.find(resource =>
70
- `/${resource.src}` === request.pathname);
66
+ const published = app.resources.find(({src}) =>
67
+ src === request.url.pathname);
71
68
  if (published !== undefined) {
72
69
  return new Response(published.code, {
73
70
  status: statuses.OK,
@@ -81,16 +78,23 @@ export default env => {
81
78
  return route(request);
82
79
  };
83
80
 
84
- const _serve = async request => {
85
- const path = new Path(env.paths.public, request.pathname);
86
- return await path.isFile ? resource(path.file) : publishedResource(request);
81
+ const resource = async request => {
82
+ const {pathname} = request.url;
83
+ const {root} = http.static;
84
+ if (pathname.startsWith(root)) {
85
+ const path = app.paths.public.join(pathname.replace(root, ""));
86
+ return await path.isFile
87
+ ? staticResource(path.file)
88
+ : publishedResource(request);
89
+ }
90
+ return route(request);
87
91
  };
88
92
 
89
93
  const handle = async request => {
90
94
  try {
91
- return await _serve(request);
95
+ return await resource(request);
92
96
  } catch (error) {
93
- env.log.auto(error);
97
+ app.log.auto(error);
94
98
  return new Response(null, {status: statuses.InternalServerError});
95
99
  }
96
100
  };
@@ -104,21 +108,14 @@ export default env => {
104
108
  try {
105
109
  return parseContentType(request.headers.get("content-type"), body);
106
110
  } catch (error) {
107
- env.log.warn(error);
111
+ app.log.warn(error);
108
112
  return body;
109
113
  }
110
114
  };
111
115
 
112
- const {http} = env;
113
- const modules = filter("serve", env.modules);
114
-
115
- // handle is the last module to be executed
116
- const handlers = [...modules, handle].reduceRight((acc, handler) =>
117
- input => handler(input, acc));
118
-
119
116
  const decoder = new TextDecoder();
120
- serve(async request => {
121
- // preprocess request
117
+
118
+ const parseBody = async request => {
122
119
  const reader = request.body.getReader();
123
120
  const chunks = [];
124
121
  let result;
@@ -129,11 +126,26 @@ export default env => {
129
126
  }
130
127
  } while (!result.done);
131
128
 
132
- const body = chunks.length === 0 ? undefined
133
- : parseContent(request, chunks.join());
129
+ return chunks.length === 0 ? null : parseContent(request, chunks.join());
130
+ };
131
+
132
+ const parseRequest = async request => {
133
+ const cookies = request.headers.get("cookie");
134
+
135
+ return {
136
+ request,
137
+ url: new URL(request.url),
138
+ body: await parseBody(request),
139
+ cookies: fromNull(cookies === null
140
+ ? {}
141
+ : Object.fromEntries(cookies.split(";").map(c => c.trim().split("=")))),
142
+ headers: fromNull(Object.fromEntries(request.headers)),
143
+ };
144
+ };
134
145
 
135
- const {pathname, search} = new URL(`https://example.com${request.url}`);
146
+ // handle is the last module to be executed
147
+ const handlers = [...filter("handle", app.modules), handle]
148
+ .reduceRight((acc, handler) => input => handler(input, acc));
136
149
 
137
- return handlers({original: request, pathname: pathname + search, body});
138
- }, http);
150
+ serve(async request => handlers(await parseRequest(request)), config.http);
139
151
  };
@@ -0,0 +1,36 @@
1
+ import {Path} from "runtime-compat/fs";
2
+
3
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
4
+
5
+ const post = async app => {
6
+ // after hook, publish a zero assumptions app.js (no css imports)
7
+ const code = app.entrypoints.filter(({type}) => type === "script")
8
+ .map(entrypoint => entrypoint.code).join("");
9
+ await app.publish({src: `${app.config.dist}.js`, code, type: "module"});
10
+
11
+ if (!app.config.http.static.pure) {
12
+ const memoryFiles = await Path.collect(app.paths.static, /\.(?:js|css)$/u,
13
+ {recursive: false});
14
+ await Promise.all(memoryFiles.map(async file => {
15
+ const code = await file.text();
16
+ const src = file.name;
17
+ await app.publish({src, code, type: file.extension === ".js" ?
18
+ "module" : "style"});
19
+ if (file.extension === ".css") {
20
+ app.bootstrap({type: "style", code: `import "./${file.name}";`});
21
+ }
22
+ }));
23
+ }
24
+ await Promise.all(Object.entries(app.library).map(async libfile => {
25
+ const [, src] = libfile;
26
+ const code = await Path.resolve().join("node_modules", src).text();
27
+ await app.publish({src, code, type: "module"});
28
+ }));
29
+ };
30
+
31
+ export default async app => {
32
+ app.log.info("running publish hooks");
33
+ await [...filter("publish", app.modules), _ => _]
34
+ .reduceRight((acc, handler) => input => handler(input, acc))(app);
35
+ await post(app);
36
+ };
@@ -0,0 +1,7 @@
1
+ const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
+
3
+ export default async app => {
4
+ app.log.info("running register hooks");
5
+ await [...filter("register", app.modules), _ => _]
6
+ .reduceRight((acc, handler) => input => handler(input, acc))(app);
7
+ };
@@ -0,0 +1,69 @@
1
+ import {Path} from "runtime-compat/fs";
2
+ import {Logger} from "primate";
3
+ import fromNull from "../fromNull.js";
4
+
5
+ // insensitive-case equal
6
+ const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
7
+ // HTTP verbs
8
+ const verbs = [
9
+ // CRUD
10
+ "post", "get", "put", "delete",
11
+ // extended
12
+ "delete", "connect", "options", "trace", "patch",
13
+ ];
14
+
15
+ const toRoute = file => {
16
+ const ending = -3;
17
+ const route = file
18
+ // remove ending
19
+ .slice(0, ending)
20
+ // transform /index -> ""
21
+ .replace("/index", "")
22
+ // transform index -> ""
23
+ .replace("index", "")
24
+ // prepare for regex
25
+ .replaceAll(/\{(?<named>.*)\}/gu, (_, name) => `(?<${name}>.*?)`)
26
+ ;
27
+ return new RegExp(`^/${route}$`, "u");
28
+ };
29
+
30
+ export default async app => {
31
+ const routes = (await Promise.all(
32
+ (await Path.collect(app.paths.routes, /^.*.js$/u))
33
+ .map(async route => {
34
+ const imported = (await import(route)).default;
35
+ const file = `${route}`.replace(app.paths.routes, "").slice(1);
36
+ if (imported === undefined) {
37
+ app.log.warn(`empty route file at ${file}`);
38
+ return [];
39
+ }
40
+
41
+ const path = toRoute(file);
42
+ return Object.entries(imported)
43
+ .filter(([verb]) => verbs.includes(verb))
44
+ .map(([method, handler]) => ({method, handler, path}));
45
+ }))).flat();
46
+ const find = (method, path) => routes.find(route =>
47
+ ieq(route.method, method) && route.path.test(path));
48
+
49
+ const router = {
50
+ async route({request, url, ...rest}) {
51
+ const {method} = request;
52
+ const {pathname, searchParams} = url;
53
+ const verb = find(method, pathname) ?? (() => {
54
+ throw new Logger.Warn(`no ${method} route to ${pathname}`);
55
+ })();
56
+
57
+ const data = {
58
+ request,
59
+ url,
60
+ path: verb.path?.exec(pathname)?.groups ?? Object.create(null),
61
+ query: fromNull(Object.fromEntries(searchParams)),
62
+ ...rest,
63
+ };
64
+
65
+ return verb.handler(data);
66
+ },
67
+ };
68
+ return router;
69
+ };
package/src/run.js CHANGED
@@ -1,4 +1,4 @@
1
- import config from "./config.js";
1
+ import app from "./app.js";
2
2
  import command from "./commands/exports.js";
3
3
 
4
- export default async name => command(name)(await config());
4
+ export default async name => command(name)(await app());
package/src/start.js CHANGED
@@ -1,27 +1,20 @@
1
- import register from "./register.js";
2
- import compile from "./compile.js";
3
- import publish from "./publish.js";
4
- import bundle from "./bundle.js";
5
- import route from "./route.js";
6
- import serve from "./serve.js";
1
+ import {register, compile, publish, bundle, route, handle}
2
+ from "./hooks/exports.js";
7
3
 
8
- export default async (env, operations = {}) => {
4
+ export default async (app, operations = {}) => {
9
5
  // register handlers
10
- await register(env);
6
+ await register({...app, register(name, handler) {
7
+ app.handlers[name] = handler;
8
+ }});
9
+
11
10
  // compile server-side code
12
- await compile(env);
11
+ await compile(app);
13
12
  // publish client-side code
14
- await publish(env);
13
+ await publish(app);
15
14
 
16
- // after publish hook, publish a zero assumptions app.js (no css imports)
17
- const code = env.entrypoints.filter(({type}) => type === "script")
18
- .map(({code}) => code).join("");
19
- await env.publish({src: `${env.dist}.js`, code, type: "module"});
15
+ // bundle client-side code
16
+ await bundle(app, operations?.bundle);
20
17
 
21
- if (operations?.bundle) {
22
- // bundle client-side code
23
- await bundle(env);
24
- }
25
- // serve
26
- serve({router: await route(env), ...env});
18
+ // handle
19
+ await handle({router: await route(app), ...app});
27
20
  };
package/LICENSE DELETED
@@ -1,19 +0,0 @@
1
- Copyright (c) Terrablue <terrablue@proton.me> and contributors.
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining a copy
4
- of this software and associated documentation files (the "Software"), to deal
5
- in the Software without restriction, including without limitation the rights
6
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- copies of the Software, and to permit persons to whom the Software is
8
- furnished to do so, subject to the following conditions:
9
-
10
- The above copyright notice and this permission notice shall be included in
11
- all copies or substantial portions of the Software.
12
-
13
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
- THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
- THE SOFTWARE.
package/src/bundle.js DELETED
@@ -1,23 +0,0 @@
1
- import {File} from "runtime-compat/fs";
2
- const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
3
-
4
- const makePublic = async env => {
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
-
13
- if (await paths.static.exists) {
14
- // copy static files to public
15
- await File.copy(paths.static, paths.public);
16
- }
17
- };
18
-
19
- export default async env => {
20
- await makePublic(env);
21
- [...filter("bundle", env.modules), _ => _].reduceRight((acc, handler) =>
22
- input => handler(input, acc))(env);
23
- };
package/src/cache.js DELETED
@@ -1,17 +0,0 @@
1
- const Cache = class Cache {
2
- static #caches = [];
3
-
4
- static get(object, property) {
5
- return Cache.#caches.find(entry =>
6
- entry.object === object && entry.property === property)?.value;
7
- }
8
-
9
- static put(object, property, cacher) {
10
- const value = cacher();
11
- Cache.#caches.push({object, property, value});
12
- return value;
13
- }
14
- };
15
-
16
- export default (object, property, cacher) =>
17
- Cache.get(object, property) ?? Cache.put(object, property, cacher);
package/src/compile.js DELETED
@@ -1,5 +0,0 @@
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 DELETED
@@ -1,111 +0,0 @@
1
- import crypto from "runtime-compat/crypto";
2
- import {is} from "runtime-compat/dyndef";
3
- import {File, Path} from "runtime-compat/fs";
4
- import cache from "./cache.js";
5
- import extend from "./extend.js";
6
- import defaults from "./primate.config.js";
7
- import {colors, print, default as Logger} from "./Logger.js";
8
- import * as handlers from "./handlers/exports.js";
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
- const hash = async (string, algorithm = "sha-384") => {
49
- const encoder = new TextEncoder();
50
- const bytes = await crypto.subtle.digest(algorithm, encoder.encode(string));
51
- const algo = algorithm.replace("-", () => "");
52
- return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
53
- };
54
-
55
- export default async (filename = "primate.config.js") => {
56
- is(filename).string();
57
- const root = await getRoot();
58
- const config = await getConfig(root, filename);
59
-
60
- const {name, version} = JSON.parse(await new Path(import.meta.url)
61
- .directory.directory.join("package.json").file.read());
62
-
63
- const env = {
64
- ...config,
65
- name, version,
66
- resources: [],
67
- entrypoints: [],
68
- paths: qualify(root, config.paths),
69
- root,
70
- log: new Logger(config.logger),
71
- register: (name, handler) => {
72
- env.handlers[name] = handler;
73
- },
74
- handlers: {...handlers},
75
- render: async ({body = "", head = ""} = {}) => {
76
- const html = await index(env);
77
- const heads = env.resources.map(({src, code, type, inline, integrity}) => {
78
- const tag = type === "style" ? "link" : "script";
79
- const pre = type === "style"
80
- ? `<${tag} rel="stylesheet" integrity="${integrity}"`
81
- : `<${tag} type="${type}" integrity="${integrity}"`;
82
- const middle = type === "style"
83
- ? ` href="${src}">`
84
- : ` src="${src}">`;
85
- const post = type === "style" ? "" : `</${tag}>`;
86
- return inline ? `${pre}>${code}${post}` : `${pre}${middle}${post}`;
87
- }).join("\n");
88
- return html
89
- .replace("%body%", () => body)
90
- .replace("%head%", () => `${head}${heads}`);
91
- },
92
- publish: async ({src, code, type = "", inline = false}) => {
93
- const integrity = await hash(code);
94
- env.resources.push({src, code, type, inline, integrity});
95
- return integrity;
96
- },
97
- bootstrap: ({type, code}) => {
98
- env.entrypoints.push({type, code});
99
- },
100
- };
101
- print(colors.blue(colors.bold(name)), colors.blue(version), "");
102
- print(colors.gray(`at http://${config.http.host}:${config.http.port}`), "\n");
103
- const {modules} = config;
104
- // modules may load other modules
105
- const loads = await Promise.all(modules
106
- .filter(module => module.load !== undefined)
107
- .map(module => module.load()));
108
-
109
- return cache("config", filename, () => ({...env,
110
- modules: modules.concat(loads.flat())}));
111
- };
@@ -1 +0,0 @@
1
- export default class InfoError extends Error {}
@@ -1 +0,0 @@
1
- export default class InternalServerError extends Error {}
@@ -1 +0,0 @@
1
- export default class PredicateError extends Error {}
@@ -1 +0,0 @@
1
- export default class RouteError extends Error {}
package/src/errors.js DELETED
@@ -1,2 +0,0 @@
1
- export {default as InternalServerError} from "./errors/InternalServer.js";
2
- export {default as PredicateError} from "./errors/Predicate.js";
package/src/publish.js DELETED
@@ -1,5 +0,0 @@
1
- const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
-
3
- export default async env =>
4
- [...filter("publish", env.modules), _ => _].reduceRight((acc, handler) =>
5
- input => handler(input, acc))(env);
package/src/register.js DELETED
@@ -1,5 +0,0 @@
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/route.js DELETED
@@ -1,63 +0,0 @@
1
- import {Path} from "runtime-compat/fs";
2
- import Logger from "./Logger.js";
3
-
4
- // insensitive-case equal
5
- const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
6
- // HTTP verbs
7
- const verbs = [
8
- // CRUD
9
- "post", "get", "put", "delete",
10
- // extended
11
- "delete", "connect", "options", "trace", "patch",
12
- ];
13
- export default async env => {
14
- const routes = [];
15
- const find = (method, path, fallback = {handler: r => r}) =>
16
- routes.find(route =>
17
- ieq(route.method, method) && route.path.test(path)) ?? fallback;
18
-
19
- const router = {
20
- route: async ({request}) => {
21
- const {method} = request.original;
22
- const url = new URL(`https://primatejs.com${request.pathname}`);
23
- const {pathname, searchParams} = url;
24
- const params = Object.fromEntries(searchParams);
25
- const verb = find(method, pathname, {handler: () => {
26
- throw new Logger.Info(`no ${method.toUpperCase()} route to ${pathname}`);
27
- }});
28
- const path = pathname.split("/").filter(part => part !== "");
29
- const named = verb.path?.exec(pathname)?.groups ?? {};
30
-
31
- return verb.handler(await find("map", pathname)
32
- .handler({...request, pathname, params, path, named}));
33
- },
34
- };
35
- const toRoute = file => {
36
- const ending = -3;
37
- const route = file
38
- // remove ending
39
- .slice(0, ending)
40
- // transform /index -> ""
41
- .replace("/index", "")
42
- // transform index -> ""
43
- .replace("index", "")
44
- // prepare for regex
45
- .replaceAll(/\{(?<named>.*)\}/gu, (_, name) => `(?<${name}>.*?)`)
46
- ;
47
- return new RegExp(`^/${route}$`, "u");
48
- };
49
- for (const route of await Path.collect(env.paths.routes, /^.*.js$/u)) {
50
- const imported = (await import(route)).default;
51
- const file = `${route}`.replace(env.paths.routes, "").slice(1);
52
- if (imported === undefined) {
53
- env.log.warn(`empty route file at ${file}`);
54
- } else {
55
- const valids = Object.entries(imported)
56
- .filter(([verb]) => verbs.includes(verb));
57
- for (const [method, handler] of valids) {
58
- routes.push({method, path: toRoute(file), handler});
59
- }
60
- }
61
- }
62
- return router;
63
- };
File without changes
File without changes