@voxgig/build 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build.ts CHANGED
@@ -18,6 +18,7 @@ import { srv_handler } from './env/lambda/srv_handler'
18
18
  import { resources_yml } from './env/lambda/res_yml'
19
19
 
20
20
  import { env_gen, ENV_FILES, ENV_SRC, KINDS as ENV_KINDS } from './env/env_gen'
21
+ import { web_gen, WEB_FILES } from './env/web/web_gen'
21
22
 
22
23
  import {
23
24
  generate, empty, TM,
@@ -42,7 +43,15 @@ const EnvGen = {
42
43
  env_gen,
43
44
  files: ENV_FILES,
44
45
  srcfiles: ENV_SRC,
45
- kinds: ENV_KINDS,
46
+ kinds: [...ENV_KINDS, 'web'],
47
+ }
48
+
49
+
50
+ // EnvWeb: the experimental web frontend (SPA + backend web runner +
51
+ // auth), generated create-once. See env/web/web_gen.ts.
52
+ const EnvWeb = {
53
+ web_gen,
54
+ files: WEB_FILES,
46
55
  }
47
56
 
48
57
 
@@ -59,6 +68,7 @@ const Fragments = {
59
68
  export {
60
69
  EnvLambda,
61
70
  EnvGen,
71
+ EnvWeb,
62
72
  Fragments,
63
73
 
64
74
  // Building blocks for ejected project templates (src/gen/<name>.ts
package/dist/build.d.ts CHANGED
@@ -28,6 +28,7 @@ declare const EnvGen: {
28
28
  folder: string;
29
29
  tm?: string;
30
30
  src?: string;
31
+ root?: string;
31
32
  }) => Promise<void>;
32
33
  files: Record<string, {
33
34
  frag: string;
@@ -40,10 +41,25 @@ declare const EnvGen: {
40
41
  }[]>;
41
42
  kinds: string[];
42
43
  };
44
+ declare const EnvWeb: {
45
+ web_gen: (model: any, spec: {
46
+ root: string;
47
+ tm?: string;
48
+ env?: any;
49
+ force?: boolean;
50
+ }) => Promise<{
51
+ created: string[];
52
+ skipped: string[];
53
+ }>;
54
+ files: {
55
+ frag: string;
56
+ out: string;
57
+ }[];
58
+ };
43
59
  declare const Fragments: {
44
60
  load: typeof loadFragment;
45
61
  render: typeof renderFragment;
46
62
  list: typeof listFragments;
47
63
  folder: string;
48
64
  };
49
- export { EnvLambda, EnvGen, Fragments, generate, empty, TM, loadFragment, renderFragment, MsgMetaShape, CoreConfShape, CloudConfShape, res_dynamo_yml, };
65
+ export { EnvLambda, EnvGen, EnvWeb, Fragments, generate, empty, TM, loadFragment, renderFragment, MsgMetaShape, CoreConfShape, CloudConfShape, res_dynamo_yml, };
package/dist/build.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /* Copyright © 2022-2026 Voxgig Ltd, MIT License. */
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.res_dynamo_yml = exports.CloudConfShape = exports.CoreConfShape = exports.MsgMetaShape = exports.renderFragment = exports.loadFragment = exports.TM = exports.empty = exports.generate = exports.Fragments = exports.EnvGen = exports.EnvLambda = void 0;
4
+ exports.res_dynamo_yml = exports.CloudConfShape = exports.CoreConfShape = exports.MsgMetaShape = exports.renderFragment = exports.loadFragment = exports.TM = exports.empty = exports.generate = exports.Fragments = exports.EnvWeb = exports.EnvGen = exports.EnvLambda = void 0;
5
5
  // EnvLambda: generation of the Lambda deployment templates, built on the
6
6
  // jostraca templating library. The templates live in env/lambda/, one file
7
7
  // per output for convenience:
@@ -18,6 +18,7 @@ const srv_yml_1 = require("./env/lambda/srv_yml");
18
18
  const srv_handler_1 = require("./env/lambda/srv_handler");
19
19
  const res_yml_1 = require("./env/lambda/res_yml");
20
20
  const env_gen_1 = require("./env/env_gen");
21
+ const web_gen_1 = require("./env/web/web_gen");
21
22
  const generate_1 = require("./env/lambda/generate");
22
23
  Object.defineProperty(exports, "generate", { enumerable: true, get: function () { return generate_1.generate; } });
23
24
  Object.defineProperty(exports, "empty", { enumerable: true, get: function () { return generate_1.empty; } });
@@ -43,9 +44,16 @@ const EnvGen = {
43
44
  env_gen: env_gen_1.env_gen,
44
45
  files: env_gen_1.ENV_FILES,
45
46
  srcfiles: env_gen_1.ENV_SRC,
46
- kinds: env_gen_1.KINDS,
47
+ kinds: [...env_gen_1.KINDS, 'web'],
47
48
  };
48
49
  exports.EnvGen = EnvGen;
50
+ // EnvWeb: the experimental web frontend (SPA + backend web runner +
51
+ // auth), generated create-once. See env/web/web_gen.ts.
52
+ const EnvWeb = {
53
+ web_gen: web_gen_1.web_gen,
54
+ files: web_gen_1.WEB_FILES,
55
+ };
56
+ exports.EnvWeb = EnvWeb;
49
57
  // Fragment tooling (used by voxgig-system template list/eject/diff, and
50
58
  // available to project code).
51
59
  const Fragments = {
package/dist/build.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"build.js","sourceRoot":"","sources":["../build.ts"],"names":[],"mappings":";AAAA,oDAAoD;;;AAEpD,yEAAyE;AACzE,2EAA2E;AAC3E,8BAA8B;AAC9B,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,yEAAyE;AACzE,EAAE;AACF,yEAAyE;AACzE,2EAA2E;AAC3E,wEAAwE;AACxE,qCAAqC;AAErC,kDAA8C;AAC9C,0DAAsD;AACtD,kDAAoD;AAEpD,2CAA+E;AAE/E,oDAG8B;AA0C5B,yFA5CA,mBAAQ,OA4CA;AACR,sFA7CU,gBAAK,OA6CV;AACL,mFA9CiB,aAAE,OA8CjB;AACF,6FA9CA,uBAAY,OA8CA;AACZ,+FA/Cc,yBAAc,OA+Cd;AA5ChB,qCAA0C;AA6CxC,6FA7CO,kBAAY,OA6CP;AA5Cd,uCAA4D;AA6C1D,8FA7CO,oBAAa,OA6CP;AACb,+FA9CsB,qBAAc,OA8CtB;AA7ChB,yDAAqD;AA8CnD,+FA9CO,+BAAc,OA8CP;AA3ChB,MAAM,SAAS,GAAG;IAChB,OAAO,EAAP,iBAAO;IACP,WAAW,EAAX,yBAAW;IACX,aAAa,EAAb,uBAAa;CACd,CAAA;AAwBC,8BAAS;AArBX,wEAAwE;AACxE,yDAAyD;AACzD,MAAM,MAAM,GAAG;IACb,OAAO,EAAP,iBAAO;IACP,KAAK,EAAE,mBAAS;IAChB,QAAQ,EAAE,iBAAO;IACjB,KAAK,EAAE,eAAS;CACjB,CAAA;AAeC,wBAAM;AAZR,wEAAwE;AACxE,8BAA8B;AAC9B,MAAM,SAAS,GAAG;IAChB,IAAI,EAAE,uBAAY;IAClB,MAAM,EAAE,yBAAc;IACtB,IAAI,EAAE,wBAAa;IACnB,MAAM,EAAE,iBAAM;CACf,CAAA;AAMC,8BAAS"}
1
+ {"version":3,"file":"build.js","sourceRoot":"","sources":["../build.ts"],"names":[],"mappings":";AAAA,oDAAoD;;;AAEpD,yEAAyE;AACzE,2EAA2E;AAC3E,8BAA8B;AAC9B,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,yEAAyE;AACzE,EAAE;AACF,yEAAyE;AACzE,2EAA2E;AAC3E,wEAAwE;AACxE,qCAAqC;AAErC,kDAA8C;AAC9C,0DAAsD;AACtD,kDAAoD;AAEpD,2CAA+E;AAC/E,+CAAsD;AAEtD,oDAG8B;AAmD5B,yFArDA,mBAAQ,OAqDA;AACR,sFAtDU,gBAAK,OAsDV;AACL,mFAvDiB,aAAE,OAuDjB;AACF,6FAvDA,uBAAY,OAuDA;AACZ,+FAxDc,yBAAc,OAwDd;AArDhB,qCAA0C;AAsDxC,6FAtDO,kBAAY,OAsDP;AArDd,uCAA4D;AAsD1D,8FAtDO,oBAAa,OAsDP;AACb,+FAvDsB,qBAAc,OAuDtB;AAtDhB,yDAAqD;AAuDnD,+FAvDO,+BAAc,OAuDP;AApDhB,MAAM,SAAS,GAAG;IAChB,OAAO,EAAP,iBAAO;IACP,WAAW,EAAX,yBAAW;IACX,aAAa,EAAb,uBAAa;CACd,CAAA;AAgCC,8BAAS;AA7BX,wEAAwE;AACxE,yDAAyD;AACzD,MAAM,MAAM,GAAG;IACb,OAAO,EAAP,iBAAO;IACP,KAAK,EAAE,mBAAS;IAChB,QAAQ,EAAE,iBAAO;IACjB,KAAK,EAAE,CAAC,GAAG,eAAS,EAAE,KAAK,CAAC;CAC7B,CAAA;AAuBC,wBAAM;AApBR,oEAAoE;AACpE,wDAAwD;AACxD,MAAM,MAAM,GAAG;IACb,OAAO,EAAP,iBAAO;IACP,KAAK,EAAE,mBAAS;CACjB,CAAA;AAgBC,wBAAM;AAbR,wEAAwE;AACxE,8BAA8B;AAC9B,MAAM,SAAS,GAAG;IAChB,IAAI,EAAE,uBAAY;IAClB,MAAM,EAAE,yBAAc;IACtB,IAAI,EAAE,wBAAa;IACnB,MAAM,EAAE,iBAAM;CACf,CAAA;AAOC,8BAAS"}
@@ -12,5 +12,6 @@ declare const env_gen: (model: any, spec: {
12
12
  folder: string;
13
13
  tm?: string;
14
14
  src?: string;
15
+ root?: string;
15
16
  }) => Promise<void>;
16
17
  export { env_gen, ENV_FILES, ENV_SRC, KINDS, };
@@ -21,6 +21,7 @@ const path_1 = __importDefault(require("path"));
21
21
  const util_1 = require("@voxgig/util");
22
22
  const conf_1 = require("../shape/conf");
23
23
  const generate_1 = require("./lambda/generate");
24
+ const web_gen_1 = require("./web/web_gen");
24
25
  // The supported environment kinds and the files each generates.
25
26
  // `out` may contain $$slots$$ (rendered with the same slot values).
26
27
  const ENV_FILES = {
@@ -97,6 +98,16 @@ const env_gen = async (model, spec) => {
97
98
  continue;
98
99
  }
99
100
  const kind = def.kind || name;
101
+ // The 'web' kind is the frontend SPA (+ backend web runner + auth),
102
+ // not a flat deployment-artifact set - delegate to EnvWeb.
103
+ if ('web' === kind) {
104
+ if (null == spec.root) {
105
+ throw new Error('@voxgig/build: env kind "web" needs spec.root ' +
106
+ '(the project root); pass it from the build action');
107
+ }
108
+ await (0, web_gen_1.web_gen)(model, { root: spec.root, tm: spec.tm, env: def });
109
+ continue;
110
+ }
100
111
  const filedefs = ENV_FILES[kind];
101
112
  if (null == filedefs) {
102
113
  throw new Error('@voxgig/build: unknown environment kind: ' + kind +
@@ -1 +1 @@
1
- {"version":3,"file":"env_gen.js","sourceRoot":"","sources":["../../env/env_gen.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"env_gen.js","sourceRoot":"","sources":["../../env/env_gen.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ declare const WEB_FILES: {
2
+ frag: string;
3
+ out: string;
4
+ }[];
5
+ declare const DEFAULT_USERS: {
6
+ name: string;
7
+ email: string;
8
+ password: string;
9
+ }[];
10
+ declare const web_gen: (model: any, spec: {
11
+ root: string;
12
+ tm?: string;
13
+ env?: any;
14
+ force?: boolean;
15
+ }) => Promise<{
16
+ created: string[];
17
+ skipped: string[];
18
+ }>;
19
+ export { web_gen, WEB_FILES, DEFAULT_USERS, };
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /* Copyright © 2026 Voxgig Ltd, MIT License. */
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.DEFAULT_USERS = exports.WEB_FILES = exports.web_gen = void 0;
8
+ // EnvWeb: generate the experimental web frontend - a Single Page App built
9
+ // from web components on a client-side Seneca service bus (no React/Vue) -
10
+ // plus the backend web runner and auth service it needs.
11
+ //
12
+ // Everything EnvWeb generates is CREATE-ONCE: a project owns its frontend
13
+ // after generation (the SPA is a starting point users heavily customize).
14
+ // Fragments live in @voxgig/build tm/web/ and are project-shadowable via
15
+ // spec.tm (the project's tm/web folder), like every other template.
16
+ //
17
+ // The entity admin is model-driven at RUNTIME (it fetches the compiled
18
+ // model.json), so the generated SPA adapts to any model with no
19
+ // per-entity code generation.
20
+ const fs_1 = __importDefault(require("fs"));
21
+ const path_1 = __importDefault(require("path"));
22
+ const util_1 = require("@voxgig/util");
23
+ const conf_1 = require("../../shape/conf");
24
+ const generate_1 = require("../lambda/generate");
25
+ // File manifest: fragment (relative to tm/web) -> output path (relative to
26
+ // the project root, which holds backend/ and web/). $$slots$$ in the
27
+ // output path are rendered too.
28
+ const WEB_FILES = [
29
+ // Frontend SPA (web/)
30
+ { frag: 'web/package.json.frag', out: 'web/package.json' },
31
+ { frag: 'web/index.html.frag', out: 'web/index.html' },
32
+ { frag: 'web/playwright.config.js.frag', out: 'web/playwright.config.js' },
33
+ { frag: 'web/src/main.js.frag', out: 'web/src/main.js' },
34
+ { frag: 'web/src/bus.js.frag', out: 'web/src/bus.js' },
35
+ { frag: 'web/src/style.css.frag', out: 'web/src/style.css' },
36
+ { frag: 'web/src/cmp/app.js.frag', out: 'web/src/cmp/app.js' },
37
+ { frag: 'web/src/cmp/auth.js.frag', out: 'web/src/cmp/auth.js' },
38
+ { frag: 'web/src/cmp/admin.js.frag', out: 'web/src/cmp/admin.js' },
39
+ { frag: 'web/e2e/smoke.spec.js.frag', out: 'web/e2e/smoke.spec.js' },
40
+ // Backend web runner + auth service (backend/src/...)
41
+ { frag: 'backend/env/web/web.ts.frag', out: 'backend/src/env/web/web.ts' },
42
+ { frag: 'backend/srv/auth/auth-srv.ts.frag', out: 'backend/src/srv/auth/auth-srv.ts' },
43
+ { frag: 'backend/srv/auth/get_info.ts.frag', out: 'backend/src/srv/auth/get_info.ts' },
44
+ { frag: 'backend/srv/auth/signin_user.ts.frag', out: 'backend/src/srv/auth/signin_user.ts' },
45
+ { frag: 'backend/srv/auth/signout_user.ts.frag', out: 'backend/src/srv/auth/signout_user.ts' },
46
+ { frag: 'backend/srv/auth/load_auth.ts.frag', out: 'backend/src/srv/auth/load_auth.ts' },
47
+ { frag: 'backend/srv/auth/web_signin_user.ts.frag', out: 'backend/src/srv/auth/web_signin_user.ts' },
48
+ { frag: 'backend/srv/auth/web_signout_user.ts.frag', out: 'backend/src/srv/auth/web_signout_user.ts' },
49
+ { frag: 'backend/srv/auth/web_load_auth.ts.frag', out: 'backend/src/srv/auth/web_load_auth.ts' },
50
+ ];
51
+ exports.WEB_FILES = WEB_FILES;
52
+ // Default seeded users (local testing only), rendered into the runner.
53
+ const DEFAULT_USERS = [
54
+ { name: 'Alice Example', email: 'alice@example.com', password: 'alice-pass-01' },
55
+ { name: 'Bob Example', email: 'bob@example.com', password: 'bob-pass-01' },
56
+ ];
57
+ exports.DEFAULT_USERS = DEFAULT_USERS;
58
+ // web_gen(model, spec): generate the frontend + backend web pieces into
59
+ // the project. spec.root is the project root (parent of backend/); files
60
+ // that already exist are left untouched (create-once).
61
+ const web_gen = async (model, spec) => {
62
+ const core = (0, conf_1.CoreConfShape)(model.main.conf.core);
63
+ const envdef = spec.env || {};
64
+ const users = envdef.users || DEFAULT_USERS;
65
+ const seed = users[0] || DEFAULT_USERS[0];
66
+ const port = envdef.port ||
67
+ (model.main.conf.port && model.main.conf.port.backend) || 8080;
68
+ const slots = {
69
+ name: core.name,
70
+ Name: (0, util_1.camelify)(core.name),
71
+ users: JSON.stringify(users, null, 2),
72
+ e2eport: envdef.e2eport || (port + 10),
73
+ seedEmail: seed.email,
74
+ seedPassword: seed.password,
75
+ };
76
+ const created = [];
77
+ const skipped = [];
78
+ for (const fd of WEB_FILES) {
79
+ const outrel = (0, generate_1.renderFragment)(fd.out, slots);
80
+ const dest = path_1.default.join(spec.root, outrel);
81
+ if (fs_1.default.existsSync(dest) && !spec.force) {
82
+ skipped.push(outrel);
83
+ continue;
84
+ }
85
+ const content = (0, generate_1.renderFragment)((0, generate_1.loadFragment)(fd.frag, spec, 'web'), slots);
86
+ fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
87
+ fs_1.default.writeFileSync(dest, content);
88
+ created.push(outrel);
89
+ }
90
+ return { created: created.sort(), skipped: skipped.sort() };
91
+ };
92
+ exports.web_gen = web_gen;
93
+ //# sourceMappingURL=web_gen.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web_gen.js","sourceRoot":"","sources":["../../../env/web/web_gen.ts"],"names":[],"mappings":"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"}
package/env/env_gen.ts CHANGED
@@ -20,6 +20,7 @@ import { camelify } from '@voxgig/util'
20
20
  import { CoreConfShape } from '../shape/conf'
21
21
 
22
22
  import { generate, loadFragment, renderFragment } from './lambda/generate'
23
+ import { web_gen } from './web/web_gen'
23
24
 
24
25
 
25
26
  // The supported environment kinds and the files each generates.
@@ -93,6 +94,8 @@ const env_gen = async (model: any, spec: {
93
94
  tm?: string // project tm/env folder (fragment shadowing)
94
95
  src?: string // project src/env folder: runtime entries generated
95
96
  // here ONCE (existing files are never overwritten)
97
+ root?: string // project root (holds backend/ and web/) - required
98
+ // for the 'web' kind (full frontend generation)
96
99
  }) => {
97
100
 
98
101
  const core = CoreConfShape(model.main.conf.core)
@@ -107,6 +110,17 @@ const env_gen = async (model: any, spec: {
107
110
  }
108
111
 
109
112
  const kind = def.kind || name
113
+
114
+ // The 'web' kind is the frontend SPA (+ backend web runner + auth),
115
+ // not a flat deployment-artifact set - delegate to EnvWeb.
116
+ if ('web' === kind) {
117
+ if (null == spec.root) {
118
+ throw new Error('@voxgig/build: env kind "web" needs spec.root ' +
119
+ '(the project root); pass it from the build action')
120
+ }
121
+ await web_gen(model, { root: spec.root, tm: spec.tm, env: def })
122
+ continue
123
+ }
110
124
  const filedefs = ENV_FILES[kind]
111
125
  if (null == filedefs) {
112
126
  throw new Error('@voxgig/build: unknown environment kind: ' + kind +
@@ -0,0 +1,115 @@
1
+ /* Copyright © 2026 Voxgig Ltd, MIT License. */
2
+
3
+ // EnvWeb: generate the experimental web frontend - a Single Page App built
4
+ // from web components on a client-side Seneca service bus (no React/Vue) -
5
+ // plus the backend web runner and auth service it needs.
6
+ //
7
+ // Everything EnvWeb generates is CREATE-ONCE: a project owns its frontend
8
+ // after generation (the SPA is a starting point users heavily customize).
9
+ // Fragments live in @voxgig/build tm/web/ and are project-shadowable via
10
+ // spec.tm (the project's tm/web folder), like every other template.
11
+ //
12
+ // The entity admin is model-driven at RUNTIME (it fetches the compiled
13
+ // model.json), so the generated SPA adapts to any model with no
14
+ // per-entity code generation.
15
+
16
+ import Fs from 'fs'
17
+ import Path from 'path'
18
+
19
+ import { camelify } from '@voxgig/util'
20
+
21
+ import { CoreConfShape } from '../../shape/conf'
22
+
23
+ import { loadFragment, renderFragment } from '../lambda/generate'
24
+
25
+
26
+ // File manifest: fragment (relative to tm/web) -> output path (relative to
27
+ // the project root, which holds backend/ and web/). $$slots$$ in the
28
+ // output path are rendered too.
29
+ const WEB_FILES: { frag: string, out: string }[] = [
30
+ // Frontend SPA (web/)
31
+ { frag: 'web/package.json.frag', out: 'web/package.json' },
32
+ { frag: 'web/index.html.frag', out: 'web/index.html' },
33
+ { frag: 'web/playwright.config.js.frag', out: 'web/playwright.config.js' },
34
+ { frag: 'web/src/main.js.frag', out: 'web/src/main.js' },
35
+ { frag: 'web/src/bus.js.frag', out: 'web/src/bus.js' },
36
+ { frag: 'web/src/style.css.frag', out: 'web/src/style.css' },
37
+ { frag: 'web/src/cmp/app.js.frag', out: 'web/src/cmp/app.js' },
38
+ { frag: 'web/src/cmp/auth.js.frag', out: 'web/src/cmp/auth.js' },
39
+ { frag: 'web/src/cmp/admin.js.frag', out: 'web/src/cmp/admin.js' },
40
+ { frag: 'web/e2e/smoke.spec.js.frag', out: 'web/e2e/smoke.spec.js' },
41
+
42
+ // Backend web runner + auth service (backend/src/...)
43
+ { frag: 'backend/env/web/web.ts.frag', out: 'backend/src/env/web/web.ts' },
44
+ { frag: 'backend/srv/auth/auth-srv.ts.frag', out: 'backend/src/srv/auth/auth-srv.ts' },
45
+ { frag: 'backend/srv/auth/get_info.ts.frag', out: 'backend/src/srv/auth/get_info.ts' },
46
+ { frag: 'backend/srv/auth/signin_user.ts.frag', out: 'backend/src/srv/auth/signin_user.ts' },
47
+ { frag: 'backend/srv/auth/signout_user.ts.frag', out: 'backend/src/srv/auth/signout_user.ts' },
48
+ { frag: 'backend/srv/auth/load_auth.ts.frag', out: 'backend/src/srv/auth/load_auth.ts' },
49
+ { frag: 'backend/srv/auth/web_signin_user.ts.frag', out: 'backend/src/srv/auth/web_signin_user.ts' },
50
+ { frag: 'backend/srv/auth/web_signout_user.ts.frag', out: 'backend/src/srv/auth/web_signout_user.ts' },
51
+ { frag: 'backend/srv/auth/web_load_auth.ts.frag', out: 'backend/src/srv/auth/web_load_auth.ts' },
52
+ ]
53
+
54
+
55
+ // Default seeded users (local testing only), rendered into the runner.
56
+ const DEFAULT_USERS = [
57
+ { name: 'Alice Example', email: 'alice@example.com', password: 'alice-pass-01' },
58
+ { name: 'Bob Example', email: 'bob@example.com', password: 'bob-pass-01' },
59
+ ]
60
+
61
+
62
+ // web_gen(model, spec): generate the frontend + backend web pieces into
63
+ // the project. spec.root is the project root (parent of backend/); files
64
+ // that already exist are left untouched (create-once).
65
+ const web_gen = async (model: any, spec: {
66
+ root: string // project root (holds backend/ and web/)
67
+ tm?: string // project tm/web folder (fragment shadowing)
68
+ env?: any // the model env entry (config: port, users)
69
+ force?: boolean // overwrite existing files (default: create-once)
70
+ }) => {
71
+
72
+ const core = CoreConfShape(model.main.conf.core)
73
+ const envdef = spec.env || {}
74
+
75
+ const users = envdef.users || DEFAULT_USERS
76
+ const seed = users[0] || DEFAULT_USERS[0]
77
+ const port = envdef.port ||
78
+ (model.main.conf.port && model.main.conf.port.backend) || 8080
79
+
80
+ const slots: Record<string, any> = {
81
+ name: core.name,
82
+ Name: camelify(core.name),
83
+ users: JSON.stringify(users, null, 2),
84
+ e2eport: envdef.e2eport || (port + 10),
85
+ seedEmail: seed.email,
86
+ seedPassword: seed.password,
87
+ }
88
+
89
+ const created: string[] = []
90
+ const skipped: string[] = []
91
+
92
+ for (const fd of WEB_FILES) {
93
+ const outrel = renderFragment(fd.out, slots)
94
+ const dest = Path.join(spec.root, outrel)
95
+
96
+ if (Fs.existsSync(dest) && !spec.force) {
97
+ skipped.push(outrel)
98
+ continue
99
+ }
100
+
101
+ const content = renderFragment(loadFragment(fd.frag, spec, 'web'), slots)
102
+ Fs.mkdirSync(Path.dirname(dest), { recursive: true })
103
+ Fs.writeFileSync(dest, content)
104
+ created.push(outrel)
105
+ }
106
+
107
+ return { created: created.sort(), skipped: skipped.sort() }
108
+ }
109
+
110
+
111
+ export {
112
+ web_gen,
113
+ WEB_FILES,
114
+ DEFAULT_USERS,
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxgig/build",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "main": "dist/build.js",
5
5
  "type": "commonjs",
6
6
  "types": "dist/build.d.ts",
@@ -0,0 +1,120 @@
1
+
2
+ // Local web runner: the local in-memory backend plus an HTTP layer for
3
+ // the SPA - express serving web/dist, the model, and a single seneca
4
+ // gateway endpoint (/seneca) that the browser-side Seneca bus posts
5
+ // messages to (seneca-browser fetch transport). Cookie auth via
6
+ // @seneca/gateway-auth (express_cookie): signin sets the todo-auth
7
+ // cookie, after which the gateway attaches the principal so @seneca/owner
8
+ // scopes todos per user.
9
+ //
10
+ // Predefined users are seeded at startup (local testing only).
11
+
12
+ import Path from 'node:path'
13
+
14
+ import Express from 'express'
15
+ import CookieParser from 'cookie-parser'
16
+
17
+ import Seneca from 'seneca'
18
+ import { Local } from '@voxgig/system'
19
+
20
+ import { basic, base } from '../shared/basic'
21
+
22
+ import Pkg from '../../../package.json'
23
+ import Model from '../../../model/model.json'
24
+
25
+
26
+ const PORT = parseInt(process.env.PORT || '', 10) ||
27
+ (Model as any).main.conf.port.backend
28
+
29
+ // Predefined users seeded at startup (local testing only). Edit freely.
30
+ const USERS = $$users$$
31
+
32
+
33
+ run()
34
+
35
+
36
+ async function run() {
37
+ const { deep } = Seneca.util
38
+
39
+ const seneca = Seneca(deep(base.seneca, { tag: '$$name$$-web' }))
40
+
41
+ seneca.context.model = Model
42
+ seneca.context.env = 'local'
43
+ seneca.context.stage = 'local'
44
+ seneca.context.srvname = 'all'
45
+ seneca.context.pkg = Pkg
46
+
47
+ seneca.test()
48
+
49
+ basic(seneca)
50
+
51
+ seneca
52
+ .use('gateway', {
53
+ // Allow auth + every service the model declares (aim:<srv>).
54
+ allow: Object.keys((Model as any).main.srv).reduce(
55
+ (a: any, n: string) => (a['aim:' + n] = true, a),
56
+ { 'aim:req,on:auth': true }),
57
+ })
58
+ .use('gateway-express', {
59
+ // gateway-express sets/clears the auth cookie when a result carries
60
+ // gateway$.auth (see web_signin_user / web_signout_user). Cookie
61
+ // attributes use the plugin defaults (httpOnly, sameSite).
62
+ auth: {
63
+ token: {
64
+ name: '$$name$$-auth',
65
+ },
66
+ },
67
+ })
68
+ .use('gateway-auth', {
69
+ spec: {
70
+ express_cookie: {
71
+ active: true,
72
+ token: {
73
+ name: '$$name$$-auth',
74
+ },
75
+ user: {
76
+ auth: true,
77
+ require: false,
78
+ },
79
+ },
80
+ },
81
+ })
82
+
83
+ seneca.use(Local, {
84
+ srv: {
85
+ folder: __dirname + '/../../../dist/srv',
86
+ },
87
+ })
88
+
89
+ await seneca.ready()
90
+
91
+ // Seed the predefined users (idempotent per boot: in-memory store).
92
+ for (const u of USERS) {
93
+ const res = await seneca.post('sys:user,register:user', u)
94
+ if (!res.ok) {
95
+ console.log('SEED-USER-FAILED', u.email, res.why)
96
+ }
97
+ }
98
+
99
+ const app = Express()
100
+ // web/ is a sibling of backend/; from dist/env/local go up to the
101
+ // project root, then web/dist.
102
+ const webdist = Path.join(
103
+ __dirname, '..', '..', '..', '..', 'web', 'dist')
104
+
105
+ app
106
+ .use(Express.json())
107
+ .use(new (CookieParser as any)())
108
+ .post('/seneca', seneca.export('gateway-express/handler'))
109
+ .get('/model.json',
110
+ (_req: any, res: any) => res.sendFile(
111
+ Path.join(__dirname, '..', '..', '..', 'model', 'model.json')))
112
+ .use(Express.static(webdist))
113
+ .listen(PORT)
114
+
115
+ console.log('$$name$$-web started', {
116
+ port: PORT,
117
+ users: USERS.map((u) => u.email),
118
+ version: Pkg.version,
119
+ })
120
+ }
@@ -0,0 +1,4 @@
1
+
2
+ import { MakeSrv } from '@voxgig/system'
3
+
4
+ module.exports = MakeSrv('auth', require)
@@ -0,0 +1,5 @@
1
+ module.exports = function make_get_info() {
2
+ return async function get_info(this: any, _msg: any) {
3
+ return { ok: true, srv: 'auth' }
4
+ }
5
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = function make_load_auth() {
2
+ return async function load_auth(this: any, msg: any) {
3
+ const user = await this.entity('sys/user').load$(msg.user_id)
4
+ return { ok: !!user, state: user ? 'signedin' : 'signedout', user }
5
+ }
6
+ }
@@ -0,0 +1,14 @@
1
+ // Sign a user in via @seneca/user login (email + password).
2
+ module.exports = function make_signin_user() {
3
+ return async function signin_user(this: any, msg: any) {
4
+ const seneca = this
5
+
6
+ const out = await seneca.post('sys:user,login:user', {
7
+ email: msg.email,
8
+ password: msg.password,
9
+ fields: ['id', 'email', 'name', 'handle'],
10
+ })
11
+
12
+ return out
13
+ }
14
+ }
@@ -0,0 +1,5 @@
1
+ module.exports = function make_signout_user() {
2
+ return async function signout_user(this: any, msg: any) {
3
+ return this.post('sys:user,logout:user', { token: msg.token })
4
+ }
5
+ }
@@ -0,0 +1,12 @@
1
+ // Current auth state, from the principal the gateway resolved (cookie).
2
+ module.exports = function make_web_load_auth() {
3
+ return async function web_load_auth(this: any, _msg: any, meta: any) {
4
+ const user = meta.custom?.principal?.user
5
+ const out: any = { ok: true, state: 'signedout' }
6
+ if (user) {
7
+ out.state = 'signedin'
8
+ out.user = { id: user.id, email: user.email, handle: user.handle }
9
+ }
10
+ return out
11
+ }
12
+ }
@@ -0,0 +1,25 @@
1
+ // Gateway signin: on success the gateway$ auth token result field makes
2
+ // gateway-auth (express_cookie) set the auth cookie.
3
+ module.exports = function make_web_signin_user() {
4
+ return async function web_signin_user(this: any, msg: any) {
5
+ const res = await this.post('aim:auth,signin:user', {
6
+ email: msg.email,
7
+ password: msg.password,
8
+ })
9
+
10
+ const out: any = { ok: res.ok }
11
+ if (res.ok) {
12
+ out.gateway$ = { auth: { token: res.login.token } }
13
+ out.user = {
14
+ id: res.user.id,
15
+ email: res.user.email,
16
+ name: res.user.name,
17
+ handle: res.user.handle,
18
+ }
19
+ }
20
+ else {
21
+ out.why = res.why
22
+ }
23
+ return out
24
+ }
25
+ }
@@ -0,0 +1,9 @@
1
+ module.exports = function make_web_signout_user() {
2
+ return async function web_signout_user(this: any, _msg: any, meta: any) {
3
+ const token = meta.custom?.principal?.token
4
+ if (token) {
5
+ await this.post('aim:auth,signout:user', { token })
6
+ }
7
+ return { ok: true, gateway$: { auth: { remove: true } } }
8
+ }
9
+ }
@@ -0,0 +1,21 @@
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ // Generic smoke test: the SPA boots, a seeded user can sign in, and the
4
+ // model-driven entity admin renders with at least one entity. Add
5
+ // project-specific CRUD flows alongside this file.
6
+
7
+ test('sign in and load the entity admin', async ({ page }) => {
8
+ await page.goto('/')
9
+
10
+ await expect(page.locator('vg-auth h2')).toHaveText('Sign in')
11
+
12
+ await page.fill('input[name=email]', '$$seedEmail$$')
13
+ await page.fill('input[name=password]', '$$seedPassword$$')
14
+ await page.click('vg-auth button[type=submit]')
15
+
16
+ await expect(page.locator('.vg-auth-bar b')).toHaveText('$$seedEmail$$')
17
+ await expect(page.locator('vg-entity-admin nav a')).not.toHaveCount(0)
18
+
19
+ // The first entity's list loads (count line rendered).
20
+ await expect(page.locator('#vg-count')).toBeVisible()
21
+ })
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>$$Name$$</title>
7
+ <script src="/seneca-browser.js"></script>
8
+ </head>
9
+ <body>
10
+ <vg-app></vg-app>
11
+ <script type="module" src="/src/main.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "$$name$$-web",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "$$Name$$ SPA: web components on a Seneca service bus",
6
+ "scripts": {
7
+ "postinstall": "node -e \"require('fs').copyFileSync(require.resolve('seneca-browser'),'public/seneca-browser.js')\"",
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "e2e": "playwright test"
11
+ },
12
+ "devDependencies": {
13
+ "@playwright/test": "^1.58.2",
14
+ "seneca-browser": "^8.0.0-rc4",
15
+ "vite": "^7.3.4"
16
+ }
17
+ }
@@ -0,0 +1,36 @@
1
+ // Headless-chrome E2E for the $$Name$$ SPA. The backend web runner (which
2
+ // serves the built SPA and the /seneca gateway) is started as the test
3
+ // webServer; Playwright waits for it, then drives the SPA.
4
+ import { defineConfig, devices } from '@playwright/test'
5
+
6
+ const PORT = $$e2eport$$
7
+
8
+ export default defineConfig({
9
+ testDir: './e2e',
10
+ timeout: 30000,
11
+ fullyParallel: false,
12
+ reporter: [['list']],
13
+ use: {
14
+ baseURL: `http://localhost:${PORT}`,
15
+ trace: 'off',
16
+ },
17
+ projects: [
18
+ {
19
+ name: 'chromium',
20
+ use: {
21
+ ...devices['Desktop Chrome'],
22
+ // Self-contained chromium (no system chrome / no desktop): install
23
+ // with `npx playwright install chromium` (no --with-deps).
24
+ launchOptions: { args: ['--no-sandbox'] },
25
+ },
26
+ },
27
+ ],
28
+ webServer: {
29
+ command: `npm run build && cd ../backend && PORT=${PORT} node dist/env/web/web.js`,
30
+ url: `http://localhost:${PORT}/`,
31
+ timeout: 60000,
32
+ reuseExistingServer: false,
33
+ stdout: 'ignore',
34
+ stderr: 'pipe',
35
+ },
36
+ })
@@ -0,0 +1,61 @@
1
+ // The browser-side Seneca service bus. All component state and all
2
+ // backend communication flow through messages on this bus:
3
+ //
4
+ // aim:* -> the backend gateway (/seneca) via the
5
+ // seneca-browser fetch transport (cookie auth)
6
+ // cmp:* -> local component plugins (state)
7
+ //
8
+ // Cross-component notification uses the tiny `evt` plugin below:
9
+ // components post cmp:bus,emit:<name> and subscribe with onEvent().
10
+
11
+ /* global Seneca */
12
+
13
+ const bus = Seneca({
14
+ legacy: false,
15
+ timeout: 8888,
16
+ plugin: {
17
+ browser: {
18
+ endpoint: '/seneca',
19
+ fetch: {
20
+ credentials: 'same-origin',
21
+ },
22
+ },
23
+ },
24
+ })
25
+ .test()
26
+ .client({ type: 'browser', pin: 'aim:*' })
27
+
28
+
29
+ // Event fanout: seneca actions have single handlers, so the bus plugin
30
+ // keeps a listener registry for UI notification.
31
+ const listeners = {}
32
+
33
+ bus.use(function evt() {
34
+ this.add('cmp:bus,emit:evt', function (msg, reply) {
35
+ const subs = listeners[msg.name] || []
36
+ for (const fn of subs) {
37
+ try {
38
+ fn(msg.data || {})
39
+ }
40
+ catch (e) {
41
+ console.error('evt listener failed', msg.name, e)
42
+ }
43
+ }
44
+ reply({ ok: true, count: subs.length })
45
+ })
46
+ })
47
+
48
+ function onEvent(name, fn) {
49
+ (listeners[name] = listeners[name] || []).push(fn)
50
+ }
51
+
52
+ function emit(name, data) {
53
+ bus.post('cmp:bus,emit:evt', { name, data })
54
+ }
55
+
56
+
57
+ export {
58
+ bus,
59
+ onEvent,
60
+ emit,
61
+ }
@@ -0,0 +1,254 @@
1
+ // Entity admin: a django-admin-style generic CRUD UI, driven entirely by
2
+ // the compiled model (fetched from /model.json). The entity_admin plugin
3
+ // owns the model + per-entity message mapping (state); the
4
+ // <vg-entity-admin> component renders nav / list / form and talks to the
5
+ // plugin (and through it, the backend) only via bus messages.
6
+
7
+ import { bus, emit } from '../bus.js'
8
+
9
+
10
+ // Fields managed by the system, not the form.
11
+ const SYS_FIELDS = ['id', 'owner_id', 't_c', 't_m', 't_mh', 't_ch']
12
+
13
+
14
+ bus.use(function entity_admin() {
15
+ const seneca = this
16
+
17
+ let model = null
18
+
19
+ // Entities from the model: [{ zone, name, canon, fields }]
20
+ function entities() {
21
+ const out = []
22
+ const ent = (model && model.main.ent) || {}
23
+ for (const zone of Object.keys(ent)) {
24
+ if ('sys' === zone) {
25
+ continue
26
+ }
27
+ for (const name of Object.keys(ent[zone])) {
28
+ out.push({
29
+ zone,
30
+ name,
31
+ canon: zone + '/' + name,
32
+ fields: ent[zone][name].field || {},
33
+ })
34
+ }
35
+ }
36
+ return out
37
+ }
38
+
39
+ // Message mapping convention: entity zone/name -> aim:<zone>,<verb>:<name>.
40
+ function msgFor(canon, verb) {
41
+ const [zone, name] = canon.split('/')
42
+ return { aim: zone, [verb]: name }
43
+ }
44
+
45
+ seneca.add('cmp:entity_admin,load:model', function (msg, reply) {
46
+ if (model) {
47
+ return reply({ ok: true, entities: entities() })
48
+ }
49
+ fetch('/model.json').then((r) => r.json()).then((m) => {
50
+ model = m
51
+ reply({ ok: true, entities: entities() })
52
+ }).catch((e) => reply({ ok: false, why: String(e) }))
53
+ })
54
+
55
+ seneca.add('cmp:entity_admin,list:item', function (msg, reply) {
56
+ this.act(msgFor(msg.canon, 'list'), function (err, out) {
57
+ reply(err ? { ok: false, why: err.message } : out)
58
+ })
59
+ })
60
+
61
+ seneca.add('cmp:entity_admin,save:item', function (msg, reply) {
62
+ this.act({ ...msgFor(msg.canon, 'save'), item: msg.item },
63
+ function (err, out) {
64
+ if (!err && out.ok) {
65
+ emit('entity', { canon: msg.canon })
66
+ }
67
+ reply(err ? { ok: false, why: err.message } : out)
68
+ })
69
+ })
70
+
71
+ seneca.add('cmp:entity_admin,remove:item', function (msg, reply) {
72
+ this.act({ ...msgFor(msg.canon, 'remove'), id: msg.id },
73
+ function (err, out) {
74
+ if (!err && out.ok) {
75
+ emit('entity', { canon: msg.canon })
76
+ }
77
+ reply(err ? { ok: false, why: err.message } : out)
78
+ })
79
+ })
80
+ })
81
+
82
+
83
+ function inputFor(fname, fdef, value) {
84
+ const kind = (fdef && fdef.kind) || 'String'
85
+ const val = null == value ? '' : value
86
+ if ('Boolean' === kind) {
87
+ return `<input name="${fname}" type="checkbox" ${true === value ? 'checked' : ''} />`
88
+ }
89
+ if ('Number' === kind) {
90
+ return `<input name="${fname}" type="number" value="${val}" />`
91
+ }
92
+ return `<input name="${fname}" type="text" value="${String(val).replace(/"/g, '&quot;')}" />`
93
+ }
94
+
95
+
96
+ class VgEntityAdmin extends HTMLElement {
97
+ constructor() {
98
+ super()
99
+ this.canon = null
100
+ this.editing = null // item being edited, or {} for new
101
+ }
102
+
103
+ connectedCallback() {
104
+ this.render()
105
+ }
106
+
107
+ async render() {
108
+ const res = await bus.post('cmp:entity_admin,load:model')
109
+ if (!res.ok) {
110
+ this.innerHTML = `<p>model load failed: ${res.why}</p>`
111
+ return
112
+ }
113
+ this.entities = res.entities
114
+ if (null == this.canon && 0 < res.entities.length) {
115
+ this.canon = res.entities[0].canon
116
+ }
117
+
118
+ const nav = res.entities.map((e) =>
119
+ `<a href="#" class="vg-nav ${e.canon === this.canon ? 'sel' : ''}"
120
+ data-canon="${e.canon}">${e.canon}</a>`).join(' ')
121
+
122
+ this.innerHTML = `
123
+ <div class="vg-admin">
124
+ <nav>${nav}</nav>
125
+ <div id="vg-list"></div>
126
+ <div id="vg-form"></div>
127
+ </div>`
128
+
129
+ for (const a of this.querySelectorAll('.vg-nav')) {
130
+ a.onclick = (ev) => {
131
+ ev.preventDefault()
132
+ this.canon = a.dataset.canon
133
+ this.editing = null
134
+ this.render()
135
+ }
136
+ }
137
+
138
+ await this.renderList()
139
+ this.renderForm()
140
+ }
141
+
142
+ entdef() {
143
+ return this.entities.find((e) => e.canon === this.canon)
144
+ }
145
+
146
+ async renderList() {
147
+ const out = await bus.post('cmp:entity_admin,list:item',
148
+ { canon: this.canon })
149
+ const items = (out.ok && out.list) || []
150
+ const fields = Object.keys(this.entdef().fields)
151
+ .filter((f) => !SYS_FIELDS.includes(f))
152
+
153
+ const head = fields.map((f) => `<th>${f}</th>`).join('')
154
+ const rows = items.map((item) => `
155
+ <tr data-id="${item.id}">
156
+ ${fields.map((f) => `<td>${null == item[f] ? '' : item[f]}</td>`).join('')}
157
+ <td>
158
+ <button class="vg-edit" data-id="${item.id}">edit</button>
159
+ <button class="vg-del" data-id="${item.id}">delete</button>
160
+ </td>
161
+ </tr>`).join('')
162
+
163
+ this.querySelector('#vg-list').innerHTML = `
164
+ <h2>${this.canon} <button id="vg-new">new</button></h2>
165
+ <table class="vg-table">
166
+ <thead><tr>${head}<th></th></tr></thead>
167
+ <tbody>${rows}</tbody>
168
+ </table>
169
+ <p id="vg-count">${items.length} item${1 === items.length ? '' : 's'}</p>`
170
+
171
+ this.items = items
172
+ this.querySelector('#vg-new').onclick = () => {
173
+ this.editing = {}
174
+ this.renderForm()
175
+ }
176
+ for (const b of this.querySelectorAll('.vg-edit')) {
177
+ b.onclick = () => {
178
+ this.editing = this.items.find((i) => i.id === b.dataset.id)
179
+ this.renderForm()
180
+ }
181
+ }
182
+ for (const b of this.querySelectorAll('.vg-del')) {
183
+ b.onclick = async () => {
184
+ await bus.post('cmp:entity_admin,remove:item',
185
+ { canon: this.canon, id: b.dataset.id })
186
+ this.editing = null
187
+ await this.renderList()
188
+ this.renderForm()
189
+ }
190
+ }
191
+ }
192
+
193
+ renderForm() {
194
+ const box = this.querySelector('#vg-form')
195
+ if (null == this.editing) {
196
+ box.innerHTML = ''
197
+ return
198
+ }
199
+
200
+ const def = this.entdef()
201
+ const fields = Object.keys(def.fields)
202
+ .filter((f) => !SYS_FIELDS.includes(f))
203
+
204
+ box.innerHTML = `
205
+ <form class="vg-entity-form">
206
+ <h3>${this.editing.id ? 'Edit' : 'New'} ${this.canon}</h3>
207
+ ${fields.map((f) => `
208
+ <label>${(def.fields[f] && def.fields[f].label) || f}
209
+ ${inputFor(f, def.fields[f], this.editing[f])}
210
+ </label>`).join('')}
211
+ <button type="submit">Save</button>
212
+ <button type="button" id="vg-cancel">Cancel</button>
213
+ <div id="vg-form-err"></div>
214
+ </form>`
215
+
216
+ box.querySelector('#vg-cancel').onclick = () => {
217
+ this.editing = null
218
+ this.renderForm()
219
+ }
220
+
221
+ box.querySelector('form').onsubmit = async (ev) => {
222
+ ev.preventDefault()
223
+ const item = { ...(this.editing.id ? { id: this.editing.id } : {}) }
224
+ for (const f of fields) {
225
+ const input = box.querySelector(`[name="${f}"]`)
226
+ const kind = (def.fields[f] && def.fields[f].kind) || 'String'
227
+ if ('Boolean' === kind) {
228
+ item[f] = input.checked
229
+ }
230
+ else if ('' === input.value) {
231
+ // Blank optional field: leave unset (don't send an empty string;
232
+ // an optional/Skip field rejects '').
233
+ continue
234
+ }
235
+ else {
236
+ item[f] = 'Number' === kind ? Number(input.value) : input.value
237
+ }
238
+ }
239
+ const res = await bus.post('cmp:entity_admin,save:item',
240
+ { canon: this.canon, item })
241
+ if (!res.ok) {
242
+ box.querySelector('#vg-form-err').textContent =
243
+ 'save failed: ' + (res.why || '')
244
+ }
245
+ else {
246
+ this.editing = null
247
+ await this.renderList()
248
+ this.renderForm()
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ customElements.define('vg-entity-admin', VgEntityAdmin)
@@ -0,0 +1,30 @@
1
+ // App shell: shows the auth component always; the entity admin only when
2
+ // signed in. Reacts to bus auth events.
3
+
4
+ import { bus, onEvent } from '../bus.js'
5
+
6
+
7
+ class VgApp extends HTMLElement {
8
+ connectedCallback() {
9
+ this.innerHTML = `
10
+ <header><h1>$$Name$$</h1><vg-auth></vg-auth></header>
11
+ <main id="vg-main"></main>`
12
+
13
+ onEvent('auth', ({ user }) => this.setMain(user))
14
+
15
+ // Resolve the cookie session on load.
16
+ bus.post('cmp:auth,load:state')
17
+ }
18
+
19
+ setMain(user) {
20
+ const main = this.querySelector('#vg-main')
21
+ if (user) {
22
+ main.innerHTML = '<vg-entity-admin></vg-entity-admin>'
23
+ }
24
+ else {
25
+ main.innerHTML = '<p class="vg-hint">Sign in to manage your data.</p>'
26
+ }
27
+ }
28
+ }
29
+
30
+ customElements.define('vg-app', VgApp)
@@ -0,0 +1,97 @@
1
+ // Auth: a seneca plugin holding the signed-in principal, and the
2
+ // <vg-auth> web component (sign-in form / signed-in bar) driving it
3
+ // purely through bus messages.
4
+
5
+ import { bus, emit } from '../bus.js'
6
+
7
+
8
+ bus.use(function auth_cmp() {
9
+ const seneca = this
10
+
11
+ let user = null
12
+
13
+ seneca.add('cmp:auth,get:state', function (msg, reply) {
14
+ reply({ ok: true, user })
15
+ })
16
+
17
+ // Resolve current auth from the backend (cookie session).
18
+ seneca.add('cmp:auth,load:state', function (msg, reply) {
19
+ this.act('aim:req,on:auth,load:auth', function (err, out) {
20
+ user = (!err && out.ok && 'signedin' === out.state) ? out.user : null
21
+ emit('auth', { user })
22
+ reply({ ok: true, user })
23
+ })
24
+ })
25
+
26
+ seneca.add('cmp:auth,signin:user', function (msg, reply) {
27
+ this.act('aim:req,on:auth,signin:user', {
28
+ email: msg.email,
29
+ password: msg.password,
30
+ }, function (err, out) {
31
+ if (err || !out.ok) {
32
+ return reply({ ok: false, why: (out && out.why) || 'signin-failed' })
33
+ }
34
+ user = out.user
35
+ emit('auth', { user })
36
+ reply({ ok: true, user })
37
+ })
38
+ })
39
+
40
+ seneca.add('cmp:auth,signout:user', function (msg, reply) {
41
+ this.act('aim:req,on:auth,signout:user', function () {
42
+ user = null
43
+ emit('auth', { user })
44
+ reply({ ok: true })
45
+ })
46
+ })
47
+ })
48
+
49
+
50
+ class VgAuth extends HTMLElement {
51
+ connectedCallback() {
52
+ this.render()
53
+ }
54
+
55
+ async render() {
56
+ const state = await bus.post('cmp:auth,get:state')
57
+
58
+ if (state.user) {
59
+ this.innerHTML = `
60
+ <div class="vg-auth-bar">
61
+ <span>Signed in: <b>${state.user.email}</b></span>
62
+ <button id="vg-signout">Sign out</button>
63
+ </div>`
64
+ this.querySelector('#vg-signout').onclick = async () => {
65
+ await bus.post('cmp:auth,signout:user')
66
+ this.render()
67
+ }
68
+ }
69
+ else {
70
+ this.innerHTML = `
71
+ <form class="vg-auth-form">
72
+ <h2>Sign in</h2>
73
+ <label>Email <input name="email" type="email" required /></label>
74
+ <label>Password <input name="password" type="password" required /></label>
75
+ <button type="submit">Sign in</button>
76
+ <div class="vg-auth-err" id="vg-auth-err"></div>
77
+ </form>`
78
+ this.querySelector('form').onsubmit = async (ev) => {
79
+ ev.preventDefault()
80
+ const fd = new FormData(ev.target)
81
+ const res = await bus.post('cmp:auth,signin:user', {
82
+ email: fd.get('email'),
83
+ password: fd.get('password'),
84
+ })
85
+ if (!res.ok) {
86
+ this.querySelector('#vg-auth-err').textContent =
87
+ 'Sign in failed: ' + res.why
88
+ }
89
+ else {
90
+ this.render()
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ customElements.define('vg-auth', VgAuth)
@@ -0,0 +1,10 @@
1
+ // Todo SPA entry: web components on a Seneca service bus (experimental).
2
+ // No framework: each component is a custom element backed by a seneca
3
+ // plugin (state), and all data flows are bus messages - aim:* messages
4
+ // travel to the backend gateway via the seneca-browser fetch transport.
5
+
6
+ import './style.css'
7
+ import './bus.js'
8
+ import './cmp/auth.js'
9
+ import './cmp/admin.js'
10
+ import './cmp/app.js'
@@ -0,0 +1,26 @@
1
+ * { box-sizing: border-box; }
2
+ body { font: 15px/1.5 system-ui, sans-serif; margin: 0; color: #1c2733; }
3
+ header { display: flex; align-items: center; justify-content: space-between;
4
+ padding: 0.6rem 1.2rem; border-bottom: 2px solid #e3e8ee; }
5
+ header h1 { font-size: 1.2rem; margin: 0; }
6
+ main { padding: 1.2rem; max-width: 60rem; }
7
+
8
+ .vg-auth-bar { display: flex; gap: 0.8rem; align-items: center; }
9
+ .vg-auth-form { max-width: 22rem; display: grid; gap: 0.6rem; }
10
+ .vg-auth-form label { display: grid; gap: 0.2rem; }
11
+ .vg-auth-err { color: #b3261e; min-height: 1.2em; }
12
+
13
+ .vg-admin nav { margin-bottom: 1rem; }
14
+ .vg-admin nav a { margin-right: 0.8rem; text-decoration: none; }
15
+ .vg-admin nav a.sel { font-weight: 700; border-bottom: 2px solid #1c2733; }
16
+
17
+ .vg-table { border-collapse: collapse; width: 100%; }
18
+ .vg-table th, .vg-table td { text-align: left; padding: 0.35rem 0.6rem;
19
+ border-bottom: 1px solid #e3e8ee; }
20
+
21
+ .vg-entity-form { margin-top: 1.2rem; max-width: 26rem; display: grid;
22
+ gap: 0.6rem; padding: 1rem; border: 1px solid #e3e8ee; border-radius: 6px; }
23
+ .vg-entity-form label { display: grid; gap: 0.2rem; }
24
+
25
+ button { cursor: pointer; padding: 0.25rem 0.7rem; }
26
+ .vg-hint { color: #5b6b7b; }