@selvajs/server 0.2.0-beta.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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/dist/compute/__tests__/client-cache.test.d.ts +11 -0
  3. package/dist/compute/__tests__/client-cache.test.d.ts.map +1 -0
  4. package/dist/compute/__tests__/client-cache.test.js +149 -0
  5. package/dist/compute/__tests__/client-cache.test.js.map +1 -0
  6. package/dist/compute/__tests__/rate-limit.test.d.ts +2 -0
  7. package/dist/compute/__tests__/rate-limit.test.d.ts.map +1 -0
  8. package/dist/compute/__tests__/rate-limit.test.js +62 -0
  9. package/dist/compute/__tests__/rate-limit.test.js.map +1 -0
  10. package/dist/compute/__tests__/remote-definition.test.d.ts +11 -0
  11. package/dist/compute/__tests__/remote-definition.test.d.ts.map +1 -0
  12. package/dist/compute/__tests__/remote-definition.test.js +114 -0
  13. package/dist/compute/__tests__/remote-definition.test.js.map +1 -0
  14. package/dist/compute/__tests__/safe-url.test.d.ts +2 -0
  15. package/dist/compute/__tests__/safe-url.test.d.ts.map +1 -0
  16. package/dist/compute/__tests__/safe-url.test.js +71 -0
  17. package/dist/compute/__tests__/safe-url.test.js.map +1 -0
  18. package/dist/compute/__tests__/solve-pipeline.test.d.ts +11 -0
  19. package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +1 -0
  20. package/dist/compute/__tests__/solve-pipeline.test.js +245 -0
  21. package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -0
  22. package/dist/compute/__tests__/transform-input.test.d.ts +14 -0
  23. package/dist/compute/__tests__/transform-input.test.d.ts.map +1 -0
  24. package/dist/compute/__tests__/transform-input.test.js +114 -0
  25. package/dist/compute/__tests__/transform-input.test.js.map +1 -0
  26. package/dist/compute/client-cache.d.ts +104 -0
  27. package/dist/compute/client-cache.d.ts.map +1 -0
  28. package/dist/compute/client-cache.js +147 -0
  29. package/dist/compute/client-cache.js.map +1 -0
  30. package/dist/compute/index.d.ts +8 -0
  31. package/dist/compute/index.d.ts.map +1 -0
  32. package/dist/compute/index.js +9 -0
  33. package/dist/compute/index.js.map +1 -0
  34. package/dist/compute/limits.d.ts +102 -0
  35. package/dist/compute/limits.d.ts.map +1 -0
  36. package/dist/compute/limits.js +76 -0
  37. package/dist/compute/limits.js.map +1 -0
  38. package/dist/compute/rate-limit.d.ts +49 -0
  39. package/dist/compute/rate-limit.d.ts.map +1 -0
  40. package/dist/compute/rate-limit.js +49 -0
  41. package/dist/compute/rate-limit.js.map +1 -0
  42. package/dist/compute/remote-definition.d.ts +53 -0
  43. package/dist/compute/remote-definition.d.ts.map +1 -0
  44. package/dist/compute/remote-definition.js +110 -0
  45. package/dist/compute/remote-definition.js.map +1 -0
  46. package/dist/compute/safe-url.d.ts +15 -0
  47. package/dist/compute/safe-url.d.ts.map +1 -0
  48. package/dist/compute/safe-url.js +195 -0
  49. package/dist/compute/safe-url.js.map +1 -0
  50. package/dist/compute/solve-pipeline.d.ts +127 -0
  51. package/dist/compute/solve-pipeline.d.ts.map +1 -0
  52. package/dist/compute/solve-pipeline.js +195 -0
  53. package/dist/compute/solve-pipeline.js.map +1 -0
  54. package/dist/compute/transform-input.d.ts +20 -0
  55. package/dist/compute/transform-input.d.ts.map +1 -0
  56. package/dist/compute/transform-input.js +47 -0
  57. package/dist/compute/transform-input.js.map +1 -0
  58. package/dist/definitions/__tests__/load-for-render.test.d.ts +8 -0
  59. package/dist/definitions/__tests__/load-for-render.test.d.ts.map +1 -0
  60. package/dist/definitions/__tests__/load-for-render.test.js +196 -0
  61. package/dist/definitions/__tests__/load-for-render.test.js.map +1 -0
  62. package/dist/definitions/__tests__/schema-extraction.test.d.ts +11 -0
  63. package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +1 -0
  64. package/dist/definitions/__tests__/schema-extraction.test.js +115 -0
  65. package/dist/definitions/__tests__/schema-extraction.test.js.map +1 -0
  66. package/dist/definitions/definition-service.d.ts +77 -0
  67. package/dist/definitions/definition-service.d.ts.map +1 -0
  68. package/dist/definitions/definition-service.js +184 -0
  69. package/dist/definitions/definition-service.js.map +1 -0
  70. package/dist/definitions/index.d.ts +4 -0
  71. package/dist/definitions/index.d.ts.map +1 -0
  72. package/dist/definitions/index.js +8 -0
  73. package/dist/definitions/index.js.map +1 -0
  74. package/dist/definitions/load-for-render.d.ts +61 -0
  75. package/dist/definitions/load-for-render.d.ts.map +1 -0
  76. package/dist/definitions/load-for-render.js +165 -0
  77. package/dist/definitions/load-for-render.js.map +1 -0
  78. package/dist/definitions/schema-extraction.d.ts +22 -0
  79. package/dist/definitions/schema-extraction.d.ts.map +1 -0
  80. package/dist/definitions/schema-extraction.js +89 -0
  81. package/dist/definitions/schema-extraction.js.map +1 -0
  82. package/dist/index.d.ts +18 -0
  83. package/dist/index.d.ts.map +1 -0
  84. package/dist/index.js +18 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/providers/__tests__/create-selva-providers.test.d.ts +7 -0
  87. package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +1 -0
  88. package/dist/providers/__tests__/create-selva-providers.test.js +135 -0
  89. package/dist/providers/__tests__/create-selva-providers.test.js.map +1 -0
  90. package/dist/providers/create-selva-providers.d.ts +53 -0
  91. package/dist/providers/create-selva-providers.d.ts.map +1 -0
  92. package/dist/providers/create-selva-providers.js +152 -0
  93. package/dist/providers/create-selva-providers.js.map +1 -0
  94. package/dist/providers/index.d.ts +2 -0
  95. package/dist/providers/index.d.ts.map +1 -0
  96. package/dist/providers/index.js +6 -0
  97. package/dist/providers/index.js.map +1 -0
  98. package/package.json +60 -0
  99. package/src/compute/__tests__/client-cache.test.ts +164 -0
  100. package/src/compute/__tests__/rate-limit.test.ts +69 -0
  101. package/src/compute/__tests__/remote-definition.test.ts +140 -0
  102. package/src/compute/__tests__/safe-url.test.ts +72 -0
  103. package/src/compute/__tests__/solve-pipeline.test.ts +294 -0
  104. package/src/compute/__tests__/transform-input.test.ts +155 -0
  105. package/src/compute/client-cache.ts +278 -0
  106. package/src/compute/index.ts +48 -0
  107. package/src/compute/limits.ts +147 -0
  108. package/src/compute/rate-limit.ts +83 -0
  109. package/src/compute/remote-definition.ts +149 -0
  110. package/src/compute/safe-url.ts +186 -0
  111. package/src/compute/solve-pipeline.ts +324 -0
  112. package/src/compute/transform-input.ts +52 -0
  113. package/src/definitions/__tests__/load-for-render.test.ts +244 -0
  114. package/src/definitions/__tests__/schema-extraction.test.ts +137 -0
  115. package/src/definitions/definition-service.ts +243 -0
  116. package/src/definitions/index.ts +22 -0
  117. package/src/definitions/load-for-render.ts +272 -0
  118. package/src/definitions/schema-extraction.ts +117 -0
  119. package/src/index.ts +18 -0
  120. package/src/providers/__tests__/create-selva-providers.test.ts +172 -0
  121. package/src/providers/__tests__/fixtures/override-config.mjs +8 -0
  122. package/src/providers/create-selva-providers.ts +241 -0
  123. package/src/providers/index.ts +12 -0
@@ -0,0 +1,278 @@
1
+ import {
2
+ GrasshopperClient,
3
+ enableDebugLogging,
4
+ type GrasshopperComputeConfig,
5
+ type SolveScheduler
6
+ } from '@selvajs/compute';
7
+
8
+ // ============================================================================
9
+ // Shared compute client cache
10
+ // ============================================================================
11
+ //
12
+ // One warm `GrasshopperClient` (+ its `SolveScheduler`) per distinct compute
13
+ // server, keyed by the server's **`id`** (ADR 0004 — a server's identity is its
14
+ // `id`, never its URL). Definitions can pin different servers (the app's
15
+ // `resolveServerForOrg` honors a per-definition pin → org default → global
16
+ // default), so this is deliberately a per-server LRU, NOT a single shared
17
+ // client — two definitions on two servers keep two warm clients; a churn of
18
+ // one-off servers is bounded by `maxCachedClients`.
19
+ //
20
+ // Both hot paths share this cache:
21
+ // - the solve endpoint uses the entry's `scheduler`;
22
+ // - the definition-viewer render path uses the entry's `client` for `getIO`.
23
+ // Sharing means a definition that was just solved renders from the same warm
24
+ // client (and vice-versa) instead of re-handshaking Rhino.Compute per page load.
25
+ //
26
+ // Staleness: keyed on `id`, a rotated URL/apiKey keeps the SAME key with stale
27
+ // connection details — so the config-write path MUST call `evict(id)` when a
28
+ // server's config changes (ADR 0004 Consequences). That replaces the old
29
+ // implicit "new key → LRU age-out" staleness the URL-keyed cache relied on.
30
+
31
+ /**
32
+ * Opaque identity of a compute server. Callers never construct the branded
33
+ * string directly — they pass a resolved server config to `serverIdentity()`.
34
+ * Keeping this opaque from day one (ADR 0004 D1) means "a server is now a pool
35
+ * of URLs behind one id" stays an additive, non-breaking change.
36
+ */
37
+ export type ServerIdentity = string & { readonly __brand: 'ServerIdentity' };
38
+
39
+ /** Minimal resolved-server shape the cache needs (a subset of the app's `ComputeServerConfig`). */
40
+ export interface ResolvedServer {
41
+ /** Stable identity — the cache key. */
42
+ id: string;
43
+ /** Base URL of the Rhino.Compute instance (a resolution detail of `id`). */
44
+ serverUrl: string;
45
+ /** Sent as `RhinoComputeKey`. */
46
+ apiKey?: string;
47
+ }
48
+
49
+ /** Derive the opaque cache identity from a resolved server. Identity is the `id`. */
50
+ export function serverIdentity(server: Pick<ResolvedServer, 'id'>): ServerIdentity {
51
+ return server.id as ServerIdentity;
52
+ }
53
+
54
+ /**
55
+ * Per-request telemetry holders read by the caller right after a solve resolves
56
+ * to build a Server-Timing header. Populated by the client/scheduler callbacks.
57
+ */
58
+ export interface CachedClient {
59
+ client: GrasshopperClient;
60
+ scheduler: SolveScheduler;
61
+ /**
62
+ * Last decode/solve/encode reported by the compute server (Server-Timing).
63
+ * Written by onServerTiming, read right after scheduler.solve resolves to split
64
+ * the solve wall time into on-compute-server work vs. the compute↔web-server
65
+ * link. Queue-mode schedulers serialize solves, so this correlates with the
66
+ * request that just resolved; a Selva-cache hit leaves it null (no compute
67
+ * call). Debug aid — a concurrent burst may misattribute one request's timing.
68
+ */
69
+ rhinoTiming: { last: { decode: number; solve: number; encode: number } | null };
70
+ /**
71
+ * Last solve's cache verdicts from the scheduler's onSettle: whether the
72
+ * in-process response cache served it (no compute call) and whether the
73
+ * definition had to be re-uploaded. Same read-after-solve pattern and
74
+ * concurrency caveat as rhinoTiming.
75
+ */
76
+ solveMeta: { last: { fromCache: boolean; definitionReuploaded?: boolean } | null };
77
+ }
78
+
79
+ /**
80
+ * Config injected by the consuming app. Everything that used to be read from
81
+ * `$env` / `computeLimits` in the app module is passed in, so this module stays
82
+ * env-agnostic and testable.
83
+ */
84
+ export interface ClientCacheConfig {
85
+ /** Per-solve timeout forwarded to the scheduler (`ComputeLimits.maxSolveDurationMs`). */
86
+ maxSolveDurationMs: number;
87
+ /** Ask Rhino.Compute to cache solve results and return them on identical repeats. */
88
+ cachesolve: boolean;
89
+ /** Also cache solves that reported GH errors (only meaningful with `cachesolve`). */
90
+ cacheerroredsolves: boolean;
91
+ /** Reference large definitions by server cache key (pointer) instead of re-uploading. */
92
+ reuseServerDefinitionCache: boolean;
93
+ /** Concise cache/timing logs. When false, `onDebugLog` is never invoked. */
94
+ debug: boolean;
95
+ /** VERBOSE lib-level logging (full solve request/response incl. geometry). */
96
+ debugVerbose: boolean;
97
+ /** Max distinct warm clients before the LRU evicts the oldest. Default 16. */
98
+ maxCachedClients?: number;
99
+ /** Sink for the concise debug lines (the app wires `console.log`). */
100
+ onDebugLog?: (message: string) => void;
101
+ }
102
+
103
+ export interface ClientCache {
104
+ /**
105
+ * Get (or create) the warm client + scheduler for a resolved compute server,
106
+ * keyed by its `id`. `definitionGuid`, when present, is stamped as the
107
+ * `X-Selva-Definition` header on this client's outbound solve/IO requests —
108
+ * inert routing/telemetry metadata until a pool router exists (ADR 0004 D2).
109
+ */
110
+ getClient(server: ResolvedServer, opts?: { definitionGuid?: string }): Promise<CachedClient>;
111
+ /**
112
+ * Dispose and drop the warm client for `id` (or a `ServerIdentity`). Called by
113
+ * the config-write path when a server's URL/key rotates so the next request
114
+ * rebuilds against fresh connection details (ADR 0004 Consequences).
115
+ */
116
+ evict(id: string | ServerIdentity): void;
117
+ /** Dispose every warm client. Test seam / shutdown hook. */
118
+ disposeAll(): void;
119
+ }
120
+
121
+ const DEFAULT_MAX_CACHED_CLIENTS = 16;
122
+
123
+ /**
124
+ * Build a per-server warm-client cache. Each cache owns its own LRU `Map` keyed
125
+ * on server `id`.
126
+ */
127
+ export function createClientCache(config: ClientCacheConfig): ClientCache {
128
+ const maxCachedClients = config.maxCachedClients ?? DEFAULT_MAX_CACHED_CLIENTS;
129
+ const cache = new Map<string, CachedClient>();
130
+
131
+ if (config.debugVerbose) enableDebugLogging();
132
+
133
+ const debugLog = (message: string) => {
134
+ if (config.debug) config.onDebugLog?.(message);
135
+ };
136
+
137
+ async function build(server: ResolvedServer, definitionGuid?: string): Promise<CachedClient> {
138
+ // Holders for per-request results from the client/scheduler callbacks; the
139
+ // caller reads them right after each solve resolves to build Server-Timing.
140
+ const rhinoTiming: CachedClient['rhinoTiming'] = { last: null };
141
+ const solveMeta: CachedClient['solveMeta'] = { last: null };
142
+
143
+ const clientConfig: GrasshopperComputeConfig = {
144
+ serverUrl: server.serverUrl,
145
+ apiKey: server.apiKey,
146
+ // Lib-level debug is the verbose one (full payload logs); keep it on the
147
+ // verbose flag so the concise cache logs can run without the noise.
148
+ debug: config.debugVerbose,
149
+ // Ask Rhino.Compute to cache solve results and return them on identical
150
+ // repeats (same definition + inputs), skipping the solve.
151
+ cachesolve: config.cachesolve,
152
+ // Opt-in: also cache solves that reported GH errors.
153
+ cacheerroredsolves: config.cacheerroredsolves,
154
+ // DEBUG: the server's per-request timing breakdown, which exposes the two
155
+ // SERVER-side caches at once — definition cache (pointer reuse) via
156
+ // `decode`, solve cache (cachesolve) via `solve`. Flagged below so the
157
+ // contrast is obvious in the logs.
158
+ onServerTiming: (t) => {
159
+ const decode = t.decode ?? 0;
160
+ const solve = t.solve ?? 0;
161
+ const encode = t.encode ?? 0;
162
+ // Always recorded (cheap): the caller reads this after scheduler.solve.
163
+ rhinoTiming.last = { decode, solve, encode };
164
+ if (!config.debug) return;
165
+ // Heuristic thresholds: a genuine parse/solve is many ms; a cache hit
166
+ // is sub-millisecond. Only meaningful relative to the first (cold) solve.
167
+ const defCache =
168
+ decode <= 1 ? 'HIT (definition reused, no re-upload/parse)' : 'miss (full parse)';
169
+ const solveCache =
170
+ config.cachesolve && solve <= 1
171
+ ? 'HIT (cached result, solve skipped)'
172
+ : config.cachesolve
173
+ ? 'miss (solved fresh)'
174
+ : 'off';
175
+ debugLog(
176
+ `[Compute/rhino-cache] decode=${decode}ms solve=${solve}ms encode=${encode}ms ` +
177
+ `| definition-cache: ${defCache} | solve-cache (cachesolve): ${solveCache}`
178
+ );
179
+ }
180
+ };
181
+
182
+ // `X-Selva-Definition` on the wire (ADR 0004 D2). Baked at client-create
183
+ // time because the scheduler has no per-request header hook — inert for a
184
+ // single-member "pool", useful in compute-side access logs immediately.
185
+ if (definitionGuid) {
186
+ clientConfig.headers = { 'X-Selva-Definition': definitionGuid };
187
+ }
188
+
189
+ const client = await GrasshopperClient.create(clientConfig);
190
+ const scheduler = client.createScheduler({
191
+ mode: 'queue',
192
+ timeoutMs: config.maxSolveDurationMs,
193
+ cache: { maxEntries: 20, ttlMs: 5 * 60_000 },
194
+ // Solve large definitions by server cache-key (pointer) instead of
195
+ // re-uploading the full binary every solve. On a stale-pointer miss the
196
+ // client transparently re-uploads.
197
+ reuseServerDefinitionCache: config.reuseServerDefinitionCache,
198
+ // DEBUG: observe the in-process response cache. `fromCache === true` means
199
+ // this solve was served from the app's own cache — Rhino.Compute was never
200
+ // called. `false` means it went to the compute server.
201
+ onSettle: (_ctx, result) => {
202
+ if (result.status !== 'success') return;
203
+ // Always recorded (cheap): the caller surfaces these on Server-Timing.
204
+ solveMeta.last = {
205
+ fromCache: result.fromCache === true,
206
+ definitionReuploaded: result.definitionReuploaded
207
+ };
208
+ if (!config.debug) return;
209
+ debugLog(
210
+ `[Compute/selva-cache] ${result.fromCache ? 'HIT — served from cache (no compute call)' : 'miss — went to Rhino.Compute'} (${Math.round(result.durationMs)}ms)`
211
+ );
212
+ // Definition-cache verdict for a real compute call (not a cache HIT).
213
+ if (!result.fromCache) {
214
+ debugLog(
215
+ result.definitionReuploaded === true
216
+ ? '[Compute/def-cache] miss — definition RE-UPLOADED (pointer was cold/stale)'
217
+ : result.definitionReuploaded === false
218
+ ? '[Compute/def-cache] HIT — definition reused from server cache (no upload)'
219
+ : '[Compute/def-cache] n/a — reuse disabled or non-reusable definition'
220
+ );
221
+ }
222
+ // Surface real GH errors when present. An errored solve is NEVER cached
223
+ // server-side (cachesolve only stores error-free solves).
224
+ if (!result.fromCache) {
225
+ const errs = (result.response as { errors?: unknown[] })?.errors;
226
+ const errCount = Array.isArray(errs) ? errs.length : 0;
227
+ if (errCount > 0) {
228
+ debugLog(
229
+ `[Compute/rhino-cache] ${errCount} GH error(s); not cacheable until fixed. First: ${JSON.stringify((errs as unknown[])[0])}`
230
+ );
231
+ }
232
+ }
233
+ }
234
+ });
235
+ return { client, scheduler, rhinoTiming, solveMeta };
236
+ }
237
+
238
+ return {
239
+ async getClient(server, opts): Promise<CachedClient> {
240
+ const key = server.id;
241
+ const existing = cache.get(key);
242
+ if (existing) {
243
+ // Refresh LRU position.
244
+ cache.delete(key);
245
+ cache.set(key, existing);
246
+ return existing;
247
+ }
248
+
249
+ const entry = await build(server, opts?.definitionGuid);
250
+
251
+ // Evict the least-recently-used entry before inserting when at capacity.
252
+ if (cache.size >= maxCachedClients) {
253
+ const oldestKey = cache.keys().next().value;
254
+ if (oldestKey !== undefined) {
255
+ cache.get(oldestKey)?.scheduler.dispose();
256
+ cache.delete(oldestKey);
257
+ }
258
+ }
259
+
260
+ cache.set(key, entry);
261
+ return entry;
262
+ },
263
+
264
+ evict(id): void {
265
+ const key = id as string;
266
+ const entry = cache.get(key);
267
+ if (entry) {
268
+ entry.scheduler.dispose();
269
+ cache.delete(key);
270
+ }
271
+ },
272
+
273
+ disposeAll(): void {
274
+ for (const entry of cache.values()) entry.scheduler.dispose();
275
+ cache.clear();
276
+ }
277
+ };
278
+ }
@@ -0,0 +1,48 @@
1
+ // Compute/solve server building blocks — transport-agnostic, env-injected.
2
+
3
+ export {
4
+ resolveComputeLimits,
5
+ readPositiveInt,
6
+ readBool,
7
+ type ComputeLimits,
8
+ type EnvRecord
9
+ } from './limits.js';
10
+
11
+ export {
12
+ createComputeRateLimiter,
13
+ type ComputeRateLimiter,
14
+ type ComputeRateLimiterConfig,
15
+ type RateLimitResult
16
+ } from './rate-limit.js';
17
+
18
+ export { isSafeRemoteDefinitionUrl, assertSafeRemoteDefinitionUrl } from './safe-url.js';
19
+
20
+ export { transformInputParameter } from './transform-input.js';
21
+
22
+ export {
23
+ createClientCache,
24
+ serverIdentity,
25
+ type ClientCache,
26
+ type ClientCacheConfig,
27
+ type CachedClient,
28
+ type ResolvedServer,
29
+ type ServerIdentity
30
+ } from './client-cache.js';
31
+
32
+ export {
33
+ createRemoteDefinitionFetcher,
34
+ readBodyWithCap,
35
+ type RemoteDefinitionFetcher,
36
+ type RemoteDefinitionConfig
37
+ } from './remote-definition.js';
38
+
39
+ export {
40
+ runSolvePipeline,
41
+ COMPUTE_CONTRACT_VERSION,
42
+ COMPUTE_VERSION_HEADER,
43
+ type SolvePipelineArgs,
44
+ type SolveOutcome,
45
+ type SolveEnvelope,
46
+ type SolvePhaseMetrics,
47
+ type PipelineInput
48
+ } from './solve-pipeline.js';
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Compute/solve server limits, resolved from an injected env map.
3
+ *
4
+ * This is the transport- and framework-agnostic core: it takes a plain
5
+ * `Record<string, string | undefined>` (the caller passes SvelteKit's
6
+ * `$env/dynamic/private`, `process.env`, or any config source) and returns a
7
+ * fully-resolved `ComputeLimits` object. No ambient `$env` / `process.env`
8
+ * read happens here, so the module is importable from any runtime and testable
9
+ * without env plumbing.
10
+ *
11
+ * Every knob is env-overridable so deployments can tune without code changes;
12
+ * the defaults are sized for typical interactive use (sliders + small uploads).
13
+ * Centralizing them surfaces the tradeoffs (raise the rate cap → also consider
14
+ * the body cap) at one glance.
15
+ *
16
+ * Client-side counterparts (the in-flight compute throttle and slider debounce)
17
+ * live in `@selvajs/ui` and are bundled into the client, so they can't read
18
+ * env — they carry their own defaults.
19
+ */
20
+
21
+ const MB = 1024 * 1024;
22
+
23
+ /** Env source: any string→string map. `$env/dynamic/private` satisfies this. */
24
+ export type EnvRecord = Record<string, string | undefined>;
25
+
26
+ /**
27
+ * Parse a positive integer env value. Returns `fallback` when absent, and warns
28
+ * + falls back when present but not a finite positive number.
29
+ */
30
+ export function readPositiveInt(env: EnvRecord, name: string, fallback: number): number {
31
+ const raw = env[name];
32
+ if (!raw) return fallback;
33
+ const parsed = Number(raw);
34
+ if (!Number.isFinite(parsed) || parsed <= 0) {
35
+ console.warn(`[selva] Invalid ${name}=${raw}, falling back to ${fallback}`);
36
+ return fallback;
37
+ }
38
+ return Math.floor(parsed);
39
+ }
40
+
41
+ /**
42
+ * Parse a boolean env flag. Accepts `true/1/yes/on` (case-insensitive) as true
43
+ * and `false/0/no/off` as false; any other / absent value falls back.
44
+ */
45
+ export function readBool(env: EnvRecord, name: string, fallback: boolean): boolean {
46
+ const raw = env[name];
47
+ if (raw == null || raw === '') return fallback;
48
+ const v = raw.trim().toLowerCase();
49
+ if (v === 'true' || v === '1' || v === 'yes' || v === 'on') return true;
50
+ if (v === 'false' || v === '0' || v === 'no' || v === 'off') return false;
51
+ console.warn(`[selva] Invalid ${name}=${raw}, falling back to ${fallback}`);
52
+ return fallback;
53
+ }
54
+
55
+ /** Fully-resolved server-side compute limits. */
56
+ export interface ComputeLimits {
57
+ /**
58
+ * Longest the app waits for one solve. Enforced by the SolveScheduler
59
+ * (`@selvajs/compute`), which propagates AbortSignal into the upstream
60
+ * Compute call — a timeout actually cancels the work. Forwarded to the
61
+ * client to drive the in-browser AbortController.
62
+ *
63
+ * Caveat: bounds only the parts of the stack we own. A reverse proxy or
64
+ * serverless platform cap may shoot the request sooner; bumping past those
65
+ * caps produces 502s, not longer solves.
66
+ */
67
+ maxSolveDurationMs: number;
68
+ /** Fixed-window cap on /api/compute: window length + max requests per window. */
69
+ rateLimitWindowMs: number;
70
+ rateLimitMaxRequests: number;
71
+ /**
72
+ * Largest .gh definition accepted on upload AND the largest remote definition
73
+ * fetched for compute — kept in lockstep so a remote URL can't smuggle a file
74
+ * past the upload cap.
75
+ */
76
+ maxGhFileSize: number;
77
+ maxImageFileSize: number;
78
+ /**
79
+ * /api/compute JSON *request* body cap (inputs + values, not the .gh). A
80
+ * `file`-widget input embeds geometry as base64 in `values`; must stay <=
81
+ * the adapter-node global BODY_SIZE_LIMIT or the global backstop rejects first.
82
+ */
83
+ computeRequestMaxBytes: number;
84
+ /**
85
+ * /api/compute JSON *response* cap. A `file`-typed output is base64-embedded
86
+ * in the response; guards V8's ~512 MB single-string wall (a `JSON.stringify`
87
+ * `RangeError`) and browser-tab OOM. Defensive backstop → clear 413 instead of
88
+ * an opaque crash (real fix is out-of-band streaming, ADR 0003).
89
+ */
90
+ computeResponseMaxBytes: number;
91
+ /** Hard cap on fetching a remote definition — tracks `maxGhFileSize`. */
92
+ remoteDefinitionMaxBytes: number;
93
+ /** Deadline on fetching a remote definition (slow-loris protection). */
94
+ remoteDefinitionFetchTimeoutMs: number;
95
+ /** In-process cache TTL for remote-fetched .gh bytes. */
96
+ definitionCacheTtlMs: number;
97
+ /**
98
+ * Reuse the server's cached definition via a pointer instead of re-uploading
99
+ * the binary each solve. SAFE only on a compute server that signals a
100
+ * stale-pointer miss (VektorNode fork / a fork that throws) — a server that
101
+ * returns an empty 200 would silently yield EMPTY geometry. Off for unknown
102
+ * / standard rhino.compute.
103
+ */
104
+ computeReuseDefinitionCache: boolean;
105
+ /**
106
+ * Ask Rhino.Compute to cache solve RESULTS keyed on the full request and
107
+ * return a cached result on an identical repeat. Server-wide (survives Selva
108
+ * restarts, spans instances). Only helps IDENTICAL re-solves.
109
+ */
110
+ computeServerCachesolve: boolean;
111
+ /**
112
+ * Also cache solves that reported Grasshopper errors — correct for
113
+ * error-by-design definitions (guarded Python, pruned branches) that still
114
+ * produce valid geometry. Requires `computeServerCachesolve` and a server
115
+ * honoring the flag. Conservative default off.
116
+ */
117
+ computeCacheErroredSolves: boolean;
118
+ }
119
+
120
+ /**
121
+ * Resolve every server-side compute limit from an env map. Pure: the same env
122
+ * always yields the same limits, and nothing is read ambiently. The app calls
123
+ * this once at its composition root with `$env/dynamic/private`.
124
+ */
125
+ export function resolveComputeLimits(env: EnvRecord): ComputeLimits {
126
+ const maxGhFileSize = readPositiveInt(env, 'MAX_GH_FILE_SIZE_BYTES', 300 * MB);
127
+ return {
128
+ maxSolveDurationMs: readPositiveInt(env, 'MAX_SOLVE_DURATION_MS', 100_000),
129
+ rateLimitWindowMs: readPositiveInt(env, 'COMPUTE_RATE_LIMIT_WINDOW_MS', 100_000),
130
+ rateLimitMaxRequests: readPositiveInt(env, 'COMPUTE_RATE_LIMIT_MAX', 120),
131
+ maxGhFileSize,
132
+ maxImageFileSize: readPositiveInt(env, 'MAX_IMAGE_FILE_SIZE_BYTES', 10 * MB),
133
+ computeRequestMaxBytes: readPositiveInt(env, 'COMPUTE_REQUEST_MAX_BYTES', 300 * MB),
134
+ computeResponseMaxBytes: readPositiveInt(env, 'COMPUTE_RESPONSE_MAX_BYTES', 300 * MB),
135
+ // Tracks the upload cap so a remote URL can't smuggle a larger file.
136
+ remoteDefinitionMaxBytes: maxGhFileSize,
137
+ remoteDefinitionFetchTimeoutMs: readPositiveInt(
138
+ env,
139
+ 'REMOTE_DEFINITION_FETCH_TIMEOUT_MS',
140
+ 30_000
141
+ ),
142
+ definitionCacheTtlMs: readPositiveInt(env, 'DEFINITION_CACHE_TTL_MS', 5 * 60 * 1000),
143
+ computeReuseDefinitionCache: readBool(env, 'COMPUTE_REUSE_DEFINITION_CACHE', true),
144
+ computeServerCachesolve: readBool(env, 'COMPUTE_SERVER_CACHESOLVE', true),
145
+ computeCacheErroredSolves: readBool(env, 'COMPUTE_CACHE_ERRORED_SOLVES', false)
146
+ };
147
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Per-key fixed-window rate limiter for the compute-solve endpoint. Compute
3
+ * solves are the one expensive endpoint in a Selva-engine app — without a cap a
4
+ * single authenticated user (or share-link consumer) can burn through compute
5
+ * budget far faster than any other surface allows.
6
+ *
7
+ * Design:
8
+ * - Process-local `Map`. Multi-instance deployments see N× the per-key rate —
9
+ * acceptable as a first line; a distributed limiter is a follow-up. The state
10
+ * lives behind this factory so a shared-store implementation can slot in
11
+ * later without changing the call sites.
12
+ * - Fixed window (vs sliding) — simpler, no per-request bookkeeping; the
13
+ * "burst at window edges" mode is fine here (share-link `maxSolves` and
14
+ * per-user auth already prevent unbounded abuse — this flattens short spikes).
15
+ * - Keyed by caller — the app passes `user:{userId}` for authenticated solves,
16
+ * `share:{linkId}` for share-token solves, so anonymous consumers of one link
17
+ * don't share a bucket with the link's owner.
18
+ *
19
+ * The window + cap are injected (they live in `ComputeLimits`), so this module
20
+ * reads no env of its own.
21
+ */
22
+
23
+ export interface RateLimitResult {
24
+ allowed: boolean;
25
+ /** Seconds until the bucket resets, when not allowed. */
26
+ retryAfter?: number;
27
+ }
28
+
29
+ export interface ComputeRateLimiterConfig {
30
+ /** Fixed-window length in ms. */
31
+ windowMs: number;
32
+ /** Max requests admitted per key per window. */
33
+ maxPerWindow: number;
34
+ }
35
+
36
+ export interface ComputeRateLimiter {
37
+ /**
38
+ * Check + record one request against `key`. Increments the counter on allow.
39
+ * Returns `{ allowed: false, retryAfter }` once the bucket is full.
40
+ */
41
+ check(key: string): RateLimitResult;
42
+ /** Test seam — wipes in-memory state. Production code never calls this. */
43
+ reset(): void;
44
+ /** The resolved config, exposed for time-based test assertions. */
45
+ readonly config: Readonly<ComputeRateLimiterConfig>;
46
+ }
47
+
48
+ interface BucketEntry {
49
+ count: number;
50
+ resetAt: number;
51
+ }
52
+
53
+ /**
54
+ * Build a process-local fixed-window rate limiter. Each limiter owns its own
55
+ * bucket `Map`, so distinct endpoints/keys-spaces don't collide.
56
+ */
57
+ export function createComputeRateLimiter(config: ComputeRateLimiterConfig): ComputeRateLimiter {
58
+ const { windowMs, maxPerWindow } = config;
59
+ const buckets = new Map<string, BucketEntry>();
60
+
61
+ return {
62
+ config,
63
+ check(key: string): RateLimitResult {
64
+ const now = Date.now();
65
+ const entry = buckets.get(key);
66
+
67
+ if (!entry || now > entry.resetAt) {
68
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
69
+ return { allowed: true };
70
+ }
71
+
72
+ if (entry.count >= maxPerWindow) {
73
+ return { allowed: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
74
+ }
75
+
76
+ entry.count += 1;
77
+ return { allowed: true };
78
+ },
79
+ reset(): void {
80
+ buckets.clear();
81
+ }
82
+ };
83
+ }