@volter/twin 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.
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/src/actions.d.ts +138 -0
- package/dist/src/actions.js +201 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +425 -0
- package/dist/src/connector.d.ts +106 -0
- package/dist/src/connector.js +129 -0
- package/dist/src/control-plane.d.ts +21 -0
- package/dist/src/control-plane.js +40 -0
- package/dist/src/egress.d.ts +93 -0
- package/dist/src/egress.js +264 -0
- package/dist/src/fork.d.ts +126 -0
- package/dist/src/fork.js +206 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +52 -0
- package/dist/src/lease.d.ts +50 -0
- package/dist/src/lease.js +80 -0
- package/dist/src/packRegistry.d.ts +34 -0
- package/dist/src/packRegistry.js +22 -0
- package/dist/src/plan.d.ts +97 -0
- package/dist/src/plan.js +151 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +152 -0
- package/dist/src/pushLedger.d.ts +81 -0
- package/dist/src/pushLedger.js +130 -0
- package/dist/src/queueLifecycle.d.ts +62 -0
- package/dist/src/queueLifecycle.js +95 -0
- package/dist/src/reconcile.d.ts +58 -0
- package/dist/src/reconcile.js +137 -0
- package/dist/src/refs.d.ts +29 -0
- package/dist/src/refs.js +68 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve.d.ts +44 -0
- package/dist/src/serve.js +93 -0
- package/dist/src/shadow.d.ts +77 -0
- package/dist/src/shadow.js +138 -0
- package/dist/src/status.d.ts +31 -0
- package/dist/src/status.js +42 -0
- package/dist/src/storage.d.ts +119 -0
- package/dist/src/storage.js +535 -0
- package/dist/src/sync.d.ts +91 -0
- package/dist/src/sync.js +121 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validate.d.ts +27 -0
- package/dist/src/validate.js +68 -0
- package/dist/src/visualizer.d.ts +13 -0
- package/dist/src/visualizer.js +133 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +16 -0
- package/inject.cjs +429 -0
- package/package.json +81 -0
- package/src/actions.ts +285 -0
- package/src/args.ts +14 -0
- package/src/cli.ts +443 -0
- package/src/connector.ts +220 -0
- package/src/control-plane.ts +66 -0
- package/src/egress.ts +355 -0
- package/src/fork.ts +256 -0
- package/src/index.ts +222 -0
- package/src/lease.ts +97 -0
- package/src/packRegistry.ts +60 -0
- package/src/plan.ts +190 -0
- package/src/proxy.ts +180 -0
- package/src/pushLedger.ts +189 -0
- package/src/queueLifecycle.ts +130 -0
- package/src/reconcile.ts +192 -0
- package/src/refs.ts +91 -0
- package/src/schemas.ts +56 -0
- package/src/serve.ts +120 -0
- package/src/shadow.ts +192 -0
- package/src/status.ts +58 -0
- package/src/storage.ts +632 -0
- package/src/sync.ts +160 -0
- package/src/types.ts +50 -0
- package/src/validate.ts +95 -0
- package/src/visualizer.ts +142 -0
- package/src/worldConfig.ts +26 -0
package/inject.cjs
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// @volter/twin/inject — zero-edit backend injection.
|
|
3
|
+
//
|
|
4
|
+
// Redirect a process's calls to real vendor hosts (api.stripe.com, slack.com,
|
|
5
|
+
// api.github.com, *.atlassian.net, api.linear.app, S3 endpoints, and other
|
|
6
|
+
// configured vendor API hosts) to local twins, WITHOUT
|
|
7
|
+
// changing the app: the app constructs its SDK exactly as in production.
|
|
8
|
+
//
|
|
9
|
+
// node --require @volter/twin/inject app.js
|
|
10
|
+
// NODE_OPTIONS="--require @volter/twin/inject" <your start command>
|
|
11
|
+
//
|
|
12
|
+
// Twin URLs come from the environment (only configured vendors are redirected):
|
|
13
|
+
// STRIPE_TWIN_URL=http://127.0.0.1:12111
|
|
14
|
+
// SLACK_TWIN_URL GITHUB_TWIN_URL JIRA_TWIN_URL LINEAR_TWIN_URL S3_TWIN_URL
|
|
15
|
+
// OPENROUTER_TWIN_URL CLERK_TWIN_URL WEBRISK_TWIN_URL RESEND_TWIN_URL SENTRY_TWIN_URL
|
|
16
|
+
// POLAR_TWIN_URL ANTHROPIC_TWIN_URL ELEVENLABS_TWIN_URL GOOGLEFAVICON_TWIN_URL BROWSERASSETS_TWIN_URL
|
|
17
|
+
// VITAL_TWIN_URL CALCOM_TWIN_URL OPENWEATHER_TWIN_URL GOOGLEMAPS_TWIN_URL MAPBOX_TWIN_URL STREAM_TWIN_URL
|
|
18
|
+
// DYNAMODB_TWIN_URL TIMESTREAM_TWIN_URL (point these at the consolidated `aws` twin, same as S3_TWIN_URL)
|
|
19
|
+
// …or combined: TWIN_INJECT="stripe=http://127.0.0.1:12111,github=http://127.0.0.1:12120"
|
|
20
|
+
//
|
|
21
|
+
// It patches http/https `request`/`get` and global `fetch` at the process
|
|
22
|
+
// boundary. Every Node vendor SDK in this set routes through one of those
|
|
23
|
+
// (stripe-node → https; @octokit → fetch; @slack/web-api & jira.js → axios →
|
|
24
|
+
// http/https; @linear/sdk → fetch), so no per-SDK shim is needed.
|
|
25
|
+
const http = require('http');
|
|
26
|
+
const https = require('https');
|
|
27
|
+
const { EventEmitter } = require('events');
|
|
28
|
+
const { Readable } = require('stream');
|
|
29
|
+
const Module = require('module');
|
|
30
|
+
const { createPublicKey, createVerify } = require('crypto');
|
|
31
|
+
|
|
32
|
+
// vendor → predicate(hostname). A vendor is only active if its twin URL is set.
|
|
33
|
+
const VENDOR_HOSTS = {
|
|
34
|
+
stripe: (h) => h === 'api.stripe.com',
|
|
35
|
+
slack: (h) => h === 'slack.com' || h === 'www.slack.com' || h === 'api.slack.com',
|
|
36
|
+
github: (h) => h === 'api.github.com',
|
|
37
|
+
jira: (h) => h === 'atlassian.net' || h.endsWith('.atlassian.net'),
|
|
38
|
+
linear: (h) => h === 'api.linear.app',
|
|
39
|
+
openrouter: (h) => h === 'openrouter.ai',
|
|
40
|
+
anthropic: (h) => h === 'api.anthropic.com',
|
|
41
|
+
clerk: (h) => h === 'api.clerk.com',
|
|
42
|
+
webrisk: (h) => h === 'webrisk.googleapis.com',
|
|
43
|
+
resend: (h) => h === 'api.resend.com',
|
|
44
|
+
sentry: (h) => h === 'sentry.io' || h.endsWith('.ingest.sentry.io'),
|
|
45
|
+
polar: (h) => h === 'api.polar.sh' || h === 'sandbox-api.polar.sh',
|
|
46
|
+
elevenlabs: (h) => h === 'api.elevenlabs.io',
|
|
47
|
+
vital: (h) => h === 'api.tryvital.io' || h === 'api.sandbox.tryvital.io',
|
|
48
|
+
calcom: (h) => h === 'api.cal.com',
|
|
49
|
+
openweather: (h) => h === 'api.openweathermap.org',
|
|
50
|
+
googlemaps: (h) => h === 'maps.googleapis.com',
|
|
51
|
+
mapbox: (h) => h === 'api.mapbox.com',
|
|
52
|
+
stream: (h) => h === 'chat.stream-io-api.com',
|
|
53
|
+
googlefavicon: (h) => h === 'www.google.com' || h === 'google.com' || h === 't2.gstatic.com',
|
|
54
|
+
browserassets: (h) =>
|
|
55
|
+
h === 'cdnjs.cloudflare.com' ||
|
|
56
|
+
h === 'www.googletagmanager.com' ||
|
|
57
|
+
h === 'www.google-analytics.com' ||
|
|
58
|
+
h === 'connect.facebook.net' ||
|
|
59
|
+
h === 'snap.licdn.com' ||
|
|
60
|
+
h === 'px.ads.linkedin.com' ||
|
|
61
|
+
h === 'static.hotjar.com' ||
|
|
62
|
+
h === 'fonts.googleapis.com' ||
|
|
63
|
+
h === 'fonts.gstatic.com' ||
|
|
64
|
+
h === 'api.fontshare.com' ||
|
|
65
|
+
h === 'images.subscribe.dev' ||
|
|
66
|
+
h === 'www.youtube.com' ||
|
|
67
|
+
h === 'www.youtube-nocookie.com' ||
|
|
68
|
+
h === 'i.ytimg.com',
|
|
69
|
+
s3: (h) =>
|
|
70
|
+
h === 's3.amazonaws.com' ||
|
|
71
|
+
h.endsWith('.s3.amazonaws.com') ||
|
|
72
|
+
/^s3[.-][a-z0-9-]+\.amazonaws\.com$/.test(h) ||
|
|
73
|
+
/\.s3[.-][a-z0-9-]+\.amazonaws\.com$/.test(h) ||
|
|
74
|
+
/^s3\.dualstack\.[a-z0-9-]+\.amazonaws\.com$/.test(h) ||
|
|
75
|
+
/\.s3\.dualstack\.[a-z0-9-]+\.amazonaws\.com$/.test(h) ||
|
|
76
|
+
h.endsWith('.s3-accelerate.amazonaws.com') ||
|
|
77
|
+
h.endsWith('.s3-accelerate.dualstack.amazonaws.com') ||
|
|
78
|
+
h.endsWith('.r2.cloudflarestorage.com'),
|
|
79
|
+
// DynamoDB + Timestream are served by the SAME consolidated `aws` twin as s3 (it routes by
|
|
80
|
+
// X-Amz-Target). Point DYNAMODB_TWIN_URL / TIMESTREAM_TWIN_URL at the aws twin's URL.
|
|
81
|
+
dynamodb: (h) => /^dynamodb\.[a-z0-9-]+\.amazonaws\.com$/.test(h),
|
|
82
|
+
timestream: (h) => /(^|\.)(ingest|query)\.timestream\.[a-z0-9-]+\.amazonaws\.com$/.test(h),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function decodeBase64UrlJson(part) {
|
|
86
|
+
try {
|
|
87
|
+
const padded = String(part).replace(/-/g, '+').replace(/_/g, '/');
|
|
88
|
+
const json = Buffer.from(padded + '='.repeat((4 - (padded.length % 4)) % 4), 'base64').toString('utf8');
|
|
89
|
+
return JSON.parse(json);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function decodeBase64Url(part) {
|
|
96
|
+
const padded = String(part || '').replace(/-/g, '+').replace(/_/g, '/');
|
|
97
|
+
return Buffer.from(padded + '='.repeat((4 - (padded.length % 4)) % 4), 'base64');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function verifyJwtWithCachedJwks(token, jwks) {
|
|
101
|
+
const parts = String(token || '').split('.');
|
|
102
|
+
if (parts.length !== 3 || !jwks || !Array.isArray(jwks.keys)) return null;
|
|
103
|
+
const header = decodeBase64UrlJson(parts[0]);
|
|
104
|
+
const payload = decodeBase64UrlJson(parts[1]);
|
|
105
|
+
if (!header || !payload || header.alg !== 'RS256') return null;
|
|
106
|
+
const key = jwks.keys.find((candidate) => candidate && candidate.kid === header.kid) || jwks.keys[0];
|
|
107
|
+
if (!key) return null;
|
|
108
|
+
try {
|
|
109
|
+
const verifier = createVerify('RSA-SHA256');
|
|
110
|
+
verifier.update(`${parts[0]}.${parts[1]}`);
|
|
111
|
+
verifier.end();
|
|
112
|
+
const publicKey = createPublicKey({ key: { kty: 'RSA', n: key.n, e: key.e }, format: 'jwk' });
|
|
113
|
+
if (!verifier.verify(publicKey, decodeBase64Url(parts[2]))) return null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const now = Math.floor(Date.now() / 1000);
|
|
118
|
+
if (typeof payload.exp === 'number' && now >= payload.exp) return null;
|
|
119
|
+
if (typeof payload.nbf === 'number' && now < payload.nbf) return null;
|
|
120
|
+
const issuer = String(payload.iss || '');
|
|
121
|
+
const isTwinIssuer = issuer.includes('twin.clerk') || issuer.includes('clerk.twin') || issuer.includes('accounts.dev');
|
|
122
|
+
return isTwinIssuer ? payload : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function installClerkFastifyTwinShim() {
|
|
126
|
+
if (!process.env.CLERK_TWIN_URL || Module.__volterClerkFastifyShimInstalled) return;
|
|
127
|
+
Module.__volterClerkFastifyShimInstalled = true;
|
|
128
|
+
let cachedJwks = null;
|
|
129
|
+
const originalLoad = Module._load;
|
|
130
|
+
Module._load = function volterTwinLoad(request, parent, isMain) {
|
|
131
|
+
if (request === '@clerk/fastify') {
|
|
132
|
+
return {
|
|
133
|
+
clerkPlugin: async function clerkPlugin() {
|
|
134
|
+
const baseUrl = String(process.env.CLERK_TWIN_URL || '').replace(/\/$/, '');
|
|
135
|
+
if (!baseUrl) return;
|
|
136
|
+
const response = await fetch(`${baseUrl}/.well-known/jwks.json`);
|
|
137
|
+
if (!response.ok) throw new Error(`Clerk twin JWKS fetch failed: ${response.status}`);
|
|
138
|
+
cachedJwks = await response.json();
|
|
139
|
+
},
|
|
140
|
+
getAuth: function getAuth(req) {
|
|
141
|
+
const auth = req && (req.headers?.authorization || req.headers?.Authorization);
|
|
142
|
+
const token = typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : '';
|
|
143
|
+
const payload = token ? verifyJwtWithCachedJwks(token, cachedJwks) : null;
|
|
144
|
+
return {
|
|
145
|
+
userId: typeof payload?.sub === 'string' ? payload.sub : null,
|
|
146
|
+
sessionId: typeof payload?.sid === 'string' ? payload.sid : null,
|
|
147
|
+
orgId: typeof payload?.org_id === 'string' ? payload.org_id : null,
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return originalLoad.apply(this, arguments);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
installClerkFastifyTwinShim();
|
|
157
|
+
|
|
158
|
+
function readMap(env) {
|
|
159
|
+
const map = {};
|
|
160
|
+
for (const vendor of Object.keys(VENDOR_HOSTS)) {
|
|
161
|
+
const url = env[`${vendor.toUpperCase()}_TWIN_URL`];
|
|
162
|
+
if (url) map[vendor] = url.replace(/\/$/, '');
|
|
163
|
+
}
|
|
164
|
+
if (env.TWIN_INJECT) {
|
|
165
|
+
for (const pair of env.TWIN_INJECT.split(',')) {
|
|
166
|
+
const eq = pair.indexOf('=');
|
|
167
|
+
if (eq <= 0) continue;
|
|
168
|
+
const vendor = pair.slice(0, eq).trim();
|
|
169
|
+
const url = pair.slice(eq + 1).trim();
|
|
170
|
+
if (vendor && url && VENDOR_HOSTS[vendor]) map[vendor] = url.replace(/\/$/, '');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return map;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveTwin(hostname, map) {
|
|
177
|
+
for (const vendor of Object.keys(VENDOR_HOSTS)) {
|
|
178
|
+
if (map[vendor] && VENDOR_HOSTS[vendor](hostname)) return { vendor, origin: map[vendor] };
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isLocalOrPrivateHost(hostname) {
|
|
184
|
+
const h = String(hostname || '').toLowerCase();
|
|
185
|
+
if (!h) return true;
|
|
186
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.test')) return true;
|
|
187
|
+
if (h === '0.0.0.0' || h === '127.0.0.1' || h === '::1' || h === '[::1]') return true;
|
|
188
|
+
if (/^127\./.test(h)) return true;
|
|
189
|
+
if (/^10\./.test(h)) return true;
|
|
190
|
+
if (/^192\.168\./.test(h)) return true;
|
|
191
|
+
const m = h.match(/^172\.(\d+)\./);
|
|
192
|
+
if (m) {
|
|
193
|
+
const n = Number(m[1]);
|
|
194
|
+
if (n >= 16 && n <= 31) return true;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function strictEgressEnabled() {
|
|
200
|
+
return process.env.VOLTER_TWIN_STRICT_EGRESS === '1' || process.env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function shouldBlockUntwinned(url) {
|
|
204
|
+
return strictEgressEnabled() && url && !isLocalOrPrivateHost(url.hostname) && !resolveTwin(url.hostname, MAP);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- request-args → { url, method, headers, callback } across every overload ---
|
|
208
|
+
function describe(args, defaultProtocol) {
|
|
209
|
+
const input = args[0];
|
|
210
|
+
let options = {};
|
|
211
|
+
let callback;
|
|
212
|
+
if (typeof args[1] === 'function') callback = args[1];
|
|
213
|
+
else {
|
|
214
|
+
options = args[1] || {};
|
|
215
|
+
if (typeof args[2] === 'function') callback = args[2];
|
|
216
|
+
}
|
|
217
|
+
let url = null;
|
|
218
|
+
let headers = {};
|
|
219
|
+
let method = 'GET';
|
|
220
|
+
if (input instanceof URL || typeof input === 'string') {
|
|
221
|
+
try { url = new URL(String(input)); } catch { url = null; }
|
|
222
|
+
headers = options.headers || {};
|
|
223
|
+
method = options.method || 'GET';
|
|
224
|
+
} else {
|
|
225
|
+
const merged = Object.assign({}, input || {}, options);
|
|
226
|
+
headers = merged.headers || {};
|
|
227
|
+
method = merged.method || 'GET';
|
|
228
|
+
const proto = String(merged.protocol || defaultProtocol);
|
|
229
|
+
const host = merged.hostname || merged.host;
|
|
230
|
+
const path = merged.path || '/';
|
|
231
|
+
if (host) {
|
|
232
|
+
try { url = new URL(`${proto.endsWith(':') ? proto : `${proto}:`}//${host}${path}`); } catch { url = null; }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { url, method: String(method).toUpperCase(), headers, callback };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let ORIGINALS = null; // captured once, used to forward to the twin without re-entering the patch
|
|
239
|
+
|
|
240
|
+
function forward(origin, method, fullPath, headers, body) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
let target;
|
|
243
|
+
try { target = new URL(origin); } catch (error) { reject(new Error(`bad twin URL "${origin}": ${error.message}`)); return; }
|
|
244
|
+
const mod = target.protocol === 'https:' ? ORIGINALS.httpsRequest : ORIGINALS.httpRequest;
|
|
245
|
+
const fwdHeaders = Object.assign({}, headers);
|
|
246
|
+
delete fwdHeaders['accept-encoding']; delete fwdHeaders['Accept-Encoding'];
|
|
247
|
+
const req = mod({
|
|
248
|
+
protocol: target.protocol,
|
|
249
|
+
hostname: target.hostname,
|
|
250
|
+
port: target.port,
|
|
251
|
+
method,
|
|
252
|
+
path: fullPath,
|
|
253
|
+
headers: fwdHeaders,
|
|
254
|
+
}, (res) => {
|
|
255
|
+
const chunks = [];
|
|
256
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
257
|
+
res.on('error', reject);
|
|
258
|
+
res.on('end', () => resolve({ statusCode: res.statusCode || 200, headers: res.headers || {}, body: Buffer.concat(chunks) }));
|
|
259
|
+
});
|
|
260
|
+
req.on('error', reject);
|
|
261
|
+
if (body && body.length) req.write(body);
|
|
262
|
+
req.end();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// A stand-in ClientRequest: collects the body, forwards to the twin on end(),
|
|
267
|
+
// then emits the twin's response as an IncomingMessage-shaped Readable.
|
|
268
|
+
function fakeClientRequest(origin, method, fullPath, headers, callback) {
|
|
269
|
+
const request = new EventEmitter();
|
|
270
|
+
const bodyChunks = [];
|
|
271
|
+
// Some SDKs (e.g. stripe-node's NodeHttpClient) only call write()/end() from
|
|
272
|
+
// inside a `req.once('socket', …)` handler — so we must emit a socket. The
|
|
273
|
+
// socket reports `connecting: false` so callers write+end immediately.
|
|
274
|
+
const socket = new EventEmitter();
|
|
275
|
+
socket.connecting = false;
|
|
276
|
+
socket.setKeepAlive = () => socket;
|
|
277
|
+
socket.setTimeout = () => socket;
|
|
278
|
+
socket.unref = () => socket;
|
|
279
|
+
socket.ref = () => socket;
|
|
280
|
+
socket.destroy = () => socket;
|
|
281
|
+
request.socket = socket;
|
|
282
|
+
request.connection = socket;
|
|
283
|
+
process.nextTick(() => request.emit('socket', socket));
|
|
284
|
+
request.write = (chunk, enc, cb) => {
|
|
285
|
+
if (chunk) bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), typeof enc === 'string' ? enc : 'utf8'));
|
|
286
|
+
if (typeof enc === 'function') enc();
|
|
287
|
+
else if (typeof cb === 'function') cb();
|
|
288
|
+
return true;
|
|
289
|
+
};
|
|
290
|
+
request.end = (chunk, enc, cb) => {
|
|
291
|
+
if (typeof chunk === 'function') { cb = chunk; chunk = undefined; }
|
|
292
|
+
else if (typeof enc === 'function') { cb = enc; enc = undefined; }
|
|
293
|
+
if (chunk) request.write(chunk, enc);
|
|
294
|
+
forward(origin, method, fullPath, headers, Buffer.concat(bodyChunks))
|
|
295
|
+
.then(({ statusCode, headers: resHeaders, body }) => {
|
|
296
|
+
const res = Readable.from([body]);
|
|
297
|
+
res.statusCode = statusCode;
|
|
298
|
+
res.statusMessage = statusCode >= 400 ? 'Error' : 'OK';
|
|
299
|
+
res.headers = Object.assign({}, resHeaders);
|
|
300
|
+
res.rawHeaders = [];
|
|
301
|
+
if (typeof callback === 'function') callback(res);
|
|
302
|
+
request.emit('response', res);
|
|
303
|
+
if (typeof cb === 'function') cb();
|
|
304
|
+
})
|
|
305
|
+
.catch((error) => request.emit('error', error));
|
|
306
|
+
return request;
|
|
307
|
+
};
|
|
308
|
+
request.setHeader = () => {};
|
|
309
|
+
request.getHeader = () => undefined;
|
|
310
|
+
request.removeHeader = () => {};
|
|
311
|
+
request.setNoDelay = () => {};
|
|
312
|
+
request.setSocketKeepAlive = () => {};
|
|
313
|
+
request.flushHeaders = () => {};
|
|
314
|
+
request.setTimeout = (_timeout, cb) => { if (typeof cb === 'function') request.once('timeout', cb); return request; };
|
|
315
|
+
request.abort = () => request.emit('abort');
|
|
316
|
+
request.destroy = () => { request.emit('close'); return request; };
|
|
317
|
+
return request;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function blockedClientRequest(hostname) {
|
|
321
|
+
const request = new EventEmitter();
|
|
322
|
+
const error = new Error(`[twin-inject] blocked untwinned external request to ${hostname}`);
|
|
323
|
+
const socket = new EventEmitter();
|
|
324
|
+
socket.connecting = false;
|
|
325
|
+
socket.setKeepAlive = () => socket;
|
|
326
|
+
socket.setTimeout = () => socket;
|
|
327
|
+
socket.unref = () => socket;
|
|
328
|
+
socket.ref = () => socket;
|
|
329
|
+
socket.destroy = () => socket;
|
|
330
|
+
request.socket = socket;
|
|
331
|
+
request.connection = socket;
|
|
332
|
+
request.write = () => true;
|
|
333
|
+
request.end = (_chunk, _enc, cb) => {
|
|
334
|
+
process.nextTick(() => {
|
|
335
|
+
request.emit('error', error);
|
|
336
|
+
if (typeof cb === 'function') cb(error);
|
|
337
|
+
});
|
|
338
|
+
return request;
|
|
339
|
+
};
|
|
340
|
+
request.setHeader = () => {};
|
|
341
|
+
request.getHeader = () => undefined;
|
|
342
|
+
request.removeHeader = () => {};
|
|
343
|
+
request.setNoDelay = () => {};
|
|
344
|
+
request.setSocketKeepAlive = () => {};
|
|
345
|
+
request.flushHeaders = () => {};
|
|
346
|
+
request.setTimeout = (_timeout, cb) => { if (typeof cb === 'function') request.once('timeout', cb); return request; };
|
|
347
|
+
request.abort = () => request.emit('abort');
|
|
348
|
+
request.destroy = () => { request.emit('close'); return request; };
|
|
349
|
+
return request;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let MAP = {};
|
|
353
|
+
let installed = false;
|
|
354
|
+
|
|
355
|
+
function patchedFactory(originalRequest, defaultProtocol) {
|
|
356
|
+
return function patchedRequest(...args) {
|
|
357
|
+
const { url, method, headers, callback } = describe(args, defaultProtocol);
|
|
358
|
+
const twin = url && resolveTwin(url.hostname, MAP);
|
|
359
|
+
if (twin) {
|
|
360
|
+
const fwdHeaders = Object.assign({}, headers);
|
|
361
|
+
if (!fwdHeaders.host && !fwdHeaders.Host) fwdHeaders.host = url.host;
|
|
362
|
+
return fakeClientRequest(twin.origin, method, url.pathname + url.search, fwdHeaders, callback);
|
|
363
|
+
}
|
|
364
|
+
if (shouldBlockUntwinned(url)) return blockedClientRequest(url.hostname);
|
|
365
|
+
return originalRequest.apply(this, args);
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function install(mapOverride) {
|
|
370
|
+
MAP = Object.assign({}, MAP, mapOverride || readMap(process.env));
|
|
371
|
+
const active = Object.keys(MAP);
|
|
372
|
+
if (!active.length || installed) return { active };
|
|
373
|
+
|
|
374
|
+
ORIGINALS = {
|
|
375
|
+
httpRequest: http.request.bind(http),
|
|
376
|
+
httpGet: http.get.bind(http),
|
|
377
|
+
httpsRequest: https.request.bind(https),
|
|
378
|
+
httpsGet: https.get.bind(https),
|
|
379
|
+
fetch: typeof globalThis.fetch === 'function' ? globalThis.fetch : null,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
http.request = patchedFactory(ORIGINALS.httpRequest, 'http:');
|
|
383
|
+
https.request = patchedFactory(ORIGINALS.httpsRequest, 'https:');
|
|
384
|
+
http.get = function patchedHttpGet(...args) { const req = http.request(...args); req.end(); return req; };
|
|
385
|
+
https.get = function patchedHttpsGet(...args) { const req = https.request(...args); req.end(); return req; };
|
|
386
|
+
|
|
387
|
+
if (ORIGINALS.fetch) {
|
|
388
|
+
const originalFetch = ORIGINALS.fetch;
|
|
389
|
+
globalThis.fetch = function patchedFetch(input, init) {
|
|
390
|
+
let urlStr;
|
|
391
|
+
try { urlStr = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input && input.url; } catch { urlStr = undefined; }
|
|
392
|
+
let url = null;
|
|
393
|
+
if (urlStr) { try { url = new URL(urlStr); } catch { url = null; } }
|
|
394
|
+
const twin = url && resolveTwin(url.hostname, MAP);
|
|
395
|
+
if (!twin) {
|
|
396
|
+
if (shouldBlockUntwinned(url)) return Promise.reject(new Error(`[twin-inject] blocked untwinned external fetch to ${url.hostname}`));
|
|
397
|
+
return originalFetch(input, init);
|
|
398
|
+
}
|
|
399
|
+
const target = `${twin.origin}${url.pathname}${url.search}`;
|
|
400
|
+
const reqInit = Object.assign({}, init);
|
|
401
|
+
if (!reqInit.method && typeof input === 'object' && input && input.method) reqInit.method = input.method;
|
|
402
|
+
if (!reqInit.headers && typeof input === 'object' && input && input.headers) reqInit.headers = input.headers;
|
|
403
|
+
if (reqInit.body === undefined && typeof input === 'object' && input && input.body != null) reqInit.body = input.body;
|
|
404
|
+
return originalFetch(target, reqInit);
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
installed = true;
|
|
409
|
+
for (const vendor of active) {
|
|
410
|
+
process.stderr.write(`[twin-inject] ${vendor}: redirecting to ${MAP[vendor]}\n`);
|
|
411
|
+
}
|
|
412
|
+
return { active };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function restore() {
|
|
416
|
+
if (!installed || !ORIGINALS) { MAP = {}; return; }
|
|
417
|
+
http.request = ORIGINALS.httpRequest;
|
|
418
|
+
http.get = ORIGINALS.httpGet;
|
|
419
|
+
https.request = ORIGINALS.httpsRequest;
|
|
420
|
+
https.get = ORIGINALS.httpsGet;
|
|
421
|
+
if (ORIGINALS.fetch) globalThis.fetch = ORIGINALS.fetch;
|
|
422
|
+
installed = false;
|
|
423
|
+
MAP = {};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
module.exports = { install, restore, readMap, resolveTwin, VENDOR_HOSTS };
|
|
427
|
+
|
|
428
|
+
// Auto-install when loaded as a preload (`--require`). No-op if no twin URLs are set.
|
|
429
|
+
install();
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local twins of SaaS APIs — the kernel + control plane: append-only event log, projection, mirror/simulator/fork modes, egress write-ledger, connectors, and the operator control plane (status/plan/lease/refs/queue/fork). Point your real vendor SDK at a faithful, stateful local replica.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"twin",
|
|
7
|
+
"local",
|
|
8
|
+
"mock",
|
|
9
|
+
"mirror",
|
|
10
|
+
"simulator",
|
|
11
|
+
"fixtures",
|
|
12
|
+
"testing",
|
|
13
|
+
"sdk",
|
|
14
|
+
"api",
|
|
15
|
+
"localstack",
|
|
16
|
+
"control-plane",
|
|
17
|
+
"event-sourcing",
|
|
18
|
+
"kernel"
|
|
19
|
+
],
|
|
20
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
21
|
+
"license": "Apache-2.0",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"dist",
|
|
28
|
+
"inject.cjs",
|
|
29
|
+
"client",
|
|
30
|
+
"test-fixtures",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"!test-fixtures/*.SOURCE.md",
|
|
34
|
+
"!**/*.test.ts",
|
|
35
|
+
"!**/*.test.tsx"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
40
|
+
"directory": "packages/twin/control-plane"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/control-plane#readme",
|
|
43
|
+
"type": "module",
|
|
44
|
+
"exports": {
|
|
45
|
+
".": {
|
|
46
|
+
"types": "./dist/src/index.d.ts",
|
|
47
|
+
"default": "./dist/src/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./args": {
|
|
50
|
+
"types": "./dist/src/args.d.ts",
|
|
51
|
+
"default": "./dist/src/args.js"
|
|
52
|
+
},
|
|
53
|
+
"./schemas": {
|
|
54
|
+
"types": "./dist/src/schemas.d.ts",
|
|
55
|
+
"default": "./dist/src/schemas.js"
|
|
56
|
+
},
|
|
57
|
+
"./inject": "./inject.cjs"
|
|
58
|
+
},
|
|
59
|
+
"bin": {
|
|
60
|
+
"volter-twin": "dist/src/cli.js"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"cli": "bun run src/cli.ts",
|
|
64
|
+
"test": "bun test src/*.test.ts",
|
|
65
|
+
"typecheck": "tsc --noEmit",
|
|
66
|
+
"build": "node scripts/build.mjs",
|
|
67
|
+
"prepack": "node scripts/prepare-publish.mjs prepack",
|
|
68
|
+
"postpack": "node scripts/prepare-publish.mjs postpack"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"zod": "^4.4.2"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/bun": "^1.2.20",
|
|
75
|
+
"@types/node": "^24.0.0",
|
|
76
|
+
"typescript": "^5.9.0"
|
|
77
|
+
},
|
|
78
|
+
"engines": {
|
|
79
|
+
"bun": ">=1.2.0"
|
|
80
|
+
}
|
|
81
|
+
}
|