@volter/twin-x 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.
Files changed (42) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +138 -0
  3. package/dist/client/x-mirror.bundle.js +321 -0
  4. package/dist/client/x-mirror.d.ts +45 -0
  5. package/dist/client/x-mirror.js +417 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +29 -0
  8. package/dist/src/index.d.ts +14 -0
  9. package/dist/src/index.js +68 -0
  10. package/dist/src/x-budget.d.ts +54 -0
  11. package/dist/src/x-budget.js +123 -0
  12. package/dist/src/x-capabilities.d.ts +3 -0
  13. package/dist/src/x-capabilities.js +1106 -0
  14. package/dist/src/x-conformance.d.ts +8 -0
  15. package/dist/src/x-conformance.js +91 -0
  16. package/dist/src/x-connector.d.ts +125 -0
  17. package/dist/src/x-connector.js +546 -0
  18. package/dist/src/x-media.d.ts +87 -0
  19. package/dist/src/x-media.js +275 -0
  20. package/dist/src/x-mirror-ui.d.ts +61 -0
  21. package/dist/src/x-mirror-ui.js +253 -0
  22. package/dist/src/x-problems.d.ts +38 -0
  23. package/dist/src/x-problems.js +130 -0
  24. package/dist/src/x-scopes.d.ts +7 -0
  25. package/dist/src/x-scopes.js +62 -0
  26. package/dist/src/x-server.d.ts +14 -0
  27. package/dist/src/x-server.js +127 -0
  28. package/dist/src/x-twin.d.ts +21 -0
  29. package/dist/src/x-twin.js +1534 -0
  30. package/package.json +58 -0
  31. package/src/cli.ts +27 -0
  32. package/src/index.ts +132 -0
  33. package/src/x-budget.ts +150 -0
  34. package/src/x-capabilities.ts +1161 -0
  35. package/src/x-conformance.ts +113 -0
  36. package/src/x-connector.ts +546 -0
  37. package/src/x-media.ts +295 -0
  38. package/src/x-mirror-ui.ts +263 -0
  39. package/src/x-problems.ts +143 -0
  40. package/src/x-scopes.ts +67 -0
  41. package/src/x-server.ts +126 -0
  42. package/src/x-twin.ts +1545 -0
@@ -0,0 +1,275 @@
1
+ // Media BYTES and upload sessions for the X twin — what `POST /2/media/upload` (one-shot) and the
2
+ // chunked initialize / append / finalize protocol hold before a post attaches the media.
3
+ //
4
+ // The method is the slack pack's (slack-blobs.ts), transcribed. Bytes ride the KERNEL'S BLOB SEAM
5
+ // (`getActiveBlobStore()`, runtime contract R11), content-addressed by sha256, under this service's
6
+ // own `resources` directory, which `world scrub` already owns. An upload is NOT a kernel action:
7
+ // at X, uploading media publishes nothing — a media id is a private, expiring handle that only a
8
+ // later `POST /2/tweets` makes public. Recording the upload through `applyTwinWrite` would create a
9
+ // durably pending entry with no vendor write of its own to perform, and wedge every deploy behind
10
+ // it. So the upload SESSION (who uploaded it, its category, its segments, its finished digest) is a
11
+ // bare pointer record in the same byte annex, carrying no approval weight. What becomes state is
12
+ // the POST: the post's own entry names each attached media by key, id and digest, and that is the
13
+ // record a deploy performs (x-connector.ts uploads the bytes, then posts).
14
+ //
15
+ // Reads go through `readResourceBlob`, which looks in this branch and then its retained ancestors,
16
+ // so a World branch sees media its parent uploaded and a perform finds the bytes the post names.
17
+ import { join } from 'node:path';
18
+ import { blobDigest, getActiveBlobStore, readResourceBlob, resourceChain, worldPaths } from '@volter/world-core';
19
+ const SERVICE = 'x';
20
+ /** X keeps an uploaded media id usable for a day (`expires_after_secs: 86400` on its answers). */
21
+ export const MEDIA_EXPIRES_AFTER_SECS = 86_400;
22
+ /** X's image ceiling: 5 MB. */
23
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
24
+ /** X's video ceiling: 512 MB. */
25
+ export const MAX_VIDEO_BYTES = 512 * 1024 * 1024;
26
+ /** A `tweet_video` may run at most 140 seconds (longer videos are an `amplify_video` or a Premium entitlement). */
27
+ export const MAX_TWEET_VIDEO_MS = 140_000;
28
+ const resourcesDir = (root) => worldPaths(SERVICE, root).resources;
29
+ const blobKey = (sha256) => join('blobs', 'sha256', sha256.slice(0, 2), sha256);
30
+ const recordKey = (id) => join('media', `${safeId(id)}.json`);
31
+ const segmentKey = (id, index) => join('media-segments', safeId(id), String(index).padStart(4, '0'));
32
+ const typeKey = (sha256) => join('blob-types', `${sha256}.json`);
33
+ /** The direct children under `relative` across the chain, nearest branch first per name. */
34
+ async function listAcross(relative, root) {
35
+ const found = new Map();
36
+ for (const dir of resourceChain(SERVICE, root)) {
37
+ const prefix = `${join(dir, relative)}/`;
38
+ for (const key of await getActiveBlobStore().list(prefix)) {
39
+ if (!key.startsWith(prefix))
40
+ continue;
41
+ const name = key.slice(prefix.length);
42
+ if (name.includes('/') || found.has(name))
43
+ continue;
44
+ found.set(name, key);
45
+ }
46
+ }
47
+ return found;
48
+ }
49
+ function safeId(id) {
50
+ if (!/^\d{1,19}$/.test(id))
51
+ throw new Error(`invalid X media id for storage: ${JSON.stringify(id)}`);
52
+ return id;
53
+ }
54
+ /** Store bytes content-addressed; a re-upload of identical content is a no-op write. The MIME
55
+ * type the upload was read as rides beside them, so the bytes routes answer with the type the
56
+ * twin decided, never one a URL's extension claims. */
57
+ export async function putXBlob(bytes, contentType, root) {
58
+ const sha256 = blobDigest(bytes);
59
+ const key = join(resourcesDir(root), blobKey(sha256));
60
+ if (!(await getActiveBlobStore().exists(key)))
61
+ await getActiveBlobStore().put(key, bytes);
62
+ await getActiveBlobStore().put(join(resourcesDir(root), typeKey(sha256)), new TextEncoder().encode(JSON.stringify({ content_type: contentType })));
63
+ return { sha256, size: bytes.length };
64
+ }
65
+ /** The MIME type stored bytes were uploaded as (this branch, then its ancestors). */
66
+ export async function readXBlobType(sha256, root) {
67
+ if (!/^[0-9a-f]{64}$/.test(sha256))
68
+ return undefined;
69
+ try {
70
+ const stored = await readResourceBlob(SERVICE, typeKey(sha256), root);
71
+ const type = stored === null ? undefined : JSON.parse(new TextDecoder().decode(stored)).content_type;
72
+ return typeof type === 'string' ? type : undefined;
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ /** Stored bytes by digest (this branch, then its ancestors); null when absent or malformed. */
79
+ export async function readXBlob(sha256, root) {
80
+ if (!/^[0-9a-f]{64}$/.test(sha256))
81
+ return null;
82
+ return await readResourceBlob(SERVICE, blobKey(sha256), root);
83
+ }
84
+ export async function writeMediaRecord(record, root) {
85
+ await getActiveBlobStore().put(join(resourcesDir(root), recordKey(record.id)), new TextEncoder().encode(JSON.stringify(record)));
86
+ }
87
+ export async function readMediaRecord(id, root) {
88
+ if (!/^\d{1,19}$/.test(id))
89
+ return undefined;
90
+ try {
91
+ const stored = await readResourceBlob(SERVICE, recordKey(id), root);
92
+ if (stored === null)
93
+ return undefined;
94
+ const parsed = JSON.parse(new TextDecoder().decode(stored));
95
+ return typeof parsed.id === 'string' ? parsed : undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ /** Every media id this branch or an ancestor has minted — so a new media id (and a new post id)
102
+ * steps past them all. */
103
+ export async function listMediaIds(root) {
104
+ return [...(await listAcross('media', root)).keys()]
105
+ .filter((name) => name.endsWith('.json'))
106
+ .map((name) => name.slice(0, -'.json'.length))
107
+ .filter((id) => /^\d+$/.test(id));
108
+ }
109
+ /** One APPEND segment, stored under its index (a retried index overwrites, as at X). */
110
+ export async function putSegment(id, index, bytes, root) {
111
+ await getActiveBlobStore().put(join(resourcesDir(root), segmentKey(id, index)), bytes);
112
+ }
113
+ /** Every segment of an upload, in index order, across the branch chain (the nearest copy of an
114
+ * index wins, as a retried APPEND overwrites at X). */
115
+ export async function readSegments(id, root) {
116
+ const found = await listAcross(join('media-segments', safeId(id)), root);
117
+ const keys = [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, key]) => key);
118
+ const out = [];
119
+ for (const key of keys) {
120
+ const bytes = await getActiveBlobStore().get(key);
121
+ if (bytes !== null)
122
+ out.push(bytes);
123
+ }
124
+ return out;
125
+ }
126
+ export async function clearSegments(id, root) {
127
+ const prefix = `${join(resourcesDir(root), 'media-segments', safeId(id))}/`;
128
+ for (const key of await getActiveBlobStore().list(prefix)) {
129
+ try {
130
+ await getActiveBlobStore().remove(key);
131
+ }
132
+ catch { /* already gone */ }
133
+ }
134
+ }
135
+ export function sniffImage(bytes) {
136
+ const b = bytes;
137
+ const u16be = (i) => (b[i] << 8) | b[i + 1];
138
+ const u16le = (i) => b[i] | (b[i + 1] << 8);
139
+ const u24le = (i) => b[i] | (b[i + 1] << 8) | (b[i + 2] << 16);
140
+ const u32be = (i) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3];
141
+ if (b.length >= 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
142
+ return { mediaType: 'image/png', width: u32be(16), height: u32be(20) };
143
+ }
144
+ if (b.length >= 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) {
145
+ return { mediaType: 'image/gif', width: u16le(6), height: u16le(8) };
146
+ }
147
+ if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
148
+ // walk the marker segments to the first start-of-frame (SOF0..SOF15 except DHT/JPG/DAC)
149
+ let i = 2;
150
+ while (i + 9 < b.length) {
151
+ if (b[i] !== 0xff) {
152
+ i += 1;
153
+ continue;
154
+ }
155
+ const marker = b[i + 1];
156
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
157
+ i += 2;
158
+ continue;
159
+ }
160
+ const length = u16be(i + 2);
161
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
162
+ return { mediaType: 'image/jpeg', height: u16be(i + 5), width: u16be(i + 7) };
163
+ }
164
+ i += 2 + length;
165
+ }
166
+ return undefined;
167
+ }
168
+ if (b.length >= 30 && String.fromCharCode(...b.slice(0, 4)) === 'RIFF' && String.fromCharCode(...b.slice(8, 12)) === 'WEBP') {
169
+ const chunk = String.fromCharCode(...b.slice(12, 16));
170
+ if (chunk === 'VP8 ')
171
+ return { mediaType: 'image/webp', width: u16le(26) & 0x3fff, height: u16le(28) & 0x3fff };
172
+ if (chunk === 'VP8L') {
173
+ const bits = b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24);
174
+ return { mediaType: 'image/webp', width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
175
+ }
176
+ if (chunk === 'VP8X')
177
+ return { mediaType: 'image/webp', width: u24le(24) + 1, height: u24le(27) + 1 };
178
+ }
179
+ return undefined;
180
+ }
181
+ /**
182
+ * An MP4 (ISO BMFF) read from its boxes: `ftyp` first, then `moov` (anywhere — ffmpeg writes it
183
+ * after `mdat` unless told to fast-start) holding `mvhd` (timescale + duration) and one `trak` per
184
+ * stream whose `tkhd` carries the presented width/height (16.16 fixed point); the video track is
185
+ * the one with a non-zero size. Nothing is decoded — the codec is the browser's business.
186
+ */
187
+ export function sniffVideo(bytes) {
188
+ const b = bytes;
189
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
190
+ const type = (at) => String.fromCharCode(b[at + 4], b[at + 5], b[at + 6], b[at + 7]);
191
+ /** The child boxes of [start, end): [type, payloadStart, boxEnd]. */
192
+ const boxes = (start, end) => {
193
+ const out = [];
194
+ let at = start;
195
+ while (at + 8 <= end) {
196
+ let size = view.getUint32(at);
197
+ let header = 8;
198
+ if (size === 1) {
199
+ if (at + 16 > end)
200
+ break;
201
+ size = Number(view.getBigUint64(at + 8));
202
+ header = 16;
203
+ }
204
+ else if (size === 0)
205
+ size = end - at;
206
+ if (size < header || at + size > end)
207
+ break;
208
+ out.push([type(at), at + header, at + size]);
209
+ at += size;
210
+ }
211
+ return out;
212
+ };
213
+ const top = boxes(0, b.length);
214
+ if (top[0]?.[0] !== 'ftyp')
215
+ return undefined;
216
+ const moov = top.find(([t]) => t === 'moov');
217
+ if (!moov)
218
+ return undefined;
219
+ let durationMs;
220
+ let width = 0;
221
+ let height = 0;
222
+ for (const [t, start, end] of boxes(moov[1], moov[2])) {
223
+ if (t === 'mvhd' && end - start >= 32) {
224
+ const version = b[start];
225
+ const timescale = version === 1 ? view.getUint32(start + 20) : view.getUint32(start + 12);
226
+ const duration = version === 1 ? Number(view.getBigUint64(start + 24)) : view.getUint32(start + 16);
227
+ if (timescale > 0)
228
+ durationMs = Math.round((duration * 1000) / timescale);
229
+ }
230
+ if (t === 'trak') {
231
+ const tkhd = boxes(start, end).find(([k]) => k === 'tkhd');
232
+ if (!tkhd)
233
+ continue;
234
+ const version = b[tkhd[1]];
235
+ const at = tkhd[1] + (version === 1 ? 88 : 76);
236
+ if (at + 8 > tkhd[2])
237
+ continue;
238
+ const w = view.getUint32(at) >>> 16;
239
+ const h = view.getUint32(at + 4) >>> 16;
240
+ if (w > 0 && h > 0 && width === 0) {
241
+ width = w;
242
+ height = h;
243
+ }
244
+ }
245
+ }
246
+ if (durationMs === undefined || width === 0)
247
+ return undefined;
248
+ return { mediaType: 'video/mp4', width, height, durationMs };
249
+ }
250
+ /**
251
+ * The video's preview image. X's is a frame its transcoder cut; the twin decodes nothing (the codec
252
+ * is the browser's), so it serves a STAND-IN at the video's own size and aspect — a dark frame with
253
+ * X's play glyph — and says so here. `x.media.video_poster_frame` is the todo for a real frame.
254
+ */
255
+ export function videoPosterSvg(width, height) {
256
+ const w = width > 0 ? width : 1280;
257
+ const h = height > 0 ? height : 720;
258
+ const r = Math.round(Math.min(w, h) * 0.09);
259
+ const cx = w / 2;
260
+ const cy = h / 2;
261
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#16181c"/>`
262
+ + `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#1d9bf0"/><path d="M${cx - r * 0.3} ${cy - r * 0.45}L${cx + r * 0.5} ${cy}L${cx - r * 0.3} ${cy + r * 0.45}Z" fill="#fff"/></svg>`;
263
+ }
264
+ /** The file extension pbs.twimg.com serves an image under. */
265
+ export function mediaExtension(mediaType) {
266
+ if (mediaType === 'image/jpeg')
267
+ return 'jpg';
268
+ if (mediaType === 'image/png')
269
+ return 'png';
270
+ if (mediaType === 'image/gif')
271
+ return 'gif';
272
+ if (mediaType === 'image/webp')
273
+ return 'webp';
274
+ return 'bin';
275
+ }
@@ -0,0 +1,61 @@
1
+ export type XRow = Record<string, any>;
2
+ /** x.com's timeline timestamp: "now"/"42s", "5m", "3h" inside a day, "Sep 26" inside the year,
3
+ * "Sep 26, 2025" before it. `nowMs` is passed in, never read. */
4
+ export declare function relativeTime(iso: unknown, nowMs: number, timeZone?: string): string;
5
+ /** The single-post page's stamp: "8:05 PM · Sep 26, 2026", in the viewer's zone. */
6
+ export declare function fullTimestamp(iso: unknown, timeZone?: string): string;
7
+ /** The profile header's "Joined September 2026". */
8
+ export declare function joinedLabel(iso: unknown, timeZone?: string): string;
9
+ /** The replies to one post among rows the viewer can read, oldest first as x.com lists them. */
10
+ export declare function repliesTo(postId: string, rows: XRow[]): XRow[];
11
+ export type TextSegment = {
12
+ kind: 'text' | 'mention' | 'hashtag' | 'url';
13
+ value: string;
14
+ };
15
+ /** A post's text split the way x.com colours it: @mentions, #hashtags and links are entities,
16
+ * everything else is plain text. Pure; the twin does not model `entities`, so the mirror reads
17
+ * them out of the text exactly as the twin's own mentions timeline does. */
18
+ export declare function textSegments(text: unknown): TextSegment[];
19
+ /** A link's target: a bare domain is https. */
20
+ export declare function linkHref(url: string): string;
21
+ /** The link text x.com shows for a URL: scheme stripped, long paths cut with an ellipsis. */
22
+ export declare function displayUrl(url: string): string;
23
+ /** The avatar placeholder's colour, a pure function of the handle (the twin serves no image). */
24
+ export declare function avatarHue(handle: unknown): number;
25
+ /** The placeholder's letter: the display name's first character, else the handle's. */
26
+ export declare function avatarInitial(user: XRow | undefined): string;
27
+ /** Users from a response's `includes`, by id. */
28
+ export declare function usersById(includes: unknown): Map<string, XRow>;
29
+ /** Posts from a response's `includes`, by id (the quoted / replied-to posts). */
30
+ export declare function tweetsById(includes: unknown): Map<string, XRow>;
31
+ /** The id a post references with the given type ('replied_to' | 'quoted'), if any. */
32
+ export declare function referencedId(post: XRow, type: 'replied_to' | 'quoted'): string | undefined;
33
+ /** Media from a response's `includes`, by media key. */
34
+ export declare function mediaByKey(includes: unknown): Map<string, XRow>;
35
+ /** The media a post carries, in its own `attachments.media_keys` order, as the read expanded them. */
36
+ export declare function postMedia(post: XRow, media: Map<string, XRow>): XRow[];
37
+ /** The playable file of a video: its MP4 variant with the highest bit rate. */
38
+ export declare function videoSource(media: XRow): string | undefined;
39
+ /** "0:03" — the duration badge x.com prints on a video. */
40
+ export declare function durationLabel(ms: unknown): string;
41
+ /** The query every post-rendering read asks for: the fields and expansions a post row draws. */
42
+ export declare const POST_READ_QUERY: string;
43
+ /** The query the profile header asks for. */
44
+ export declare const PROFILE_READ_QUERY = "user.fields=created_at,description,location,url,verified,protected,most_recent_tweet_id";
45
+ /** Build the React/TSX mirror client to browser JS (Bun bundles TSX); memoized at module scope,
46
+ * which is why a pack on its own does exactly one `Bun.build`. */
47
+ export declare function buildXMirrorClient(): Promise<string>;
48
+ /** Serve the X mirror UI (React app) + its backing X API v2 on one origin. */
49
+ export declare function createXMirrorServer(options: {
50
+ root?: string;
51
+ port?: number;
52
+ readOnly?: boolean;
53
+ }): Promise<{
54
+ port: number;
55
+ url: string;
56
+ stop: () => void;
57
+ }>;
58
+ /** The app-shell HTML (pure). The client itself is the React app. */
59
+ export declare function xMirrorHtml(): string;
60
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
61
+ export declare function xMirrorStyles(): Promise<string>;
@@ -0,0 +1,253 @@
1
+ // X MIRROR UI — x.com's own view of the twin's state: the org account's profile, its timeline,
2
+ // a single post's page, its mentions, and the compose box. A React/TSX app bundled by Bun that
3
+ // renders by consuming the twin's OWN X API v2 on the same origin (GET /2/users/by/username/:u,
4
+ // /2/users/:id/tweets, /2/users/:id/timelines/reverse_chronological, /2/users/:id/mentions,
5
+ // /2/tweets/:id) and writes through POST /2/tweets — the same routes any X client uses — so the
6
+ // screen is data-coupled to real twin state. Archetype A (passthrough), transcribed from the
7
+ // Discord mirror: there is exactly ONE serving code path, so API<->UI parity cannot drift.
8
+ //
9
+ // SIGN-IN. x.com's own login (handle, then password) with the password leg replaced by the
10
+ // bearer the twin issued through /_twin/tokens: the mirror is a client like any other and
11
+ // presents a registered token rather than bypassing the auth gate. Both are kept in the TAB'S
12
+ // sessionStorage; the mirror keeps no other state.
13
+ //
14
+ // PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's
15
+ // own fetch adapter as its API backend and reads every byte of state back over the wire.
16
+ import { readFile } from 'node:fs/promises';
17
+ import { bundleClient, fileResponse } from '@volter/world-core';
18
+ import { serveHttp } from '@volter/world-core';
19
+ import { createXTwinFetch } from "./x-server.js";
20
+ const CLIENT_ENTRY = () => new URL('../client/x-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
21
+ const CLIENT_CSS = () => new URL('../client/x-mirror.css', import.meta.url).pathname; // lazy: same reason
22
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
23
+ const LONG_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
24
+ /** A wall-clock reading of an instant in a time zone. x.com shows the VIEWER'S local time, so the
25
+ * browser passes none (its own zone); a verify passes 'UTC' so the output is a pure function. */
26
+ function wallClock(ms, timeZone) {
27
+ const parts = new Intl.DateTimeFormat('en-US', {
28
+ ...(timeZone ? { timeZone } : {}), year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', hourCycle: 'h23',
29
+ }).formatToParts(new Date(ms));
30
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? 0);
31
+ return { year: get('year'), month: get('month') - 1, day: get('day'), hour: get('hour') % 24, minute: get('minute') };
32
+ }
33
+ /** x.com's timeline timestamp: "now"/"42s", "5m", "3h" inside a day, "Sep 26" inside the year,
34
+ * "Sep 26, 2025" before it. `nowMs` is passed in, never read. */
35
+ export function relativeTime(iso, nowMs, timeZone) {
36
+ if (typeof iso !== 'string')
37
+ return '';
38
+ const t = Date.parse(iso);
39
+ if (Number.isNaN(t))
40
+ return '';
41
+ const s = Math.max(0, Math.floor((nowMs - t) / 1000));
42
+ if (s < 60)
43
+ return s < 5 ? 'now' : `${s}s`;
44
+ if (s < 3600)
45
+ return `${Math.floor(s / 60)}m`;
46
+ if (s < 86_400)
47
+ return `${Math.floor(s / 3600)}h`;
48
+ const d = wallClock(t, timeZone);
49
+ const day = `${MONTHS[d.month]} ${d.day}`;
50
+ return d.year === wallClock(nowMs, timeZone).year ? day : `${day}, ${d.year}`;
51
+ }
52
+ /** The single-post page's stamp: "8:05 PM · Sep 26, 2026", in the viewer's zone. */
53
+ export function fullTimestamp(iso, timeZone) {
54
+ if (typeof iso !== 'string')
55
+ return '';
56
+ const t = Date.parse(iso);
57
+ if (Number.isNaN(t))
58
+ return '';
59
+ const d = wallClock(t, timeZone);
60
+ const hour12 = d.hour % 12 === 0 ? 12 : d.hour % 12;
61
+ return `${hour12}:${String(d.minute).padStart(2, '0')} ${d.hour < 12 ? 'AM' : 'PM'} · ${MONTHS[d.month]} ${d.day}, ${d.year}`;
62
+ }
63
+ /** The profile header's "Joined September 2026". */
64
+ export function joinedLabel(iso, timeZone) {
65
+ if (typeof iso !== 'string')
66
+ return '';
67
+ const t = Date.parse(iso);
68
+ if (Number.isNaN(t))
69
+ return '';
70
+ const d = wallClock(t, timeZone);
71
+ return `Joined ${LONG_MONTHS[d.month]} ${d.year}`;
72
+ }
73
+ /** The replies to one post among rows the viewer can read, oldest first as x.com lists them. */
74
+ export function repliesTo(postId, rows) {
75
+ const seen = new Set();
76
+ const out = [];
77
+ for (const row of rows) {
78
+ const id = String(row.id);
79
+ if (seen.has(id) || referencedId(row, 'replied_to') !== postId)
80
+ continue;
81
+ seen.add(id);
82
+ out.push(row);
83
+ }
84
+ return out.sort((a, b) => (BigInt(String(a.id)) < BigInt(String(b.id)) ? -1 : 1));
85
+ }
86
+ /** A post's text split the way x.com colours it: @mentions, #hashtags and links are entities,
87
+ * everything else is plain text. Pure; the twin does not model `entities`, so the mirror reads
88
+ * them out of the text exactly as the twin's own mentions timeline does. */
89
+ export function textSegments(text) {
90
+ const source = typeof text === 'string' ? text : '';
91
+ const out = [];
92
+ // x.com links a bare domain too (`volter.ai/changelog`): a generic TLD always, a country code only with
93
+ // `www.` or a path (so `install.sh` stays text); an @ inside a URL stays part of it. The TLDs are the
94
+ // common ones, not X's full list.
95
+ const re = /(https?:\/\/[^\s]+|\b(?:www\.)?(?:[A-Za-z0-9-]+\.)+(?:com|org|net|dev|app|xyz)\b(?:\/[^\s]*)?|\b(?:www\.(?:[A-Za-z0-9-]+\.)+(?:ai|io|co|sh|me|so|gg|tv)\b(?:\/[^\s]*)?|(?:[A-Za-z0-9-]+\.)+(?:ai|io|co|sh|me|so|gg|tv)\/[^\s]*))|(@[A-Za-z0-9_]{1,15})|(#[\p{L}\p{N}_]+)/gu;
96
+ let last = 0;
97
+ for (const m of source.matchAll(re)) {
98
+ const at = m.index ?? 0;
99
+ // an @ or # glued to a preceding word character is not an entity at X (e.g. an email address)
100
+ if ((m[2] || m[3]) && at > 0 && /[A-Za-z0-9_]/.test(source[at - 1]))
101
+ continue;
102
+ if (at > last)
103
+ out.push({ kind: 'text', value: source.slice(last, at) });
104
+ // closing punctuation after a URL is the sentence's, not the link's
105
+ let value = m[1] ? m[0].replace(/[.,;:!?\]'"]+$/, '') : m[0];
106
+ // a `)` closes the URL's own `(` (wiki/Foo_(bar)) or else the sentence's
107
+ while (m[1] && value.endsWith(')') && (value.match(/\(/g)?.length ?? 0) < (value.match(/\)/g)?.length ?? 0))
108
+ value = value.slice(0, -1).replace(/[.,;:!?\]'"]+$/, '');
109
+ out.push({ kind: m[1] ? 'url' : m[2] ? 'mention' : 'hashtag', value });
110
+ last = at + value.length;
111
+ }
112
+ if (last < source.length)
113
+ out.push({ kind: 'text', value: source.slice(last) });
114
+ return out;
115
+ }
116
+ /** A link's target: a bare domain is https. */
117
+ export function linkHref(url) { return /^https?:\/\//i.test(url) ? url : `https://${url}`; }
118
+ /** The link text x.com shows for a URL: scheme stripped, long paths cut with an ellipsis. */
119
+ export function displayUrl(url) {
120
+ const bare = url.replace(/^https?:\/\/(www\.)?/, '');
121
+ return bare.length > 30 ? `${bare.slice(0, 29)}…` : bare;
122
+ }
123
+ /** The avatar placeholder's colour, a pure function of the handle (the twin serves no image). */
124
+ export function avatarHue(handle) {
125
+ const s = String(handle ?? '').toLowerCase();
126
+ let h = 0;
127
+ for (let i = 0; i < s.length; i += 1)
128
+ h = (h * 31 + s.charCodeAt(i)) % 360;
129
+ return h;
130
+ }
131
+ /** The placeholder's letter: the display name's first character, else the handle's. */
132
+ export function avatarInitial(user) {
133
+ const source = String(user?.name ?? user?.username ?? '?').trim();
134
+ return (Array.from(source)[0] ?? '?').toUpperCase();
135
+ }
136
+ /** Users from a response's `includes`, by id. */
137
+ export function usersById(includes) {
138
+ const out = new Map();
139
+ const users = includes?.users;
140
+ if (Array.isArray(users))
141
+ for (const u of users)
142
+ out.set(String(u.id), u);
143
+ return out;
144
+ }
145
+ /** Posts from a response's `includes`, by id (the quoted / replied-to posts). */
146
+ export function tweetsById(includes) {
147
+ const out = new Map();
148
+ const tweets = includes?.tweets;
149
+ if (Array.isArray(tweets))
150
+ for (const t of tweets)
151
+ out.set(String(t.id), t);
152
+ return out;
153
+ }
154
+ /** The id a post references with the given type ('replied_to' | 'quoted'), if any. */
155
+ export function referencedId(post, type) {
156
+ const refs = post.referenced_tweets;
157
+ if (!Array.isArray(refs))
158
+ return undefined;
159
+ const hit = refs.find((r) => r?.type === type);
160
+ return hit ? String(hit.id) : undefined;
161
+ }
162
+ /** Media from a response's `includes`, by media key. */
163
+ export function mediaByKey(includes) {
164
+ const out = new Map();
165
+ const media = includes?.media;
166
+ if (Array.isArray(media))
167
+ for (const m of media)
168
+ out.set(String(m.media_key), m);
169
+ return out;
170
+ }
171
+ /** The media a post carries, in its own `attachments.media_keys` order, as the read expanded them. */
172
+ export function postMedia(post, media) {
173
+ const keys = post.attachments?.media_keys;
174
+ if (!Array.isArray(keys))
175
+ return [];
176
+ return keys.map((k) => media.get(String(k))).filter((m) => m !== undefined);
177
+ }
178
+ /** The playable file of a video: its MP4 variant with the highest bit rate. */
179
+ export function videoSource(media) {
180
+ const variants = Array.isArray(media.variants) ? media.variants : [];
181
+ const mp4 = variants.filter((v) => v.content_type === 'video/mp4' && typeof v.url === 'string');
182
+ mp4.sort((a, b) => Number(b.bit_rate ?? 0) - Number(a.bit_rate ?? 0));
183
+ return mp4[0]?.url;
184
+ }
185
+ /** "0:03" — the duration badge x.com prints on a video. */
186
+ export function durationLabel(ms) {
187
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0)
188
+ return '';
189
+ const total = Math.round(ms / 1000);
190
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
191
+ }
192
+ /** The query every post-rendering read asks for: the fields and expansions a post row draws. */
193
+ export const POST_READ_QUERY = 'tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id,referenced_tweets'
194
+ + '&expansions=author_id,in_reply_to_user_id,referenced_tweets.id,referenced_tweets.id.author_id,attachments.media_keys'
195
+ + '&user.fields=verified,protected'
196
+ + '&media.fields=url,type,width,height,duration_ms,preview_image_url,variants';
197
+ /** The query the profile header asks for. */
198
+ export const PROFILE_READ_QUERY = 'user.fields=created_at,description,location,url,verified,protected,most_recent_tweet_id';
199
+ const APP_SHELL = `<!doctype html>
200
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
201
+ <base href="/"><title>X</title><link rel="stylesheet" href="assets/styles.css"></head>
202
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
203
+ let clientBundle = null;
204
+ /** Build the React/TSX mirror client to browser JS (Bun bundles TSX); memoized at module scope,
205
+ * which is why a pack on its own does exactly one `Bun.build`. */
206
+ export function buildXMirrorClient() {
207
+ if (!clientBundle) {
208
+ clientBundle = bundleClient(CLIENT_ENTRY())
209
+ .catch((error) => { clientBundle = null; throw error; });
210
+ }
211
+ return clientBundle;
212
+ }
213
+ /** Serve the X mirror UI (React app) + its backing X API v2 on one origin. */
214
+ export async function createXMirrorServer(options) {
215
+ const twin = createXTwinFetch(options);
216
+ const server = await serveHttp({
217
+ // LOOPBACK-SPECIFIC bind, as the Discord mirror: a wildcard bind on `port: 0` can be shadowed
218
+ // by a long-running app already listening on 127.0.0.1 at the same port.
219
+ hostname: '127.0.0.1',
220
+ port: options.port ?? 0,
221
+ idleTimeout: 60,
222
+ async fetch(request) {
223
+ const url = new URL(request.url);
224
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
225
+ try {
226
+ return new Response(await buildXMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
227
+ }
228
+ catch (error) {
229
+ return new Response(String(error), { status: 500 });
230
+ }
231
+ }
232
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
233
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
234
+ }
235
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
236
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
237
+ }
238
+ // Everything else -> the twin's OWN FETCH ADAPTER: the React client fetches X's real v2
239
+ // paths, and the adapter is the same closure `createXTwinServer` serves.
240
+ return twin(request);
241
+ },
242
+ });
243
+ const port = server.port ?? options.port ?? 0;
244
+ return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
245
+ }
246
+ /** The app-shell HTML (pure). The client itself is the React app. */
247
+ export function xMirrorHtml() {
248
+ return APP_SHELL;
249
+ }
250
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
251
+ export function xMirrorStyles() {
252
+ return readFile(CLIENT_CSS(), 'utf8');
253
+ }
@@ -0,0 +1,38 @@
1
+ export type XResponse = {
2
+ status: number;
3
+ body: unknown;
4
+ headers?: Record<string, string>;
5
+ };
6
+ /** The generic auth problem X answers a bad/absent/expired bearer with on /2 resources. */
7
+ export declare function unauthorizedProblem(): XResponse;
8
+ /** A user token whose scope set does not cover the endpoint. Wording: `x.errors.scope_wording`. */
9
+ export declare function forbiddenProblem(detail: string): XResponse;
10
+ /** An unmodelled /2 route fails like the vendor: 404 problem, never a fake success. */
11
+ export declare function notFoundProblem(): XResponse;
12
+ /**
13
+ * A named resource the request addressed does not exist — X's `resource-not-found` problem,
14
+ * which carries the offending value/resource_type rather than the bare about:blank form.
15
+ * Wording pinned by `x.errors.resource_not_found_wording`.
16
+ */
17
+ export declare function resourceNotFoundError(value: string, resourceType: 'tweet' | 'user', parameter: string): Record<string, unknown>;
18
+ export declare function resourceNotFoundProblem(value: string, resourceType: 'tweet' | 'user', parameter: string): XResponse;
19
+ /**
20
+ * THE LOOKUP ENVELOPE, and why its status differs from the refusal above. X's *lookup* endpoints
21
+ * (`GET /2/tweets`, `GET /2/tweets/:id`) answer PARTIALLY: a request for five ids where two are
22
+ * missing has to return the three that exist AND say what happened to the other two, so the
23
+ * missing ones ride in a sibling `errors` array under HTTP 200 rather than turning the whole
24
+ * request into a 404. The bulk route FORCES that model — there is no other way to express a
25
+ * partial result — and X applies the same envelope to the single-id route. What matters for this
26
+ * twin, and what its verifies assert, is the half that is not a status: a `data` entry is NEVER
27
+ * fabricated for an id nobody created; the id comes back inside `errors`, by name.
28
+ *
29
+ * The exact `detail`/`title` WORDING is unpinned by this build, same boundary as every other
30
+ * envelope here (`x.errors.resource_not_found_wording`).
31
+ */
32
+ export declare function lookupResult(data: unknown, errors: Array<Record<string, unknown>>, includes?: Record<string, unknown>): XResponse;
33
+ /** The invalid-parameter envelope (OpenAPI InvalidRequestProblem + the wire's `errors` array). */
34
+ export declare function invalidRequestProblem(parameters: Record<string, string[]>, message: string): XResponse;
35
+ /** HTTP 429, legacy code 88 — the documented pair (docs.x.com fundamentals/rate-limits). */
36
+ export declare function rateLimitExceeded(headers: Record<string, string>): XResponse;
37
+ /** A write attempted against a read-only twin. Not a vendor shape — the kernel's own refusal. */
38
+ export declare function readOnlyRefusal(): XResponse;