@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,143 @@
|
|
|
1
|
+
// X API v2 error envelopes for the POSTING surface.
|
|
2
|
+
//
|
|
3
|
+
// TRANSCRIBED, NOT SHARED. `packages/twin/xidentity/src/xidentity-problems.ts` already models
|
|
4
|
+
// X's error families for the identity surface, and two packs disagreeing about one vendor's
|
|
5
|
+
// error shape would be worse than one pack. The architecture guardrail forbids a cross-vendor
|
|
6
|
+
// pack import (`scripts/architecture.test.ts`, A3: "does not import another vendor pack"), so
|
|
7
|
+
// the agreement is kept by TRANSCRIPTION and stated here, at the seam, rather than by a shared
|
|
8
|
+
// module: every envelope below is byte-identical in shape to the xidentity function named beside
|
|
9
|
+
// it. If X's error shape is ever re-grounded, both files move together.
|
|
10
|
+
//
|
|
11
|
+
// The families this surface can produce (numbering follows xidentity-problems.ts):
|
|
12
|
+
//
|
|
13
|
+
// 2. v2 problem envelopes — the OpenAPI 2.167 `Problem` family, discriminated by a `type`
|
|
14
|
+
// URL. The generic auth failure is the `about:blank` form.
|
|
15
|
+
// (xidentity: `unauthorizedProblem` / `forbiddenProblem` / `notFoundProblem`.)
|
|
16
|
+
// 3. the request-level invalid-parameter envelope `{errors:[{parameters,message}], title,
|
|
17
|
+
// detail, type: …/invalid-request}` — the OpenAPI's InvalidRequestProblem.
|
|
18
|
+
// (xidentity: `invalidRequestProblem`.)
|
|
19
|
+
// 4. the 15-minute-window rate refusal: HTTP 429 with legacy error code 88.
|
|
20
|
+
// (xidentity: `rateLimitExceeded`.)
|
|
21
|
+
//
|
|
22
|
+
// Family 1 (the RFC 6749 token-endpoint body) cannot arise here: OAuth is xidentity's and stays
|
|
23
|
+
// there. This pack consumes the bearer token that flow produces; it never mints one.
|
|
24
|
+
//
|
|
25
|
+
// EVIDENCE BOUNDARY. Shapes are the OpenAPI 2.167 `Problem` family as recorded in
|
|
26
|
+
// spec-sources.json's xidentity entry (live-fetched 2026-08-21). Exact wire WORDING for the
|
|
27
|
+
// posting surface's refusals was not captured from an official artefact by this build — no X
|
|
28
|
+
// call is made anywhere in this pack — so each string that is not shape-forced is filed as a
|
|
29
|
+
// pinning todo in the manifest (`x.errors.*_wording`) rather than claimed pinned.
|
|
30
|
+
|
|
31
|
+
export type XResponse = {
|
|
32
|
+
status: number;
|
|
33
|
+
body: unknown;
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const NOSTORE = { 'cache-control': 'no-cache, no-store, max-age=0' };
|
|
38
|
+
const PROBLEM = { ...NOSTORE, 'content-type': 'application/problem+json' };
|
|
39
|
+
|
|
40
|
+
/** The generic auth problem X answers a bad/absent/expired bearer with on /2 resources. */
|
|
41
|
+
export function unauthorizedProblem(): XResponse {
|
|
42
|
+
return {
|
|
43
|
+
status: 401,
|
|
44
|
+
body: { title: 'Unauthorized', type: 'about:blank', status: 401, detail: 'Unauthorized' },
|
|
45
|
+
headers: { ...PROBLEM },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A user token whose scope set does not cover the endpoint. Wording: `x.errors.scope_wording`. */
|
|
50
|
+
export function forbiddenProblem(detail: string): XResponse {
|
|
51
|
+
return {
|
|
52
|
+
status: 403,
|
|
53
|
+
body: { title: 'Forbidden', type: 'about:blank', status: 403, detail },
|
|
54
|
+
headers: { ...PROBLEM },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An unmodelled /2 route fails like the vendor: 404 problem, never a fake success. */
|
|
59
|
+
export function notFoundProblem(): XResponse {
|
|
60
|
+
return {
|
|
61
|
+
status: 404,
|
|
62
|
+
body: { title: 'Not Found Error', type: 'about:blank', status: 404, detail: 'Not Found' },
|
|
63
|
+
headers: { ...PROBLEM },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A named resource the request addressed does not exist — X's `resource-not-found` problem,
|
|
69
|
+
* which carries the offending value/resource_type rather than the bare about:blank form.
|
|
70
|
+
* Wording pinned by `x.errors.resource_not_found_wording`.
|
|
71
|
+
*/
|
|
72
|
+
export function resourceNotFoundError(value: string, resourceType: 'tweet' | 'user', parameter: string): Record<string, unknown> {
|
|
73
|
+
return {
|
|
74
|
+
parameter,
|
|
75
|
+
resource_id: value,
|
|
76
|
+
value,
|
|
77
|
+
detail: `Could not find ${resourceType} with ${parameter}: [${value}].`,
|
|
78
|
+
title: 'Not Found Error',
|
|
79
|
+
resource_type: resourceType,
|
|
80
|
+
type: 'https://api.twitter.com/2/problems/resource-not-found',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resourceNotFoundProblem(value: string, resourceType: 'tweet' | 'user', parameter: string): XResponse {
|
|
85
|
+
return {
|
|
86
|
+
status: 404,
|
|
87
|
+
body: { errors: [resourceNotFoundError(value, resourceType, parameter)] },
|
|
88
|
+
headers: { ...NOSTORE, 'content-type': 'application/json' },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* THE LOOKUP ENVELOPE, and why its status differs from the refusal above. X's *lookup* endpoints
|
|
94
|
+
* (`GET /2/tweets`, `GET /2/tweets/:id`) answer PARTIALLY: a request for five ids where two are
|
|
95
|
+
* missing has to return the three that exist AND say what happened to the other two, so the
|
|
96
|
+
* missing ones ride in a sibling `errors` array under HTTP 200 rather than turning the whole
|
|
97
|
+
* request into a 404. The bulk route FORCES that model — there is no other way to express a
|
|
98
|
+
* partial result — and X applies the same envelope to the single-id route. What matters for this
|
|
99
|
+
* twin, and what its verifies assert, is the half that is not a status: a `data` entry is NEVER
|
|
100
|
+
* fabricated for an id nobody created; the id comes back inside `errors`, by name.
|
|
101
|
+
*
|
|
102
|
+
* The exact `detail`/`title` WORDING is unpinned by this build, same boundary as every other
|
|
103
|
+
* envelope here (`x.errors.resource_not_found_wording`).
|
|
104
|
+
*/
|
|
105
|
+
export function lookupResult(data: unknown, errors: Array<Record<string, unknown>>, includes?: Record<string, unknown>): XResponse {
|
|
106
|
+
const body: Record<string, unknown> = {};
|
|
107
|
+
if (data !== undefined) body.data = data;
|
|
108
|
+
if (includes !== undefined && Object.keys(includes).length > 0) body.includes = includes;
|
|
109
|
+
if (errors.length > 0) body.errors = errors;
|
|
110
|
+
return { status: 200, body, headers: { ...NOSTORE, 'content-type': 'application/json' } };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The invalid-parameter envelope (OpenAPI InvalidRequestProblem + the wire's `errors` array). */
|
|
114
|
+
export function invalidRequestProblem(parameters: Record<string, string[]>, message: string): XResponse {
|
|
115
|
+
return {
|
|
116
|
+
status: 400,
|
|
117
|
+
body: {
|
|
118
|
+
errors: [{ parameters, message }],
|
|
119
|
+
title: 'Invalid Request',
|
|
120
|
+
detail: 'One or more parameters to your request was invalid.',
|
|
121
|
+
type: 'https://api.x.com/2/problems/invalid-request',
|
|
122
|
+
},
|
|
123
|
+
headers: { ...NOSTORE, 'content-type': 'application/json' },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** HTTP 429, legacy code 88 — the documented pair (docs.x.com fundamentals/rate-limits). */
|
|
128
|
+
export function rateLimitExceeded(headers: Record<string, string>): XResponse {
|
|
129
|
+
return {
|
|
130
|
+
status: 429,
|
|
131
|
+
body: { errors: [{ code: 88, message: 'Rate limit exceeded' }] },
|
|
132
|
+
headers: { ...NOSTORE, ...headers, 'content-type': 'application/json' },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A write attempted against a read-only twin. Not a vendor shape — the kernel's own refusal. */
|
|
137
|
+
export function readOnlyRefusal(): XResponse {
|
|
138
|
+
return {
|
|
139
|
+
status: 405,
|
|
140
|
+
body: { title: 'Method Not Allowed', type: 'about:blank', status: 405, detail: 'This twin is running read-only; writes are refused.' },
|
|
141
|
+
headers: { ...PROBLEM },
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/x-scopes.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The X OAuth 2.0 scopes this POSTING surface reads, and the endpoint→scope map that gates it.
|
|
2
|
+
//
|
|
3
|
+
// TRANSCRIBED, NOT SHARED — same seam note as x-problems.ts. The catalog itself is
|
|
4
|
+
// `packages/twin/xidentity/src/xidentity-scopes.ts`'s `SCOPE_CATALOG`, taken verbatim from the
|
|
5
|
+
// X API v2 OpenAPI 2.167 `OAuth2UserToken` security scheme (recorded in spec-sources.json's
|
|
6
|
+
// xidentity entry, live-fetched 2026-08-21). Only the scopes THIS surface can require are
|
|
7
|
+
// listed; the other nineteen are xidentity's business, and re-listing them here would create a
|
|
8
|
+
// second catalog to drift.
|
|
9
|
+
//
|
|
10
|
+
// The endpoint→scope pairs are the OpenAPI's own per-operation `security` entries for the three
|
|
11
|
+
// operations this pack models — the same source the catalog came from.
|
|
12
|
+
|
|
13
|
+
/** The subset of X's scope catalog the posting surface can require, with X's own consent wording. */
|
|
14
|
+
export const X_SCOPES: Record<string, string> = {
|
|
15
|
+
'tweet.read': 'View all posts you can see, including those from protected accounts.',
|
|
16
|
+
'tweet.write': 'Create and repost on your behalf.',
|
|
17
|
+
'users.read': 'View any account you can see, including protected accounts.',
|
|
18
|
+
'offline.access': 'Stay connected to your account until you revoke access.',
|
|
19
|
+
'media.write': 'Upload media, such as photos and videos, on your behalf.',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Space-separated scope param → ordered unique scope list (X formats scope space-separated). */
|
|
23
|
+
export function parseScopeList(raw: string | null | undefined): string[] {
|
|
24
|
+
if (!raw) return [];
|
|
25
|
+
const seen = new Set<string>();
|
|
26
|
+
const out: string[] = [];
|
|
27
|
+
for (const s of raw.split(/[\s+]+/)) {
|
|
28
|
+
if (!s || seen.has(s)) continue;
|
|
29
|
+
seen.add(s);
|
|
30
|
+
out.push(s);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The scopes a token must hold for one modelled operation, from the OpenAPI's `security` entry. */
|
|
36
|
+
export const REQUIRED_SCOPES: Record<
|
|
37
|
+
'tweets.create' | 'tweets.reply' | 'tweets.delete' | 'mentions.list' | 'tweets.lookup' | 'timelines.read' | 'search.recent' | 'users.lookup' | 'media.upload',
|
|
38
|
+
readonly string[]
|
|
39
|
+
> = {
|
|
40
|
+
'tweets.create': ['tweet.write', 'users.read'],
|
|
41
|
+
// A reply is the SAME OpenAPI operation as an original post (`POST /2/tweets`), so its scope
|
|
42
|
+
// requirement is identical at the vendor. The split lives one layer up — in the CENSUS, where
|
|
43
|
+
// a grant can name one and not the other. Keeping the two rows here, equal and separate,
|
|
44
|
+
// records that the vendor does not distinguish them and this estate does.
|
|
45
|
+
'tweets.reply': ['tweet.write', 'users.read'],
|
|
46
|
+
'tweets.delete': ['tweet.write', 'users.read'],
|
|
47
|
+
'mentions.list': ['tweet.read', 'users.read'],
|
|
48
|
+
// The READ operations, from the same source: the pinned client's own per-method docblocks name
|
|
49
|
+
// `users.read` + `tweet.read` on the lookup endpoints (dist/cjs/v2/client.v2.read.js, the
|
|
50
|
+
// `singleTweet`/`tweets` comments) and `tweet.read` + `users.read` on the timelines
|
|
51
|
+
// (`homeTimeline`, `userTimeline`). One scope pair for the whole read surface, as X publishes it.
|
|
52
|
+
'tweets.lookup': ['tweet.read', 'users.read'],
|
|
53
|
+
'timelines.read': ['tweet.read', 'users.read'],
|
|
54
|
+
'search.recent': ['tweet.read', 'users.read'],
|
|
55
|
+
// User lookup (by id and by username). The pinned client's `user`/`userByUsername` docblocks
|
|
56
|
+
// name no scope; the adjacent `me()` docblock (client.v2.read.js:234) names this pair, and it is
|
|
57
|
+
// transcribed from there — the one user-lookup scope statement in the grounding source.
|
|
58
|
+
'users.lookup': ['tweet.read', 'users.read'],
|
|
59
|
+
// Media upload (one-shot, initialize/append/finalize, STATUS): docs.x.com's per-operation
|
|
60
|
+
// security entry names `media.write` alone ("Upload media, such as photos and videos, on your
|
|
61
|
+
// behalf"). Attaching the uploaded id to a post is POST /2/tweets and needs that route's pair.
|
|
62
|
+
'media.upload': ['media.write'],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function missingScopes(held: readonly string[], required: readonly string[]): string[] {
|
|
66
|
+
return required.filter((scope) => !held.includes(scope));
|
|
67
|
+
}
|
package/src/x-server.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// X (Twitter) twin HTTP server.
|
|
2
|
+
//
|
|
3
|
+
// FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, built from the
|
|
4
|
+
// kernel's ONE adaptation (`createTwinFetchFromHandler` — manifest door, body read, header map,
|
|
5
|
+
// worldNow() stamp, JSON reply); this file contributes only VALUES — plus the two places X's wire
|
|
6
|
+
// exceeds that adapter's text-in/JSON-out shape, both about media BYTES:
|
|
7
|
+
//
|
|
8
|
+
// - a MULTIPART upload (`POST /2/media/upload`, `…/{id}/append`): the adapter reads bodies as
|
|
9
|
+
// text, which corrupts binary, so the form is parsed here and handed to the same handler as
|
|
10
|
+
// `form` (a JSON upload, base64 in the body, goes through the adapter unchanged);
|
|
11
|
+
// - the media bytes themselves, read from the blob seam by digest, at X's own path shapes:
|
|
12
|
+
// pbs.twimg.com's `/media/<name>.<ext>` for an image and `/ext_tw_video_thumb/<id>/pu/img/…`
|
|
13
|
+
// for a video's preview, video.twimg.com's `/ext_tw_video/<id>/pu/vid/avc1/<w>x<h>/<name>.mp4`
|
|
14
|
+
// for the video file — with HTTP Range (206), so a <video> can seek. Those hosts are public
|
|
15
|
+
// at X (an <img> or <video> carries no bearer), so these routes are too.
|
|
16
|
+
//
|
|
17
|
+
// The server is one line of Bun.serve around that same closure.
|
|
18
|
+
import { readMediaRecord, readXBlob, readXBlobType, videoPosterSvg } from './x-media.ts';
|
|
19
|
+
import { handleXTwinRequest } from './x-twin.ts';
|
|
20
|
+
import { createTwinFetchFromHandler, runWithCorrelationId, serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
|
|
21
|
+
|
|
22
|
+
/** Options every X-twin HTTP surface needs, independent of who owns the socket. */
|
|
23
|
+
export interface XTwinFetchOptions {
|
|
24
|
+
root?: string;
|
|
25
|
+
readOnly?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MULTIPART_UPLOAD_PATH = /^\/2\/media\/upload(?:\/\d{1,19}\/append)?\/?$/;
|
|
29
|
+
const MEDIA_BYTES_PATH = /^\/media\/([0-9a-f]{64})\.(jpg|png|gif|webp)$/;
|
|
30
|
+
const VIDEO_BYTES_PATH = /^\/ext_tw_video\/\d{1,19}\/pu\/vid\/avc1\/\d+x\d+\/([0-9a-f]{64})\.mp4$/;
|
|
31
|
+
const VIDEO_THUMB_PATH = /^\/ext_tw_video_thumb\/(\d{1,19})\/pu\/img\/[0-9a-f]{64}\.svg$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: a range
|
|
35
|
+
* that does not parse — including a first-byte-pos past its last-byte-pos (`bytes=5-2`) — is
|
|
36
|
+
* IGNORED and the whole body is a 200; a well-formed range that starts past the end is a 416. The
|
|
37
|
+
* content type is the one the twin decided at upload (stored beside the bytes), and `nosniff`
|
|
38
|
+
* stops a browser second-guessing it.
|
|
39
|
+
*/
|
|
40
|
+
function bytesResponse(request: Request, bytes: Uint8Array, contentType: string): Response {
|
|
41
|
+
const total = bytes.length;
|
|
42
|
+
const base = { 'content-type': contentType, 'x-content-type-options': 'nosniff', 'accept-ranges': 'bytes', 'cache-control': 'public, max-age=604800, immutable' };
|
|
43
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
|
|
44
|
+
let start = 0;
|
|
45
|
+
let end = total - 1;
|
|
46
|
+
const valid = range !== null && (range[1] !== '' || range[2] !== '') && !(range[1] !== '' && range[2] !== '' && Number(range[1]) > Number(range[2]));
|
|
47
|
+
if (valid) {
|
|
48
|
+
if (range[1] === '') { start = Math.max(0, total - Number(range[2])); } else {
|
|
49
|
+
start = Number(range[1]);
|
|
50
|
+
end = range[2] === '' ? total - 1 : Math.min(Number(range[2]), total - 1);
|
|
51
|
+
}
|
|
52
|
+
if (start >= total || (range[1] === '' && Number(range[2]) === 0)) return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
|
|
53
|
+
}
|
|
54
|
+
const partial = start !== 0 || end !== total - 1;
|
|
55
|
+
const body = bytes.slice(start, end + 1);
|
|
56
|
+
return new Response(request.method === 'HEAD' ? null : new Blob([body]), {
|
|
57
|
+
status: partial ? 206 : 200,
|
|
58
|
+
headers: { ...base, 'content-length': String(body.length), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createXTwinFetch(options: XTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
63
|
+
const adapted = createTwinFetchFromHandler(handleXTwinRequest, {
|
|
64
|
+
...options,
|
|
65
|
+
extras: (request) => ({ publicBase: twinPublicBase(request) }),
|
|
66
|
+
manifest: statefulTwinManifest({ vendor: 'x', twinOf: 'the X (Twitter) API v2 posting surface', stores: 'tweets, replies, deletions, the mentions timeline and uploaded images' }),
|
|
67
|
+
});
|
|
68
|
+
return async (request: Request): Promise<Response> => {
|
|
69
|
+
const url = new URL(request.url);
|
|
70
|
+
const reading = request.method === 'GET' || request.method === 'HEAD';
|
|
71
|
+
const image = reading ? MEDIA_BYTES_PATH.exec(url.pathname) : null;
|
|
72
|
+
const video = reading ? VIDEO_BYTES_PATH.exec(url.pathname) : null;
|
|
73
|
+
if (image || video) {
|
|
74
|
+
const digest = (image ?? video)![1]!;
|
|
75
|
+
const bytes = await readXBlob(digest, options.root);
|
|
76
|
+
const type = await readXBlobType(digest, options.root);
|
|
77
|
+
// bytes with no recorded type were never an upload this twin read: not served
|
|
78
|
+
if (!bytes || !type) return new Response('Not Found', { status: 404 });
|
|
79
|
+
return bytesResponse(request, bytes, type);
|
|
80
|
+
}
|
|
81
|
+
const thumb = reading ? VIDEO_THUMB_PATH.exec(url.pathname) : null;
|
|
82
|
+
if (thumb) {
|
|
83
|
+
const record = await readMediaRecord(thumb[1]!, options.root);
|
|
84
|
+
if (!record?.sha256) return new Response('Not Found', { status: 404 });
|
|
85
|
+
return new Response(request.method === 'HEAD' ? null : videoPosterSvg(record.width ?? 0, record.height ?? 0), { headers: { 'content-type': 'image/svg+xml', 'x-content-type-options': 'nosniff', 'cache-control': 'public, max-age=604800' } });
|
|
86
|
+
}
|
|
87
|
+
const type = request.headers.get('content-type') ?? '';
|
|
88
|
+
if (request.method === 'POST' && MULTIPART_UPLOAD_PATH.test(url.pathname) && /^multipart\/form-data/i.test(type)) {
|
|
89
|
+
let form: FormData;
|
|
90
|
+
try { form = await request.formData(); } catch {
|
|
91
|
+
return Response.json({ errors: [{ parameters: { body: ['(unparseable)'] }, message: 'The multipart body could not be parsed.' }], title: 'Invalid Request', detail: 'One or more parameters to your request was invalid.', type: 'https://api.x.com/2/problems/invalid-request' }, { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
const fields: Record<string, string> = {};
|
|
94
|
+
let media: Uint8Array | undefined;
|
|
95
|
+
for (const [name, value] of form.entries() as Iterable<[string, unknown]>) {
|
|
96
|
+
if (typeof value === 'string') fields[name] = value;
|
|
97
|
+
else if (name === 'media' && value instanceof Blob) media = new Uint8Array(await value.arrayBuffer());
|
|
98
|
+
}
|
|
99
|
+
const headers: Record<string, string> = {};
|
|
100
|
+
request.headers.forEach((value, key) => { headers[key] = value; });
|
|
101
|
+
const invoke = () => handleXTwinRequest({
|
|
102
|
+
method: request.method,
|
|
103
|
+
path: url.pathname + (url.search || ''),
|
|
104
|
+
headers,
|
|
105
|
+
form: { fields, ...(media ? { media } : {}) },
|
|
106
|
+
readOnly: options.readOnly ?? false,
|
|
107
|
+
occurredAt: worldNow(),
|
|
108
|
+
publicBase: twinPublicBase(request),
|
|
109
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
110
|
+
});
|
|
111
|
+
const requestId = request.headers.get('x-twins-request-id') ?? undefined;
|
|
112
|
+
const result = await (requestId ? runWithCorrelationId(requestId, invoke) : invoke());
|
|
113
|
+
return new Response(JSON.stringify(result.body), { status: result.status, headers: { 'content-type': 'application/json', ...result.headers } });
|
|
114
|
+
}
|
|
115
|
+
return adapted(request);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function createXTwinServer(options: { root?: string; port?: number; readOnly?: boolean } = {}): Promise<{ port: number; stop: () => void }> {
|
|
120
|
+
const server = await serveHttp({
|
|
121
|
+
port: options.port ?? 0,
|
|
122
|
+
idleTimeout: 60,
|
|
123
|
+
fetch: createXTwinFetch(options),
|
|
124
|
+
});
|
|
125
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
126
|
+
}
|