@volter/twin-segment 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/README.md +154 -0
- package/client/segment-mirror.css +45 -0
- package/client/segment-mirror.tsx +154 -0
- package/dist/client/segment-mirror.bundle.js +342 -0
- package/dist/client/segment-mirror.css +45 -0
- package/dist/client/segment-mirror.d.ts +34 -0
- package/dist/client/segment-mirror.js +80 -0
- package/dist/client/segment-mirror.tsx +154 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +34 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +53 -0
- package/dist/src/segment-budget.d.ts +41 -0
- package/dist/src/segment-budget.js +112 -0
- package/dist/src/segment-capabilities.d.ts +12 -0
- package/dist/src/segment-capabilities.gen.d.ts +3 -0
- package/dist/src/segment-capabilities.gen.js +22 -0
- package/dist/src/segment-capabilities.js +907 -0
- package/dist/src/segment-conformance.d.ts +8 -0
- package/dist/src/segment-conformance.js +106 -0
- package/dist/src/segment-connector.d.ts +76 -0
- package/dist/src/segment-connector.js +226 -0
- package/dist/src/segment-mirror-ui.d.ts +42 -0
- package/dist/src/segment-mirror-ui.js +143 -0
- package/dist/src/segment-server.d.ts +25 -0
- package/dist/src/segment-server.js +90 -0
- package/dist/src/segment-surface.gen.d.ts +48 -0
- package/dist/src/segment-surface.gen.js +267 -0
- package/dist/src/segment-twin.d.ts +98 -0
- package/dist/src/segment-twin.js +543 -0
- package/package.json +59 -0
- package/src/cli.ts +30 -0
- package/src/index.ts +87 -0
- package/src/segment-budget.ts +138 -0
- package/src/segment-capabilities.gen.ts +25 -0
- package/src/segment-capabilities.ts +988 -0
- package/src/segment-conformance.ts +123 -0
- package/src/segment-connector.ts +233 -0
- package/src/segment-journey.uitest.ts +116 -0
- package/src/segment-mirror-ui.ts +157 -0
- package/src/segment-server.ts +95 -0
- package/src/segment-surface.gen.ts +277 -0
- package/src/segment-twin.ts +664 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Segment conformance — vendor-property checks over the modeled tracking plane. Each check
|
|
2
|
+
// asserts a property of SEGMENT (a status, an envelope shape, a stored VALUE, a documented
|
|
3
|
+
// refusal), never twin self-consistency, and each goes red if its handler is deleted.
|
|
4
|
+
//
|
|
5
|
+
// The third direction the probe⇄claim pair is blind to is closed by ROUTER_SURFACE below: every
|
|
6
|
+
// method/path pair the ratified surface declares is exercised, and anything that answers neither
|
|
7
|
+
// a modeled outcome nor the vendor-shaped loud gap is a failure. That is what catches
|
|
8
|
+
// served-but-unclaimed surface (the googleoauth precedent, ADDING_A_TWIN §6).
|
|
9
|
+
import { OPS } from "./segment-surface.gen.js";
|
|
10
|
+
import { events, groups, handleSegmentTwinRequest, identities } from "./segment-twin.js";
|
|
11
|
+
const KEY = 'conformance_write_key';
|
|
12
|
+
const body = (o) => JSON.stringify(o);
|
|
13
|
+
/** The five modeled ops, as a LITERAL. Deliberately not derived from SEMANTICS: two constants
|
|
14
|
+
* asserting about each other is not a check (the tinybird shape, ADDING_A_TWIN §6) — deleting a
|
|
15
|
+
* handler must make this table disagree with the router, not follow it. */
|
|
16
|
+
const MODELED_OPS = new Set(['batch', 'track', 'identify', 'page', 'group', 'alias']);
|
|
17
|
+
export async function checkSegmentConformance(options = {}) {
|
|
18
|
+
const failures = [];
|
|
19
|
+
// COUNTED, never a literal: a hardcoded `checksRun` is satisfied by a report that ran FEWER
|
|
20
|
+
// checks than it claims, so deleting a check would leave the suite green — the
|
|
21
|
+
// two-constants-asserting-about-each-other shape (var/line/mixpanel/REVIEW.md F8).
|
|
22
|
+
let checksRun = 0;
|
|
23
|
+
const check = (name, ok) => { checksRun += 1; if (!ok)
|
|
24
|
+
failures.push(name); };
|
|
25
|
+
const h = (method, path, payload, authorization) => handleSegmentTwinRequest({ method, path, body: payload, authorization, root: options.root });
|
|
26
|
+
// 1. The envelope the unmodified SDK sends is accepted with a 2xx — the ONLY thing
|
|
27
|
+
// @segment/analytics-node@2 reads (`response.status >= 200 && response.status < 300`).
|
|
28
|
+
const flushed = await h('POST', '/v1/batch', body({
|
|
29
|
+
batch: [
|
|
30
|
+
{ type: 'identify', userId: 'conf_user', traits: { plan: 'pro' }, messageId: 'conf_m1' },
|
|
31
|
+
{ type: 'track', userId: 'conf_user', event: 'Conformance Ran', properties: { n: 1 }, messageId: 'conf_m2' },
|
|
32
|
+
],
|
|
33
|
+
writeKey: KEY,
|
|
34
|
+
sentAt: '2026-08-24T00:00:00.000Z',
|
|
35
|
+
}));
|
|
36
|
+
check('POST /v1/batch answers 2xx for the SDK envelope', flushed.status >= 200 && flushed.status < 300);
|
|
37
|
+
check('every message in the batch folded, not just batch[0]', events(options.root).filter((e) => ['conf_m1', 'conf_m2'].includes(String(e.messageId))).length === 2);
|
|
38
|
+
check('the identify folded its traits into an identity', identities(options.root).find((i) => i.id === 'conf_user')?.traits?.plan === 'pro');
|
|
39
|
+
check('the track kept its event name and properties', events(options.root).some((e) => e.event === 'Conformance Ran' && e.properties.n === 1));
|
|
40
|
+
check('the writeKey travelled from the ENVELOPE onto each message', events(options.root).find((e) => e.messageId === 'conf_m2')?.writeKey === KEY);
|
|
41
|
+
// 2. Basic auth with the write key as the USERNAME and an empty password — analytics-node v1's
|
|
42
|
+
// wire shape, documented at http-api/index.md '#### Basic authentication'.
|
|
43
|
+
await h('POST', '/v1/track', body({ userId: 'conf_basic', event: 'Basic Auth', messageId: 'conf_m3' }), `Basic ${Buffer.from('basic_key:', 'utf8').toString('base64')}`);
|
|
44
|
+
const basic = events(options.root).find((e) => e.messageId === 'conf_m3');
|
|
45
|
+
check('Basic <base64(writeKey:)> resolves the write key from the header', basic?.writeKey === 'basic_key' && basic?.authScheme === 'basic');
|
|
46
|
+
// 3. A group call folds a group with its traits; an alias links previousId onto the userId.
|
|
47
|
+
await h('POST', '/v1/group', body({ userId: 'conf_user', groupId: 'conf_group', traits: { name: 'Initech' }, writeKey: KEY, messageId: 'conf_m4' }));
|
|
48
|
+
check('POST /v1/group folds group traits', groups(options.root).find((g) => g.id === 'conf_group')?.traits?.name === 'Initech');
|
|
49
|
+
await h('POST', '/v1/alias', body({ userId: 'conf_user', previousId: 'conf_anon', writeKey: KEY, messageId: 'conf_m5' }));
|
|
50
|
+
check('POST /v1/alias records previousId on the identity', (identities(options.root).find((i) => i.id === 'conf_user')?.previousIds ?? []).includes('conf_anon'));
|
|
51
|
+
// 4. The documented ACCEPT-AND-DROP: "The HTTP API requires that each payload has a userId
|
|
52
|
+
// and/or anonymousId... Segment's tracking API responds with an no_user_anon_id error", and
|
|
53
|
+
// the page's own rule is that everything but oversize/invalid-JSON answers 200.
|
|
54
|
+
const before = events(options.root).length;
|
|
55
|
+
const anon = await h('POST', '/v1/track', body({ event: 'No Identifier', writeKey: KEY, messageId: 'conf_m6' }));
|
|
56
|
+
check('a payload with neither userId nor anonymousId still answers 200', anon.status === 200);
|
|
57
|
+
check('...and is NOT folded into the event feed', events(options.root).length === before);
|
|
58
|
+
// 5. "If you send an event with invalid JSON, Segment returns a 400 Bad Request error" — and
|
|
59
|
+
// the body is {code, message}, the shape analytics-python parses off any non-200.
|
|
60
|
+
const bad = await h('POST', '/v1/batch', '{not json');
|
|
61
|
+
const badBody = bad.body;
|
|
62
|
+
check('invalid JSON answers 400 with the vendor {code, message} envelope', bad.status === 400 && typeof badBody.code === 'string' && typeof badBody.message === 'string');
|
|
63
|
+
// 6. "There is a maximum of 32KB per normal API request... Segment's API responds with 400 Bad
|
|
64
|
+
// Request if these limits are exceeded."
|
|
65
|
+
const oversize = await h('POST', '/v1/track', body({ userId: 'u', event: 'Big', properties: { blob: 'x'.repeat(33 * 1024) }, writeKey: KEY }));
|
|
66
|
+
check('a >32KB direct request answers 400', oversize.status === 400);
|
|
67
|
+
// 7. A ratified-but-unmodeled op fails LOUDLY by name — never the vendor's blanket 200, which
|
|
68
|
+
// would be indistinguishable from success.
|
|
69
|
+
const gap = await h('POST', '/v1/screen', body({ userId: 'u', name: 'Home', writeKey: KEY }));
|
|
70
|
+
const gapMsg = String(gap.body.message ?? '');
|
|
71
|
+
check('an unmodeled route answers the loud [twin gap] naming the op', gap.status === 404 && gapMsg.includes('[twin gap]') && gapMsg.includes('screen'));
|
|
72
|
+
// 8. The sharper one: an unmodeled message TYPE riding INSIDE a modeled route. `screen` is a
|
|
73
|
+
// type the unmodified SDK can put in a /v1/batch envelope, so the per-message dispatcher has
|
|
74
|
+
// to refuse it by name rather than inherit /v1/batch's success.
|
|
75
|
+
const countBefore = events(options.root).length;
|
|
76
|
+
const mixed = await h('POST', '/v1/batch', body({ batch: [{ type: 'track', userId: 'u', event: 'Rides Along', messageId: 'conf_m7' }, { type: 'screen', userId: 'u', name: 'Home', messageId: 'conf_m8' }], writeKey: KEY }));
|
|
77
|
+
const mixedMsg = String(mixed.body.message ?? '');
|
|
78
|
+
check('an unmodeled TYPE inside a modeled batch fails loudly by name', mixed.status === 404 && mixedMsg.includes('screen'));
|
|
79
|
+
check('...and nothing from that batch was stored', events(options.root).length === countBefore);
|
|
80
|
+
// 9. "Segment deduplicates events using the messageId field."
|
|
81
|
+
await h('POST', '/v1/track', body({ userId: 'conf_user', event: 'Deduped', writeKey: KEY, messageId: 'conf_dupe' }));
|
|
82
|
+
await h('POST', '/v1/track', body({ userId: 'conf_user', event: 'Deduped Again', writeKey: KEY, messageId: 'conf_dupe' }));
|
|
83
|
+
check('a repeated messageId folds exactly once', events(options.root).filter((e) => e.messageId === 'conf_dupe').length === 1);
|
|
84
|
+
// 10. Non-ASCII survives byte for byte. In-process fixtures can hide an encoding bug the wire
|
|
85
|
+
// exposes, so this value is also driven through the real SDK in the integration suite.
|
|
86
|
+
await h('POST', '/v1/track', body({ userId: 'conf_uni', event: 'Café ☕', properties: { name: '日本語', emoji: '🎉' }, writeKey: KEY, messageId: 'conf_uni' }));
|
|
87
|
+
const uni = events(options.root).find((e) => e.messageId === 'conf_uni');
|
|
88
|
+
check('a non-ASCII payload round-trips unmangled', uni?.event === 'Café ☕' && uni.properties.name === '日本語');
|
|
89
|
+
// 11. Unknown route: the vendor-shaped error envelope, not a bare string or an HTML page.
|
|
90
|
+
const unknown = await h('GET', '/never-a-real-endpoint');
|
|
91
|
+
const unknownBody = unknown.body;
|
|
92
|
+
check('an unknown route answers 404 with the {code, message} envelope', unknown.status === 404 && typeof unknownBody.code === 'string' && typeof unknownBody.message === 'string');
|
|
93
|
+
// 12. ROUTER_SURFACE: every ratified op is exercised, and each must answer EITHER a modeled
|
|
94
|
+
// outcome or the loud gap. A route that is served but not claimed here shows up as a
|
|
95
|
+
// modeled-looking answer for an op MODELED_OPS does not list.
|
|
96
|
+
let surfaceOk = true;
|
|
97
|
+
for (const op of OPS) {
|
|
98
|
+
const probe = await h(op.method, op.path, op.method === 'GET' ? undefined : body({ userId: 'probe_user', event: 'Probe', groupId: 'g', previousId: 'p', batch: [], writeKey: KEY }));
|
|
99
|
+
const msg = String(probe.body?.message ?? '');
|
|
100
|
+
const isGap = probe.status === 404 && msg.includes('[twin gap]');
|
|
101
|
+
if (MODELED_OPS.has(op.id) ? isGap : !isGap)
|
|
102
|
+
surfaceOk = false;
|
|
103
|
+
}
|
|
104
|
+
check('every ratified op answers either a modeled outcome or the loud gap, and no other', surfaceOk);
|
|
105
|
+
return { ok: failures.length === 0, checksRun, failures };
|
|
106
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { TwinAction } from '@volter/world-core';
|
|
2
|
+
import { SegmentBudget, type SegmentBudgetOptions } from './segment-budget.js';
|
|
3
|
+
export type SegmentExecute = (req: {
|
|
4
|
+
method: string;
|
|
5
|
+
path: string;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
}) => Promise<{
|
|
8
|
+
status: number;
|
|
9
|
+
data: unknown;
|
|
10
|
+
}>;
|
|
11
|
+
/** The real Segment tracking host. The ONLY place this pack names it as a live target. */
|
|
12
|
+
export declare const SEGMENT_API_BASE = "https://api.segment.io";
|
|
13
|
+
/**
|
|
14
|
+
* THE CHOKE POINT — the one place this pack builds a `SegmentExecute` that really reaches
|
|
15
|
+
* api.segment.io. Reads the source write key from `SEGMENT_WRITE_KEY` (never a literal).
|
|
16
|
+
*
|
|
17
|
+
* AUTH: this sends the scheme the PINNED SDK sends, not the scaffold's guess. @segment/
|
|
18
|
+
* analytics-node@2 puts the write key IN THE BODY and sets no Authorization header at all
|
|
19
|
+
* (src/plugins/segmentio/publisher.ts:230-247 — Content-Type and User-Agent only, plus a Bearer
|
|
20
|
+
* header ONLY when oauthSettings are configured), and the vendor documents that as its first
|
|
21
|
+
* auth option: "The authentication writeKey should be sent as part of the body of the request...
|
|
22
|
+
* For this auth type, you do not need to set any authentication header." A `Bearer <writeKey>`
|
|
23
|
+
* header — what the S4 scaffold emits by default — is not one of the three documented schemes and
|
|
24
|
+
* would authenticate nothing (ruled: denominator:auth-is-three-documented-schemes-not-one). So
|
|
25
|
+
* the key is STAMPED INTO THE ENVELOPE here, and `sentAt` alongside it, exactly as the SDK does.
|
|
26
|
+
* No vendor SDK is imported: real-transport `fetch` keeps the pack SDK-free at runtime (B3).
|
|
27
|
+
*/
|
|
28
|
+
export declare function liveSegmentExecute(opts?: {
|
|
29
|
+
apiKey?: string;
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
/** An existing budget to share across executes. Omit and one is constructed. Cannot be null. */
|
|
33
|
+
budget?: SegmentBudget;
|
|
34
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
35
|
+
budgetOptions?: SegmentBudgetOptions;
|
|
36
|
+
/** Frozen clock for the envelope's `sentAt`; tests inject it, production omits it. */
|
|
37
|
+
now?: () => Date;
|
|
38
|
+
}): SegmentExecute;
|
|
39
|
+
/** The auth step the docblock above promises, made real: the write key rides INSIDE the envelope
|
|
40
|
+
* (`writeKey`), with `sentAt` beside it, exactly as @segment/analytics-node@2's Publisher builds
|
|
41
|
+
* it. Only an ABSENT key is filled — an envelope that already names one (a different source, a
|
|
42
|
+
* caller override) is left exactly as it is. This is the mixpanel F7 class, pre-empted: a
|
|
43
|
+
* docblock claiming a stamping no call site performed sent every replayed message unauthenticated. */
|
|
44
|
+
export declare function stampEnvelope(body: unknown, writeKey: string, sentAt: Date): unknown;
|
|
45
|
+
/** D7 entry point. v1 pulls nothing — see the file header for the vendor fact that makes it so,
|
|
46
|
+
* and segment-capabilities.ts's `segment.connector.pull` todo for the filed gap. `syncPull` is
|
|
47
|
+
* still called with the (empty) resource list so the shadow-diff contract holds and a later
|
|
48
|
+
* version only has to fill the array. */
|
|
49
|
+
export declare function syncSegmentFromReal(_execute: SegmentExecute, options?: {
|
|
50
|
+
root?: string;
|
|
51
|
+
occurredAt?: string;
|
|
52
|
+
}): Promise<{
|
|
53
|
+
pulled: number;
|
|
54
|
+
}>;
|
|
55
|
+
/** Replay locally-ingested messages through the injected execute (push half of D7). Sends the
|
|
56
|
+
* vendor's own batch envelope — `{batch: [<message>...], writeKey, sentAt}` — which is exactly
|
|
57
|
+
* what @segment/analytics-node@2's Publisher puts on the wire, so the replay is byte-shaped like
|
|
58
|
+
* a real SDK flush rather than like this twin's internal rows. */
|
|
59
|
+
export declare function pushPendingSegmentActions(execute: SegmentExecute, options?: {
|
|
60
|
+
root?: string;
|
|
61
|
+
writeKey?: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
pushed: number;
|
|
64
|
+
failed: number;
|
|
65
|
+
refused: number;
|
|
66
|
+
}>;
|
|
67
|
+
/** Rebuild the vendor ENVELOPE this action recorded. The twin folds one flush into ONE action
|
|
68
|
+
* whose `projection.creates` carries every event row of that flush, so the replay reconstructs
|
|
69
|
+
* the same `{batch: [...]}` the SDK originally sent rather than exploding it into single-message
|
|
70
|
+
* requests. Two traps this closes:
|
|
71
|
+
* • the twin stores the discriminator as `messageType`, because the kernel's META set silently
|
|
72
|
+
* drops a field literally named `type` (control-plane/src/actions.ts) — so the replay has to
|
|
73
|
+
* map it BACK to `type`, or every replayed message would reach the real vendor typeless;
|
|
74
|
+
* • `action.fields` alone is only the FIRST message of the flush, so reading it instead of the
|
|
75
|
+
* projection would silently push one message and confirm the whole batch. */
|
|
76
|
+
export declare function pushEnvelopeFor(action: TwinAction, writeKey?: string): Record<string, unknown>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Segment CONNECTOR — pull/push against an INJECTED `SegmentExecute`, so every test and every
|
|
2
|
+
// capability verify runs offline against a deterministic fake (the repo-wide connector
|
|
3
|
+
// discipline: the real network exists only inside liveSegmentExecute).
|
|
4
|
+
//
|
|
5
|
+
// v1 scope, honestly: PUSH replays locally-ingested messages back through the injected execute
|
|
6
|
+
// onto the vendor's real POST /v1/batch. PULL mirrors NOTHING, and that is a FACT about this
|
|
7
|
+
// vendor rather than an unfinished job: Segment's tracking plane has NO read-back operation at
|
|
8
|
+
// all. Every one of the sixteen ratified ops in var/line/segment/SURFACE.json is a WRITE (fifteen
|
|
9
|
+
// ingestion routes plus an OAuth token exchange); the vendor's own answer to "did my event land"
|
|
10
|
+
// is the browser Source Debugger, a live websocket view (segment-docs src/connections/sources/
|
|
11
|
+
// debugger.md), not an API. The nearest read surface is the Profile API on a DIFFERENT host
|
|
12
|
+
// (profiles.segment.com/v1/spaces/{spaceId}/collections/users/profiles/{id}/{traits,events,
|
|
13
|
+
// external_ids,metadata,links}) behind a DIFFERENT credential (an Engage access token as HTTP
|
|
14
|
+
// Basic username) and a different product tier, and it returns resolved PROFILES, never the raw
|
|
15
|
+
// ingested events this twin folds. So there is nothing on the modeled plane to mirror. The gap is
|
|
16
|
+
// FILED as the segment.connector.pull todo in segment-capabilities.ts and ruled in
|
|
17
|
+
// var/line/segment/RULINGS.json (denominator:profile-api-is-the-only-read-back) — never faked
|
|
18
|
+
// with an empty resource list, because a connector that maps "no read path" to "an empty account"
|
|
19
|
+
// hands syncPull an empty account to fold over real observed state (ADDING_A_TWIN §6).
|
|
20
|
+
import { assertBudgetGuardIntact, assertFastForward, confirmAction, NonFastForwardPushError, pendingActions, syncPull } from '@volter/world-core';
|
|
21
|
+
import { SegmentBudget, SegmentBudgetError, segmentCallWeight } from "./segment-budget.js";
|
|
22
|
+
const SERVICE = 'segment';
|
|
23
|
+
/** The real Segment tracking host. The ONLY place this pack names it as a live target. */
|
|
24
|
+
export const SEGMENT_API_BASE = 'https://api.segment.io';
|
|
25
|
+
/**
|
|
26
|
+
* THE CHOKE POINT — the one place this pack builds a `SegmentExecute` that really reaches
|
|
27
|
+
* api.segment.io. Reads the source write key from `SEGMENT_WRITE_KEY` (never a literal).
|
|
28
|
+
*
|
|
29
|
+
* AUTH: this sends the scheme the PINNED SDK sends, not the scaffold's guess. @segment/
|
|
30
|
+
* analytics-node@2 puts the write key IN THE BODY and sets no Authorization header at all
|
|
31
|
+
* (src/plugins/segmentio/publisher.ts:230-247 — Content-Type and User-Agent only, plus a Bearer
|
|
32
|
+
* header ONLY when oauthSettings are configured), and the vendor documents that as its first
|
|
33
|
+
* auth option: "The authentication writeKey should be sent as part of the body of the request...
|
|
34
|
+
* For this auth type, you do not need to set any authentication header." A `Bearer <writeKey>`
|
|
35
|
+
* header — what the S4 scaffold emits by default — is not one of the three documented schemes and
|
|
36
|
+
* would authenticate nothing (ruled: denominator:auth-is-three-documented-schemes-not-one). So
|
|
37
|
+
* the key is STAMPED INTO THE ENVELOPE here, and `sentAt` alongside it, exactly as the SDK does.
|
|
38
|
+
* No vendor SDK is imported: real-transport `fetch` keeps the pack SDK-free at runtime (B3).
|
|
39
|
+
*/
|
|
40
|
+
export function liveSegmentExecute(opts = {}) {
|
|
41
|
+
const apiKey = opts.apiKey ?? process.env.SEGMENT_WRITE_KEY;
|
|
42
|
+
const base = (opts.baseUrl ?? SEGMENT_API_BASE).replace(/\/$/, '');
|
|
43
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
44
|
+
const now = opts.now ?? (() => new Date());
|
|
45
|
+
if (!apiKey)
|
|
46
|
+
throw new Error('liveSegmentExecute: SEGMENT_WRITE_KEY is not set (never pass a literal)');
|
|
47
|
+
// No caller value yields an unguarded execute: omitted/null builds the default; anything else
|
|
48
|
+
// must be an UNMODIFIED SegmentBudget. `instanceof` alone is NOT a check — a one-line subclass
|
|
49
|
+
// or a Proxy trapping `get` satisfies it and disables the ceiling — so the kernel's
|
|
50
|
+
// method-identity assertion is used instead (docs/contributing/architecture.md D8).
|
|
51
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
52
|
+
? assertBudgetGuardIntact(opts.budget, SegmentBudget, 'liveSegmentExecute')
|
|
53
|
+
: new SegmentBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
54
|
+
return async ({ method, path, body }) => {
|
|
55
|
+
const weight = segmentCallWeight(method, path);
|
|
56
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
57
|
+
const reservation = budget.checkBudget(weight);
|
|
58
|
+
const payload = body === undefined ? undefined : stampEnvelope(body, apiKey, now());
|
|
59
|
+
const res = await doFetch(`${base}${path}`, {
|
|
60
|
+
method,
|
|
61
|
+
// "To send data to Segment's HTTP API, a content-type header must be set to
|
|
62
|
+
// 'application/json'." (http-api/index.md '### Content-Type')
|
|
63
|
+
headers: payload === undefined ? {} : { 'content-type': 'application/json' },
|
|
64
|
+
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
|
|
65
|
+
});
|
|
66
|
+
const headers = {};
|
|
67
|
+
res.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
// Settles the reservation; a 429/Retry-After arms the persisted cooldown (and may throw).
|
|
70
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
71
|
+
// call that louder refusal wins; an answer Segment ACCEPTED is kept, so a write that landed is
|
|
72
|
+
// never recorded as failed and performed again on retry.
|
|
73
|
+
try {
|
|
74
|
+
budget.recordCall(weight, headers, { status: res.status, reservation });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (!(error instanceof SegmentBudgetError) || !res.ok)
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
let data = null;
|
|
81
|
+
if (text) {
|
|
82
|
+
try {
|
|
83
|
+
data = JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
data = text;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { status: res.status, data };
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** The auth step the docblock above promises, made real: the write key rides INSIDE the envelope
|
|
93
|
+
* (`writeKey`), with `sentAt` beside it, exactly as @segment/analytics-node@2's Publisher builds
|
|
94
|
+
* it. Only an ABSENT key is filled — an envelope that already names one (a different source, a
|
|
95
|
+
* caller override) is left exactly as it is. This is the mixpanel F7 class, pre-empted: a
|
|
96
|
+
* docblock claiming a stamping no call site performed sent every replayed message unauthenticated. */
|
|
97
|
+
export function stampEnvelope(body, writeKey, sentAt) {
|
|
98
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
99
|
+
return body;
|
|
100
|
+
const env = body;
|
|
101
|
+
return {
|
|
102
|
+
...env,
|
|
103
|
+
...(typeof env.writeKey === 'string' && env.writeKey ? {} : { writeKey }),
|
|
104
|
+
...(typeof env.sentAt === 'string' && env.sentAt ? {} : { sentAt: sentAt.toISOString() }),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A pull's `occurredAt` must MOVE, never be pinned. The kernel hashes an observed event over
|
|
109
|
+
* (`occurredAt` + post-state), so under a fixed poll time a vendor value that REVERTS (A->B->A
|
|
110
|
+
* across polls) collides with its own earlier observation: `syncPull` reports a delta while
|
|
111
|
+
* nothing lands, and the projection keeps serving the stale value. Wall clock alone is not quite
|
|
112
|
+
* enough either — two polls inside one millisecond hand the kernel the same stamp — so it is
|
|
113
|
+
* forced strictly increasing within the process (the dynadot `pollTimestamp` precedent).
|
|
114
|
+
*/
|
|
115
|
+
let lastPollAt = 0;
|
|
116
|
+
function pollTimestamp() {
|
|
117
|
+
const now = Math.max(Date.now(), lastPollAt + 1);
|
|
118
|
+
lastPollAt = now;
|
|
119
|
+
return new Date(now).toISOString();
|
|
120
|
+
}
|
|
121
|
+
/** D7 entry point. v1 pulls nothing — see the file header for the vendor fact that makes it so,
|
|
122
|
+
* and segment-capabilities.ts's `segment.connector.pull` todo for the filed gap. `syncPull` is
|
|
123
|
+
* still called with the (empty) resource list so the shadow-diff contract holds and a later
|
|
124
|
+
* version only has to fill the array. */
|
|
125
|
+
export async function syncSegmentFromReal(_execute, options = {}) {
|
|
126
|
+
const resources = [];
|
|
127
|
+
// Kernel API takes an OPTIONS OBJECT; occurredAt is required (positional args die at runtime).
|
|
128
|
+
syncPull({
|
|
129
|
+
service: SERVICE,
|
|
130
|
+
resources,
|
|
131
|
+
occurredAt: options.occurredAt ?? pollTimestamp(),
|
|
132
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
133
|
+
});
|
|
134
|
+
return { pulled: resources.length };
|
|
135
|
+
}
|
|
136
|
+
/** Replay locally-ingested messages through the injected execute (push half of D7). Sends the
|
|
137
|
+
* vendor's own batch envelope — `{batch: [<message>...], writeKey, sentAt}` — which is exactly
|
|
138
|
+
* what @segment/analytics-node@2's Publisher puts on the wire, so the replay is byte-shaped like
|
|
139
|
+
* a real SDK flush rather than like this twin's internal rows. */
|
|
140
|
+
export async function pushPendingSegmentActions(execute, options = {}) {
|
|
141
|
+
let pushed = 0;
|
|
142
|
+
let failed = 0;
|
|
143
|
+
let refused = 0;
|
|
144
|
+
for (const action of pendingActions(SERVICE, options.root)) {
|
|
145
|
+
// Only ingested flushes are pushable in v1; a `drop.messages` action records messages the
|
|
146
|
+
// VENDOR would have rejected, so replaying them would re-send data Segment already refused.
|
|
147
|
+
if (!action.operation?.startsWith('ingest.'))
|
|
148
|
+
continue;
|
|
149
|
+
// R14 non-fast-forward gate (THE reference enrollment for the connector sweep): the mirror
|
|
150
|
+
// moving past this action's stamped basis means someone else changed the remote since it was
|
|
151
|
+
// authored — pushing would overwrite them. The action is SKIPPED (stays pending, per-ref like
|
|
152
|
+
// git), counted, and the operator fetches + reconciles, then reverts/re-authors or pushes
|
|
153
|
+
// with a reviewed force through the kernel seam. The check sits IMMEDIATELY before the
|
|
154
|
+
// vendor write: any later and the refusal arrives after the damage.
|
|
155
|
+
try {
|
|
156
|
+
assertFastForward(SERVICE, action.id, options.root);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error instanceof NonFastForwardPushError) {
|
|
160
|
+
refused += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
const envelope = pushEnvelopeFor(action, options.writeKey);
|
|
166
|
+
const res = await execute({ method: 'POST', path: '/v1/batch', body: envelope });
|
|
167
|
+
// "Segment returns a 200 response for all API requests except errors caused by large payloads
|
|
168
|
+
// and JSON errors (which return 400 responses.)" — so any 2xx is acceptance of the REQUEST.
|
|
169
|
+
// Anything else leaves the action pending rather than silently confirming it.
|
|
170
|
+
if (res.status >= 200 && res.status < 300) {
|
|
171
|
+
// The kernel's confirmAction takes an OPTIONS OBJECT — it records the pushed fields as an
|
|
172
|
+
// OBSERVED event and maps the local action onto it, so the change is counted exactly once.
|
|
173
|
+
//
|
|
174
|
+
// AND IT HAS TO CARRY THE WHOLE FLUSH. `projectResources` SUPPRESSES a confirmed action's
|
|
175
|
+
// projection outright — `if (reverted.has(a.id) || confirmed.has(a.id)) continue`
|
|
176
|
+
// (control-plane/src/actions.ts) — and rebuilds that state from the observed events the
|
|
177
|
+
// confirm wrote instead. A Segment flush is ONE action whose projection carries EVERY
|
|
178
|
+
// message of the batch plus the identities and groups they folded, so confirming with
|
|
179
|
+
// `fields` alone observes batch[0] only and DELETES the rest of the flush from the
|
|
180
|
+
// projection the moment it is pushed. Every other resource of the action therefore rides in
|
|
181
|
+
// `additionalObservations`, the kernel's own seam for a compound action
|
|
182
|
+
// (npm-registry-connector.ts is the precedent).
|
|
183
|
+
const primaryKey = `${action.subject.type}:${action.subject.id}`;
|
|
184
|
+
const additionalObservations = (action.projection?.creates ?? [])
|
|
185
|
+
.filter((c) => `${c.type}:${c.id}` !== primaryKey)
|
|
186
|
+
.map((c) => ({ subject: { type: c.type, id: c.id }, fields: c.fields }));
|
|
187
|
+
confirmAction({
|
|
188
|
+
service: SERVICE,
|
|
189
|
+
actionId: action.id,
|
|
190
|
+
subject: action.subject,
|
|
191
|
+
fields: action.fields ?? {},
|
|
192
|
+
...(additionalObservations.length ? { additionalObservations } : {}),
|
|
193
|
+
occurredAt: new Date().toISOString(),
|
|
194
|
+
...(options.root ? { root: options.root } : {}),
|
|
195
|
+
});
|
|
196
|
+
pushed += 1;
|
|
197
|
+
}
|
|
198
|
+
else
|
|
199
|
+
failed += 1;
|
|
200
|
+
}
|
|
201
|
+
return { pushed, failed, refused };
|
|
202
|
+
}
|
|
203
|
+
/** Rebuild the vendor ENVELOPE this action recorded. The twin folds one flush into ONE action
|
|
204
|
+
* whose `projection.creates` carries every event row of that flush, so the replay reconstructs
|
|
205
|
+
* the same `{batch: [...]}` the SDK originally sent rather than exploding it into single-message
|
|
206
|
+
* requests. Two traps this closes:
|
|
207
|
+
* • the twin stores the discriminator as `messageType`, because the kernel's META set silently
|
|
208
|
+
* drops a field literally named `type` (control-plane/src/actions.ts) — so the replay has to
|
|
209
|
+
* map it BACK to `type`, or every replayed message would reach the real vendor typeless;
|
|
210
|
+
* • `action.fields` alone is only the FIRST message of the flush, so reading it instead of the
|
|
211
|
+
* projection would silently push one message and confirm the whole batch. */
|
|
212
|
+
export function pushEnvelopeFor(action, writeKey) {
|
|
213
|
+
const creates = (action.projection?.creates ?? []).filter((c) => c.type === 'event');
|
|
214
|
+
const rows = creates.length ? creates.map((c) => c.fields) : [(action.fields ?? {})];
|
|
215
|
+
const batch = rows.map((f) => {
|
|
216
|
+
const message = { type: f.messageType, messageId: f.messageId };
|
|
217
|
+
for (const key of ['userId', 'anonymousId', 'event', 'name', 'category', 'groupId', 'previousId', 'properties', 'traits', 'context', 'integrations', 'timestamp']) {
|
|
218
|
+
if (f[key] !== null && f[key] !== undefined)
|
|
219
|
+
message[key] = f[key];
|
|
220
|
+
}
|
|
221
|
+
return message;
|
|
222
|
+
});
|
|
223
|
+
const first = rows[0] ?? {};
|
|
224
|
+
const resolved = writeKey ?? (typeof first.writeKey === 'string' ? first.writeKey : undefined);
|
|
225
|
+
return { batch, ...(resolved ? { writeKey: resolved } : {}) };
|
|
226
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** One row of the `events` / `dropped` store projections, as the door serves it. */
|
|
2
|
+
export type SegmentRow = Record<string, any>;
|
|
3
|
+
/** The tone a message-type pill gets. `alias` and `group` are the identity-plumbing calls, so they
|
|
4
|
+
* read differently from `track`, which is the one carrying a business event name. */
|
|
5
|
+
export type PillTone = 'track' | 'identity' | 'screenish' | 'dropped';
|
|
6
|
+
/** STATIC literal class names — a template-literal class (`pill-${tone}`) is erased by the
|
|
7
|
+
* bundler, so the mirror emits these and the verifies assert them (ADDING_A_TWIN §6). */
|
|
8
|
+
export declare const PILL_CLASS: Record<PillTone, string>;
|
|
9
|
+
/** The tone for a message type. Pure function of the vendor's own six call types. */
|
|
10
|
+
export declare function typeTone(messageType: unknown): PillTone;
|
|
11
|
+
/**
|
|
12
|
+
* The LABEL the debugger shows for a message — what the vendor's own stream leads each row with.
|
|
13
|
+
* A `track` is named by its `event`; a `page`/`screen` by its `name`; the identity calls by the
|
|
14
|
+
* subject they resolve. Never the message id, which tells a reader nothing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function messageLabel(row: SegmentRow): string;
|
|
17
|
+
/** The subject a row is attributed to — `userId` when Segment could resolve one, else the
|
|
18
|
+
* anonymous id. A row with neither is precisely the one the vendor drops. */
|
|
19
|
+
export declare function subjectOf(row: SegmentRow): string;
|
|
20
|
+
/** Flatten one level of a properties/traits object into `key=value` chips, sorted so a rendered
|
|
21
|
+
* row is byte-identical on replay. Nested values render as compact JSON. */
|
|
22
|
+
export declare function propertyChips(value: unknown): Array<{
|
|
23
|
+
key: string;
|
|
24
|
+
value: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** `2026-03-01T12:00:00.000Z` → `12:00:00` — the wall-position the debugger's stream reads by.
|
|
27
|
+
* Pure string slicing, so it neither reads a clock nor depends on the machine's zone. */
|
|
28
|
+
export declare function clockLabel(iso: unknown): string;
|
|
29
|
+
/** Build the React/TSX debugger client to browser JS (Bun bundles TSX); cached per process. */
|
|
30
|
+
export declare function buildSegmentMirrorClient(): Promise<string>;
|
|
31
|
+
/** Serve the Source Debugger mirror (React app) + the twin's own fetch adapter behind it. */
|
|
32
|
+
export declare function createSegmentMirrorServer(options?: {
|
|
33
|
+
root?: string;
|
|
34
|
+
port?: number;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
port: number;
|
|
37
|
+
stop: () => void;
|
|
38
|
+
}>;
|
|
39
|
+
/** The app-shell HTML (pure, for tests). The debugger itself is the React client. */
|
|
40
|
+
export declare function segmentMirrorHtml(): string;
|
|
41
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
42
|
+
export declare function segmentMirrorStyles(): Promise<string>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SEGMENT MIRROR UI — the SOURCE DEBUGGER, served as a React/TSX app (bundled by Bun).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS SCREEN AND NOT ANOTHER. The Segment HTTP Tracking API is WRITE-ONLY: every one of its
|
|
4
|
+
// sixteen ratified operations is an ingest, and none of them reads a message back. So the vendor's
|
|
5
|
+
// own answer to "my event returned 200 and never arrived" is not an API call — it is the Source
|
|
6
|
+
// Debugger, the live stream of what the source actually received. That asymmetry is exactly why
|
|
7
|
+
// ui-scope.json rules this vendor OWED a mirror, and it is why the debugger is the FIRST screen:
|
|
8
|
+
// without it a caller has a 200 and nothing else.
|
|
9
|
+
//
|
|
10
|
+
// DATA COUPLING. The client reads the twin's OWN store door (`GET /twin/store/events`,
|
|
11
|
+
// `…/dropped`), the kernel's read-only named-projection door and the sanctioned way a mirror reads
|
|
12
|
+
// twin state. There is no mirror-local view model and no second copy of the projection: what the
|
|
13
|
+
// screen shows is what `events(root)` folded from real ingests, so a caller who POSTs through the
|
|
14
|
+
// unmodified `@segment/analytics-node` client sees that message on this screen.
|
|
15
|
+
//
|
|
16
|
+
// THE DROPPED PANE IS THE POINT. Segment answers 200 and then silently drops a message it cannot
|
|
17
|
+
// attribute; this twin RECORDS the refusal (`dropped(root)`, with a reason). The debugger shows
|
|
18
|
+
// both streams side by side, so "returned 200 and never arrived" stops being invisible — which is
|
|
19
|
+
// the exact failure the vendor tells you to open this screen for.
|
|
20
|
+
//
|
|
21
|
+
// PURE FRONTEND (R3): this module imports no handler and no twin internals. It MOUNTS the pack's
|
|
22
|
+
// own fetch adapter as the API backend and the client reads every byte of state back over the wire.
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { bundleClient, fileResponse } from '@volter/world-core';
|
|
25
|
+
import { serveHttp } from '@volter/world-core';
|
|
26
|
+
import { createSegmentTwinFetch } from "./segment-server.js";
|
|
27
|
+
const CLIENT_ENTRY = () => new URL('../client/segment-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
|
|
28
|
+
const CLIENT_CSS = () => new URL('../client/segment-mirror.css', import.meta.url).pathname; // lazy: same
|
|
29
|
+
/** STATIC literal class names — a template-literal class (`pill-${tone}`) is erased by the
|
|
30
|
+
* bundler, so the mirror emits these and the verifies assert them (ADDING_A_TWIN §6). */
|
|
31
|
+
export const PILL_CLASS = {
|
|
32
|
+
track: 'pill pill-track',
|
|
33
|
+
identity: 'pill pill-identity',
|
|
34
|
+
screenish: 'pill pill-screenish',
|
|
35
|
+
dropped: 'pill pill-dropped',
|
|
36
|
+
};
|
|
37
|
+
/** The tone for a message type. Pure function of the vendor's own six call types. */
|
|
38
|
+
export function typeTone(messageType) {
|
|
39
|
+
switch (String(messageType ?? '')) {
|
|
40
|
+
case 'track': return 'track';
|
|
41
|
+
case 'identify':
|
|
42
|
+
case 'alias':
|
|
43
|
+
case 'group': return 'identity';
|
|
44
|
+
default: return 'screenish';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The LABEL the debugger shows for a message — what the vendor's own stream leads each row with.
|
|
49
|
+
* A `track` is named by its `event`; a `page`/`screen` by its `name`; the identity calls by the
|
|
50
|
+
* subject they resolve. Never the message id, which tells a reader nothing.
|
|
51
|
+
*/
|
|
52
|
+
export function messageLabel(row) {
|
|
53
|
+
const type = String(row.messageType ?? '');
|
|
54
|
+
if (type === 'track')
|
|
55
|
+
return String(row.event ?? '(unnamed event)');
|
|
56
|
+
if (type === 'page' || type === 'screen')
|
|
57
|
+
return String(row.name ?? '(unnamed page)');
|
|
58
|
+
if (type === 'group')
|
|
59
|
+
return `group ${String(row.groupId ?? '?')}`;
|
|
60
|
+
if (type === 'alias')
|
|
61
|
+
return `alias ${String(row.previousId ?? '?')} → ${String(row.userId ?? '?')}`;
|
|
62
|
+
return String(row.userId ?? row.anonymousId ?? '(anonymous)');
|
|
63
|
+
}
|
|
64
|
+
/** The subject a row is attributed to — `userId` when Segment could resolve one, else the
|
|
65
|
+
* anonymous id. A row with neither is precisely the one the vendor drops. */
|
|
66
|
+
export function subjectOf(row) {
|
|
67
|
+
const userId = row.userId === null || row.userId === undefined ? '' : String(row.userId);
|
|
68
|
+
if (userId !== '')
|
|
69
|
+
return userId;
|
|
70
|
+
const anon = row.anonymousId === null || row.anonymousId === undefined ? '' : String(row.anonymousId);
|
|
71
|
+
return anon === '' ? '—' : `anon:${anon}`;
|
|
72
|
+
}
|
|
73
|
+
/** Flatten one level of a properties/traits object into `key=value` chips, sorted so a rendered
|
|
74
|
+
* row is byte-identical on replay. Nested values render as compact JSON. */
|
|
75
|
+
export function propertyChips(value) {
|
|
76
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
77
|
+
return [];
|
|
78
|
+
return Object.entries(value)
|
|
79
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
80
|
+
.map(([key, v]) => ({ key, value: typeof v === 'string' ? v : JSON.stringify(v) ?? 'null' }));
|
|
81
|
+
}
|
|
82
|
+
/** `2026-03-01T12:00:00.000Z` → `12:00:00` — the wall-position the debugger's stream reads by.
|
|
83
|
+
* Pure string slicing, so it neither reads a clock nor depends on the machine's zone. */
|
|
84
|
+
export function clockLabel(iso) {
|
|
85
|
+
const s = String(iso ?? '');
|
|
86
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(s) ? s.slice(11, 19) : '—';
|
|
87
|
+
}
|
|
88
|
+
const APP_SHELL = `<!doctype html>
|
|
89
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
90
|
+
<base href="/"><title>Segment Source Debugger (twin)</title><link rel="stylesheet" href="assets/styles.css"></head>
|
|
91
|
+
<body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
|
|
92
|
+
let clientBundle = null;
|
|
93
|
+
/** Build the React/TSX debugger client to browser JS (Bun bundles TSX); cached per process. */
|
|
94
|
+
export function buildSegmentMirrorClient() {
|
|
95
|
+
if (!clientBundle) {
|
|
96
|
+
clientBundle = bundleClient(CLIENT_ENTRY())
|
|
97
|
+
.catch((error) => { clientBundle = null; throw error; });
|
|
98
|
+
}
|
|
99
|
+
return clientBundle;
|
|
100
|
+
}
|
|
101
|
+
/** Serve the Source Debugger mirror (React app) + the twin's own fetch adapter behind it. */
|
|
102
|
+
export async function createSegmentMirrorServer(options = {}) {
|
|
103
|
+
const twin = createSegmentTwinFetch(options);
|
|
104
|
+
const server = await serveHttp({
|
|
105
|
+
// LOOPBACK-SPECIFIC bind: with the default wildcard hostname, `port: 0` can be handed a port
|
|
106
|
+
// some long-running app already LISTENS on at 127.0.0.1, and that more specific listener then
|
|
107
|
+
// shadows this server for every 127.0.0.1 fetch — the verify would talk to a stranger.
|
|
108
|
+
hostname: '127.0.0.1',
|
|
109
|
+
port: options.port ?? 0,
|
|
110
|
+
idleTimeout: 60,
|
|
111
|
+
async fetch(request) {
|
|
112
|
+
const url = new URL(request.url);
|
|
113
|
+
if (request.method === 'GET' && url.pathname === '/assets/app.js') {
|
|
114
|
+
try {
|
|
115
|
+
return new Response(await buildSegmentMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return new Response(String(error), { status: 500 });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
|
|
122
|
+
return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
123
|
+
}
|
|
124
|
+
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
|
|
125
|
+
return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
126
|
+
}
|
|
127
|
+
// Everything else → the twin's OWN FETCH ADAPTER (composition, R2). The client reads
|
|
128
|
+
// `/twin/store/events` and `/twin/store/dropped` on this same origin, and the adapter is the
|
|
129
|
+
// same closure `createSegmentTwinServer` serves — so there is exactly ONE serving code path
|
|
130
|
+
// and the mirror port cannot drift from the API port.
|
|
131
|
+
return twin(request);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
135
|
+
}
|
|
136
|
+
/** The app-shell HTML (pure, for tests). The debugger itself is the React client. */
|
|
137
|
+
export function segmentMirrorHtml() {
|
|
138
|
+
return APP_SHELL;
|
|
139
|
+
}
|
|
140
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
141
|
+
export function segmentMirrorStyles() {
|
|
142
|
+
return readFile(CLIENT_CSS(), 'utf8');
|
|
143
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Options every Segment-twin HTTP surface needs, independent of who owns the socket. */
|
|
2
|
+
export interface SegmentTwinFetchOptions {
|
|
3
|
+
root?: string;
|
|
4
|
+
}
|
|
5
|
+
/** The twin's HTTP face. Two things it must get right, both invisible to an in-process verify:
|
|
6
|
+
* 1. THE AUTHORIZATION HEADER IS PART OF THE PAYLOAD on this vendor. Two of Segment's three
|
|
7
|
+
* documented auth schemes live in that header (Basic with the write key as the username;
|
|
8
|
+
* OAuth Bearer), so a fetch that dropped it would make an analytics-node v1 client — which
|
|
9
|
+
* sends `auth: { username: writeKey }` and NO writeKey in the body — arrive anonymous.
|
|
10
|
+
* 2. An empty-body status must serve a genuinely EMPTY body: `Response.json(null)` puts the
|
|
11
|
+
* four bytes "null" on the wire (the A3-caught serialization class in
|
|
12
|
+
* var/line/mixpanel/REVIEW.md).
|
|
13
|
+
*
|
|
14
|
+
* FETCH-FIRST (runtime contract R12b): this is the pack's whole HTTP surface as a plain fetch,
|
|
15
|
+
* and `createSegmentTwinServer` is one line of `Bun.serve` around it. It is a CUSTOM fetch, not
|
|
16
|
+
* the kernel adapter (`createTwinFetchFromHandler`): the authorization threading (1) and the
|
|
17
|
+
* handler-throw guard (below) are this pack's own. The keyless `GET /twin` manifest door the
|
|
18
|
+
* adapter would have provided is mounted here by hand, answering the same
|
|
19
|
+
* `statefulTwinManifest` shape — discovery is a door every twin owes (serve-path determinism
|
|
20
|
+
* R9 replays it), and `/twin` shadows no Segment route: the Tracking API lives under `/v1/*`. */
|
|
21
|
+
export declare function createSegmentTwinFetch(options?: SegmentTwinFetchOptions): (request: Request) => Promise<Response>;
|
|
22
|
+
export declare function createSegmentTwinServer(options?: {
|
|
23
|
+
port?: number;
|
|
24
|
+
root?: string;
|
|
25
|
+
}): Promise<import("@volter/world-core").HttpServer>;
|