@lantharos/pult 0.8.0 → 0.10.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/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as mapBlocks, C as HEADER, D as sign, O as verify, P as findActionDef, T as NonceWindow, a as collect, b as KIND, d as Transport, f as b, g as home, h as isPendingAttachment, i as validate, j as DEFAULT_STATES, k as eachBlock, m as fieldValues, n as reviveDates, o as findAction, p as bindActions, s as flag, t as ValidationError, u as PultError, v as page, w as refKey, x as captureOrigin, y as view } from "./schema-CzG8J_9_.mjs";
1
+ import { A as mapBlocks, D as sign, E as NonceWindow, F as HOME, I as actionId, L as findActionDef, M as valuesIn, N as DEFAULT_STATES, O as verify, S as kindOf, T as refKey, a as collect, b as captureOrigin, d as Transport, f as b, g as isPendingAttachment, h as hasPendingAttachments, i as validate, k as eachBlock, m as fieldValues, n as reviveDates, o as findAction, p as bindActions, s as flag, t as ValidationError, u as PultError, v as view, w as HEADER, y as KIND } from "./schema-DuTVNptF.mjs";
2
2
  //#region ../protocol/src/flags.ts
3
3
  function rampAt(ramp, now = Date.now()) {
4
4
  if (now >= ramp.end) return ramp.to;
@@ -15,7 +15,7 @@ function currentValue(state, now = Date.now()) {
15
15
  }
16
16
  function resolveFlag(name, state, context = {}, now = Date.now()) {
17
17
  const { key, attributes = {} } = context;
18
- if (key !== void 0 && key in state.targets) return state.targets[key];
18
+ if (key !== void 0 && Object.hasOwn(state.targets, key)) return state.targets[key];
19
19
  const rule = state.rules.find((candidate) => candidate.conditions.every((condition) => holds(condition, attributes[condition.attribute])));
20
20
  if (rule) return rule.rollout === null ? rule.value : share(name, key, rule.rollout);
21
21
  const rollout = currentRollout(state, now);
@@ -63,10 +63,11 @@ var Batcher = class {
63
63
  }
64
64
  send(item) {
65
65
  return new Promise((resolve, reject) => {
66
- const size = JSON.stringify(item).length;
66
+ const json = JSON.stringify(item);
67
+ const size = utf8Length(json);
67
68
  if (this.#bytes + size > MAX_BYTES) this.#flush();
68
69
  this.#queue.push({
69
- item,
70
+ json,
70
71
  size,
71
72
  resolve,
72
73
  reject
@@ -89,15 +90,15 @@ var Batcher = class {
89
90
  if (batch.length) this.#post(batch);
90
91
  }
91
92
  async #post(batch) {
92
- const body = { items: batch.map((entry) => entry.item) };
93
+ const body = `{"items":[${batch.map((entry) => entry.json).join(",")}]}`;
93
94
  try {
94
95
  let response;
95
96
  try {
96
- response = await this.transport.request("POST", "/v1/items", body);
97
+ response = await this.transport.json("POST", "/v1/items", body);
97
98
  } catch (error) {
98
99
  if (!(error instanceof PultError && error.code === "manifest_required")) throw error;
99
100
  await this.sync();
100
- response = await this.transport.request("POST", "/v1/items", body);
101
+ response = await this.transport.json("POST", "/v1/items", body);
101
102
  }
102
103
  response.results.forEach((outcome, index) => {
103
104
  const entry = batch[index];
@@ -109,8 +110,16 @@ var Batcher = class {
109
110
  }
110
111
  }
111
112
  };
113
+ function utf8Length(text) {
114
+ let bytes = text.length;
115
+ for (let index = 0; index < text.length; index++) {
116
+ const code = text.charCodeAt(index);
117
+ if (code >= 128) bytes += code < 2048 || code >= 55296 && code <= 57343 ? 1 : 2;
118
+ }
119
+ return bytes;
120
+ }
112
121
  //#endregion
113
- //#region src/define/inbox.ts
122
+ //#region src/define/inbox/inbox.ts
114
123
  function inbox(name, options) {
115
124
  const states = options.states ?? DEFAULT_STATES;
116
125
  const closed = options.closed ?? (options.states ? states.slice(1) : ["handled"]);
@@ -138,7 +147,7 @@ function liveView(title, data, record) {
138
147
  };
139
148
  }
140
149
  //#endregion
141
- //#region src/define/triage.ts
150
+ //#region src/define/inbox/triage.ts
142
151
  function noul(prompt, criteria) {
143
152
  return {
144
153
  type: "noul",
@@ -204,14 +213,14 @@ function action(name, options) {
204
213
  const identity = (value) => value;
205
214
  function resource(name, options) {
206
215
  const origin = captureOrigin();
207
- const actions = { "*": { id: `${name}.*` } };
216
+ const actions = { "*": { id: actionId(name, "*") } };
208
217
  for (const [key, action] of Object.entries(options.actions?.(identity) ?? {})) actions[key] = {
209
218
  [KIND]: "action",
210
219
  origin,
211
220
  options: action,
212
221
  resource: name,
213
222
  name: key,
214
- id: `${name}.${key}`
223
+ id: actionId(name, key)
215
224
  };
216
225
  return {
217
226
  [KIND]: "resource",
@@ -319,28 +328,35 @@ async function titles(definitions, request) {
319
328
  return Object.fromEntries(resolved.filter((entry) => entry !== null));
320
329
  }
321
330
  async function available(definitions, request) {
322
- const availability = {};
323
- for (const ref of request.refs) {
331
+ const entries = await Promise.all(request.refs.map(async (ref) => {
324
332
  const resource = definitions.resources.get(ref.resource);
325
333
  const record = resource && await resource.options.get(ref.id);
326
- if (!resource || !record) continue;
327
- const actions = {};
328
- for (const [name, action] of Object.entries(resource.actions)) {
329
- if (!("options" in action)) continue;
330
- const { when, defaults } = action.options;
331
- if (when && !when(record)) continue;
332
- actions[name] = defaults ? JSON.parse(JSON.stringify(defaults(record))) : null;
333
- }
334
- availability[`${ref.resource}:${ref.id}`] = actions;
334
+ return resource && record ? [[refKey(ref), actionsFor(resource, record)]] : [];
335
+ }));
336
+ return Object.fromEntries(entries.flat());
337
+ }
338
+ function actionsFor(resource, record) {
339
+ const actions = {};
340
+ for (const [name, action] of Object.entries(resource.actions)) {
341
+ if (kindOf(action) !== "action") continue;
342
+ const { when, defaults } = action.options;
343
+ if (when && !when(record)) continue;
344
+ actions[name] = defaults ? JSON.parse(JSON.stringify(defaults(record))) : null;
335
345
  }
336
- return availability;
346
+ return actions;
337
347
  }
338
348
  async function settle(definitions, blocks, scope) {
339
349
  const bound = scope ? bindActions(blocks, scope.resource, recordId(scope.resource, scope.record)) : blocks;
340
350
  const loaded = /* @__PURE__ */ new Map();
341
- if (scope) loaded.set(`${scope.resource.name}:${recordId(scope.resource, scope.record)}`, Promise.resolve(scope.record));
351
+ if (scope) loaded.set(refKey({
352
+ resource: scope.resource.name,
353
+ id: recordId(scope.resource, scope.record)
354
+ }), Promise.resolve(scope.record));
342
355
  const load = (resource, id) => {
343
- const key = `${resource.name}:${id}`;
356
+ const key = refKey({
357
+ resource: resource.name,
358
+ id
359
+ });
344
360
  if (!loaded.has(key)) loaded.set(key, Promise.resolve(resource.options.get(id)));
345
361
  return loaded.get(key);
346
362
  };
@@ -361,15 +377,10 @@ async function settle(definitions, blocks, scope) {
361
377
  const visit = (target) => {
362
378
  if (!targets.has(keyOf(target))) targets.set(keyOf(target), verdict(target));
363
379
  };
364
- const editsIn = (value) => {
365
- if (value && typeof value === "object" && "edit" in value) visit(value.edit);
366
- };
367
380
  eachBlock(bound, (block) => {
368
381
  if (block.type === "actions") for (const button of block.actions) visit(button);
369
382
  if (block.type === "form") visit(block.form);
370
- if (block.type === "fields") for (const field of block.fields) editsIn(field.value);
371
- if (block.type === "stats") for (const stat of block.stats) editsIn(stat.value);
372
- if (block.type === "table") for (const row of block.rows) for (const value of row) editsIn(value);
383
+ for (const value of valuesIn(block)) if (value && typeof value === "object" && "edit" in value) visit(value.edit);
373
384
  });
374
385
  if (!targets.size) return bound;
375
386
  const verdicts = new Map(await Promise.all([...targets].map(async ([key, pending]) => [key, await pending])));
@@ -482,7 +493,10 @@ var Dispatcher = class {
482
493
  this.report = report;
483
494
  }
484
495
  definitions() {
485
- this.#definitions ??= Promise.all(Object.values(this.project.modules).map((load) => load())).then(collect);
496
+ this.#definitions ??= Promise.all(Object.values(this.project.modules).map((load) => load())).then(collect).catch((error) => {
497
+ this.#definitions = null;
498
+ throw error;
499
+ });
486
500
  return this.#definitions;
487
501
  }
488
502
  async handle(envelope, defer = () => {}) {
@@ -572,7 +586,7 @@ async function renderItem(inbox, item, upload) {
572
586
  return {
573
587
  title,
574
588
  record: item.record ?? void 0,
575
- data: JSON.parse(JSON.stringify(data)),
589
+ data,
576
590
  blocks: bindActions(await upload(blocks), inbox.options.resource ?? null, item.record ?? null),
577
591
  replyTo: inbox.options.reply?.to(view)
578
592
  };
@@ -591,6 +605,7 @@ function defaultBlocks(data) {
591
605
  //#endregion
592
606
  //#region src/client/flags.ts
593
607
  const FRESH_MS = 1e4;
608
+ const MAX_PARSED = 256;
594
609
  const EMPTY = {
595
610
  version: -1,
596
611
  flags: {}
@@ -615,6 +630,7 @@ var Flags = class {
615
630
  const state = (await this.#current()).flags[flag.name];
616
631
  const raw = state === void 0 ? void 0 : resolveFlag(flag.name, state, contextOf(context));
617
632
  if (raw === void 0 || raw === null) return flag.options.default;
633
+ if (!flag.options.value) return typeof raw === "boolean" ? raw : flag.options.default;
618
634
  return this.#validated(flag, raw);
619
635
  }
620
636
  takeReads() {
@@ -624,7 +640,7 @@ var Flags = class {
624
640
  return reads;
625
641
  }
626
642
  #store(snapshot) {
627
- if (snapshot.version !== this.#snapshot?.version) this.#parsed.clear();
643
+ if (snapshot !== this.#snapshot) this.#parsed.clear();
628
644
  this.#snapshot = snapshot;
629
645
  this.#checkedAt = Date.now();
630
646
  }
@@ -651,12 +667,16 @@ var Flags = class {
651
667
  return this.#snapshot ?? EMPTY;
652
668
  }
653
669
  #validated(flag, raw) {
654
- const key = `${flag.name}\u0000${JSON.stringify(raw)}`;
655
- let parsed = this.#parsed.get(key);
670
+ let values = this.#parsed.get(flag.name);
671
+ if (!values) {
672
+ values = /* @__PURE__ */ new Map();
673
+ this.#parsed.set(flag.name, values);
674
+ }
675
+ let parsed = values.get(raw);
656
676
  if (!parsed) {
657
- const schema = flag.options.value;
658
- parsed = schema ? validate(schema, raw).catch(() => flag.options.default) : Promise.resolve(typeof raw === "boolean" ? raw : flag.options.default);
659
- this.#parsed.set(key, parsed);
677
+ if (values.size >= MAX_PARSED) values.clear();
678
+ parsed = validate(flag.options.value, raw).catch(() => flag.options.default);
679
+ values.set(raw, parsed);
660
680
  }
661
681
  return parsed;
662
682
  }
@@ -672,11 +692,12 @@ function contextOf(context) {
672
692
  //#endregion
673
693
  //#region src/client/socket.ts
674
694
  const HEARTBEAT_MS = 25e3;
695
+ const SILENCE_MS = 2 * HEARTBEAT_MS;
675
696
  const MAX_BACKOFF_MS = 3e4;
676
- function connect({ transport, version, onRequest, onSyncManifest, onFlags, reads, onStatus }) {
677
- const nonces = new NonceWindow();
697
+ function connect({ transport, nonces, version, onRequest, onSyncManifest, onFlags, reads, onStatus }) {
678
698
  let socket = null;
679
699
  let heartbeat;
700
+ let retry;
680
701
  let attempt = 0;
681
702
  let closed = false;
682
703
  const send = (message) => socket?.send(JSON.stringify(message));
@@ -716,20 +737,44 @@ function connect({ transport, version, onRequest, onSyncManifest, onFlags, reads
716
737
  });
717
738
  }
718
739
  };
740
+ const stopHeartbeat = () => {
741
+ if (heartbeat) clearInterval(heartbeat);
742
+ };
743
+ const reconnect = () => {
744
+ stopHeartbeat();
745
+ onFlags(null);
746
+ onStatus?.("disconnected");
747
+ if (closed) return;
748
+ const delay = Math.min(MAX_BACKOFF_MS, 500 * 2 ** attempt++) * (.5 + Math.random() / 2);
749
+ retry = setTimeout(open, delay);
750
+ };
719
751
  const open = async () => {
720
752
  const signature = await sign(transport.key.secret, "connect", version);
753
+ if (closed) return;
721
754
  const params = new URLSearchParams({
722
755
  environment: transport.key.environment,
723
756
  version,
724
757
  ...signature
725
758
  });
726
- socket = new WebSocket(`${transport.url.replace(/^http/, "ws")}/v1/connect?${params}`);
727
- socket.addEventListener("message", (event) => {
759
+ const current = new WebSocket(`${transport.url.replace(/^http/, "ws")}/v1/connect?${params}`);
760
+ let heardAt = Date.now();
761
+ let lost = false;
762
+ const lose = () => {
763
+ if (lost) return;
764
+ lost = true;
765
+ current.close();
766
+ reconnect();
767
+ };
768
+ socket = current;
769
+ current.addEventListener("message", (event) => {
770
+ heardAt = Date.now();
728
771
  if (event.data !== "pong") handle(JSON.parse(String(event.data)));
729
772
  });
730
- socket.addEventListener("open", () => {
773
+ current.addEventListener("open", () => {
774
+ heardAt = Date.now();
731
775
  heartbeat = setInterval(() => {
732
- socket?.send("ping");
776
+ if (Date.now() - heardAt > SILENCE_MS) return lose();
777
+ current.send("ping");
733
778
  const counted = reads();
734
779
  if (counted) send({
735
780
  type: "reads",
@@ -737,38 +782,52 @@ function connect({ transport, version, onRequest, onSyncManifest, onFlags, reads
737
782
  });
738
783
  }, HEARTBEAT_MS);
739
784
  });
740
- socket.addEventListener("close", () => {
741
- if (heartbeat) clearInterval(heartbeat);
742
- onFlags(null);
743
- onStatus?.("disconnected");
744
- if (closed) return;
745
- const delay = Math.min(MAX_BACKOFF_MS, 500 * 2 ** attempt++) * (.5 + Math.random() / 2);
746
- setTimeout(open, delay);
747
- });
785
+ current.addEventListener("close", lose);
748
786
  };
749
787
  open();
750
788
  return { close() {
751
789
  closed = true;
790
+ if (retry) clearTimeout(retry);
791
+ stopHeartbeat();
752
792
  socket?.close();
753
793
  } };
754
794
  }
755
795
  //#endregion
756
- //#region src/client/pult.ts
757
- function createPult({ key = env("PULT_KEY"), project, url = env("PULT_URL") ?? "https://app.pult.sh", fetch = globalThis.fetch }) {
758
- if (!key) throw new Error("Pult needs a key: pass `key` or set PULT_KEY");
759
- const transport = new Transport(key, url, (input, init) => fetch(input, init));
760
- const upload = async (blocks) => Promise.all(blocks.map(async (block) => {
761
- if (!isPendingAttachment(block)) return block;
762
- const data = block.data instanceof Blob ? block.data : new Uint8Array(block.data);
763
- const { key } = await transport.upload(block.name, block.contentType, data);
796
+ //#region src/client/upload.ts
797
+ function uploader(transport) {
798
+ const attach = async ({ name, contentType, data }) => {
799
+ const bytes = await bytesOf(data);
800
+ const { key } = await transport.upload(name, contentType, bytes);
764
801
  return {
765
802
  type: "attachment",
766
- name: block.name,
803
+ name,
767
804
  key,
768
- contentType: block.contentType,
769
- size: byteLength(data)
805
+ contentType,
806
+ size: bytes.byteLength
807
+ };
808
+ };
809
+ const walk = (blocks) => Promise.all(blocks.map(async (block) => {
810
+ if (isPendingAttachment(block)) return attach(block);
811
+ if (block.type === "columns") return {
812
+ type: "columns",
813
+ columns: await Promise.all(block.columns.map(walk))
770
814
  };
815
+ return block;
771
816
  }));
817
+ return async (blocks) => hasPendingAttachments(blocks) ? walk(blocks) : blocks;
818
+ }
819
+ async function bytesOf(data) {
820
+ if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
821
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
822
+ return data;
823
+ }
824
+ //#endregion
825
+ //#region src/client/pult.ts
826
+ function createPult({ key = env("PULT_KEY"), project, url = env("PULT_URL") ?? "https://app.pult.sh", fetch = globalThis.fetch }) {
827
+ if (!key) throw new Error("Pult needs a key: pass `key` or set PULT_KEY");
828
+ const transport = new Transport(key, url, (input, init) => fetch(input, init));
829
+ const upload = uploader(transport);
830
+ const nonces = new NonceWindow();
772
831
  const report = async (job, update) => {
773
832
  await transport.request("POST", `/v1/jobs/${job}`, update);
774
833
  };
@@ -812,6 +871,7 @@ function createPult({ key = env("PULT_KEY"), project, url = env("PULT_URL") ?? "
812
871
  function listen(options = {}) {
813
872
  return connect({
814
873
  transport,
874
+ nonces,
815
875
  version: manifest.version,
816
876
  onRequest: (envelope) => dispatcher.handle(envelope),
817
877
  onSyncManifest: (environment) => environment === manifest.sync ? {
@@ -824,7 +884,6 @@ function createPult({ key = env("PULT_KEY"), project, url = env("PULT_URL") ?? "
824
884
  });
825
885
  }
826
886
  function handler() {
827
- const nonces = new NonceWindow();
828
887
  return async (request, context) => {
829
888
  const body = await request.text();
830
889
  const signature = signedHeaders(request.headers);
@@ -887,9 +946,6 @@ function signedHeaders(headers) {
887
946
  signature
888
947
  } : null;
889
948
  }
890
- function byteLength(data) {
891
- return data instanceof Blob ? data.size : data.byteLength;
892
- }
893
949
  function env(name) {
894
950
  return globalThis.process?.env[name];
895
951
  }
@@ -915,20 +971,29 @@ function defineConfig(options) {
915
971
  };
916
972
  }
917
973
  //#endregion
918
- //#region src/define/notify.ts
919
- function slack(channel) {
920
- return { channel: channel.replace(/^#/, "") };
921
- }
922
- function notify(name, options) {
974
+ //#region src/define/console/page.ts
975
+ function page(name, options) {
923
976
  return {
924
- [KIND]: "notify",
977
+ [KIND]: "page",
925
978
  origin: captureOrigin(),
926
979
  name,
927
980
  options
928
981
  };
929
982
  }
983
+ function home(options) {
984
+ const resolved = Array.isArray(options) || typeof options === "function" ? { render: options } : options;
985
+ return {
986
+ [KIND]: "page",
987
+ origin: captureOrigin(),
988
+ name: HOME,
989
+ options: {
990
+ label: "Home",
991
+ ...resolved
992
+ }
993
+ };
994
+ }
930
995
  //#endregion
931
- //#region src/define/role.ts
996
+ //#region src/define/console/role.ts
932
997
  function role(name, options) {
933
998
  return {
934
999
  [KIND]: "role",
@@ -938,7 +1003,23 @@ function role(name, options) {
938
1003
  };
939
1004
  }
940
1005
  //#endregion
941
- //#region src/define/status.ts
1006
+ //#region src/define/console/space.ts
1007
+ function space(name, options) {
1008
+ const { home } = options;
1009
+ return {
1010
+ [KIND]: "space",
1011
+ origin: captureOrigin(),
1012
+ name,
1013
+ options,
1014
+ home: home === void 0 ? null : kindOf(home) === "page" ? home : page(name, {
1015
+ label: options.label,
1016
+ icon: options.icon,
1017
+ render: home
1018
+ })
1019
+ };
1020
+ }
1021
+ //#endregion
1022
+ //#region src/define/controls/status.ts
942
1023
  function status(options) {
943
1024
  return {
944
1025
  [KIND]: "status",
@@ -947,4 +1028,17 @@ function status(options) {
947
1028
  };
948
1029
  }
949
1030
  //#endregion
950
- export { PultError, ValidationError, action, b, choice, createPult, defineConfig, flag, home, inbox, notify, noul, page, resource, role, run, score, setState, slack, status, tag, triage, view };
1031
+ //#region src/define/inbox/notify.ts
1032
+ function slack(channel) {
1033
+ return { channel: channel.replace(/^#/, "") };
1034
+ }
1035
+ function notify(name, options) {
1036
+ return {
1037
+ [KIND]: "notify",
1038
+ origin: captureOrigin(),
1039
+ name,
1040
+ options
1041
+ };
1042
+ }
1043
+ //#endregion
1044
+ export { PultError, ValidationError, action, b, choice, createPult, defineConfig, flag, home, inbox, notify, noul, page, resource, role, run, score, setState, slack, space, status, tag, triage, view };
package/package.json CHANGED
@@ -1,18 +1,37 @@
1
1
  {
2
2
  "name": "@lantharos/pult",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "The other side of your app",
5
5
  "license": "MIT",
6
+ "keywords": [
7
+ "back office",
8
+ "admin panel",
9
+ "internal tools",
10
+ "console",
11
+ "feature flags",
12
+ "status page"
13
+ ],
6
14
  "homepage": "https://pult.sh",
15
+ "bugs": "https://github.com/Lantharos/Pult/issues",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Lantharos/Pult.git",
19
+ "directory": "packages/pult"
20
+ },
7
21
  "type": "module",
22
+ "sideEffects": false,
23
+ "engines": {
24
+ "node": ">=22.17.0"
25
+ },
8
26
  "exports": {
9
27
  ".": {
10
28
  "types": "./index.d.mts",
11
29
  "default": "./index.mjs"
12
- }
30
+ },
31
+ "./package.json": "./package.json"
13
32
  },
14
33
  "bin": {
15
- "pult": "./cli.mjs"
34
+ "pult": "cli.mjs"
16
35
  },
17
36
  "publishConfig": {
18
37
  "access": "public"