@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
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
// X CONNECTOR — the live-vendor path for the posting surface.
|
|
2
|
+
//
|
|
3
|
+
// PULL (real → twin): TWO timelines, because an org's public voice has two halves. The MENTIONS
|
|
4
|
+
// TIMELINE (`GET /2/users/:id/mentions`) is what was said TO the org — the input the demo's
|
|
5
|
+
// reply class consumes. The OWN TIMELINE (`GET /2/users/:id/tweets`) is what the org itself
|
|
6
|
+
// said, INCLUDING what it said somewhere else: by a human on x.com, or by an earlier run.
|
|
7
|
+
// Without the second half, a twin that has been away comes back believing it never spoke.
|
|
8
|
+
// Both are PAGED: a busy account's timeline arrives in pages joined by `meta.next_token`, and a
|
|
9
|
+
// pull that read only the first page would put the older half of the account permanently outside
|
|
10
|
+
// the twin while reporting a complete fold.
|
|
11
|
+
//
|
|
12
|
+
// PUSH (twin → real): unlike xidentity, this vendor HAS a write API, so push is real. A pending
|
|
13
|
+
// `x.post.create` / `x.post.reply` / `x.post.quote` becomes `POST /2/tweets` (each carrying the
|
|
14
|
+
// same body field the twin dispatched on — `reply.in_reply_to_tweet_id` for a reply,
|
|
15
|
+
// `quote_tweet_id` for a quote, neither for an original post); a pending `x.post.delete`
|
|
16
|
+
// becomes `DELETE /2/tweets/:id`. Each is confirmed with the id the REAL vendor minted, so the
|
|
17
|
+
// twin's projection stops claiming a local id for a post that now exists in public.
|
|
18
|
+
//
|
|
19
|
+
// MEDIA. A post that carries images or a video names each one on its entry by key, local media id
|
|
20
|
+
// and DIGEST (x-twin.ts createPost). The local media id means nothing at X, and an upload is not an
|
|
21
|
+
// entry of its own (x-media.ts), so there is no adopted subject to resolve: the perform adapter
|
|
22
|
+
// reads each file's bytes off the blob seam by digest, uploads them to the vendor through the same
|
|
23
|
+
// v2 media endpoints the twin serves (one-shot for an image; initialize/append/finalize for a video,
|
|
24
|
+
// then STATUS until X's processing says `succeeded`), and creates the post with the media ids X
|
|
25
|
+
// minted. X media ids expire a day after upload, so uploading at perform time is also what the
|
|
26
|
+
// vendor asks for.
|
|
27
|
+
//
|
|
28
|
+
// THE PERFORM PATH IS BUDGETED. The kernel hands `performXAction` its own executor
|
|
29
|
+
// (buildRemoteExecute: the World's sealed credential), which knows nothing of X's limits, so the
|
|
30
|
+
// adapter wraps it (`budgetedXExecute`) in this pack's XBudget ledger — the same mechanism
|
|
31
|
+
// `liveXExecute` uses: every call is charged BEFORE it goes out, a refusal THROWS without calling
|
|
32
|
+
// X, and a 429 / Retry-After arms the persisted cooldown. The ledger is the box's X ledger
|
|
33
|
+
// (VOLTER_HOME), shared by every World and branch that performs to X from here, so no number of
|
|
34
|
+
// branches multiplies the allowance.
|
|
35
|
+
//
|
|
36
|
+
// THE BOUND ON A VIDEO. X's processing can take longer than anyone should hold a request for. The
|
|
37
|
+
// wait on STATUS honours each `check_after_secs` (at least 1 s) and stops at 120 s in total or 30
|
|
38
|
+
// polls, whichever comes first; past that the perform fails with a RETRYABLE reason ("video still
|
|
39
|
+
// processing at the vendor"). The kernel records that as a `failed` receipt, which leaves the entry
|
|
40
|
+
// deployable, so the next deploy performs it again (and uploads afresh — an unfinished media id is
|
|
41
|
+
// not reused). So a deploy, and a write at an `auto` head (which performs inside the request),
|
|
42
|
+
// spends at most ~120 s waiting on X's processing per video, plus the upload itself. At an `auto`
|
|
43
|
+
// head the kernel reverts a write whose perform failed (head.ts performAtHead): the app is told no,
|
|
44
|
+
// with that reason, and retries the write itself.
|
|
45
|
+
//
|
|
46
|
+
// The vendor I/O is an INJECTED executor (the auth boundary): the kernel and this pack hold NO X
|
|
47
|
+
// credential and import NO network client. Offline/tests pass a fake executor; live runs pass
|
|
48
|
+
// `liveXExecute(accessToken)`. Same code path either way.
|
|
49
|
+
import { assertBudgetGuardIntact, confirmAction, pendingActions, syncPull } from '@volter/world-core';
|
|
50
|
+
import { XBudget, XBudgetError, xBudgetPath, xCallWeight } from "./x-budget.js";
|
|
51
|
+
import { readXBlob } from "./x-media.js";
|
|
52
|
+
const SERVICE = 'x';
|
|
53
|
+
/** X's real host — the live executor's routing table. ONLY mapped paths may be called live, so a
|
|
54
|
+
* typo or a widened caller cannot reach an X endpoint this pack has never modelled. */
|
|
55
|
+
const HOST = 'https://api.x.com';
|
|
56
|
+
const ALLOWED_PATHS = [
|
|
57
|
+
/^\/2\/tweets$/,
|
|
58
|
+
/^\/2\/tweets\/[^/]+$/,
|
|
59
|
+
/^\/2\/users\/[^/]+\/mentions$/,
|
|
60
|
+
/^\/2\/users\/[^/]+\/tweets$/,
|
|
61
|
+
];
|
|
62
|
+
/** The modelled tweet.fields a pull requests — every field the twin's post rows can hold. */
|
|
63
|
+
export const PULL_TWEET_FIELDS = ['author_id', 'created_at', 'conversation_id', 'in_reply_to_user_id', 'referenced_tweets'];
|
|
64
|
+
/**
|
|
65
|
+
* A live executor against the real X API, holding the operator's OWN user access token.
|
|
66
|
+
*
|
|
67
|
+
* THIS IS THE ONE PLACE this pack issues a live X request, and therefore the one place the rate
|
|
68
|
+
* budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request goes
|
|
69
|
+
* out (`checkBudget`, which THROWS instead of returning when the ceiling or a cooldown says stop)
|
|
70
|
+
* and the response is fed back (`recordCall`) so a 429 / `Retry-After` becomes a PERSISTED
|
|
71
|
+
* cooldown that makes every later call fail fast WITHOUT touching X. There is deliberately no
|
|
72
|
+
* option to disable the guard and no value of `budget` that yields an unguarded client
|
|
73
|
+
* (`assertBudgetGuardIntact`). This matters more here than on a read-only vendor: an unguarded
|
|
74
|
+
* loop at this vendor does not waste quota, it POSTS IN PUBLIC.
|
|
75
|
+
*/
|
|
76
|
+
export function liveXExecute(accessToken, opts = {}) {
|
|
77
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
78
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
79
|
+
? assertBudgetGuardIntact(opts.budget, XBudget, 'liveXExecute')
|
|
80
|
+
: new XBudget({ ...(opts.budgetOptions ?? {}), token: accessToken });
|
|
81
|
+
const explicitLedger = opts.budgetOptions?.path !== undefined || opts.budgetOptions?.root !== undefined;
|
|
82
|
+
if (opts.budget && !explicitLedger && budget.path !== xBudgetPath({ token: accessToken })) {
|
|
83
|
+
throw new Error('liveXExecute: injected budget is not keyed to the credential this client will send');
|
|
84
|
+
}
|
|
85
|
+
return async (method, path, init) => {
|
|
86
|
+
const bare = path.split('?')[0] ?? path;
|
|
87
|
+
if (!ALLOWED_PATHS.some((allowed) => allowed.test(bare)))
|
|
88
|
+
throw new Error(`liveXExecute: refusing to call an unmapped X path: ${bare}`);
|
|
89
|
+
const weight = xCallWeight(method, path);
|
|
90
|
+
if (Object.keys(init?.headers ?? {}).some((name) => name.toLowerCase() === 'authorization')) {
|
|
91
|
+
throw new Error('liveXExecute: refusing an injected Authorization header; the guarded credential is fixed at construction');
|
|
92
|
+
}
|
|
93
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
94
|
+
const reservation = budget.checkBudget(weight);
|
|
95
|
+
const res = await doFetch(`${HOST}${path}`, {
|
|
96
|
+
method,
|
|
97
|
+
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}), Authorization: `Bearer ${accessToken}` },
|
|
98
|
+
...(init?.body !== undefined ? { body: init.body } : {}),
|
|
99
|
+
});
|
|
100
|
+
const resHeaders = {};
|
|
101
|
+
res.headers.forEach((v, k) => {
|
|
102
|
+
resHeaders[k.toLowerCase()] = v;
|
|
103
|
+
});
|
|
104
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. The cooldown is
|
|
105
|
+
// persisted before body parsing or any throw, so even an HTML/plain-text 429 survives it.
|
|
106
|
+
// recordCall may THROW after arming it (a back-off beyond the cap). On a refused call that
|
|
107
|
+
// louder refusal wins; on a call X ACCEPTED the answer is kept — a post that landed in public
|
|
108
|
+
// must be confirmed under its id, never recorded as failed and posted again on retry.
|
|
109
|
+
try {
|
|
110
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!(error instanceof XBudgetError) || !res.ok)
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const raw = await res.text();
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = raw === '' ? {} : JSON.parse(raw);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`x returned non-JSON for ${method} ${bare}: HTTP ${res.status}`);
|
|
123
|
+
}
|
|
124
|
+
// A REFUSED call is NOT an empty result. X answers failures with a problem envelope
|
|
125
|
+
// (`title`/`type`), a legacy `errors` array, OR a 200 whose body carries partial `errors` —
|
|
126
|
+
// a status check alone cannot tell refusal from emptiness, so every refusal shape throws.
|
|
127
|
+
if (res.status >= 400)
|
|
128
|
+
throw new Error(`x refused ${method} ${bare}: HTTP ${res.status} ${JSON.stringify(parsed)}`);
|
|
129
|
+
if (parsed && typeof parsed === 'object' && !('data' in parsed) && !('meta' in parsed) && ('errors' in parsed || 'title' in parsed)) {
|
|
130
|
+
throw new Error(`x refused ${method} ${bare}: ${JSON.stringify(parsed)}`);
|
|
131
|
+
}
|
|
132
|
+
return parsed;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Map one real timeline post → the twin's `post` SyncResource. Nothing invented: a field the
|
|
136
|
+
* response omits records NOTHING rather than a placeholder, so a partial reply can never fold a
|
|
137
|
+
* null over a previously observed value. */
|
|
138
|
+
export function mapTimelinePost(row) {
|
|
139
|
+
return {
|
|
140
|
+
type: 'post',
|
|
141
|
+
id: String(row.id ?? ''),
|
|
142
|
+
fields: {
|
|
143
|
+
...(typeof row.text === 'string' ? { text: row.text } : {}),
|
|
144
|
+
...(typeof row.author_id === 'string' ? { author_id: row.author_id } : {}),
|
|
145
|
+
...(typeof row.created_at === 'string' ? { created_at: row.created_at } : {}),
|
|
146
|
+
...(typeof row.conversation_id === 'string' ? { conversation_id: row.conversation_id } : {}),
|
|
147
|
+
...(typeof row.in_reply_to_user_id === 'string' ? { in_reply_to_user_id: row.in_reply_to_user_id } : {}),
|
|
148
|
+
...(Array.isArray(row.referenced_tweets) ? { referenced_tweets: row.referenced_tweets } : {}),
|
|
149
|
+
deleted: false,
|
|
150
|
+
pulled: true,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A MOVING pull timestamp, forced strictly increasing within the process — never a pinned
|
|
156
|
+
* constant (ADDING_A_TWIN.md §6: under a fixed poll time a vendor value that REVERTS across polls
|
|
157
|
+
* collides with its own earlier observation and the delta silently vanishes).
|
|
158
|
+
*/
|
|
159
|
+
let lastPollMs = 0;
|
|
160
|
+
function pollTimestamp() {
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
|
|
163
|
+
return new Date(lastPollMs).toISOString();
|
|
164
|
+
}
|
|
165
|
+
/** How many pages one pull will follow before it refuses. A vendor that keeps handing back a
|
|
166
|
+
* token is a loop or a bug, not a deep timeline; the pull stops and SAYS SO rather than paging
|
|
167
|
+
* forever or quietly truncating. 32 pages at X's 100-per-page ceiling is its own 3200-post
|
|
168
|
+
* timeline depth. */
|
|
169
|
+
const MAX_PULL_PAGES = 32;
|
|
170
|
+
/**
|
|
171
|
+
* Read ONE timeline to its end, following `meta.next_token`.
|
|
172
|
+
*
|
|
173
|
+
* The refusal checks live HERE, not only in the live executor: an injected executor (or a vendor
|
|
174
|
+
* 200 carrying a problem envelope) must never fold an empty timeline over observed state. X OMITS
|
|
175
|
+
* `data` on an empty timeline and answers with `meta` alone, so an empty page is
|
|
176
|
+
* `meta.result_count === 0` — a body with NEITHER `data` nor `meta` is a refusal and throws.
|
|
177
|
+
*/
|
|
178
|
+
async function collectXTimeline(execute, label, basePath) {
|
|
179
|
+
const collected = [];
|
|
180
|
+
const seenTokens = new Set();
|
|
181
|
+
let token;
|
|
182
|
+
for (let page = 0; page < MAX_PULL_PAGES; page += 1) {
|
|
183
|
+
const paging = token === undefined ? '' : `&pagination_token=${encodeURIComponent(token)}`;
|
|
184
|
+
const body = await execute('GET', `${basePath}?tweet.fields=${PULL_TWEET_FIELDS.join(',')}${paging}`);
|
|
185
|
+
if (!body || typeof body !== 'object')
|
|
186
|
+
throw new Error(`x ${label} pull refused or malformed: no body`);
|
|
187
|
+
const rows = body.data;
|
|
188
|
+
if (rows === undefined) {
|
|
189
|
+
if (body.meta && typeof body.meta === 'object')
|
|
190
|
+
return collected;
|
|
191
|
+
throw new Error(`x ${label} pull refused or malformed: ${JSON.stringify(body).slice(0, 200)}`);
|
|
192
|
+
}
|
|
193
|
+
if (!Array.isArray(rows))
|
|
194
|
+
throw new Error(`x ${label} pull refused or malformed: ${JSON.stringify(body).slice(0, 200)}`);
|
|
195
|
+
// An X post id is a NUMERIC snowflake string. A row without one is not a post this twin can
|
|
196
|
+
// key, and folding it would put a foreign id into the projection's id space.
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
if (!row || typeof row !== 'object' || typeof row.id !== 'string' || !/^\d+$/.test(row.id)) {
|
|
199
|
+
throw new Error(`x ${label} pull returned a row with no X-shaped id: ${JSON.stringify(row).slice(0, 200)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
collected.push(...rows.map(mapTimelinePost));
|
|
203
|
+
const next = body.meta?.next_token;
|
|
204
|
+
if (next === undefined || next === null)
|
|
205
|
+
return collected;
|
|
206
|
+
if (typeof next !== 'string' || next === '')
|
|
207
|
+
throw new Error(`x ${label} pull returned an unusable next_token: ${JSON.stringify(next)}`);
|
|
208
|
+
// A token this pull has already followed means the vendor is not advancing. Following it
|
|
209
|
+
// again loops; stopping SILENTLY would report a complete pull that is missing rows.
|
|
210
|
+
if (seenTokens.has(next))
|
|
211
|
+
throw new Error(`x ${label} pull was handed a repeated next_token (${next}); refusing to page in a loop`);
|
|
212
|
+
seenTokens.add(next);
|
|
213
|
+
token = next;
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`x ${label} pull exceeded ${MAX_PULL_PAGES} pages; refusing to page further`);
|
|
216
|
+
}
|
|
217
|
+
async function collectXMentions(execute, userId) {
|
|
218
|
+
return collectXTimeline(execute, 'mentions', `/2/users/${encodeURIComponent(userId)}/mentions`);
|
|
219
|
+
}
|
|
220
|
+
/** The org's OWN posted timeline — what it said, including what it said somewhere else. */
|
|
221
|
+
async function collectXOwnTimeline(execute, userId) {
|
|
222
|
+
return collectXTimeline(execute, 'own timeline', `/2/users/${encodeURIComponent(userId)}/tweets`);
|
|
223
|
+
}
|
|
224
|
+
/** PULL the org's mentions timeline into the twin's observed log (idempotent). */
|
|
225
|
+
export async function pullXMentions(execute, userId, root, occurredAt) {
|
|
226
|
+
const resources = await collectXMentions(execute, userId);
|
|
227
|
+
syncPull({ service: SERVICE, resources, occurredAt: occurredAt ?? pollTimestamp(), ...(root !== undefined ? { root } : {}) });
|
|
228
|
+
return resources.length;
|
|
229
|
+
}
|
|
230
|
+
/** PULL the org's OWN posted timeline into the twin's observed log (idempotent). */
|
|
231
|
+
export async function pullXOwnTimeline(execute, userId, root, occurredAt) {
|
|
232
|
+
const resources = await collectXOwnTimeline(execute, userId);
|
|
233
|
+
syncPull({ service: SERVICE, resources, occurredAt: occurredAt ?? pollTimestamp(), ...(root !== undefined ? { root } : {}) });
|
|
234
|
+
return resources.length;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* D7 consumer-facing pull entry point: pull everything readable from the real X posting surface
|
|
238
|
+
* and fold it into the twin in ONE shadow-diffed `syncPull`, returning the standard
|
|
239
|
+
* `{ observed, deltasAppended }`. Idempotent — a re-pull of identical state appends nothing.
|
|
240
|
+
*/
|
|
241
|
+
export async function syncXFromReal(execute, opts) {
|
|
242
|
+
const occurredAt = opts.occurredAt ?? pollTimestamp();
|
|
243
|
+
const mentions = await collectXMentions(execute, opts.userId);
|
|
244
|
+
// BOTH halves by DEFAULT. `includeOwnTimeline: false` exists for a caller that genuinely only
|
|
245
|
+
// wants the inbox — never as the default, which is exactly the hole the pull audit filed as
|
|
246
|
+
// `x.connector.pull_own_timeline`.
|
|
247
|
+
const own = opts.includeOwnTimeline === false ? [] : await collectXOwnTimeline(execute, opts.userId);
|
|
248
|
+
// At the vendor a post can only be on one of the two timelines (the mentions timeline excludes
|
|
249
|
+
// the account's own posts), but keying the fold by id keeps it single-valued rather than
|
|
250
|
+
// trusting that.
|
|
251
|
+
const byId = new Map();
|
|
252
|
+
for (const resource of [...mentions, ...own])
|
|
253
|
+
byId.set(resource.id, resource);
|
|
254
|
+
const resources = [...byId.values()];
|
|
255
|
+
const result = syncPull({ service: SERVICE, resources, occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
256
|
+
return { observed: result.observed, deltasAppended: result.deltasAppended };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The request ONE pending twin action becomes at the real vendor. A pure shape builder — it
|
|
260
|
+
* issues nothing, so it can be asserted directly. The reply's discriminator travels here exactly
|
|
261
|
+
* as the twin read it: `reply.in_reply_to_tweet_id`, the same field, in the same place.
|
|
262
|
+
*/
|
|
263
|
+
export function xRequestForAction(action) {
|
|
264
|
+
const fields = action.fields ?? {};
|
|
265
|
+
const id = action.subject?.id ?? '';
|
|
266
|
+
// `media.media_ids`, when the post carries media: the ids as the fields name them — the vendor's
|
|
267
|
+
// once performXAction has uploaded and swapped them in.
|
|
268
|
+
const mediaIds = Array.isArray(fields.media) ? fields.media.map((m) => String(m?.media_id ?? '')).filter((m) => m !== '') : [];
|
|
269
|
+
const media = mediaIds.length > 0 ? { media: { media_ids: mediaIds } } : {};
|
|
270
|
+
if (action.operation === 'x.post.create') {
|
|
271
|
+
return { method: 'POST', path: '/2/tweets', body: JSON.stringify({ text: String(fields.text ?? ''), ...media }) };
|
|
272
|
+
}
|
|
273
|
+
if (action.operation === 'x.post.reply') {
|
|
274
|
+
const referenced = Array.isArray(fields.referenced_tweets) ? fields.referenced_tweets : [];
|
|
275
|
+
const parent = referenced.find((r) => r?.type === 'replied_to');
|
|
276
|
+
if (!parent?.id)
|
|
277
|
+
return undefined;
|
|
278
|
+
return { method: 'POST', path: '/2/tweets', body: JSON.stringify({ text: String(fields.text ?? ''), reply: { in_reply_to_tweet_id: String(parent.id) }, ...media }) };
|
|
279
|
+
}
|
|
280
|
+
if (action.operation === 'x.post.quote') {
|
|
281
|
+
const referenced = Array.isArray(fields.referenced_tweets) ? fields.referenced_tweets : [];
|
|
282
|
+
const quoted = referenced.find((r) => r?.type === 'quoted');
|
|
283
|
+
if (!quoted?.id)
|
|
284
|
+
return undefined;
|
|
285
|
+
return { method: 'POST', path: '/2/tweets', body: JSON.stringify({ text: String(fields.text ?? ''), quote_tweet_id: String(quoted.id), ...media }) };
|
|
286
|
+
}
|
|
287
|
+
if (action.operation === 'x.post.delete') {
|
|
288
|
+
return { method: 'DELETE', path: `/2/tweets/${encodeURIComponent(id)}` };
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* PUSH one pending action to the real vendor and confirm it with what the vendor answered.
|
|
294
|
+
*
|
|
295
|
+
* A create/reply is confirmed under the REAL id X minted, not the twin's locally minted one:
|
|
296
|
+
* after a push the public post has a public id, and a projection still claiming the local id
|
|
297
|
+
* would make every later delete address a post that does not exist. The local subject is
|
|
298
|
+
* confirmed as superseded in the same call.
|
|
299
|
+
*/
|
|
300
|
+
export async function pushXAction(execute, action, opts = {}) {
|
|
301
|
+
// A post's media ids are local; only the perform adapter uploads the files and swaps in X's ids.
|
|
302
|
+
// So a create/reply/quote CARRYING media is not pushable here — reported, not thrown, so a push
|
|
303
|
+
// of many entries carries on past it. A delete never uploads anything and is pushed as ever.
|
|
304
|
+
const writesPost = action.operation === 'x.post.create' || action.operation === 'x.post.reply' || action.operation === 'x.post.quote';
|
|
305
|
+
if (writesPost && Array.isArray(action.fields?.media) && action.fields.media.length > 0) {
|
|
306
|
+
return { pushed: false, reason: `${action.operation} carries media, which crosses through performXAction (it uploads the files first)` };
|
|
307
|
+
}
|
|
308
|
+
const request = xRequestForAction(action);
|
|
309
|
+
if (!request)
|
|
310
|
+
return { pushed: false };
|
|
311
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
312
|
+
const localId = action.subject?.id ?? '';
|
|
313
|
+
const reply = await execute(request.method, request.path, request.body === undefined ? undefined : { body: request.body });
|
|
314
|
+
if (request.method === 'DELETE') {
|
|
315
|
+
if (reply?.data?.deleted !== true)
|
|
316
|
+
throw new Error(`x refused the delete of ${localId}: ${JSON.stringify(reply).slice(0, 200)}`);
|
|
317
|
+
confirmAction({ service: SERVICE, actionId: action.id, subject: { type: 'post', id: localId }, fields: { ...(action.fields ?? {}), deleted: true, pushed: true }, occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
318
|
+
return { pushed: true, realId: localId };
|
|
319
|
+
}
|
|
320
|
+
const realId = reply?.data?.id;
|
|
321
|
+
if (typeof realId !== 'string' || !/^\d+$/.test(realId))
|
|
322
|
+
throw new Error(`x returned no post id for ${action.operation}: ${JSON.stringify(reply).slice(0, 200)}`);
|
|
323
|
+
confirmAction({
|
|
324
|
+
service: SERVICE,
|
|
325
|
+
actionId: action.id,
|
|
326
|
+
subject: { type: 'post', id: realId },
|
|
327
|
+
fields: { ...(action.fields ?? {}), pushed: true, local_id: localId },
|
|
328
|
+
// The locally minted subject is retired in the SAME compound confirmation, so no window
|
|
329
|
+
// exists in which both ids look live.
|
|
330
|
+
...(localId !== realId ? { additionalObservations: [{ subject: { type: 'post', id: localId }, fields: { ...(action.fields ?? {}), superseded_by: realId, deleted: true } }] } : {}),
|
|
331
|
+
occurredAt,
|
|
332
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
333
|
+
});
|
|
334
|
+
return { pushed: true, realId };
|
|
335
|
+
}
|
|
336
|
+
/** Push every pending action this connector knows how to push. */
|
|
337
|
+
export async function pushPendingXActions(execute, opts = {}) {
|
|
338
|
+
let pushed = 0;
|
|
339
|
+
let unpushable = 0;
|
|
340
|
+
for (const action of pendingActions(SERVICE, opts.root)) {
|
|
341
|
+
const result = await pushXAction(execute, action, opts);
|
|
342
|
+
if (result.pushed)
|
|
343
|
+
pushed += 1;
|
|
344
|
+
else
|
|
345
|
+
unpushable += 1;
|
|
346
|
+
}
|
|
347
|
+
return { pushed, unpushable };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The perform adapter (runtime contract R18): what a World's deploy calls for each landed X entry
|
|
351
|
+
* on a twin whose root is the platform. A post, reply, quote or delete crosses through the host's
|
|
352
|
+
* executor, which adds the sealed credential; the kernel records the landing from the returned id.
|
|
353
|
+
* Anything else (a seeded account or token) is the twin's own record and crosses nothing. A
|
|
354
|
+
* reply's or quote's parent authored against a local id crosses against the id X minted for it.
|
|
355
|
+
*/
|
|
356
|
+
/**
|
|
357
|
+
* The kernel executor, charged to this pack's XBudget: the check RESERVES before the call and
|
|
358
|
+
* throws (XBudgetError) instead of calling when the ceiling, the burst bound or a cooldown says
|
|
359
|
+
* stop; the response settles the reservation and arms a cooldown on 429 / Retry-After.
|
|
360
|
+
*/
|
|
361
|
+
export function budgetedXExecute(execute, budget = new XBudget()) {
|
|
362
|
+
const guard = assertBudgetGuardIntact(budget, XBudget, 'budgetedXExecute');
|
|
363
|
+
return async (request) => {
|
|
364
|
+
const weight = xCallWeight(request.method, request.path);
|
|
365
|
+
const reservation = guard.checkBudget(weight);
|
|
366
|
+
const res = await execute(request);
|
|
367
|
+
// The vendor has ANSWERED: whatever the ledger says next, the answer goes back. recordCall
|
|
368
|
+
// persists any cooldown BEFORE it throws (a back-off beyond the cap), so the throw is caught
|
|
369
|
+
// here and the cooldown stands — the next call is refused by it. Dropping the answer would
|
|
370
|
+
// record a write X accepted as failed: at an auto head the entry is reverted, and a retry
|
|
371
|
+
// posts it twice. A refused answer carries its own status to the caller.
|
|
372
|
+
try {
|
|
373
|
+
guard.recordCall(weight, Object.fromEntries(Object.entries(res.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])), { status: res.status, reservation });
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (!(error instanceof XBudgetError))
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
return res;
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
export async function performXAction(kernelExecute, action, ctx) {
|
|
383
|
+
// ONE LEDGER PER CREDENTIAL: X's limits belong to the token, so every World and branch performing
|
|
384
|
+
// with one sealed credential shares that credential's ledger (keyed by its fingerprint); with none
|
|
385
|
+
// sealed (a twin-only perform) the ledger is the control root's, never one machine-wide file
|
|
386
|
+
const execute = budgetedXExecute(kernelExecute, new XBudget(ctx.credential !== undefined ? { token: ctx.credential } : ctx.root !== undefined ? { root: ctx.root } : {}));
|
|
387
|
+
const fields = { ...(action.fields ?? {}) };
|
|
388
|
+
if (Array.isArray(fields.referenced_tweets)) {
|
|
389
|
+
fields.referenced_tweets = fields.referenced_tweets.map((r) => (r?.id ? { ...r, id: ctx.resolve('post', String(r.id)) } : r));
|
|
390
|
+
}
|
|
391
|
+
const subjectId = action.subject?.id ?? '';
|
|
392
|
+
const target = action.operation === 'x.post.delete' ? ctx.resolve('post', subjectId) : subjectId;
|
|
393
|
+
// Media first: each file goes to the vendor, and the post names the ids X minted for them.
|
|
394
|
+
const vendorMediaIds = [];
|
|
395
|
+
if (action.operation !== 'x.post.delete' && Array.isArray(fields.media) && fields.media.length > 0) {
|
|
396
|
+
const uploaded = [];
|
|
397
|
+
for (const media of fields.media) {
|
|
398
|
+
const bytes = typeof media.sha256 === 'string' ? await readXBlob(media.sha256, ctx.root) : null;
|
|
399
|
+
if (!bytes)
|
|
400
|
+
throw new Error(`x cannot perform ${action.operation} on ${subjectId}: the bytes of ${String(media.media_key)} are not on this twin's blob seam`);
|
|
401
|
+
const vendor = await uploadMediaToVendor(execute, media, bytes);
|
|
402
|
+
vendorMediaIds.push(vendor.id);
|
|
403
|
+
uploaded.push({ ...media, media_id: vendor.id, ...(vendor.media_key ? { media_key: vendor.media_key } : {}) });
|
|
404
|
+
}
|
|
405
|
+
fields.media = uploaded;
|
|
406
|
+
}
|
|
407
|
+
const request = xRequestForAction({ operation: action.operation, subject: { type: 'post', id: target }, fields });
|
|
408
|
+
if (!request) {
|
|
409
|
+
// seeded state (x.twin.*) is the twin's own record; a post it cannot express fails loudly, never a silent receipt
|
|
410
|
+
if ((action.operation ?? '').startsWith('x.twin.'))
|
|
411
|
+
return { externalId: ctx.resolve(action.subject?.type ?? 'post', subjectId), data: { performed: false, reason: `${action.operation} is the twin's own record — nothing at X to write` } };
|
|
412
|
+
throw new Error(`x cannot perform ${action.operation ?? 'this entry'} on ${subjectId}: no X request expresses it`);
|
|
413
|
+
}
|
|
414
|
+
const res = await execute({
|
|
415
|
+
method: request.method,
|
|
416
|
+
path: request.path,
|
|
417
|
+
headers: { accept: 'application/json', ...(request.body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
418
|
+
...(request.body === undefined ? {} : { body: request.body }),
|
|
419
|
+
});
|
|
420
|
+
if (res.status < 200 || res.status >= 300)
|
|
421
|
+
throw new Error(`x refused ${action.operation}: HTTP ${res.status} ${res.body.slice(0, 200)}`);
|
|
422
|
+
let reply = {};
|
|
423
|
+
try {
|
|
424
|
+
reply = res.body ? JSON.parse(res.body) : {};
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
throw new Error(`x answered ${action.operation} with a body that is not JSON: ${res.body.slice(0, 200)}`);
|
|
428
|
+
}
|
|
429
|
+
if (request.method === 'DELETE') {
|
|
430
|
+
if (reply?.data?.deleted !== true)
|
|
431
|
+
throw new Error(`x refused the delete of ${target}: ${res.body.slice(0, 200)}`);
|
|
432
|
+
return { externalId: target, data: { deleted: true } };
|
|
433
|
+
}
|
|
434
|
+
const realId = reply?.data?.id;
|
|
435
|
+
if (typeof realId !== 'string' || !/^\d+$/.test(realId))
|
|
436
|
+
throw new Error(`x returned no post id for ${action.operation}: ${res.body.slice(0, 200)}`);
|
|
437
|
+
return { externalId: realId, url: `https://x.com/i/web/status/${realId}`, data: { text: reply.data.text, ...(vendorMediaIds.length > 0 ? { media: fields.media } : {}) } };
|
|
438
|
+
}
|
|
439
|
+
// ── media to the vendor ──────────────────────────────────────────────────────────────────────
|
|
440
|
+
/** How much of a video one APPEND carries. X accepts segments up to 5 MB. */
|
|
441
|
+
const APPEND_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
442
|
+
/** How long, in total, a perform waits on X's video processing before it fails the entry
|
|
443
|
+
* retryably — the bound on a request at an `auto` head (see the header). */
|
|
444
|
+
export const MAX_PROCESSING_WAIT_MS = 120_000;
|
|
445
|
+
/** At most this many STATUS reads per video, whatever `check_after_secs` says. */
|
|
446
|
+
export const MAX_PROCESSING_POLLS = 30;
|
|
447
|
+
/** The vendor has the video but has not finished processing it inside the bound: retry later. */
|
|
448
|
+
export class XVideoStillProcessingError extends Error {
|
|
449
|
+
mediaId;
|
|
450
|
+
retryable = true;
|
|
451
|
+
constructor(mediaId, waitedMs, polls) {
|
|
452
|
+
super(`video still processing at the vendor (media ${mediaId}) after ${Math.round(waitedMs / 1000)}s and ${polls} STATUS reads — the entry stays pending; the next deploy performs it again`);
|
|
453
|
+
this.mediaId = mediaId;
|
|
454
|
+
this.name = 'XVideoStillProcessingError';
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** A multipart/form-data body as bytes, with the boundary its content-type names. */
|
|
458
|
+
async function multipart(parts) {
|
|
459
|
+
const form = new FormData();
|
|
460
|
+
for (const [name, value] of Object.entries(parts)) {
|
|
461
|
+
if (typeof value === 'string')
|
|
462
|
+
form.append(name, value);
|
|
463
|
+
else {
|
|
464
|
+
const copy = new Uint8Array(value.length);
|
|
465
|
+
copy.set(value);
|
|
466
|
+
form.append(name, new Blob([copy]), 'blob');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const encoded = new Response(form);
|
|
470
|
+
// read the boundary BEFORE the body: Bun drops the content-type once the body has been consumed
|
|
471
|
+
const contentType = encoded.headers.get('content-type');
|
|
472
|
+
if (!contentType)
|
|
473
|
+
throw new Error('x: the runtime did not name a multipart boundary');
|
|
474
|
+
return { body: new Uint8Array(await encoded.arrayBuffer()), contentType };
|
|
475
|
+
}
|
|
476
|
+
async function vendorCall(execute, label, request) {
|
|
477
|
+
const res = await execute({ ...request, headers: { accept: 'application/json', ...(request.headers ?? {}) } });
|
|
478
|
+
if (res.status < 200 || res.status >= 300)
|
|
479
|
+
throw new Error(`x refused ${label}: HTTP ${res.status} ${res.body.slice(0, 200)}`);
|
|
480
|
+
let parsed;
|
|
481
|
+
try {
|
|
482
|
+
parsed = res.body ? JSON.parse(res.body) : {};
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
throw new Error(`x answered ${label} with a body that is not JSON: ${res.body.slice(0, 200)}`);
|
|
486
|
+
}
|
|
487
|
+
if (parsed?.errors && !parsed?.data)
|
|
488
|
+
throw new Error(`x refused ${label}: ${res.body.slice(0, 200)}`);
|
|
489
|
+
return parsed;
|
|
490
|
+
}
|
|
491
|
+
/** One file to the vendor: an image in one request; a video initialized, appended in segments,
|
|
492
|
+
* finalized, then polled on STATUS as X's `processing_info` asks until it has succeeded. */
|
|
493
|
+
async function uploadMediaToVendor(execute, media, bytes) {
|
|
494
|
+
const minted = (answer) => ({ id: String(answer.data.id), ...(typeof answer.data.media_key === 'string' ? { media_key: answer.data.media_key } : {}) });
|
|
495
|
+
const category = typeof media.media_category === 'string' ? media.media_category : media.type === 'video' ? 'tweet_video' : 'tweet_image';
|
|
496
|
+
if (media.type !== 'video') {
|
|
497
|
+
const form = await multipart({ media_category: category, media: bytes });
|
|
498
|
+
const answer = await vendorCall(execute, 'the image upload', { method: 'POST', path: '/2/media/upload', headers: { 'content-type': form.contentType }, body: form.body });
|
|
499
|
+
const id = answer?.data?.id;
|
|
500
|
+
if (typeof id !== 'string' || !/^\d+$/.test(id))
|
|
501
|
+
throw new Error(`x returned no media id for the image upload: ${JSON.stringify(answer).slice(0, 200)}`);
|
|
502
|
+
return minted(answer);
|
|
503
|
+
}
|
|
504
|
+
const init = await vendorCall(execute, 'the video upload (initialize)', {
|
|
505
|
+
method: 'POST', path: '/2/media/upload/initialize', headers: { 'content-type': 'application/json' },
|
|
506
|
+
body: JSON.stringify({ media_type: String(media.media_type ?? 'video/mp4'), total_bytes: bytes.length, media_category: category }),
|
|
507
|
+
});
|
|
508
|
+
const id = init?.data?.id;
|
|
509
|
+
if (typeof id !== 'string' || !/^\d+$/.test(id))
|
|
510
|
+
throw new Error(`x returned no media id for the video upload: ${JSON.stringify(init).slice(0, 200)}`);
|
|
511
|
+
for (let at = 0, index = 0; at < bytes.length; at += APPEND_CHUNK_BYTES, index += 1) {
|
|
512
|
+
const form = await multipart({ segment_index: String(index), media: bytes.subarray(at, at + APPEND_CHUNK_BYTES) });
|
|
513
|
+
await vendorCall(execute, `the video upload (append ${index})`, { method: 'POST', path: `/2/media/upload/${id}/append`, headers: { 'content-type': form.contentType }, body: form.body });
|
|
514
|
+
}
|
|
515
|
+
let state = await vendorCall(execute, 'the video upload (finalize)', { method: 'POST', path: `/2/media/upload/${id}/finalize` });
|
|
516
|
+
const started = Date.now();
|
|
517
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
518
|
+
let polls = 0;
|
|
519
|
+
for (;;) {
|
|
520
|
+
const info = state?.data?.processing_info;
|
|
521
|
+
if (!info || info.state === 'succeeded')
|
|
522
|
+
return minted({ data: { ...init.data, ...(state?.data ?? {}) } });
|
|
523
|
+
if (info.state === 'failed')
|
|
524
|
+
throw new Error(`x failed to process video ${id}: ${JSON.stringify(state.errors ?? info).slice(0, 200)}`);
|
|
525
|
+
const remaining = MAX_PROCESSING_WAIT_MS - (Date.now() - started);
|
|
526
|
+
const waitMs = Math.max(1, Number(info.check_after_secs ?? 1)) * 1000;
|
|
527
|
+
if (polls >= MAX_PROCESSING_POLLS || waitMs > remaining)
|
|
528
|
+
throw new XVideoStillProcessingError(id, Date.now() - started, polls);
|
|
529
|
+
await sleep(waitMs);
|
|
530
|
+
try {
|
|
531
|
+
state = await vendorCall(execute, 'the video upload (status)', { method: 'GET', path: `/2/media/upload?command=STATUS&media_id=${id}` });
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
// the budget's BURST bound refusing a poll is a wait, not a failure, while the bound has room
|
|
535
|
+
// (the poll is not sent until the budget admits it); past the bound it is the same verdict as
|
|
536
|
+
// any other wait that outlasts it — the video is still processing, retry on the next deploy
|
|
537
|
+
if (!(error instanceof XBudgetError) || error.kind !== 'burst')
|
|
538
|
+
throw error;
|
|
539
|
+
if (error.retryAfterMs > MAX_PROCESSING_WAIT_MS - (Date.now() - started))
|
|
540
|
+
throw new XVideoStillProcessingError(id, Date.now() - started, polls);
|
|
541
|
+
await sleep(error.retryAfterMs);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
polls += 1;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** The stored upload session / finished media object, keyed by the media id X hands back. */
|
|
2
|
+
export type XMediaRecord = {
|
|
3
|
+
id: string;
|
|
4
|
+
media_key: string;
|
|
5
|
+
account_id: string;
|
|
6
|
+
media_category: string;
|
|
7
|
+
/** The declared (initialize) or sniffed MIME type. */
|
|
8
|
+
media_type: string;
|
|
9
|
+
/** `initialized` while segments arrive; an image is `succeeded` once its bytes are whole; a video
|
|
10
|
+
* is `processing` (X's pending → in_progress) until STATUS reads walk it to `succeeded`, or
|
|
11
|
+
* `failed` when the bytes are not a video X would take. */
|
|
12
|
+
state: 'initialized' | 'processing' | 'succeeded' | 'failed';
|
|
13
|
+
/** A video's processing step counted in STATUS reads (0 = pending, 1 = in_progress, 2 = done). */
|
|
14
|
+
processing_step?: number;
|
|
15
|
+
/** World-clock epoch seconds of FINALIZE: processing also advances one step per
|
|
16
|
+
* `check_after_secs` elapsed since, so a client that sleeps and then posts is not held back. */
|
|
17
|
+
finalized_at?: number;
|
|
18
|
+
/** Why processing failed, in X's words (served as a problem in the response's `errors`). */
|
|
19
|
+
error?: string;
|
|
20
|
+
duration_ms?: number;
|
|
21
|
+
total_bytes?: number;
|
|
22
|
+
sha256?: string;
|
|
23
|
+
size?: number;
|
|
24
|
+
width?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
additional_owners?: string[];
|
|
27
|
+
created_at: string;
|
|
28
|
+
/** Epoch seconds after which X no longer accepts the id on a post. */
|
|
29
|
+
expires_at: number;
|
|
30
|
+
};
|
|
31
|
+
/** X keeps an uploaded media id usable for a day (`expires_after_secs: 86400` on its answers). */
|
|
32
|
+
export declare const MEDIA_EXPIRES_AFTER_SECS = 86400;
|
|
33
|
+
/** X's image ceiling: 5 MB. */
|
|
34
|
+
export declare const MAX_IMAGE_BYTES: number;
|
|
35
|
+
/** X's video ceiling: 512 MB. */
|
|
36
|
+
export declare const MAX_VIDEO_BYTES: number;
|
|
37
|
+
/** A `tweet_video` may run at most 140 seconds (longer videos are an `amplify_video` or a Premium entitlement). */
|
|
38
|
+
export declare const MAX_TWEET_VIDEO_MS = 140000;
|
|
39
|
+
/** Store bytes content-addressed; a re-upload of identical content is a no-op write. The MIME
|
|
40
|
+
* type the upload was read as rides beside them, so the bytes routes answer with the type the
|
|
41
|
+
* twin decided, never one a URL's extension claims. */
|
|
42
|
+
export declare function putXBlob(bytes: Uint8Array, contentType: string, root?: string): Promise<{
|
|
43
|
+
sha256: string;
|
|
44
|
+
size: number;
|
|
45
|
+
}>;
|
|
46
|
+
/** The MIME type stored bytes were uploaded as (this branch, then its ancestors). */
|
|
47
|
+
export declare function readXBlobType(sha256: string, root?: string): Promise<string | undefined>;
|
|
48
|
+
/** Stored bytes by digest (this branch, then its ancestors); null when absent or malformed. */
|
|
49
|
+
export declare function readXBlob(sha256: string, root?: string): Promise<Uint8Array | null>;
|
|
50
|
+
export declare function writeMediaRecord(record: XMediaRecord, root?: string): Promise<void>;
|
|
51
|
+
export declare function readMediaRecord(id: string, root?: string): Promise<XMediaRecord | undefined>;
|
|
52
|
+
/** Every media id this branch or an ancestor has minted — so a new media id (and a new post id)
|
|
53
|
+
* steps past them all. */
|
|
54
|
+
export declare function listMediaIds(root?: string): Promise<string[]>;
|
|
55
|
+
/** One APPEND segment, stored under its index (a retried index overwrites, as at X). */
|
|
56
|
+
export declare function putSegment(id: string, index: number, bytes: Uint8Array, root?: string): Promise<void>;
|
|
57
|
+
/** Every segment of an upload, in index order, across the branch chain (the nearest copy of an
|
|
58
|
+
* index wins, as a retried APPEND overwrites at X). */
|
|
59
|
+
export declare function readSegments(id: string, root?: string): Promise<Uint8Array[]>;
|
|
60
|
+
export declare function clearSegments(id: string, root?: string): Promise<void>;
|
|
61
|
+
export type ImageInfo = {
|
|
62
|
+
mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
65
|
+
};
|
|
66
|
+
export declare function sniffImage(bytes: Uint8Array): ImageInfo | undefined;
|
|
67
|
+
export type VideoInfo = {
|
|
68
|
+
mediaType: 'video/mp4';
|
|
69
|
+
width: number;
|
|
70
|
+
height: number;
|
|
71
|
+
durationMs: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* An MP4 (ISO BMFF) read from its boxes: `ftyp` first, then `moov` (anywhere — ffmpeg writes it
|
|
75
|
+
* after `mdat` unless told to fast-start) holding `mvhd` (timescale + duration) and one `trak` per
|
|
76
|
+
* stream whose `tkhd` carries the presented width/height (16.16 fixed point); the video track is
|
|
77
|
+
* the one with a non-zero size. Nothing is decoded — the codec is the browser's business.
|
|
78
|
+
*/
|
|
79
|
+
export declare function sniffVideo(bytes: Uint8Array): VideoInfo | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* The video's preview image. X's is a frame its transcoder cut; the twin decodes nothing (the codec
|
|
82
|
+
* is the browser's), so it serves a STAND-IN at the video's own size and aspect — a dark frame with
|
|
83
|
+
* X's play glyph — and says so here. `x.media.video_poster_frame` is the todo for a real frame.
|
|
84
|
+
*/
|
|
85
|
+
export declare function videoPosterSvg(width: number, height: number): string;
|
|
86
|
+
/** The file extension pbs.twimg.com serves an image under. */
|
|
87
|
+
export declare function mediaExtension(mediaType: string): string;
|