@skaile/transform-proxy 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1060 @@
1
+ import { createRequire } from "node:module";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { parse } from "yaml";
8
+ // src/log.ts
9
+ /** Default logger: drops everything. Hosts pass their own `Logger`. */
10
+ const silentLogger = {
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {}
15
+ };
16
+ function errorMessage(err) {
17
+ return err instanceof Error ? err.message : String(err);
18
+ }
19
+ // src/chain.ts
20
+ /**
21
+ * Composes the stages of one pipeline into a single transform.
22
+ * Outbound runs stage 1 to n, inbound and streamed inbound run stage n to 1.
23
+ * The empty list is the identity.
24
+ */
25
+ function composeTransforms(stages, log = silentLogger) {
26
+ const instructions = stages.flatMap((stage) => stage.instruction === void 0 ? [] : [stage.instruction]);
27
+ const reversed = [...stages].reverse();
28
+ return {
29
+ ...instructions.length > 0 ? { instruction: instructions.join("\n\n") } : {},
30
+ async outbound(text, leaf) {
31
+ let out = text;
32
+ for (const stage of stages) out = await stage.outbound(out, leaf);
33
+ return out;
34
+ },
35
+ inbound(text) {
36
+ let out = text;
37
+ for (const stage of reversed) out = stage.inbound(out);
38
+ return out;
39
+ },
40
+ inboundStream() {
41
+ return composeStreams(stages.map((stage) => stage.inboundStream()));
42
+ },
43
+ async dispose() {
44
+ for (const stage of stages) try {
45
+ await stage.dispose?.();
46
+ } catch (err) {
47
+ log.error(`transform dispose failed: ${errorMessage(err)}`);
48
+ }
49
+ }
50
+ };
51
+ }
52
+ /**
53
+ * Streams stage by stage. `streams` is in pipeline order (stage 1 first).
54
+ * push feeds stage n first and passes each result down to stage 1.
55
+ * flush goes n to 1: each stage's tail is pushed through the stages below it.
56
+ */
57
+ function composeStreams(streams) {
58
+ const reversed = [...streams].reverse();
59
+ return {
60
+ push(chunk) {
61
+ let out = chunk;
62
+ for (const stream of reversed) out = stream.push(out);
63
+ return out;
64
+ },
65
+ flush() {
66
+ let carry = "";
67
+ for (const stream of reversed) carry = (carry === "" ? "" : stream.push(carry)) + stream.flush();
68
+ return carry;
69
+ }
70
+ };
71
+ }
72
+ // src/facts.ts
73
+ function createSessionFacts() {
74
+ const bySource = /* @__PURE__ */ new Map();
75
+ let version = 0;
76
+ return {
77
+ facts: {
78
+ get version() {
79
+ return version;
80
+ },
81
+ list(kind) {
82
+ const all = [];
83
+ for (const facts of bySource.values()) for (const fact of facts) if (kind === void 0 || fact.kind === kind) all.push(fact);
84
+ return all;
85
+ }
86
+ },
87
+ publisher(source) {
88
+ return {
89
+ publish(facts) {
90
+ bySource.set(source, facts.map((fact) => ({
91
+ kind: fact.kind,
92
+ value: fact.value,
93
+ source
94
+ })));
95
+ version++;
96
+ },
97
+ clear() {
98
+ bySource.delete(source);
99
+ version++;
100
+ }
101
+ };
102
+ }
103
+ };
104
+ }
105
+ // src/modules.ts
106
+ /** Loads and opens every configured transform once. Any failure rejects with the transform id. */
107
+ async function openTransforms(specs, baseDir, log) {
108
+ const factories = /* @__PURE__ */ new Map();
109
+ for (const [id, spec] of Object.entries(specs)) {
110
+ const module = typeof spec.module === "string" ? await loadModule(id, spec.module, baseDir) : spec.module;
111
+ try {
112
+ factories.set(id, await module.open(spec.config, {
113
+ baseDir,
114
+ log
115
+ }));
116
+ } catch (err) {
117
+ throw new Error(`transform "${id}" failed to open: ${errorMessage(err)}`, { cause: err });
118
+ }
119
+ }
120
+ return factories;
121
+ }
122
+ async function loadModule(id, specifier, baseDir) {
123
+ let exported;
124
+ try {
125
+ exported = (await import(resolveSpecifier(specifier, baseDir))).default;
126
+ } catch (err) {
127
+ throw new Error(`transform "${id}": cannot load module ${specifier}: ${errorMessage(err)}`, { cause: err });
128
+ }
129
+ if (!isTransformModule(exported)) throw new Error(`transform "${id}": ${specifier} has no default TransformModule export`);
130
+ return exported;
131
+ }
132
+ /**
133
+ * Resolves a module specifier to a file URL. Relative and absolute paths resolve against `baseDir`.
134
+ * Package names resolve from `baseDir` by the `require` conditions first, then by the `import` condition.
135
+ */
136
+ function resolveSpecifier(specifier, baseDir) {
137
+ const root = resolve(baseDir);
138
+ if (specifier.startsWith(".") || isAbsolute(specifier)) return pathToFileURL(resolve(root, specifier)).href;
139
+ try {
140
+ return pathToFileURL(createRequire(join(root, "noop.cjs")).resolve(specifier)).href;
141
+ } catch (err) {
142
+ const fallback = resolveImportCondition(specifier, root);
143
+ if (fallback === void 0) throw err;
144
+ return pathToFileURL(fallback).href;
145
+ }
146
+ }
147
+ /** Walks up node_modules from `root` and resolves a package entry through its `import` condition. */
148
+ function resolveImportCondition(specifier, root) {
149
+ const parts = specifier.split("/");
150
+ const nameParts = specifier.startsWith("@") ? 2 : 1;
151
+ const name = parts.slice(0, nameParts).join("/");
152
+ const subpath = parts.length > nameParts ? `./${parts.slice(nameParts).join("/")}` : ".";
153
+ let dir = root;
154
+ for (;;) {
155
+ const pkgDir = join(dir, "node_modules", name);
156
+ const pkgJson = join(pkgDir, "package.json");
157
+ if (existsSync(pkgJson)) {
158
+ const pkg = JSON.parse(readFileSync(pkgJson, "utf8"));
159
+ const target = pkg.exports === void 0 ? subpath === "." ? pkg.main ?? "index.js" : void 0 : exportTarget(pkg.exports, subpath);
160
+ return target === void 0 ? void 0 : join(pkgDir, target);
161
+ }
162
+ const parent = dirname(dir);
163
+ if (parent === dir) return void 0;
164
+ dir = parent;
165
+ }
166
+ }
167
+ function exportTarget(exports, subpath) {
168
+ return conditionTarget((isRecord$1(exports) && Object.keys(exports).some((key) => key.startsWith(".")) ? exports : { ".": exports })[subpath]);
169
+ }
170
+ function conditionTarget(entry) {
171
+ if (typeof entry === "string") return entry;
172
+ if (!isRecord$1(entry)) return void 0;
173
+ return conditionTarget(entry.import) ?? conditionTarget(entry.default);
174
+ }
175
+ function isTransformModule(value) {
176
+ return isRecord$1(value) && typeof value.id === "string" && typeof value.open === "function";
177
+ }
178
+ function isRecord$1(value) {
179
+ return typeof value === "object" && value !== null && !Array.isArray(value);
180
+ }
181
+ // src/wire/json-args.ts
182
+ const SIMPLE_ESCAPES = {
183
+ "\"": "\"",
184
+ "\\": "\\",
185
+ "/": "/",
186
+ b: "\b",
187
+ f: "\f",
188
+ n: "\n",
189
+ r: "\r",
190
+ t: " "
191
+ };
192
+ /** The body of a JSON string for `text`, without the surrounding quotes. */
193
+ function escapeJsonBody(text) {
194
+ return JSON.stringify(text).slice(1, -1);
195
+ }
196
+ /**
197
+ * Streaming restorer over raw JSON text. `openString` yields a fresh restorer for each string
198
+ * value, so a placeholder never spans two values. Structural JSON outside strings passes through.
199
+ */
200
+ function createJsonArgRestorer(openString) {
201
+ let current;
202
+ let escape = "none";
203
+ let hex = "";
204
+ return {
205
+ push(fragment) {
206
+ let out = "";
207
+ for (const ch of fragment) {
208
+ if (current === void 0) {
209
+ out += ch;
210
+ if (ch === "\"") current = openString();
211
+ continue;
212
+ }
213
+ const restorer = current;
214
+ if (escape === "unicode") {
215
+ hex += ch;
216
+ if (hex.length === 4) {
217
+ out += escapeJsonBody(restorer.push(String.fromCharCode(Number.parseInt(hex, 16))));
218
+ hex = "";
219
+ escape = "none";
220
+ }
221
+ continue;
222
+ }
223
+ if (escape === "backslash") {
224
+ escape = "none";
225
+ if (ch === "u") {
226
+ escape = "unicode";
227
+ continue;
228
+ }
229
+ out += escapeJsonBody(restorer.push(SIMPLE_ESCAPES[ch] ?? ch));
230
+ continue;
231
+ }
232
+ if (ch === "\\") {
233
+ escape = "backslash";
234
+ continue;
235
+ }
236
+ if (ch === "\"") {
237
+ out += escapeJsonBody(restorer.flush()) + "\"";
238
+ current = void 0;
239
+ continue;
240
+ }
241
+ out += escapeJsonBody(restorer.push(ch));
242
+ }
243
+ return out;
244
+ },
245
+ flush() {
246
+ if (current === void 0) return "";
247
+ const tail = escapeJsonBody(current.flush());
248
+ current = void 0;
249
+ escape = "none";
250
+ hex = "";
251
+ return tail;
252
+ }
253
+ };
254
+ }
255
+ // src/wire/sse.ts
256
+ const DELIMITER = /\r?\n\r?\n/;
257
+ function parseSseBlock(raw) {
258
+ let event;
259
+ const data = [];
260
+ for (const line of raw.split(/\r?\n/)) if (line.startsWith("event:")) event = line.slice(6).replace(/^ /, "");
261
+ else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
262
+ return {
263
+ event,
264
+ data: data.join("\n"),
265
+ raw
266
+ };
267
+ }
268
+ /** Serializes one rewritten event. `data` is JSON-encoded; the event name is kept when the source had one. */
269
+ function formatSseEvent(event, data) {
270
+ return `${event === void 0 ? "" : `event: ${event}\n`}data: ${JSON.stringify(data)}\n\n`;
271
+ }
272
+ /**
273
+ * Builds an SSE transformer. `handle` returns the output for one complete event (usually
274
+ * `block.raw` or `formatSseEvent(...)`); `finish` returns output for state still open at end of stream.
275
+ */
276
+ function createSseFramer(handle, finish = () => "") {
277
+ let buffer = "";
278
+ return {
279
+ push(chunk) {
280
+ buffer += chunk;
281
+ let out = "";
282
+ for (let match = DELIMITER.exec(buffer); match !== null; match = DELIMITER.exec(buffer)) {
283
+ const end = match.index + match[0].length;
284
+ const raw = buffer.slice(0, end);
285
+ buffer = buffer.slice(end);
286
+ out += handle(parseSseBlock(raw));
287
+ }
288
+ return out;
289
+ },
290
+ flush() {
291
+ const rest = buffer;
292
+ buffer = "";
293
+ return (rest === "" ? "" : handle(parseSseBlock(rest + "\n\n"))) + finish();
294
+ }
295
+ };
296
+ }
297
+ // src/wire/types.ts
298
+ /** Thrown by `request` for attachments (image, document, file parts); the server answers 400 `unsupported_content`. */
299
+ var UnsupportedContentError = class extends Error {
300
+ code = "unsupported_content";
301
+ constructor(message) {
302
+ super(message);
303
+ this.name = "UnsupportedContentError";
304
+ }
305
+ };
306
+ // src/wire/anthropic.ts
307
+ const isObj = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
308
+ /** Block types that carry attachments the proxy cannot pseudonymize. */
309
+ const REFUSED_BLOCKS = {
310
+ image: true,
311
+ document: true,
312
+ search_result: true
313
+ };
314
+ const signatureKey = (signature) => `anthropic:${signature}`;
315
+ function refuse(block) {
316
+ if (typeof block.type === "string" && REFUSED_BLOCKS[block.type] === true) throw new UnsupportedContentError(`Anthropic ${block.type} content cannot be transformed`);
317
+ }
318
+ async function outbound(transform, text, leaf) {
319
+ return text === "" ? text : transform.outbound(text, leaf);
320
+ }
321
+ /** A string, or an array of blocks whose `text` blocks are rewritten. */
322
+ async function rewriteText(content, leaf, transform) {
323
+ if (typeof content === "string") return outbound(transform, content, leaf);
324
+ if (!Array.isArray(content)) return content;
325
+ const out = [];
326
+ for (const block of content) {
327
+ if (!isObj(block)) {
328
+ out.push(block);
329
+ continue;
330
+ }
331
+ refuse(block);
332
+ out.push(block.type === "text" && typeof block.text === "string" ? {
333
+ ...block,
334
+ text: await outbound(transform, block.text, leaf)
335
+ } : block);
336
+ }
337
+ return out;
338
+ }
339
+ /** Every string leaf of a JSON value (tool-call input); keys stay as they are. */
340
+ async function rewriteStrings(value, leaf, transform) {
341
+ if (typeof value === "string") return outbound(transform, value, leaf);
342
+ if (Array.isArray(value)) {
343
+ const out = [];
344
+ for (const item of value) out.push(await rewriteStrings(item, leaf, transform));
345
+ return out;
346
+ }
347
+ if (isObj(value)) {
348
+ const out = {};
349
+ for (const [key, item] of Object.entries(value)) out[key] = await rewriteStrings(item, leaf, transform);
350
+ return out;
351
+ }
352
+ return value;
353
+ }
354
+ /** Every string leaf of a JSON value, restored inbound. */
355
+ function restoreStrings(value, transform) {
356
+ if (typeof value === "string") return transform.inbound(value);
357
+ if (Array.isArray(value)) return value.map((item) => restoreStrings(item, transform));
358
+ if (isObj(value)) {
359
+ const out = {};
360
+ for (const [key, item] of Object.entries(value)) out[key] = restoreStrings(item, transform);
361
+ return out;
362
+ }
363
+ return value;
364
+ }
365
+ /** A thinking block: swaps in the cached model text when its signature is known, else rewrites it. */
366
+ async function rewriteThinking(block, ctx) {
367
+ if (typeof block.signature === "string") {
368
+ const cached = ctx.signatures.get(signatureKey(block.signature));
369
+ if (cached !== void 0) return {
370
+ ...block,
371
+ thinking: cached
372
+ };
373
+ }
374
+ if (typeof block.thinking !== "string") return block;
375
+ return {
376
+ ...block,
377
+ thinking: await outbound(ctx.transform, block.thinking, { kind: "thinking" })
378
+ };
379
+ }
380
+ async function rewriteContent(content, role, toolNames, ctx) {
381
+ const textKind = role === "assistant" ? "assistant" : "user";
382
+ if (typeof content === "string") return outbound(ctx.transform, content, { kind: textKind });
383
+ if (!Array.isArray(content)) return content;
384
+ const out = [];
385
+ for (const block of content) {
386
+ if (!isObj(block)) {
387
+ out.push(block);
388
+ continue;
389
+ }
390
+ refuse(block);
391
+ switch (block.type) {
392
+ case "text":
393
+ out.push(typeof block.text === "string" ? {
394
+ ...block,
395
+ text: await outbound(ctx.transform, block.text, { kind: textKind })
396
+ } : block);
397
+ break;
398
+ case "thinking":
399
+ out.push(await rewriteThinking(block, ctx));
400
+ break;
401
+ case "tool_use": {
402
+ const toolName = typeof block.name === "string" ? block.name : void 0;
403
+ out.push({
404
+ ...block,
405
+ input: await rewriteStrings(block.input, {
406
+ kind: "toolCall",
407
+ toolName
408
+ }, ctx.transform)
409
+ });
410
+ break;
411
+ }
412
+ case "tool_result": {
413
+ const toolName = typeof block.tool_use_id === "string" ? toolNames.get(block.tool_use_id) : void 0;
414
+ out.push({
415
+ ...block,
416
+ content: await rewriteText(block.content, {
417
+ kind: "toolResult",
418
+ toolName
419
+ }, ctx.transform)
420
+ });
421
+ break;
422
+ }
423
+ default: out.push(block);
424
+ }
425
+ }
426
+ return out;
427
+ }
428
+ /** Maps each `tool_use` id to its tool name, so tool results can name their tool. */
429
+ function toolNamesById(messages) {
430
+ const names = /* @__PURE__ */ new Map();
431
+ for (const message of messages) {
432
+ if (!isObj(message) || !Array.isArray(message.content)) continue;
433
+ for (const block of message.content) if (isObj(block) && block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") names.set(block.id, block.name);
434
+ }
435
+ return names;
436
+ }
437
+ function withInstruction(system, instruction) {
438
+ if (system === void 0) return instruction;
439
+ if (typeof system === "string") return `${instruction}\n\n${system}`;
440
+ if (Array.isArray(system)) return [{
441
+ type: "text",
442
+ text: instruction
443
+ }, ...system];
444
+ return system;
445
+ }
446
+ function newBlockState(kind, transform) {
447
+ return {
448
+ kind,
449
+ restore: kind === "tool" ? createJsonArgRestorer(() => transform.inboundStream()) : transform.inboundStream(),
450
+ raw: ""
451
+ };
452
+ }
453
+ /** The delta that carries a block's held-back tail, shaped for the block's kind. */
454
+ function tailDelta(kind, tail) {
455
+ if (kind === "text") return {
456
+ type: "text_delta",
457
+ text: tail
458
+ };
459
+ if (kind === "thinking") return {
460
+ type: "thinking_delta",
461
+ thinking: tail
462
+ };
463
+ return {
464
+ type: "input_json_delta",
465
+ partial_json: tail
466
+ };
467
+ }
468
+ function createStreamTransformer(ctx) {
469
+ const blocks = /* @__PURE__ */ new Map();
470
+ const handle = (block) => {
471
+ let payload;
472
+ try {
473
+ payload = JSON.parse(block.data);
474
+ } catch {
475
+ return block.raw;
476
+ }
477
+ if (!isObj(payload)) return block.raw;
478
+ const index = typeof payload.index === "number" ? payload.index : void 0;
479
+ switch (payload.type) {
480
+ case "content_block_start": {
481
+ const content = payload.content_block;
482
+ if (index === void 0 || !isObj(content)) return block.raw;
483
+ if (content.type === "text" || content.type === "thinking" || content.type === "tool_use") {
484
+ const kind = content.type === "tool_use" ? "tool" : content.type;
485
+ blocks.set(index, newBlockState(kind, ctx.transform));
486
+ }
487
+ return block.raw;
488
+ }
489
+ case "content_block_delta": {
490
+ const state = index === void 0 ? void 0 : blocks.get(index);
491
+ const delta = payload.delta;
492
+ if (state === void 0 || !isObj(delta)) return block.raw;
493
+ if (state.kind === "text" && delta.type === "text_delta" && typeof delta.text === "string") return formatSseEvent(block.event, {
494
+ ...payload,
495
+ delta: {
496
+ ...delta,
497
+ text: state.restore.push(delta.text)
498
+ }
499
+ });
500
+ if (state.kind === "thinking" && delta.type === "thinking_delta" && typeof delta.thinking === "string") {
501
+ state.raw += delta.thinking;
502
+ return formatSseEvent(block.event, {
503
+ ...payload,
504
+ delta: {
505
+ ...delta,
506
+ thinking: state.restore.push(delta.thinking)
507
+ }
508
+ });
509
+ }
510
+ if (state.kind === "thinking" && delta.type === "signature_delta" && typeof delta.signature === "string") {
511
+ ctx.signatures.set(signatureKey(delta.signature), state.raw);
512
+ return block.raw;
513
+ }
514
+ if (state.kind === "tool" && delta.type === "input_json_delta" && typeof delta.partial_json === "string") return formatSseEvent(block.event, {
515
+ ...payload,
516
+ delta: {
517
+ ...delta,
518
+ partial_json: state.restore.push(delta.partial_json)
519
+ }
520
+ });
521
+ return block.raw;
522
+ }
523
+ case "content_block_stop": {
524
+ const state = index === void 0 ? void 0 : blocks.get(index);
525
+ if (index === void 0 || state === void 0) return block.raw;
526
+ blocks.delete(index);
527
+ const tail = state.restore.flush();
528
+ if (tail === "") return block.raw;
529
+ return formatSseEvent("content_block_delta", {
530
+ type: "content_block_delta",
531
+ index,
532
+ delta: tailDelta(state.kind, tail)
533
+ }) + block.raw;
534
+ }
535
+ default: return block.raw;
536
+ }
537
+ };
538
+ const finish = () => {
539
+ let out = "";
540
+ for (const [index, state] of blocks) {
541
+ const tail = state.restore.flush();
542
+ if (tail !== "") out += formatSseEvent("content_block_delta", {
543
+ type: "content_block_delta",
544
+ index,
545
+ delta: tailDelta(state.kind, tail)
546
+ });
547
+ }
548
+ blocks.clear();
549
+ return out;
550
+ };
551
+ return createSseFramer(handle, finish);
552
+ }
553
+ const anthropicWalker = {
554
+ async request(body, ctx) {
555
+ if (!isObj(body)) return body;
556
+ const out = { ...body };
557
+ if (body.system !== void 0) out.system = await rewriteText(body.system, { kind: "system" }, ctx.transform);
558
+ if (ctx.transform.instruction !== void 0) out.system = withInstruction(out.system, ctx.transform.instruction);
559
+ if (Array.isArray(body.messages)) {
560
+ const toolNames = toolNamesById(body.messages);
561
+ const messages = [];
562
+ for (const message of body.messages) messages.push(isObj(message) ? {
563
+ ...message,
564
+ content: await rewriteContent(message.content, message.role, toolNames, ctx)
565
+ } : message);
566
+ out.messages = messages;
567
+ }
568
+ return out;
569
+ },
570
+ response(body, ctx) {
571
+ if (!isObj(body) || !Array.isArray(body.content)) return body;
572
+ const content = body.content.map((block) => {
573
+ if (!isObj(block)) return block;
574
+ if (block.type === "text" && typeof block.text === "string") return {
575
+ ...block,
576
+ text: ctx.transform.inbound(block.text)
577
+ };
578
+ if (block.type === "thinking" && typeof block.thinking === "string") {
579
+ if (typeof block.signature === "string") ctx.signatures.set(signatureKey(block.signature), block.thinking);
580
+ return {
581
+ ...block,
582
+ thinking: ctx.transform.inbound(block.thinking)
583
+ };
584
+ }
585
+ if (block.type === "tool_use") return {
586
+ ...block,
587
+ input: restoreStrings(block.input, ctx.transform)
588
+ };
589
+ return block;
590
+ });
591
+ return {
592
+ ...body,
593
+ content
594
+ };
595
+ },
596
+ stream(ctx) {
597
+ return createStreamTransformer(ctx);
598
+ }
599
+ };
600
+ // src/routes.ts
601
+ /**
602
+ * The route table. Routes are matched in order, so longer suffixes come first.
603
+ * Add the OpenAI walkers here (`/chat/completions`, `/responses`) when they exist. Until then
604
+ * those paths, and embeddings, answer 404 `unsupported_route`.
605
+ */
606
+ const WIRE_ROUTES = [{
607
+ suffix: "/messages/count_tokens",
608
+ wire: anthropicWalker,
609
+ rewriteResponse: false
610
+ }, {
611
+ suffix: "/messages",
612
+ wire: anthropicWalker,
613
+ rewriteResponse: true
614
+ }];
615
+ /** Classifies a request by method and path. `undefined` means the route is unsupported. */
616
+ function classify(method, pathname) {
617
+ if (method === "GET" && pathname.endsWith("/models")) return { kind: "passthrough" };
618
+ if (method !== "POST") return void 0;
619
+ const route = WIRE_ROUTES.find((candidate) => pathname.endsWith(candidate.suffix));
620
+ return route === void 0 ? void 0 : {
621
+ kind: "wire",
622
+ route
623
+ };
624
+ }
625
+ // src/sessions.ts
626
+ function createSessionState() {
627
+ return {
628
+ facts: createSessionFacts(),
629
+ signatures: /* @__PURE__ */ new Map(),
630
+ instances: /* @__PURE__ */ new Map()
631
+ };
632
+ }
633
+ /**
634
+ * Returns the session's instance for a pipeline, building it on first use.
635
+ * Concurrent first calls share one build; a failed build is forgotten so the next call retries.
636
+ */
637
+ function instanceFor(state, pipelineId, build) {
638
+ const existing = state.instances.get(pipelineId);
639
+ if (existing) return existing;
640
+ const pending = build().catch((err) => {
641
+ if (state.instances.get(pipelineId) === pending) state.instances.delete(pipelineId);
642
+ throw err;
643
+ });
644
+ state.instances.set(pipelineId, pending);
645
+ return pending;
646
+ }
647
+ /** Disposes every instance of a session, waiting for pending builds first. Dispose errors are logged. */
648
+ async function disposeInstances(state, log) {
649
+ const settled = await Promise.allSettled([...state.instances.values()]);
650
+ state.instances.clear();
651
+ for (const result of settled) {
652
+ if (result.status !== "fulfilled") continue;
653
+ try {
654
+ await result.value.dispose?.();
655
+ } catch (err) {
656
+ log.error(`transform dispose failed: ${errorMessage(err)}`);
657
+ }
658
+ }
659
+ }
660
+ // src/server.ts
661
+ const UPSTREAM_HEADER = "x-skaile-transform-upstream";
662
+ const SESSION_HEADER = "x-skaile-transform-session";
663
+ const PIPELINE_HEADER = "x-skaile-transform-pipeline";
664
+ const TOKEN_HEADER = "x-skaile-transform-token";
665
+ const DEFAULT_SESSION = "default";
666
+ const LOOPBACK_HOSTS = {
667
+ "127.0.0.1": true,
668
+ localhost: true,
669
+ "::1": true,
670
+ "[::1]": true
671
+ };
672
+ /** Connection-level headers: never forwarded in either direction. */
673
+ const HOP_BY_HOP = {
674
+ connection: true,
675
+ "keep-alive": true,
676
+ "proxy-connection": true,
677
+ te: true,
678
+ trailer: true,
679
+ "transfer-encoding": true,
680
+ upgrade: true
681
+ };
682
+ const REQUEST_SKIP = {
683
+ ...HOP_BY_HOP,
684
+ host: true,
685
+ "content-length": true,
686
+ "accept-encoding": true
687
+ };
688
+ /** Response headers the proxy re-derives: the body length and encoding after rewriting, and set-cookie (sent via getSetCookie). */
689
+ const RESPONSE_SKIP = {
690
+ ...HOP_BY_HOP,
691
+ "content-length": true,
692
+ "content-encoding": true,
693
+ "set-cookie": true
694
+ };
695
+ async function createTransformProxy(options) {
696
+ const log = options.log ?? silentLogger;
697
+ const host = options.host ?? "127.0.0.1";
698
+ if (!Object.hasOwn(LOOPBACK_HOSTS, host) && options.token === void 0) throw new Error(`transform proxy host ${host} is not loopback: a token is required`);
699
+ validatePipelines(options);
700
+ const allowed = options.allowUpstreams === void 0 ? void 0 : new Set(options.allowUpstreams.map(originOf));
701
+ const factories = await openTransforms(options.transforms, options.baseDir, log);
702
+ const pipelines = options.pipelines;
703
+ const sessions = /* @__PURE__ */ new Map();
704
+ function stateFor(id) {
705
+ const existing = sessions.get(id);
706
+ if (existing) return existing;
707
+ const created = createSessionState();
708
+ sessions.set(id, created);
709
+ return created;
710
+ }
711
+ async function buildInstance(state, sessionId, pipelineId) {
712
+ const stages = [];
713
+ const ids = pipelines[pipelineId] ?? [];
714
+ try {
715
+ for (const id of ids) {
716
+ const factory = factories.get(id);
717
+ if (factory === void 0) throw new Error(`transform "${id}" is not configured`);
718
+ try {
719
+ stages.push(await factory({
720
+ sessionId,
721
+ facts: state.facts.facts,
722
+ log
723
+ }));
724
+ } catch (err) {
725
+ throw new Error(`transform "${id}" failed to start: ${errorMessage(err)}`, { cause: err });
726
+ }
727
+ }
728
+ } catch (err) {
729
+ for (const stage of stages) try {
730
+ await stage.dispose?.();
731
+ } catch (disposeErr) {
732
+ log.error(`transform dispose failed: ${errorMessage(disposeErr)}`);
733
+ }
734
+ throw err;
735
+ }
736
+ return composeTransforms(stages, log);
737
+ }
738
+ async function handle(req, res) {
739
+ const url = new URL(req.url ?? "/", "http://proxy.invalid");
740
+ if (options.token !== void 0 && !tokenMatches(header(req, "x-skaile-transform-token"), options.token)) return sendError(res, 401, "unauthorized", `${TOKEN_HEADER} is missing or wrong`);
741
+ if (req.method === "OPTIONS") return sendError(res, 405, "method_not_allowed", "OPTIONS is refused");
742
+ const upstreamValue = header(req, UPSTREAM_HEADER);
743
+ if (upstreamValue === void 0) return sendError(res, 400, "missing_upstream", `${UPSTREAM_HEADER} is required`);
744
+ const upstream = parseHttpUrl(upstreamValue);
745
+ if (upstream === void 0) return sendError(res, 400, "invalid_upstream", `${UPSTREAM_HEADER} must be an http(s) URL`);
746
+ if (!upstreamAllowed(upstream, allowed)) return sendError(res, 403, "upstream_not_allowed", `upstream ${upstream.origin} is not allowed`);
747
+ const sessionId = header(req, "x-skaile-transform-session") ?? DEFAULT_SESSION;
748
+ const pipelineId = header(req, "x-skaile-transform-pipeline") ?? options.defaultPipeline;
749
+ if (pipelineId === void 0) return sendError(res, 400, "no_pipeline", `${PIPELINE_HEADER} is required`);
750
+ if (!Object.hasOwn(pipelines, pipelineId)) return sendError(res, 400, "unknown_pipeline", `pipeline "${pipelineId}" is not configured`);
751
+ const method = req.method ?? "GET";
752
+ const classified = classify(method, url.pathname);
753
+ if (classified === void 0) return sendError(res, 404, "unsupported_route", `${method} ${url.pathname} is not proxied`);
754
+ const forwardTo = `${upstream.origin}${upstream.pathname.replace(/\/+$/, "")}${url.pathname}${url.search}`;
755
+ const headers = forwardRequestHeaders(req.headers);
756
+ if (classified.kind === "passthrough") return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, void 0), void 0);
757
+ const { route } = classified;
758
+ const encoding = header(req, "content-encoding");
759
+ if (encoding !== void 0 && encoding !== "identity") return sendError(res, 400, "unsupported_encoding", "request bodies must not be content-encoded");
760
+ const raw = await readBody(req);
761
+ let parsed;
762
+ try {
763
+ parsed = JSON.parse(raw.toString("utf8"));
764
+ } catch {
765
+ return sendError(res, 400, "invalid_json", "request body is not JSON");
766
+ }
767
+ const state = stateFor(sessionId);
768
+ let instance;
769
+ try {
770
+ instance = await instanceFor(state, pipelineId, () => buildInstance(state, sessionId, pipelineId));
771
+ } catch (err) {
772
+ return sendError(res, 502, "transform_failed", errorMessage(err));
773
+ }
774
+ const ctx = {
775
+ transform: instance,
776
+ signatures: state.signatures
777
+ };
778
+ let rewritten;
779
+ try {
780
+ rewritten = await route.wire.request(parsed, ctx);
781
+ } catch (err) {
782
+ if (err instanceof UnsupportedContentError) return sendError(res, 400, "unsupported_content", err.message);
783
+ return sendError(res, 502, "transform_failed", errorMessage(err));
784
+ }
785
+ return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, JSON.stringify(rewritten)), route.rewriteResponse ? {
786
+ wire: route.wire,
787
+ ctx
788
+ } : void 0);
789
+ }
790
+ async function fetchUpstream(res, target, method, headers, body) {
791
+ try {
792
+ return await fetch(target, {
793
+ method,
794
+ headers,
795
+ body,
796
+ redirect: "manual"
797
+ });
798
+ } catch (err) {
799
+ sendError(res, 502, "upstream_unreachable", errorMessage(err));
800
+ return;
801
+ }
802
+ }
803
+ /** Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. */
804
+ async function relayUpstream(res, upstreamRes, rewrite) {
805
+ if (upstreamRes === void 0) return;
806
+ const contentType = upstreamRes.headers.get("content-type") ?? "";
807
+ const headers = relayHeaders(upstreamRes.headers);
808
+ if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
809
+ if (rewrite !== void 0 && upstreamRes.ok && contentType.includes("application/json")) {
810
+ const text = await upstreamRes.text();
811
+ let restored;
812
+ try {
813
+ restored = JSON.stringify(rewrite.wire.response(JSON.parse(text), rewrite.ctx));
814
+ } catch (err) {
815
+ return sendError(res, 502, "transform_failed", errorMessage(err));
816
+ }
817
+ res.writeHead(upstreamRes.status, headers);
818
+ res.end(restored);
819
+ return;
820
+ }
821
+ res.writeHead(upstreamRes.status, headers);
822
+ res.end(Buffer.from(await upstreamRes.arrayBuffer()));
823
+ }
824
+ const server = createServer((req, res) => {
825
+ handle(req, res).catch((err) => {
826
+ log.error(`request failed: ${errorMessage(err)}`);
827
+ sendError(res, 500, "internal_error", "transform proxy failed");
828
+ });
829
+ });
830
+ const listening = Promise.withResolvers();
831
+ server.once("error", listening.reject);
832
+ server.listen(options.port ?? 0, host, () => {
833
+ server.off("error", listening.reject);
834
+ listening.resolve();
835
+ });
836
+ await listening.promise;
837
+ const address = server.address();
838
+ return {
839
+ url: `http://${host.includes(":") ? `[${host}]` : host}:${address.port}`,
840
+ session(id) {
841
+ const state = stateFor(id);
842
+ return {
843
+ facts: state.facts.facts,
844
+ publisher: (source) => state.facts.publisher(source)
845
+ };
846
+ },
847
+ async dropSession(id) {
848
+ const state = sessions.get(id);
849
+ if (state === void 0) return;
850
+ sessions.delete(id);
851
+ await disposeInstances(state, log);
852
+ },
853
+ async close() {
854
+ const closed = Promise.withResolvers();
855
+ server.close((err) => err ? closed.reject(err) : closed.resolve());
856
+ server.closeAllConnections();
857
+ await closed.promise;
858
+ for (const id of [...sessions.keys()]) {
859
+ const state = sessions.get(id);
860
+ sessions.delete(id);
861
+ if (state !== void 0) await disposeInstances(state, log);
862
+ }
863
+ }
864
+ };
865
+ }
866
+ function validatePipelines(options) {
867
+ for (const [pipelineId, ids] of Object.entries(options.pipelines)) for (const id of ids) if (!Object.hasOwn(options.transforms, id)) throw new Error(`pipeline "${pipelineId}" names unknown transform "${id}"`);
868
+ if (options.defaultPipeline !== void 0 && !Object.hasOwn(options.pipelines, options.defaultPipeline)) throw new Error(`defaultPipeline "${options.defaultPipeline}" is not a configured pipeline`);
869
+ }
870
+ function originOf(value) {
871
+ const url = parseHttpUrl(value);
872
+ if (url === void 0) throw new Error(`allowUpstreams entry "${value}" is not an http(s) URL`);
873
+ return url.origin;
874
+ }
875
+ function parseHttpUrl(value) {
876
+ try {
877
+ const url = new URL(value);
878
+ return url.protocol === "https:" || url.protocol === "http:" ? url : void 0;
879
+ } catch {
880
+ return;
881
+ }
882
+ }
883
+ function upstreamAllowed(url, allowed) {
884
+ if (allowed !== void 0) return allowed.has(url.origin);
885
+ return url.protocol === "https:" || url.protocol === "http:" && Object.hasOwn(LOOPBACK_HOSTS, url.hostname);
886
+ }
887
+ function tokenMatches(given, expected) {
888
+ if (given === void 0) return false;
889
+ const a = Buffer.from(given);
890
+ const b = Buffer.from(expected);
891
+ return a.length === b.length && timingSafeEqual(a, b);
892
+ }
893
+ function header(req, name) {
894
+ const value = req.headers[name];
895
+ const first = Array.isArray(value) ? value[0] : value;
896
+ return first === void 0 || first === "" ? void 0 : first;
897
+ }
898
+ function forwardRequestHeaders(incoming) {
899
+ const out = new Headers();
900
+ for (const [name, value] of Object.entries(incoming)) {
901
+ if (value === void 0 || name.startsWith("x-skaile-transform-") || Object.hasOwn(REQUEST_SKIP, name)) continue;
902
+ for (const item of Array.isArray(value) ? value : [value]) out.append(name, item);
903
+ }
904
+ out.set("accept-encoding", "identity");
905
+ return out;
906
+ }
907
+ function relayHeaders(headers) {
908
+ const out = {};
909
+ headers.forEach((value, name) => {
910
+ if (!Object.hasOwn(RESPONSE_SKIP, name)) out[name] = value;
911
+ });
912
+ const cookies = headers.getSetCookie();
913
+ if (cookies.length > 0) out["set-cookie"] = cookies;
914
+ return out;
915
+ }
916
+ async function readBody(req) {
917
+ const chunks = [];
918
+ for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
919
+ return Buffer.concat(chunks);
920
+ }
921
+ /** Streams an SSE body through the transformer, chunk by chunk, decoding UTF-8 across chunk boundaries. */
922
+ async function pipeStream(res, upstreamRes, headers, transformer, log) {
923
+ res.writeHead(upstreamRes.status, headers);
924
+ const reader = upstreamRes.body?.getReader();
925
+ if (reader === void 0) {
926
+ res.end();
927
+ return;
928
+ }
929
+ const decoder = new TextDecoder();
930
+ try {
931
+ for (;;) {
932
+ const { done, value } = await reader.read();
933
+ if (done) break;
934
+ await writeText(res, transformer.push(decoder.decode(value, { stream: true })));
935
+ if (res.destroyed) {
936
+ await reader.cancel();
937
+ return;
938
+ }
939
+ }
940
+ await writeText(res, transformer.push(decoder.decode()) + transformer.flush());
941
+ res.end();
942
+ } catch (err) {
943
+ log.error(`stream relay failed: ${errorMessage(err)}`);
944
+ res.destroy();
945
+ }
946
+ }
947
+ function writeText(res, text) {
948
+ if (text === "" || res.write(text)) return Promise.resolve();
949
+ const drained = Promise.withResolvers();
950
+ const done = () => {
951
+ res.off("drain", done);
952
+ res.off("close", done);
953
+ drained.resolve();
954
+ };
955
+ res.once("drain", done);
956
+ res.once("close", done);
957
+ return drained.promise;
958
+ }
959
+ /** Writes the error envelope `{ error: { type, message } }`. */
960
+ function sendError(res, status, type, message) {
961
+ if (res.headersSent) {
962
+ res.destroy();
963
+ return;
964
+ }
965
+ res.writeHead(status, { "content-type": "application/json" });
966
+ res.end(JSON.stringify({ error: {
967
+ type,
968
+ message
969
+ } }));
970
+ }
971
+ // src/config.ts
972
+ const CONFIG_FILE_NAME = "transform-proxy.yaml";
973
+ /** The implicit lookup locations, in order: `./.skaile/` then `~/.config/skaile/`. */
974
+ function configCandidates(cwd, home) {
975
+ return [join(cwd, ".skaile", CONFIG_FILE_NAME), join(home, ".config", "skaile", CONFIG_FILE_NAME)];
976
+ }
977
+ /**
978
+ * Finds the config file: the explicit path when given (it must exist), otherwise the first
979
+ * existing implicit candidate. Returns `undefined` when no implicit candidate exists.
980
+ */
981
+ function findProxyConfig(lookup) {
982
+ if (lookup.explicit !== void 0) {
983
+ const path = resolve(lookup.cwd, lookup.explicit);
984
+ if (!existsSync(path)) throw new Error(`transform proxy config not found: ${path}`);
985
+ return path;
986
+ }
987
+ return configCandidates(lookup.cwd, lookup.home).find((candidate) => existsSync(candidate));
988
+ }
989
+ /** Finds and loads the config. Throws, naming the searched paths, when none is found. */
990
+ function resolveProxyConfig(lookup) {
991
+ const path = findProxyConfig(lookup);
992
+ if (path === void 0) {
993
+ const searched = configCandidates(lookup.cwd, lookup.home).join(", ");
994
+ throw new Error(`no transform proxy config found (pass --config, or create one at ${searched})`);
995
+ }
996
+ return {
997
+ path,
998
+ options: loadProxyConfig(path)
999
+ };
1000
+ }
1001
+ const TOP_LEVEL_KEYS = {
1002
+ transforms: true,
1003
+ pipelines: true,
1004
+ defaultPipeline: true,
1005
+ allowUpstreams: true
1006
+ };
1007
+ /** Reads and validates a YAML config file. Unknown keys are rejected so that typos fail loudly. */
1008
+ function loadProxyConfig(path) {
1009
+ const source = parse(readFileSync(path, "utf8"));
1010
+ try {
1011
+ return parseProxyConfig(source ?? {}, dirname(path));
1012
+ } catch (err) {
1013
+ throw new Error(`invalid transform proxy config ${path}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1014
+ }
1015
+ }
1016
+ /** Validates an already-parsed config object. Exported for hosts that load YAML themselves. */
1017
+ function parseProxyConfig(source, baseDir) {
1018
+ if (!isRecord(source)) throw new Error("the document must be a mapping");
1019
+ for (const key of Object.keys(source)) if (!Object.hasOwn(TOP_LEVEL_KEYS, key)) throw new Error(`unknown key "${key}"`);
1020
+ return {
1021
+ baseDir,
1022
+ transforms: parseTransforms(source.transforms),
1023
+ pipelines: parsePipelines(source.pipelines),
1024
+ ...source.defaultPipeline === void 0 ? {} : { defaultPipeline: requireString(source.defaultPipeline, "defaultPipeline") },
1025
+ ...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") }
1026
+ };
1027
+ }
1028
+ function parseTransforms(value) {
1029
+ if (value === void 0) return {};
1030
+ if (!isRecord(value)) throw new Error("transforms must be a mapping of transform id to { module, config }");
1031
+ const out = {};
1032
+ for (const [id, spec] of Object.entries(value)) {
1033
+ if (!isRecord(spec)) throw new Error(`transforms.${id} must be a mapping with a module`);
1034
+ const module = requireString(spec.module, `transforms.${id}.module`);
1035
+ out[id] = spec.config === void 0 ? { module } : {
1036
+ module,
1037
+ config: spec.config
1038
+ };
1039
+ }
1040
+ return out;
1041
+ }
1042
+ function parsePipelines(value) {
1043
+ if (value === void 0) return {};
1044
+ if (!isRecord(value)) throw new Error("pipelines must be a mapping of pipeline id to a list of transform ids");
1045
+ const out = {};
1046
+ for (const [id, stages] of Object.entries(value)) out[id] = requireStringList(stages, `pipelines.${id}`);
1047
+ return out;
1048
+ }
1049
+ function requireString(value, field) {
1050
+ if (typeof value !== "string" || value === "") throw new Error(`${field} must be a non-empty string`);
1051
+ return value;
1052
+ }
1053
+ function requireStringList(value, field) {
1054
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item === "")) throw new Error(`${field} must be a list of non-empty strings`);
1055
+ return value;
1056
+ }
1057
+ function isRecord(value) {
1058
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1059
+ }
1060
+ export { silentLogger as _, parseProxyConfig as a, SESSION_HEADER as c, createTransformProxy as d, anthropicWalker as f, errorMessage as g, composeTransforms as h, loadProxyConfig as i, TOKEN_HEADER as l, createSessionFacts as m, configCandidates as n, resolveProxyConfig as o, UnsupportedContentError as p, findProxyConfig as r, PIPELINE_HEADER as s, CONFIG_FILE_NAME as t, UPSTREAM_HEADER as u };