@volter/twin-instagram 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 +56 -0
- package/dist/client/instagram-mirror.bundle.js +321 -0
- package/dist/client/instagram-mirror.d.ts +58 -0
- package/dist/client/instagram-mirror.js +257 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +30 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +71 -0
- package/dist/src/instagram-budget.d.ts +44 -0
- package/dist/src/instagram-budget.js +112 -0
- package/dist/src/instagram-capabilities.d.ts +4 -0
- package/dist/src/instagram-capabilities.js +1249 -0
- package/dist/src/instagram-conformance.d.ts +8 -0
- package/dist/src/instagram-conformance.js +44 -0
- package/dist/src/instagram-connector.d.ts +79 -0
- package/dist/src/instagram-connector.js +437 -0
- package/dist/src/instagram-errors.d.ts +33 -0
- package/dist/src/instagram-errors.js +73 -0
- package/dist/src/instagram-media.d.ts +134 -0
- package/dist/src/instagram-media.js +388 -0
- package/dist/src/instagram-mirror-ui.d.ts +57 -0
- package/dist/src/instagram-mirror-ui.js +158 -0
- package/dist/src/instagram-server.d.ts +14 -0
- package/dist/src/instagram-server.js +146 -0
- package/dist/src/instagram-twin.d.ts +52 -0
- package/dist/src/instagram-twin.js +884 -0
- package/package.json +57 -0
- package/src/cli.ts +28 -0
- package/src/index.ts +117 -0
- package/src/instagram-budget.ts +130 -0
- package/src/instagram-capabilities.ts +1191 -0
- package/src/instagram-conformance.ts +58 -0
- package/src/instagram-connector.ts +400 -0
- package/src/instagram-errors.ts +89 -0
- package/src/instagram-media.ts +403 -0
- package/src/instagram-mirror-ui.ts +173 -0
- package/src/instagram-server.ts +146 -0
- package/src/instagram-twin.ts +859 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Instagram 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 Instagram's
|
|
6
|
+
// wire exceeds that adapter's text-in/JSON-out shape, both about media BYTES:
|
|
7
|
+
//
|
|
8
|
+
// - the rupload POSTs (rupload.facebook.com/ig-api-upload/…) carry raw video bytes, which the
|
|
9
|
+
// adapter's `request.text()` would corrupt, so they are read here and handed to the same handler
|
|
10
|
+
// as `bytes`;
|
|
11
|
+
// - the bytes themselves, read from the blob seam by digest at the CDN's path shapes — a Reel's
|
|
12
|
+
// `media_url` (`/o1/v/t16/f2/m86/<digest>.mp4`), its stand-in thumbnail
|
|
13
|
+
// (`/v/t51.71878-15/<digest>_poster.svg`) and a profile picture (`/v/t51.2885-19/<digest>.jpg|png`)
|
|
14
|
+
// — with HTTP Range (206/416), so a <video> can seek, through the kernel's ranged reads. Those URLs
|
|
15
|
+
// are public at Instagram's CDN (a page's <video> carries no token), so these routes are too, and
|
|
16
|
+
// they serve only a digest a held media or profile names.
|
|
17
|
+
import { handleInstagramTwinRequest } from './instagram-twin.ts';
|
|
18
|
+
import { blobKey, reelPosterSvg } from './instagram-media.ts';
|
|
19
|
+
import { createTwinFetchFromHandler, ownFields, projectResources, readResourceBlobRange, resourceBlobSize, runWithCorrelationId, serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
|
|
20
|
+
|
|
21
|
+
const SERVICE = 'instagram';
|
|
22
|
+
|
|
23
|
+
export interface InstagramTwinFetchOptions {
|
|
24
|
+
root?: string;
|
|
25
|
+
readOnly?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const RUPLOAD_PATH = /^\/ig-api-upload\//;
|
|
29
|
+
const REEL_BYTES = /^\/o1\/v\/t16\/f2\/m86\/([0-9a-f]{64})\.mp4$/;
|
|
30
|
+
const REEL_POSTER = /^\/v\/t51\.71878-15\/([0-9a-f]{64})_poster\.svg$/;
|
|
31
|
+
const PROFILE_PICTURE = /^\/v\/t51\.2885-19\/([0-9a-f]{64})\.(jpg|png)$/;
|
|
32
|
+
|
|
33
|
+
/** The most one read of the blob seam returns: an open-ended `bytes=N-` is answered with at most
|
|
34
|
+
* this much (a player asks again from where it stands), and any longer answer — a whole file, a long
|
|
35
|
+
* closed or suffix range — is streamed in reads of this size, so no request holds a large file. */
|
|
36
|
+
export const RANGE_CAP = 8 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
async function rangeBody(key: string, start: number, end: number, root?: string): Promise<Blob | ReadableStream<Uint8Array> | null> {
|
|
39
|
+
if (end - start + 1 <= RANGE_CAP) {
|
|
40
|
+
const bytes = await readResourceBlobRange(SERVICE, key, start, end, root);
|
|
41
|
+
return bytes === null ? null : new Blob([new Uint8Array(bytes)]);
|
|
42
|
+
}
|
|
43
|
+
let at = start;
|
|
44
|
+
return new ReadableStream<Uint8Array>({
|
|
45
|
+
async pull(controller) {
|
|
46
|
+
if (at > end) { controller.close(); return; }
|
|
47
|
+
const chunk = await readResourceBlobRange(SERVICE, key, at, Math.min(end, at + RANGE_CAP - 1), root);
|
|
48
|
+
if (chunk === null || chunk.length === 0) { controller.error(new Error('the stored bytes went away mid-read')); return; }
|
|
49
|
+
at += chunk.length;
|
|
50
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A stored digest as a response, honouring a single `Range: bytes=` (206 / 416) as a CDN does. */
|
|
56
|
+
async function bytesResponse(request: Request, sha256: string, contentType: string, root?: string): Promise<Response> {
|
|
57
|
+
const key = blobKey(sha256);
|
|
58
|
+
const total = await resourceBlobSize(SERVICE, key, root);
|
|
59
|
+
if (total === null) return notFound();
|
|
60
|
+
const base = { 'content-type': contentType, 'accept-ranges': 'bytes', 'cache-control': 'public, max-age=604800, immutable' };
|
|
61
|
+
let start = 0;
|
|
62
|
+
let end = total - 1;
|
|
63
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
|
|
64
|
+
if (range && (range[1] !== '' || range[2] !== '')) {
|
|
65
|
+
if (range[1] === '') { start = Math.max(0, total - Number(range[2])); } else {
|
|
66
|
+
start = Number(range[1]);
|
|
67
|
+
end = range[2] === '' ? Math.min(total - 1, start + RANGE_CAP - 1) : Math.min(Number(range[2]), total - 1);
|
|
68
|
+
}
|
|
69
|
+
if (start >= total || start > end) return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
|
|
70
|
+
}
|
|
71
|
+
const partial = start !== 0 || end !== total - 1;
|
|
72
|
+
const headers = { ...base, 'content-length': String(end - start + 1), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) };
|
|
73
|
+
if (request.method === 'HEAD') return new Response(null, { status: partial ? 206 : 200, headers });
|
|
74
|
+
const body = await rangeBody(key, start, end, root);
|
|
75
|
+
if (body === null) return notFound();
|
|
76
|
+
return new Response(body, { status: partial ? 206 : 200, headers });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const notFound = (): Response => new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
80
|
+
|
|
81
|
+
type Row = Record<string, any>;
|
|
82
|
+
/** The live media whose Reel is this digest, if any. */
|
|
83
|
+
function mediaWithVideo(sha256: string, root?: string): Row | undefined {
|
|
84
|
+
return (projectResources(SERVICE, root) as unknown as Row[]).map((r) => ({ r, f: ownFields(r as never) as Row }))
|
|
85
|
+
.find(({ r, f }) => r.type === 'media' && f._deleted !== true && f.deleted !== true && f._video?.sha256 === sha256)?.f;
|
|
86
|
+
}
|
|
87
|
+
function userWithPicture(sha256: string, root?: string): Row | undefined {
|
|
88
|
+
return (projectResources(SERVICE, root) as unknown as Row[]).map((r) => ({ r, f: ownFields(r as never) as Row }))
|
|
89
|
+
.find(({ r, f }) => r.type === 'ig_user' && f._profile_picture?.sha256 === sha256)?.f;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createInstagramTwinFetch(options: InstagramTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
93
|
+
const adapted = createTwinFetchFromHandler(handleInstagramTwinRequest, {
|
|
94
|
+
...options,
|
|
95
|
+
extras: (request) => ({ publicBase: twinPublicBase(request) }),
|
|
96
|
+
manifest: statefulTwinManifest({ vendor: 'instagram', twinOf: 'the Instagram Platform content publishing API (Reels, resumable upload)', stores: 'professional accounts, published Reels, and staged media containers' }),
|
|
97
|
+
});
|
|
98
|
+
const serve = async (request: Request): Promise<Response> => {
|
|
99
|
+
const url = new URL(request.url);
|
|
100
|
+
const reading = request.method === 'GET' || request.method === 'HEAD';
|
|
101
|
+
const reel = reading ? REEL_BYTES.exec(url.pathname) : null;
|
|
102
|
+
if (reel) return mediaWithVideo(reel[1]!, options.root) ? bytesResponse(request, reel[1]!, 'video/mp4', options.root) : notFound();
|
|
103
|
+
const poster = reading ? REEL_POSTER.exec(url.pathname) : null;
|
|
104
|
+
if (poster) {
|
|
105
|
+
const media = mediaWithVideo(poster[1]!, options.root);
|
|
106
|
+
if (!media) return notFound();
|
|
107
|
+
return new Response(request.method === 'HEAD' ? null : reelPosterSvg(Number(media._video.width ?? 0), Number(media._video.height ?? 0)), { headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=604800' } });
|
|
108
|
+
}
|
|
109
|
+
const picture = reading ? PROFILE_PICTURE.exec(url.pathname) : null;
|
|
110
|
+
if (picture) {
|
|
111
|
+
const user = userWithPicture(picture[1]!, options.root);
|
|
112
|
+
return user ? bytesResponse(request, picture[1]!, String(user._profile_picture.media_type), options.root) : notFound();
|
|
113
|
+
}
|
|
114
|
+
if ((request.method === 'POST' || request.method === 'GET') && RUPLOAD_PATH.test(url.pathname)) {
|
|
115
|
+
const bytes = request.method === 'POST' ? new Uint8Array(await request.arrayBuffer()) : undefined;
|
|
116
|
+
const headers: Record<string, string> = {};
|
|
117
|
+
request.headers.forEach((value, key) => { headers[key] = value; });
|
|
118
|
+
const invoke = () => handleInstagramTwinRequest({
|
|
119
|
+
method: request.method,
|
|
120
|
+
path: url.pathname + (url.search || ''),
|
|
121
|
+
headers,
|
|
122
|
+
...(bytes ? { bytes } : {}),
|
|
123
|
+
readOnly: options.readOnly ?? false,
|
|
124
|
+
occurredAt: worldNow(),
|
|
125
|
+
publicBase: twinPublicBase(request),
|
|
126
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
127
|
+
});
|
|
128
|
+
const requestId = request.headers.get('x-twins-request-id') ?? undefined;
|
|
129
|
+
const result = await (requestId ? runWithCorrelationId(requestId, invoke) : invoke());
|
|
130
|
+
return new Response(JSON.stringify(result.body), { status: result.status, headers: { 'content-type': 'application/json; charset=UTF-8', ...result.headers } });
|
|
131
|
+
}
|
|
132
|
+
return adapted(request);
|
|
133
|
+
};
|
|
134
|
+
// every answer carries the World's instant as its HTTP Date (an HTTP server's own header), so a
|
|
135
|
+
// client — the mirror's relative times — reads the World's now rather than its own clock
|
|
136
|
+
return async (request: Request): Promise<Response> => {
|
|
137
|
+
const res = await serve(request);
|
|
138
|
+
const headers = new Headers(res.headers);
|
|
139
|
+
headers.set('date', new Date(worldNow()).toUTCString());
|
|
140
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function createInstagramTwinServer(options: { root?: string; port?: number; readOnly?: boolean } = {}) {
|
|
145
|
+
return serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: createInstagramTwinFetch(options) });
|
|
146
|
+
}
|