@lantharos/pult 0.0.0-stage → 0.8.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 ADDED
@@ -0,0 +1,950 @@
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";
2
+ //#region ../protocol/src/flags.ts
3
+ function rampAt(ramp, now = Date.now()) {
4
+ if (now >= ramp.end) return ramp.to;
5
+ if (now <= ramp.start) return ramp.from;
6
+ const elapsed = ramp.every ? Math.floor((now - ramp.start) / ramp.every) * ramp.every : now - ramp.start;
7
+ const value = ramp.from + (ramp.to - ramp.from) * elapsed / (ramp.end - ramp.start);
8
+ return ramp.whole ? Math.round(value) : value;
9
+ }
10
+ function currentRollout(state, now = Date.now()) {
11
+ return state.ramp?.of === "rollout" ? rampAt(state.ramp, now) : state.rollout;
12
+ }
13
+ function currentValue(state, now = Date.now()) {
14
+ return state.ramp?.of === "value" ? rampAt(state.ramp, now) : state.value;
15
+ }
16
+ function resolveFlag(name, state, context = {}, now = Date.now()) {
17
+ const { key, attributes = {} } = context;
18
+ if (key !== void 0 && key in state.targets) return state.targets[key];
19
+ const rule = state.rules.find((candidate) => candidate.conditions.every((condition) => holds(condition, attributes[condition.attribute])));
20
+ if (rule) return rule.rollout === null ? rule.value : share(name, key, rule.rollout);
21
+ const rollout = currentRollout(state, now);
22
+ if (rollout !== null) return share(name, key, rollout);
23
+ return currentValue(state, now);
24
+ }
25
+ function holds(condition, value) {
26
+ if (value === null || value === void 0) return condition.op === "is not";
27
+ switch (condition.op) {
28
+ case "is": return condition.values.includes(value);
29
+ case "is not": return !condition.values.includes(value);
30
+ case "above": return typeof value === "number" && value > Number(condition.values[0]);
31
+ case "below": return typeof value === "number" && value < Number(condition.values[0]);
32
+ }
33
+ }
34
+ function share(name, key, rollout) {
35
+ return key === void 0 ? rollout >= 100 : bucket(name, key) < rollout;
36
+ }
37
+ function bucket(name, key) {
38
+ let hash = 2166136261;
39
+ for (const char of `${name}:${key}`) {
40
+ hash ^= char.codePointAt(0);
41
+ hash = Math.imul(hash, 16777619);
42
+ }
43
+ return (hash >>> 0) % 1e4 / 100;
44
+ }
45
+ //#endregion
46
+ //#region src/client/runtime.ts
47
+ const onWorkers = globalThis.navigator?.userAgent === "Cloudflare-Workers";
48
+ //#endregion
49
+ //#region src/client/batch.ts
50
+ const FLUSH_MS = 8;
51
+ const MAX_ITEMS = 200;
52
+ const MAX_BYTES = 15e5;
53
+ const schedule = onWorkers ? queueMicrotask : (flush) => setTimeout(flush, FLUSH_MS);
54
+ var Batcher = class {
55
+ transport;
56
+ sync;
57
+ #queue = [];
58
+ #bytes = 0;
59
+ #scheduled = false;
60
+ constructor(transport, sync) {
61
+ this.transport = transport;
62
+ this.sync = sync;
63
+ }
64
+ send(item) {
65
+ return new Promise((resolve, reject) => {
66
+ const size = JSON.stringify(item).length;
67
+ if (this.#bytes + size > MAX_BYTES) this.#flush();
68
+ this.#queue.push({
69
+ item,
70
+ size,
71
+ resolve,
72
+ reject
73
+ });
74
+ this.#bytes += size;
75
+ if (this.#queue.length >= MAX_ITEMS) this.#flush();
76
+ else if (!this.#scheduled) {
77
+ this.#scheduled = true;
78
+ schedule(() => {
79
+ this.#scheduled = false;
80
+ this.#flush();
81
+ });
82
+ }
83
+ });
84
+ }
85
+ #flush() {
86
+ const batch = this.#queue;
87
+ this.#queue = [];
88
+ this.#bytes = 0;
89
+ if (batch.length) this.#post(batch);
90
+ }
91
+ async #post(batch) {
92
+ const body = { items: batch.map((entry) => entry.item) };
93
+ try {
94
+ let response;
95
+ try {
96
+ response = await this.transport.request("POST", "/v1/items", body);
97
+ } catch (error) {
98
+ if (!(error instanceof PultError && error.code === "manifest_required")) throw error;
99
+ await this.sync();
100
+ response = await this.transport.request("POST", "/v1/items", body);
101
+ }
102
+ response.results.forEach((outcome, index) => {
103
+ const entry = batch[index];
104
+ if ("error" in outcome) entry.reject(new PultError(outcome.error, 400, outcome.code));
105
+ else entry.resolve(outcome);
106
+ });
107
+ } catch (error) {
108
+ for (const entry of batch) entry.reject(error);
109
+ }
110
+ }
111
+ };
112
+ //#endregion
113
+ //#region src/define/inbox.ts
114
+ function inbox(name, options) {
115
+ const states = options.states ?? DEFAULT_STATES;
116
+ const closed = options.closed ?? (options.states ? states.slice(1) : ["handled"]);
117
+ for (const state of closed) if (!states.includes(state)) throw new Error(`Inbox "${name}" closes on "${state}", which isn't one of its states`);
118
+ for (const state of Object.keys(options.sla ?? {})) if (!states.includes(state)) throw new Error(`Inbox "${name}" has a response time for "${state}", which isn't one of its states`);
119
+ return {
120
+ [KIND]: "inbox",
121
+ origin: captureOrigin(),
122
+ name,
123
+ options,
124
+ states,
125
+ closed
126
+ };
127
+ }
128
+ function liveView(title, data, record) {
129
+ return {
130
+ title,
131
+ data,
132
+ record,
133
+ occurrences: { live: "occurrences" },
134
+ users: { live: "users" },
135
+ firstSeen: { live: "firstSeen" },
136
+ lastSeen: { live: "lastSeen" },
137
+ due: { live: "due" }
138
+ };
139
+ }
140
+ //#endregion
141
+ //#region src/define/triage.ts
142
+ function noul(prompt, criteria) {
143
+ return {
144
+ type: "noul",
145
+ prompt,
146
+ ...criteria ? { criteria } : {}
147
+ };
148
+ }
149
+ function choice(options, prompt) {
150
+ return {
151
+ type: "choice",
152
+ prompt,
153
+ options: Array.isArray(options) ? Object.fromEntries(options.map((option) => [option, option])) : options
154
+ };
155
+ }
156
+ function score(levels, prompt) {
157
+ return {
158
+ type: "score",
159
+ prompt,
160
+ levels: [...levels]
161
+ };
162
+ }
163
+ function triage(options) {
164
+ return {
165
+ instructions: options.instructions ?? "",
166
+ questions: options.questions,
167
+ rules: options.rules ?? []
168
+ };
169
+ }
170
+ const setState = (state) => ({
171
+ type: "state",
172
+ state
173
+ });
174
+ const tag = (name) => ({
175
+ type: "tag",
176
+ tag: name
177
+ });
178
+ const run = (action, input) => ({
179
+ type: "run",
180
+ action: action.id,
181
+ input
182
+ });
183
+ function decide(rules, answers) {
184
+ const index = rules.findIndex((rule) => rule.when(answers));
185
+ if (index === -1) return null;
186
+ const then = rules[index].then;
187
+ return {
188
+ rule: index,
189
+ decisions: Array.isArray(then) ? then : [then]
190
+ };
191
+ }
192
+ //#endregion
193
+ //#region src/define/resource.ts
194
+ function action(name, options) {
195
+ return {
196
+ [KIND]: "action",
197
+ origin: captureOrigin(),
198
+ options,
199
+ resource: null,
200
+ name,
201
+ id: name
202
+ };
203
+ }
204
+ const identity = (value) => value;
205
+ function resource(name, options) {
206
+ const origin = captureOrigin();
207
+ const actions = { "*": { id: `${name}.*` } };
208
+ for (const [key, action] of Object.entries(options.actions?.(identity) ?? {})) actions[key] = {
209
+ [KIND]: "action",
210
+ origin,
211
+ options: action,
212
+ resource: name,
213
+ name: key,
214
+ id: `${name}.${key}`
215
+ };
216
+ return {
217
+ [KIND]: "resource",
218
+ origin,
219
+ name,
220
+ options,
221
+ actions
222
+ };
223
+ }
224
+ function recordId(resource, record) {
225
+ return resource.options.id ? resource.options.id(record) : String(record.id);
226
+ }
227
+ //#endregion
228
+ //#region src/client/records.ts
229
+ function resourceOf(definitions, name) {
230
+ const resource = definitions.resources.get(name);
231
+ if (!resource) throw new Error(`Unknown resource "${name}"`);
232
+ return resource;
233
+ }
234
+ async function target(definitions, action, id) {
235
+ if (action.resource === null) return null;
236
+ const resource = resourceOf(definitions, action.resource);
237
+ const record = id === null ? null : await resource.options.get(id);
238
+ if (!record) throw new Error(`${resource.name} ${id} doesn't exist`);
239
+ return record;
240
+ }
241
+ function found(resource, record) {
242
+ return {
243
+ id: recordId(resource, record),
244
+ title: resource.options.title(record),
245
+ subtitle: resource.options.subtitle?.(record)
246
+ };
247
+ }
248
+ function listed(resource, record) {
249
+ return {
250
+ ...found(resource, record),
251
+ fields: fieldValues(resource.options.fields?.(record) ?? {})
252
+ };
253
+ }
254
+ async function find(definitions, request) {
255
+ const resource = resourceOf(definitions, request.resource);
256
+ if (!resource.options.find) return [];
257
+ return (await resource.options.find(request.query, request.limit)).slice(0, request.limit).map((record) => found(resource, record));
258
+ }
259
+ async function get(definitions, request, upload) {
260
+ const resource = resourceOf(definitions, request.resource);
261
+ const record = await resource.options.get(request.id);
262
+ if (!record) return null;
263
+ const blocks = resource.options.render ? await resource.options.render(record) : [];
264
+ return {
265
+ ...found(resource, record),
266
+ url: resource.options.url?.(record),
267
+ blocks: await settle(definitions, await upload(blocks), {
268
+ resource,
269
+ record
270
+ })
271
+ };
272
+ }
273
+ async function list(definitions, request) {
274
+ const resource = resourceOf(definitions, request.resource);
275
+ const options = resource.options.list;
276
+ if (!options) throw new Error(`${resource.name} can't be listed`);
277
+ const filters = options.filters ? await validate(options.filters, request.filters) : {};
278
+ const page = await options.run({
279
+ query: request.query,
280
+ filters,
281
+ cursor: request.cursor,
282
+ limit: request.limit
283
+ });
284
+ return {
285
+ records: page.records.slice(0, request.limit).map((record) => listed(resource, record)),
286
+ cursor: page.cursor ?? null
287
+ };
288
+ }
289
+ async function related(definitions, request) {
290
+ const resource = resourceOf(definitions, request.resource);
291
+ const relation = resource.options.related?.[request.relation];
292
+ if (!relation) throw new Error(`${resource.name} has no relation "${request.relation}"`);
293
+ const record = await resource.options.get(request.id);
294
+ if (!record) return [];
295
+ const target = resourceOf(definitions, relation.resource);
296
+ return (await relation.list(record, request.limit)).slice(0, request.limit).map((entry) => listed(target, entry));
297
+ }
298
+ async function choices(definitions, request) {
299
+ const action = findAction(definitions, request.action);
300
+ if (!action) throw new Error(`Unknown action "${request.action}"`);
301
+ const record = await target(definitions, action, request.record).catch(() => void 0);
302
+ if (record === void 0) return {};
303
+ const entries = Object.entries(action.options.choices ?? {});
304
+ const resolved = await Promise.all(entries.map(async ([field, options]) => [field, (await options(record)).map(toChoice)]));
305
+ return Object.fromEntries(resolved);
306
+ }
307
+ function toChoice(choice) {
308
+ return typeof choice === "string" ? {
309
+ value: choice,
310
+ label: choice
311
+ } : choice;
312
+ }
313
+ async function titles(definitions, request) {
314
+ const resolved = await Promise.all(request.refs.map(async (ref) => {
315
+ const resource = definitions.resources.get(ref.resource);
316
+ const record = resource && await resource.options.get(ref.id);
317
+ return record ? [refKey(ref), resource.options.title(record)] : null;
318
+ }));
319
+ return Object.fromEntries(resolved.filter((entry) => entry !== null));
320
+ }
321
+ async function available(definitions, request) {
322
+ const availability = {};
323
+ for (const ref of request.refs) {
324
+ const resource = definitions.resources.get(ref.resource);
325
+ 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;
335
+ }
336
+ return availability;
337
+ }
338
+ async function settle(definitions, blocks, scope) {
339
+ const bound = scope ? bindActions(blocks, scope.resource, recordId(scope.resource, scope.record)) : blocks;
340
+ const loaded = /* @__PURE__ */ new Map();
341
+ if (scope) loaded.set(`${scope.resource.name}:${recordId(scope.resource, scope.record)}`, Promise.resolve(scope.record));
342
+ const load = (resource, id) => {
343
+ const key = `${resource.name}:${id}`;
344
+ if (!loaded.has(key)) loaded.set(key, Promise.resolve(resource.options.get(id)));
345
+ return loaded.get(key);
346
+ };
347
+ const verdict = async ({ action: id, record: target }) => {
348
+ const action = findAction(definitions, id);
349
+ if (!action) return { applies: true };
350
+ const record = action.resource && target !== null ? await load(resourceOf(definitions, action.resource), target) : null;
351
+ if (action.resource && target !== null && !record) return { applies: false };
352
+ if (record && action.options.when && !action.options.when(record)) return { applies: false };
353
+ const defaults = action.options.input ? action.options.defaults?.(record) : void 0;
354
+ return {
355
+ applies: true,
356
+ ...defaults && { defaults: JSON.parse(JSON.stringify(defaults)) }
357
+ };
358
+ };
359
+ const targets = /* @__PURE__ */ new Map();
360
+ const keyOf = (target) => `${target.action}@${target.record ?? ""}`;
361
+ const visit = (target) => {
362
+ if (!targets.has(keyOf(target))) targets.set(keyOf(target), verdict(target));
363
+ };
364
+ const editsIn = (value) => {
365
+ if (value && typeof value === "object" && "edit" in value) visit(value.edit);
366
+ };
367
+ eachBlock(bound, (block) => {
368
+ if (block.type === "actions") for (const button of block.actions) visit(button);
369
+ 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);
373
+ });
374
+ if (!targets.size) return bound;
375
+ const verdicts = new Map(await Promise.all([...targets].map(async ([key, pending]) => [key, await pending])));
376
+ const editable = (value) => value && typeof value === "object" && "edit" in value && !verdicts.get(keyOf(value.edit))?.applies ? value.edit.value : value;
377
+ const settled = (button) => {
378
+ const found = verdicts.get(keyOf(button));
379
+ if (!found.applies) return null;
380
+ return found.defaults ? {
381
+ ...button,
382
+ input: {
383
+ ...found.defaults,
384
+ ...button.input
385
+ }
386
+ } : button;
387
+ };
388
+ return mapBlocks(bound, (block) => {
389
+ if (block.type === "actions") {
390
+ const actions = block.actions.flatMap((button) => settled(button) ?? []);
391
+ return actions.length ? {
392
+ ...block,
393
+ actions
394
+ } : null;
395
+ }
396
+ if (block.type === "form") {
397
+ const form = settled(block.form);
398
+ return form ? {
399
+ ...block,
400
+ form
401
+ } : null;
402
+ }
403
+ if (block.type === "fields") return {
404
+ ...block,
405
+ fields: block.fields.map((field) => ({
406
+ ...field,
407
+ value: editable(field.value)
408
+ }))
409
+ };
410
+ if (block.type === "stats") return {
411
+ ...block,
412
+ stats: block.stats.map((stat) => ({
413
+ ...stat,
414
+ value: editable(stat.value)
415
+ }))
416
+ };
417
+ if (block.type === "table") return {
418
+ ...block,
419
+ rows: block.rows.map((row) => row.map(editable))
420
+ };
421
+ return block;
422
+ });
423
+ }
424
+ //#endregion
425
+ //#region src/client/actions.ts
426
+ const ignoreProgress = async () => {};
427
+ async function runAction(definitions, manifest, { id, request, actor }, upload, jobs) {
428
+ const action = findAction(definitions, request.action);
429
+ if (!action) throw new Error(`Unknown action "${request.action}"`);
430
+ const def = findActionDef(manifest, request.action);
431
+ const record = await target(definitions, action, request.record);
432
+ if (action.options.when && !action.options.when(record)) {
433
+ const title = resourceOf(definitions, action.resource).options.title(record);
434
+ throw new Error(`${def?.label ?? action.name} doesn't apply to ${title} right now`);
435
+ }
436
+ const raw = reviveDates(def?.input ?? null, request.input);
437
+ const input = action.options.input ? await validate(action.options.input, raw) : {};
438
+ const background = Boolean(action.options.background) && !request.preview;
439
+ const progress = background ? (fraction, note) => jobs.report(id, {
440
+ progress: fraction,
441
+ note
442
+ }) : ignoreProgress;
443
+ const context = {
444
+ record,
445
+ input,
446
+ actor,
447
+ item: request.item,
448
+ progress
449
+ };
450
+ if (request.preview) {
451
+ if (!action.options.preview) return {};
452
+ return { blocks: await upload(await action.options.preview(context)) };
453
+ }
454
+ if (!background) return toResult(await action.options.run(context), upload);
455
+ jobs.defer((async () => {
456
+ try {
457
+ await jobs.report(id, { result: await toResult(await action.options.run(context), upload) });
458
+ } catch (error) {
459
+ await jobs.report(id, { error: error.message });
460
+ }
461
+ })());
462
+ return { running: true };
463
+ }
464
+ async function toResult(value, upload) {
465
+ if (!value) return {};
466
+ if (typeof value === "string") return { message: value };
467
+ return {
468
+ message: value.message,
469
+ blocks: value.blocks && await upload(value.blocks)
470
+ };
471
+ }
472
+ //#endregion
473
+ //#region src/client/dispatch.ts
474
+ var Dispatcher = class {
475
+ project;
476
+ upload;
477
+ report;
478
+ #definitions = null;
479
+ constructor(project, upload, report) {
480
+ this.project = project;
481
+ this.upload = upload;
482
+ this.report = report;
483
+ }
484
+ definitions() {
485
+ this.#definitions ??= Promise.all(Object.values(this.project.modules).map((load) => load())).then(collect);
486
+ return this.#definitions;
487
+ }
488
+ async handle(envelope, defer = () => {}) {
489
+ const { request } = envelope;
490
+ const definitions = await this.definitions();
491
+ switch (request.kind) {
492
+ case "ping": return null;
493
+ case "action": return runAction(definitions, this.project.manifest, envelope, this.upload, {
494
+ report: this.report,
495
+ defer
496
+ });
497
+ case "find": return find(definitions, request);
498
+ case "get": return get(definitions, request, this.upload);
499
+ case "list": return list(definitions, request);
500
+ case "related": return related(definitions, request);
501
+ case "choices": return choices(definitions, request);
502
+ case "titles": return titles(definitions, request);
503
+ case "available": return available(definitions, request);
504
+ case "page": return this.#page(definitions, envelope);
505
+ case "render": return renderItem(this.#inbox(definitions, request.inbox), request, this.upload);
506
+ case "triage": return this.#triage(definitions, request);
507
+ case "replay": return this.#replay(definitions, request);
508
+ case "state": return this.#state(definitions, envelope);
509
+ case "reply": return this.#reply(definitions, envelope);
510
+ }
511
+ }
512
+ async #page(definitions, { request, actor }) {
513
+ const page = definitions.pages.get(request.page);
514
+ if (!page) throw new Error(`Unknown page "${request.page}"`);
515
+ const { params, render } = page.options;
516
+ const parsed = params ? await validate(params, request.params) : {};
517
+ const blocks = typeof render === "function" ? await render({
518
+ params: parsed,
519
+ actor
520
+ }) : render;
521
+ return settle(definitions, await this.upload(blocks), null);
522
+ }
523
+ #inbox(definitions, name) {
524
+ const inbox = definitions.inboxes.get(name);
525
+ if (!inbox) throw new Error(`Unknown inbox "${name}"`);
526
+ return inbox;
527
+ }
528
+ async #state(definitions, { request, actor }) {
529
+ const inbox = this.#inbox(definitions, request.inbox);
530
+ const item = {
531
+ id: request.item,
532
+ title: request.title,
533
+ record: request.record,
534
+ data: request.data
535
+ };
536
+ await inbox.options.onState?.({
537
+ item,
538
+ from: request.from,
539
+ to: request.to,
540
+ actor
541
+ });
542
+ return null;
543
+ }
544
+ async #reply(definitions, { request, actor }) {
545
+ const reply = this.#inbox(definitions, request.inbox).options.reply;
546
+ if (!reply) throw new Error(`${request.inbox} can't send email`);
547
+ const { item, to, subject, text, thread } = request;
548
+ await reply.send({
549
+ item,
550
+ to,
551
+ subject,
552
+ text,
553
+ thread,
554
+ actor
555
+ });
556
+ return null;
557
+ }
558
+ #replay(definitions, request) {
559
+ const rules = this.#inbox(definitions, request.inbox).options.triage?.rules ?? [];
560
+ return request.answers.map((answers) => decide(rules, answers));
561
+ }
562
+ #triage(definitions, request) {
563
+ const inbox = this.#inbox(definitions, request.inbox);
564
+ return inbox.options.triage ? decide(inbox.options.triage.rules, request.answers) : null;
565
+ }
566
+ };
567
+ async function renderItem(inbox, item, upload) {
568
+ const data = inbox.options.data ? await validate(inbox.options.data, item.data) : item.data;
569
+ const title = item.title || inbox.options.title?.(data) || inbox.options.label || inbox.name;
570
+ const view = liveView(title, data, item.record ?? null);
571
+ const blocks = inbox.options.render ? await inbox.options.render(view) : defaultBlocks(data);
572
+ return {
573
+ title,
574
+ record: item.record ?? void 0,
575
+ data: JSON.parse(JSON.stringify(data)),
576
+ blocks: bindActions(await upload(blocks), inbox.options.resource ?? null, item.record ?? null),
577
+ replyTo: inbox.options.reply?.to(view)
578
+ };
579
+ }
580
+ function defaultBlocks(data) {
581
+ if (!data || typeof data !== "object") return [];
582
+ const fields = Object.entries(data).filter(([, value]) => value !== null && typeof value !== "object").map(([label, value]) => ({
583
+ label,
584
+ value
585
+ }));
586
+ return fields.length ? [{
587
+ type: "fields",
588
+ fields
589
+ }] : [];
590
+ }
591
+ //#endregion
592
+ //#region src/client/flags.ts
593
+ const FRESH_MS = 1e4;
594
+ const EMPTY = {
595
+ version: -1,
596
+ flags: {}
597
+ };
598
+ var Flags = class {
599
+ transport;
600
+ #snapshot = null;
601
+ #checkedAt = 0;
602
+ #live = false;
603
+ #refreshing = null;
604
+ #parsed = /* @__PURE__ */ new Map();
605
+ #reads = /* @__PURE__ */ new Map();
606
+ constructor(transport) {
607
+ this.transport = transport;
608
+ }
609
+ receive(snapshot) {
610
+ this.#live = snapshot !== null;
611
+ if (snapshot) this.#store(snapshot);
612
+ }
613
+ async value(flag, context) {
614
+ this.#reads.set(flag.name, (this.#reads.get(flag.name) ?? 0) + 1);
615
+ const state = (await this.#current()).flags[flag.name];
616
+ const raw = state === void 0 ? void 0 : resolveFlag(flag.name, state, contextOf(context));
617
+ if (raw === void 0 || raw === null) return flag.options.default;
618
+ return this.#validated(flag, raw);
619
+ }
620
+ takeReads() {
621
+ if (!this.#reads.size) return null;
622
+ const reads = Object.fromEntries(this.#reads);
623
+ this.#reads.clear();
624
+ return reads;
625
+ }
626
+ #store(snapshot) {
627
+ if (snapshot.version !== this.#snapshot?.version) this.#parsed.clear();
628
+ this.#snapshot = snapshot;
629
+ this.#checkedAt = Date.now();
630
+ }
631
+ async #current() {
632
+ const fresh = this.#live || Date.now() - this.#checkedAt < FRESH_MS;
633
+ if (this.#snapshot && fresh) return this.#snapshot;
634
+ if (onWorkers) return this.#refresh();
635
+ this.#refreshing ??= this.#refresh().finally(() => {
636
+ this.#refreshing = null;
637
+ });
638
+ return this.#refreshing;
639
+ }
640
+ async #refresh() {
641
+ const since = this.#snapshot?.version ?? -1;
642
+ const reads = this.takeReads();
643
+ const counted = reads ? `&reads=${encodeURIComponent(Object.entries(reads).map(([name, count]) => `${name}:${count}`).join(","))}` : "";
644
+ try {
645
+ const answer = await this.transport.request("GET", `/v1/flags?since=${since}${counted}`);
646
+ if ("unchanged" in answer) this.#checkedAt = Date.now();
647
+ else this.#store(answer);
648
+ } catch {
649
+ this.#checkedAt = Date.now();
650
+ }
651
+ return this.#snapshot ?? EMPTY;
652
+ }
653
+ #validated(flag, raw) {
654
+ const key = `${flag.name}\u0000${JSON.stringify(raw)}`;
655
+ let parsed = this.#parsed.get(key);
656
+ 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);
660
+ }
661
+ return parsed;
662
+ }
663
+ };
664
+ function contextOf(context) {
665
+ if (context === void 0 || typeof context === "string") return { key: context };
666
+ const { key, ...attributes } = context;
667
+ return {
668
+ key,
669
+ attributes
670
+ };
671
+ }
672
+ //#endregion
673
+ //#region src/client/socket.ts
674
+ const HEARTBEAT_MS = 25e3;
675
+ const MAX_BACKOFF_MS = 3e4;
676
+ function connect({ transport, version, onRequest, onSyncManifest, onFlags, reads, onStatus }) {
677
+ const nonces = new NonceWindow();
678
+ let socket = null;
679
+ let heartbeat;
680
+ let attempt = 0;
681
+ let closed = false;
682
+ const send = (message) => socket?.send(JSON.stringify(message));
683
+ const respond = async (response) => {
684
+ const body = JSON.stringify(response);
685
+ send({
686
+ type: "response",
687
+ signature: await sign(transport.key.secret, "response", body),
688
+ body
689
+ });
690
+ };
691
+ const handle = async (message) => {
692
+ if (message.type === "welcome") {
693
+ attempt = 0;
694
+ onStatus?.("connected");
695
+ const manifest = message.syncManifest ? onSyncManifest(message.environment) : null;
696
+ if (manifest) send(manifest);
697
+ return;
698
+ }
699
+ if (message.type === "flags") {
700
+ onFlags(message.snapshot);
701
+ return;
702
+ }
703
+ if (await verify(transport.key.secret, "request", message.body, message.signature) || !nonces.claim(message.signature.nonce)) return;
704
+ const envelope = JSON.parse(message.body);
705
+ try {
706
+ await respond({
707
+ id: envelope.id,
708
+ ok: true,
709
+ result: await onRequest(envelope) ?? null
710
+ });
711
+ } catch (error) {
712
+ await respond({
713
+ id: envelope.id,
714
+ ok: false,
715
+ error: { message: error.message }
716
+ });
717
+ }
718
+ };
719
+ const open = async () => {
720
+ const signature = await sign(transport.key.secret, "connect", version);
721
+ const params = new URLSearchParams({
722
+ environment: transport.key.environment,
723
+ version,
724
+ ...signature
725
+ });
726
+ socket = new WebSocket(`${transport.url.replace(/^http/, "ws")}/v1/connect?${params}`);
727
+ socket.addEventListener("message", (event) => {
728
+ if (event.data !== "pong") handle(JSON.parse(String(event.data)));
729
+ });
730
+ socket.addEventListener("open", () => {
731
+ heartbeat = setInterval(() => {
732
+ socket?.send("ping");
733
+ const counted = reads();
734
+ if (counted) send({
735
+ type: "reads",
736
+ reads: counted
737
+ });
738
+ }, HEARTBEAT_MS);
739
+ });
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
+ });
748
+ };
749
+ open();
750
+ return { close() {
751
+ closed = true;
752
+ socket?.close();
753
+ } };
754
+ }
755
+ //#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);
764
+ return {
765
+ type: "attachment",
766
+ name: block.name,
767
+ key,
768
+ contentType: block.contentType,
769
+ size: byteLength(data)
770
+ };
771
+ }));
772
+ const report = async (job, update) => {
773
+ await transport.request("POST", `/v1/jobs/${job}`, update);
774
+ };
775
+ const dispatcher = new Dispatcher(project, upload, report);
776
+ const { manifest } = project;
777
+ const deploy = () => transport.request("POST", "/v1/manifest", manifest);
778
+ const sync = () => transport.request("POST", "/v1/manifest/sync", manifest);
779
+ const batcher = new Batcher(transport, sync);
780
+ const flags = new Flags(transport);
781
+ async function send(inbox, item) {
782
+ const rendered = await renderItem(inbox, item, upload);
783
+ return batcher.send({
784
+ inbox: inbox.name,
785
+ fingerprint: item.fingerprint,
786
+ tags: item.tags,
787
+ suggest: item.suggest?.map(({ action, ...rest }) => ({
788
+ action: action.id,
789
+ ...rest
790
+ })),
791
+ version: manifest.version,
792
+ ...rendered
793
+ });
794
+ }
795
+ async function log(resource, id, text, options = {}) {
796
+ await transport.request("POST", "/v1/history", {
797
+ resource: resource.name,
798
+ id,
799
+ text,
800
+ by: options.by,
801
+ blocks: options.blocks && await upload(options.blocks)
802
+ });
803
+ }
804
+ async function receive(message) {
805
+ try {
806
+ return await transport.request("POST", "/v1/messages", message);
807
+ } catch (error) {
808
+ if (error instanceof PultError && error.code === "no_thread") return null;
809
+ throw error;
810
+ }
811
+ }
812
+ function listen(options = {}) {
813
+ return connect({
814
+ transport,
815
+ version: manifest.version,
816
+ onRequest: (envelope) => dispatcher.handle(envelope),
817
+ onSyncManifest: (environment) => environment === manifest.sync ? {
818
+ type: "manifest",
819
+ manifest
820
+ } : null,
821
+ onFlags: (snapshot) => flags.receive(snapshot),
822
+ reads: () => flags.takeReads(),
823
+ onStatus: options.onStatus
824
+ });
825
+ }
826
+ function handler() {
827
+ const nonces = new NonceWindow();
828
+ return async (request, context) => {
829
+ const body = await request.text();
830
+ const signature = signedHeaders(request.headers);
831
+ const failure = signature && await verify(transport.key.secret, "request", body, signature);
832
+ if (!signature || failure || !nonces.claim(signature.nonce)) return Response.json({ error: "invalid signature" }, { status: 401 });
833
+ const envelope = JSON.parse(body);
834
+ let response;
835
+ try {
836
+ const result = await dispatcher.handle(envelope, (work) => context?.waitUntil(work));
837
+ response = {
838
+ id: envelope.id,
839
+ ok: true,
840
+ result: result ?? null
841
+ };
842
+ } catch (error) {
843
+ response = {
844
+ id: envelope.id,
845
+ ok: false,
846
+ error: { message: error.message }
847
+ };
848
+ }
849
+ const payload = JSON.stringify(response);
850
+ const signed = await sign(transport.key.secret, "response", payload);
851
+ return new Response(payload, { headers: {
852
+ "content-type": "application/json",
853
+ [HEADER.timestamp]: signed.timestamp,
854
+ [HEADER.nonce]: signed.nonce,
855
+ [HEADER.signature]: signed.signature
856
+ } });
857
+ };
858
+ }
859
+ const status = { async set(component, state) {
860
+ if (!manifest.status?.components[component]) throw new Error(`Your status page has no component "${component}"`);
861
+ await transport.request("POST", "/v1/status", {
862
+ component,
863
+ state
864
+ });
865
+ } };
866
+ function flag(definition, context) {
867
+ return flags.value(definition, context);
868
+ }
869
+ return {
870
+ send,
871
+ log,
872
+ receive,
873
+ flag,
874
+ status,
875
+ listen,
876
+ handler,
877
+ deploy
878
+ };
879
+ }
880
+ function signedHeaders(headers) {
881
+ const timestamp = headers.get(HEADER.timestamp);
882
+ const nonce = headers.get(HEADER.nonce);
883
+ const signature = headers.get(HEADER.signature);
884
+ return timestamp && nonce && signature ? {
885
+ timestamp,
886
+ nonce,
887
+ signature
888
+ } : null;
889
+ }
890
+ function byteLength(data) {
891
+ return data instanceof Blob ? data.size : data.byteLength;
892
+ }
893
+ function env(name) {
894
+ return globalThis.process?.env[name];
895
+ }
896
+ //#endregion
897
+ //#region src/define/config.ts
898
+ function defineConfig(options) {
899
+ const environments = options.environments ?? [
900
+ "dev",
901
+ "staging",
902
+ "prod"
903
+ ];
904
+ const sync = options.sync ?? environments[0];
905
+ if (!environments.includes(sync)) throw new Error(`sync is "${sync}", which isn't one of your environments (${environments.join(", ")})`);
906
+ return {
907
+ [KIND]: "config",
908
+ origin: captureOrigin(),
909
+ options: {
910
+ delivery: { mode: "socket" },
911
+ ...options,
912
+ environments,
913
+ sync
914
+ }
915
+ };
916
+ }
917
+ //#endregion
918
+ //#region src/define/notify.ts
919
+ function slack(channel) {
920
+ return { channel: channel.replace(/^#/, "") };
921
+ }
922
+ function notify(name, options) {
923
+ return {
924
+ [KIND]: "notify",
925
+ origin: captureOrigin(),
926
+ name,
927
+ options
928
+ };
929
+ }
930
+ //#endregion
931
+ //#region src/define/role.ts
932
+ function role(name, options) {
933
+ return {
934
+ [KIND]: "role",
935
+ origin: captureOrigin(),
936
+ name,
937
+ options
938
+ };
939
+ }
940
+ //#endregion
941
+ //#region src/define/status.ts
942
+ function status(options) {
943
+ return {
944
+ [KIND]: "status",
945
+ origin: captureOrigin(),
946
+ options
947
+ };
948
+ }
949
+ //#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 };