@silmaril-security/sdk 0.1.2 → 0.3.0

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/dist/index.js CHANGED
@@ -9,6 +9,22 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // src/exceptions.ts
12
+ function parseErrorBody(body) {
13
+ try {
14
+ const parsed = JSON.parse(body);
15
+ if (!parsed || typeof parsed !== "object") {
16
+ return { details: void 0, error: void 0, apiMessage: void 0 };
17
+ }
18
+ const data = parsed;
19
+ return {
20
+ details: data.details && typeof data.details === "object" ? data.details : void 0,
21
+ error: typeof data.error === "string" ? data.error : void 0,
22
+ apiMessage: typeof data.message === "string" ? data.message : void 0
23
+ };
24
+ } catch {
25
+ return { details: void 0, error: void 0, apiMessage: void 0 };
26
+ }
27
+ }
12
28
  var MAX_PROMPT_DISPLAY_LEN, PromptBlockedException, SilmarilApiError;
13
29
  var init_exceptions = __esm({
14
30
  "src/exceptions.ts"() {
@@ -37,12 +53,20 @@ var init_exceptions = __esm({
37
53
  status;
38
54
  statusText;
39
55
  body;
56
+ details;
57
+ error;
58
+ apiMessage;
40
59
  constructor(params) {
41
- super(`Silmaril API error ${params.status} ${params.statusText}: ${params.body}`);
60
+ const statusText = params.statusText ? ` ${params.statusText}` : "";
61
+ super(`Silmaril API error ${params.status}${statusText}`);
42
62
  this.name = "SilmarilApiError";
43
63
  this.status = params.status;
44
64
  this.statusText = params.statusText;
45
65
  this.body = params.body;
66
+ const parsed = parseErrorBody(params.body);
67
+ this.details = params.details ?? parsed.details;
68
+ this.error = params.error ?? parsed.error;
69
+ this.apiMessage = params.apiMessage ?? parsed.apiMessage;
46
70
  Object.setPrototypeOf(this, _SilmarilApiError.prototype);
47
71
  }
48
72
  };
@@ -78,7 +102,7 @@ function prependToolName(text, toolName) {
78
102
  }
79
103
  return `[TOOL:${toolName}] ${text}`;
80
104
  }
81
- var HookLabel, FirewallHook, DEFAULT_HOOKS, INPUT_HOOKS, OUTPUT_HOOKS, ALL_HOOKS, FIREWALL_HOOK_TO_LABEL, DEFAULT_HOOK_THRESHOLDS, FIREWALL_HOOK_VALUES;
105
+ var HookLabel, FirewallHook, DEFAULT_HOOKS, INPUT_HOOKS, OUTPUT_HOOKS, ALL_HOOKS, FIREWALL_HOOK_TO_LABEL, FIREWALL_HOOK_VALUES;
82
106
  var init_hooks = __esm({
83
107
  "src/hooks.ts"() {
84
108
  "use strict";
@@ -127,14 +151,6 @@ var init_hooks = __esm({
127
151
  [FirewallHook.RETRIEVER_END]: HookLabel.TOOL_RESPONSE,
128
152
  [FirewallHook.LLM_END]: HookLabel.LLM_OUTPUT
129
153
  };
130
- DEFAULT_HOOK_THRESHOLDS = {
131
- [HookLabel.USER_INPUT]: 0.5,
132
- [HookLabel.SYSTEM_PROMPT]: 0.3,
133
- [HookLabel.TOOL_CALL]: 0.4,
134
- [HookLabel.TOOL_RESPONSE]: 0.3,
135
- [HookLabel.LLM_OUTPUT]: 0.4,
136
- [HookLabel.UNKNOWN]: 0.5
137
- };
138
154
  FIREWALL_HOOK_VALUES = new Set(Object.values(FirewallHook));
139
155
  }
140
156
  });
@@ -221,40 +237,49 @@ async function createLangChainHandler(firewall, options = {}) {
221
237
  const logger = options.logger ?? ((message, error) => {
222
238
  console.warn(`silmaril.firewall: ${message}`, error);
223
239
  });
224
- const overrideThreshold = options.threshold;
225
- const overrideHookThresholds = options.hookThresholds ?? {};
226
- const effectiveThreshold = (label) => {
227
- if (overrideHookThresholds[label] !== void 0) {
228
- return overrideHookThresholds[label];
240
+ const shadowMode = options.shadowMode ?? firewall.shadowMode;
241
+ const onClassify = options.onClassify;
242
+ const fireOnClassify = (hookLabel, text, result, blocked, toolName) => {
243
+ if (!onClassify) {
244
+ return;
229
245
  }
230
- if (firewall.hookThresholds[label] !== void 0) {
231
- return firewall.hookThresholds[label];
246
+ try {
247
+ onClassify({
248
+ hook: hookLabel,
249
+ ...toolName !== void 0 ? { toolName } : {},
250
+ text,
251
+ result,
252
+ blocked,
253
+ shadowMode
254
+ });
255
+ } catch (err) {
256
+ logger("onClassify callback threw", err);
232
257
  }
233
- return overrideThreshold ?? firewall.threshold;
234
258
  };
235
259
  const classify = async (text, hookLabel, runId, toolName) => {
260
+ let result;
236
261
  try {
237
- const result = await firewall.classify(text, {
262
+ result = await firewall.classify(text, {
238
263
  hook: hookLabel,
239
264
  ...toolName !== void 0 ? { toolName } : {}
240
265
  });
241
- const threshold = effectiveThreshold(hookLabel);
242
- if (result.score >= threshold) {
243
- throw new PromptBlockedException({
244
- score: result.score,
245
- threshold,
246
- promptText: text,
247
- runId
248
- });
249
- }
250
266
  } catch (err) {
251
- if (err instanceof PromptBlockedException) {
252
- throw err;
253
- }
254
267
  if (!failOpen) {
255
268
  throw err;
256
269
  }
257
270
  logger("classification failed, allowing prompt through", err);
271
+ return;
272
+ }
273
+ const threshold = result.threshold;
274
+ const blocked = result.score >= threshold;
275
+ fireOnClassify(hookLabel, text, result, blocked, toolName);
276
+ if (blocked && !shadowMode) {
277
+ throw new PromptBlockedException({
278
+ score: result.score,
279
+ threshold,
280
+ promptText: text,
281
+ runId
282
+ });
258
283
  }
259
284
  };
260
285
  class SilmarilFirewallHandler extends BaseCallbackHandler {
@@ -301,8 +326,7 @@ async function createLangChainHandler(firewall, options = {}) {
301
326
  return;
302
327
  }
303
328
  const toolName = tool?.name;
304
- const prefixed = prependToolName(text, toolName);
305
- await classify(prefixed, FIREWALL_HOOK_TO_LABEL[FirewallHook.TOOL_START], runId, toolName);
329
+ await classify(text, FIREWALL_HOOK_TO_LABEL[FirewallHook.TOOL_START], runId, toolName);
306
330
  }
307
331
  async handleRetrieverStart(_retriever, query, runId) {
308
332
  if (!enabledHooks.has(FirewallHook.RETRIEVER_START)) {
@@ -333,8 +357,7 @@ async function createLangChainHandler(firewall, options = {}) {
333
357
  return;
334
358
  }
335
359
  const toolName = _kwargs?.name;
336
- const prefixed = prependToolName(text, toolName);
337
- await classify(prefixed, FIREWALL_HOOK_TO_LABEL[FirewallHook.TOOL_END], runId, toolName);
360
+ await classify(text, FIREWALL_HOOK_TO_LABEL[FirewallHook.TOOL_END], runId, toolName);
338
361
  }
339
362
  async handleRetrieverEnd(documents, runId) {
340
363
  if (!enabledHooks.has(FirewallHook.RETRIEVER_END)) {
@@ -420,17 +443,7 @@ function findLastUserMessage(messages) {
420
443
  function createMiddleware(firewall, options = {}) {
421
444
  const scanInput = options.scanInput ?? true;
422
445
  const scanOutput = options.scanOutput ?? false;
423
- const overrideThreshold = options.threshold;
424
- const overrideHookThresholds = options.hookThresholds ?? {};
425
- const effectiveThreshold = (label) => {
426
- if (overrideHookThresholds[label] !== void 0) {
427
- return overrideHookThresholds[label];
428
- }
429
- if (firewall.hookThresholds[label] !== void 0) {
430
- return firewall.hookThresholds[label];
431
- }
432
- return overrideThreshold ?? firewall.threshold;
433
- };
446
+ const shadowMode = options.shadowMode ?? firewall.shadowMode;
434
447
  const classifyOrBlock = async (text, hook, toolName) => {
435
448
  if (!text.trim()) {
436
449
  return;
@@ -439,16 +452,17 @@ function createMiddleware(firewall, options = {}) {
439
452
  text,
440
453
  toolName !== void 0 ? { hook, toolName } : { hook }
441
454
  );
442
- const threshold = effectiveThreshold(hook);
455
+ const threshold = result.threshold;
443
456
  const blocked = result.score >= threshold;
444
457
  options.onClassify?.({
445
458
  hook,
446
459
  ...toolName !== void 0 ? { toolName } : {},
447
460
  text,
448
461
  result,
449
- blocked
462
+ blocked,
463
+ shadowMode
450
464
  });
451
- if (blocked) {
465
+ if (blocked && !shadowMode) {
452
466
  const err = new PromptBlockedException({
453
467
  score: result.score,
454
468
  threshold,
@@ -561,7 +575,45 @@ var MAX_INPUT_CHARS = MAX_INPUT_TOKENS * CHARS_PER_TOKEN;
561
575
  var CHUNK_WINDOW_CHARS = CHUNK_WINDOW * CHARS_PER_TOKEN;
562
576
  var CHUNK_OVERLAP_CHARS = CHUNK_OVERLAP * CHARS_PER_TOKEN;
563
577
  var CHUNK_STRIDE_CHARS = CHUNK_WINDOW_CHARS - CHUNK_OVERLAP_CHARS;
578
+ function isHighSurrogate(code) {
579
+ return code >= 55296 && code <= 56319;
580
+ }
581
+ function isLowSurrogate(code) {
582
+ return code >= 56320 && code <= 57343;
583
+ }
584
+ function sanitizeText(text) {
585
+ let out = "";
586
+ for (let i = 0; i < text.length; i++) {
587
+ const code = text.charCodeAt(i);
588
+ if (isHighSurrogate(code)) {
589
+ if (i + 1 < text.length && isLowSurrogate(text.charCodeAt(i + 1))) {
590
+ out += text[i];
591
+ out += text[i + 1];
592
+ i++;
593
+ }
594
+ continue;
595
+ }
596
+ if (isLowSurrogate(code)) {
597
+ continue;
598
+ }
599
+ out += text[i];
600
+ }
601
+ return out;
602
+ }
603
+ function safeChunkStart(text, start) {
604
+ if (start > 0 && start < text.length && isLowSurrogate(text.charCodeAt(start)) && isHighSurrogate(text.charCodeAt(start - 1))) {
605
+ return start + 1;
606
+ }
607
+ return start;
608
+ }
609
+ function safeChunkEnd(text, end) {
610
+ if (end > 0 && end < text.length && isHighSurrogate(text.charCodeAt(end - 1)) && isLowSurrogate(text.charCodeAt(end))) {
611
+ return end - 1;
612
+ }
613
+ return end;
614
+ }
564
615
  function chunkText(text) {
616
+ text = sanitizeText(text);
565
617
  const n = text.length;
566
618
  if (n > MAX_INPUT_CHARS) {
567
619
  throw new Error(
@@ -573,8 +625,12 @@ function chunkText(text) {
573
625
  }
574
626
  const chunks = [];
575
627
  for (let start = 0; start < n; start += CHUNK_STRIDE_CHARS) {
576
- const end = start + CHUNK_WINDOW_CHARS;
577
- chunks.push(text.slice(start, end));
628
+ const safeStart = safeChunkStart(text, start);
629
+ if (safeStart >= n) {
630
+ break;
631
+ }
632
+ const end = safeChunkEnd(text, safeStart + CHUNK_WINDOW_CHARS);
633
+ chunks.push(text.slice(safeStart, end));
578
634
  if (end >= n) {
579
635
  break;
580
636
  }
@@ -584,16 +640,122 @@ function chunkText(text) {
584
640
 
585
641
  // src/firewall.ts
586
642
  init_exceptions();
587
- var DEFAULT_THRESHOLD = 0.5;
643
+ var BASE_THRESHOLD = 0.5;
644
+ var TARGET_SEQUENCE_FPR = 0.01;
645
+ var MAX_ADAPTIVE_THRESHOLD = 0.9;
588
646
  var DEFAULT_TIMEOUT_MS = 1e4;
647
+ var DEFAULT_CHUNK_CONCURRENCY = 8;
589
648
  var DEFAULT_MAX_RETRIES = 5;
590
649
  var MAX_BACKOFF_SECONDS = 30;
650
+ var MAX_ERROR_BODY_BYTES = 1 << 16;
651
+ function adaptiveThreshold(scoringOpportunityCount) {
652
+ if (!Number.isInteger(scoringOpportunityCount) || scoringOpportunityCount < 1) {
653
+ throw new Error(
654
+ `Firewall: scoringOpportunityCount must be an integer >= 1, got ${scoringOpportunityCount}`
655
+ );
656
+ }
657
+ if (scoringOpportunityCount === 1) {
658
+ return BASE_THRESHOLD;
659
+ }
660
+ const targetChunkFpr = 1 - Math.pow(1 - TARGET_SEQUENCE_FPR, 1 / scoringOpportunityCount);
661
+ const oddsRatio = TARGET_SEQUENCE_FPR / targetChunkFpr;
662
+ const rawThreshold = oddsRatio / (1 + oddsRatio);
663
+ return Math.min(rawThreshold, MAX_ADAPTIVE_THRESHOLD);
664
+ }
665
+ function blockResultFromResponse(data, threshold) {
666
+ const result = {
667
+ prediction: data.prediction,
668
+ score: Number(data.score),
669
+ threshold
670
+ };
671
+ if (data.primary_outcome !== void 0) {
672
+ result.primaryOutcome = data.primary_outcome;
673
+ }
674
+ if (data.outcome_scores !== void 0) {
675
+ result.outcomeScores = Object.freeze(
676
+ Object.fromEntries(Object.entries(data.outcome_scores).map(([k, v]) => [k, Number(v)]))
677
+ );
678
+ }
679
+ if (data.detector_scores !== void 0) {
680
+ result.detectorScores = Object.freeze(
681
+ Object.fromEntries(Object.entries(data.detector_scores).map(([k, v]) => [k, Number(v)]))
682
+ );
683
+ }
684
+ if (data.detector_counts !== void 0) {
685
+ result.detectorCounts = Object.freeze(
686
+ Object.fromEntries(Object.entries(data.detector_counts).map(([k, v]) => [k, Number(v)]))
687
+ );
688
+ }
689
+ return Object.freeze(result);
690
+ }
691
+ async function readCappedErrorBody(response) {
692
+ if (!response.body) {
693
+ return response.text().then((body2) => body2.slice(0, MAX_ERROR_BODY_BYTES)).catch(() => "");
694
+ }
695
+ const reader = response.body.getReader();
696
+ const chunks = [];
697
+ let remaining = MAX_ERROR_BODY_BYTES;
698
+ try {
699
+ while (remaining > 0) {
700
+ const { done, value } = await reader.read();
701
+ if (done) {
702
+ break;
703
+ }
704
+ if (!value) {
705
+ continue;
706
+ }
707
+ const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value;
708
+ chunks.push(chunk);
709
+ remaining -= chunk.byteLength;
710
+ if (chunk.byteLength < value.byteLength) {
711
+ break;
712
+ }
713
+ }
714
+ } catch {
715
+ return "";
716
+ } finally {
717
+ await reader.cancel().catch(() => void 0);
718
+ }
719
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
720
+ const body = new Uint8Array(total);
721
+ let offset = 0;
722
+ for (const chunk of chunks) {
723
+ body.set(chunk, offset);
724
+ offset += chunk.byteLength;
725
+ }
726
+ return new TextDecoder().decode(body);
727
+ }
728
+ async function mapWithConcurrency(items, concurrency, task) {
729
+ const results = new Array(items.length);
730
+ let nextIndex = 0;
731
+ let firstError;
732
+ const workerCount = Math.min(concurrency, items.length);
733
+ async function worker() {
734
+ for (; ; ) {
735
+ const index = nextIndex;
736
+ nextIndex += 1;
737
+ if (index >= items.length) {
738
+ return;
739
+ }
740
+ try {
741
+ results[index] = await task(items[index], index);
742
+ } catch (err) {
743
+ firstError ??= err;
744
+ }
745
+ }
746
+ }
747
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
748
+ if (firstError !== void 0) {
749
+ throw firstError;
750
+ }
751
+ return results;
752
+ }
591
753
  var Firewall = class {
592
754
  apiKey;
593
755
  apiUrl;
594
- threshold;
595
756
  timeoutMs;
596
- hookThresholds;
757
+ chunkConcurrency;
758
+ shadowMode;
597
759
  headers;
598
760
  constructor(options) {
599
761
  if (!options.apiKey) {
@@ -604,9 +766,17 @@ var Firewall = class {
604
766
  }
605
767
  this.apiKey = options.apiKey;
606
768
  this.apiUrl = options.apiUrl;
607
- this.threshold = options.threshold ?? DEFAULT_THRESHOLD;
608
769
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
609
- this.hookThresholds = options.hookThresholds ?? {};
770
+ if (typeof this.timeoutMs !== "number" || !Number.isFinite(this.timeoutMs) || this.timeoutMs < 0) {
771
+ throw new Error(`Firewall: timeoutMs must be a finite non-negative number, got ${this.timeoutMs}`);
772
+ }
773
+ this.chunkConcurrency = options.chunkConcurrency ?? DEFAULT_CHUNK_CONCURRENCY;
774
+ if (!Number.isInteger(this.chunkConcurrency) || this.chunkConcurrency < 1) {
775
+ throw new Error(
776
+ `Firewall: chunkConcurrency must be an integer >= 1, got ${this.chunkConcurrency}`
777
+ );
778
+ }
779
+ this.shadowMode = options.shadowMode ?? false;
610
780
  this.headers = Object.freeze({
611
781
  "x-api-key": this.apiKey,
612
782
  "content-type": "application/json"
@@ -614,39 +784,52 @@ var Firewall = class {
614
784
  }
615
785
  async classify(text, options = {}) {
616
786
  const chunks = chunkText(text);
787
+ const threshold = adaptiveThreshold(chunks.length);
617
788
  if (chunks.length === 1) {
618
- const payload = { text: chunks[0] };
619
- if (options.hook !== void 0) {
620
- payload.hook = options.hook;
621
- }
622
- if (options.toolName !== void 0) {
623
- payload.tool_name = options.toolName;
624
- }
625
- const data = await this.postWithRetry(payload);
626
- return Object.freeze({ prediction: data.prediction, score: Number(data.score) });
627
- }
628
- const batchOpts = {};
629
- if (options.hook !== void 0) {
630
- batchOpts.hooks = chunks.map(() => options.hook);
789
+ return this.classifySingleChunk(chunks[0], options, threshold);
631
790
  }
632
- if (options.toolName !== void 0) {
633
- batchOpts.toolNames = chunks.map(() => options.toolName);
634
- }
635
- const results = await this.classifyBatch(chunks, batchOpts);
791
+ const results = await mapWithConcurrency(
792
+ chunks,
793
+ this.chunkConcurrency,
794
+ (chunk) => this.classifySingleChunk(chunk, options, threshold)
795
+ );
636
796
  return results.reduce((best, r) => r.score > best.score ? r : best);
637
797
  }
638
798
  async classifyBatch(texts, options = {}) {
639
- const payload = { texts };
799
+ if (texts.length === 0) {
800
+ throw new Error("Firewall: texts must not be empty");
801
+ }
802
+ if (options.hooks !== void 0 && options.hooks.length !== texts.length) {
803
+ throw new Error(
804
+ `Firewall: hooks length ${options.hooks.length} does not match texts length ${texts.length}`
805
+ );
806
+ }
807
+ if (options.toolNames !== void 0 && options.toolNames.length !== texts.length) {
808
+ throw new Error(
809
+ `Firewall: toolNames length ${options.toolNames.length} does not match texts length ${texts.length}`
810
+ );
811
+ }
812
+ if (options.metadata !== void 0 && options.metadata.length !== texts.length) {
813
+ throw new Error(
814
+ `Firewall: metadata length ${options.metadata.length} does not match texts length ${texts.length}`
815
+ );
816
+ }
817
+ const threshold = adaptiveThreshold(texts.length);
818
+ const payload = {
819
+ texts: texts.map((text) => sanitizeText(text)),
820
+ threshold
821
+ };
640
822
  if (options.hooks && options.hooks.length > 0) {
641
823
  payload.hooks = options.hooks.map((h) => String(h));
642
824
  }
643
825
  if (options.toolNames && options.toolNames.length > 0) {
644
826
  payload.tool_names = options.toolNames.map((t) => t === void 0 ? null : t);
645
827
  }
828
+ if (options.metadata && options.metadata.length > 0) {
829
+ payload.metadata = options.metadata.map((m) => m === void 0 ? null : m);
830
+ }
646
831
  const data = await this.postWithRetry(payload);
647
- return data.predictions.map(
648
- (p) => Object.freeze({ prediction: p.prediction, score: Number(p.score) })
649
- );
832
+ return data.predictions.map((p) => blockResultFromResponse(p, threshold));
650
833
  }
651
834
  asLangChainHandler(options = {}) {
652
835
  return Promise.resolve().then(() => (init_langchain(), langchain_exports)).then((m) => m.createLangChainHandler(this, options));
@@ -654,23 +837,18 @@ var Firewall = class {
654
837
  asMiddleware(options = {}) {
655
838
  return createMiddleware(this, options);
656
839
  }
657
- effectiveThreshold(hook) {
658
- if (hook === void 0) {
659
- return this.threshold;
660
- }
661
- return this.hookThresholds[hook] ?? this.threshold;
662
- }
663
840
  async postWithRetry(payload, maxRetries = DEFAULT_MAX_RETRIES) {
664
841
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
665
842
  const response = await fetch(this.apiUrl, {
666
843
  method: "POST",
667
844
  headers: this.headers,
668
845
  body: JSON.stringify(payload),
846
+ redirect: "error",
669
847
  signal: AbortSignal.timeout(this.timeoutMs)
670
848
  });
671
849
  if (response.status !== 429 || attempt === maxRetries) {
672
850
  if (!response.ok) {
673
- const body = await response.text().catch(() => "");
851
+ const body = await readCappedErrorBody(response);
674
852
  throw new SilmarilApiError({
675
853
  status: response.status,
676
854
  statusText: response.statusText,
@@ -684,6 +862,20 @@ var Firewall = class {
684
862
  }
685
863
  throw new Error("Firewall: exhausted retries (unreachable)");
686
864
  }
865
+ async classifySingleChunk(text, options, threshold) {
866
+ const payload = { text, threshold };
867
+ if (options.hook !== void 0) {
868
+ payload.hook = options.hook;
869
+ }
870
+ if (options.toolName !== void 0) {
871
+ payload.tool_name = options.toolName;
872
+ }
873
+ if (options.metadata !== void 0) {
874
+ payload.metadata = options.metadata;
875
+ }
876
+ const data = await this.postWithRetry(payload);
877
+ return blockResultFromResponse(data, threshold);
878
+ }
687
879
  };
688
880
 
689
881
  // src/index.ts
@@ -695,9 +887,8 @@ export {
695
887
  CHUNK_OVERLAP_CHARS,
696
888
  CHUNK_WINDOW,
697
889
  CHUNK_WINDOW_CHARS,
890
+ DEFAULT_CHUNK_CONCURRENCY,
698
891
  DEFAULT_HOOKS,
699
- DEFAULT_HOOK_THRESHOLDS,
700
- DEFAULT_THRESHOLD,
701
892
  DEFAULT_TIMEOUT_MS,
702
893
  FIREWALL_HOOK_TO_LABEL,
703
894
  Firewall,
@@ -712,6 +903,7 @@ export {
712
903
  chunkText,
713
904
  prependHook,
714
905
  prependToolName,
715
- resolveHooks
906
+ resolveHooks,
907
+ sanitizeText
716
908
  };
717
909
  //# sourceMappingURL=index.js.map