@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/README.md +17 -11
- package/dist/abort-CNJ8Sn4g.js +13 -0
- package/dist/abort-CNJ8Sn4g.js.map +1 -0
- package/dist/client.d.ts +34 -135
- package/dist/client.js +335 -240
- package/dist/client.js.map +1 -1
- package/dist/{outcome-DsNmx27P.d.ts → outcome-Bk9mnMi0.d.ts} +9 -28
- package/dist/{outcome-ai_9fm43.js → outcome-DNrffAXp.js} +48 -46
- package/dist/outcome-DNrffAXp.js.map +1 -0
- package/dist/server.d.ts +97 -222
- package/dist/server.js +657 -423
- package/dist/server.js.map +1 -1
- package/dist/shared.d.ts +2 -2
- package/dist/shared.js +1 -1
- package/package.json +9 -17
- package/dist/outcome-ai_9fm43.js.map +0 -1
package/dist/client.js
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
|
-
import { n as
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
11
|
-
if (!
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
17
|
-
if (!
|
|
18
|
-
|
|
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
|
|
29
|
-
|
|
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:
|
|
85
|
+
ended: { kind: readEndedKind(payload) }
|
|
70
86
|
};
|
|
71
87
|
case "diagnostic": {
|
|
72
|
-
const
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
146
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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.
|
|
201
|
-
|
|
202
|
-
return state;
|
|
231
|
+
state.hasPendingChanges &&= editedSinceSent;
|
|
232
|
+
return;
|
|
203
233
|
}
|
|
204
|
-
state.awaitingAck = false;
|
|
205
234
|
state.heldResult = null;
|
|
206
|
-
|
|
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
|
|
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.
|
|
224
|
-
state.hasPendingChanges = false;
|
|
252
|
+
state.hasPendingChanges &&= editedSinceSent;
|
|
225
253
|
state.hasNeverSolved = false;
|
|
226
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
261
|
-
const state =
|
|
262
|
-
|
|
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
|
|
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
|
|
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
|
|
330
|
+
function settlementOf(result) {
|
|
300
331
|
const diagnostics = state.diagnostics;
|
|
301
|
-
if (result.aborted) return
|
|
332
|
+
if (result.aborted) return {
|
|
302
333
|
status: "aborted",
|
|
303
334
|
diagnostics
|
|
304
|
-
}
|
|
305
|
-
if (result.blocked) return
|
|
335
|
+
};
|
|
336
|
+
if (result.blocked) return {
|
|
306
337
|
status: "blocked",
|
|
307
338
|
diagnostics
|
|
308
|
-
}
|
|
309
|
-
|
|
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.
|
|
406
|
+
return state.heldResult !== null;
|
|
353
407
|
},
|
|
354
408
|
acknowledge() {
|
|
355
|
-
if (!state.awaitingAck) return;
|
|
356
409
|
const held = state.heldResult;
|
|
357
|
-
|
|
410
|
+
if (!held) return;
|
|
358
411
|
state.heldResult = null;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (held) settleResult(held);
|
|
412
|
+
commitSolveResult(state, held, editedSinceSent());
|
|
413
|
+
land(() => settlementOf(held));
|
|
362
414
|
},
|
|
363
415
|
discard() {
|
|
364
|
-
if (!state.
|
|
365
|
-
state.awaitingAck = false;
|
|
416
|
+
if (!state.heldResult) return;
|
|
366
417
|
state.heldResult = null;
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
if (
|
|
413
|
-
|
|
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:
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
state
|
|
441
|
-
state.
|
|
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,
|
|
492
|
+
applySolveResult(state, rejected ? {
|
|
459
493
|
...result,
|
|
460
494
|
outputs: blankOutputs(currentSchema),
|
|
461
495
|
meshes: []
|
|
462
|
-
} : result);
|
|
463
|
-
|
|
464
|
-
if (state.
|
|
465
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
511
|
-
currentAbortController =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
560
|
-
|
|
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(
|
|
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)}:${
|
|
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.
|
|
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
|
-
|
|
602
|
-
|
|
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
|
|
629
|
-
|
|
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
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
|
|
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 (
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
source
|
|
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
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
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
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
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("
|
|
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)
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
820
|
-
cooldownUntil = Date.now() +
|
|
821
|
-
|
|
903
|
+
const seconds = retryAfter ?? DEFAULT_RETRY_AFTER_S;
|
|
904
|
+
cooldownUntil = Date.now() + seconds * 1e3;
|
|
905
|
+
throw rateLimited(seconds);
|
|
822
906
|
}
|
|
823
|
-
throw new Error(
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
834
|
-
|
|
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
|
-
|
|
926
|
+
parsed = JSON.parse(bodyText);
|
|
837
927
|
} catch (err) {
|
|
838
|
-
throw
|
|
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, {
|
|
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 =
|
|
855
|
-
const size = sizeMB >= 1 ? `${sizeMB.toFixed(2)} MB` : `${(
|
|
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
|
|
858
|
-
const heap =
|
|
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,
|
|
967
|
+
export { EMPTY_LIVE_SOLVE, clearExternalValue, createComputeFetchSolveFn, createRequestResponseDriver, createSolveEventStream, createSolveSession, decodeOutcome, finalizeResult, getExternalInputs, readExternalValue, stableInputKey, writeExternalValue };
|
|
873
968
|
|
|
874
969
|
//# sourceMappingURL=client.js.map
|