@arnilo/prism 0.0.3 → 0.0.5

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 (131) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +62 -26
  3. package/dist/agent-loops.d.ts +8 -1
  4. package/dist/agent-loops.js +57 -11
  5. package/dist/agents.js +212 -32
  6. package/dist/checkpoints.d.ts +11 -0
  7. package/dist/checkpoints.js +144 -0
  8. package/dist/cli-init.d.ts +41 -0
  9. package/dist/cli-init.js +390 -0
  10. package/dist/cli-runner.d.ts +7 -1
  11. package/dist/cli-runner.js +13 -1
  12. package/dist/compaction.js +9 -1
  13. package/dist/content.d.ts +121 -0
  14. package/dist/content.js +538 -0
  15. package/dist/contracts.d.ts +236 -11
  16. package/dist/contracts.js +8 -0
  17. package/dist/event-multiplexer.d.ts +23 -0
  18. package/dist/event-multiplexer.js +136 -0
  19. package/dist/execution-policy.d.ts +28 -0
  20. package/dist/execution-policy.js +24 -0
  21. package/dist/feedback.d.ts +48 -0
  22. package/dist/feedback.js +230 -0
  23. package/dist/index.d.ts +20 -6
  24. package/dist/index.js +13 -5
  25. package/dist/input.js +11 -1
  26. package/dist/leases.d.ts +8 -0
  27. package/dist/leases.js +111 -0
  28. package/dist/node/agent-definitions.js +3 -5
  29. package/dist/node/config.d.ts +1 -0
  30. package/dist/node/config.js +5 -3
  31. package/dist/node/contribution-discovery.js +5 -8
  32. package/dist/node/session-store-jsonl.js +8 -5
  33. package/dist/node/settings.js +2 -2
  34. package/dist/node/trust.js +2 -4
  35. package/dist/observability.d.ts +3 -0
  36. package/dist/observability.js +18 -0
  37. package/dist/providers/media.d.ts +44 -0
  38. package/dist/providers/media.js +126 -0
  39. package/dist/providers/openai-compatible.js +18 -119
  40. package/dist/providers/openai-primitives.d.ts +9 -0
  41. package/dist/providers/openai-primitives.js +129 -0
  42. package/dist/providers/transport.d.ts +40 -0
  43. package/dist/providers/transport.js +221 -0
  44. package/dist/redaction.js +40 -13
  45. package/dist/resources.d.ts +5 -0
  46. package/dist/resources.js +4 -0
  47. package/dist/structured-output.d.ts +11 -0
  48. package/dist/structured-output.js +59 -0
  49. package/dist/testing/feedback.d.ts +6 -0
  50. package/dist/testing/feedback.js +37 -0
  51. package/dist/testing/persistence-schema.d.ts +102 -0
  52. package/dist/testing/persistence-schema.js +487 -0
  53. package/dist/testing/provider-conformance.js +10 -1
  54. package/dist/testing/run-ledger-conformance.d.ts +33 -0
  55. package/dist/testing/run-ledger-conformance.js +178 -0
  56. package/dist/testing/session-store-conformance.d.ts +16 -0
  57. package/dist/testing/session-store-conformance.js +73 -0
  58. package/dist/tools.d.ts +17 -0
  59. package/dist/tools.js +29 -2
  60. package/docs/a2a.md +73 -0
  61. package/docs/agent-events.md +17 -10
  62. package/docs/agent-loops.md +11 -5
  63. package/docs/agent-session-runtime.md +15 -16
  64. package/docs/cli-rpc.md +36 -5
  65. package/docs/coding-agent-tools.md +43 -9
  66. package/docs/coding-security.md +88 -0
  67. package/docs/compaction-observational-memory.md +2 -0
  68. package/docs/context-and-skills.md +1 -0
  69. package/docs/credential-storage.md +177 -0
  70. package/docs/credentials-and-redaction.md +4 -3
  71. package/docs/database-persistence.md +52 -7
  72. package/docs/evaluations.md +122 -0
  73. package/docs/extensions.md +2 -2
  74. package/docs/host-security.md +33 -2
  75. package/docs/index.md +46 -18
  76. package/docs/input-and-prompt-assembly.md +6 -5
  77. package/docs/mcp-tools.md +184 -0
  78. package/docs/middleware-hooks.md +2 -0
  79. package/docs/migration.md +51 -28
  80. package/docs/model-registry.md +5 -3
  81. package/docs/multimodal-content.md +156 -0
  82. package/docs/observability.md +171 -0
  83. package/docs/performance.md +249 -1
  84. package/docs/persistence-credentials-multimodality-primitives.md +303 -0
  85. package/docs/postgres-persistence.md +143 -0
  86. package/docs/provider-conformance.md +18 -0
  87. package/docs/provider-layer.md +1 -1
  88. package/docs/provider-packages.md +2 -0
  89. package/docs/provider-primitives.md +281 -0
  90. package/docs/providers/ai-sdk.md +113 -0
  91. package/docs/providers/kimi.md +1 -0
  92. package/docs/providers/neuralwatt.md +1 -0
  93. package/docs/providers/openai-compatible.md +2 -1
  94. package/docs/providers/openai.md +8 -1
  95. package/docs/providers/opencode-go.md +1 -0
  96. package/docs/providers/openrouter.md +1 -0
  97. package/docs/providers/zai.md +1 -0
  98. package/docs/public-contracts.md +13 -5
  99. package/docs/rag.md +113 -0
  100. package/docs/release-and-install.md +237 -30
  101. package/docs/resource-loading.md +14 -4
  102. package/docs/review-coverage-2026-07-14.md +260 -0
  103. package/docs/review-coverage-2026-07-15.md +193 -0
  104. package/docs/run-ledger-conformance.md +96 -0
  105. package/docs/runs-and-usage.md +43 -4
  106. package/docs/server.md +139 -0
  107. package/docs/session-store-conformance.md +16 -0
  108. package/docs/session-stores-and-branching.md +1 -0
  109. package/docs/settings-auth-trust-security.md +6 -5
  110. package/docs/sqlite-persistence.md +123 -0
  111. package/docs/structured-output.md +9 -0
  112. package/docs/supervisors.md +71 -0
  113. package/docs/tool-conformance.md +1 -0
  114. package/docs/tool-execution-primitives.md +374 -0
  115. package/docs/tools.md +39 -1
  116. package/docs/workflow-orchestration-primitives.md +581 -0
  117. package/docs/workflow-tui-primitives.md +5 -0
  118. package/docs/workflows.md +293 -0
  119. package/docs/working-and-semantic-memory.md +169 -0
  120. package/package.json +43 -5
  121. package/templates/init/README.md.tmpl +28 -0
  122. package/templates/init/env.example.tmpl +1 -0
  123. package/templates/init/gitignore.tmpl +11 -0
  124. package/templates/init/optional/evals-example.ts.tmpl +17 -0
  125. package/templates/init/optional/workflows-example.ts.tmpl +27 -0
  126. package/templates/init/package.json.tmpl +22 -0
  127. package/templates/init/providers.json +76 -0
  128. package/templates/init/src/agent.ts.tmpl +10 -0
  129. package/templates/init/src/index.ts.tmpl +12 -0
  130. package/templates/init/src/tests/agent.test.ts.tmpl +24 -0
  131. package/templates/init/tsconfig.json.tmpl +15 -0
@@ -0,0 +1,538 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { isIP } from "node:net";
5
+ import { assertPermission } from "./security.js";
6
+ /** Known model input capability tags for `ModelCapabilities.input`. */
7
+ export const MODEL_INPUT_CAPABILITIES = ["text", "image", "audio", "file", "document"];
8
+ /** Default per-item media byte ceiling (10 MB; aligns with coding-agent image bounds). */
9
+ export const DEFAULT_MAX_MEDIA_ITEM_BYTES = 10_000_000;
10
+ /** Default total media byte budget per request assembly. */
11
+ export const DEFAULT_MAX_MEDIA_REQUEST_BYTES = 32 * 1024 * 1024;
12
+ /** Default decoded audio duration ceiling. */
13
+ export const DEFAULT_MAX_AUDIO_DURATION_MS = 5 * 60 * 1000;
14
+ /** Default URL fetch timeout for media resolution. */
15
+ export const DEFAULT_MEDIA_FETCH_TIMEOUT_MS = 30_000;
16
+ /** Default maximum media-bearing blocks per request assembly. */
17
+ export const DEFAULT_MAX_MEDIA_ITEMS_PER_REQUEST = 32;
18
+ export class UnsupportedModalityError extends Error {
19
+ modality;
20
+ provider;
21
+ model;
22
+ constructor(modality, model) {
23
+ super(`Model ${model.provider}/${model.model} does not support ${modality} input`);
24
+ this.name = "UnsupportedModalityError";
25
+ this.modality = modality;
26
+ this.provider = model.provider;
27
+ this.model = model.model;
28
+ }
29
+ }
30
+ export class MediaContentError extends Error {
31
+ code;
32
+ constructor(code, message) {
33
+ super(message);
34
+ this.name = "MediaContentError";
35
+ this.code = code;
36
+ }
37
+ }
38
+ export function contentBlockInputModality(block) {
39
+ switch (block.type) {
40
+ case "image":
41
+ return "image";
42
+ case "audio":
43
+ return "audio";
44
+ case "file":
45
+ return "file";
46
+ case "document":
47
+ return "document";
48
+ default:
49
+ return undefined;
50
+ }
51
+ }
52
+ export function collectMessageContentBlocks(messages) {
53
+ return messages.flatMap((message) => [...message.content]);
54
+ }
55
+ export function assertModelSupportsContentBlocks(model, blocks) {
56
+ const supported = model.capabilities?.input;
57
+ if (!supported?.length)
58
+ return;
59
+ for (const block of blocks) {
60
+ const modality = contentBlockInputModality(block);
61
+ if (modality && !supported.includes(modality)) {
62
+ throw new UnsupportedModalityError(modality, model);
63
+ }
64
+ }
65
+ }
66
+ export function assertMessagesSupportModelCapabilities(model, messages) {
67
+ assertModelSupportsContentBlocks(model, collectMessageContentBlocks(messages));
68
+ }
69
+ export function assertMediaBlocksWithinBounds(blocks, bounds = {}) {
70
+ const maxItems = bounds.maxItems ?? DEFAULT_MAX_MEDIA_ITEMS_PER_REQUEST;
71
+ const maxItemBytes = bounds.maxItemBytes ?? DEFAULT_MAX_MEDIA_ITEM_BYTES;
72
+ const maxRequestBytes = bounds.maxRequestBytes ?? DEFAULT_MAX_MEDIA_REQUEST_BYTES;
73
+ const maxAudioDurationMs = bounds.maxAudioDurationMs ?? DEFAULT_MAX_AUDIO_DURATION_MS;
74
+ if (blocks.length > maxItems) {
75
+ throw new MediaContentError("too_many_items", `Media item count ${blocks.length} exceeded ${maxItems}`);
76
+ }
77
+ let requestBytes = 0;
78
+ for (const block of blocks) {
79
+ const inlineBytes = estimateInlineMediaBytes(block);
80
+ if (inlineBytes > maxItemBytes) {
81
+ throw new MediaContentError("item_too_large", `Media item exceeded ${maxItemBytes} bytes`);
82
+ }
83
+ requestBytes += inlineBytes;
84
+ if (requestBytes > maxRequestBytes) {
85
+ throw new MediaContentError("request_too_large", `Media request budget exceeded ${maxRequestBytes} bytes`);
86
+ }
87
+ if (block.type === "audio" && block.durationMs !== undefined && block.durationMs > maxAudioDurationMs) {
88
+ throw new MediaContentError("audio_too_long", `Audio duration ${block.durationMs}ms exceeded ${maxAudioDurationMs}ms`);
89
+ }
90
+ }
91
+ }
92
+ export function assertSsrfAllowedUrl(url, policy = {}) {
93
+ let parsed;
94
+ try {
95
+ parsed = new URL(url);
96
+ }
97
+ catch {
98
+ throw new MediaContentError("ssrf_denied", "Media URL is not a valid absolute URL");
99
+ }
100
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
101
+ throw new MediaContentError("unsupported_url_scheme", `Media URL scheme ${parsed.protocol} is not allowed`);
102
+ }
103
+ if (parsed.username || parsed.password) {
104
+ throw new MediaContentError("ssrf_denied", "Media URL must not embed credentials");
105
+ }
106
+ const hostname = normalizeHostname(parsed.hostname);
107
+ if (policy.allowedHostnames?.length) {
108
+ if (!policy.allowedHostnames.some((allowed) => hostname === normalizeHostname(allowed))) {
109
+ throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allow-listed`);
110
+ }
111
+ return;
112
+ }
113
+ if (policy.denyPrivateHosts === false)
114
+ return;
115
+ if (hostname === "localhost"
116
+ || hostname.endsWith(".localhost")
117
+ || hostname.endsWith(".local")
118
+ || hostname === "metadata"
119
+ || hostname === "metadata.google.internal"
120
+ || hostname === "instance-data") {
121
+ throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
122
+ }
123
+ if (isBlockedIp(hostname)) {
124
+ throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
125
+ }
126
+ }
127
+ export function sniffMediaMimeType(bytes) {
128
+ if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]))
129
+ return "application/pdf";
130
+ if (startsWithAscii(bytes, 0, "%PDF"))
131
+ return "application/pdf";
132
+ if (startsWithAscii(bytes, 0, "RIFF") && startsWithAscii(bytes, 8, "WAVE"))
133
+ return "audio/wav";
134
+ if (startsWithAscii(bytes, 0, "RIFF") && startsWithAscii(bytes, 8, "WEBP"))
135
+ return "image/webp";
136
+ if (startsWithAscii(bytes, 0, "OggS"))
137
+ return "audio/ogg";
138
+ if (startsWithAscii(bytes, 0, "fLaC"))
139
+ return "audio/flac";
140
+ if (startsWithAscii(bytes, 0, "ID3"))
141
+ return "audio/mpeg";
142
+ if (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0)
143
+ return "audio/mpeg";
144
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
145
+ return "image/png";
146
+ if (startsWith(bytes, [0xff, 0xd8, 0xff]))
147
+ return "image/jpeg";
148
+ if (startsWithAscii(bytes, 0, "GIF8"))
149
+ return "image/gif";
150
+ if (startsWithAscii(bytes, 0, "PK\u0003\u0004"))
151
+ return "application/zip";
152
+ return undefined;
153
+ }
154
+ export function assertDeclaredMediaTypeMatches(declaredMediaType, bytes, policy = {}) {
155
+ if (policy.strictMagicValidation === false)
156
+ return;
157
+ const sniffed = sniffMediaMimeType(bytes);
158
+ if (!sniffed)
159
+ return;
160
+ if (!mediaTypesCompatible(declaredMediaType, sniffed)) {
161
+ throw new MediaContentError("mime_mismatch", `Declared media type ${declaredMediaType} does not match sniffed type ${sniffed}`);
162
+ }
163
+ }
164
+ export async function resolveMediaContentBlock(block, options = {}) {
165
+ return (await resolveMediaContentBlocks([block], options))[0];
166
+ }
167
+ /** Resolve and validate one complete media request before provider serialization/upload. */
168
+ export async function resolveMediaContentBlocks(blocks, options = {}) {
169
+ const bounds = options.bounds ?? {};
170
+ const maxRequestBytes = bounds.maxRequestBytes ?? DEFAULT_MAX_MEDIA_REQUEST_BYTES;
171
+ assertMediaBlocksWithinBounds(blocks, bounds);
172
+ const resolved = [];
173
+ let requestBytes = 0;
174
+ for (const block of blocks) {
175
+ const item = await resolveMediaBlock(block, options);
176
+ requestBytes += item.bytes.byteLength;
177
+ if (requestBytes > maxRequestBytes) {
178
+ throw new MediaContentError("request_too_large", `Media request budget exceeded ${maxRequestBytes} bytes`);
179
+ }
180
+ resolved.push(item);
181
+ }
182
+ return resolved;
183
+ }
184
+ async function resolveMediaBlock(block, options) {
185
+ const bounds = options.bounds ?? {};
186
+ const maxItemBytes = bounds.maxItemBytes ?? DEFAULT_MAX_MEDIA_ITEM_BYTES;
187
+ const source = mediaSourceKind(block);
188
+ let bytes;
189
+ let mediaType = declaredMediaType(block);
190
+ if (source === "data") {
191
+ bytes = decodeBase64Bounded(readDataField(block), maxItemBytes);
192
+ }
193
+ else if (source === "resource") {
194
+ if (!options.loader) {
195
+ throw new MediaContentError("resource_required", "ResourceLoader is required to resolve resourceUri media");
196
+ }
197
+ bytes = await loadBoundedBinaryResource(options.loader, readResourceUri(block), options.loadContext, maxItemBytes, options.signal);
198
+ }
199
+ else {
200
+ bytes = await fetchBoundedMediaUrl(readUrlField(block), {
201
+ maxBytes: maxItemBytes,
202
+ timeoutMs: bounds.fetchTimeoutMs ?? DEFAULT_MEDIA_FETCH_TIMEOUT_MS,
203
+ ssrf: options.ssrf,
204
+ fetch: options.fetch,
205
+ resolveHostname: options.resolveHostname,
206
+ requestUrl: options.requestUrl,
207
+ signal: options.signal,
208
+ });
209
+ }
210
+ assertDeclaredMediaTypeMatches(mediaType, bytes, options.mime);
211
+ const sniffed = sniffMediaMimeType(bytes);
212
+ if (sniffed && (mediaType === "application/octet-stream" || !mediaType)) {
213
+ mediaType = sniffed;
214
+ }
215
+ const durationMs = block.type === "audio" ? block.durationMs : undefined;
216
+ return {
217
+ mediaType,
218
+ bytes,
219
+ name: readName(block),
220
+ durationMs,
221
+ transcript: readTranscript(block),
222
+ metadata: "metadata" in block ? block.metadata : undefined,
223
+ };
224
+ }
225
+ export async function loadBoundedBinaryResource(loader, uri, context, maxBytes, signal) {
226
+ await assertPermission(context?.permission, {
227
+ kind: "resource",
228
+ action: "load",
229
+ target: uri,
230
+ metadata: context?.metadata,
231
+ });
232
+ signal?.throwIfAborted();
233
+ const resource = await loader.load(uri, { ...context, signal });
234
+ const bytes = resource.data ?? (resource.text !== undefined ? new TextEncoder().encode(resource.text) : undefined);
235
+ if (!bytes)
236
+ throw new MediaContentError("missing_source", `Resource ${uri} has no data or text`);
237
+ if (bytes.byteLength > maxBytes) {
238
+ throw new MediaContentError("item_too_large", `Resource ${uri} exceeded ${maxBytes} bytes`);
239
+ }
240
+ return bytes;
241
+ }
242
+ function mediaSourceKind(block) {
243
+ const sources = [
244
+ readDataField(block) ? "data" : undefined,
245
+ readResourceUri(block) ? "resource" : undefined,
246
+ readUrlField(block) ? "url" : undefined,
247
+ ].filter((value) => value !== undefined);
248
+ if (sources.length === 0) {
249
+ throw new MediaContentError("missing_source", `Media block ${block.type} requires data, url, or resourceUri`);
250
+ }
251
+ if (sources.length > 1) {
252
+ throw new MediaContentError("ambiguous_source", `Media block ${block.type} must provide only one of data, url, or resourceUri`);
253
+ }
254
+ return sources[0];
255
+ }
256
+ function readDataField(block) {
257
+ return "data" in block ? block.data : undefined;
258
+ }
259
+ function readUrlField(block) {
260
+ return "url" in block ? block.url : undefined;
261
+ }
262
+ function readResourceUri(block) {
263
+ return "resourceUri" in block ? block.resourceUri : undefined;
264
+ }
265
+ function readName(block) {
266
+ return "name" in block ? block.name : undefined;
267
+ }
268
+ function readTranscript(block) {
269
+ return "transcript" in block ? block.transcript : undefined;
270
+ }
271
+ function declaredMediaType(block) {
272
+ if ("mediaType" in block && block.mediaType)
273
+ return block.mediaType;
274
+ if (block.type === "image" && block.mimeType)
275
+ return block.mimeType;
276
+ return "application/octet-stream";
277
+ }
278
+ function estimateInlineMediaBytes(block) {
279
+ const data = readDataField(block);
280
+ if (data)
281
+ return estimateBase64DecodedBytes(data);
282
+ return 0;
283
+ }
284
+ function estimateBase64DecodedBytes(data) {
285
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
286
+ return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
287
+ }
288
+ function decodeBase64Bounded(data, maxBytes) {
289
+ const estimated = estimateBase64DecodedBytes(data);
290
+ if (estimated > maxBytes) {
291
+ throw new MediaContentError("item_too_large", `Inline media exceeded ${maxBytes} bytes`);
292
+ }
293
+ let bytes;
294
+ try {
295
+ bytes = Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
296
+ }
297
+ catch {
298
+ throw new MediaContentError("invalid_base64", "Media data is not valid base64");
299
+ }
300
+ if (bytes.byteLength > maxBytes) {
301
+ throw new MediaContentError("item_too_large", `Inline media exceeded ${maxBytes} bytes`);
302
+ }
303
+ return bytes;
304
+ }
305
+ async function fetchBoundedMediaUrl(url, options) {
306
+ assertSsrfAllowedUrl(url, options.ssrf);
307
+ const timeoutController = new AbortController();
308
+ const timeout = setTimeout(() => timeoutController.abort(), options.timeoutMs);
309
+ const signal = options.signal
310
+ ? AbortSignal.any([options.signal, timeoutController.signal])
311
+ : timeoutController.signal;
312
+ try {
313
+ if (options.fetch)
314
+ return await readFetchResponse(await options.fetch(url, { signal, redirect: "error" }), options.maxBytes);
315
+ const parsed = new URL(url);
316
+ const hostname = normalizeHostname(parsed.hostname);
317
+ const family = isIP(hostname);
318
+ const address = family
319
+ ? { address: hostname, family: family }
320
+ : await resolvePublicAddress(hostname, options.resolveHostname ?? defaultMediaHostnameResolver, signal, options.ssrf);
321
+ const bytes = await (options.requestUrl ?? requestPinnedMediaUrl)({
322
+ url: parsed,
323
+ address,
324
+ maxBytes: options.maxBytes,
325
+ signal,
326
+ });
327
+ if (bytes.byteLength > options.maxBytes) {
328
+ throw new MediaContentError("item_too_large", `Fetched media exceeded ${options.maxBytes} bytes`);
329
+ }
330
+ return bytes;
331
+ }
332
+ catch (error) {
333
+ if (error instanceof MediaContentError)
334
+ throw error;
335
+ if (timeoutController.signal.aborted) {
336
+ throw new MediaContentError("fetch_timeout", `Media fetch timed out after ${options.timeoutMs}ms`);
337
+ }
338
+ if (options.signal?.aborted)
339
+ throw options.signal.reason ?? new Error("Media fetch aborted");
340
+ throw new MediaContentError("fetch_failed", error instanceof Error ? error.message : "Media fetch failed");
341
+ }
342
+ finally {
343
+ clearTimeout(timeout);
344
+ }
345
+ }
346
+ async function defaultMediaHostnameResolver(hostname) {
347
+ return dnsLookup(hostname, { all: true, verbatim: true });
348
+ }
349
+ async function resolvePublicAddress(hostname, resolver, signal, policy) {
350
+ const addresses = await raceAbort(resolver(hostname, signal), signal);
351
+ if (addresses.length === 0)
352
+ throw new MediaContentError("fetch_failed", "Media hostname resolved to no addresses");
353
+ if (addresses.length > 32)
354
+ throw new MediaContentError("fetch_failed", "Media hostname resolved to too many addresses");
355
+ if (policy?.denyPrivateHosts !== false && !policy?.allowedHostnames?.length) {
356
+ if (addresses.some(({ address }) => isBlockedIp(normalizeHostname(address)))) {
357
+ throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} resolved to a private address`);
358
+ }
359
+ }
360
+ const selected = addresses[0];
361
+ if (isIP(normalizeHostname(selected.address)) !== selected.family) {
362
+ throw new MediaContentError("fetch_failed", "Media hostname resolver returned an invalid address");
363
+ }
364
+ return { address: normalizeHostname(selected.address), family: selected.family };
365
+ }
366
+ async function raceAbort(promise, signal) {
367
+ signal.throwIfAborted();
368
+ return new Promise((resolve, reject) => {
369
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
370
+ const onAbort = () => {
371
+ cleanup();
372
+ reject(signal.reason);
373
+ };
374
+ signal.addEventListener("abort", onAbort, { once: true });
375
+ promise.then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
376
+ });
377
+ }
378
+ async function readFetchResponse(response, maxBytes) {
379
+ if (!response.ok)
380
+ throw new MediaContentError("fetch_failed", `Media fetch failed with status ${response.status}`);
381
+ const reader = response.body?.getReader();
382
+ if (!reader)
383
+ throw new MediaContentError("fetch_failed", "Media fetch returned no response body");
384
+ const chunks = [];
385
+ let total = 0;
386
+ try {
387
+ while (true) {
388
+ const { done, value } = await reader.read();
389
+ if (done)
390
+ break;
391
+ if (!value)
392
+ continue;
393
+ total += value.byteLength;
394
+ if (total > maxBytes)
395
+ throw new MediaContentError("item_too_large", `Fetched media exceeded ${maxBytes} bytes`);
396
+ chunks.push(value);
397
+ }
398
+ }
399
+ finally {
400
+ try {
401
+ await reader.cancel();
402
+ }
403
+ catch {
404
+ // Reader may already be closed after abort/complete.
405
+ }
406
+ reader.releaseLock();
407
+ }
408
+ return joinChunks(chunks, total);
409
+ }
410
+ function requestPinnedMediaUrl({ url, address, maxBytes, signal }) {
411
+ return new Promise((resolve, reject) => {
412
+ const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
413
+ agent: false,
414
+ family: address.family,
415
+ signal,
416
+ lookup: (_hostname, _options, callback) => callback(null, address.address, address.family),
417
+ }, (response) => readIncomingMessage(response, maxBytes).then(resolve, reject));
418
+ request.on("error", reject);
419
+ request.end();
420
+ });
421
+ }
422
+ async function readIncomingMessage(response, maxBytes) {
423
+ const status = response.statusCode ?? 0;
424
+ if (status < 200 || status >= 300) {
425
+ response.resume();
426
+ throw new MediaContentError("fetch_failed", `Media fetch failed with status ${status}`);
427
+ }
428
+ const chunks = [];
429
+ let total = 0;
430
+ for await (const chunk of response) {
431
+ const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk);
432
+ total += bytes.byteLength;
433
+ if (total > maxBytes) {
434
+ response.destroy();
435
+ throw new MediaContentError("item_too_large", `Fetched media exceeded ${maxBytes} bytes`);
436
+ }
437
+ chunks.push(bytes);
438
+ }
439
+ return joinChunks(chunks, total);
440
+ }
441
+ function joinChunks(chunks, total) {
442
+ const bytes = new Uint8Array(total);
443
+ let offset = 0;
444
+ for (const chunk of chunks) {
445
+ bytes.set(chunk, offset);
446
+ offset += chunk.byteLength;
447
+ }
448
+ return bytes;
449
+ }
450
+ function mediaTypesCompatible(declared, sniffed) {
451
+ const normalizedDeclared = declared.toLowerCase();
452
+ const normalizedSniffed = sniffed.toLowerCase();
453
+ if (normalizedDeclared === normalizedSniffed)
454
+ return true;
455
+ if (normalizedDeclared === "application/octet-stream")
456
+ return true;
457
+ if (normalizedDeclared.startsWith("text/") && normalizedSniffed.startsWith("text/"))
458
+ return true;
459
+ if (normalizedDeclared === "audio/mpeg" && normalizedSniffed === "audio/mpeg")
460
+ return true;
461
+ if (normalizedDeclared === "audio/mp3" && normalizedSniffed === "audio/mpeg")
462
+ return true;
463
+ if (normalizedDeclared === "application/zip" && normalizedSniffed === "application/zip")
464
+ return true;
465
+ return false;
466
+ }
467
+ function normalizeHostname(hostname) {
468
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
469
+ return normalized.endsWith(".") ? normalized.slice(0, -1) : normalized;
470
+ }
471
+ function isBlockedIp(hostname) {
472
+ const normalized = normalizeHostname(hostname);
473
+ const family = isIP(normalized);
474
+ if (family === 4)
475
+ return isBlockedIpv4(normalized);
476
+ if (family === 6)
477
+ return isBlockedIpv6(normalized);
478
+ return false;
479
+ }
480
+ function isBlockedIpv4(address) {
481
+ const [a, b] = address.split(".").map(Number);
482
+ return a === 0
483
+ || a === 10
484
+ || a === 127
485
+ || (a === 100 && b >= 64 && b <= 127)
486
+ || (a === 169 && b === 254)
487
+ || (a === 172 && b >= 16 && b <= 31)
488
+ || (a === 192 && (b === 0 || b === 168))
489
+ || (a === 198 && (b === 18 || b === 19 || b === 51))
490
+ || (a === 203 && b === 0)
491
+ || a >= 224;
492
+ }
493
+ function isBlockedIpv6(address) {
494
+ const words = parseIpv6Words(address);
495
+ if (!words)
496
+ return true;
497
+ if (words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1)
498
+ return true;
499
+ if ((words[0] & 0xfe00) === 0xfc00)
500
+ return true;
501
+ if ((words[0] & 0xffc0) === 0xfe80 || (words[0] & 0xffc0) === 0xfec0)
502
+ return true;
503
+ if ((words[0] & 0xff00) === 0xff00)
504
+ return true;
505
+ if (words[0] === 0x2001 && words[1] === 0x0db8)
506
+ return true;
507
+ const mapped = words.slice(0, 5).every((word) => word === 0) && (words[5] === 0 || words[5] === 0xffff);
508
+ return mapped && isBlockedIpv4(`${words[6] >> 8}.${words[6] & 0xff}.${words[7] >> 8}.${words[7] & 0xff}`);
509
+ }
510
+ function parseIpv6Words(address) {
511
+ const parts = address.split("::");
512
+ if (parts.length > 2)
513
+ return undefined;
514
+ const left = parts[0] ? parts[0].split(":") : [];
515
+ const right = parts[1] ? parts[1].split(":") : [];
516
+ const missing = 8 - left.length - right.length;
517
+ if (missing < 0 || (parts.length === 1 && missing !== 0))
518
+ return undefined;
519
+ const words = [...left, ...Array.from({ length: missing }, () => "0"), ...right].map((part) => Number.parseInt(part, 16));
520
+ return words.length === 8 && words.every((word) => Number.isInteger(word) && word >= 0 && word <= 0xffff)
521
+ ? words
522
+ : undefined;
523
+ }
524
+ function startsWith(bytes, prefix) {
525
+ if (bytes.length < prefix.length)
526
+ return false;
527
+ return prefix.every((value, index) => bytes[index] === value);
528
+ }
529
+ function startsWithAscii(bytes, offset, text) {
530
+ if (bytes.length < offset + text.length)
531
+ return false;
532
+ for (let index = 0; index < text.length; index += 1) {
533
+ if (bytes[offset + index] !== text.charCodeAt(index))
534
+ return false;
535
+ }
536
+ return true;
537
+ }
538
+ //# sourceMappingURL=content.js.map