@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.
- package/LICENSE +202 -0
- package/README.md +138 -0
- package/dist/client/x-mirror.bundle.js +321 -0
- package/dist/client/x-mirror.d.ts +45 -0
- package/dist/client/x-mirror.js +417 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +29 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +68 -0
- package/dist/src/x-budget.d.ts +54 -0
- package/dist/src/x-budget.js +123 -0
- package/dist/src/x-capabilities.d.ts +3 -0
- package/dist/src/x-capabilities.js +1106 -0
- package/dist/src/x-conformance.d.ts +8 -0
- package/dist/src/x-conformance.js +91 -0
- package/dist/src/x-connector.d.ts +125 -0
- package/dist/src/x-connector.js +546 -0
- package/dist/src/x-media.d.ts +87 -0
- package/dist/src/x-media.js +275 -0
- package/dist/src/x-mirror-ui.d.ts +61 -0
- package/dist/src/x-mirror-ui.js +253 -0
- package/dist/src/x-problems.d.ts +38 -0
- package/dist/src/x-problems.js +130 -0
- package/dist/src/x-scopes.d.ts +7 -0
- package/dist/src/x-scopes.js +62 -0
- package/dist/src/x-server.d.ts +14 -0
- package/dist/src/x-server.js +127 -0
- package/dist/src/x-twin.d.ts +21 -0
- package/dist/src/x-twin.js +1534 -0
- package/package.json +58 -0
- package/src/cli.ts +27 -0
- package/src/index.ts +132 -0
- package/src/x-budget.ts +150 -0
- package/src/x-capabilities.ts +1161 -0
- package/src/x-conformance.ts +113 -0
- package/src/x-connector.ts +546 -0
- package/src/x-media.ts +295 -0
- package/src/x-mirror-ui.ts +263 -0
- package/src/x-problems.ts +143 -0
- package/src/x-scopes.ts +67 -0
- package/src/x-server.ts +126 -0
- package/src/x-twin.ts +1545 -0
package/src/x-media.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
|
|
20
|
+
const SERVICE = 'x';
|
|
21
|
+
|
|
22
|
+
/** The stored upload session / finished media object, keyed by the media id X hands back. */
|
|
23
|
+
export type XMediaRecord = {
|
|
24
|
+
id: string;
|
|
25
|
+
media_key: string;
|
|
26
|
+
account_id: string;
|
|
27
|
+
media_category: string;
|
|
28
|
+
/** The declared (initialize) or sniffed MIME type. */
|
|
29
|
+
media_type: string;
|
|
30
|
+
/** `initialized` while segments arrive; an image is `succeeded` once its bytes are whole; a video
|
|
31
|
+
* is `processing` (X's pending → in_progress) until STATUS reads walk it to `succeeded`, or
|
|
32
|
+
* `failed` when the bytes are not a video X would take. */
|
|
33
|
+
state: 'initialized' | 'processing' | 'succeeded' | 'failed';
|
|
34
|
+
/** A video's processing step counted in STATUS reads (0 = pending, 1 = in_progress, 2 = done). */
|
|
35
|
+
processing_step?: number;
|
|
36
|
+
/** World-clock epoch seconds of FINALIZE: processing also advances one step per
|
|
37
|
+
* `check_after_secs` elapsed since, so a client that sleeps and then posts is not held back. */
|
|
38
|
+
finalized_at?: number;
|
|
39
|
+
/** Why processing failed, in X's words (served as a problem in the response's `errors`). */
|
|
40
|
+
error?: string;
|
|
41
|
+
duration_ms?: number;
|
|
42
|
+
total_bytes?: number;
|
|
43
|
+
sha256?: string;
|
|
44
|
+
size?: number;
|
|
45
|
+
width?: number;
|
|
46
|
+
height?: number;
|
|
47
|
+
additional_owners?: string[];
|
|
48
|
+
created_at: string;
|
|
49
|
+
/** Epoch seconds after which X no longer accepts the id on a post. */
|
|
50
|
+
expires_at: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** X keeps an uploaded media id usable for a day (`expires_after_secs: 86400` on its answers). */
|
|
54
|
+
export const MEDIA_EXPIRES_AFTER_SECS = 86_400;
|
|
55
|
+
/** X's image ceiling: 5 MB. */
|
|
56
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
57
|
+
/** X's video ceiling: 512 MB. */
|
|
58
|
+
export const MAX_VIDEO_BYTES = 512 * 1024 * 1024;
|
|
59
|
+
/** A `tweet_video` may run at most 140 seconds (longer videos are an `amplify_video` or a Premium entitlement). */
|
|
60
|
+
export const MAX_TWEET_VIDEO_MS = 140_000;
|
|
61
|
+
|
|
62
|
+
const resourcesDir = (root?: string): string => worldPaths(SERVICE, root).resources;
|
|
63
|
+
const blobKey = (sha256: string): string => join('blobs', 'sha256', sha256.slice(0, 2), sha256);
|
|
64
|
+
const recordKey = (id: string): string => join('media', `${safeId(id)}.json`);
|
|
65
|
+
const segmentKey = (id: string, index: number): string => join('media-segments', safeId(id), String(index).padStart(4, '0'));
|
|
66
|
+
const typeKey = (sha256: string): string => join('blob-types', `${sha256}.json`);
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
/** The direct children under `relative` across the chain, nearest branch first per name. */
|
|
70
|
+
async function listAcross(relative: string, root?: string): Promise<Map<string, string>> {
|
|
71
|
+
const found = new Map<string, string>();
|
|
72
|
+
for (const dir of resourceChain(SERVICE, root)) {
|
|
73
|
+
const prefix = `${join(dir, relative)}/`;
|
|
74
|
+
for (const key of await getActiveBlobStore().list(prefix)) {
|
|
75
|
+
if (!key.startsWith(prefix)) continue;
|
|
76
|
+
const name = key.slice(prefix.length);
|
|
77
|
+
if (name.includes('/') || found.has(name)) continue;
|
|
78
|
+
found.set(name, key);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function safeId(id: string): string {
|
|
85
|
+
if (!/^\d{1,19}$/.test(id)) throw new Error(`invalid X media id for storage: ${JSON.stringify(id)}`);
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Store bytes content-addressed; a re-upload of identical content is a no-op write. The MIME
|
|
90
|
+
* type the upload was read as rides beside them, so the bytes routes answer with the type the
|
|
91
|
+
* twin decided, never one a URL's extension claims. */
|
|
92
|
+
export async function putXBlob(bytes: Uint8Array, contentType: string, root?: string): Promise<{ sha256: string; size: number }> {
|
|
93
|
+
const sha256 = blobDigest(bytes);
|
|
94
|
+
const key = join(resourcesDir(root), blobKey(sha256));
|
|
95
|
+
if (!(await getActiveBlobStore().exists(key))) await getActiveBlobStore().put(key, bytes);
|
|
96
|
+
await getActiveBlobStore().put(join(resourcesDir(root), typeKey(sha256)), new TextEncoder().encode(JSON.stringify({ content_type: contentType })));
|
|
97
|
+
return { sha256, size: bytes.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The MIME type stored bytes were uploaded as (this branch, then its ancestors). */
|
|
101
|
+
export async function readXBlobType(sha256: string, root?: string): Promise<string | undefined> {
|
|
102
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) return undefined;
|
|
103
|
+
try {
|
|
104
|
+
const stored = await readResourceBlob(SERVICE, typeKey(sha256), root);
|
|
105
|
+
const type = stored === null ? undefined : (JSON.parse(new TextDecoder().decode(stored)) as { content_type?: unknown }).content_type;
|
|
106
|
+
return typeof type === 'string' ? type : undefined;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Stored bytes by digest (this branch, then its ancestors); null when absent or malformed. */
|
|
113
|
+
export async function readXBlob(sha256: string, root?: string): Promise<Uint8Array | null> {
|
|
114
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) return null;
|
|
115
|
+
return await readResourceBlob(SERVICE, blobKey(sha256), root);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function writeMediaRecord(record: XMediaRecord, root?: string): Promise<void> {
|
|
119
|
+
await getActiveBlobStore().put(join(resourcesDir(root), recordKey(record.id)), new TextEncoder().encode(JSON.stringify(record)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function readMediaRecord(id: string, root?: string): Promise<XMediaRecord | undefined> {
|
|
123
|
+
if (!/^\d{1,19}$/.test(id)) return undefined;
|
|
124
|
+
try {
|
|
125
|
+
const stored = await readResourceBlob(SERVICE, recordKey(id), root);
|
|
126
|
+
if (stored === null) return undefined;
|
|
127
|
+
const parsed = JSON.parse(new TextDecoder().decode(stored)) as XMediaRecord;
|
|
128
|
+
return typeof parsed.id === 'string' ? parsed : undefined;
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Every media id this branch or an ancestor has minted — so a new media id (and a new post id)
|
|
135
|
+
* steps past them all. */
|
|
136
|
+
export async function listMediaIds(root?: string): Promise<string[]> {
|
|
137
|
+
return [...(await listAcross('media', root)).keys()]
|
|
138
|
+
.filter((name) => name.endsWith('.json'))
|
|
139
|
+
.map((name) => name.slice(0, -'.json'.length))
|
|
140
|
+
.filter((id) => /^\d+$/.test(id));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One APPEND segment, stored under its index (a retried index overwrites, as at X). */
|
|
144
|
+
export async function putSegment(id: string, index: number, bytes: Uint8Array, root?: string): Promise<void> {
|
|
145
|
+
await getActiveBlobStore().put(join(resourcesDir(root), segmentKey(id, index)), bytes);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every segment of an upload, in index order, across the branch chain (the nearest copy of an
|
|
149
|
+
* index wins, as a retried APPEND overwrites at X). */
|
|
150
|
+
export async function readSegments(id: string, root?: string): Promise<Uint8Array[]> {
|
|
151
|
+
const found = await listAcross(join('media-segments', safeId(id)), root);
|
|
152
|
+
const keys = [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, key]) => key);
|
|
153
|
+
const out: Uint8Array[] = [];
|
|
154
|
+
for (const key of keys) {
|
|
155
|
+
const bytes = await getActiveBlobStore().get(key);
|
|
156
|
+
if (bytes !== null) out.push(bytes);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function clearSegments(id: string, root?: string): Promise<void> {
|
|
162
|
+
const prefix = `${join(resourcesDir(root), 'media-segments', safeId(id))}/`;
|
|
163
|
+
for (const key of await getActiveBlobStore().list(prefix)) {
|
|
164
|
+
try { await getActiveBlobStore().remove(key); } catch { /* already gone */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── what the bytes are ──────────────────────────────────────────────────────────────────────
|
|
169
|
+
// X decides an image's type and size from the bytes it received, not from what the client said.
|
|
170
|
+
// The twin does the same: the four still-image formats X accepts, read from their headers.
|
|
171
|
+
|
|
172
|
+
export type ImageInfo = { mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; width: number; height: number };
|
|
173
|
+
|
|
174
|
+
export function sniffImage(bytes: Uint8Array): ImageInfo | undefined {
|
|
175
|
+
const b = bytes;
|
|
176
|
+
const u16be = (i: number) => (b[i]! << 8) | b[i + 1]!;
|
|
177
|
+
const u16le = (i: number) => b[i]! | (b[i + 1]! << 8);
|
|
178
|
+
const u24le = (i: number) => b[i]! | (b[i + 1]! << 8) | (b[i + 2]! << 16);
|
|
179
|
+
const u32be = (i: number) => ((b[i]! << 24) >>> 0) + (b[i + 1]! << 16) + (b[i + 2]! << 8) + b[i + 3]!;
|
|
180
|
+
if (b.length >= 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
|
|
181
|
+
return { mediaType: 'image/png', width: u32be(16), height: u32be(20) };
|
|
182
|
+
}
|
|
183
|
+
if (b.length >= 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) {
|
|
184
|
+
return { mediaType: 'image/gif', width: u16le(6), height: u16le(8) };
|
|
185
|
+
}
|
|
186
|
+
if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
187
|
+
// walk the marker segments to the first start-of-frame (SOF0..SOF15 except DHT/JPG/DAC)
|
|
188
|
+
let i = 2;
|
|
189
|
+
while (i + 9 < b.length) {
|
|
190
|
+
if (b[i] !== 0xff) { i += 1; continue; }
|
|
191
|
+
const marker = b[i + 1]!;
|
|
192
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
|
|
193
|
+
const length = u16be(i + 2);
|
|
194
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
195
|
+
return { mediaType: 'image/jpeg', height: u16be(i + 5), width: u16be(i + 7) };
|
|
196
|
+
}
|
|
197
|
+
i += 2 + length;
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
if (b.length >= 30 && String.fromCharCode(...b.slice(0, 4)) === 'RIFF' && String.fromCharCode(...b.slice(8, 12)) === 'WEBP') {
|
|
202
|
+
const chunk = String.fromCharCode(...b.slice(12, 16));
|
|
203
|
+
if (chunk === 'VP8 ') return { mediaType: 'image/webp', width: u16le(26) & 0x3fff, height: u16le(28) & 0x3fff };
|
|
204
|
+
if (chunk === 'VP8L') {
|
|
205
|
+
const bits = b[21]! | (b[22]! << 8) | (b[23]! << 16) | (b[24]! << 24);
|
|
206
|
+
return { mediaType: 'image/webp', width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
|
|
207
|
+
}
|
|
208
|
+
if (chunk === 'VP8X') return { mediaType: 'image/webp', width: u24le(24) + 1, height: u24le(27) + 1 };
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type VideoInfo = { mediaType: 'video/mp4'; width: number; height: number; durationMs: number };
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* An MP4 (ISO BMFF) read from its boxes: `ftyp` first, then `moov` (anywhere — ffmpeg writes it
|
|
217
|
+
* after `mdat` unless told to fast-start) holding `mvhd` (timescale + duration) and one `trak` per
|
|
218
|
+
* stream whose `tkhd` carries the presented width/height (16.16 fixed point); the video track is
|
|
219
|
+
* the one with a non-zero size. Nothing is decoded — the codec is the browser's business.
|
|
220
|
+
*/
|
|
221
|
+
export function sniffVideo(bytes: Uint8Array): VideoInfo | undefined {
|
|
222
|
+
const b = bytes;
|
|
223
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
224
|
+
const type = (at: number) => String.fromCharCode(b[at + 4]!, b[at + 5]!, b[at + 6]!, b[at + 7]!);
|
|
225
|
+
/** The child boxes of [start, end): [type, payloadStart, boxEnd]. */
|
|
226
|
+
const boxes = (start: number, end: number): Array<[string, number, number]> => {
|
|
227
|
+
const out: Array<[string, number, number]> = [];
|
|
228
|
+
let at = start;
|
|
229
|
+
while (at + 8 <= end) {
|
|
230
|
+
let size = view.getUint32(at);
|
|
231
|
+
let header = 8;
|
|
232
|
+
if (size === 1) {
|
|
233
|
+
if (at + 16 > end) break;
|
|
234
|
+
size = Number(view.getBigUint64(at + 8));
|
|
235
|
+
header = 16;
|
|
236
|
+
} else if (size === 0) size = end - at;
|
|
237
|
+
if (size < header || at + size > end) break;
|
|
238
|
+
out.push([type(at), at + header, at + size]);
|
|
239
|
+
at += size;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
};
|
|
243
|
+
const top = boxes(0, b.length);
|
|
244
|
+
if (top[0]?.[0] !== 'ftyp') return undefined;
|
|
245
|
+
const moov = top.find(([t]) => t === 'moov');
|
|
246
|
+
if (!moov) return undefined;
|
|
247
|
+
let durationMs: number | undefined;
|
|
248
|
+
let width = 0;
|
|
249
|
+
let height = 0;
|
|
250
|
+
for (const [t, start, end] of boxes(moov[1], moov[2])) {
|
|
251
|
+
if (t === 'mvhd' && end - start >= 32) {
|
|
252
|
+
const version = b[start]!;
|
|
253
|
+
const timescale = version === 1 ? view.getUint32(start + 20) : view.getUint32(start + 12);
|
|
254
|
+
const duration = version === 1 ? Number(view.getBigUint64(start + 24)) : view.getUint32(start + 16);
|
|
255
|
+
if (timescale > 0) durationMs = Math.round((duration * 1000) / timescale);
|
|
256
|
+
}
|
|
257
|
+
if (t === 'trak') {
|
|
258
|
+
const tkhd = boxes(start, end).find(([k]) => k === 'tkhd');
|
|
259
|
+
if (!tkhd) continue;
|
|
260
|
+
const version = b[tkhd[1]]!;
|
|
261
|
+
const at = tkhd[1] + (version === 1 ? 88 : 76);
|
|
262
|
+
if (at + 8 > tkhd[2]) continue;
|
|
263
|
+
const w = view.getUint32(at) >>> 16;
|
|
264
|
+
const h = view.getUint32(at + 4) >>> 16;
|
|
265
|
+
if (w > 0 && h > 0 && width === 0) { width = w; height = h; }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (durationMs === undefined || width === 0) return undefined;
|
|
269
|
+
return { mediaType: 'video/mp4', width, height, durationMs };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The video's preview image. X's is a frame its transcoder cut; the twin decodes nothing (the codec
|
|
274
|
+
* is the browser's), so it serves a STAND-IN at the video's own size and aspect — a dark frame with
|
|
275
|
+
* X's play glyph — and says so here. `x.media.video_poster_frame` is the todo for a real frame.
|
|
276
|
+
*/
|
|
277
|
+
export function videoPosterSvg(width: number, height: number): string {
|
|
278
|
+
const w = width > 0 ? width : 1280;
|
|
279
|
+
const h = height > 0 ? height : 720;
|
|
280
|
+
const r = Math.round(Math.min(w, h) * 0.09);
|
|
281
|
+
const cx = w / 2;
|
|
282
|
+
const cy = h / 2;
|
|
283
|
+
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"/>`
|
|
284
|
+
+ `<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>`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The file extension pbs.twimg.com serves an image under. */
|
|
288
|
+
export function mediaExtension(mediaType: string): string {
|
|
289
|
+
if (mediaType === 'image/jpeg') return 'jpg';
|
|
290
|
+
if (mediaType === 'image/png') return 'png';
|
|
291
|
+
if (mediaType === 'image/gif') return 'gif';
|
|
292
|
+
if (mediaType === 'image/webp') return 'webp';
|
|
293
|
+
return 'bin';
|
|
294
|
+
}
|
|
295
|
+
|
|
@@ -0,0 +1,263 @@
|
|
|
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.ts';
|
|
20
|
+
|
|
21
|
+
const CLIENT_ENTRY = () => new URL('../client/x-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
|
|
22
|
+
const CLIENT_CSS = () => new URL('../client/x-mirror.css', import.meta.url).pathname; // lazy: same reason
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Pure, dependency-free render/format helpers (importable by the React client; Bun tree-shakes
|
|
26
|
+
// the server-only exports out of the browser bundle). Keep free of any @volter/world-core / Bun /
|
|
27
|
+
// handler usage — a capability verify renders the mirror's OWN functions over twin state.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export type XRow = Record<string, any>;
|
|
31
|
+
|
|
32
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
33
|
+
const LONG_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
34
|
+
|
|
35
|
+
type Parts = { year: number; month: number; day: number; hour: number; minute: number };
|
|
36
|
+
|
|
37
|
+
/** A wall-clock reading of an instant in a time zone. x.com shows the VIEWER'S local time, so the
|
|
38
|
+
* browser passes none (its own zone); a verify passes 'UTC' so the output is a pure function. */
|
|
39
|
+
function wallClock(ms: number, timeZone?: string): Parts {
|
|
40
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
41
|
+
...(timeZone ? { timeZone } : {}), year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', hourCycle: 'h23',
|
|
42
|
+
}).formatToParts(new Date(ms));
|
|
43
|
+
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0);
|
|
44
|
+
return { year: get('year'), month: get('month') - 1, day: get('day'), hour: get('hour') % 24, minute: get('minute') };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** x.com's timeline timestamp: "now"/"42s", "5m", "3h" inside a day, "Sep 26" inside the year,
|
|
48
|
+
* "Sep 26, 2025" before it. `nowMs` is passed in, never read. */
|
|
49
|
+
export function relativeTime(iso: unknown, nowMs: number, timeZone?: string): string {
|
|
50
|
+
if (typeof iso !== 'string') return '';
|
|
51
|
+
const t = Date.parse(iso);
|
|
52
|
+
if (Number.isNaN(t)) return '';
|
|
53
|
+
const s = Math.max(0, Math.floor((nowMs - t) / 1000));
|
|
54
|
+
if (s < 60) return s < 5 ? 'now' : `${s}s`;
|
|
55
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
56
|
+
if (s < 86_400) return `${Math.floor(s / 3600)}h`;
|
|
57
|
+
const d = wallClock(t, timeZone);
|
|
58
|
+
const day = `${MONTHS[d.month]} ${d.day}`;
|
|
59
|
+
return d.year === wallClock(nowMs, timeZone).year ? day : `${day}, ${d.year}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The single-post page's stamp: "8:05 PM · Sep 26, 2026", in the viewer's zone. */
|
|
63
|
+
export function fullTimestamp(iso: unknown, timeZone?: string): string {
|
|
64
|
+
if (typeof iso !== 'string') return '';
|
|
65
|
+
const t = Date.parse(iso);
|
|
66
|
+
if (Number.isNaN(t)) return '';
|
|
67
|
+
const d = wallClock(t, timeZone);
|
|
68
|
+
const hour12 = d.hour % 12 === 0 ? 12 : d.hour % 12;
|
|
69
|
+
return `${hour12}:${String(d.minute).padStart(2, '0')} ${d.hour < 12 ? 'AM' : 'PM'} · ${MONTHS[d.month]} ${d.day}, ${d.year}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The profile header's "Joined September 2026". */
|
|
73
|
+
export function joinedLabel(iso: unknown, timeZone?: string): string {
|
|
74
|
+
if (typeof iso !== 'string') return '';
|
|
75
|
+
const t = Date.parse(iso);
|
|
76
|
+
if (Number.isNaN(t)) return '';
|
|
77
|
+
const d = wallClock(t, timeZone);
|
|
78
|
+
return `Joined ${LONG_MONTHS[d.month]} ${d.year}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The replies to one post among rows the viewer can read, oldest first as x.com lists them. */
|
|
82
|
+
export function repliesTo(postId: string, rows: XRow[]): XRow[] {
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
const out: XRow[] = [];
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
const id = String(row.id);
|
|
87
|
+
if (seen.has(id) || referencedId(row, 'replied_to') !== postId) continue;
|
|
88
|
+
seen.add(id);
|
|
89
|
+
out.push(row);
|
|
90
|
+
}
|
|
91
|
+
return out.sort((a, b) => (BigInt(String(a.id)) < BigInt(String(b.id)) ? -1 : 1));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type TextSegment = { kind: 'text' | 'mention' | 'hashtag' | 'url'; value: string };
|
|
95
|
+
|
|
96
|
+
/** A post's text split the way x.com colours it: @mentions, #hashtags and links are entities,
|
|
97
|
+
* everything else is plain text. Pure; the twin does not model `entities`, so the mirror reads
|
|
98
|
+
* them out of the text exactly as the twin's own mentions timeline does. */
|
|
99
|
+
export function textSegments(text: unknown): TextSegment[] {
|
|
100
|
+
const source = typeof text === 'string' ? text : '';
|
|
101
|
+
const out: TextSegment[] = [];
|
|
102
|
+
// x.com links a bare domain too (`volter.ai/changelog`): a generic TLD always, a country code only with
|
|
103
|
+
// `www.` or a path (so `install.sh` stays text); an @ inside a URL stays part of it. The TLDs are the
|
|
104
|
+
// common ones, not X's full list.
|
|
105
|
+
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;
|
|
106
|
+
let last = 0;
|
|
107
|
+
for (const m of source.matchAll(re)) {
|
|
108
|
+
const at = m.index ?? 0;
|
|
109
|
+
// an @ or # glued to a preceding word character is not an entity at X (e.g. an email address)
|
|
110
|
+
if ((m[2] || m[3]) && at > 0 && /[A-Za-z0-9_]/.test(source[at - 1]!)) continue;
|
|
111
|
+
if (at > last) out.push({ kind: 'text', value: source.slice(last, at) });
|
|
112
|
+
// closing punctuation after a URL is the sentence's, not the link's
|
|
113
|
+
let value = m[1] ? m[0].replace(/[.,;:!?\]'"]+$/, '') : m[0];
|
|
114
|
+
// a `)` closes the URL's own `(` (wiki/Foo_(bar)) or else the sentence's
|
|
115
|
+
while (m[1] && value.endsWith(')') && (value.match(/\(/g)?.length ?? 0) < (value.match(/\)/g)?.length ?? 0)) value = value.slice(0, -1).replace(/[.,;:!?\]'"]+$/, '');
|
|
116
|
+
out.push({ kind: m[1] ? 'url' : m[2] ? 'mention' : 'hashtag', value });
|
|
117
|
+
last = at + value.length;
|
|
118
|
+
}
|
|
119
|
+
if (last < source.length) out.push({ kind: 'text', value: source.slice(last) });
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A link's target: a bare domain is https. */
|
|
124
|
+
export function linkHref(url: string): string { return /^https?:\/\//i.test(url) ? url : `https://${url}`; }
|
|
125
|
+
|
|
126
|
+
/** The link text x.com shows for a URL: scheme stripped, long paths cut with an ellipsis. */
|
|
127
|
+
export function displayUrl(url: string): string {
|
|
128
|
+
const bare = url.replace(/^https?:\/\/(www\.)?/, '');
|
|
129
|
+
return bare.length > 30 ? `${bare.slice(0, 29)}…` : bare;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The avatar placeholder's colour, a pure function of the handle (the twin serves no image). */
|
|
133
|
+
export function avatarHue(handle: unknown): number {
|
|
134
|
+
const s = String(handle ?? '').toLowerCase();
|
|
135
|
+
let h = 0;
|
|
136
|
+
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) % 360;
|
|
137
|
+
return h;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The placeholder's letter: the display name's first character, else the handle's. */
|
|
141
|
+
export function avatarInitial(user: XRow | undefined): string {
|
|
142
|
+
const source = String(user?.name ?? user?.username ?? '?').trim();
|
|
143
|
+
return (Array.from(source)[0] ?? '?').toUpperCase();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Users from a response's `includes`, by id. */
|
|
147
|
+
export function usersById(includes: unknown): Map<string, XRow> {
|
|
148
|
+
const out = new Map<string, XRow>();
|
|
149
|
+
const users = (includes as XRow | undefined)?.users;
|
|
150
|
+
if (Array.isArray(users)) for (const u of users) out.set(String(u.id), u);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Posts from a response's `includes`, by id (the quoted / replied-to posts). */
|
|
155
|
+
export function tweetsById(includes: unknown): Map<string, XRow> {
|
|
156
|
+
const out = new Map<string, XRow>();
|
|
157
|
+
const tweets = (includes as XRow | undefined)?.tweets;
|
|
158
|
+
if (Array.isArray(tweets)) for (const t of tweets) out.set(String(t.id), t);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The id a post references with the given type ('replied_to' | 'quoted'), if any. */
|
|
163
|
+
export function referencedId(post: XRow, type: 'replied_to' | 'quoted'): string | undefined {
|
|
164
|
+
const refs = post.referenced_tweets;
|
|
165
|
+
if (!Array.isArray(refs)) return undefined;
|
|
166
|
+
const hit = refs.find((r: XRow) => r?.type === type);
|
|
167
|
+
return hit ? String(hit.id) : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Media from a response's `includes`, by media key. */
|
|
171
|
+
export function mediaByKey(includes: unknown): Map<string, XRow> {
|
|
172
|
+
const out = new Map<string, XRow>();
|
|
173
|
+
const media = (includes as XRow | undefined)?.media;
|
|
174
|
+
if (Array.isArray(media)) for (const m of media) out.set(String(m.media_key), m);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The media a post carries, in its own `attachments.media_keys` order, as the read expanded them. */
|
|
179
|
+
export function postMedia(post: XRow, media: Map<string, XRow>): XRow[] {
|
|
180
|
+
const keys = post.attachments?.media_keys;
|
|
181
|
+
if (!Array.isArray(keys)) return [];
|
|
182
|
+
return keys.map((k: unknown) => media.get(String(k))).filter((m): m is XRow => m !== undefined);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The playable file of a video: its MP4 variant with the highest bit rate. */
|
|
186
|
+
export function videoSource(media: XRow): string | undefined {
|
|
187
|
+
const variants = Array.isArray(media.variants) ? (media.variants as XRow[]) : [];
|
|
188
|
+
const mp4 = variants.filter((v) => v.content_type === 'video/mp4' && typeof v.url === 'string');
|
|
189
|
+
mp4.sort((a, b) => Number(b.bit_rate ?? 0) - Number(a.bit_rate ?? 0));
|
|
190
|
+
return mp4[0]?.url as string | undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** "0:03" — the duration badge x.com prints on a video. */
|
|
194
|
+
export function durationLabel(ms: unknown): string {
|
|
195
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '';
|
|
196
|
+
const total = Math.round(ms / 1000);
|
|
197
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The query every post-rendering read asks for: the fields and expansions a post row draws. */
|
|
201
|
+
export const POST_READ_QUERY = 'tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id,referenced_tweets'
|
|
202
|
+
+ '&expansions=author_id,in_reply_to_user_id,referenced_tweets.id,referenced_tweets.id.author_id,attachments.media_keys'
|
|
203
|
+
+ '&user.fields=verified,protected'
|
|
204
|
+
+ '&media.fields=url,type,width,height,duration_ms,preview_image_url,variants';
|
|
205
|
+
|
|
206
|
+
/** The query the profile header asks for. */
|
|
207
|
+
export const PROFILE_READ_QUERY = 'user.fields=created_at,description,location,url,verified,protected,most_recent_tweet_id';
|
|
208
|
+
|
|
209
|
+
const APP_SHELL = `<!doctype html>
|
|
210
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
211
|
+
<base href="/"><title>X</title><link rel="stylesheet" href="assets/styles.css"></head>
|
|
212
|
+
<body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
|
|
213
|
+
|
|
214
|
+
let clientBundle: Promise<string> | null = null;
|
|
215
|
+
/** Build the React/TSX mirror client to browser JS (Bun bundles TSX); memoized at module scope,
|
|
216
|
+
* which is why a pack on its own does exactly one `Bun.build`. */
|
|
217
|
+
export function buildXMirrorClient(): Promise<string> {
|
|
218
|
+
if (!clientBundle) {
|
|
219
|
+
clientBundle = bundleClient(CLIENT_ENTRY())
|
|
220
|
+
.catch((error) => { clientBundle = null; throw error; });
|
|
221
|
+
}
|
|
222
|
+
return clientBundle;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Serve the X mirror UI (React app) + its backing X API v2 on one origin. */
|
|
226
|
+
export async function createXMirrorServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; url: string; stop: () => void }> {
|
|
227
|
+
const twin = createXTwinFetch(options);
|
|
228
|
+
const server = await serveHttp({
|
|
229
|
+
// LOOPBACK-SPECIFIC bind, as the Discord mirror: a wildcard bind on `port: 0` can be shadowed
|
|
230
|
+
// by a long-running app already listening on 127.0.0.1 at the same port.
|
|
231
|
+
hostname: '127.0.0.1',
|
|
232
|
+
port: options.port ?? 0,
|
|
233
|
+
idleTimeout: 60,
|
|
234
|
+
async fetch(request) {
|
|
235
|
+
const url = new URL(request.url);
|
|
236
|
+
if (request.method === 'GET' && url.pathname === '/assets/app.js') {
|
|
237
|
+
try { return new Response(await buildXMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
|
|
238
|
+
catch (error) { return new Response(String(error), { status: 500 }); }
|
|
239
|
+
}
|
|
240
|
+
if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
|
|
241
|
+
return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
242
|
+
}
|
|
243
|
+
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
|
|
244
|
+
return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
245
|
+
}
|
|
246
|
+
// Everything else -> the twin's OWN FETCH ADAPTER: the React client fetches X's real v2
|
|
247
|
+
// paths, and the adapter is the same closure `createXTwinServer` serves.
|
|
248
|
+
return twin(request);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const port = server.port ?? options.port ?? 0;
|
|
252
|
+
return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The app-shell HTML (pure). The client itself is the React app. */
|
|
256
|
+
export function xMirrorHtml(): string {
|
|
257
|
+
return APP_SHELL;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
261
|
+
export function xMirrorStyles(): Promise<string> {
|
|
262
|
+
return readFile(CLIENT_CSS(), 'utf8');
|
|
263
|
+
}
|