@selvajs/solve 2.0.0-beta.9 → 2.0.1

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,2 +1,969 @@
1
- import{n as e,r as t,t as n}from"./outcome-BOrAeRAQ.js";import{getDefaultValue as r,getInputItems as i}from"@selvajs/schemas";import{GrasshopperResponseProcessor as a}from"@selvajs/compute/grasshopper";function o(e,t){return`external:${e}:${t}`}function s(e){let{scopeKey:t,inputId:n,value:r}=e;t&&n&&typeof sessionStorage<`u`&&sessionStorage.setItem(o(t,n),JSON.stringify(r))}function c(e){let{scopeKey:t,inputId:n}=e;if(!t||!n||typeof sessionStorage>`u`)return;let r=sessionStorage.getItem(o(t,n));if(r!==null)try{return JSON.parse(r)}catch{return}}function l(e){let{scopeKey:t,inputId:n}=e;t&&n&&typeof sessionStorage<`u`&&sessionStorage.removeItem(o(t,n))}function u(e){return i(e).filter(e=>e.source?.kind===`client`).map(e=>({paramId:e.paramId,displayName:e.displayName??e.paramId}))}const d=Object.freeze({solveId:null,running:!1,diagnostics:[],progress:/* @__PURE__ */ new Map,ended:null,other:[],lastSeq:0});function f(e,t){let r=t.solveId===e.solveId?e:{...d,solveId:t.solveId,running:!0};if(t.seq<=r.lastSeq)return e;let i={...r,lastSeq:t.seq},a=t.payload??{};switch(t.type){case`solveStarted`:return{...i,running:!0};case`solveEnded`:return{...i,running:!1,ended:{kind:typeof a.kind==`string`?a.kind:`ok`}};case`diagnostic`:{let[e]=n({diagnostics:[a]})?.diagnostics??[];return e?{...i,diagnostics:[...i.diagnostics,e]}:i}case`progress`:{let e=typeof a.source==`string`?a.source:``,t=new Map(i.progress),n=p(a),r=i.progress.get(e)?.fraction;return r!==void 0&&n.fraction!==void 0&&n.fraction<r&&(n.fraction=r),t.set(e,n),{...i,progress:t}}default:return{...i,other:[...i.other,t]}}}function p(e){let t=e=>typeof e==`number`&&Number.isFinite(e)?e:void 0,n=t(e.fraction),r=t(e.done),i=t(e.total),a=t(e.nextFraction),o=t(e.stepMs),s=e=>Math.min(1,Math.max(0,e));return{...typeof e.source==`string`?{source:e.source}:{},...n===void 0?{}:{fraction:s(n)},...r===void 0?{}:{done:r},...i===void 0?{}:{total:i},...typeof e.label==`string`?{label:e.label}:{},...a===void 0?{}:{nextFraction:s(a)},...o!==void 0&&o>0?{stepMs:o}:{}}}function m(e,t,n){let i=new Set(u(e).map(e=>e.paramId)),a={};for(let o of e.inputs)if(i.has(o.id)){let e=n({scopeKey:t,inputId:o.id});e!==void 0&&(a[o.id]=e)}else a[o.id]=o.default??r(o.paramType);for(let t of e.outputs)a[t.id]=null;return a}function h(e){let t=e===!1;return{hasPendingChanges:t,hasNeverSolved:t}}function g(e,t,n,r){return e.values[t]=n,r===!1?(e.pendingValues[t]=n,e.hasPendingChanges=!0,{state:e,shouldSolve:!1}):{state:e,shouldSolve:!0}}function _(e,t){if(!e?.inputs)return t;let n={};for(let r of e.inputs)r.id in t&&(n[r.id]=t[r.id]);return n}function v(e,t){return e.error=``,e.computeErrors=t.errors??[],e.computeWarnings=t.warnings??[],e.diagnostics=t.diagnostics??[],e.blocked=t.blocked??!1,e.blocked?(e.awaitingAck=!1,e.heldResult=null,b(e,t)):S(e.diagnostics)?(e.awaitingAck=!0,e.heldResult=t,e.pendingValues={},e.hasPendingChanges=!1,e):(e.awaitingAck=!1,e.heldResult=null,b(e,t))}function y(e){let t={};for(let n of e?.outputs??[])t[n.id]=null;return t}function b(e,t){e.meshes=t.meshes??[];let{meshes:n,...r}=t;return e.lastResult=r,Object.assign(e.values,t.outputs),e.pendingValues={},e.hasPendingChanges=!1,e.hasNeverSolved=!1,e}function x(e,t){return t?`solving`:e.awaitingAck?`review`:e.blocked?`blocked`:`idle`}function S(e){return(e??[]).some(e=>e.isGate&&e.level!==`remark`)}function C(e){let t=e.schema,n=h(t?.instanceSolve),r={values:m(t,e.scopeKey,c),error:``,computeErrors:[],computeWarnings:[],diagnostics:[],blocked:!1,awaitingAck:!1,heldResult:null,meshes:[],pendingValues:{},hasPendingChanges:n.hasPendingChanges,hasNeverSolved:n.hasNeverSolved,lastResult:null},i=/* @__PURE__ */ new Set,a=()=>i.forEach(e=>e()),o=/* @__PURE__ */ new Set,s=[],l={status:`aborted`,diagnostics:[]};function u(){return new Promise(e=>s.push(e))}function p(e){let t=s;s=[],t.forEach(t=>t(e)),o.forEach(t=>t(e))}function S(){let e=s;s=[],e.forEach(e=>e(l))}function C(e){let t=r.diagnostics;if(e.aborted)return p({status:`aborted`,diagnostics:t});if(e.blocked)return p({status:`blocked`,diagnostics:t});p({status:`applied`,result:r.lastResult,diagnostics:t})}let w=d,T=e.driver.onEvent?.(e=>{let t=f(w,e);t!==w&&(w=t,a())});function E(){let e=u();return D(),e}function D(){e.driver.solve({..._(t,r.values)}),a()}return{get values(){return r.values},get error(){return r.error},get computeErrors(){return r.computeErrors},get computeWarnings(){return r.computeWarnings},get diagnostics(){return r.diagnostics},get blocked(){return r.blocked},get awaitingAck(){return r.awaitingAck},acknowledge(){if(!r.awaitingAck)return;let e=r.heldResult;r.awaitingAck=!1,r.heldResult=null,e&&b(r,e),a(),e&&C(e)},discard(){r.awaitingAck&&(r.awaitingAck=!1,r.heldResult=null,a(),p({status:`discarded`,diagnostics:r.diagnostics}))},get live(){return w},get phase(){return x(r,e.driver.isSolving)},abort(){e.driver.cancel()},get meshes(){return r.meshes},get lastResult(){return r.lastResult},get hasPendingChanges(){return r.hasPendingChanges},get hasNeverSolved(){return r.hasNeverSolved},get isSolving(){return e.driver.isSolving},setValue(e,n,i=!1){let{shouldSolve:o}=g(r,e,n,t?.instanceSolve);o||i?(i&&!o&&(r.pendingValues={},r.hasPendingChanges=!1),D()):a()},solve:E,ensureSolved(){return e.driver.isSolving||e.driver.hasQueued||r.awaitingAck?u():r.hasPendingChanges||r.hasNeverSolved||r.error||!r.lastResult?E():r.blocked?Promise.resolve({status:`blocked`,diagnostics:r.diagnostics}):Promise.resolve({status:`applied`,result:r.lastResult,diagnostics:r.diagnostics})},onSettled(e){return o.add(e),()=>o.delete(e)},loadValues(e){Object.assign(r.values,e),t?.instanceSolve===!1?(r.hasPendingChanges=!0,a()):D()},rebuild(n,i){S(),t=n,e.driver.clearCache?.(),r.meshes=[],r.lastResult=null,r.error=``,r.computeErrors=[],r.computeWarnings=[],r.diagnostics=[],r.blocked=!1,r.awaitingAck=!1,r.heldResult=null,r.pendingValues={},r.values=m(n,i,c);let o=h(n?.instanceSolve);r.hasPendingChanges=o.hasPendingChanges,r.hasNeverSolved=o.hasNeverSolved,n&&Object.keys(r.values).length>0&&n.instanceSolve!==!1?D():a()},report(n){v(r,n.blocked||n.aborted?{...n,outputs:y(t),meshes:[]}:n),a(),!(r.awaitingAck||e.driver.hasQueued)&&C(n)},reportError(t){r.error=t,a(),e.driver.hasQueued||p({status:`failed`,error:t})},reportAborted(){a(),e.driver.hasQueued||p(l)},subscribe(e){return i.add(e),()=>i.delete(e)},notify:a,dispose(){T?.(),i.clear(),o.clear(),S()}}}function w(e,t){let{runDeadlineMs:n,onChange:r}=t,i=!1,a=null,o=null;function s(e){i!==e&&(i=e,r?.())}function c(){o?.abort(),o=null}async function l(t){c(),o=new AbortController;let{signal:r}=o,i=setTimeout(()=>{console.warn(`[Solve/throttle] run exceeded ${n}ms — aborting`),o?.abort(new DOMException(`Run deadline exceeded`,`TimeoutError`))},n);s(!0);try{await e(t,r)}catch(e){(!(e instanceof Error)||e.name!==`AbortError`&&e.name!==`TimeoutError`)&&console.error(`[Solve/throttle] unhandled error in run:`,e)}finally{if(clearTimeout(i),s(!1),o=null,a!==null){let e=a;a=null,l(e)}}}function u(e){i?(a!==null&&console.debug(`[Solve/throttle] superseded pending run (latest-wins)`),a=e):l(e)}function d(){a=null,c()}return{trigger:u,get isRunning(){return i},get hasPending(){return a!==null},cancel:d}}function T(e){return E(e)}function E(e){if(typeof e!=`object`||!e)return JSON.stringify(e)??`null`;if(Array.isArray(e))return`[${e.map(E).join(`,`)}]`;let t=e;return`{${Object.keys(t).sort().map(e=>`${JSON.stringify(e)}:${E(t[e])}`).join(`,`)}}`}function D(e={}){let{max:t=16,meshPolicy:n}=e,r=/* @__PURE__ */ new Map;function i(e){e.meshes?.length&&n?.release(e.meshes)}function a(e){return!n||!e.meshes?.length?e:{...e,meshes:n.clone(e.meshes)}}function o(e){let t=r.get(e);t!==void 0&&(r.delete(e),i(t))}return{get(e){let n=T(e),i=r.get(n);if(i!==void 0)return r.delete(n),r.set(n,i),console.info(`[Solve/memo] HIT — served from client memo (${r.size}/${t})`),a(i)},set(e,n){let i=T(e);for(o(i),r.set(i,a(n));r.size>t;){let e=r.keys().next().value;if(e===void 0)break;o(e),console.info(`[Solve/memo] evicted LRU entry (cap ${t})`)}},clear(){r.size>0&&console.info(`[Solve/memo] cleared ${r.size} entries (definition changed)`),r.forEach(i),r.clear()}}}function O(e,t,n){let{meshPolicy:r,solveDeadlineMs:i,onChange:a,events:o}=n,s=D({meshPolicy:r});function c(e){e.reason?.name===`TimeoutError`?t().reportError(`The solve took longer than ${Math.round(i/1e3)} s and was stopped.`):t().reportAborted()}let l=w(async(n,r)=>{let i=s.get(n);if(i!==void 0){if(r.aborted)return c(r);t().report(i)}else try{let i=await e(n,r);if(r.aborted)return console.debug(`[Solve/driver] solve completed after abort — result discarded`),c(r);let a={...i,values:n};s.set(n,a),t().report(a)}catch(e){if(r.aborted)return console.debug(`[Solve/driver] solve aborted (cancelled or timed out)`),c(r);console.warn(`[Solve/driver] solve failed:`,e),t().reportError(e instanceof Error?e.message:String(e))}},{runDeadlineMs:i,onChange:a});return{solve(e){l.trigger(e)},cancel(){l.cancel(),o?.cancelCurrent?.()},get isSolving(){return l.isRunning},get hasQueued(){return l.hasPending},clearCache(){s.clear()},...o?{onEvent:e=>o.subscribe(e)}:{}}}function k(){return typeof crypto<`u`&&`randomUUID`in crypto?crypto.randomUUID().replace(/-/g,``):Array.from({length:32},()=>Math.floor(Math.random()*16).toString(16)).join(``)}function A(e){let t=k(),n=/* @__PURE__ */ new Set,r=e.fetch??((...e)=>fetch(...e)),i=null,a=null;function o(){if(i||typeof EventSource>`u`)return;let r=`${e.endpoint}${e.endpoint.includes(`?`)?`&`:`?`}streamId=${t}`;i=new EventSource(r),i.addEventListener(`solve`,e=>{let t;try{t=JSON.parse(e.data)}catch{return}t.type===`solveStarted`?a=t.solveId:t.type===`solveEnded`&&a===t.solveId&&(a=null);for(let e of n)e(t)})}return o(),{streamId:t,get currentSolveId(){return a},subscribe(e){return n.add(e),o(),()=>{n.delete(e)}},cancelCurrent(){let t=a;t&&r(e.cancelEndpoint(t),{method:`POST`,keepalive:!0}).catch(()=>{})},close(){i?.close(),i=null,n.clear(),a=null}}}function j(e){let t={};if(!e)return t;for(let n of e.split(`,`)){let[e,...r]=n.split(`;`).map(e=>e.trim()),i=r.find(e=>e.startsWith(`dur=`));if(!e||!i)continue;let a=Number(i.slice(4));Number.isFinite(a)&&(t[e]=a)}return t}const M=e=>{throw Error(`Rate limit reached. Try again in ${Math.ceil(e)}s.`)},N=()=>{throw Error(`Your session has expired. Sign in again in a new tab, then re-run — your inputs are preserved.`)};function P(r){let i=r.debug??!1,o=(...e)=>{i&&console.info(...e)},s=0;return async(c,l)=>{let u=s-Date.now();if(u>0)throw Error(`Rate limit reached. Try again in ${Math.ceil(u/1e3)}s.`);let d=performance.now(),f=r.inputs(),p=r.outputs(),m=r.channel?.(),h=r.versionId?.(),g=r.streamId?.(),_=JSON.stringify({inputs:f,values:c,definitionUrl:r.definitionUrl(),...h?{versionId:h}:m===`draft`?{channel:`draft`}:{},...g?{streamId:g}:{}}),v;try{v=await fetch(r.endpoint,{method:`POST`,headers:{"Content-Type":`application/json`},body:_,signal:l})}catch(e){if(l.aborted)return o(`[Compute] solve aborted during fetch — empty result discarded`),{outputs:{}};throw Error(`Request blocked — your session may have expired. Reload the page to sign in again.`,{cause:e})}if(l.aborted)return o(`[Compute] solve aborted after headers — empty result discarded`),{outputs:{}};let y=v.redirected&&new URL(v.url).pathname===`/login`;if((v.status===401||y)&&(r.onSessionExpired??N)(),!v.ok){let e=await v.text().catch(()=>``),t={};try{t=JSON.parse(e)}catch{e&&console.warn(`[Compute] non-JSON error body (HTTP ${v.status}):`,e.slice(0,300))}if(v.status===503||v.status===504)throw Error(t.message||`Compute server is offline or unreachable. Please try again later.`);if(v.status===429){let e=Number(v.headers.get(`Retry-After`))||Number(t.retryAfter)||5;s=Date.now()+e*1e3,(r.onRateLimited??M)(e)}throw Error(t.message||`Compute error (HTTP ${v.status})`)}let b=performance.now()-d,x=performance.now(),S=await v.text();if(l.aborted)return o(`[Compute] solve aborted mid-download — empty result discarded`),{outputs:{}};let C=performance.now()-x,w=S.length,T;try{T=JSON.parse(S)}catch(e){throw Error(`Received an invalid response from the server. Reload the page and try again.`,{cause:e})}let E=new a(T),D=n(T.selva?.outcome)??t(T.errors??[],T.warnings??[]),O=D.blocked||D.aborted,k=r.meshes&&!O?await r.meshes.extract(E.response,{debug:i}):[],A={};for(let e of O?[]:p){let t=E.getValue({byId:e.id},{parseValues:!0});A[e.id]=t??(e.nickname?E.getValue({byName:e.nickname},{parseValues:!0}):void 0)}if(i){let e=JSON.stringify(c).length,t=j(v.headers.get(`Server-Timing`)),n=t.total??0,r=Math.max(0,b-n),i=w/1048576,a=i>=1?`${i.toFixed(2)} MB`:`${(w/1024).toFixed(0)} KB`,s=C>0?(i/(C/1e3)).toFixed(1):`∞`,l=performance.memory?.usedJSHeapSize,u=l===void 0?``:` | heap=${(l/1048576).toFixed(0)} MB`;if(o(`[Compute] req=${(_.length/1024).toFixed(0)}KB (values ${(e/1024).toFixed(0)}KB) | ttfb=${b.toFixed(0)}ms (network≈${r.toFixed(0)} + server ${n.toFixed(0)}) | download=${C.toFixed(0)}ms (${a} @ ${s} MB/s)${u}`),t.selva_cache!==void 0){let e=t.selva_cache?`HIT — served without calling compute`:t.rhino_cache?`HIT — Rhino.Compute's result cache, solved nothing`:`miss — solved on Rhino.Compute`;o(`[Compute] └─ cache: ${e}`)}}return e({outputs:A,meshes:k,source:T},D)}}export{d as EMPTY_LIVE_SOLVE,v as applySolveResult,g as applyValueChange,m as buildInitialValues,l as clearExternalValue,w as createAsyncThrottle,P as createComputeFetchSolveFn,O as createRequestResponseDriver,A as createSolveEventStream,D as createSolveMemo,C as createSolveSession,n as decodeOutcome,e as finalizeResult,u as getExternalInputs,h as makeInitialFlags,_ as pickInputValues,c as readExternalValue,f as reduceLiveSolve,x as solvePhase,T as stableInputKey,s as writeExternalValue};
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";
3
+ import { getDefaultValue, getInputItems } from "@selvajs/schemas";
4
+ import { GrasshopperResponseProcessor } from "@selvajs/compute/grasshopper";
5
+ // src/client/external-storage.ts
6
+ const STORAGE_PREFIX = "external";
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
+ }
19
+ }
20
+ function writeExternalValue(args) {
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
+ }
28
+ }
29
+ function readExternalValue(ref) {
30
+ const slot = openSlot(ref);
31
+ if (!slot) return void 0;
32
+ const raw = slot.storage.getItem(slot.key);
33
+ if (raw === null) return void 0;
34
+ try {
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return;
38
+ }
39
+ }
40
+ function clearExternalValue(ref) {
41
+ const slot = openSlot(ref);
42
+ slot?.storage.removeItem(slot.key);
43
+ }
44
+ function getExternalInputs(schema) {
45
+ return getInputItems(schema).filter((item) => item.source?.kind === "client").map((item) => ({
46
+ paramId: item.paramId,
47
+ displayName: item.displayName ?? item.paramId
48
+ }));
49
+ }
50
+ // src/client/events/live-solve.ts
51
+ const EMPTY_LIVE_SOLVE = Object.freeze({
52
+ solveId: null,
53
+ running: false,
54
+ diagnostics: [],
55
+ progress: /* @__PURE__ */ new Map(),
56
+ ended: null,
57
+ other: [],
58
+ lastSeq: 0
59
+ });
60
+ function readEndedKind(payload) {
61
+ const { kind } = payload;
62
+ return kind === "ok" || kind === "blocked" || kind === "aborted" ? kind : "error";
63
+ }
64
+ function reduceLiveSolve(state, event) {
65
+ if (event.type === "solveEnded" && state.solveId !== null && event.solveId !== state.solveId) return state;
66
+ const base = event.solveId === state.solveId ? state : {
67
+ ...EMPTY_LIVE_SOLVE,
68
+ solveId: event.solveId,
69
+ running: true
70
+ };
71
+ if (event.seq <= base.lastSeq) return state;
72
+ const next = {
73
+ ...base,
74
+ lastSeq: event.seq
75
+ };
76
+ const payload = event.payload ?? {};
77
+ switch (event.type) {
78
+ case "solveStarted": return {
79
+ ...next,
80
+ running: true
81
+ };
82
+ case "solveEnded": return {
83
+ ...next,
84
+ running: false,
85
+ ended: { kind: readEndedKind(payload) }
86
+ };
87
+ case "diagnostic": {
88
+ const diagnostic = decodeDiagnostic(payload);
89
+ return diagnostic ? {
90
+ ...next,
91
+ diagnostics: [...next.diagnostics, diagnostic]
92
+ } : next;
93
+ }
94
+ case "progress": {
95
+ const source = typeof payload.source === "string" ? payload.source : "";
96
+ const progress = new Map(next.progress);
97
+ const report = readProgress(payload);
98
+ const previous = next.progress.get(source)?.fraction;
99
+ if (previous !== void 0 && (report.fraction ?? 0) < previous) report.fraction = previous;
100
+ progress.set(source, report);
101
+ return {
102
+ ...next,
103
+ progress
104
+ };
105
+ }
106
+ default: return {
107
+ ...next,
108
+ other: [...next.other, event]
109
+ };
110
+ }
111
+ }
112
+ function readProgress(payload) {
113
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
114
+ const fraction = num(payload.fraction);
115
+ const done = num(payload.done);
116
+ const total = num(payload.total);
117
+ const nextFraction = num(payload.nextFraction);
118
+ const stepMs = num(payload.stepMs);
119
+ const clamp = (v) => Math.min(1, Math.max(0, v));
120
+ return {
121
+ ...typeof payload.source === "string" ? { source: payload.source } : {},
122
+ ...fraction !== void 0 ? { fraction: clamp(fraction) } : {},
123
+ ...done !== void 0 ? { done } : {},
124
+ ...total !== void 0 ? { total } : {},
125
+ ...typeof payload.label === "string" ? { label: payload.label } : {},
126
+ ...nextFraction !== void 0 ? { nextFraction: clamp(nextFraction) } : {},
127
+ ...stepMs !== void 0 && stepMs > 0 ? { stepMs } : {}
128
+ };
129
+ }
130
+ // src/client/session/solve-session-core.ts
131
+ /**
132
+ * Client-sourced inputs are hydrated from `read` and left absent when no stored value
133
+ * exists, so the missing-inputs panel can detect them. Other inputs fall back to their
134
+ * declared default, then the paramType default.
135
+ */
136
+ function buildInitialValues(schema, scopeKey, read) {
137
+ const clientSet = new Set(getExternalInputs(schema).map((e) => e.paramId));
138
+ const values = {};
139
+ for (const input of schema.inputs) {
140
+ if (clientSet.has(input.id)) {
141
+ const stored = read({
142
+ scopeKey,
143
+ inputId: input.id
144
+ });
145
+ if (stored !== void 0) values[input.id] = stored;
146
+ continue;
147
+ }
148
+ values[input.id] = input.default ?? getDefaultValue(input.paramType);
149
+ }
150
+ for (const output of schema.outputs) values[output.id] = null;
151
+ return values;
152
+ }
153
+ /** Manual-solve schemas (instanceSolve === false) start dirty; auto-solve schemas start clean. */
154
+ function createInitialState(schema, scopeKey, read) {
155
+ const manual = schema.instanceSolve === false;
156
+ return {
157
+ values: buildInitialValues(schema, scopeKey, read),
158
+ error: "",
159
+ computeErrors: [],
160
+ computeWarnings: [],
161
+ diagnostics: [],
162
+ blocked: false,
163
+ heldResult: null,
164
+ meshes: [],
165
+ hasPendingChanges: manual,
166
+ hasNeverSolved: manual,
167
+ lastResult: null,
168
+ unsolved: null
169
+ };
170
+ }
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) {
176
+ state.values[id] = value;
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;
187
+ }
188
+ const RESEND_UNCHANGED = /* @__PURE__ */ new Set(["dynamicValueList", "file"]);
189
+ /**
190
+ * Whether setting `value` would solve again for nothing: it is what the last solve sent and what
191
+ * the session holds, and nothing failed or aborted since (`lastSent` is null then). A field that
192
+ * reports on input and again on blur would otherwise solve twice, and Grasshopper re-applies every
193
+ * dynamic list on each update, so the repeat is a real solve. Objects never count as repeats.
194
+ */
195
+ function isRepeatOfLastSolve(schema, lastSent, values, id, value) {
196
+ if (!lastSent || !(id in lastSent)) return false;
197
+ if (value !== null && typeof value === "object") return false;
198
+ if (!Object.is(lastSent[id], value) || !Object.is(values[id], value)) return false;
199
+ const paramType = schema.inputs.find((input) => input.id === id)?.paramType;
200
+ return !RESEND_UNCHANGED.has(paramType ?? "");
201
+ }
202
+ /**
203
+ * Solve outputs live in the same `values` map as inputs (so widgets like dynamic value
204
+ * lists can read them) but must not be echoed back to the driver: a measured 6.4 MB
205
+ * options-list output showed this can re-upload a multi-MB payload no backend reads.
206
+ */
207
+ function pickInputValues(schema, values) {
208
+ const picked = {};
209
+ for (const input of schema.inputs) if (Object.hasOwn(values, input.id)) picked[input.id] = values[input.id];
210
+ return picked;
211
+ }
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) {
223
+ state.error = "";
224
+ state.computeErrors = result.errors ?? [];
225
+ state.computeWarnings = result.warnings ?? [];
226
+ state.diagnostics = result.diagnostics ?? [];
227
+ state.blocked = result.blocked ?? false;
228
+ state.unsolved = result.aborted ? "aborted" : null;
229
+ if (!state.blocked && needsAcknowledgement(state.diagnostics)) {
230
+ state.heldResult = result;
231
+ state.hasPendingChanges &&= editedSinceSent;
232
+ return;
233
+ }
234
+ state.heldResult = null;
235
+ commitSolveResult(state, result, editedSinceSent);
236
+ }
237
+ /**
238
+ * Every schema output set to null. A blocked solve reports `outputs: {}`, and merging that into
239
+ * `values` would leave the previous solve's outputs behind.
240
+ */
241
+ function blankOutputs(schema) {
242
+ const blank = {};
243
+ for (const output of schema.outputs) blank[output.id] = null;
244
+ return blank;
245
+ }
246
+ /** Writes a result into the state. Shared by the clean path and by `acknowledge()`. */
247
+ function commitSolveResult(state, result, editedSinceSent) {
248
+ state.meshes = result.meshes ?? [];
249
+ const { meshes: _meshes, ...retained } = result;
250
+ state.lastResult = retained;
251
+ Object.assign(state.values, result.outputs);
252
+ state.hasPendingChanges &&= editedSinceSent;
253
+ state.hasNeverSolved = false;
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";
262
+ }
263
+ /**
264
+ * One value for what a host should show. Solving wins: a new solve supersedes a held or refused
265
+ * result. `isSolving` is passed in because the driver owns it.
266
+ */
267
+ function solvePhase(state, isSolving) {
268
+ if (isSolving) return "solving";
269
+ if (state.heldResult) return "review";
270
+ if (state.blocked) return "blocked";
271
+ return "idle";
272
+ }
273
+ /**
274
+ * Whether this solve's messages still need the user's acknowledgement.
275
+ *
276
+ * Only a Selva Message component gates. Grasshopper raises warnings constantly for reasons that
277
+ * concern whoever is editing the definition, not whoever is using the app — a component
278
+ * complaining about its own inputs is not something an end user can act on, and interrupting
279
+ * them for it trains them to dismiss the one message that mattered.
280
+ *
281
+ * Every solve that gates asks again, by design — an acknowledgement covers the solve in front of
282
+ * the user, never a future one. Under `instanceSolve` with a live warning that means a dialog
283
+ * per solve, which is the intended checkpoint, not an oversight.
284
+ *
285
+ * A transport that reports no structure has no diagnostics, so nothing gates: the messages are
286
+ * still shown, just without interrupting.
287
+ */
288
+ function needsAcknowledgement(diagnostics) {
289
+ return diagnostics.some((d) => d.isGate && d.level !== "remark");
290
+ }
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
+ }
300
+ function createSolveSession(args) {
301
+ let currentSchema = args.schema;
302
+ let lastSent = null;
303
+ let lastDispatched = null;
304
+ const state = createInitialState(currentSchema, args.scopeKey, readExternalValue);
305
+ let disposed = false;
306
+ const listeners = /* @__PURE__ */ new Set();
307
+ const emit = () => notifyAll(listeners, void 0);
308
+ const settledListeners = /* @__PURE__ */ new Set();
309
+ let waiters = [];
310
+ const ABORTED = {
311
+ status: "aborted",
312
+ diagnostics: []
313
+ };
314
+ function nextSettlement() {
315
+ if (disposed) return Promise.resolve(ABORTED);
316
+ return new Promise((resolve) => waiters.push(resolve));
317
+ }
318
+ function settle(settlement) {
319
+ const waiting = waiters;
320
+ waiters = [];
321
+ waiting.forEach((resolve) => resolve(settlement));
322
+ notifyAll(settledListeners, settlement);
323
+ }
324
+ /** Resolves waiting callers without telling listeners: no solve ended, the session moved on. */
325
+ function abandon() {
326
+ const waiting = waiters;
327
+ waiters = [];
328
+ waiting.forEach((resolve) => resolve(ABORTED));
329
+ }
330
+ function settlementOf(result) {
331
+ const diagnostics = state.diagnostics;
332
+ if (result.aborted) return {
333
+ status: "aborted",
334
+ diagnostics
335
+ };
336
+ if (result.blocked) return {
337
+ status: "blocked",
338
+ diagnostics
339
+ };
340
+ return {
341
+ status: "applied",
342
+ result: state.lastResult,
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
361
+ });
362
+ }
363
+ function editedSinceSent() {
364
+ return inputsChangedSince(currentSchema, state.values, lastDispatched);
365
+ }
366
+ let live = EMPTY_LIVE_SOLVE;
367
+ const stopEvents = args.driver.onEvent?.((event) => {
368
+ const next = reduceLiveSolve(live, event);
369
+ if (next === live) return;
370
+ live = next;
371
+ emit();
372
+ });
373
+ function solve() {
374
+ const settled = nextSettlement();
375
+ dispatch();
376
+ return settled;
377
+ }
378
+ function dispatch() {
379
+ if (disposed) return;
380
+ dropHeld();
381
+ lastSent = { ...pickInputValues(currentSchema, state.values) };
382
+ lastDispatched = lastSent;
383
+ args.driver.solve(lastSent);
384
+ emit();
385
+ }
386
+ return {
387
+ get values() {
388
+ return state.values;
389
+ },
390
+ get error() {
391
+ return state.error;
392
+ },
393
+ get computeErrors() {
394
+ return state.computeErrors;
395
+ },
396
+ get computeWarnings() {
397
+ return state.computeWarnings;
398
+ },
399
+ get diagnostics() {
400
+ return state.diagnostics;
401
+ },
402
+ get blocked() {
403
+ return state.blocked;
404
+ },
405
+ get awaitingAck() {
406
+ return state.heldResult !== null;
407
+ },
408
+ acknowledge() {
409
+ const held = state.heldResult;
410
+ if (!held) return;
411
+ state.heldResult = null;
412
+ commitSolveResult(state, held, editedSinceSent());
413
+ land(() => settlementOf(held));
414
+ },
415
+ discard() {
416
+ if (!state.heldResult) return;
417
+ state.heldResult = null;
418
+ state.unsolved = "discarded";
419
+ land(() => ({
420
+ status: "discarded",
421
+ diagnostics: state.diagnostics
422
+ }));
423
+ },
424
+ get live() {
425
+ return live;
426
+ },
427
+ get phase() {
428
+ return solvePhase(state, args.driver.isSolving);
429
+ },
430
+ abort() {
431
+ args.driver.cancel();
432
+ },
433
+ get meshes() {
434
+ return state.meshes;
435
+ },
436
+ get lastResult() {
437
+ return state.lastResult;
438
+ },
439
+ get hasPendingChanges() {
440
+ return state.hasPendingChanges;
441
+ },
442
+ get hasNeverSolved() {
443
+ return state.hasNeverSolved;
444
+ },
445
+ get isSolving() {
446
+ return args.driver.isSolving;
447
+ },
448
+ setValue(id, value, forceSolve = false) {
449
+ if (!forceSolve && isRepeatOfLastSolve(currentSchema, lastSent, state.values, id, value)) return;
450
+ if (applyValueChange(state, id, value, currentSchema.instanceSolve, forceSolve)) dispatch();
451
+ else emit();
452
+ },
453
+ solve,
454
+ ensureSolved() {
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,
462
+ diagnostics: state.diagnostics
463
+ });
464
+ return Promise.resolve({
465
+ status: step,
466
+ diagnostics: state.diagnostics
467
+ });
468
+ },
469
+ onSettled(listener) {
470
+ settledListeners.add(listener);
471
+ return () => settledListeners.delete(listener);
472
+ },
473
+ loadValues(incoming) {
474
+ if (applyLoadedValues(state, incoming, currentSchema.instanceSolve)) dispatch();
475
+ else emit();
476
+ },
477
+ rebuild(schema, scopeKey) {
478
+ if (disposed) return;
479
+ abandon();
480
+ currentSchema = schema;
481
+ lastSent = null;
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();
486
+ else emit();
487
+ },
488
+ report(result) {
489
+ const rejected = result.blocked || result.aborted;
490
+ const edited = result.aborted === true || editedSinceSent();
491
+ if (result.aborted) lastSent = null;
492
+ applySolveResult(state, rejected ? {
493
+ ...result,
494
+ outputs: blankOutputs(currentSchema),
495
+ meshes: []
496
+ } : result, edited);
497
+ if (args.driver.hasQueued) state.heldResult = null;
498
+ if (state.heldResult) return emit();
499
+ land(() => settlementOf(result));
500
+ },
501
+ reportError(message) {
502
+ dropHeld();
503
+ lastSent = null;
504
+ state.error = message;
505
+ state.unsolved = null;
506
+ land(() => ({
507
+ status: "failed",
508
+ error: message
509
+ }));
510
+ },
511
+ reportAborted() {
512
+ lastSent = null;
513
+ state.unsolved = "aborted";
514
+ land(() => ABORTED);
515
+ },
516
+ subscribe(listener) {
517
+ listeners.add(listener);
518
+ return () => listeners.delete(listener);
519
+ },
520
+ notify: emit,
521
+ dispose() {
522
+ disposed = true;
523
+ stopEvents?.();
524
+ listeners.clear();
525
+ settledListeners.clear();
526
+ args.driver.reset?.();
527
+ abandon();
528
+ }
529
+ };
530
+ }
531
+ // src/client/async-throttle.ts
532
+ /** `run` must settle once its signal fires: the slot frees only then. */
533
+ function createAsyncThrottle(run, options) {
534
+ const { runDeadlineMs, onChange } = options;
535
+ let isRunning = false;
536
+ let pendingValues = null;
537
+ let currentAbortController = null;
538
+ function setRunning(next) {
539
+ if (isRunning === next) return;
540
+ isRunning = next;
541
+ try {
542
+ onChange?.();
543
+ } catch (err) {
544
+ console.error("[Solve/throttle] onChange threw:", err);
545
+ }
546
+ }
547
+ async function execute(values) {
548
+ const controller = new AbortController();
549
+ currentAbortController = controller;
550
+ const deadlineId = setTimeout(() => {
551
+ console.warn(`[Solve/throttle] run exceeded ${runDeadlineMs}ms — aborting`);
552
+ controller.abort(new DOMException("Run deadline exceeded", "TimeoutError"));
553
+ }, runDeadlineMs);
554
+ setRunning(true);
555
+ try {
556
+ await run(values, controller.signal);
557
+ } catch (err) {
558
+ if (!(err instanceof Error) || err.name !== "AbortError" && err.name !== "TimeoutError") console.error("[Solve/throttle] unhandled error in run:", err);
559
+ } finally {
560
+ clearTimeout(deadlineId);
561
+ if (currentAbortController === controller) currentAbortController = null;
562
+ setRunning(false);
563
+ if (pendingValues !== null && !isRunning) {
564
+ const next = pendingValues;
565
+ pendingValues = null;
566
+ execute(next);
567
+ }
568
+ }
569
+ }
570
+ function trigger(values) {
571
+ if (isRunning) {
572
+ if (pendingValues !== null) console.debug("[Solve/throttle] superseded pending run (latest-wins)");
573
+ pendingValues = values;
574
+ } else execute(values);
575
+ }
576
+ function cancel() {
577
+ pendingValues = null;
578
+ currentAbortController?.abort();
579
+ currentAbortController = null;
580
+ }
581
+ return {
582
+ trigger,
583
+ get isRunning() {
584
+ return isRunning;
585
+ },
586
+ get hasPending() {
587
+ return pendingValues !== null;
588
+ },
589
+ cancel
590
+ };
591
+ }
592
+ // src/client/solve-memo.ts
593
+ /**
594
+ * Deterministic string key for a set of input values. Sorts object keys at every level so
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.
600
+ */
601
+ function stableInputKey(input) {
602
+ const value = input !== null && typeof input === "object" && typeof input.toJSON === "function" ? input.toJSON() : input;
603
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
604
+ if (Array.isArray(value)) return `[${value.map(stableInputKey).join(",")}]`;
605
+ const obj = value;
606
+ return `{${Object.keys(obj).filter((k) => obj[k] !== void 0).sort().map((k) => `${JSON.stringify(k)}:${stableInputKey(obj[k])}`).join(",")}}`;
607
+ }
608
+ function createSolveMemo(options = {}) {
609
+ const { max = 16, meshPolicy } = options;
610
+ const entries = /* @__PURE__ */ new Map();
611
+ function release(result) {
612
+ if (result.meshes?.length) meshPolicy?.release(result.meshes);
613
+ }
614
+ function copy(result) {
615
+ if (!meshPolicy || !result.meshes?.length) return result;
616
+ return {
617
+ ...result,
618
+ meshes: meshPolicy.clone(result.meshes)
619
+ };
620
+ }
621
+ function evict(key) {
622
+ const entry = entries.get(key);
623
+ if (entry === void 0) return;
624
+ entries.delete(key);
625
+ release(entry);
626
+ }
627
+ return {
628
+ get(values) {
629
+ const key = stableInputKey(values);
630
+ const hit = entries.get(key);
631
+ if (hit === void 0) return void 0;
632
+ entries.delete(key);
633
+ entries.set(key, hit);
634
+ console.debug(`[Solve/memo] HIT — served from client memo (${entries.size}/${max})`);
635
+ return copy(hit);
636
+ },
637
+ set(values, result) {
638
+ const key = stableInputKey(values);
639
+ evict(key);
640
+ entries.set(key, copy(result));
641
+ for (const oldest of entries.keys()) {
642
+ if (entries.size <= max) break;
643
+ evict(oldest);
644
+ }
645
+ },
646
+ clear() {
647
+ entries.forEach(release);
648
+ entries.clear();
649
+ }
650
+ };
651
+ }
652
+ // src/client/drivers/request-response.ts
653
+ /**
654
+ * `getReporter` is lazy (`() => session`) because the session and driver reference each
655
+ * other — the host builds the driver first, then the session, then can hand this back.
656
+ */
657
+ function createRequestResponseDriver(onSolve, getReporter, options) {
658
+ const { meshPolicy, solveDeadlineMs, onChange, events } = options;
659
+ const memo = createSolveMemo({ meshPolicy });
660
+ let generation = 0;
661
+ function reportStopped(signal) {
662
+ console.debug("[Solve/driver] solve stopped (cancelled or timed out)");
663
+ if (signal.reason?.name === "TimeoutError") getReporter().reportError(`The solve took longer than ${Math.round(solveDeadlineMs / 1e3)} s and was stopped.`);
664
+ else getReporter().reportAborted();
665
+ }
666
+ const throttle = createAsyncThrottle(async (values, signal) => {
667
+ const startedIn = generation;
668
+ let result;
669
+ try {
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);
678
+ }
679
+ } catch (err) {
680
+ if (startedIn !== generation) return;
681
+ if (signal.aborted) return reportStopped(signal);
682
+ console.warn("[Solve/driver] solve failed:", err);
683
+ return getReporter().reportError(err instanceof Error ? err.message : String(err));
684
+ }
685
+ if (startedIn !== generation) return;
686
+ if (signal.aborted) return reportStopped(signal);
687
+ getReporter().report(result);
688
+ }, {
689
+ runDeadlineMs: solveDeadlineMs,
690
+ onChange
691
+ });
692
+ function cancel() {
693
+ throttle.cancel();
694
+ events?.cancelCurrent?.();
695
+ }
696
+ return {
697
+ solve(values) {
698
+ throttle.trigger(values);
699
+ },
700
+ cancel,
701
+ get isSolving() {
702
+ return throttle.isRunning;
703
+ },
704
+ get hasQueued() {
705
+ return throttle.hasPending;
706
+ },
707
+ reset() {
708
+ generation += 1;
709
+ cancel();
710
+ memo.clear();
711
+ },
712
+ ...events ? { onEvent: (listener) => events.subscribe(listener) } : {}
713
+ };
714
+ }
715
+ // src/client/events/solve-event-stream.ts
716
+ const MIN_RECONNECT_MS = 1e3;
717
+ const MAX_RECONNECT_MS = 3e4;
718
+ function mintStreamId() {
719
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID().replace(/-/g, "");
720
+ return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
721
+ }
722
+ function createSolveEventStream(options) {
723
+ const streamId = mintStreamId();
724
+ const listeners = /* @__PURE__ */ new Set();
725
+ const doFetch = options.fetch ?? ((...args) => fetch(...args));
726
+ let source = null;
727
+ let currentSolveId = null;
728
+ let reconnectMs = MIN_RECONNECT_MS;
729
+ let reconnectTimer = null;
730
+ function open() {
731
+ if (source || typeof EventSource === "undefined") return;
732
+ const url = `${options.endpoint}${options.endpoint.includes("?") ? "&" : "?"}streamId=${streamId}`;
733
+ const opened = new EventSource(url);
734
+ source = opened;
735
+ opened.addEventListener("open", () => {
736
+ reconnectMs = MIN_RECONNECT_MS;
737
+ });
738
+ opened.addEventListener("solve", (raw) => {
739
+ let event;
740
+ try {
741
+ event = JSON.parse(raw.data);
742
+ } catch {
743
+ return;
744
+ }
745
+ if (event.type === "solveStarted") currentSolveId = event.solveId;
746
+ else if (event.type === "solveEnded" && currentSolveId === event.solveId) currentSolveId = null;
747
+ for (const listener of listeners) listener(event);
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
+ });
756
+ }
757
+ open();
758
+ return {
759
+ streamId,
760
+ get currentSolveId() {
761
+ return currentSolveId;
762
+ },
763
+ subscribe(listener) {
764
+ listeners.add(listener);
765
+ return () => {
766
+ listeners.delete(listener);
767
+ };
768
+ },
769
+ cancelCurrent() {
770
+ const solveId = currentSolveId;
771
+ if (!solveId) return;
772
+ doFetch(options.cancelEndpoint(solveId), {
773
+ method: "POST",
774
+ keepalive: true
775
+ }).catch(() => {});
776
+ },
777
+ close() {
778
+ if (reconnectTimer) clearTimeout(reconnectTimer);
779
+ source?.close();
780
+ source = null;
781
+ listeners.clear();
782
+ currentSolveId = null;
783
+ }
784
+ };
785
+ }
786
+ // src/client/compute-fetch-solve-fn.ts
787
+ /**
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.
791
+ *
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`).
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;
800
+ function parseServerTiming(header) {
801
+ const out = {};
802
+ if (!header) return out;
803
+ for (const part of header.split(",")) {
804
+ const [name, ...params] = part.split(";").map((s) => s.trim());
805
+ const dur = params.find((p) => p.startsWith("dur="));
806
+ if (!name || !dur) continue;
807
+ const ms = Number(dur.slice(4));
808
+ if (Number.isFinite(ms)) out[name] = ms;
809
+ }
810
+ return out;
811
+ }
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
+ }
845
+ function createComputeFetchSolveFn(opts) {
846
+ const debug = opts.debug ?? false;
847
+ const log = (...args) => {
848
+ if (debug) console.info(...args);
849
+ };
850
+ let cooldownUntil = 0;
851
+ function rateLimited(retryAfterSeconds) {
852
+ opts.onRateLimited?.(retryAfterSeconds);
853
+ return rateLimitedError(retryAfterSeconds);
854
+ }
855
+ return async (values, signal) => {
856
+ const remainingMs = cooldownUntil - Date.now();
857
+ if (remainingMs > 0) throw rateLimited(remainingMs / 1e3);
858
+ const solveStart = performance.now();
859
+ const inputs = opts.inputs();
860
+ const outputs = opts.outputs();
861
+ const channel = opts.channel?.();
862
+ const versionId = opts.versionId?.();
863
+ const streamId = opts.streamId?.();
864
+ const payload = JSON.stringify({
865
+ inputs,
866
+ values,
867
+ definitionUrl: opts.definitionUrl(),
868
+ ...versionId ? { versionId } : channel === "draft" ? { channel: "draft" } : {},
869
+ ...streamId ? { streamId } : {}
870
+ });
871
+ let res;
872
+ try {
873
+ res = await fetch(opts.endpoint, {
874
+ method: "POST",
875
+ headers: { "Content-Type": "application/json" },
876
+ body: payload,
877
+ signal
878
+ });
879
+ } catch (err) {
880
+ if (signal.aborted) {
881
+ log("[Compute] solve aborted during fetch — empty result discarded");
882
+ return { outputs: {} };
883
+ }
884
+ throw new Error("Could not reach the server. Check your connection, or reload the page if your session expired.", { cause: err });
885
+ }
886
+ if (signal.aborted) {
887
+ log("[Compute] solve aborted after headers — empty result discarded");
888
+ return { outputs: {} };
889
+ }
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
+ }
895
+ if (!res.ok) {
896
+ const errorBody = await res.text().catch(() => "");
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.");
902
+ if (res.status === 429) {
903
+ const seconds = retryAfter ?? DEFAULT_RETRY_AFTER_S;
904
+ cooldownUntil = Date.now() + seconds * 1e3;
905
+ throw rateLimited(seconds);
906
+ }
907
+ throw new Error(message || `Compute error (HTTP ${res.status})`);
908
+ }
909
+ const ttfbMs = performance.now() - solveStart;
910
+ const downloadStart = performance.now();
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 });
920
+ }
921
+ const downloadMs = performance.now() - downloadStart;
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;
925
+ try {
926
+ parsed = JSON.parse(bodyText);
927
+ } catch (err) {
928
+ throw invalidResponse(err);
929
+ }
930
+ if (!isPlainObject(parsed)) throw invalidResponse();
931
+ const solved = parsed;
932
+ const processor = new GrasshopperResponseProcessor(solved);
933
+ const outcome = decodeOutcome(solved.selva?.outcome) ?? outcomeFromComputeMessages(solved.errors ?? [], solved.warnings ?? []);
934
+ const rejected = outcome.blocked || outcome.aborted;
935
+ const meshes = opts.meshes && !rejected ? await opts.meshes.extract(processor.response, {
936
+ debug,
937
+ signal
938
+ }) : [];
939
+ const resultOutputs = {};
940
+ for (const o of rejected ? [] : outputs) {
941
+ const byId = processor.getValue({ byId: o.id }, { parseValues: true });
942
+ resultOutputs[o.id] = byId ?? (o.nickname ? processor.getValue({ byName: o.nickname }, { parseValues: true }) : void 0);
943
+ }
944
+ if (debug) {
945
+ const valuesBytes = JSON.stringify(values).length;
946
+ const serverTiming = parseServerTiming(res.headers.get("Server-Timing"));
947
+ const serverTotal = serverTiming.total ?? 0;
948
+ const networkMs = Math.max(0, ttfbMs - serverTotal);
949
+ const sizeMB = bodyChars / 1048576;
950
+ const size = sizeMB >= 1 ? `${sizeMB.toFixed(2)} MB` : `${(bodyChars / 1024).toFixed(0)} KB`;
951
+ const mbps = downloadMs > 0 ? (sizeMB / (downloadMs / 1e3)).toFixed(1) : "∞";
952
+ const heapBytes = performance.memory?.usedJSHeapSize;
953
+ const heap = heapBytes !== void 0 ? ` | heap=${(heapBytes / 1048576).toFixed(0)} MB` : "";
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}`);
955
+ if (serverTiming.selva_cache !== void 0) {
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";
957
+ log(`[Compute] └─ cache: ${cacheMsg}`);
958
+ }
959
+ }
960
+ return finalizeResult({
961
+ outputs: resultOutputs,
962
+ meshes,
963
+ source: solved
964
+ }, outcome);
965
+ };
966
+ }
967
+ export { EMPTY_LIVE_SOLVE, clearExternalValue, createComputeFetchSolveFn, createRequestResponseDriver, createSolveEventStream, createSolveSession, decodeOutcome, finalizeResult, getExternalInputs, readExternalValue, stableInputKey, writeExternalValue };
968
+
2
969
  //# sourceMappingURL=client.js.map