primate 0.15.7 → 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/{env.js → app.js} +65 -28
  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 -46
  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 -23
  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 -5
  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.7",
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.1"
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 {
@@ -1,9 +1,8 @@
1
1
  import crypto from "runtime-compat/crypto";
2
2
  import {is} from "runtime-compat/dyndef";
3
3
  import {File, Path} from "runtime-compat/fs";
4
- import cache from "./cache.js";
5
4
  import extend from "./extend.js";
6
- import defaults from "./primate.config.js";
5
+ import defaults from "./defaults/primate.config.js";
7
6
  import {colors, print, default as Logger} from "./Logger.js";
8
7
  import * as handlers from "./handlers/exports.js";
9
8
 
@@ -16,10 +15,22 @@ const qualify = (root, paths) =>
16
15
  return sofar;
17
16
  }, {});
18
17
 
18
+ const configName = "primate.config.js";
19
+
19
20
  const getConfig = async (root, filename) => {
20
- try {
21
- return extend(defaults, (await import(root.join(filename))).default);
22
- } catch (error) {
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 {
23
34
  return defaults;
24
35
  }
25
36
  };
@@ -34,14 +45,16 @@ const getRoot = async () => {
34
45
  }
35
46
  };
36
47
 
37
- const index = async env => {
48
+ const src = new Path(import.meta.url).up(1);
49
+
50
+ const index = async app => {
38
51
  const name = "index.html";
39
52
  try {
40
53
  // user-provided file
41
- return await File.read(`${env.paths.static.join(name)}`);
54
+ return await File.read(`${app.paths.static.join(name)}`);
42
55
  } catch (error) {
43
56
  // fallback
44
- return new Path(import.meta.url).directory.join(name).file.read();
57
+ return src.join("defaults", name).text();
45
58
  }
46
59
  };
47
60
 
@@ -52,39 +65,47 @@ const hash = async (string, algorithm = "sha-384") => {
52
65
  return `${algo}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
53
66
  };
54
67
 
55
- export default async (filename = "primate.config.js") => {
68
+ export default async (filename = configName) => {
56
69
  is(filename).string();
57
70
  const root = await getRoot();
58
71
  const config = await getConfig(root, filename);
59
72
 
60
- const {name, version} = JSON.parse(await new Path(import.meta.url)
61
- .directory.directory.join("package.json").file.read());
73
+ const {name, version} = await src.up(1).join("package.json").json();
62
74
 
63
75
  // if ssl activated, resolve key and cert early
64
76
  if (config.http.ssl) {
65
77
  config.http.ssl.key = root.join(config.http.ssl.key);
66
78
  config.http.ssl.cert = root.join(config.http.ssl.cert);
67
- config.secure = true;
68
79
  }
69
80
 
70
- const env = {
71
- ...config,
81
+ const app = {
82
+ config,
83
+ secure: config.http?.ssl !== undefined,
72
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
+ },
73
97
  resources: [],
74
98
  entrypoints: [],
75
99
  paths: qualify(root, config.paths),
76
100
  root,
77
101
  log: new Logger(config.logger),
78
- register: (name, handler) => {
79
- env.handlers[name] = handler;
80
- },
81
102
  handlers: {...handlers},
82
103
  render: async ({body = "", head = ""} = {}) => {
83
- const html = await index(env);
84
- const heads = env.resources.map(({src, code, type, inline, integrity}) => {
104
+ const html = await index(app);
105
+ const heads = app.resources.map(({src, code, type, inline, integrity}) => {
85
106
  const tag = type === "style" ? "link" : "script";
86
107
  const pre = type === "style"
87
- ? `<${tag} rel="stylesheet" integrity="${integrity}"`
108
+ ? `<${tag} rel="stylesheet"`
88
109
  : `<${tag} type="${type}" integrity="${integrity}"`;
89
110
  const middle = type === "style"
90
111
  ? ` href="${src}">`
@@ -97,24 +118,40 @@ export default async (filename = "primate.config.js") => {
97
118
  .replace("%head%", () => `${head}${heads}`);
98
119
  },
99
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
100
126
  const integrity = await hash(code);
101
- env.resources.push({src, code, type, inline, integrity});
127
+ const _src = new Path(config.http.static.root).join(src ?? "");
128
+ app.resources.push({src: `${_src}`, code, type, inline, integrity});
102
129
  return integrity;
103
130
  },
104
131
  bootstrap: ({type, code}) => {
105
- env.entrypoints.push({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};
106
142
  },
143
+ modules: [...config.modules],
107
144
  };
108
145
  print(colors.blue(colors.bold(name)), colors.blue(version), "");
109
- const type = env.secure ? "https" : "http";
146
+ const type = app.secure ? "https" : "http";
110
147
  const address = `${type}://${config.http.host}:${config.http.port}`;
111
148
  print(colors.gray(`at ${address}`), "\n");
112
- const {modules} = config;
113
149
  // modules may load other modules
114
- const loads = await Promise.all(modules
150
+ await Promise.all(app.modules
115
151
  .filter(module => module.load !== undefined)
116
- .map(module => module.load()));
152
+ .map(module => module.load({...app, load(dependent) {
153
+ app.modules.push(dependent);
154
+ }})));
117
155
 
118
- return cache("config", filename, () => ({...env,
119
- modules: modules.concat(loads.flat())}));
156
+ return app;
120
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,35 +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
-
9
- const regex = /\.([a-z1-9]*)$/u;
10
- 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";
11
6
 
12
7
  const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
13
8
 
14
9
  const contents = {
15
10
  "application/x-www-form-urlencoded": body =>
16
- Object.fromEntries(body.split("&").map(part => part.split("=")
17
- .map(subpart => decodeURIComponent(subpart).replaceAll("+", " ")))),
11
+ fromNull(Object.fromEntries(body.split("&").map(part => part.split("=")
12
+ .map(subpart => decodeURIComponent(subpart).replaceAll("+", " "))))),
18
13
  "application/json": body => JSON.parse(body),
19
14
  };
20
15
 
21
- export default env => {
16
+ export default async app => {
17
+ const {config} = app;
18
+ const {http} = config;
19
+
22
20
  const _respond = async request => {
23
- const csp = Object.keys(env.http.csp).reduce((policy_string, key) =>
24
- `${policy_string}${key} ${env.http.csp[key]};`, "");
25
- 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
26
24
  .map(resource => `'${resource.integrity}'`).join(" ");
27
25
  const _csp = scripts === "" ? csp : `${csp}script-src 'self' ${scripts};`;
28
26
  // remove inline resources
29
- for (let i = env.resources.length - 1; i >= 0; i--) {
30
- const resource = env.resources[i];
27
+ for (let i = app.resources.length - 1; i >= 0; i--) {
28
+ const resource = app.resources[i];
31
29
  if (resource.inline) {
32
- env.resources.splice(i, 1);
30
+ app.resources.splice(i, 1);
33
31
  }
34
32
  }
35
33
 
@@ -39,15 +37,15 @@ export default env => {
39
37
  };
40
38
 
41
39
  try {
42
- const {router} = env;
43
- const modules = filter("route", env.modules);
40
+ const {router} = app;
41
+ const modules = filter("route", app.modules);
44
42
  // handle is the last module to be executed
45
43
  const handlers = [...modules, router.route].reduceRight((acc, handler) =>
46
44
  input => handler(input, acc));
47
- return await respond(await handlers({request, env}))(env, headers);
45
+ return await respond(await handlers(request))(app, headers);
48
46
  } catch (error) {
49
- env.log.auto(error);
50
- return http404()(env, headers);
47
+ app.log.auto(error);
48
+ return http404()(app, headers);
51
49
  }
52
50
  };
53
51
 
@@ -56,7 +54,7 @@ export default env => {
56
54
  return isResponse(response) ? response : new Response(...response);
57
55
  };
58
56
 
59
- const resource = async file => new Response(file.readable, {
57
+ const staticResource = async file => new Response(file.readable, {
60
58
  status: statuses.OK,
61
59
  headers: {
62
60
  "Content-Type": mime(file.name),
@@ -65,8 +63,8 @@ export default env => {
65
63
  });
66
64
 
67
65
  const publishedResource = request => {
68
- const published = env.resources.find(resource =>
69
- `/${resource.src}` === request.pathname);
66
+ const published = app.resources.find(({src}) =>
67
+ src === request.url.pathname);
70
68
  if (published !== undefined) {
71
69
  return new Response(published.code, {
72
70
  status: statuses.OK,
@@ -80,16 +78,23 @@ export default env => {
80
78
  return route(request);
81
79
  };
82
80
 
83
- const _serve = async request => {
84
- const path = new Path(env.paths.public, request.pathname);
85
- 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);
86
91
  };
87
92
 
88
93
  const handle = async request => {
89
94
  try {
90
- return await _serve(request);
95
+ return await resource(request);
91
96
  } catch (error) {
92
- env.log.auto(error);
97
+ app.log.auto(error);
93
98
  return new Response(null, {status: statuses.InternalServerError});
94
99
  }
95
100
  };
@@ -103,21 +108,14 @@ export default env => {
103
108
  try {
104
109
  return parseContentType(request.headers.get("content-type"), body);
105
110
  } catch (error) {
106
- env.log.warn(error);
111
+ app.log.warn(error);
107
112
  return body;
108
113
  }
109
114
  };
110
115
 
111
- const {http} = env;
112
- const modules = filter("serve", env.modules);
113
-
114
- // handle is the last module to be executed
115
- const handlers = [...modules, handle].reduceRight((acc, handler) =>
116
- input => handler(input, acc));
117
-
118
116
  const decoder = new TextDecoder();
119
- serve(async request => {
120
- // preprocess request
117
+
118
+ const parseBody = async request => {
121
119
  const reader = request.body.getReader();
122
120
  const chunks = [];
123
121
  let result;
@@ -128,11 +126,26 @@ export default env => {
128
126
  }
129
127
  } while (!result.done);
130
128
 
131
- const body = chunks.length === 0 ? undefined
132
- : 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
+ };
133
145
 
134
- 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));
135
149
 
136
- return handlers({original: request, pathname: pathname + search, body});
137
- }, http);
150
+ serve(async request => handlers(await parseRequest(request)), config.http);
138
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 env from "./env.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 env());
4
+ export default async name => command(name)(await app());
package/src/start.js CHANGED
@@ -1,30 +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";
7
- import config from "./config.js";
1
+ import {register, compile, publish, bundle, route, handle}
2
+ from "./hooks/exports.js";
8
3
 
9
- export default async (env, operations = {}) => {
10
- // read/write configuration
11
- await config(env);
4
+ export default async (app, operations = {}) => {
12
5
  // register handlers
13
- await register(env);
6
+ await register({...app, register(name, handler) {
7
+ app.handlers[name] = handler;
8
+ }});
9
+
14
10
  // compile server-side code
15
- await compile(env);
11
+ await compile(app);
16
12
  // publish client-side code
17
- await publish(env);
13
+ await publish(app);
18
14
 
19
- // after publish hook, publish a zero assumptions app.js (no css imports)
20
- const code = env.entrypoints.filter(({type}) => type === "script")
21
- .map(({code}) => code).join("");
22
- await env.publish({src: `${env.dist}.js`, code, type: "module"});
15
+ // bundle client-side code
16
+ await bundle(app, operations?.bundle);
23
17
 
24
- if (operations?.bundle) {
25
- // bundle client-side code
26
- await bundle(env);
27
- }
28
- // serve
29
- serve({router: await route(env), ...env});
18
+ // handle
19
+ await handle({router: await route(app), ...app});
30
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,5 +0,0 @@
1
- const filter = (key, array) => array?.flatMap(m => m[key] ?? []) ?? [];
2
-
3
- export default async env =>
4
- [...filter("config", env.modules), _ => _].reduceRight((acc, handler) =>
5
- input => handler(input, acc))(env);
@@ -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