@volter/world-core 2.0.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 +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- package/vendor-hosts.cjs +200 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// THE PLACEHOLDER REMOTE (docs/concepts/the-model.md#a-branch-is-a-position-not-a-copy, "default data"): a fresh twin is a clone of the
|
|
2
|
+
// coherent default data its pack ships, and that seed is a PULL, never a pending write. While a
|
|
3
|
+
// service's placeholder-pull window is open — a marker file under its state dir, read through the
|
|
4
|
+
// active world store like the world clock — every local `set` write the twin accepts lands as an
|
|
5
|
+
// OBSERVED event from the placeholder (origin `virtual`, provenance `placeholder`) and no action row
|
|
6
|
+
// is appended: `main` equals `origin/main` when the seed ends, `diff` is empty, nothing is pending,
|
|
7
|
+
// and no real remote linked later is ever offered the seed. The placeholder has no origin and no
|
|
8
|
+
// credential, so it can never be a push target; a pull from it again is "back to the defaults".
|
|
9
|
+
import { appendParentEntry, toEntry } from './log.ts';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { worldPaths, worldStateRoot } from './storage.ts';
|
|
12
|
+
import { WorldServiceEventSchema } from './schemas.ts';
|
|
13
|
+
import { hashFieldValue } from './hash.ts';
|
|
14
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
15
|
+
import type { TwinAction } from './actions.ts';
|
|
16
|
+
import type { WorldServiceEvent } from './types.ts';
|
|
17
|
+
|
|
18
|
+
export const PLACEHOLDER_REMOTE = 'placeholder';
|
|
19
|
+
|
|
20
|
+
/** The window is per WORLD SERVICE ROOT, not per state service: a twin may record under a state
|
|
21
|
+
* service that is not its world service id (slack records under `chat`), and every state service
|
|
22
|
+
* of one twin lives under the same root — so the marker sits at the root's state dir. */
|
|
23
|
+
export function placeholderPullMarkerPath(_service: string, root?: string): string {
|
|
24
|
+
return join(worldStateRoot(root), 'placeholder-pull');
|
|
25
|
+
}
|
|
26
|
+
/** Is the service's placeholder-pull window open? */
|
|
27
|
+
export function placeholderPullActive(service: string, root?: string): boolean {
|
|
28
|
+
return getActiveWorldStore().exists(placeholderPullMarkerPath(service, root));
|
|
29
|
+
}
|
|
30
|
+
export function beginPlaceholderPull(service: string, root?: string, at: string = new Date().toISOString()): void {
|
|
31
|
+
const path = placeholderPullMarkerPath(service, root);
|
|
32
|
+
const store = getActiveWorldStore();
|
|
33
|
+
store.mkdir(join(path, '..'));
|
|
34
|
+
store.write(path, `${JSON.stringify({ remote: PLACEHOLDER_REMOTE, openedAt: at })}\n`);
|
|
35
|
+
}
|
|
36
|
+
export function endPlaceholderPull(service: string, root?: string): void {
|
|
37
|
+
const path = placeholderPullMarkerPath(service, root);
|
|
38
|
+
if (getActiveWorldStore().exists(path)) getActiveWorldStore().remove(path);
|
|
39
|
+
}
|
|
40
|
+
/** Open the window around `fn` (in-process seeding). */
|
|
41
|
+
export async function withPlaceholderPull<T>(service: string, root: string | undefined, fn: () => T | Promise<T>): Promise<T> {
|
|
42
|
+
beginPlaceholderPull(service, root);
|
|
43
|
+
try { return await fn(); } finally { endPlaceholderPull(service, root); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The observed events a local write becomes when it is a pull from the placeholder: the write's
|
|
47
|
+
* own subject and fields, plus every resource its projection creates or updates. A projection
|
|
48
|
+
* DELETE has no observed form (the placeholder holds what exists, not what was removed) and is
|
|
49
|
+
* refused loudly — a seed creates the world, it does not un-create it. */
|
|
50
|
+
export function placeholderEventsFor(action: TwinAction): WorldServiceEvent[] {
|
|
51
|
+
const observations: Array<{ subject: { type: string; id: string }; fields: Record<string, unknown> }> = [];
|
|
52
|
+
if (action.fields) observations.push({ subject: action.subject, fields: action.fields });
|
|
53
|
+
for (const c of action.projection?.creates ?? []) observations.push({ subject: { type: c.type, id: c.id }, fields: c.fields });
|
|
54
|
+
for (const u of action.projection?.updates ?? []) observations.push({ subject: { type: u.type, id: u.id }, fields: u.fields });
|
|
55
|
+
if (action.projection?.deletes?.length) throw new Error(`placeholder pull: ${action.service} ${action.operation ?? action.op} deletes ${action.projection.deletes.map((d) => `${d.type}:${d.id}`).join(', ')} — a seed creates the placeholder world, it cannot remove from it`);
|
|
56
|
+
return observations.map((o) => {
|
|
57
|
+
const contentHash = hashFieldValue({ subject: o.subject, fields: o.fields, operation: action.operation ?? action.op });
|
|
58
|
+
const id = `placeholder:${action.service}:${o.subject.type}:${o.subject.id}:${contentHash}`;
|
|
59
|
+
return WorldServiceEventSchema.parse({
|
|
60
|
+
id,
|
|
61
|
+
service: action.service,
|
|
62
|
+
type: `${action.service}.${o.subject.type}.delta`,
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
idempotencyKey: id,
|
|
65
|
+
occurredAt: action.occurredAt,
|
|
66
|
+
observedAt: action.occurredAt,
|
|
67
|
+
origin: 'virtual',
|
|
68
|
+
subject: o.subject,
|
|
69
|
+
data: { changed: Object.fromEntries(Object.entries(o.fields).map(([k, v]) => [k, { after: v }])), provenance: PLACEHOLDER_REMOTE, remote: PLACEHOLDER_REMOTE },
|
|
70
|
+
}) as WorldServiceEvent;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Land `action` as the placeholder's observed facts. Caller holds the service locks. */
|
|
75
|
+
export function landAsPlaceholder(action: TwinAction, root?: string): { events: WorldServiceEvent[] } {
|
|
76
|
+
const paths = worldPaths(action.service, root);
|
|
77
|
+
const events = placeholderEventsFor(action);
|
|
78
|
+
// v2: the placeholder's rows land as ENTRIES on the parent log (one row kind), marked as the placeholder's
|
|
79
|
+
for (const event of events) appendParentEntry({ ...toEntry(event as unknown as Record<string, unknown>), provenance: 'placeholder' }, root);
|
|
80
|
+
return { events };
|
|
81
|
+
}
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Zero-edit browser injection: a dev proxy you put in front of your app so the
|
|
2
|
+
// browser's vendor SDK calls share the SAME twin the backend uses — no app edits.
|
|
3
|
+
//
|
|
4
|
+
// volter-twin proxy --target http://localhost:3000 \
|
|
5
|
+
// --map stripe=http://127.0.0.1:12111 --route stripe=/v1/ --loader-host stripe=https://api.stripe.com
|
|
6
|
+
//
|
|
7
|
+
// The browser loads your app through the proxy. Requests to a configured vendor's
|
|
8
|
+
// browser API path (its `apiPathPrefix`, e.g. Stripe's /v1/…) are forwarded to that
|
|
9
|
+
// vendor's twin; everything else passes through to your app. For vendors whose browser
|
|
10
|
+
// SDK loads from a CDN and calls an absolute host (its `loaderHost`, e.g. Stripe.js →
|
|
11
|
+
// api.stripe.com), the proxy rewrites the loader so its calls become same-origin and get
|
|
12
|
+
// forwarded too. This kernel module is VENDOR-AGNOSTIC: the per-vendor routing values are
|
|
13
|
+
// DATA supplied by the caller (sourced from each pack's `TwinPack.browserRouting`), never a
|
|
14
|
+
// hardcoded vendor table.
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
|
|
18
|
+
/** How one vendor's browser SDK is routed to its twin. Supplied by the caller. */
|
|
19
|
+
export interface VendorRoute {
|
|
20
|
+
/** The vendor's twin origin, e.g. http://127.0.0.1:12111 */
|
|
21
|
+
origin: string;
|
|
22
|
+
/** Same-origin browser API path that belongs to this vendor, e.g. '/v1/' */
|
|
23
|
+
apiPathPrefix: string;
|
|
24
|
+
/** Absolute API host to strip from the vendor's browser loader so calls become
|
|
25
|
+
* same-origin (e.g. 'https://api.stripe.com'). Omit if the SDK already calls same-origin. */
|
|
26
|
+
loaderHost?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TwinProxyOptions {
|
|
30
|
+
/** Your app's origin, e.g. http://localhost:3000 */
|
|
31
|
+
target: string;
|
|
32
|
+
/** vendor → routing. */
|
|
33
|
+
map: Record<string, VendorRoute>;
|
|
34
|
+
/** Extra local service origins whose redirects should be rewritten back to this proxy origin. */
|
|
35
|
+
redirectOrigins?: string[];
|
|
36
|
+
/** Listen port (0 = ephemeral). */
|
|
37
|
+
port?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TwinProxy {
|
|
41
|
+
port: number;
|
|
42
|
+
stop(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function vendorForPath(path: string, map: Record<string, VendorRoute>): string | null {
|
|
46
|
+
for (const [vendor, route] of Object.entries(map)) {
|
|
47
|
+
if (path.startsWith(route.apiPathPrefix)) return vendor;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function escapeRegExp(value: string): string {
|
|
53
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Strip `loaderHost` (a full origin, e.g. 'https://api.stripe.com') from `text` so its
|
|
58
|
+
* calls become same-origin — but ONLY where it appears as a complete host, not as a
|
|
59
|
+
* blind substring. A plain `split(loaderHost).join('')` (the prior implementation) is
|
|
60
|
+
* NOT position-aware: it removes the substring wherever it occurs, including as a
|
|
61
|
+
* PREFIX of a longer, different host — e.g. 'https://api.stripe.com.evil.example/x'
|
|
62
|
+
* contains 'https://api.stripe.com' as a literal substring, and a naive strip corrupts
|
|
63
|
+
* it into '.evil.example/x' (the true host is stripe.com.evil.example, not stripe.com;
|
|
64
|
+
* same substring bug would also mangle a subdomain like sandbox.api.stripe.com if the
|
|
65
|
+
* scheme weren't part of the match). Position-aware fix: only strip a match that is NOT
|
|
66
|
+
* immediately followed by a domain-continuing character (letter/digit/dot/hyphen) —
|
|
67
|
+
* that character means the matched text is a prefix of a longer host, not the whole
|
|
68
|
+
* host, so it is left untouched.
|
|
69
|
+
*/
|
|
70
|
+
function stripLoaderHost(text: string, loaderHost: string): string {
|
|
71
|
+
const pattern = new RegExp(`${escapeRegExp(loaderHost)}(?![A-Za-z0-9.-])`, 'g');
|
|
72
|
+
return text.replace(pattern, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rewriteLocation(location: string | string[] | undefined, req: http.IncomingMessage, origins: string[]): string | string[] | undefined {
|
|
76
|
+
if (!location || Array.isArray(location)) return location;
|
|
77
|
+
const host = req.headers.host;
|
|
78
|
+
if (!host) return location;
|
|
79
|
+
const proxyOrigin = `http://${host}`;
|
|
80
|
+
for (const origin of origins) {
|
|
81
|
+
const normalized = origin.replace(/\/$/, '');
|
|
82
|
+
if (location === normalized) return proxyOrigin;
|
|
83
|
+
if (location.startsWith(`${normalized}/`)) return `${proxyOrigin}${location.slice(normalized.length)}`;
|
|
84
|
+
}
|
|
85
|
+
return location;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function forwardTo(
|
|
89
|
+
origin: string,
|
|
90
|
+
req: http.IncomingMessage,
|
|
91
|
+
res: http.ServerResponse,
|
|
92
|
+
rewrite?: (body: Buffer, headers: http.IncomingHttpHeaders) => Buffer,
|
|
93
|
+
redirectOrigins: string[] = [],
|
|
94
|
+
): void {
|
|
95
|
+
const target = new URL(req.url || '/', origin);
|
|
96
|
+
const headers = { ...req.headers, host: target.host };
|
|
97
|
+
delete headers['accept-encoding']; // so we can rewrite uncompressed bodies
|
|
98
|
+
const upstream = http.request(target, { method: req.method, headers }, (up) => {
|
|
99
|
+
if ((up.statusCode ?? 0) >= 300 && (up.statusCode ?? 0) < 400) {
|
|
100
|
+
const responseHeaders = { ...up.headers };
|
|
101
|
+
// `location` is single-valued, so rewriteLocation returns string|undefined here (never an array).
|
|
102
|
+
responseHeaders.location = rewriteLocation(responseHeaders.location, req, [origin, ...redirectOrigins]) as string | undefined;
|
|
103
|
+
delete responseHeaders['transfer-encoding'];
|
|
104
|
+
delete responseHeaders['content-length'];
|
|
105
|
+
res.writeHead(up.statusCode || 302, responseHeaders);
|
|
106
|
+
res.end();
|
|
107
|
+
up.resume();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const chunks: Buffer[] = [];
|
|
111
|
+
up.on('data', (c) => chunks.push(c as Buffer));
|
|
112
|
+
up.on('error', () => res.destroyed || res.end());
|
|
113
|
+
up.on('end', () => {
|
|
114
|
+
const body = Buffer.concat(chunks);
|
|
115
|
+
const out = rewrite ? rewrite(body, up.headers) : body;
|
|
116
|
+
const responseHeaders = { ...up.headers };
|
|
117
|
+
delete responseHeaders['content-length'];
|
|
118
|
+
delete responseHeaders['content-encoding'];
|
|
119
|
+
delete responseHeaders['transfer-encoding'];
|
|
120
|
+
responseHeaders['content-length'] = String(Buffer.byteLength(out));
|
|
121
|
+
res.writeHead(up.statusCode || 502, responseHeaders);
|
|
122
|
+
res.end(out);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
upstream.on('error', (error) => {
|
|
126
|
+
if (res.headersSent) { res.destroy(error); return; }
|
|
127
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
128
|
+
res.end(`twin proxy upstream error: ${error.message}`);
|
|
129
|
+
});
|
|
130
|
+
req.pipe(upstream);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createTwinProxy(opts: TwinProxyOptions): TwinProxy {
|
|
134
|
+
const map: Record<string, VendorRoute> = {};
|
|
135
|
+
for (const [vendor, route] of Object.entries(opts.map)) map[vendor] = { ...route, origin: route.origin.replace(/\/$/, '') };
|
|
136
|
+
const redirectOrigins = (opts.redirectOrigins ?? []).map((origin) => origin.replace(/\/$/, ''));
|
|
137
|
+
|
|
138
|
+
const server = http.createServer((req, res) => {
|
|
139
|
+
const path = req.url || '/';
|
|
140
|
+
const vendor = vendorForPath(path, map);
|
|
141
|
+
if (vendor) {
|
|
142
|
+
forwardTo(map[vendor].origin, req, res, undefined, redirectOrigins); // browser vendor API call → the twin
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Otherwise pass through to the app, rewriting vendor loaders so their calls come back here.
|
|
146
|
+
forwardTo(opts.target, req, res, (body, headers) => {
|
|
147
|
+
const contentType = String(headers['content-type'] || '');
|
|
148
|
+
if (!contentType.includes('javascript') && !contentType.includes('html')) return body;
|
|
149
|
+
let text = body.toString('utf8');
|
|
150
|
+
for (const route of Object.values(map)) {
|
|
151
|
+
if (route.loaderHost) text = stripLoaderHost(text, route.loaderHost);
|
|
152
|
+
}
|
|
153
|
+
return Buffer.from(text, 'utf8');
|
|
154
|
+
}, redirectOrigins);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// WebSocket / HTTP upgrade passthrough — routed by the SAME vendor map as plain
|
|
158
|
+
// requests, so a vendor whose browser surface speaks WebSocket (a media SFU's
|
|
159
|
+
// signal path, a realtime API) reaches its twin instead of the app.
|
|
160
|
+
server.on('upgrade', (req, socket, head) => {
|
|
161
|
+
const upgradeVendor = vendorForPath(req.url || '/', map);
|
|
162
|
+
const target = new URL(req.url || '/', upgradeVendor ? map[upgradeVendor].origin : opts.target);
|
|
163
|
+
const upstream = net.connect(Number(target.port || 80), target.hostname, () => {
|
|
164
|
+
upstream.write(`${req.method} ${target.pathname}${target.search} HTTP/${req.httpVersion}\r\n`);
|
|
165
|
+
for (const [name, value] of Object.entries(req.headers)) upstream.write(`${name}: ${value}\r\n`);
|
|
166
|
+
upstream.write('\r\n');
|
|
167
|
+
upstream.write(head);
|
|
168
|
+
upstream.pipe(socket);
|
|
169
|
+
socket.pipe(upstream);
|
|
170
|
+
});
|
|
171
|
+
const killSocket = () => { if (!socket.destroyed) socket.destroy(); };
|
|
172
|
+
const killUpstream = () => { if (!upstream.destroyed) upstream.destroy(); };
|
|
173
|
+
upstream.on('error', killSocket);
|
|
174
|
+
socket.on('error', killUpstream);
|
|
175
|
+
socket.on('close', killUpstream);
|
|
176
|
+
upstream.on('close', killSocket);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
server.listen(opts.port ?? 0, '127.0.0.1');
|
|
180
|
+
const address = server.address();
|
|
181
|
+
const port = address && typeof address === 'object' ? address.port : (opts.port ?? 0);
|
|
182
|
+
return { port, stop: () => server.close() };
|
|
183
|
+
}
|