@selvajs/server 0.2.0-beta.3 → 0.2.0-beta.4

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.
Files changed (145) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -0
  3. package/dist/compute/client-cache.d.ts.map +1 -1
  4. package/dist/compute/client-cache.js +6 -0
  5. package/dist/compute/client-cache.js.map +1 -1
  6. package/dist/compute/safe-url.d.ts.map +1 -1
  7. package/dist/compute/safe-url.js +16 -0
  8. package/dist/compute/safe-url.js.map +1 -1
  9. package/dist/compute/solve-cache-single-flight.d.ts +8 -1
  10. package/dist/compute/solve-cache-single-flight.d.ts.map +1 -1
  11. package/dist/compute/solve-cache-single-flight.js +4 -2
  12. package/dist/compute/solve-cache-single-flight.js.map +1 -1
  13. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  14. package/dist/compute/solve-pipeline.js +3 -1
  15. package/dist/compute/solve-pipeline.js.map +1 -1
  16. package/package.json +14 -18
  17. package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
  18. package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
  19. package/dist/access/__tests__/project-access-input.test.js +0 -146
  20. package/dist/access/__tests__/project-access-input.test.js.map +0 -1
  21. package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
  22. package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
  23. package/dist/compute/__tests__/client-cache.test.js +0 -218
  24. package/dist/compute/__tests__/client-cache.test.js.map +0 -1
  25. package/dist/compute/__tests__/definition-byte-cache.test.d.ts +0 -2
  26. package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +0 -1
  27. package/dist/compute/__tests__/definition-byte-cache.test.js +0 -107
  28. package/dist/compute/__tests__/definition-byte-cache.test.js.map +0 -1
  29. package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
  30. package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
  31. package/dist/compute/__tests__/rate-limit.test.js +0 -98
  32. package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
  33. package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
  34. package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
  35. package/dist/compute/__tests__/remote-definition.test.js +0 -114
  36. package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
  37. package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
  38. package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
  39. package/dist/compute/__tests__/safe-url.test.js +0 -71
  40. package/dist/compute/__tests__/safe-url.test.js.map +0 -1
  41. package/dist/compute/__tests__/solve-cache.test.d.ts +0 -13
  42. package/dist/compute/__tests__/solve-cache.test.d.ts.map +0 -1
  43. package/dist/compute/__tests__/solve-cache.test.js +0 -234
  44. package/dist/compute/__tests__/solve-cache.test.js.map +0 -1
  45. package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
  46. package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
  47. package/dist/compute/__tests__/solve-pipeline.test.js +0 -557
  48. package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
  49. package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
  50. package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
  51. package/dist/compute/__tests__/transform-input.test.js +0 -120
  52. package/dist/compute/__tests__/transform-input.test.js.map +0 -1
  53. package/dist/definitions/__tests__/definition-service.test.d.ts +0 -14
  54. package/dist/definitions/__tests__/definition-service.test.d.ts.map +0 -1
  55. package/dist/definitions/__tests__/definition-service.test.js +0 -95
  56. package/dist/definitions/__tests__/definition-service.test.js.map +0 -1
  57. package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
  58. package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
  59. package/dist/definitions/__tests__/load-for-render.test.js +0 -196
  60. package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
  61. package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
  62. package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
  63. package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
  64. package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
  65. package/dist/http/__tests__/body-size.test.d.ts +0 -2
  66. package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
  67. package/dist/http/__tests__/body-size.test.js +0 -24
  68. package/dist/http/__tests__/body-size.test.js.map +0 -1
  69. package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
  70. package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
  71. package/dist/http/__tests__/route-classifier.test.js +0 -54
  72. package/dist/http/__tests__/route-classifier.test.js.map +0 -1
  73. package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
  74. package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
  75. package/dist/http/__tests__/safe-redirect.test.js +0 -44
  76. package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
  77. package/dist/http/__tests__/security-headers.test.d.ts +0 -2
  78. package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
  79. package/dist/http/__tests__/security-headers.test.js +0 -24
  80. package/dist/http/__tests__/security-headers.test.js.map +0 -1
  81. package/dist/ops/__tests__/semver.test.d.ts +0 -2
  82. package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
  83. package/dist/ops/__tests__/semver.test.js +0 -38
  84. package/dist/ops/__tests__/semver.test.js.map +0 -1
  85. package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
  86. package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
  87. package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
  88. package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
  89. package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
  90. package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
  91. package/dist/tokens/__tests__/token-codec.test.js +0 -50
  92. package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
  93. package/src/access/__tests__/project-access-input.test.ts +0 -175
  94. package/src/access/index.ts +0 -9
  95. package/src/access/project-access-input.ts +0 -145
  96. package/src/compute/__tests__/client-cache.test.ts +0 -244
  97. package/src/compute/__tests__/definition-byte-cache.test.ts +0 -129
  98. package/src/compute/__tests__/rate-limit.test.ts +0 -106
  99. package/src/compute/__tests__/remote-definition.test.ts +0 -140
  100. package/src/compute/__tests__/safe-url.test.ts +0 -72
  101. package/src/compute/__tests__/solve-cache.test.ts +0 -268
  102. package/src/compute/__tests__/solve-pipeline.test.ts +0 -660
  103. package/src/compute/__tests__/transform-input.test.ts +0 -162
  104. package/src/compute/client-cache.ts +0 -321
  105. package/src/compute/definition-byte-cache.ts +0 -168
  106. package/src/compute/index.ts +0 -82
  107. package/src/compute/limits.ts +0 -237
  108. package/src/compute/memory-solve-cache.ts +0 -0
  109. package/src/compute/rate-limit.ts +0 -110
  110. package/src/compute/remote-definition.ts +0 -149
  111. package/src/compute/safe-url.ts +0 -186
  112. package/src/compute/solve-cache-envelope.ts +0 -98
  113. package/src/compute/solve-cache-key.ts +0 -85
  114. package/src/compute/solve-cache-single-flight.ts +0 -59
  115. package/src/compute/solve-pipeline.ts +0 -603
  116. package/src/compute/transform-input.ts +0 -66
  117. package/src/definitions/__tests__/definition-service.test.ts +0 -131
  118. package/src/definitions/__tests__/load-for-render.test.ts +0 -244
  119. package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
  120. package/src/definitions/definition-service.ts +0 -247
  121. package/src/definitions/index.ts +0 -22
  122. package/src/definitions/load-for-render.ts +0 -272
  123. package/src/definitions/schema-extraction.ts +0 -117
  124. package/src/errors/SentryErrorReporter.ts +0 -88
  125. package/src/errors/index.ts +0 -3
  126. package/src/http/__tests__/body-size.test.ts +0 -27
  127. package/src/http/__tests__/route-classifier.test.ts +0 -60
  128. package/src/http/__tests__/safe-redirect.test.ts +0 -47
  129. package/src/http/__tests__/security-headers.test.ts +0 -29
  130. package/src/http/body-size.ts +0 -24
  131. package/src/http/index.ts +0 -10
  132. package/src/http/redirect.ts +0 -16
  133. package/src/http/route-classifier.ts +0 -74
  134. package/src/http/security-headers.ts +0 -42
  135. package/src/index.ts +0 -27
  136. package/src/ops/__tests__/semver.test.ts +0 -42
  137. package/src/ops/index.ts +0 -3
  138. package/src/ops/semver.ts +0 -60
  139. package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
  140. package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
  141. package/src/providers/create-selva-providers.ts +0 -241
  142. package/src/providers/index.ts +0 -12
  143. package/src/tokens/__tests__/token-codec.test.ts +0 -63
  144. package/src/tokens/index.ts +0 -8
  145. package/src/tokens/token-codec.ts +0 -86
@@ -1,603 +0,0 @@
1
- /**
2
- * Transport-agnostic solve pipeline — the callable core lifted out of the app's
3
- * `/api/compute` route (embeddable-server-layer plan, K3).
4
- *
5
- * Given an already-resolved solve context — the `.gh` bytes, the input params +
6
- * user values, and a warm `SolveScheduler` (from `createClientCache`) — this
7
- * runs the framework-free half of a solve:
8
- *
9
- * input tree build → scheduler.solve (with abort/timeout classification) →
10
- * JSON serialize (size-guarded) → optional gzip → Server-Timing envelope.
11
- *
12
- * It returns a discriminated {@link SolveOutcome}: `ok` carries the ready-to-send
13
- * {@link SolveEnvelope} (body + headers + phase metrics), while the error
14
- * variants (`timeout`, `client_abort`, `too_large`, `compute_error`) name a
15
- * failure the transport maps to its own status code. Nothing here throws for an
16
- * expected failure and nothing here touches auth, the database, share tokens,
17
- * rate limits, or metric sinks — those stay app policy in the route.
18
- *
19
- * The Server-Timing string it emits IS the versioned wire contract (audit D5):
20
- * see {@link COMPUTE_CONTRACT_VERSION} and {@link COMPUTE_VERSION_HEADER}.
21
- */
22
-
23
- import {
24
- ErrorCodes,
25
- RhinoComputeError,
26
- TreeBuilder,
27
- type GrasshopperComputeResponse,
28
- type InputParam,
29
- type SolveDefinition
30
- } from '@selvajs/compute';
31
- import type { SchemaInput } from '@selvajs/schemas';
32
- import { gzip } from 'node:zlib';
33
- import { promisify } from 'node:util';
34
- import { transformInputParameter } from './transform-input.js';
35
- import type { CachedClient } from './client-cache.js';
36
- import type { ByteRefOutcome } from './definition-byte-cache.js';
37
- import { deriveSolveCacheInputKey, type SolveCacheConfigSubset } from './solve-cache-key.js';
38
- import {
39
- decodeSolveCacheEntry,
40
- encodeSolveCacheEntry,
41
- gunzipEntryBody
42
- } from './solve-cache-envelope.js';
43
-
44
- /** Async gzip (audit B8) — off the event loop, unlike the old `gzipSync`. */
45
- const gzipAsync = promisify(gzip);
46
-
47
- // ============================================================================
48
- // Wire contract version (audit D5)
49
- // ============================================================================
50
- //
51
- // The /api/compute response shape (JSON body + Server-Timing phases) is now an
52
- // explicit, versioned artifact rather than ad-hoc route output. The version
53
- // rides an additive response header so a client can branch on it without any
54
- // change to the existing body — bump this (and document the change) whenever the
55
- // envelope's shape changes in a way a consumer could observe.
56
-
57
- /** Current compute-response contract version. Additive header; bump on shape change. */
58
- export const COMPUTE_CONTRACT_VERSION = 1 as const;
59
- /** Response header carrying {@link COMPUTE_CONTRACT_VERSION}. */
60
- export const COMPUTE_VERSION_HEADER = 'X-Selva-Compute-Version';
61
-
62
- // ============================================================================
63
- // Inputs & outcomes
64
- // ============================================================================
65
-
66
- /** A schema input carrying the optional numeric bounds the transform reads. */
67
- export type PipelineInput = SchemaInput & {
68
- minimum?: number;
69
- maximum?: number;
70
- stepSize?: number;
71
- };
72
-
73
- export interface SolvePipelineArgs {
74
- /**
75
- * The definition to solve. Either raw `.gh` bytes (remote fetch, or a caller
76
- * that resolved them eagerly) or a `DefinitionRef` — an identity-keyed lazy
77
- * loader (from the definition-byte cache) whose bytes the scheduler
78
- * materializes ONLY when an upload is unavoidable. A pointer-known solve of a
79
- * `DefinitionRef` moves zero bytes.
80
- */
81
- definitionSource: SolveDefinition;
82
- /**
83
- * When `definitionSource` is a byte-cache `DefinitionRef`, its mutable outcome
84
- * so the pipeline can emit the `def_bytes` Server-Timing verdict (skipped =
85
- * `load` never ran, hit = warm cache, miss = loader). Omit for raw-bytes solves.
86
- */
87
- byteRefOutcome?: ByteRefOutcome;
88
- /** Persisted input params; only those with a `paramType` are sent to the solve. */
89
- inputs: PipelineInput[];
90
- /** User-chosen values keyed by input id; missing keys fall back to the schema default. */
91
- values: Record<string, unknown>;
92
- /** Warm client bundle from `createClientCache().getClient(...)`. */
93
- client: CachedClient;
94
- /** Longest a serialized result may be (bytes) before a 413. */
95
- responseMaxBytes: number;
96
- /** Solve-deadline millis — used only to phrase the timeout message. */
97
- maxSolveDurationMs: number;
98
- /** Client's `Accept-Encoding`; gzip is applied only when it advertises `gzip`. */
99
- acceptEncoding: string;
100
- /**
101
- * Request abort signal, forwarded to the scheduler so a client disconnect
102
- * cancels the upstream compute call. Its `aborted` flag also disambiguates a
103
- * client disconnect from the scheduler's own deadline firing.
104
- */
105
- signal: AbortSignal;
106
- /**
107
- * Wall-clock origin (`performance.now()` captured at the top of the request)
108
- * so the pipeline's `load`/`total` phase timings line up with the caller's
109
- * pre-solve prep. The caller owns everything before this and stamps `defLoadMs`.
110
- */
111
- loadStartMs: number;
112
- /** Pre-solve "load" phase duration the caller already measured (auth + DB + fetch). */
113
- defLoadMs: number;
114
- /**
115
- * Pre-solve prep sub-phase marks (`[label, ms]`), surfaced verbatim as `p_*`
116
- * Server-Timing entries. The caller assembles these; the pipeline only echoes.
117
- */
118
- prepMarks?: [string, number][];
119
- /**
120
- * Optional durable L2 solve cache (H1). Present ONLY for live-channel solves
121
- * whose definition has a caching quota > 0 — its presence IS the channel gate
122
- * (zero policy conditionals in the pipeline). When set, the pipeline derives
123
- * the wide input key from the transformed tree + `configSubset`, checks
124
- * `lookup` before solving (a hit skips compute entirely, returning the stored
125
- * gzipped envelope), and write-throughs `store` on a successful solve.
126
- */
127
- solveCache?: SolvePipelineCacheHook;
128
- }
129
-
130
- /**
131
- * The app-supplied L2 hook. The app binds `lookup`/`store` to the org/definition/
132
- * version scope and the definition's quota; the pipeline supplies the SHA-256
133
- * `inputKey` (derived from the transformed tree + `configSubset`) and the opaque
134
- * encoded entry bytes. Both are best-effort — `lookup` may miss/throw (treated as
135
- * a miss), `store` is fire-and-forget.
136
- */
137
- export interface SolvePipelineCacheHook {
138
- /** Solve-affecting config folded into the key (R8) — model units, tolerances, server id. */
139
- configSubset: SolveCacheConfigSubset;
140
- /** Fetch a stored entry for `inputKey`, or `null` on miss. Must not reject fatally. */
141
- lookup(inputKey: string): Promise<Uint8Array | null>;
142
- /** Store the encoded entry for `inputKey`. Fire-and-forget; best-effort. */
143
- store(inputKey: string, entry: Uint8Array): void;
144
- }
145
-
146
- /** Phase timings the pipeline measured; the caller uses them for debug logging. */
147
- export interface SolvePhaseMetrics {
148
- treeBuildMs: number;
149
- solveMs: number;
150
- serializeMs: number;
151
- gzipMs: number;
152
- serverTotalMs: number;
153
- /** Uncompressed serialized length in bytes. */
154
- serializedBytes: number;
155
- /** Gzipped length in bytes, or null when compression was skipped. */
156
- compressedBytes: number | null;
157
- /**
158
- * L2 durable-cache verdict: `true` = served from L2 (no solve ran), `false` =
159
- * L2 consulted and missed (solved), `null` = L2 not consulted (no hook — draft
160
- * or caching off). Drives the `l2_cache` Server-Timing entry.
161
- */
162
- l2CacheHit: boolean | null;
163
- }
164
-
165
- /** A ready-to-send response: body + headers + the solve result + phase metrics. */
166
- export interface SolveEnvelope {
167
- /** The response body — a gzip `Uint8Array` when `encoding === 'gzip'`, else the JSON string. */
168
- body: string | Uint8Array;
169
- /** `'gzip'` when the body is compressed; absent otherwise. */
170
- encoding?: 'gzip';
171
- /** Fully-assembled response headers (Content-Type/-Length/-Encoding, Vary, Server-Timing, version). */
172
- headers: Record<string, string>;
173
- /**
174
- * The solved definition object, present only on a fresh (non-L2) solve. Absent
175
- * on an L2 hit — the pipeline serves the stored gzipped body without ever
176
- * materializing the object. The caller reads `errorCount`/`warningCount` off
177
- * the `ok` outcome for its metric record, not this field.
178
- */
179
- result?: GrasshopperComputeResponse;
180
- /** Phase timings, for the caller's optional debug breakdown. */
181
- metrics: SolvePhaseMetrics;
182
- }
183
-
184
- /**
185
- * Discriminated result. `ok` carries the envelope; every other variant names an
186
- * expected failure the transport maps to a status code (the app route maps
187
- * timeout→504, client_abort→499, too_large→413, shed→503+Retry-After;
188
- * `compute_error` re-surfaces the original error for the generic 500/503 path).
189
- * `durationMs` on the error variants is the solve wall time up to the failure,
190
- * for the metric record.
191
- */
192
- export type SolveOutcome =
193
- | {
194
- kind: 'ok';
195
- envelope: SolveEnvelope;
196
- solveMs: number;
197
- errorCount: number;
198
- warningCount: number;
199
- }
200
- | { kind: 'timeout'; durationMs: number; message: string }
201
- | { kind: 'client_abort'; durationMs: number }
202
- | { kind: 'too_large' }
203
- /**
204
- * Scheduler backpressure shed the solve before it executed — the per-server
205
- * queue was full (`QUEUE_FULL`) or it sat queued past the wait deadline
206
- * (`QUEUE_TIMEOUT`). Retryable: the route maps this to 503 + `Retry-After`.
207
- * `retryAfterSeconds` is a suggested backoff hint for the client.
208
- */
209
- | {
210
- kind: 'shed';
211
- durationMs: number;
212
- reason: 'queue_full' | 'queue_timeout';
213
- retryAfterSeconds: number;
214
- message: string;
215
- }
216
- | { kind: 'compute_error'; durationMs: number; error: unknown };
217
-
218
- // ============================================================================
219
- // Pipeline
220
- // ============================================================================
221
-
222
- /**
223
- * Run the framework-free half of a solve and produce a typed outcome. See the
224
- * module doc for the phase sequence and the app/library boundary.
225
- */
226
- export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOutcome> {
227
- const { client, signal } = args;
228
-
229
- // --- input tree build ---------------------------------------------------
230
- const treeBuildStart = performance.now();
231
- const inputTree = TreeBuilder.fromInputParams(
232
- args.inputs
233
- .filter((input) => input.paramType)
234
- .map((input): InputParam => transformInputParameter(input, args.values[input.id]))
235
- );
236
- const treeBuildMs = performance.now() - treeBuildStart;
237
-
238
- // --- L2 durable cache lookup (H1) ---------------------------------------
239
- // Derive the wide key from the TRANSFORMED tree (R13) + config subset (R8),
240
- // then check the injected hook. Present only for cacheable live solves — its
241
- // presence is the channel gate. A hit skips the solve entirely and serves the
242
- // stored gzipped envelope; the derived key is reused for the write-through on
243
- // a miss. Lookup is best-effort: any rejection is swallowed to a miss.
244
- let inputKey: string | null = null;
245
- if (args.solveCache) {
246
- inputKey = deriveSolveCacheInputKey(inputTree, args.solveCache.configSubset).hash;
247
- let stored: Uint8Array | null = null;
248
- try {
249
- stored = await args.solveCache.lookup(inputKey);
250
- } catch {
251
- stored = null;
252
- }
253
- if (stored) {
254
- const hit = buildEnvelopeFromCacheEntry(stored, inputKey, args);
255
- if (hit) return hit;
256
- // Corrupt/tampered entry → fall through and solve as a miss.
257
- }
258
- }
259
-
260
- // --- solve --------------------------------------------------------------
261
- // Snapshot the shared telemetry sequence counters. The holders live on the
262
- // warm client and are written by every request on this server; with
263
- // maxConcurrentSolves > 1 another request can write them mid-flight. After
264
- // the solve, a slot is attributed to THIS request only when exactly one
265
- // write happened since this snapshot — for solveMeta that single settle is
266
- // necessarily ours (onSettle fires before scheduler.solve() resolves).
267
- // Anything ambiguous is dropped from Server-Timing, never misattributed.
268
- const settleSeqBefore = client.solveMeta.seq;
269
- const rhinoSeqBefore = client.rhinoTiming.seq;
270
-
271
- let result: GrasshopperComputeResponse;
272
- const solveStart = performance.now();
273
- try {
274
- result = await client.scheduler.solve(args.definitionSource, inputTree, { signal });
275
- } catch (err) {
276
- const durationMs = performance.now() - solveStart;
277
- // Distinguish timeout (scheduler deadline timer) from client disconnect
278
- // (request signal). AbortError with the request signal NOT aborted means
279
- // the scheduler's own timeout fired — the solve genuinely timed out.
280
- const isAbort = err instanceof Error && err.name === 'AbortError';
281
- if (isAbort) {
282
- if (signal.aborted) return { kind: 'client_abort', durationMs };
283
- return {
284
- kind: 'timeout',
285
- durationMs,
286
- message: `Solve exceeded the ${Math.round(args.maxSolveDurationMs / 1000)}s deadline.`
287
- };
288
- }
289
- // Scheduler backpressure (audit B7): a full queue or an over-deadline queue
290
- // wait sheds the solve BEFORE compute runs. Classify these as `shed` (503 +
291
- // Retry-After) rather than the generic 500 `compute_error` path — they're
292
- // load signals, not failures, and the client should back off and retry.
293
- if (err instanceof RhinoComputeError) {
294
- if (err.code === ErrorCodes.QUEUE_FULL) {
295
- return {
296
- kind: 'shed',
297
- durationMs,
298
- reason: 'queue_full',
299
- retryAfterSeconds: 1,
300
- message: 'Compute server is at capacity. Retry shortly.'
301
- };
302
- }
303
- if (err.code === ErrorCodes.QUEUE_TIMEOUT) {
304
- return {
305
- kind: 'shed',
306
- durationMs,
307
- reason: 'queue_timeout',
308
- retryAfterSeconds: 1,
309
- message: 'Compute request waited too long in the queue. Retry shortly.'
310
- };
311
- }
312
- }
313
- return { kind: 'compute_error', durationMs, error: err };
314
- }
315
- const solveMs = performance.now() - solveStart;
316
-
317
- // --- serialize (size-guarded) ------------------------------------------
318
- const serializeStart = performance.now();
319
- let serialized: string;
320
- try {
321
- serialized = JSON.stringify(result);
322
- } catch (err) {
323
- // V8 caps a single string at ~512 MB; an oversized `file` output trips a
324
- // RangeError here. Surface it as the same too-large outcome as the byte cap.
325
- if (err instanceof RangeError) return { kind: 'too_large' };
326
- throw err;
327
- }
328
- if (serialized.length > args.responseMaxBytes) return { kind: 'too_large' };
329
- const serializeMs = performance.now() - serializeStart;
330
-
331
- // --- gzip ---------------------------------------------------------------
332
- // Compress BEFORE the timing snapshot so its cost is a measured phase (`gzip`
333
- // in Server-Timing, included in `total`). Buffered (not streamed) so
334
- // Content-Length is known and a connection cut mid-transfer fails hard
335
- // instead of truncating the JSON.
336
- // gzip is async (audit B8) — a multi-MB `gzipSync` blocked the event loop for
337
- // every other request on this instance. Two independent reasons to compress:
338
- // - the client advertised gzip AND the body clears the 1 KB break-even (tiny
339
- // bodies aren't worth compressing for the wire);
340
- // - an L2 write-through is pending — the entry stores compressed bytes so
341
- // every future hit is near-CPU-free, so we compress even a small body (it
342
- // will be cached), just not for the wire below the threshold.
343
- const clientWantsGzip = /\bgzip\b/i.test(args.acceptEncoding);
344
- const worthSendingGzip = clientWantsGzip && serialized.length > 1024;
345
- const willCache = args.solveCache != null;
346
- let compressed: Buffer | null = null;
347
- let gzipMs = 0;
348
- if (worthSendingGzip || willCache) {
349
- const gzipStart = performance.now();
350
- compressed = await gzipAsync(Buffer.from(serialized));
351
- gzipMs = performance.now() - gzipStart;
352
- }
353
-
354
- // --- L2 write-through (H1) ----------------------------------------------
355
- // On a cacheable solve with a compressed body, store the gzipped envelope so
356
- // future hits are near-CPU-free. Fire-and-forget through the hook; the store
357
- // itself is best-effort (may silently drop under quota/byte pressure).
358
- if (args.solveCache && inputKey && compressed) {
359
- const entry = encodeSolveCacheEntry(
360
- {
361
- errorCount: result.errors?.length ?? 0,
362
- warningCount: result.warnings?.length ?? 0,
363
- serializedBytes: serialized.length,
364
- inputHash: inputKey
365
- },
366
- new Uint8Array(compressed)
367
- );
368
- args.solveCache.store(inputKey, entry);
369
- }
370
-
371
- const serverTotalMs = performance.now() - args.loadStartMs;
372
-
373
- // --- per-request telemetry attribution (see snapshot above) -------------
374
- // settle: ours iff exactly one settle happened since the snapshot. rhino:
375
- // only meaningful when the settle is ours AND it was a real compute call
376
- // (a Selva-cache hit never reaches the server), and only unambiguous when
377
- // exactly one Server-Timing callback fired in the window.
378
- const settle = client.solveMeta.seq === settleSeqBefore + 1 ? client.solveMeta.last : null;
379
- const rhino =
380
- settle && !settle.fromCache && client.rhinoTiming.seq === rhinoSeqBefore + 1
381
- ? client.rhinoTiming.last
382
- : null;
383
-
384
- // Send gzip to the client only when it's worth it for the wire — a body
385
- // compressed solely to feed the L2 write-through (or one below the 1 KB
386
- // break-even) goes out uncompressed.
387
- const sendCompressed = compressed && worthSendingGzip ? compressed : null;
388
-
389
- // --- Server-Timing envelope (the versioned wire contract) ---------------
390
- const serverTiming = buildServerTiming({
391
- defLoadMs: args.defLoadMs,
392
- treeBuildMs,
393
- solveMs,
394
- serializeMs,
395
- gzipMs,
396
- serverTotalMs,
397
- rhino,
398
- settle,
399
- byteRefOutcome: args.byteRefOutcome,
400
- // L2 was consulted (hook present) and missed — otherwise not consulted.
401
- l2CacheHit: args.solveCache ? false : null,
402
- prepMarks: args.prepMarks ?? []
403
- });
404
-
405
- const headers: Record<string, string> = {
406
- 'Content-Type': 'application/json',
407
- 'Server-Timing': serverTiming,
408
- [COMPUTE_VERSION_HEADER]: String(COMPUTE_CONTRACT_VERSION),
409
- Vary: 'Accept-Encoding'
410
- };
411
-
412
- const metrics: SolvePhaseMetrics = {
413
- treeBuildMs,
414
- solveMs,
415
- serializeMs,
416
- gzipMs,
417
- serverTotalMs,
418
- serializedBytes: serialized.length,
419
- compressedBytes: sendCompressed?.byteLength ?? null,
420
- l2CacheHit: args.solveCache ? false : null
421
- };
422
-
423
- let envelope: SolveEnvelope;
424
- if (sendCompressed) {
425
- // A downstream proxy's `encode gzip` skips already-encoded responses, so
426
- // this never double-compresses. Vary is set on both branches.
427
- headers['Content-Encoding'] = 'gzip';
428
- headers['Content-Length'] = String(sendCompressed.byteLength);
429
- envelope = {
430
- body: new Uint8Array(sendCompressed),
431
- encoding: 'gzip',
432
- headers,
433
- result,
434
- metrics
435
- };
436
- } else {
437
- headers['Content-Length'] = String(Buffer.byteLength(serialized));
438
- envelope = { body: serialized, headers, result, metrics };
439
- }
440
-
441
- return {
442
- kind: 'ok',
443
- envelope,
444
- solveMs,
445
- errorCount: result.errors?.length ?? 0,
446
- warningCount: result.warnings?.length ?? 0
447
- };
448
- }
449
-
450
- // ============================================================================
451
- // L2 cache-hit envelope
452
- // ============================================================================
453
-
454
- /**
455
- * Build the `ok` outcome from a stored L2 entry — the near-CPU-free hit path (no
456
- * solve, no `JSON.stringify`, no gzip). Returns `null` when the entry is corrupt
457
- * or its stored `inputHash` doesn't match the requesting key (defense-in-depth
458
- * against a wide-hash collision), so the caller falls through and solves.
459
- */
460
- function buildEnvelopeFromCacheEntry(
461
- entryBytes: Uint8Array,
462
- inputKey: string,
463
- args: SolvePipelineArgs
464
- ): (SolveOutcome & { kind: 'ok' }) | null {
465
- const decoded = decodeSolveCacheEntry(entryBytes);
466
- // A collision that produced the same storage key still can't serve the wrong
467
- // geometry: the stored hash must match the requesting key's hash.
468
- if (!decoded || decoded.header.inputHash !== inputKey) return null;
469
-
470
- const { header, gzippedBody } = decoded;
471
- const clientWantsGzip = /\bgzip\b/i.test(args.acceptEncoding);
472
- const serverTotalMs = performance.now() - args.loadStartMs;
473
-
474
- const serverTiming = buildServerTiming({
475
- defLoadMs: args.defLoadMs,
476
- treeBuildMs: 0,
477
- solveMs: 0,
478
- serializeMs: 0,
479
- gzipMs: 0,
480
- serverTotalMs,
481
- rhino: null,
482
- settle: null,
483
- byteRefOutcome: args.byteRefOutcome,
484
- l2CacheHit: true,
485
- prepMarks: args.prepMarks ?? []
486
- });
487
-
488
- const headers: Record<string, string> = {
489
- 'Content-Type': 'application/json',
490
- 'Server-Timing': serverTiming,
491
- [COMPUTE_VERSION_HEADER]: String(COMPUTE_CONTRACT_VERSION),
492
- Vary: 'Accept-Encoding'
493
- };
494
-
495
- const metrics: SolvePhaseMetrics = {
496
- treeBuildMs: 0,
497
- solveMs: 0,
498
- serializeMs: 0,
499
- gzipMs: 0,
500
- serverTotalMs,
501
- serializedBytes: header.serializedBytes,
502
- compressedBytes: clientWantsGzip ? gzippedBody.byteLength : null,
503
- l2CacheHit: true
504
- };
505
-
506
- let envelope: SolveEnvelope;
507
- if (clientWantsGzip) {
508
- // Serve the stored compressed bytes verbatim — the whole point of R6.
509
- headers['Content-Encoding'] = 'gzip';
510
- headers['Content-Length'] = String(gzippedBody.byteLength);
511
- envelope = { body: new Uint8Array(gzippedBody), encoding: 'gzip', headers, metrics };
512
- } else {
513
- // Rare non-gzip client: gunzip back to the JSON string for the response.
514
- const json = gunzipEntryBody(gzippedBody);
515
- headers['Content-Length'] = String(Buffer.byteLength(json));
516
- envelope = { body: json, headers, metrics };
517
- }
518
-
519
- return {
520
- kind: 'ok',
521
- envelope,
522
- solveMs: 0,
523
- errorCount: header.errorCount,
524
- warningCount: header.warningCount
525
- };
526
- }
527
-
528
- // ============================================================================
529
- // Server-Timing assembly
530
- // ============================================================================
531
-
532
- interface ServerTimingParts {
533
- defLoadMs: number;
534
- treeBuildMs: number;
535
- solveMs: number;
536
- serializeMs: number;
537
- gzipMs: number;
538
- serverTotalMs: number;
539
- rhino: CachedClient['rhinoTiming']['last'];
540
- settle: CachedClient['solveMeta']['last'];
541
- byteRefOutcome?: ByteRefOutcome;
542
- /** L2 verdict: true=hit, false=miss (consulted), null=not consulted. */
543
- l2CacheHit: boolean | null;
544
- prepMarks: [string, number][];
545
- }
546
-
547
- /**
548
- * Assemble the `Server-Timing` header. The browser reads these to attribute its
549
- * round-trip: `total` here == the server's headers-to-out wall time, so browser
550
- * `ttfb − total` ≈ network+send latency. When the compute server reported its
551
- * own decode/solve/encode (VektorNode fork), `rhino_*` is time ON the compute
552
- * server and `compute_link` is everything between (network + queue wait). Prep
553
- * sub-phases ride as `p_*`; cache verdicts as 0/1 `selva_cache`/`def_reupload`.
554
- */
555
- function buildServerTiming(parts: ServerTimingParts): string {
556
- let header =
557
- `load;dur=${parts.defLoadMs.toFixed(1)}, ` +
558
- `tree;dur=${parts.treeBuildMs.toFixed(1)}, ` +
559
- `solve;dur=${parts.solveMs.toFixed(1)}, ` +
560
- `serialize;dur=${parts.serializeMs.toFixed(1)}, ` +
561
- `gzip;dur=${parts.gzipMs.toFixed(1)}, ` +
562
- `total;dur=${parts.serverTotalMs.toFixed(1)}`;
563
-
564
- const rhino = parts.rhino;
565
- if (rhino) {
566
- const onRhinoMs = rhino.decode + rhino.solve + rhino.encode;
567
- const computeLinkMs = Math.max(0, parts.solveMs - onRhinoMs);
568
- header +=
569
- `, rhino_decode;dur=${rhino.decode.toFixed(1)}` +
570
- `, rhino_solve;dur=${rhino.solve.toFixed(1)}` +
571
- `, rhino_encode;dur=${rhino.encode.toFixed(1)}` +
572
- `, compute_link;dur=${computeLinkMs.toFixed(1)}`;
573
- }
574
-
575
- for (const [label, ms] of parts.prepMarks) {
576
- header += `, p_${label};dur=${ms.toFixed(1)}`;
577
- }
578
-
579
- const settle = parts.settle;
580
- if (settle) {
581
- header += `, selva_cache;dur=${settle.fromCache ? 1 : 0}`;
582
- if (settle.definitionReuploaded !== undefined) {
583
- header += `, def_reupload;dur=${settle.definitionReuploaded ? 1 : 0}`;
584
- }
585
- }
586
-
587
- // Byte-cache verdict for `DefinitionRef` solves: skipped = the scheduler never
588
- // materialized bytes (pointer-known solve — the whole point of this path), hit
589
- // = warm byte cache, miss = fell through to storage. Absent for raw-bytes solves.
590
- const byteRef = parts.byteRefOutcome;
591
- if (byteRef) {
592
- const verdict = !byteRef.loaded ? 'skipped' : byteRef.fromCache ? 'hit' : 'miss';
593
- header += `, def_bytes;desc=${verdict}`;
594
- }
595
-
596
- // Durable L2 verdict (H1): 1 = hit (no solve ran), 0 = miss (consulted then
597
- // solved). Absent when the L2 wasn't consulted (draft channel / caching off).
598
- if (parts.l2CacheHit !== null) {
599
- header += `, l2_cache;dur=${parts.l2CacheHit ? 1 : 0}`;
600
- }
601
-
602
- return header;
603
- }
@@ -1,66 +0,0 @@
1
- import { type InputParam, type InputParamSchema, processInput } from '@selvajs/compute';
2
- import type { SchemaInput } from '@selvajs/schemas';
3
-
4
- /**
5
- * Adapt a persisted `SchemaInput` (plus the user's chosen value) into the package's
6
- * raw `InputParamSchema`, then let `@selvajs/compute`'s own `processInput` produce the
7
- * typed `InputParam`. The package handles every paramType (Number/Integer/Text/Boolean/
8
- * ValueList/Geometry/File/Color), normalizes the lowercase UI paramType to its
9
- * PascalCase form, and falls back gracefully on underspecified inputs — so we no longer
10
- * maintain a parallel hand-rolled transform that silently coerced valueList/file/color
11
- * to Text and drifted from the package (and from the parapet app, which already delegates
12
- * here). `values`/`acceptedFormats` are absent on `SchemaInput` (they live on the layout
13
- * item's config), so valueList/file inputs fall back to carrying the selected value with
14
- * no option list — the same value that reaches Grasshopper.
15
- */
16
- export function transformInputParameter(
17
- input: SchemaInput & { minimum?: number; maximum?: number; stepSize?: number },
18
- value: unknown
19
- ): InputParam {
20
- const raw: InputParamSchema = {
21
- id: input.id,
22
- name: input.nickname,
23
- nickname: input.nickname || null,
24
- description: input.description || '',
25
- // Dynamic value lists ride the ValueList contract on the wire — the plugin's
26
- // GetDynamicValueListParameter deliberately reuses TypeName "ValueList" so the
27
- // compute fork's existing ValueList input case assigns it. processInput has no
28
- // dynamicValueList handler; without this mapping it falls back to Geometry with
29
- // a null default and TreeBuilder DROPS the input, so the user's selection never
30
- // reaches the definition (the param then solves on its own stale fallback).
31
- paramType: input.paramType === 'dynamicValueList' ? 'valueList' : input.paramType,
32
- treeAccess: input.inputStructure === 'tree',
33
- minimum: input.minimum ?? null,
34
- maximum: input.maximum ?? null,
35
- atLeast: 1,
36
- atMost: 1,
37
- stepSize: input.stepSize,
38
- // The user's selection wins; otherwise the schema default.
39
- default: value ?? input.default
40
- };
41
-
42
- const processed = processInput(raw);
43
- // Two cases where processInput drops a default we must carry anyway (the
44
- // default IS what TreeBuilder puts on the wire; a dropped default means the
45
- // input is omitted from the solve and the user's selection never reaches
46
- // Grasshopper):
47
- // - a plain array default (multi-select widgets, e.g. a dynamic value list
48
- // checklist) is dropped as "malformed"; TreeBuilder.appendFlat handles
49
- // arrays natively.
50
- // - since @selvajs/compute 3.1.0-beta.5, the ValueList fallback drops a
51
- // scalar default it can't validate against a `values` map — and
52
- // `SchemaInput` never carries the option map (it lives on the layout
53
- // item's config), so every app value-list selection lands here.
54
- const effective = value ?? input.default;
55
- if (processed.default == null && effective != null) {
56
- if (Array.isArray(effective)) {
57
- (processed as { default: unknown }).default = effective;
58
- } else if (processed.paramType === 'ValueList' && typeof effective !== 'object') {
59
- // Scoped to ValueList: for other types a dropped scalar default means
60
- // the package rejected it as unparseable — restoring it would ship
61
- // garbage to Grasshopper.
62
- (processed as { default: unknown }).default = effective;
63
- }
64
- }
65
- return processed;
66
- }