primate 0.1.1 → 0.4.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 (46) hide show
  1. package/README.md +4 -3
  2. package/package.json +9 -3
  3. package/source/client/Action.js +14 -19
  4. package/source/client/App.js +1 -4
  5. package/source/client/Client.js +14 -24
  6. package/source/client/Context.js +9 -15
  7. package/source/client/Element.js +12 -5
  8. package/source/client/Session.js +27 -0
  9. package/source/client/document.js +6 -0
  10. package/source/preset/primate.json +2 -6
  11. package/source/server/Action.js +53 -75
  12. package/source/server/App.js +26 -7
  13. package/source/server/Bundler.js +13 -16
  14. package/source/server/Context.js +64 -56
  15. package/source/server/{promises/Eager.js → EagerPromise.js} +3 -1
  16. package/source/server/File.js +6 -2
  17. package/source/server/Projector.js +86 -0
  18. package/source/server/Router.js +7 -9
  19. package/source/server/Session.js +9 -33
  20. package/source/server/attributes.js +2 -0
  21. package/source/server/conf.js +20 -26
  22. package/source/server/{Crypto.js → crypto.js} +0 -0
  23. package/source/server/domain/Domain.js +61 -56
  24. package/source/server/domain/Field.js +16 -13
  25. package/source/server/domain/domains.js +16 -0
  26. package/source/server/errors.js +0 -1
  27. package/source/server/exports.js +9 -8
  28. package/source/server/{utils/extend_object.js → extend_object.js} +0 -0
  29. package/source/server/invariants.js +0 -2
  30. package/source/server/sanitize.js +5 -0
  31. package/source/server/servers/Dynamic.js +19 -13
  32. package/source/server/servers/Static.js +25 -20
  33. package/source/server/servers/content-security-policy.json +0 -2
  34. package/source/server/store/Store.js +13 -0
  35. package/source/server/types/Date.js +2 -2
  36. package/source/server/types/Domain.js +0 -5
  37. package/source/server/types/Storeable.js +6 -7
  38. package/source/server/view/TreeNode.js +2 -0
  39. package/source/server/view/View.js +5 -0
  40. package/source/client/Base.js +0 -5
  41. package/source/server/Base.js +0 -35
  42. package/source/server/constructible.js +0 -8
  43. package/source/server/errors/Fallback.js +0 -1
  44. package/source/server/fallback.js +0 -11
  45. package/source/server/promises/Meta.js +0 -42
  46. package/source/server/promises.js +0 -2
package/README.md CHANGED
@@ -18,8 +18,8 @@ yarn add primate
18
18
  * **Minimal** just one dependency ([ws][])
19
19
  * **Simple** only native web technologies (JavaScript, HTML)
20
20
  * **Full-stack** server and client, both in JavaScript
21
- * **Layered** separation of data (domains), logic (actions) and
22
- presentation (views)
21
+ * **Layered** separation of data (domains), logic (actions) and presentation
22
+ (views)
23
23
  * **Correct** data verification using field definitions in domains
24
24
  * **Secure** serving hash-verified scripts on secure HTTP with a strong CSP
25
25
  * **Roles** access control with contexts
@@ -31,7 +31,7 @@ ad-hoc getters
31
31
 
32
32
  ## Getting started
33
33
 
34
- Coming soon.
34
+ See the [getting started][getting-started] guide.
35
35
 
36
36
  ## Resources
37
37
 
@@ -48,3 +48,4 @@ will move over to semantic versioning.
48
48
  BSD-3-Clause
49
49
 
50
50
  [ws]: https://github.com/websockets/ws
51
+ [getting-started]: https://primatejs.com/getting-started
package/package.json CHANGED
@@ -1,15 +1,21 @@
1
1
  {
2
2
  "name": "primate",
3
- "version": "0.1.1",
3
+ "version": "0.4.0",
4
4
  "author": "Primate core team <core@primatejs.com>",
5
5
  "homepage": "https://primatejs.com",
6
+ "bugs": "https://adaptivecloud.dev/primate/primate/issues",
7
+ "repository": "https://adaptivecloud.dev/primate/primate",
6
8
  "description": "Server-client framework",
7
9
  "license": "BSD-3-Clause",
8
10
  "dependencies": {
9
- "ws": "^8.4.0"
11
+ "ws": "^8.4.2"
10
12
  },
11
13
  "scripts": {
12
- "test": "node --experimental-json-modules node_modules/stick stick.json"
14
+ "copy": "rm -rf output && mkdir output && cp source/* output -a",
15
+ "instrument": "nyc instrument source ./output",
16
+ "stick": "node --experimental-json-modules node_modules/stick stick.json",
17
+ "coverage": "yarn instrument && nyc yarn run stick",
18
+ "test": "yarn copy && yarn run stick"
13
19
  },
14
20
  "type": "module",
15
21
  "exports": "./source/server/exports.js",
@@ -1,15 +1,11 @@
1
1
  import Element from "../Element.js";
2
2
  import View from "./View.js";
3
- import Base from "./Base.js";
3
+ import {origin_base, origin} from "./document.js";
4
4
 
5
- const base = document.baseURI.replace(document.location.origin, "");
6
- const full = `${document.location.origin}${base}`;
7
-
8
- export default class Action extends Base {
9
- constructor(name, context) {
10
- super();
5
+ export default class Action {
6
+ constructor(name, session) {
11
7
  this.name = name;
12
- this.context = context;
8
+ this.session = session;
13
9
  this.listeners = [];
14
10
  }
15
11
 
@@ -25,9 +21,9 @@ export default class Action extends Base {
25
21
 
26
22
  create_view(data, root, save_history = true) {
27
23
  if (save_history) {
28
- const pathname = data.pathname === "" ? "/" : data.pathname;
29
- window.location.pathname !== pathname
30
- && history.pushState({}, "", pathname);
24
+ if (data.url !== document.location.href.replace(origin, "")) {
25
+ history.pushState({}, "", data.url);
26
+ }
31
27
  }
32
28
  this.view = new View(data, this, root);
33
29
  }
@@ -75,9 +71,9 @@ export default class Action extends Base {
75
71
  }
76
72
  event.preventDefault();
77
73
  const data = await this.write(target.action, this.get_form_data(target));
78
- return data.pathname === document.location.href.replace(full, "")
74
+ return data.type === "update"
79
75
  ? this.view.update(data.payload)
80
- : this.context.run(data);
76
+ : this.session.run(data);
81
77
  }
82
78
 
83
79
  async onclick(event) {
@@ -94,12 +90,11 @@ export default class Action extends Base {
94
90
  }
95
91
  if (target !== null) {
96
92
  const href = Element.value(target, "href");
97
- const url = new URL(href, full);
98
- if (event.button === 0 && url.href.startsWith(full)) {
93
+ const url = new URL(href, origin_base);
94
+ if (event.button === 0 && url.href.startsWith(origin_base)) {
99
95
  event.preventDefault();
100
96
  if (href !== "") {
101
- const data = await this.read(href);
102
- await this.context.run(data);
97
+ await this.session.run(await this.read(href));
103
98
  }
104
99
  }
105
100
  }
@@ -152,10 +147,10 @@ export default class Action extends Base {
152
147
  }
153
148
 
154
149
  read(pathname) {
155
- return this.context.read(pathname);
150
+ return this.session.read(pathname);
156
151
  }
157
152
 
158
153
  write(pathname, payload) {
159
- return this.context.write(pathname, payload);
154
+ return this.session.write(pathname, payload);
160
155
  }
161
156
  }
@@ -1,15 +1,12 @@
1
1
  import Client from "./Client.js";
2
2
 
3
- const host = document.location.host;
4
- const base = document.baseURI.replace(document.location.origin, "");
5
-
6
3
  export default class App {
7
4
  constructor() {
8
5
  this.definitions = {};
9
6
  }
10
7
 
11
8
  run() {
12
- this.client = new Client({host, base});
9
+ this.client = new Client();
13
10
  this.client.open();
14
11
  }
15
12
 
@@ -1,35 +1,32 @@
1
- import Context from "./Context.js";
2
- import {contexts} from "./exports.js";
1
+ import Session from "./Session.js";
2
+ import {base, host, origin_base} from "./document.js";
3
3
 
4
4
  const events = ["submit", "click", "change", "input"];
5
5
  let back = undefined;
6
+ const location = `wss://${host}${base}`;
6
7
 
7
8
  export default class Client {
8
9
  constructor(conf) {
9
10
  this.conf = conf;
11
+ this.session = new Session(this);
10
12
 
11
13
  window.onpopstate = async event => {
12
14
  const {pathname, search} = event.target.location;
13
- return this.execute(await this.read(pathname + search));
15
+ return this.session.run(await this.read(pathname + search));
14
16
  };
15
17
 
16
18
  for (const key of events) {
17
- window.addEventListener(key, event => this.context.on(key, event));
19
+ window.addEventListener(key, event => this.session.on(key, event));
18
20
  }
19
21
  }
20
22
 
21
- get location() {
22
- return `wss://${this.conf.host}${this.conf.base}`;
23
- }
24
-
25
23
  open() {
26
24
  return this.connection?.readyState === 1 ? this
27
25
  : new Promise(resolve => {
28
- const connection = new WebSocket(this.location);
29
- connection.addEventListener("message", ({data}) => data === "open"
26
+ this.connection = new WebSocket(location);
27
+ this.connection.addEventListener("message", ({data}) => data === "open"
30
28
  ? resolve(this)
31
29
  : this.receive(JSON.parse(data)));
32
- this.connection = connection;
33
30
  });
34
31
  }
35
32
 
@@ -38,16 +35,10 @@ export default class Client {
38
35
  back(data);
39
36
  back = undefined;
40
37
  } else {
41
- await this.execute(data);
38
+ await this.session.execute(data);
42
39
  }
43
40
  }
44
41
 
45
- async execute(data) {
46
- const {context} = data;
47
- this.context = new (contexts[context] ?? Context)(context, this);
48
- await this.context.run(data);
49
- }
50
-
51
42
  read(url) {
52
43
  return this.send("read", url);
53
44
  }
@@ -56,16 +47,15 @@ export default class Client {
56
47
  return this.send("write", url, payload);
57
48
  }
58
49
 
59
- get full() {
60
- return `${document.location.origin}${this.conf.base}`;
61
- }
62
-
63
50
  async send(type, url, payload = {}) {
64
51
  await this.open();
65
- const {pathname, search} = new URL(url, this.full);
52
+ const {pathname, search} = new URL(url, origin_base);
53
+ const _url = pathname + search;
66
54
  return new Promise(resolve => {
67
55
  back = data => resolve(data);
68
- this.connection.send(JSON.stringify({type, pathname, search, payload}));
56
+ this.connection.send(JSON.stringify({
57
+ type, pathname, search, payload, "url": _url,
58
+ }));
69
59
  });
70
60
  }
71
61
  }
@@ -1,24 +1,26 @@
1
- import Base from "./Base.js";
2
1
  import Action from "./Action.js";
3
2
  import {routes} from "./exports.js";
4
3
 
5
- export default class Context extends Base {
6
- constructor(name, client) {
7
- super();
4
+ export default class Context {
5
+ constructor(name, session) {
8
6
  this.name = name;
9
7
  this.actions = {};
10
8
  this.used_actions = [];
11
- this.client = client;
9
+ this.session = session;
10
+ }
11
+
12
+ get Class() {
13
+ return this.constructor;
12
14
  }
13
15
 
14
16
  async run(data) {
15
17
  const {namespace, action} = data;
16
18
  const route = `${namespace}/${action}`;
17
19
  const module = routes?.[namespace]?.[action] ?? Action;
18
- this.actions[route] = new module(action, this);
20
+ this.actions[route] = new module(action, this.session);
19
21
  const nextaction = this.actions[route];
20
22
  nextaction.enter(data);
21
- await this.Class().before(nextaction);
23
+ await this.Class.before(nextaction);
22
24
  nextaction.before();
23
25
  this.action = nextaction;
24
26
  }
@@ -31,14 +33,6 @@ export default class Context extends Base {
31
33
  return data;
32
34
  }
33
35
 
34
- read(pathname) {
35
- return this.client.read(pathname);
36
- }
37
-
38
- write(pathname, payload) {
39
- return this.client.write(pathname, payload);
40
- }
41
-
42
36
  on(key, event) {
43
37
  this.action[`on${key}`](event);
44
38
  }
@@ -77,8 +77,7 @@ export default class Element {
77
77
  }
78
78
 
79
79
  static value(element, name) {
80
- const attribute = element.attributes.getNamedItem(name);
81
- return attribute === null ? null : attribute.value;
80
+ return element?.attributes.getNamedItem(name)?.value;
82
81
  }
83
82
 
84
83
  static evaluate(attribute, source, data) {
@@ -143,8 +142,10 @@ export default class Element {
143
142
  return this;
144
143
  }
145
144
 
146
- class(name, set) {
147
- this.element.classList.toggle(name, set !== false);
145
+ class(classes, set) {
146
+ classes.split(" ").forEach(name => {
147
+ this.element.classList.toggle(name, set !== false);
148
+ })
148
149
  return this;
149
150
  }
150
151
 
@@ -181,7 +182,8 @@ export default class Element {
181
182
 
182
183
  options(value) {
183
184
  value instanceof Array && value.forEach(option => {
184
- if (this.element.children[option._id] === undefined) {
185
+ const children = [...this.element.children];
186
+ if (!children.find(child => child.value === option._id)) {
185
187
  const newElement = document.createElement("option");
186
188
  newElement.setAttribute("value", option._id);
187
189
  newElement.innerHTML = option.name;
@@ -201,11 +203,13 @@ export default class Element {
201
203
 
202
204
  select_value(value) {
203
205
  this.element.value = value === undefined ? "" : value;
206
+ return this;
204
207
  }
205
208
 
206
209
  input_value(value) {
207
210
  const type = Element.value(this.element, "type");
208
211
  this[`input_${type}_value`]?.(value) ?? this.input_input_value(value);
212
+ return this;
209
213
  }
210
214
 
211
215
  textarea_value(value) {
@@ -216,6 +220,7 @@ export default class Element {
216
220
  if (value === true) {
217
221
  this.element.checked = true;
218
222
  }
223
+ return this;
219
224
  }
220
225
 
221
226
  input_file_value(value) {
@@ -225,12 +230,14 @@ export default class Element {
225
230
  }
226
231
  reader.onload = ({target}) => element.setAttribute("base64", target.result);
227
232
  element.addEventListener("change", file_listener);
233
+ return this;
228
234
  }
229
235
 
230
236
  input_input_value(value) {
231
237
  if (value !== undefined) {
232
238
  this.element.value = value;
233
239
  }
240
+ return this;
234
241
  }
235
242
 
236
243
  default_value(value) {
@@ -0,0 +1,27 @@
1
+ import Context from "./Context.js";
2
+ import {contexts} from "./exports.js";
3
+
4
+ export default class Session {
5
+ constructor(client) {
6
+ this.client = client;
7
+ }
8
+
9
+ async run(data) {
10
+ const {context} = data;
11
+ this.context = new (contexts[context] ?? Context)(context, this);
12
+
13
+ await this.context.run(data);
14
+ }
15
+
16
+ on(key, event) {
17
+ this.context.on(key, event);
18
+ }
19
+
20
+ read(pathname) {
21
+ return this.client.read(pathname);
22
+ }
23
+
24
+ write(pathname, payload) {
25
+ return this.client.write(pathname, payload);
26
+ }
27
+ }
@@ -0,0 +1,6 @@
1
+ const {baseURI} = document;
2
+ const {host, href, origin} = document.location;
3
+ const base = baseURI === href ? "/" : baseURI.replace(origin, "");
4
+ const origin_base = `${origin}${base}`;
5
+
6
+ export {base, origin, origin_base, host};
@@ -2,10 +2,7 @@
2
2
  "base": "/",
3
3
  "debug": false,
4
4
  "defaults": {
5
- "action": "index",
6
- "context": "guest",
7
- "namespace": "default",
8
- "store": "default.js"
5
+ "context": "guest"
9
6
  },
10
7
  "files": {
11
8
  "index": "index.html"
@@ -21,8 +18,7 @@
21
18
  "paths": {
22
19
  "client": "client",
23
20
  "data": {
24
- "domains": "domains",
25
- "stores": "stores"
21
+ "domains": "domains"
26
22
  },
27
23
  "public": "public",
28
24
  "server": "server",
@@ -1,118 +1,96 @@
1
- import Base from "./Base.js";
2
- import {MetaPromise} from "./promises.js";
3
-
4
- const _response = Symbol("#response");
5
-
6
- export default class Action extends Base {
7
- constructor(data, session) {
8
- super();
9
- this._data = data;
1
+ import {resolve} from "path";
2
+ import {default as EagerPromise, eager} from "./EagerPromise.js";
3
+ import View from "./view/View.js";
4
+ import Projector from "./Projector.js";
5
+ import InternalServerError from "./errors/InternalServer.js";
6
+ import {assert} from "./invariants.js";
7
+ import sanitize from "./sanitize.js";
8
+
9
+ export default class Action {
10
+ static action = "index";
11
+ static namespace = "default";
12
+ static layout = "default";
13
+
14
+ constructor(request, session, context) {
15
+ this.request = request;
10
16
  this.session = session;
17
+ this.context = EagerPromise.resolve(context);
11
18
  }
12
19
 
13
- static get layout() {
14
- return "default";
15
- }
16
-
17
- static async load(data, session, context) {
18
- let module;
19
- const server = this.conf.paths.server;
20
- const {namespace, action} = data.path;
20
+ static async new(request, session, context) {
21
+ const {namespace = this.namespace, action = this.action} = request.path;
22
+ assert(!namespace.includes("."), () => {
23
+ throw new InternalServerError("namespace may not include a dot"); });
21
24
  const route = `${namespace}/${action}`;
25
+ const path = resolve(`${context.directory}/${route}.js`);
22
26
  try {
23
- module = (await import(`${server}/${context}/${route}.js`)).default;
27
+ return new (await import(path)).default(request, session, context);
24
28
  } catch (error) {
25
- throw new Error(`route '${route}' missing`);
29
+ throw new Error(`route \`${route}\` missing`);
26
30
  }
27
- return new module(data, session);
31
+ }
32
+
33
+ get Class() {
34
+ return this.constructor;
28
35
  }
29
36
 
30
37
  get layout() {
31
- return this.Class().layout;
38
+ return this.Class.layout;
32
39
  }
33
40
 
34
41
  get path() {
35
- return this._data.path;
42
+ const {path} = this.request;
43
+ const namespace = path.namespace ?? this.Class.namespace;
44
+ const action = path.action ?? this.Class.action;
45
+ const _id = path._id;
46
+ return {action, namespace, _id};
36
47
  }
37
48
 
38
49
  get params() {
39
- return this._data.params;
40
- }
41
-
42
- async _view(path) {
43
- const {namespace, action} = this.path;
44
- const view_path = path !== undefined ? path : `${namespace}/${action}`;
45
- return (await this.context).view(view_path);
46
- }
47
-
48
- static sanitize(payload = {}) {
49
- //assert(typeof payload === "object");
50
- return Object.keys(payload)
51
- .map(key => ({key, "value": payload[key].toString().trim()}))
52
- .map(({key, value}) => ({key, "value":
53
- value === "" ? undefined : value}))
54
- .reduce((o, {key, value}) => {o[key] = value; return o;}, {});
50
+ return this.request.params;
55
51
  }
56
52
 
57
53
  async run(type) {
58
54
  this.before && await this.before();
59
- await this[type](this.Class().sanitize(this._data.payload));
60
- }
61
-
62
- get response() {
63
- return this[_response]();
55
+ await this[type](sanitize(this.request.payload));
64
56
  }
65
57
 
66
- return(data = {}) {
67
- this.respond("return", () => ({"payload": data}));
58
+ return(payload = {}) {
59
+ this.respond("return", () => ({payload}));
68
60
  }
69
61
 
70
62
  view(data = {}, layout = true) {
71
63
  this.respond("view", async () => {
72
- const _view = await this._view();
73
- const payload = await this.prepare(data, _view);
64
+ const {_view, payload} = await this.prepare(data);
74
65
  const view = layout ? _view.file(this.layout) : _view.content;
75
66
  return {view, payload};
76
67
  });
77
68
  }
78
69
 
79
70
  update(data) {
80
- this.respond("update", async () => {
81
- const _view = await this._view();
82
- const payload = await this.prepare(data, _view);
83
- return {payload};
84
- });
71
+ this.respond("update", async () =>
72
+ ({"payload": (await this.prepare(data)).payload})
73
+ );
85
74
  }
86
75
 
87
76
  redirect(location) {
88
77
  this.respond("redirect", () => ({location}));
89
78
  }
90
79
 
91
- get context() {
92
- return this.session.actual_context;
93
- }
94
-
95
- switch_context(name, parameters) {
96
- return this.session.switch_context(name, parameters);
97
- }
98
-
99
- async prepare(data = {}, view) {
100
- (await this.context).Class().prepare(this, data);
101
- return MetaPromise(data, await view.elements(this.layout));
80
+ async prepare(data = {}) {
81
+ const route = `${this.path.namespace}/${this.path.action}`;
82
+ const path = await eager`${this.context.directory}/${route}`;
83
+ const _view = await View.new(path, await this.context.layouts);
84
+ await this.context.Class.prepare(this, data);
85
+ const payload = await new Projector(data, await _view.elements(this.layout)).project(route);
86
+ return {payload, _view};
102
87
  }
103
88
 
104
- respond(type, response) {
105
- this[_response] = async () => {
106
- const data = await response();
107
- data.context = (await this.context).name;
108
- data.namespace = this.path.namespace;
109
- data.action = this.path.action;
110
- if (data.pathname === undefined) {
111
- data.pathname = this._data.pathname.replace(this.conf.base, "")
112
- + (this._data.search ?? "");
113
- }
114
- data.type = type;
115
- return data;
89
+ respond(type, how) {
90
+ this.response = async () => {
91
+ const context = await this.context.name;
92
+ const {url} = this.request;
93
+ return {...await how(), type, context, ...this.path, url};
116
94
  };
117
95
  }
118
96
  }
@@ -1,22 +1,35 @@
1
1
  import {resolve} from "path";
2
- import Base from "./Base.js";
3
2
  import Bundler from "./Bundler.js";
4
3
  import File from "./File.js";
5
4
  import Router from "./Router.js";
6
5
  import DynamicServer from "./servers/Dynamic.js";
7
6
  import StaticServer from "./servers/Static.js";
7
+ import cache from "./cache.js";
8
8
  import log from "./log.js";
9
+ import package_json from "../../package.json" assert {"type": "json" };
9
10
 
10
- export default class App extends Base {
11
- constructor() {
12
- super();
13
- this.router = new Router(this.routes, this.conf);
11
+ export default class App {
12
+ constructor(conf) {
13
+ this.conf = conf;
14
14
  this.Bundler = Bundler;
15
15
  }
16
16
 
17
+ get routes() {
18
+ return cache(this, "routes", async () => {
19
+ try {
20
+ const path = `${this.conf.root}/routes.json`;
21
+ return (await import(path, {"assert": {"type": "json"}})).default;
22
+ } catch (error) {
23
+ // local routes.json not required
24
+ return [];
25
+ }
26
+ });
27
+ }
28
+
17
29
  async run() {
18
- log.reset("Primate").yellow(this.package.version);
30
+ log.reset("Primate").yellow(package_json.version);
19
31
 
32
+ this.router = new Router(await this.routes, this.conf);
20
33
  const {index, hashes} = await new this.Bundler(this.conf).bundle();
21
34
  const router = this.router;
22
35
 
@@ -26,17 +39,23 @@ export default class App extends Base {
26
39
  "key": File.read_sync(resolve(this.conf.http.ssl.key)),
27
40
  "cert": File.read_sync(resolve(this.conf.http.ssl.cert)),
28
41
  },
42
+ "context": this.conf.defaults.context,
29
43
  };
30
44
  this.static_server = new StaticServer(conf);
31
45
  await this.static_server.run();
32
46
 
33
47
  this.dynamic_server = new DynamicServer({router,
34
- "path": this.base,
48
+ "path": this.conf.base,
35
49
  "server": this.static_server.server,
50
+ "context": this.conf.defaults.context,
36
51
  });
37
52
  await this.dynamic_server.run();
38
53
 
39
54
  const {port, host} = this.conf.http;
40
55
  this.static_server.listen(port, host);
41
56
  }
57
+
58
+ stop() {
59
+ this.static_server.close();
60
+ }
42
61
  }