@selvajs/server 0.2.0-beta.2 → 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.
- package/LICENSE +1 -1
- package/README.md +32 -0
- package/dist/compute/client-cache.d.ts +12 -0
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +18 -3
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/definition-byte-cache.d.ts +85 -0
- package/dist/compute/definition-byte-cache.d.ts.map +1 -0
- package/dist/compute/definition-byte-cache.js +110 -0
- package/dist/compute/definition-byte-cache.js.map +1 -0
- package/dist/compute/index.d.ts +7 -2
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +6 -1
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/limits.d.ts +52 -0
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +33 -1
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/memory-solve-cache.d.ts +58 -0
- package/dist/compute/memory-solve-cache.d.ts.map +1 -0
- package/dist/compute/memory-solve-cache.js +0 -0
- package/dist/compute/memory-solve-cache.js.map +1 -0
- package/dist/compute/safe-url.d.ts.map +1 -1
- package/dist/compute/safe-url.js +16 -0
- package/dist/compute/safe-url.js.map +1 -1
- package/dist/compute/solve-cache-envelope.d.ts +53 -0
- package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
- package/dist/compute/solve-cache-envelope.js +66 -0
- package/dist/compute/solve-cache-envelope.js.map +1 -0
- package/dist/compute/solve-cache-key.d.ts +65 -0
- package/dist/compute/solve-cache-key.d.ts.map +1 -0
- package/dist/compute/solve-cache-key.js +50 -0
- package/dist/compute/solve-cache-key.js.map +1 -0
- package/dist/compute/solve-cache-single-flight.d.ts +42 -0
- package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
- package/dist/compute/solve-cache-single-flight.js +50 -0
- package/dist/compute/solve-cache-single-flight.js.map +1 -0
- package/dist/compute/solve-pipeline.d.ts +71 -8
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +180 -8
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/definitions/definition-service.d.ts +1 -1
- package/dist/definitions/definition-service.d.ts.map +1 -1
- package/dist/definitions/definition-service.js +8 -4
- package/dist/definitions/definition-service.js.map +1 -1
- package/package.json +14 -18
- package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
- package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
- package/dist/access/__tests__/project-access-input.test.js +0 -146
- package/dist/access/__tests__/project-access-input.test.js.map +0 -1
- package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
- package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/client-cache.test.js +0 -200
- package/dist/compute/__tests__/client-cache.test.js.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
- package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.js +0 -98
- package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
- package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.js +0 -114
- package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
- package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
- package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
- package/dist/compute/__tests__/safe-url.test.js +0 -71
- package/dist/compute/__tests__/safe-url.test.js.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
- package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.js +0 -292
- package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
- package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
- package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
- package/dist/compute/__tests__/transform-input.test.js +0 -120
- package/dist/compute/__tests__/transform-input.test.js.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
- package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.js +0 -196
- package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
- package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
- package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
- package/dist/http/__tests__/body-size.test.d.ts +0 -2
- package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
- package/dist/http/__tests__/body-size.test.js +0 -24
- package/dist/http/__tests__/body-size.test.js.map +0 -1
- package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
- package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
- package/dist/http/__tests__/route-classifier.test.js +0 -54
- package/dist/http/__tests__/route-classifier.test.js.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.js +0 -44
- package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
- package/dist/http/__tests__/security-headers.test.d.ts +0 -2
- package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
- package/dist/http/__tests__/security-headers.test.js +0 -24
- package/dist/http/__tests__/security-headers.test.js.map +0 -1
- package/dist/ops/__tests__/semver.test.d.ts +0 -2
- package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
- package/dist/ops/__tests__/semver.test.js +0 -38
- package/dist/ops/__tests__/semver.test.js.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
- package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
- package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.js +0 -50
- package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
- package/src/access/__tests__/project-access-input.test.ts +0 -175
- package/src/access/index.ts +0 -9
- package/src/access/project-access-input.ts +0 -145
- package/src/compute/__tests__/client-cache.test.ts +0 -224
- package/src/compute/__tests__/rate-limit.test.ts +0 -106
- package/src/compute/__tests__/remote-definition.test.ts +0 -140
- package/src/compute/__tests__/safe-url.test.ts +0 -72
- package/src/compute/__tests__/solve-pipeline.test.ts +0 -341
- package/src/compute/__tests__/transform-input.test.ts +0 -162
- package/src/compute/client-cache.ts +0 -300
- package/src/compute/index.ts +0 -48
- package/src/compute/limits.ts +0 -159
- package/src/compute/rate-limit.ts +0 -110
- package/src/compute/remote-definition.ts +0 -149
- package/src/compute/safe-url.ts +0 -186
- package/src/compute/solve-pipeline.ts +0 -339
- package/src/compute/transform-input.ts +0 -66
- package/src/definitions/__tests__/load-for-render.test.ts +0 -244
- package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
- package/src/definitions/definition-service.ts +0 -243
- package/src/definitions/index.ts +0 -22
- package/src/definitions/load-for-render.ts +0 -272
- package/src/definitions/schema-extraction.ts +0 -117
- package/src/errors/SentryErrorReporter.ts +0 -88
- package/src/errors/index.ts +0 -3
- package/src/http/__tests__/body-size.test.ts +0 -27
- package/src/http/__tests__/route-classifier.test.ts +0 -60
- package/src/http/__tests__/safe-redirect.test.ts +0 -47
- package/src/http/__tests__/security-headers.test.ts +0 -29
- package/src/http/body-size.ts +0 -24
- package/src/http/index.ts +0 -10
- package/src/http/redirect.ts +0 -16
- package/src/http/route-classifier.ts +0 -74
- package/src/http/security-headers.ts +0 -42
- package/src/index.ts +0 -27
- package/src/ops/__tests__/semver.test.ts +0 -42
- package/src/ops/index.ts +0 -3
- package/src/ops/semver.ts +0 -60
- package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
- package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
- package/src/providers/create-selva-providers.ts +0 -241
- package/src/providers/index.ts +0 -12
- package/src/tokens/__tests__/token-codec.test.ts +0 -63
- package/src/tokens/index.ts +0 -8
- package/src/tokens/token-codec.ts +0 -86
|
@@ -1,300 +0,0 @@
|
|
|
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. `seq` increments on every write. The scheduler
|
|
64
|
-
* runs up to `maxConcurrentSolves` solves at once, so `last` alone can't be
|
|
65
|
-
* trusted per-request: callers snapshot `seq` before their solve and
|
|
66
|
-
* attribute `last` only when exactly one write happened since (necessarily
|
|
67
|
-
* theirs — see the guard in `runSolvePipeline`), dropping the segment
|
|
68
|
-
* otherwise instead of misattributing another request's timing.
|
|
69
|
-
*/
|
|
70
|
-
rhinoTiming: { last: { decode: number; solve: number; encode: number } | null; seq: number };
|
|
71
|
-
/**
|
|
72
|
-
* Last solve's cache verdicts from the scheduler's onSettle: whether the
|
|
73
|
-
* in-process response cache served it (no compute call) and whether the
|
|
74
|
-
* definition had to be re-uploaded. `seq` increments on EVERY settle
|
|
75
|
-
* (success or error); `last` is only written on success. Same
|
|
76
|
-
* snapshot-and-attribute pattern as rhinoTiming — onSettle always fires
|
|
77
|
-
* before the corresponding `scheduler.solve()` promise resolves, so at the
|
|
78
|
-
* caller's read point its own settle is included in `seq`.
|
|
79
|
-
*/
|
|
80
|
-
solveMeta: {
|
|
81
|
-
last: { fromCache: boolean; definitionReuploaded?: boolean } | null;
|
|
82
|
-
seq: number;
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Config injected by the consuming app. Everything that used to be read from
|
|
88
|
-
* `$env` / `computeLimits` in the app module is passed in, so this module stays
|
|
89
|
-
* env-agnostic and testable.
|
|
90
|
-
*/
|
|
91
|
-
export interface ClientCacheConfig {
|
|
92
|
-
/** Per-solve timeout forwarded to the scheduler (`ComputeLimits.maxSolveDurationMs`). */
|
|
93
|
-
maxSolveDurationMs: number;
|
|
94
|
-
/** Ask Rhino.Compute to cache solve results and return them on identical repeats. */
|
|
95
|
-
cachesolve: boolean;
|
|
96
|
-
/** Also cache solves that reported GH errors (only meaningful with `cachesolve`). */
|
|
97
|
-
cacheerroredsolves: boolean;
|
|
98
|
-
/** Reference large definitions by server cache key (pointer) instead of re-uploading. */
|
|
99
|
-
reuseServerDefinitionCache: boolean;
|
|
100
|
-
/**
|
|
101
|
-
* Max in-flight solves per compute server (scheduler `maxConcurrent`; excess
|
|
102
|
-
* FIFO-queues). Size to the server's `compute.geometry` child count
|
|
103
|
-
* (`ComputeLimits.computeMaxConcurrentSolves`).
|
|
104
|
-
*/
|
|
105
|
-
maxConcurrentSolves: number;
|
|
106
|
-
/** Concise cache/timing logs. When false, `onDebugLog` is never invoked. */
|
|
107
|
-
debug: boolean;
|
|
108
|
-
/** VERBOSE lib-level logging (full solve request/response incl. geometry). */
|
|
109
|
-
debugVerbose: boolean;
|
|
110
|
-
/** Max distinct warm clients before the LRU evicts the oldest. Default 16. */
|
|
111
|
-
maxCachedClients?: number;
|
|
112
|
-
/** Sink for the concise debug lines (the app wires `console.log`). */
|
|
113
|
-
onDebugLog?: (message: string) => void;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export interface ClientCache {
|
|
117
|
-
/**
|
|
118
|
-
* Get (or create) the warm client + scheduler for a resolved compute server,
|
|
119
|
-
* keyed by its `id`. `definitionGuid`, when present, is stamped as the
|
|
120
|
-
* `X-Selva-Definition` header on this client's outbound solve/IO requests —
|
|
121
|
-
* inert routing/telemetry metadata until a pool router exists (ADR 0004 D2).
|
|
122
|
-
*/
|
|
123
|
-
getClient(server: ResolvedServer, opts?: { definitionGuid?: string }): Promise<CachedClient>;
|
|
124
|
-
/**
|
|
125
|
-
* Dispose and drop the warm client for `id` (or a `ServerIdentity`). Called by
|
|
126
|
-
* the config-write path when a server's URL/key rotates so the next request
|
|
127
|
-
* rebuilds against fresh connection details (ADR 0004 Consequences).
|
|
128
|
-
*/
|
|
129
|
-
evict(id: string | ServerIdentity): void;
|
|
130
|
-
/** Dispose every warm client. Test seam / shutdown hook. */
|
|
131
|
-
disposeAll(): void;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const DEFAULT_MAX_CACHED_CLIENTS = 16;
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Build a per-server warm-client cache. Each cache owns its own LRU `Map` keyed
|
|
138
|
-
* on server `id`.
|
|
139
|
-
*/
|
|
140
|
-
export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
141
|
-
const maxCachedClients = config.maxCachedClients ?? DEFAULT_MAX_CACHED_CLIENTS;
|
|
142
|
-
const cache = new Map<string, CachedClient>();
|
|
143
|
-
|
|
144
|
-
if (config.debugVerbose) enableDebugLogging();
|
|
145
|
-
|
|
146
|
-
const debugLog = (message: string) => {
|
|
147
|
-
if (config.debug) config.onDebugLog?.(message);
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
async function build(server: ResolvedServer, definitionGuid?: string): Promise<CachedClient> {
|
|
151
|
-
// Holders for per-request results from the client/scheduler callbacks; the
|
|
152
|
-
// caller reads them right after each solve resolves to build Server-Timing,
|
|
153
|
-
// using the seq counters to detect (and drop) ambiguous concurrent writes.
|
|
154
|
-
const rhinoTiming: CachedClient['rhinoTiming'] = { last: null, seq: 0 };
|
|
155
|
-
const solveMeta: CachedClient['solveMeta'] = { last: null, seq: 0 };
|
|
156
|
-
|
|
157
|
-
const clientConfig: GrasshopperComputeConfig = {
|
|
158
|
-
serverUrl: server.serverUrl,
|
|
159
|
-
apiKey: server.apiKey,
|
|
160
|
-
// Lib-level debug is the verbose one (full payload logs); keep it on the
|
|
161
|
-
// verbose flag so the concise cache logs can run without the noise.
|
|
162
|
-
debug: config.debugVerbose,
|
|
163
|
-
// Ask Rhino.Compute to cache solve results and return them on identical
|
|
164
|
-
// repeats (same definition + inputs), skipping the solve.
|
|
165
|
-
cachesolve: config.cachesolve,
|
|
166
|
-
// Opt-in: also cache solves that reported GH errors.
|
|
167
|
-
cacheerroredsolves: config.cacheerroredsolves,
|
|
168
|
-
// DEBUG: the server's per-request timing breakdown, which exposes the two
|
|
169
|
-
// SERVER-side caches at once — definition cache (pointer reuse) via
|
|
170
|
-
// `decode`, solve cache (cachesolve) via `solve`. Flagged below so the
|
|
171
|
-
// contrast is obvious in the logs.
|
|
172
|
-
onServerTiming: (t) => {
|
|
173
|
-
const decode = t.decode ?? 0;
|
|
174
|
-
const solve = t.solve ?? 0;
|
|
175
|
-
const encode = t.encode ?? 0;
|
|
176
|
-
// Always recorded (cheap): the caller reads this after scheduler.solve.
|
|
177
|
-
rhinoTiming.seq += 1;
|
|
178
|
-
rhinoTiming.last = { decode, solve, encode };
|
|
179
|
-
if (!config.debug) return;
|
|
180
|
-
// Heuristic thresholds: a genuine parse/solve is many ms; a cache hit
|
|
181
|
-
// is sub-millisecond. Only meaningful relative to the first (cold) solve.
|
|
182
|
-
const defCache =
|
|
183
|
-
decode <= 1 ? 'HIT (definition reused, no re-upload/parse)' : 'miss (full parse)';
|
|
184
|
-
const solveCache =
|
|
185
|
-
config.cachesolve && solve <= 1
|
|
186
|
-
? 'HIT (cached result, solve skipped)'
|
|
187
|
-
: config.cachesolve
|
|
188
|
-
? 'miss (solved fresh)'
|
|
189
|
-
: 'off';
|
|
190
|
-
debugLog(
|
|
191
|
-
`[Compute/rhino-cache] decode=${decode}ms solve=${solve}ms encode=${encode}ms ` +
|
|
192
|
-
`| definition-cache: ${defCache} | solve-cache (cachesolve): ${solveCache}`
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
// `X-Selva-Definition` on the wire (ADR 0004 D2). Baked at client-create
|
|
198
|
-
// time because the scheduler has no per-request header hook — inert for a
|
|
199
|
-
// single-member "pool", useful in compute-side access logs immediately.
|
|
200
|
-
if (definitionGuid) {
|
|
201
|
-
clientConfig.headers = { 'X-Selva-Definition': definitionGuid };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const client = await GrasshopperClient.create(clientConfig);
|
|
205
|
-
const scheduler = client.createScheduler({
|
|
206
|
-
mode: 'queue',
|
|
207
|
-
// Queue mode defaults maxConcurrent to 1, which would serialize every
|
|
208
|
-
// solve on this server through a single slot while the compute VM's
|
|
209
|
-
// other compute.geometry children idle (audit B6) — always pass it.
|
|
210
|
-
maxConcurrent: config.maxConcurrentSolves,
|
|
211
|
-
timeoutMs: config.maxSolveDurationMs,
|
|
212
|
-
cache: { maxEntries: 20, ttlMs: 5 * 60_000 },
|
|
213
|
-
// Solve large definitions by server cache-key (pointer) instead of
|
|
214
|
-
// re-uploading the full binary every solve. On a stale-pointer miss the
|
|
215
|
-
// client transparently re-uploads.
|
|
216
|
-
reuseServerDefinitionCache: config.reuseServerDefinitionCache,
|
|
217
|
-
// DEBUG: observe the in-process response cache. `fromCache === true` means
|
|
218
|
-
// this solve was served from the app's own cache — Rhino.Compute was never
|
|
219
|
-
// called. `false` means it went to the compute server.
|
|
220
|
-
onSettle: (_ctx, result) => {
|
|
221
|
-
// Count EVERY settle (success or error) so the caller's attribution
|
|
222
|
-
// guard sees all concurrent activity, not just successful writes.
|
|
223
|
-
solveMeta.seq += 1;
|
|
224
|
-
if (result.status !== 'success') return;
|
|
225
|
-
// Always recorded (cheap): the caller surfaces these on Server-Timing.
|
|
226
|
-
solveMeta.last = {
|
|
227
|
-
fromCache: result.fromCache === true,
|
|
228
|
-
definitionReuploaded: result.definitionReuploaded
|
|
229
|
-
};
|
|
230
|
-
if (!config.debug) return;
|
|
231
|
-
debugLog(
|
|
232
|
-
`[Compute/selva-cache] ${result.fromCache ? 'HIT — served from cache (no compute call)' : 'miss — went to Rhino.Compute'} (${Math.round(result.durationMs)}ms)`
|
|
233
|
-
);
|
|
234
|
-
// Definition-cache verdict for a real compute call (not a cache HIT).
|
|
235
|
-
if (!result.fromCache) {
|
|
236
|
-
debugLog(
|
|
237
|
-
result.definitionReuploaded === true
|
|
238
|
-
? '[Compute/def-cache] miss — definition RE-UPLOADED (pointer was cold/stale)'
|
|
239
|
-
: result.definitionReuploaded === false
|
|
240
|
-
? '[Compute/def-cache] HIT — definition reused from server cache (no upload)'
|
|
241
|
-
: '[Compute/def-cache] n/a — reuse disabled or non-reusable definition'
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
// Surface real GH errors when present. An errored solve is NEVER cached
|
|
245
|
-
// server-side (cachesolve only stores error-free solves).
|
|
246
|
-
if (!result.fromCache) {
|
|
247
|
-
const errs = (result.response as { errors?: unknown[] })?.errors;
|
|
248
|
-
const errCount = Array.isArray(errs) ? errs.length : 0;
|
|
249
|
-
if (errCount > 0) {
|
|
250
|
-
debugLog(
|
|
251
|
-
`[Compute/rhino-cache] ${errCount} GH error(s); not cacheable until fixed. First: ${JSON.stringify((errs as unknown[])[0])}`
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
return { client, scheduler, rhinoTiming, solveMeta };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return {
|
|
261
|
-
async getClient(server, opts): Promise<CachedClient> {
|
|
262
|
-
const key = server.id;
|
|
263
|
-
const existing = cache.get(key);
|
|
264
|
-
if (existing) {
|
|
265
|
-
// Refresh LRU position.
|
|
266
|
-
cache.delete(key);
|
|
267
|
-
cache.set(key, existing);
|
|
268
|
-
return existing;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const entry = await build(server, opts?.definitionGuid);
|
|
272
|
-
|
|
273
|
-
// Evict the least-recently-used entry before inserting when at capacity.
|
|
274
|
-
if (cache.size >= maxCachedClients) {
|
|
275
|
-
const oldestKey = cache.keys().next().value;
|
|
276
|
-
if (oldestKey !== undefined) {
|
|
277
|
-
cache.get(oldestKey)?.scheduler.dispose();
|
|
278
|
-
cache.delete(oldestKey);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
cache.set(key, entry);
|
|
283
|
-
return entry;
|
|
284
|
-
},
|
|
285
|
-
|
|
286
|
-
evict(id): void {
|
|
287
|
-
const key = id as string;
|
|
288
|
-
const entry = cache.get(key);
|
|
289
|
-
if (entry) {
|
|
290
|
-
entry.scheduler.dispose();
|
|
291
|
-
cache.delete(key);
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
|
|
295
|
-
disposeAll(): void {
|
|
296
|
-
for (const entry of cache.values()) entry.scheduler.dispose();
|
|
297
|
-
cache.clear();
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
}
|
package/src/compute/index.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
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';
|
package/src/compute/limits.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
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
|
-
* Max in-flight solves this app instance sends to ONE compute server
|
|
120
|
-
* (forwarded as the SolveScheduler's `maxConcurrent`; excess solves FIFO-queue).
|
|
121
|
-
* Size it to the server's `compute.geometry` child count — Rhino.Compute
|
|
122
|
-
* spawns N children per VM precisely to run N solves concurrently, and a
|
|
123
|
-
* lower value idles that capacity while users queue (audit B6: the scheduler's
|
|
124
|
-
* queue-mode default is 1, which serialized ALL solves per server). Multiple
|
|
125
|
-
* app instances each apply their own cap.
|
|
126
|
-
*/
|
|
127
|
-
computeMaxConcurrentSolves: number;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Resolve every server-side compute limit from an env map. Pure: the same env
|
|
132
|
-
* always yields the same limits, and nothing is read ambiently. The app calls
|
|
133
|
-
* this once at its composition root with `$env/dynamic/private`.
|
|
134
|
-
*/
|
|
135
|
-
export function resolveComputeLimits(env: EnvRecord): ComputeLimits {
|
|
136
|
-
const maxGhFileSize = readPositiveInt(env, 'MAX_GH_FILE_SIZE_BYTES', 300 * MB);
|
|
137
|
-
return {
|
|
138
|
-
maxSolveDurationMs: readPositiveInt(env, 'MAX_SOLVE_DURATION_MS', 100_000),
|
|
139
|
-
rateLimitWindowMs: readPositiveInt(env, 'COMPUTE_RATE_LIMIT_WINDOW_MS', 100_000),
|
|
140
|
-
rateLimitMaxRequests: readPositiveInt(env, 'COMPUTE_RATE_LIMIT_MAX', 120),
|
|
141
|
-
maxGhFileSize,
|
|
142
|
-
maxImageFileSize: readPositiveInt(env, 'MAX_IMAGE_FILE_SIZE_BYTES', 10 * MB),
|
|
143
|
-
computeRequestMaxBytes: readPositiveInt(env, 'COMPUTE_REQUEST_MAX_BYTES', 300 * MB),
|
|
144
|
-
computeResponseMaxBytes: readPositiveInt(env, 'COMPUTE_RESPONSE_MAX_BYTES', 300 * MB),
|
|
145
|
-
// Tracks the upload cap so a remote URL can't smuggle a larger file.
|
|
146
|
-
remoteDefinitionMaxBytes: maxGhFileSize,
|
|
147
|
-
remoteDefinitionFetchTimeoutMs: readPositiveInt(
|
|
148
|
-
env,
|
|
149
|
-
'REMOTE_DEFINITION_FETCH_TIMEOUT_MS',
|
|
150
|
-
30_000
|
|
151
|
-
),
|
|
152
|
-
definitionCacheTtlMs: readPositiveInt(env, 'DEFINITION_CACHE_TTL_MS', 5 * 60 * 1000),
|
|
153
|
-
computeReuseDefinitionCache: readBool(env, 'COMPUTE_REUSE_DEFINITION_CACHE', true),
|
|
154
|
-
computeServerCachesolve: readBool(env, 'COMPUTE_SERVER_CACHESOLVE', true),
|
|
155
|
-
computeCacheErroredSolves: readBool(env, 'COMPUTE_CACHE_ERRORED_SOLVES', false),
|
|
156
|
-
// 4 matches rhino.compute's default --childcount; tune to the actual VM.
|
|
157
|
-
computeMaxConcurrentSolves: readPositiveInt(env, 'COMPUTE_MAX_CONCURRENT', 4)
|
|
158
|
-
};
|
|
159
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
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
|
-
* Not compute-only despite the name: `peek` + `clear` make it usable for
|
|
23
|
-
* failure-counting flows too (login limiting keys by IP, records only failed
|
|
24
|
-
* attempts via `check`, and forgives on success via `clear`).
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
export interface RateLimitResult {
|
|
28
|
-
allowed: boolean;
|
|
29
|
-
/** Seconds until the bucket resets, when not allowed. */
|
|
30
|
-
retryAfter?: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ComputeRateLimiterConfig {
|
|
34
|
-
/** Fixed-window length in ms. */
|
|
35
|
-
windowMs: number;
|
|
36
|
-
/** Max requests admitted per key per window. */
|
|
37
|
-
maxPerWindow: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ComputeRateLimiter {
|
|
41
|
-
/**
|
|
42
|
-
* Check + record one request against `key`. Increments the counter on allow.
|
|
43
|
-
* Returns `{ allowed: false, retryAfter }` once the bucket is full.
|
|
44
|
-
*/
|
|
45
|
-
check(key: string): RateLimitResult;
|
|
46
|
-
/**
|
|
47
|
-
* Check WITHOUT recording. For flows that only count selected outcomes —
|
|
48
|
-
* e.g. login limiting, where `peek` gates the attempt and `check` records
|
|
49
|
-
* only failures (a success calls `clear` instead).
|
|
50
|
-
*/
|
|
51
|
-
peek(key: string): RateLimitResult;
|
|
52
|
-
/** Drop `key`'s bucket — e.g. a successful login forgives prior failures. */
|
|
53
|
-
clear(key: string): void;
|
|
54
|
-
/** Test seam — wipes in-memory state. Production code never calls this. */
|
|
55
|
-
reset(): void;
|
|
56
|
-
/** The resolved config, exposed for time-based test assertions. */
|
|
57
|
-
readonly config: Readonly<ComputeRateLimiterConfig>;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface BucketEntry {
|
|
61
|
-
count: number;
|
|
62
|
-
resetAt: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Build a process-local fixed-window rate limiter. Each limiter owns its own
|
|
67
|
-
* bucket `Map`, so distinct endpoints/keys-spaces don't collide.
|
|
68
|
-
*/
|
|
69
|
-
export function createComputeRateLimiter(config: ComputeRateLimiterConfig): ComputeRateLimiter {
|
|
70
|
-
const { windowMs, maxPerWindow } = config;
|
|
71
|
-
const buckets = new Map<string, BucketEntry>();
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
config,
|
|
75
|
-
check(key: string): RateLimitResult {
|
|
76
|
-
const now = Date.now();
|
|
77
|
-
const entry = buckets.get(key);
|
|
78
|
-
|
|
79
|
-
if (!entry || now > entry.resetAt) {
|
|
80
|
-
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
81
|
-
return { allowed: true };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (entry.count >= maxPerWindow) {
|
|
85
|
-
return { allowed: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
entry.count += 1;
|
|
89
|
-
return { allowed: true };
|
|
90
|
-
},
|
|
91
|
-
peek(key: string): RateLimitResult {
|
|
92
|
-
const now = Date.now();
|
|
93
|
-
const entry = buckets.get(key);
|
|
94
|
-
|
|
95
|
-
if (!entry || now > entry.resetAt) {
|
|
96
|
-
return { allowed: true };
|
|
97
|
-
}
|
|
98
|
-
if (entry.count >= maxPerWindow) {
|
|
99
|
-
return { allowed: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
|
100
|
-
}
|
|
101
|
-
return { allowed: true };
|
|
102
|
-
},
|
|
103
|
-
clear(key: string): void {
|
|
104
|
-
buckets.delete(key);
|
|
105
|
-
},
|
|
106
|
-
reset(): void {
|
|
107
|
-
buckets.clear();
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
}
|