@silmaril-security/sdk 0.6.2 → 0.6.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to the Silmaril Firewall TypeScript SDK are documented here.
4
4
 
5
+ ## 0.6.3 - 2026-09-24
6
+
7
+ - Add an optional `signal` option to `classify()` and `classifyBatch()`.
8
+ Aborting stops the in-flight request or the 429 retry wait, rejects with the
9
+ signal's reason, and leaves sibling calls on the same client unaffected. An
10
+ already-aborted signal rejects without sending a request.
11
+ - Combine the caller signal with the existing per-attempt `timeoutMs` without
12
+ changing timeout behavior, and release the abort listener and timer once an
13
+ attempt finishes.
14
+ - Discard a retried 429 response body before backing off so a retry does not
15
+ leak the connection.
16
+ - Serialize each request payload once before retries, so mutating the objects
17
+ passed to a call can no longer change an in-flight logical event.
18
+ - Reject a `timeoutMs` above 2147483647 ms in the constructor instead of
19
+ letting a timer silently collapse it to an immediate abort.
20
+ - Document that `Firewall` state is immutable and one client is reusable for
21
+ concurrent promises within a runtime, while each worker thread owns its own
22
+ client.
23
+
5
24
  ## 0.6.2 - 2026-08-24
6
25
 
7
26
  - Align the runtime `sdk_version` metadata with the published package version.
package/README.md CHANGED
@@ -40,7 +40,7 @@ For reproducible installs, pin a tagged release:
40
40
  npm install @silmaril-security/sdk@0.6.2
41
41
  ```
42
42
 
43
- Requires Node 18 or later.
43
+ Requires Node 20 or later.
44
44
 
45
45
  The package name and SDK import path are both `@silmaril-security/sdk`, so call
46
46
  sites use `Firewall`, `HookLabel`, and `FirewallBlockedException` from that
@@ -117,6 +117,47 @@ internally applied threshold. Direct calls do not throw on malicious verdicts.
117
117
  The Vercel AI SDK and LangChain.js adapters use `result.threshold` and throw
118
118
  `FirewallBlockedException` when enforcement is enabled.
119
119
 
120
+ ## Concurrency and Cancellation
121
+
122
+ A `Firewall` instance holds no per-request state, so one client can serve any
123
+ number of concurrent calls in a runtime. Responses are matched to their own
124
+ promise regardless of completion order:
125
+
126
+ ```ts
127
+ const [userResult, toolResult] = await Promise.all([
128
+ fw.classify(userInput, { hook: HookLabel.USER_INPUT }),
129
+ fw.classify(toolOutput, { hook: HookLabel.TOOL_RESPONSE, toolName: "read_file" }),
130
+ ]);
131
+ ```
132
+
133
+ Pass a `signal` to cancel one call without touching its siblings:
134
+
135
+ ```ts
136
+ const controller = new AbortController();
137
+ request.on("close", () => controller.abort());
138
+
139
+ try {
140
+ const result = await fw.classify(userInput, {
141
+ hook: HookLabel.USER_INPUT,
142
+ signal: controller.signal,
143
+ });
144
+ return result;
145
+ } catch (err) {
146
+ if (controller.signal.aborted) {
147
+ return; // caller went away
148
+ }
149
+ throw err;
150
+ }
151
+ ```
152
+
153
+ Aborting stops the in-flight request or the 429 backoff wait and rejects with
154
+ the signal's reason. An already-aborted signal rejects without sending a
155
+ request. `signal` composes with `timeoutMs`, which still applies per attempt.
156
+ `classifyBatch()` accepts the same option.
157
+
158
+ Each worker thread owns its own client: construct a `Firewall` inside the
159
+ thread instead of sharing one instance across `worker_threads` boundaries.
160
+
120
161
  ## Handle Outcomes
121
162
 
122
163
  Direct calls expose typed outcome labels so applications can choose different
@@ -183,14 +224,15 @@ Outcome taxonomy:
183
224
  interface FirewallOptions {
184
225
  apiKey: string; // required
185
226
  apiUrl: string; // required
186
- timeoutMs?: number; // default: 10000 ms
227
+ timeoutMs?: number; // default: 10000 ms, max 2147483647
187
228
  mode?: "shadow" | "warn" | "block"; // omitted uses backend configuration
188
229
  shadowMode?: boolean; // deprecated legacy mapping
189
230
  }
190
231
  ```
191
232
 
192
- The SDK uses native `fetch`, `AbortSignal.timeout`, and JSON request bodies with
193
- `x-api-key` and `content-type` headers.
233
+ The SDK uses native `fetch`, an abort signal per request attempt, and JSON
234
+ request bodies with `x-api-key` and `content-type` headers. `timeoutMs` applies
235
+ to each attempt and is combined with any caller-supplied `signal`.
194
236
 
195
237
  ## Backend Thresholding
196
238
 
@@ -439,9 +481,11 @@ const handler = await createLangChainHandler(fw);
439
481
  ## Retries
440
482
 
441
483
  HTTP 429 responses are retried with exponential backoff capped at 30s, up to 5
442
- times. Redirects are rejected rather than followed. Other non-2xx responses are
443
- surfaced as `SilmarilApiError`, and transport or timeout failures are surfaced
444
- unchanged.
484
+ times. The retried response body is discarded before the wait, and each request
485
+ payload is serialized once so every attempt sends the same logical event.
486
+ Redirects are rejected rather than followed. Other non-2xx responses are
487
+ surfaced as `SilmarilApiError`, and transport, timeout, or cancellation failures
488
+ are surfaced unchanged.
445
489
 
446
490
  ## Development
447
491
 
@@ -1,5 +1,5 @@
1
1
  import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
2
- import { e as Firewall, L as LangChainAdapterOptions } from '../firewall-Cdnk_UWY.cjs';
2
+ import { e as Firewall, L as LangChainAdapterOptions } from '../firewall-DkArls03.cjs';
3
3
 
4
4
  declare function createLangChainHandler(firewall: Firewall, options?: LangChainAdapterOptions): Promise<BaseCallbackHandler>;
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
2
- import { e as Firewall, L as LangChainAdapterOptions } from '../firewall-Cdnk_UWY.js';
2
+ import { e as Firewall, L as LangChainAdapterOptions } from '../firewall-DkArls03.js';
3
3
 
4
4
  declare function createLangChainHandler(firewall: Firewall, options?: LangChainAdapterOptions): Promise<BaseCallbackHandler>;
5
5
 
@@ -1 +1 @@
1
- export { g as FirewallMiddleware, Q as createMiddleware } from '../firewall-Cdnk_UWY.cjs';
1
+ export { g as FirewallMiddleware, Q as createMiddleware } from '../firewall-DkArls03.cjs';
@@ -1 +1 @@
1
- export { g as FirewallMiddleware, Q as createMiddleware } from '../firewall-Cdnk_UWY.js';
1
+ export { g as FirewallMiddleware, Q as createMiddleware } from '../firewall-DkArls03.js';
@@ -116,6 +116,11 @@ interface ClassifyOptions {
116
116
  governance?: GovernanceContext;
117
117
  metadata?: ClassificationMetadata;
118
118
  requestId?: string;
119
+ /**
120
+ * Cancels this call only. Aborting stops the in-flight request or retry wait
121
+ * and rejects with the signal's reason; sibling calls are unaffected.
122
+ */
123
+ signal?: AbortSignal;
119
124
  }
120
125
  interface ClassifyBatchOptions {
121
126
  mode?: FirewallMode;
@@ -124,6 +129,11 @@ interface ClassifyBatchOptions {
124
129
  governance?: readonly (GovernanceContext | undefined)[];
125
130
  metadata?: readonly (ClassificationMetadata | undefined)[];
126
131
  requestId?: string;
132
+ /**
133
+ * Cancels this batch call only. Aborting stops the in-flight request or retry
134
+ * wait and rejects with the signal's reason; sibling calls are unaffected.
135
+ */
136
+ signal?: AbortSignal;
127
137
  }
128
138
  interface LangChainAdapterOptions {
129
139
  hooks?: ReadonlySet<FirewallHook>;
@@ -219,6 +229,15 @@ interface FirewallMiddleware {
219
229
  declare function createMiddleware(firewall: Firewall, options?: MiddlewareOptions): FirewallMiddleware;
220
230
 
221
231
  declare const DEFAULT_TIMEOUT_MS = 10000;
232
+ /**
233
+ * Firewall client for the `/classify` API.
234
+ *
235
+ * Instance state is set once in the constructor and never mutated by a request,
236
+ * so one client is safe to share across any number of concurrent `classify()`
237
+ * and `classifyBatch()` promises within a single JavaScript runtime. Each
238
+ * worker thread owns its own client: `Firewall` instances are not transferable
239
+ * across `worker_threads` boundaries.
240
+ */
222
241
  declare class Firewall {
223
242
  readonly apiKey: string;
224
243
  readonly apiUrl: string;
@@ -116,6 +116,11 @@ interface ClassifyOptions {
116
116
  governance?: GovernanceContext;
117
117
  metadata?: ClassificationMetadata;
118
118
  requestId?: string;
119
+ /**
120
+ * Cancels this call only. Aborting stops the in-flight request or retry wait
121
+ * and rejects with the signal's reason; sibling calls are unaffected.
122
+ */
123
+ signal?: AbortSignal;
119
124
  }
120
125
  interface ClassifyBatchOptions {
121
126
  mode?: FirewallMode;
@@ -124,6 +129,11 @@ interface ClassifyBatchOptions {
124
129
  governance?: readonly (GovernanceContext | undefined)[];
125
130
  metadata?: readonly (ClassificationMetadata | undefined)[];
126
131
  requestId?: string;
132
+ /**
133
+ * Cancels this batch call only. Aborting stops the in-flight request or retry
134
+ * wait and rejects with the signal's reason; sibling calls are unaffected.
135
+ */
136
+ signal?: AbortSignal;
127
137
  }
128
138
  interface LangChainAdapterOptions {
129
139
  hooks?: ReadonlySet<FirewallHook>;
@@ -219,6 +229,15 @@ interface FirewallMiddleware {
219
229
  declare function createMiddleware(firewall: Firewall, options?: MiddlewareOptions): FirewallMiddleware;
220
230
 
221
231
  declare const DEFAULT_TIMEOUT_MS = 10000;
232
+ /**
233
+ * Firewall client for the `/classify` API.
234
+ *
235
+ * Instance state is set once in the constructor and never mutated by a request,
236
+ * so one client is safe to share across any number of concurrent `classify()`
237
+ * and `classifyBatch()` promises within a single JavaScript runtime. Each
238
+ * worker thread owns its own client: `Firewall` instances are not transferable
239
+ * across `worker_threads` boundaries.
240
+ */
222
241
  declare class Firewall {
223
242
  readonly apiKey: string;
224
243
  readonly apiUrl: string;
package/dist/index.cjs CHANGED
@@ -811,11 +811,12 @@ function sanitizeText(text) {
811
811
  }
812
812
 
813
813
  // src/firewall.ts
814
- var SDK_VERSION = "0.6.2";
814
+ var SDK_VERSION = "0.6.3";
815
815
  var DEFAULT_TIMEOUT_MS = 1e4;
816
816
  var DEFAULT_MAX_RETRIES = 5;
817
817
  var MAX_BACKOFF_SECONDS = 30;
818
818
  var MAX_ERROR_BODY_BYTES = 1 << 16;
819
+ var MAX_TIMEOUT_MS = 2147483647;
819
820
  function resolveMode(value, requestedMode) {
820
821
  let responseMode;
821
822
  if (value === "shadow" || value === "warn" || value === "block") {
@@ -954,6 +955,71 @@ async function readCappedErrorBody(response) {
954
955
  }
955
956
  return new TextDecoder().decode(body);
956
957
  }
958
+ async function discardResponseBody(response) {
959
+ try {
960
+ if (response.body) {
961
+ if (!response.body.locked) {
962
+ await response.body.cancel();
963
+ }
964
+ return;
965
+ }
966
+ await response.text();
967
+ } catch {
968
+ }
969
+ }
970
+ function isAbortLikeError(error) {
971
+ if (typeof error !== "object" || error === null) {
972
+ return false;
973
+ }
974
+ const name = error.name;
975
+ return name === "AbortError" || name === "TimeoutError";
976
+ }
977
+ async function readBodyUnderSignal(read, signal) {
978
+ try {
979
+ return await read();
980
+ } catch (error) {
981
+ if (signal.aborted && isAbortLikeError(error)) {
982
+ throw signal.reason;
983
+ }
984
+ throw error;
985
+ }
986
+ }
987
+ function createAttemptSignal(timeoutMs, callerSignal) {
988
+ const controller = new AbortController();
989
+ const timer = setTimeout(() => {
990
+ controller.abort(
991
+ new DOMException("The operation was aborted due to timeout", "TimeoutError")
992
+ );
993
+ }, timeoutMs);
994
+ const onCallerAbort = () => {
995
+ controller.abort(callerSignal?.reason);
996
+ };
997
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
998
+ return {
999
+ signal: controller.signal,
1000
+ dispose: () => {
1001
+ clearTimeout(timer);
1002
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1003
+ }
1004
+ };
1005
+ }
1006
+ function sleep(ms, signal) {
1007
+ return new Promise((resolve, reject) => {
1008
+ if (signal?.aborted) {
1009
+ reject(signal.reason);
1010
+ return;
1011
+ }
1012
+ const onAbort = () => {
1013
+ clearTimeout(timer);
1014
+ reject(signal?.reason);
1015
+ };
1016
+ const timer = setTimeout(() => {
1017
+ signal?.removeEventListener("abort", onAbort);
1018
+ resolve();
1019
+ }, ms);
1020
+ signal?.addEventListener("abort", onAbort, { once: true });
1021
+ });
1022
+ }
957
1023
  var Firewall = class {
958
1024
  apiKey;
959
1025
  apiUrl;
@@ -974,6 +1040,11 @@ var Firewall = class {
974
1040
  if (typeof this.timeoutMs !== "number" || !Number.isFinite(this.timeoutMs) || this.timeoutMs < 0) {
975
1041
  throw new Error(`Firewall: timeoutMs must be a finite non-negative number, got ${this.timeoutMs}`);
976
1042
  }
1043
+ if (this.timeoutMs > MAX_TIMEOUT_MS) {
1044
+ throw new Error(
1045
+ `Firewall: timeoutMs must be at most ${MAX_TIMEOUT_MS} ms, got ${this.timeoutMs}`
1046
+ );
1047
+ }
977
1048
  this.mode = options.mode ?? legacyMode(options.shadowMode);
978
1049
  this.shadowMode = this.mode === "shadow";
979
1050
  this.headers = Object.freeze({
@@ -1030,7 +1101,7 @@ var Firewall = class {
1030
1101
  ...options.governance?.[index] === void 0 ? {} : { governance: options.governance[index] }
1031
1102
  })
1032
1103
  );
1033
- const data = await this.postWithRetry(payload);
1104
+ const data = await this.postWithRetry(payload, options.signal);
1034
1105
  return data.predictions.map((p) => blockResultFromResponse(p, requestedMode));
1035
1106
  }
1036
1107
  asLangChainHandler(options = {}) {
@@ -1041,28 +1112,40 @@ var Firewall = class {
1041
1112
  asMiddleware(options = {}) {
1042
1113
  return createMiddleware(this, options);
1043
1114
  }
1044
- async postWithRetry(payload, maxRetries = DEFAULT_MAX_RETRIES) {
1115
+ async postWithRetry(payload, callerSignal, maxRetries = DEFAULT_MAX_RETRIES) {
1116
+ callerSignal?.throwIfAborted();
1117
+ const body = JSON.stringify(payload);
1045
1118
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1046
- const response = await fetch(this.apiUrl, {
1047
- method: "POST",
1048
- headers: this.headers,
1049
- body: JSON.stringify(payload),
1050
- redirect: "error",
1051
- signal: AbortSignal.timeout(this.timeoutMs)
1052
- });
1053
- if (response.status !== 429 || attempt === maxRetries) {
1054
- if (!response.ok) {
1055
- const body = await readCappedErrorBody(response);
1056
- throw new SilmarilApiError({
1057
- status: response.status,
1058
- statusText: response.statusText,
1059
- body
1060
- });
1119
+ callerSignal?.throwIfAborted();
1120
+ const attemptSignal = createAttemptSignal(this.timeoutMs, callerSignal);
1121
+ try {
1122
+ const response = await fetch(this.apiUrl, {
1123
+ method: "POST",
1124
+ headers: this.headers,
1125
+ body,
1126
+ redirect: "error",
1127
+ signal: attemptSignal.signal
1128
+ });
1129
+ if (response.status !== 429 || attempt === maxRetries) {
1130
+ if (!response.ok) {
1131
+ const errorBody = await readCappedErrorBody(response);
1132
+ attemptSignal.signal.throwIfAborted();
1133
+ throw new SilmarilApiError({
1134
+ status: response.status,
1135
+ statusText: response.statusText,
1136
+ body: errorBody
1137
+ });
1138
+ }
1139
+ return await readBodyUnderSignal(
1140
+ () => response.json(),
1141
+ attemptSignal.signal
1142
+ );
1061
1143
  }
1062
- return await response.json();
1144
+ await discardResponseBody(response);
1145
+ } finally {
1146
+ attemptSignal.dispose();
1063
1147
  }
1064
- const waitSeconds = Math.min(2 ** attempt, MAX_BACKOFF_SECONDS);
1065
- await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1e3));
1148
+ await sleep(Math.min(2 ** attempt, MAX_BACKOFF_SECONDS) * 1e3, callerSignal);
1066
1149
  }
1067
1150
  throw new Error("Firewall: exhausted retries (unreachable)");
1068
1151
  }
@@ -1082,7 +1165,7 @@ var Firewall = class {
1082
1165
  ...metadataInfo,
1083
1166
  ...options.governance === void 0 ? {} : { governance: options.governance }
1084
1167
  });
1085
- const data = await this.postWithRetry(payload);
1168
+ const data = await this.postWithRetry(payload, options.signal);
1086
1169
  return blockResultFromResponse(data, requestedMode);
1087
1170
  }
1088
1171
  };