@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,186 +0,0 @@
1
- import { lookup } from 'node:dns/promises';
2
- import { isIP } from 'node:net';
3
-
4
- /**
5
- * SSRF guard for `loadRemoteDefinition`. Without it an authenticated user could
6
- * submit `http://169.254.169.254/...` (cloud metadata) or any intranet endpoint
7
- * and the server would fetch it on their behalf.
8
- *
9
- * Two layers:
10
- *
11
- * 1. `isSafeRemoteDefinitionUrl` — synchronous literal pre-filter. Rejects
12
- * protocol, hostname literals, and every IP *encoding* that maps to a
13
- * blocked address (dotted-decimal, integer, octal, hex, short-form,
14
- * IPv4-mapped IPv6). Fast and DNS-free; safe to call anywhere.
15
- *
16
- * 2. `assertSafeRemoteDefinitionUrl` — resolves the hostname via DNS and
17
- * re-checks the *resolved* IP. This is the real defense: it catches a
18
- * public name that resolves to a private IP, which the literal filter
19
- * can't see. Still not bulletproof against rebinding (the IP can change
20
- * between this lookup and fetch's own); for that the caller would have to
21
- * connect to the resolved IP directly. Connecting by IP is a larger change
22
- * — this closes the practical bypasses (literal encodings + a DNS record
23
- * that points inward) which is what an attacker actually reaches for.
24
- */
25
-
26
- /**
27
- * True when `ip` (a normalized IPv4 or IPv6 literal from `isIP`/`dns.lookup`)
28
- * is loopback, private, link-local, or otherwise must-not-fetch. IPv4-mapped
29
- * IPv6 (`::ffff:a.b.c.d`) is unwrapped to its embedded IPv4 first so the v4
30
- * rules apply.
31
- */
32
- function isBlockedIp(ip: string): boolean {
33
- let addr = ip.toLowerCase();
34
-
35
- // Unwrap IPv4-mapped IPv6 so the embedded address is judged by the IPv4
36
- // rules below. `URL` compresses the dotted form to hex (`::ffff:127.0.0.1`
37
- // → `::ffff:7f00:1`), so handle both spellings.
38
- const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(addr);
39
- if (mappedDotted) {
40
- addr = mappedDotted[1];
41
- } else {
42
- const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(addr);
43
- if (mappedHex) {
44
- const hi = parseInt(mappedHex[1], 16);
45
- const lo = parseInt(mappedHex[2], 16);
46
- addr = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join('.');
47
- }
48
- }
49
-
50
- if (isIP(addr) === 4) {
51
- const octets = addr.split('.').map(Number);
52
- if (octets.length !== 4 || octets.some((o) => o > 255)) return true; // malformed → block
53
- const [a, b] = octets;
54
- if (a === 0) return true; // 0.0.0.0/8 (incl. 0.0.0.0)
55
- if (a === 127) return true; // loopback
56
- if (a === 10) return true; // RFC1918
57
- if (a === 192 && b === 168) return true; // RFC1918
58
- if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
59
- if (a === 169 && b === 254) return true; // link-local + cloud metadata
60
- return false;
61
- }
62
-
63
- // IPv6 literals.
64
- if (addr === '::1' || addr === '::') return true; // loopback / unspecified
65
- if (/^(fc|fd)[0-9a-f]{0,2}:/.test(addr)) return true; // unique-local fc00::/7
66
- if (/^fe[89ab][0-9a-f]:/.test(addr)) return true; // link-local fe80::/10
67
- return false;
68
- }
69
-
70
- /**
71
- * Parse a bare-IPv4 hostname in any encoding browsers/glibc accept — integer
72
- * (`2130706433`), octal (`0177.0.0.1`), hex (`0x7f000001`), and short forms
73
- * (`127.1`) — into a canonical dotted-decimal string. Returns null when the
74
- * host is not a numeric IPv4 literal (i.e. it's a real name or already-valid
75
- * dotted-quad, which callers handle separately).
76
- */
77
- function canonicalizeNumericIpv4(host: string): string | null {
78
- // Already a clean dotted-quad — let isIP handle it directly.
79
- if (isIP(host) === 4) return host;
80
-
81
- const rawParts = host.split('.');
82
- if (rawParts.length === 0 || rawParts.length > 4) return null;
83
-
84
- const parseUInt = (s: string): number | null => {
85
- if (s === '') return null;
86
- let n: number;
87
- if (/^0x[0-9a-f]+$/i.test(s)) n = parseInt(s, 16);
88
- else if (/^0[0-7]+$/.test(s)) n = parseInt(s, 8);
89
- else if (/^[0-9]+$/.test(s)) n = parseInt(s, 10);
90
- else return null;
91
- return Number.isFinite(n) ? n : null;
92
- };
93
-
94
- const nums = rawParts.map(parseUInt);
95
- if (nums.some((n) => n === null)) return null;
96
- const parts = nums as number[];
97
-
98
- // inet_aton semantics: the final part absorbs all remaining low-order bytes.
99
- // 1 part → 32-bit value; 2 → a.(24-bit); 3 → a.b.(16-bit); 4 → a.b.c.d.
100
- let value: number;
101
- switch (parts.length) {
102
- case 1:
103
- value = parts[0];
104
- break;
105
- case 2:
106
- if (parts[0] > 0xff || parts[1] > 0xffffff) return null;
107
- value = (parts[0] << 24) | parts[1];
108
- break;
109
- case 3:
110
- if (parts[0] > 0xff || parts[1] > 0xff || parts[2] > 0xffff) return null;
111
- value = (parts[0] << 24) | (parts[1] << 16) | parts[2];
112
- break;
113
- default:
114
- if (parts.some((p) => p > 0xff)) return null;
115
- value = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
116
- }
117
- value = value >>> 0; // force unsigned 32-bit
118
- if (value > 0xffffffff) return null;
119
-
120
- return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff].join(
121
- '.'
122
- );
123
- }
124
-
125
- /**
126
- * Synchronous literal pre-filter. Rejects bad schemes, loopback hostnames, and
127
- * any IP literal (in any encoding) that resolves to a blocked address. Does NOT
128
- * perform DNS — a public *name* that points at a private IP passes here and is
129
- * caught by `assertSafeRemoteDefinitionUrl`.
130
- */
131
- export function isSafeRemoteDefinitionUrl(raw: string): boolean {
132
- let parsed: URL;
133
- try {
134
- parsed = new URL(raw);
135
- } catch {
136
- return false;
137
- }
138
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
139
-
140
- let host = parsed.hostname.toLowerCase();
141
- if (!host) return false;
142
-
143
- // Hostname literals that never reach DNS.
144
- if (host === 'localhost' || host.endsWith('.localhost')) return false;
145
-
146
- // Strip the brackets URL keeps around IPv6 literals.
147
- const isBracketed = host.startsWith('[') && host.endsWith(']');
148
- if (isBracketed) host = host.slice(1, -1);
149
-
150
- if (isIP(host) === 6) return !isBlockedIp(host);
151
-
152
- // Numeric IPv4 in any encoding → canonicalize, then judge.
153
- const canonical = canonicalizeNumericIpv4(host);
154
- if (canonical) return !isBlockedIp(canonical);
155
-
156
- // A real hostname — passes the literal filter; DNS resolution is the gate.
157
- return true;
158
- }
159
-
160
- /**
161
- * Full SSRF check: literal pre-filter, then resolve every A/AAAA record and
162
- * reject if *any* resolved IP is blocked. Returns the original URL on success
163
- * so callers can `await assertSafeRemoteDefinitionUrl(url)` inline. Throws on
164
- * any rejection.
165
- */
166
- export async function assertSafeRemoteDefinitionUrl(raw: string): Promise<string> {
167
- if (!isSafeRemoteDefinitionUrl(raw)) {
168
- throw new Error('Remote definition URL is not allowed');
169
- }
170
-
171
- const host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, '');
172
-
173
- // Already an IP literal — isSafeRemoteDefinitionUrl validated it; no DNS.
174
- if (isIP(host) !== 0 || canonicalizeNumericIpv4(host)) return raw;
175
-
176
- let resolved: { address: string }[];
177
- try {
178
- resolved = await lookup(host, { all: true });
179
- } catch {
180
- throw new Error('Remote definition URL is not allowed');
181
- }
182
- if (resolved.length === 0 || resolved.some((r) => isBlockedIp(r.address))) {
183
- throw new Error('Remote definition URL is not allowed');
184
- }
185
- return raw;
186
- }
@@ -1,98 +0,0 @@
1
- /**
2
- * L2 cache entry format (R6). An entry is the gzipped response body plus a tiny
3
- * metadata header, so a hit is near-CPU-free: no `JSON.stringify`, no re-solve —
4
- * gzip clients get the stored bytes verbatim, and the header carries just enough
5
- * to rebuild the `ok` outcome (error/warning counts for the metric, uncompressed
6
- * length for the `Content-Length` on a gunzip fallback).
7
- *
8
- * Wire layout (opaque to the `ISolveResultCache` backend, which sees only bytes):
9
- *
10
- * [ 4 bytes: header length N, big-endian uint32 ]
11
- * [ N bytes: UTF-8 JSON EnvelopeHeader ]
12
- * [ rest: gzipped JSON response body ]
13
- *
14
- * The `algo` echo (M3) is already stripped at the source in the package's
15
- * `runSolve`, so the body never carries the base64 definition — entries stay
16
- * small without a strip step here.
17
- */
18
-
19
- import { gunzipSync } from 'node:zlib';
20
-
21
- /** Metadata stored beside the gzipped body. Small; parsed on every hit. */
22
- export interface EnvelopeHeader {
23
- /** Grasshopper runtime error count, for the solve metric. */
24
- errorCount: number;
25
- /** Grasshopper runtime warning count, for the solve metric. */
26
- warningCount: number;
27
- /** Uncompressed JSON body length in bytes (Content-Length for a gunzip fallback). */
28
- serializedBytes: number;
29
- /**
30
- * SHA-256 hex of the canonical input preimage (H2 defense-in-depth). A hit
31
- * verifies this equals the requesting key's hash before serving — a wide-hash
32
- * collision that produced the same storage key still can't serve the wrong
33
- * geometry.
34
- */
35
- inputHash: string;
36
- }
37
-
38
- /**
39
- * Encode a cache entry from the gzipped body + header. The body MUST already be
40
- * gzipped (the pipeline gzips before storing so every hit serves compressed
41
- * bytes; a rare non-gzip client gunzips on read).
42
- */
43
- export function encodeSolveCacheEntry(header: EnvelopeHeader, gzippedBody: Uint8Array): Uint8Array {
44
- const headerJson = Buffer.from(JSON.stringify(header), 'utf8');
45
- const out = Buffer.allocUnsafe(4 + headerJson.byteLength + gzippedBody.byteLength);
46
- out.writeUInt32BE(headerJson.byteLength, 0);
47
- headerJson.copy(out, 4);
48
- Buffer.from(gzippedBody.buffer, gzippedBody.byteOffset, gzippedBody.byteLength).copy(
49
- out,
50
- 4 + headerJson.byteLength
51
- );
52
- return out;
53
- }
54
-
55
- /** A decoded entry: the parsed header + the still-gzipped body slice. */
56
- export interface DecodedSolveCacheEntry {
57
- header: EnvelopeHeader;
58
- gzippedBody: Uint8Array;
59
- }
60
-
61
- /**
62
- * Decode a cache entry. Returns `null` on any malformed input (truncated, bad
63
- * length prefix, non-JSON header) — a corrupt entry is treated as a miss, never
64
- * an error, matching the best-effort cache contract.
65
- */
66
- export function decodeSolveCacheEntry(bytes: Uint8Array): DecodedSolveCacheEntry | null {
67
- if (bytes.byteLength < 4) return null;
68
- const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
69
- const headerLen = buf.readUInt32BE(0);
70
- const headerEnd = 4 + headerLen;
71
- if (headerEnd > buf.byteLength) return null;
72
- let header: EnvelopeHeader;
73
- try {
74
- header = JSON.parse(buf.toString('utf8', 4, headerEnd)) as EnvelopeHeader;
75
- } catch {
76
- return null;
77
- }
78
- if (
79
- typeof header?.errorCount !== 'number' ||
80
- typeof header?.warningCount !== 'number' ||
81
- typeof header?.serializedBytes !== 'number' ||
82
- typeof header?.inputHash !== 'string'
83
- ) {
84
- return null;
85
- }
86
- // Slice (view, no copy) of the gzipped remainder.
87
- const gzippedBody = new Uint8Array(
88
- bytes.buffer,
89
- bytes.byteOffset + headerEnd,
90
- bytes.byteLength - headerEnd
91
- );
92
- return { header, gzippedBody };
93
- }
94
-
95
- /** Gunzip a stored body back to the JSON string, for a non-gzip client (rare). */
96
- export function gunzipEntryBody(gzippedBody: Uint8Array): string {
97
- return gunzipSync(gzippedBody).toString('utf8');
98
- }
@@ -1,85 +0,0 @@
1
- /**
2
- * Durable L2 cache-key derivation (H2 / R8 / R13).
3
- *
4
- * The in-process L1 cache keys on a 32-bit FNV hash — fine for a 20-entry Map,
5
- * unsafe for a durable, cross-user cache where a collision serves user A's
6
- * geometry to user B (CONTEXT.md L92–97 records exactly this bug shipping once).
7
- * So the L2 key is a wide SHA-256 hash, and the entry stores the canonical
8
- * preimage so a hit can compare it byte-for-byte (defense-in-depth).
9
- *
10
- * What goes into the key:
11
- * - The **transformed** input tree (R13) — the exact tree handed to the
12
- * scheduler, canonicalized with the package's exported `stableStringify` so
13
- * the L2 key parity-matches the L1 canonicalization path.
14
- * - A **config subset** (R8): everything OTHER than definition+inputs that
15
- * changes a solve's result — model units, tolerances, `dataversion`, the
16
- * compute contract version, and (when known) the Rhino server identity. A
17
- * durable key of `(versionId, inputHash)` alone would collide across
18
- * differing server configs or Rhino versions.
19
- *
20
- * `versionId`, `definitionId`, and `orgId` are NOT hashed in here — they're
21
- * separate parts of the {@link SolveCacheKey} tuple so the backend can scope its
22
- * keyspace (per-definition quota, per-org isolation) without unpacking the hash.
23
- */
24
-
25
- import { createHash } from 'node:crypto';
26
- import { stableStringify } from '@selvajs/compute';
27
- import { COMPUTE_CONTRACT_VERSION } from './solve-pipeline.js';
28
-
29
- /**
30
- * Solve-affecting configuration folded into the key alongside the input tree.
31
- * Every field here is something that, if it changed, would legitimately change
32
- * the solve output for the SAME definition + inputs. Assemble it from the app's
33
- * resolved solve config; unset fields are simply absent from the canonical form.
34
- */
35
- export interface SolveCacheConfigSubset {
36
- /** Rhino model unit system, if the app pins one (else the definition/server default). */
37
- modelUnits?: string;
38
- /** Absolute tolerance, if pinned. */
39
- absoluteTolerance?: number;
40
- /** Angle tolerance, if pinned. */
41
- angleTolerance?: number;
42
- /** Grasshopper `dataversion` / data-tree schema version, if pinned. */
43
- dataVersion?: number | string;
44
- /**
45
- * Compute-server identity (id or URL). Folds in because two servers can run
46
- * different Rhino/plugin versions that yield different geometry for the same
47
- * inputs. Optional — omit for a single-pool deployment where it's constant.
48
- */
49
- computeServerId?: string;
50
- }
51
-
52
- /** The canonical preimage + its SHA-256 digest. Store both in the L2 entry header. */
53
- export interface SolveCacheInputKey {
54
- /** The canonical string that was hashed (for hit-time byte comparison). */
55
- canonical: string;
56
- /** SHA-256 hex of {@link canonical} — the `inputKey` part of the storage key. */
57
- hash: string;
58
- }
59
-
60
- /**
61
- * Derive the durable `inputKey` from the transformed input tree and the solve
62
- * config subset. Deterministic and stable across processes/instances: the same
63
- * tree + config always produce the same hash, which is what lets a shared cache
64
- * hit across app instances.
65
- *
66
- * @param transformedTree the tree AFTER `transformInputParameter`/`TreeBuilder`
67
- * (R13) — the exact object handed to `scheduler.solve`.
68
- * @param config solve-affecting config (R8); `COMPUTE_CONTRACT_VERSION` is folded
69
- * in automatically so a wire-contract bump invalidates old entries for free.
70
- */
71
- export function deriveSolveCacheInputKey(
72
- transformedTree: unknown,
73
- config: SolveCacheConfigSubset
74
- ): SolveCacheInputKey {
75
- // stableStringify sorts keys deterministically — same content, same string,
76
- // regardless of property insertion order. Tag each part so a value can't shift
77
- // across the tree/config boundary and collide (e.g. tree "]" meeting config "[").
78
- const canonical = stableStringify({
79
- v: COMPUTE_CONTRACT_VERSION,
80
- tree: transformedTree,
81
- config
82
- });
83
- const hash = createHash('sha256').update(canonical).digest('hex');
84
- return { canonical, hash };
85
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * In-process single-flight (R4) — dogpile protection above the L2 cache.
3
- *
4
- * N identical live solves arriving while the first is still running would each
5
- * enqueue and each hit Rhino (the scheduler has no in-flight coalescing, and
6
- * Rhino's `cachesolve` still costs a round trip per repeat). The classic cold-key
7
- * stampede: a hot public definition + a deploy = a thundering herd onto compute.
8
- *
9
- * This coalesces them: the first caller for a key runs the work; concurrent
10
- * callers for the SAME key await the same promise and share its result. Once the
11
- * work settles (resolve OR reject), the key is released so the next request runs
12
- * fresh — the map never retains a settled entry, so it can't serve a stale
13
- * result or leak memory.
14
- *
15
- * It sits ABOVE the `ISolveResultCache` interface (in-process, per app instance).
16
- * A cross-instance lease (Redis `SET NX`) is a later backend capability; this is
17
- * the free first slice that covers the common same-instance burst.
18
- *
19
- * Correctness note: the shared promise resolves to ONE value for all waiters, so
20
- * the wrapped work must return an immutable / independently-serializable result
21
- * (the solve pipeline returns a fresh envelope object — safe to share by
22
- * reference here because the app serializes it per response).
23
- */
24
-
25
- export interface SolveCacheSingleFlight {
26
- /**
27
- * Run `work` under `key`, coalescing concurrent identical calls. The first
28
- * caller executes; overlapping callers for the same key await the same promise.
29
- * The key is freed as soon as the promise settles (success or failure).
30
- */
31
- run<T>(key: string, work: () => Promise<T>): Promise<T>;
32
- /** Number of keys currently in flight (observability / tests). */
33
- inFlight(): number;
34
- }
35
-
36
- export function createSolveCacheSingleFlight(): SolveCacheSingleFlight {
37
- const inflight = new Map<string, Promise<unknown>>();
38
-
39
- return {
40
- run<T>(key: string, work: () => Promise<T>): Promise<T> {
41
- const existing = inflight.get(key);
42
- if (existing) return existing as Promise<T>;
43
-
44
- // `.finally` releases the key on both settle paths. Start the work inside
45
- // the promise chain so a synchronous throw in `work` still rejects the
46
- // shared promise (and still frees the key) rather than escaping.
47
- const p = (async () => work())().finally(() => {
48
- // Only delete if it's still OUR promise — a key freed and re-taken by a
49
- // later caller must not be clobbered.
50
- if (inflight.get(key) === p) inflight.delete(key);
51
- });
52
- inflight.set(key, p);
53
- return p;
54
- },
55
- inFlight(): number {
56
- return inflight.size;
57
- }
58
- };
59
- }