@selvajs/solve 2.0.0-beta.11 → 2.0.0-beta.13

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/dist/client.js CHANGED
@@ -1,22 +1,35 @@
1
- import { n as finalizeResult, r as outcomeFromComputeMessages, t as decodeOutcome } from "./outcome-ai_9fm43.js";
1
+ import { i as outcomeFromComputeMessages, n as decodeOutcome, r as finalizeResult, t as decodeDiagnostic } from "./outcome-DNrffAXp.js";
2
+ import { t as raceAbort } from "./abort-CNJ8Sn4g.js";
2
3
  import { getDefaultValue, getInputItems } from "@selvajs/schemas";
3
4
  import { GrasshopperResponseProcessor } from "@selvajs/compute/grasshopper";
4
5
  // src/client/external-storage.ts
5
6
  const STORAGE_PREFIX = "external";
6
- function makeKey(scopeKey, inputId) {
7
- return `${STORAGE_PREFIX}:${scopeKey}:${inputId}`;
7
+ /** Null without a usable store. Merely reading `sessionStorage` throws when the browser blocks it. */
8
+ function openSlot(ref) {
9
+ if (!ref.scopeKey || !ref.inputId) return null;
10
+ try {
11
+ if (typeof sessionStorage === "undefined") return null;
12
+ return {
13
+ storage: sessionStorage,
14
+ key: `${STORAGE_PREFIX}:${ref.scopeKey}:${ref.inputId}`
15
+ };
16
+ } catch {
17
+ return null;
18
+ }
8
19
  }
9
20
  function writeExternalValue(args) {
10
- const { scopeKey, inputId, value } = args;
11
- if (!scopeKey || !inputId) return;
12
- if (typeof sessionStorage === "undefined") return;
13
- sessionStorage.setItem(makeKey(scopeKey, inputId), JSON.stringify(value));
21
+ const slot = openSlot(args);
22
+ if (!slot) return;
23
+ try {
24
+ slot.storage.setItem(slot.key, JSON.stringify(args.value));
25
+ } catch (err) {
26
+ console.warn("[Solve/storage] could not store an external value:", err);
27
+ }
14
28
  }
15
29
  function readExternalValue(ref) {
16
- const { scopeKey, inputId } = ref;
17
- if (!scopeKey || !inputId) return void 0;
18
- if (typeof sessionStorage === "undefined") return void 0;
19
- const raw = sessionStorage.getItem(makeKey(scopeKey, inputId));
30
+ const slot = openSlot(ref);
31
+ if (!slot) return void 0;
32
+ const raw = slot.storage.getItem(slot.key);
20
33
  if (raw === null) return void 0;
21
34
  try {
22
35
  return JSON.parse(raw);
@@ -25,10 +38,8 @@ function readExternalValue(ref) {
25
38
  }
26
39
  }
27
40
  function clearExternalValue(ref) {
28
- const { scopeKey, inputId } = ref;
29
- if (!scopeKey || !inputId) return;
30
- if (typeof sessionStorage === "undefined") return;
31
- sessionStorage.removeItem(makeKey(scopeKey, inputId));
41
+ const slot = openSlot(ref);
42
+ slot?.storage.removeItem(slot.key);
32
43
  }
33
44
  function getExternalInputs(schema) {
34
45
  return getInputItems(schema).filter((item) => item.source?.kind === "client").map((item) => ({
@@ -46,7 +57,12 @@ const EMPTY_LIVE_SOLVE = Object.freeze({
46
57
  other: [],
47
58
  lastSeq: 0
48
59
  });
60
+ function readEndedKind(payload) {
61
+ const { kind } = payload;
62
+ return kind === "ok" || kind === "blocked" || kind === "aborted" ? kind : "error";
63
+ }
49
64
  function reduceLiveSolve(state, event) {
65
+ if (event.type === "solveEnded" && state.solveId !== null && event.solveId !== state.solveId) return state;
50
66
  const base = event.solveId === state.solveId ? state : {
51
67
  ...EMPTY_LIVE_SOLVE,
52
68
  solveId: event.solveId,
@@ -66,10 +82,10 @@ function reduceLiveSolve(state, event) {
66
82
  case "solveEnded": return {
67
83
  ...next,
68
84
  running: false,
69
- ended: { kind: typeof payload.kind === "string" ? payload.kind : "ok" }
85
+ ended: { kind: readEndedKind(payload) }
70
86
  };
71
87
  case "diagnostic": {
72
- const [diagnostic] = decodeOutcome({ diagnostics: [payload] })?.diagnostics ?? [];
88
+ const diagnostic = decodeDiagnostic(payload);
73
89
  return diagnostic ? {
74
90
  ...next,
75
91
  diagnostics: [...next.diagnostics, diagnostic]
@@ -80,7 +96,7 @@ function reduceLiveSolve(state, event) {
80
96
  const progress = new Map(next.progress);
81
97
  const report = readProgress(payload);
82
98
  const previous = next.progress.get(source)?.fraction;
83
- if (previous !== void 0 && report.fraction !== void 0 && report.fraction < previous) report.fraction = previous;
99
+ if (previous !== void 0 && (report.fraction ?? 0) < previous) report.fraction = previous;
84
100
  progress.set(source, report);
85
101
  return {
86
102
  ...next,
@@ -135,28 +151,39 @@ function buildInitialValues(schema, scopeKey, read) {
135
151
  return values;
136
152
  }
137
153
  /** Manual-solve schemas (instanceSolve === false) start dirty; auto-solve schemas start clean. */
138
- function makeInitialFlags(instanceSolve) {
139
- const manual = instanceSolve === false;
154
+ function createInitialState(schema, scopeKey, read) {
155
+ const manual = schema.instanceSolve === false;
140
156
  return {
157
+ values: buildInitialValues(schema, scopeKey, read),
158
+ error: "",
159
+ computeErrors: [],
160
+ computeWarnings: [],
161
+ diagnostics: [],
162
+ blocked: false,
163
+ heldResult: null,
164
+ meshes: [],
141
165
  hasPendingChanges: manual,
142
- hasNeverSolved: manual
166
+ hasNeverSolved: manual,
167
+ lastResult: null,
168
+ unsolved: null
143
169
  };
144
170
  }
145
- /** Manual mode defers (records the pending value, raises the dirty flag) instead of solving. */
146
- function applyValueChange(state, id, value, instanceSolve) {
171
+ /**
172
+ * Whether the change should dispatch. Manual mode defers and raises the dirty flag instead;
173
+ * `force` overrides that, and what it solves is every pending edit.
174
+ */
175
+ function applyValueChange(state, id, value, instanceSolve, force = false) {
147
176
  state.values[id] = value;
148
- if (instanceSolve === false) {
149
- state.pendingValues[id] = value;
150
- state.hasPendingChanges = true;
151
- return {
152
- state,
153
- shouldSolve: false
154
- };
155
- }
156
- return {
157
- state,
158
- shouldSolve: true
159
- };
177
+ if (instanceSolve !== false) return true;
178
+ state.hasPendingChanges = !force;
179
+ return force;
180
+ }
181
+ /** Same rule as `applyValueChange`, for a whole set. Whether it should dispatch. */
182
+ function applyLoadedValues(state, incoming, instanceSolve) {
183
+ Object.assign(state.values, incoming);
184
+ if (instanceSolve !== false) return true;
185
+ state.hasPendingChanges = true;
186
+ return false;
160
187
  }
161
188
  const RESEND_UNCHANGED = /* @__PURE__ */ new Set(["dynamicValueList", "file"]);
162
189
  /**
@@ -169,7 +196,7 @@ function isRepeatOfLastSolve(schema, lastSent, values, id, value) {
169
196
  if (!lastSent || !(id in lastSent)) return false;
170
197
  if (value !== null && typeof value === "object") return false;
171
198
  if (!Object.is(lastSent[id], value) || !Object.is(values[id], value)) return false;
172
- const paramType = schema?.inputs?.find((input) => input.id === id)?.paramType;
199
+ const paramType = schema.inputs.find((input) => input.id === id)?.paramType;
173
200
  return !RESEND_UNCHANGED.has(paramType ?? "");
174
201
  }
175
202
  /**
@@ -178,32 +205,34 @@ function isRepeatOfLastSolve(schema, lastSent, values, id, value) {
178
205
  * options-list output showed this can re-upload a multi-MB payload no backend reads.
179
206
  */
180
207
  function pickInputValues(schema, values) {
181
- if (!schema?.inputs) return values;
182
208
  const picked = {};
183
- for (const input of schema.inputs) if (input.id in values) picked[input.id] = values[input.id];
209
+ for (const input of schema.inputs) if (Object.hasOwn(values, input.id)) picked[input.id] = values[input.id];
184
210
  return picked;
185
211
  }
186
- function applySolveResult(state, result) {
212
+ /** An input was edited after `sent` was dispatched, so a result for `sent` does not cover it. */
213
+ function inputsChangedSince(schema, values, sent) {
214
+ if (!sent) return false;
215
+ const current = pickInputValues(schema, values);
216
+ return Object.keys(current).some((id) => !Object.hasOwn(sent, id) || !Object.is(current[id], sent[id]));
217
+ }
218
+ /**
219
+ * `editedSinceSent` keeps the dirty flag for a value changed while the solve ran: its result
220
+ * answers the older value, and clearing the flag would hide the Calculate prompt on a stale answer.
221
+ */
222
+ function applySolveResult(state, result, editedSinceSent) {
187
223
  state.error = "";
188
224
  state.computeErrors = result.errors ?? [];
189
225
  state.computeWarnings = result.warnings ?? [];
190
226
  state.diagnostics = result.diagnostics ?? [];
191
227
  state.blocked = result.blocked ?? false;
192
- if (state.blocked) {
193
- state.awaitingAck = false;
194
- state.heldResult = null;
195
- return commitSolveResult(state, result);
196
- }
197
- if (needsAcknowledgement(state.diagnostics)) {
198
- state.awaitingAck = true;
228
+ state.unsolved = result.aborted ? "aborted" : null;
229
+ if (!state.blocked && needsAcknowledgement(state.diagnostics)) {
199
230
  state.heldResult = result;
200
- state.pendingValues = {};
201
- state.hasPendingChanges = false;
202
- return state;
231
+ state.hasPendingChanges &&= editedSinceSent;
232
+ return;
203
233
  }
204
- state.awaitingAck = false;
205
234
  state.heldResult = null;
206
- return commitSolveResult(state, result);
235
+ commitSolveResult(state, result, editedSinceSent);
207
236
  }
208
237
  /**
209
238
  * Every schema output set to null. A blocked solve reports `outputs: {}`, and merging that into
@@ -211,19 +240,25 @@ function applySolveResult(state, result) {
211
240
  */
212
241
  function blankOutputs(schema) {
213
242
  const blank = {};
214
- for (const output of schema?.outputs ?? []) blank[output.id] = null;
243
+ for (const output of schema.outputs) blank[output.id] = null;
215
244
  return blank;
216
245
  }
217
246
  /** Writes a result into the state. Shared by the clean path and by `acknowledge()`. */
218
- function commitSolveResult(state, result) {
247
+ function commitSolveResult(state, result, editedSinceSent) {
219
248
  state.meshes = result.meshes ?? [];
220
249
  const { meshes: _meshes, ...retained } = result;
221
250
  state.lastResult = retained;
222
251
  Object.assign(state.values, result.outputs);
223
- state.pendingValues = {};
224
- state.hasPendingChanges = false;
252
+ state.hasPendingChanges &&= editedSinceSent;
225
253
  state.hasNeverSolved = false;
226
- return state;
254
+ }
255
+ /** `busy`: the driver is solving or has a solve queued. */
256
+ function ensureSolvedStep(state, busy) {
257
+ if (busy || state.heldResult) return "wait";
258
+ if (state.hasPendingChanges) return "solve";
259
+ if (state.unsolved === "discarded") return "discarded";
260
+ if (state.unsolved || state.hasNeverSolved || state.error || !state.lastResult) return "solve";
261
+ return state.blocked ? "blocked" : "applied";
227
262
  }
228
263
  /**
229
264
  * One value for what a host should show. Solving wins: a new solve supersedes a held or refused
@@ -231,7 +266,7 @@ function commitSolveResult(state, result) {
231
266
  */
232
267
  function solvePhase(state, isSolving) {
233
268
  if (isSolving) return "solving";
234
- if (state.awaitingAck) return "review";
269
+ if (state.heldResult) return "review";
235
270
  if (state.blocked) return "blocked";
236
271
  return "idle";
237
272
  }
@@ -247,34 +282,29 @@ function solvePhase(state, isSolving) {
247
282
  * the user, never a future one. Under `instanceSolve` with a live warning that means a dialog
248
283
  * per solve, which is the intended checkpoint, not an oversight.
249
284
  *
250
- * Without `diagnostics` (a transport that reports no structure) nothing gates: the messages are
285
+ * A transport that reports no structure has no diagnostics, so nothing gates: the messages are
251
286
  * still shown, just without interrupting.
252
287
  */
253
288
  function needsAcknowledgement(diagnostics) {
254
- return (diagnostics ?? []).some((d) => d.isGate && d.level !== "remark");
289
+ return diagnostics.some((d) => d.isGate && d.level !== "remark");
255
290
  }
256
291
  // src/client/session/solve-session.ts
292
+ /** One listener's bug must not cost the others their update, or strand a solve mid-landing. */
293
+ function notifyAll(listeners, value) {
294
+ for (const listener of [...listeners]) try {
295
+ listener(value);
296
+ } catch (err) {
297
+ console.error("[Solve/session] listener threw:", err);
298
+ }
299
+ }
257
300
  function createSolveSession(args) {
258
301
  let currentSchema = args.schema;
259
302
  let lastSent = null;
260
- const flags = makeInitialFlags(currentSchema?.instanceSolve);
261
- const state = {
262
- values: buildInitialValues(currentSchema, args.scopeKey, readExternalValue),
263
- error: "",
264
- computeErrors: [],
265
- computeWarnings: [],
266
- diagnostics: [],
267
- blocked: false,
268
- awaitingAck: false,
269
- heldResult: null,
270
- meshes: [],
271
- pendingValues: {},
272
- hasPendingChanges: flags.hasPendingChanges,
273
- hasNeverSolved: flags.hasNeverSolved,
274
- lastResult: null
275
- };
303
+ let lastDispatched = null;
304
+ const state = createInitialState(currentSchema, args.scopeKey, readExternalValue);
305
+ let disposed = false;
276
306
  const listeners = /* @__PURE__ */ new Set();
277
- const emit = () => listeners.forEach((l) => l());
307
+ const emit = () => notifyAll(listeners, void 0);
278
308
  const settledListeners = /* @__PURE__ */ new Set();
279
309
  let waiters = [];
280
310
  const ABORTED = {
@@ -282,13 +312,14 @@ function createSolveSession(args) {
282
312
  diagnostics: []
283
313
  };
284
314
  function nextSettlement() {
315
+ if (disposed) return Promise.resolve(ABORTED);
285
316
  return new Promise((resolve) => waiters.push(resolve));
286
317
  }
287
318
  function settle(settlement) {
288
319
  const waiting = waiters;
289
320
  waiters = [];
290
321
  waiting.forEach((resolve) => resolve(settlement));
291
- settledListeners.forEach((listener) => listener(settlement));
322
+ notifyAll(settledListeners, settlement);
292
323
  }
293
324
  /** Resolves waiting callers without telling listeners: no solve ended, the session moved on. */
294
325
  function abandon() {
@@ -296,22 +327,42 @@ function createSolveSession(args) {
296
327
  waiters = [];
297
328
  waiting.forEach((resolve) => resolve(ABORTED));
298
329
  }
299
- function settleResult(result) {
330
+ function settlementOf(result) {
300
331
  const diagnostics = state.diagnostics;
301
- if (result.aborted) return settle({
332
+ if (result.aborted) return {
302
333
  status: "aborted",
303
334
  diagnostics
304
- });
305
- if (result.blocked) return settle({
335
+ };
336
+ if (result.blocked) return {
306
337
  status: "blocked",
307
338
  diagnostics
308
- });
309
- settle({
339
+ };
340
+ return {
310
341
  status: "applied",
311
342
  result: state.lastResult,
312
343
  diagnostics
344
+ };
345
+ }
346
+ /** One solve ended. Tells subscribers, then settles unless a queued solve is about to replace it. */
347
+ function land(settlement) {
348
+ emit();
349
+ if (!args.driver.hasQueued) settle(settlement());
350
+ }
351
+ /**
352
+ * Waiters stay: `solve()` resolves with what lands, and that is now the newer solve. Only
353
+ * listeners hear of it, so the dropped solve still settles exactly once.
354
+ */
355
+ function dropHeld() {
356
+ if (!state.heldResult) return;
357
+ state.heldResult = null;
358
+ notifyAll(settledListeners, {
359
+ status: "discarded",
360
+ diagnostics: state.diagnostics
313
361
  });
314
362
  }
363
+ function editedSinceSent() {
364
+ return inputsChangedSince(currentSchema, state.values, lastDispatched);
365
+ }
315
366
  let live = EMPTY_LIVE_SOLVE;
316
367
  const stopEvents = args.driver.onEvent?.((event) => {
317
368
  const next = reduceLiveSolve(live, event);
@@ -325,7 +376,10 @@ function createSolveSession(args) {
325
376
  return settled;
326
377
  }
327
378
  function dispatch() {
379
+ if (disposed) return;
380
+ dropHeld();
328
381
  lastSent = { ...pickInputValues(currentSchema, state.values) };
382
+ lastDispatched = lastSent;
329
383
  args.driver.solve(lastSent);
330
384
  emit();
331
385
  }
@@ -349,26 +403,23 @@ function createSolveSession(args) {
349
403
  return state.blocked;
350
404
  },
351
405
  get awaitingAck() {
352
- return state.awaitingAck;
406
+ return state.heldResult !== null;
353
407
  },
354
408
  acknowledge() {
355
- if (!state.awaitingAck) return;
356
409
  const held = state.heldResult;
357
- state.awaitingAck = false;
410
+ if (!held) return;
358
411
  state.heldResult = null;
359
- if (held) commitSolveResult(state, held);
360
- emit();
361
- if (held) settleResult(held);
412
+ commitSolveResult(state, held, editedSinceSent());
413
+ land(() => settlementOf(held));
362
414
  },
363
415
  discard() {
364
- if (!state.awaitingAck) return;
365
- state.awaitingAck = false;
416
+ if (!state.heldResult) return;
366
417
  state.heldResult = null;
367
- emit();
368
- settle({
418
+ state.unsolved = "discarded";
419
+ land(() => ({
369
420
  status: "discarded",
370
421
  diagnostics: state.diagnostics
371
- });
422
+ }));
372
423
  },
373
424
  get live() {
374
425
  return live;
@@ -396,26 +447,22 @@ function createSolveSession(args) {
396
447
  },
397
448
  setValue(id, value, forceSolve = false) {
398
449
  if (!forceSolve && isRepeatOfLastSolve(currentSchema, lastSent, state.values, id, value)) return;
399
- const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
400
- if (shouldSolve || forceSolve) {
401
- if (forceSolve && !shouldSolve) {
402
- state.pendingValues = {};
403
- state.hasPendingChanges = false;
404
- }
405
- dispatch();
406
- } else emit();
450
+ if (applyValueChange(state, id, value, currentSchema.instanceSolve, forceSolve)) dispatch();
451
+ else emit();
407
452
  },
408
453
  solve,
409
454
  ensureSolved() {
410
- if (args.driver.isSolving || args.driver.hasQueued || state.awaitingAck) return nextSettlement();
411
- if (state.hasPendingChanges || state.hasNeverSolved || state.error || !state.lastResult) return solve();
412
- if (state.blocked) return Promise.resolve({
413
- status: "blocked",
455
+ const busy = args.driver.isSolving || args.driver.hasQueued === true;
456
+ const step = ensureSolvedStep(state, busy);
457
+ if (step === "wait") return nextSettlement();
458
+ if (step === "solve") return solve();
459
+ if (step === "applied") return Promise.resolve({
460
+ status: step,
461
+ result: state.lastResult,
414
462
  diagnostics: state.diagnostics
415
463
  });
416
464
  return Promise.resolve({
417
- status: "applied",
418
- result: state.lastResult,
465
+ status: step,
419
466
  diagnostics: state.diagnostics
420
467
  });
421
468
  },
@@ -424,59 +471,47 @@ function createSolveSession(args) {
424
471
  return () => settledListeners.delete(listener);
425
472
  },
426
473
  loadValues(incoming) {
427
- Object.assign(state.values, incoming);
428
- if (currentSchema?.instanceSolve !== false) dispatch();
429
- else {
430
- state.hasPendingChanges = true;
431
- emit();
432
- }
474
+ if (applyLoadedValues(state, incoming, currentSchema.instanceSolve)) dispatch();
475
+ else emit();
433
476
  },
434
477
  rebuild(schema, scopeKey) {
478
+ if (disposed) return;
435
479
  abandon();
436
480
  currentSchema = schema;
437
481
  lastSent = null;
438
- args.driver.clearCache?.();
439
- state.meshes = [];
440
- state.lastResult = null;
441
- state.error = "";
442
- state.computeErrors = [];
443
- state.computeWarnings = [];
444
- state.diagnostics = [];
445
- state.blocked = false;
446
- state.awaitingAck = false;
447
- state.heldResult = null;
448
- state.pendingValues = {};
449
- state.values = buildInitialValues(schema, scopeKey, readExternalValue);
450
- const f = makeInitialFlags(schema?.instanceSolve);
451
- state.hasPendingChanges = f.hasPendingChanges;
452
- state.hasNeverSolved = f.hasNeverSolved;
453
- if (schema && Object.keys(state.values).length > 0 && schema.instanceSolve !== false) dispatch();
482
+ lastDispatched = null;
483
+ args.driver.reset?.();
484
+ Object.assign(state, createInitialState(schema, scopeKey, readExternalValue));
485
+ if (Object.keys(state.values).length > 0 && schema.instanceSolve !== false) dispatch();
454
486
  else emit();
455
487
  },
456
488
  report(result) {
489
+ const rejected = result.blocked || result.aborted;
490
+ const edited = result.aborted === true || editedSinceSent();
457
491
  if (result.aborted) lastSent = null;
458
- applySolveResult(state, result.blocked || result.aborted ? {
492
+ applySolveResult(state, rejected ? {
459
493
  ...result,
460
494
  outputs: blankOutputs(currentSchema),
461
495
  meshes: []
462
- } : result);
463
- emit();
464
- if (state.awaitingAck || args.driver.hasQueued) return;
465
- settleResult(result);
496
+ } : result, edited);
497
+ if (args.driver.hasQueued) state.heldResult = null;
498
+ if (state.heldResult) return emit();
499
+ land(() => settlementOf(result));
466
500
  },
467
501
  reportError(message) {
502
+ dropHeld();
468
503
  lastSent = null;
469
504
  state.error = message;
470
- emit();
471
- if (!args.driver.hasQueued) settle({
505
+ state.unsolved = null;
506
+ land(() => ({
472
507
  status: "failed",
473
508
  error: message
474
- });
509
+ }));
475
510
  },
476
511
  reportAborted() {
477
512
  lastSent = null;
478
- emit();
479
- if (!args.driver.hasQueued) settle(ABORTED);
513
+ state.unsolved = "aborted";
514
+ land(() => ABORTED);
480
515
  },
481
516
  subscribe(listener) {
482
517
  listeners.add(listener);
@@ -484,14 +519,17 @@ function createSolveSession(args) {
484
519
  },
485
520
  notify: emit,
486
521
  dispose() {
522
+ disposed = true;
487
523
  stopEvents?.();
488
524
  listeners.clear();
489
525
  settledListeners.clear();
526
+ args.driver.reset?.();
490
527
  abandon();
491
528
  }
492
529
  };
493
530
  }
494
531
  // src/client/async-throttle.ts
532
+ /** `run` must settle once its signal fires: the slot frees only then. */
495
533
  function createAsyncThrottle(run, options) {
496
534
  const { runDeadlineMs, onChange } = options;
497
535
  let isRunning = false;
@@ -500,30 +538,29 @@ function createAsyncThrottle(run, options) {
500
538
  function setRunning(next) {
501
539
  if (isRunning === next) return;
502
540
  isRunning = next;
503
- onChange?.();
504
- }
505
- function abortCurrent() {
506
- currentAbortController?.abort();
507
- currentAbortController = null;
541
+ try {
542
+ onChange?.();
543
+ } catch (err) {
544
+ console.error("[Solve/throttle] onChange threw:", err);
545
+ }
508
546
  }
509
547
  async function execute(values) {
510
- abortCurrent();
511
- currentAbortController = new AbortController();
512
- const { signal } = currentAbortController;
548
+ const controller = new AbortController();
549
+ currentAbortController = controller;
513
550
  const deadlineId = setTimeout(() => {
514
551
  console.warn(`[Solve/throttle] run exceeded ${runDeadlineMs}ms — aborting`);
515
- currentAbortController?.abort(new DOMException("Run deadline exceeded", "TimeoutError"));
552
+ controller.abort(new DOMException("Run deadline exceeded", "TimeoutError"));
516
553
  }, runDeadlineMs);
517
554
  setRunning(true);
518
555
  try {
519
- await run(values, signal);
556
+ await run(values, controller.signal);
520
557
  } catch (err) {
521
558
  if (!(err instanceof Error) || err.name !== "AbortError" && err.name !== "TimeoutError") console.error("[Solve/throttle] unhandled error in run:", err);
522
559
  } finally {
523
560
  clearTimeout(deadlineId);
561
+ if (currentAbortController === controller) currentAbortController = null;
524
562
  setRunning(false);
525
- currentAbortController = null;
526
- if (pendingValues !== null) {
563
+ if (pendingValues !== null && !isRunning) {
527
564
  const next = pendingValues;
528
565
  pendingValues = null;
529
566
  execute(next);
@@ -538,7 +575,8 @@ function createAsyncThrottle(run, options) {
538
575
  }
539
576
  function cancel() {
540
577
  pendingValues = null;
541
- abortCurrent();
578
+ currentAbortController?.abort();
579
+ currentAbortController = null;
542
580
  }
543
581
  return {
544
582
  trigger,
@@ -554,16 +592,18 @@ function createAsyncThrottle(run, options) {
554
592
  // src/client/solve-memo.ts
555
593
  /**
556
594
  * Deterministic string key for a set of input values. Sorts object keys at every level so
557
- * two logically-equal inputs built in different key order collide.
595
+ * two logically-equal inputs built in different key order collide. Otherwise matches what
596
+ * `JSON.stringify` sends: `toJSON` is honoured and an `undefined` property is absent, which a
597
+ * server treats differently from `null`.
598
+ *
599
+ * Throws on a `bigint`, as `JSON.stringify` does.
558
600
  */
559
- function stableInputKey(values) {
560
- return serialize(values);
561
- }
562
- function serialize(value) {
601
+ function stableInputKey(input) {
602
+ const value = input !== null && typeof input === "object" && typeof input.toJSON === "function" ? input.toJSON() : input;
563
603
  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
564
- if (Array.isArray(value)) return `[${value.map(serialize).join(",")}]`;
604
+ if (Array.isArray(value)) return `[${value.map(stableInputKey).join(",")}]`;
565
605
  const obj = value;
566
- return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(",")}}`;
606
+ return `{${Object.keys(obj).filter((k) => obj[k] !== void 0).sort().map((k) => `${JSON.stringify(k)}:${stableInputKey(obj[k])}`).join(",")}}`;
567
607
  }
568
608
  function createSolveMemo(options = {}) {
569
609
  const { max = 16, meshPolicy } = options;
@@ -591,22 +631,19 @@ function createSolveMemo(options = {}) {
591
631
  if (hit === void 0) return void 0;
592
632
  entries.delete(key);
593
633
  entries.set(key, hit);
594
- console.info(`[Solve/memo] HIT — served from client memo (${entries.size}/${max})`);
634
+ console.debug(`[Solve/memo] HIT — served from client memo (${entries.size}/${max})`);
595
635
  return copy(hit);
596
636
  },
597
637
  set(values, result) {
598
638
  const key = stableInputKey(values);
599
639
  evict(key);
600
640
  entries.set(key, copy(result));
601
- while (entries.size > max) {
602
- const oldest = entries.keys().next().value;
603
- if (oldest === void 0) break;
641
+ for (const oldest of entries.keys()) {
642
+ if (entries.size <= max) break;
604
643
  evict(oldest);
605
- console.info(`[Solve/memo] evicted LRU entry (cap ${max})`);
606
644
  }
607
645
  },
608
646
  clear() {
609
- if (entries.size > 0) console.info(`[Solve/memo] cleared ${entries.size} entries (definition changed)`);
610
647
  entries.forEach(release);
611
648
  entries.clear();
612
649
  }
@@ -620,62 +657,64 @@ function createSolveMemo(options = {}) {
620
657
  function createRequestResponseDriver(onSolve, getReporter, options) {
621
658
  const { meshPolicy, solveDeadlineMs, onChange, events } = options;
622
659
  const memo = createSolveMemo({ meshPolicy });
660
+ let generation = 0;
623
661
  function reportStopped(signal) {
662
+ console.debug("[Solve/driver] solve stopped (cancelled or timed out)");
624
663
  if (signal.reason?.name === "TimeoutError") getReporter().reportError(`The solve took longer than ${Math.round(solveDeadlineMs / 1e3)} s and was stopped.`);
625
664
  else getReporter().reportAborted();
626
665
  }
627
666
  const throttle = createAsyncThrottle(async (values, signal) => {
628
- const cached = memo.get(values);
629
- if (cached !== void 0) {
630
- if (signal.aborted) return reportStopped(signal);
631
- getReporter().report(cached);
632
- return;
633
- }
667
+ const startedIn = generation;
668
+ let result;
634
669
  try {
635
- const result = await onSolve(values, signal);
636
- if (signal.aborted) {
637
- console.debug("[Solve/driver] solve completed after abort — result discarded");
638
- return reportStopped(signal);
670
+ const cached = memo.get(values);
671
+ if (cached !== void 0) result = cached;
672
+ else {
673
+ result = {
674
+ ...await raceAbort(onSolve(values, signal), signal),
675
+ values
676
+ };
677
+ if (!result.aborted && startedIn === generation) memo.set(values, result);
639
678
  }
640
- const stamped = {
641
- ...result,
642
- values
643
- };
644
- memo.set(values, stamped);
645
- getReporter().report(stamped);
646
679
  } catch (err) {
647
- if (signal.aborted) {
648
- console.debug("[Solve/driver] solve aborted (cancelled or timed out)");
649
- return reportStopped(signal);
650
- }
680
+ if (startedIn !== generation) return;
681
+ if (signal.aborted) return reportStopped(signal);
651
682
  console.warn("[Solve/driver] solve failed:", err);
652
- getReporter().reportError(err instanceof Error ? err.message : String(err));
683
+ return getReporter().reportError(err instanceof Error ? err.message : String(err));
653
684
  }
685
+ if (startedIn !== generation) return;
686
+ if (signal.aborted) return reportStopped(signal);
687
+ getReporter().report(result);
654
688
  }, {
655
689
  runDeadlineMs: solveDeadlineMs,
656
690
  onChange
657
691
  });
692
+ function cancel() {
693
+ throttle.cancel();
694
+ events?.cancelCurrent?.();
695
+ }
658
696
  return {
659
697
  solve(values) {
660
698
  throttle.trigger(values);
661
699
  },
662
- cancel() {
663
- throttle.cancel();
664
- events?.cancelCurrent?.();
665
- },
700
+ cancel,
666
701
  get isSolving() {
667
702
  return throttle.isRunning;
668
703
  },
669
704
  get hasQueued() {
670
705
  return throttle.hasPending;
671
706
  },
672
- clearCache() {
707
+ reset() {
708
+ generation += 1;
709
+ cancel();
673
710
  memo.clear();
674
711
  },
675
712
  ...events ? { onEvent: (listener) => events.subscribe(listener) } : {}
676
713
  };
677
714
  }
678
715
  // src/client/events/solve-event-stream.ts
716
+ const MIN_RECONNECT_MS = 1e3;
717
+ const MAX_RECONNECT_MS = 3e4;
679
718
  function mintStreamId() {
680
719
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID().replace(/-/g, "");
681
720
  return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
@@ -686,11 +725,17 @@ function createSolveEventStream(options) {
686
725
  const doFetch = options.fetch ?? ((...args) => fetch(...args));
687
726
  let source = null;
688
727
  let currentSolveId = null;
728
+ let reconnectMs = MIN_RECONNECT_MS;
729
+ let reconnectTimer = null;
689
730
  function open() {
690
731
  if (source || typeof EventSource === "undefined") return;
691
732
  const url = `${options.endpoint}${options.endpoint.includes("?") ? "&" : "?"}streamId=${streamId}`;
692
- source = new EventSource(url);
693
- source.addEventListener("solve", (raw) => {
733
+ const opened = new EventSource(url);
734
+ source = opened;
735
+ opened.addEventListener("open", () => {
736
+ reconnectMs = MIN_RECONNECT_MS;
737
+ });
738
+ opened.addEventListener("solve", (raw) => {
694
739
  let event;
695
740
  try {
696
741
  event = JSON.parse(raw.data);
@@ -701,6 +746,13 @@ function createSolveEventStream(options) {
701
746
  else if (event.type === "solveEnded" && currentSolveId === event.solveId) currentSolveId = null;
702
747
  for (const listener of listeners) listener(event);
703
748
  });
749
+ opened.addEventListener("error", () => {
750
+ if (opened.readyState !== EventSource.CLOSED || source !== opened) return;
751
+ source = null;
752
+ currentSolveId = null;
753
+ reconnectTimer = setTimeout(open, reconnectMs);
754
+ reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
755
+ });
704
756
  }
705
757
  open();
706
758
  return {
@@ -710,7 +762,6 @@ function createSolveEventStream(options) {
710
762
  },
711
763
  subscribe(listener) {
712
764
  listeners.add(listener);
713
- open();
714
765
  return () => {
715
766
  listeners.delete(listener);
716
767
  };
@@ -724,6 +775,7 @@ function createSolveEventStream(options) {
724
775
  }).catch(() => {});
725
776
  },
726
777
  close() {
778
+ if (reconnectTimer) clearTimeout(reconnectTimer);
727
779
  source?.close();
728
780
  source = null;
729
781
  listeners.clear();
@@ -733,18 +785,18 @@ function createSolveEventStream(options) {
733
785
  }
734
786
  // src/client/compute-fetch-solve-fn.ts
735
787
  /**
736
- * `createComputeFetchSolveFn` — a ready-made `SolveFn` that POSTs to a
737
- * `/api/compute`-shaped HTTP endpoint (the wire contract `runSolvePipeline` +
738
- * `SolveEngine` produce server-side) and unpacks the result.
788
+ * A ready-made `SolveFn` that POSTs to a `/api/compute`-shaped endpoint (the wire contract
789
+ * `SolveEngine` produces) and unpacks the result, with 429 cooldown, session-expiry detection,
790
+ * a bad-response guard, and abort handling at every await.
739
791
  *
740
- * Ports the resilience behavior an interactive solve endpoint needs regardless
741
- * of app — 429 cooldown, session-expiry/redirect detection, non-JSON-response
742
- * guarding, and abort handling at every await point — so a new consumer gets it
743
- * for free instead of re-deriving it by hand. Mesh decoding is NOT built in:
744
- * this package stays renderer-agnostic (see `shared/solve-fn.ts`), so a
745
- * viewer-enabled consumer supplies its own `meshes.extract` hook (typically
746
- * `getThreeObjectsFromComputeResponse` from `@selvajs/visualization/parse`).
792
+ * Mesh decoding is NOT built in: this package stays renderer-agnostic, so a viewer-enabled
793
+ * consumer supplies `meshes.extract` (typically `getThreeObjectsFromComputeResponse` from
794
+ * `@selvajs/visualization/parse`).
747
795
  */
796
+ /** Applied to a 429 that names no wait. */
797
+ const DEFAULT_RETRY_AFTER_S = 5;
798
+ /** A server's hint, not a lock: past this the client tries again anyway. */
799
+ const MAX_RETRY_AFTER_S = 3600;
748
800
  function parseServerTiming(header) {
749
801
  const out = {};
750
802
  if (!header) return out;
@@ -757,21 +809,52 @@ function parseServerTiming(header) {
757
809
  }
758
810
  return out;
759
811
  }
760
- const defaultOnRateLimited = (retryAfterSeconds) => {
761
- throw new Error(`Rate limit reached. Try again in ${Math.ceil(retryAfterSeconds)}s.`);
762
- };
763
- const defaultOnSessionExpired = () => {
764
- throw new Error("Your session has expired. Sign in again in a new tab, then re-run — your inputs are preserved.");
765
- };
812
+ function rateLimitedError(retryAfterSeconds) {
813
+ return /* @__PURE__ */ new Error(`Rate limit reached. Try again in ${Math.ceil(retryAfterSeconds)}s.`);
814
+ }
815
+ function sessionExpiredError() {
816
+ return /* @__PURE__ */ new Error("Your session has expired. Sign in again in a new tab, then re-run — your inputs are preserved.");
817
+ }
818
+ /** `Retry-After` in either RFC form. Null when it names no usable wait. */
819
+ function parseRetryAfter(value) {
820
+ if (!value.trim()) return null;
821
+ const seconds = Number(value);
822
+ if (!Number.isNaN(seconds)) return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
823
+ const at = Date.parse(value);
824
+ return Number.isNaN(at) ? null : Math.max(0, (at - Date.now()) / 1e3);
825
+ }
826
+ function readRetryAfter(header, bodyHint) {
827
+ const seconds = (header === null ? null : parseRetryAfter(header)) ?? (typeof bodyHint === "number" ? parseRetryAfter(String(bodyHint)) : null);
828
+ return seconds === null ? null : Math.min(seconds, MAX_RETRY_AFTER_S);
829
+ }
830
+ function isPlainObject(value) {
831
+ return typeof value === "object" && value !== null && !Array.isArray(value);
832
+ }
833
+ function parseErrorBody(text) {
834
+ try {
835
+ const body = JSON.parse(text);
836
+ if (!isPlainObject(body)) return {};
837
+ return {
838
+ ...typeof body.message === "string" ? { message: body.message } : {},
839
+ retryAfter: body.retryAfter
840
+ };
841
+ } catch {
842
+ return {};
843
+ }
844
+ }
766
845
  function createComputeFetchSolveFn(opts) {
767
846
  const debug = opts.debug ?? false;
768
847
  const log = (...args) => {
769
848
  if (debug) console.info(...args);
770
849
  };
771
850
  let cooldownUntil = 0;
851
+ function rateLimited(retryAfterSeconds) {
852
+ opts.onRateLimited?.(retryAfterSeconds);
853
+ return rateLimitedError(retryAfterSeconds);
854
+ }
772
855
  return async (values, signal) => {
773
856
  const remainingMs = cooldownUntil - Date.now();
774
- if (remainingMs > 0) throw new Error(`Rate limit reached. Try again in ${Math.ceil(remainingMs / 1e3)}s.`);
857
+ if (remainingMs > 0) throw rateLimited(remainingMs / 1e3);
775
858
  const solveStart = performance.now();
776
859
  const inputs = opts.inputs();
777
860
  const outputs = opts.outputs();
@@ -798,49 +881,61 @@ function createComputeFetchSolveFn(opts) {
798
881
  log("[Compute] solve aborted during fetch — empty result discarded");
799
882
  return { outputs: {} };
800
883
  }
801
- throw new Error("Request blocked — your session may have expired. Reload the page to sign in again.", { cause: err });
884
+ throw new Error("Could not reach the server. Check your connection, or reload the page if your session expired.", { cause: err });
802
885
  }
803
886
  if (signal.aborted) {
804
887
  log("[Compute] solve aborted after headers — empty result discarded");
805
888
  return { outputs: {} };
806
889
  }
807
- const redirectedToLogin = res.redirected && new URL(res.url).pathname === "/login";
808
- if (res.status === 401 || redirectedToLogin) (opts.onSessionExpired ?? defaultOnSessionExpired)();
890
+ const redirectedToLogin = res.redirected && new URL(res.url).pathname === (opts.loginPath ?? "/login");
891
+ if (res.status === 401 || redirectedToLogin) {
892
+ opts.onSessionExpired?.();
893
+ throw sessionExpiredError();
894
+ }
809
895
  if (!res.ok) {
810
896
  const errorBody = await res.text().catch(() => "");
811
- let d = {};
812
- try {
813
- d = JSON.parse(errorBody);
814
- } catch {
815
- if (errorBody) console.warn(`[Compute] non-JSON error body (HTTP ${res.status}):`, errorBody.slice(0, 300));
816
- }
817
- if (res.status === 503 || res.status === 504) throw new Error(d.message || "Compute server is offline or unreachable. Please try again later.");
897
+ const { message, retryAfter: bodyHint } = parseErrorBody(errorBody);
898
+ if (errorBody && message === void 0) log(`[Compute] unreadable error body (HTTP ${res.status}, ${errorBody.length} chars)`);
899
+ const retryAfter = readRetryAfter(res.headers.get("Retry-After"), bodyHint);
900
+ if (retryAfter !== null) cooldownUntil = Date.now() + retryAfter * 1e3;
901
+ if (res.status === 503 || res.status === 504) throw new Error(message || "Compute server is offline or unreachable. Please try again later.");
818
902
  if (res.status === 429) {
819
- const retryAfter = Number(res.headers.get("Retry-After")) || Number(d.retryAfter) || 5;
820
- cooldownUntil = Date.now() + retryAfter * 1e3;
821
- (opts.onRateLimited ?? defaultOnRateLimited)(retryAfter);
903
+ const seconds = retryAfter ?? DEFAULT_RETRY_AFTER_S;
904
+ cooldownUntil = Date.now() + seconds * 1e3;
905
+ throw rateLimited(seconds);
822
906
  }
823
- throw new Error(d.message || `Compute error (HTTP ${res.status})`);
907
+ throw new Error(message || `Compute error (HTTP ${res.status})`);
824
908
  }
825
909
  const ttfbMs = performance.now() - solveStart;
826
910
  const downloadStart = performance.now();
827
- const bodyText = await res.text();
828
- if (signal.aborted) {
829
- log("[Compute] solve aborted mid-download — empty result discarded");
830
- return { outputs: {} };
911
+ let bodyText;
912
+ try {
913
+ bodyText = await res.text();
914
+ } catch (err) {
915
+ if (signal.aborted) {
916
+ log("[Compute] solve aborted mid-download — empty result discarded");
917
+ return { outputs: {} };
918
+ }
919
+ throw new Error("The connection dropped while receiving the result. Please try again.", { cause: err });
831
920
  }
832
921
  const downloadMs = performance.now() - downloadStart;
833
- const bytes = bodyText.length;
834
- let solved;
922
+ const bodyChars = bodyText.length;
923
+ const invalidResponse = (cause) => new Error("Received an invalid response from the server. Reload the page and try again.", { cause });
924
+ let parsed;
835
925
  try {
836
- solved = JSON.parse(bodyText);
926
+ parsed = JSON.parse(bodyText);
837
927
  } catch (err) {
838
- throw new Error("Received an invalid response from the server. Reload the page and try again.", { cause: err });
928
+ throw invalidResponse(err);
839
929
  }
930
+ if (!isPlainObject(parsed)) throw invalidResponse();
931
+ const solved = parsed;
840
932
  const processor = new GrasshopperResponseProcessor(solved);
841
933
  const outcome = decodeOutcome(solved.selva?.outcome) ?? outcomeFromComputeMessages(solved.errors ?? [], solved.warnings ?? []);
842
934
  const rejected = outcome.blocked || outcome.aborted;
843
- const meshes = opts.meshes && !rejected ? await opts.meshes.extract(processor.response, { debug }) : [];
935
+ const meshes = opts.meshes && !rejected ? await opts.meshes.extract(processor.response, {
936
+ debug,
937
+ signal
938
+ }) : [];
844
939
  const resultOutputs = {};
845
940
  for (const o of rejected ? [] : outputs) {
846
941
  const byId = processor.getValue({ byId: o.id }, { parseValues: true });
@@ -851,11 +946,11 @@ function createComputeFetchSolveFn(opts) {
851
946
  const serverTiming = parseServerTiming(res.headers.get("Server-Timing"));
852
947
  const serverTotal = serverTiming.total ?? 0;
853
948
  const networkMs = Math.max(0, ttfbMs - serverTotal);
854
- const sizeMB = bytes / 1048576;
855
- const size = sizeMB >= 1 ? `${sizeMB.toFixed(2)} MB` : `${(bytes / 1024).toFixed(0)} KB`;
949
+ const sizeMB = bodyChars / 1048576;
950
+ const size = sizeMB >= 1 ? `${sizeMB.toFixed(2)} MB` : `${(bodyChars / 1024).toFixed(0)} KB`;
856
951
  const mbps = downloadMs > 0 ? (sizeMB / (downloadMs / 1e3)).toFixed(1) : "∞";
857
- const heapMB = performance.memory?.usedJSHeapSize;
858
- const heap = heapMB !== void 0 ? ` | heap=${(heapMB / 1048576).toFixed(0)} MB` : "";
952
+ const heapBytes = performance.memory?.usedJSHeapSize;
953
+ const heap = heapBytes !== void 0 ? ` | heap=${(heapBytes / 1048576).toFixed(0)} MB` : "";
859
954
  log(`[Compute] req=${(payload.length / 1024).toFixed(0)}KB (values ${(valuesBytes / 1024).toFixed(0)}KB) | ttfb=${ttfbMs.toFixed(0)}ms (network≈${networkMs.toFixed(0)} + server ${serverTotal.toFixed(0)}) | download=${downloadMs.toFixed(0)}ms (${size} @ ${mbps} MB/s)${heap}`);
860
955
  if (serverTiming.selva_cache !== void 0) {
861
956
  const cacheMsg = serverTiming.selva_cache ? "HIT — served without calling compute" : serverTiming.rhino_cache ? "HIT — Rhino.Compute's result cache, solved nothing" : "miss — solved on Rhino.Compute";
@@ -869,6 +964,6 @@ function createComputeFetchSolveFn(opts) {
869
964
  }, outcome);
870
965
  };
871
966
  }
872
- export { EMPTY_LIVE_SOLVE, applySolveResult, applyValueChange, buildInitialValues, clearExternalValue, createAsyncThrottle, createComputeFetchSolveFn, createRequestResponseDriver, createSolveEventStream, createSolveMemo, createSolveSession, decodeOutcome, finalizeResult, getExternalInputs, makeInitialFlags, pickInputValues, readExternalValue, reduceLiveSolve, solvePhase, stableInputKey, writeExternalValue };
967
+ export { EMPTY_LIVE_SOLVE, clearExternalValue, createComputeFetchSolveFn, createRequestResponseDriver, createSolveEventStream, createSolveSession, decodeOutcome, finalizeResult, getExternalInputs, readExternalValue, stableInputKey, writeExternalValue };
873
968
 
874
969
  //# sourceMappingURL=client.js.map