@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,158 @@
|
|
|
1
|
+
// INSTAGRAM MIRROR UI — instagram.com's own view of the twin's state: a professional account's
|
|
2
|
+
// profile (its avatar, username, name, bio, and the posts / followers / following counts it holds),
|
|
3
|
+
// its Reels grid (9:16 tiles with a play glyph), and a Reel viewer (a full-height vertical player with
|
|
4
|
+
// the caption and username over it). A React/TSX app bundled by Bun that renders by consuming the
|
|
5
|
+
// twin's OWN Graph API on the same origin (GET /v26.0/me and GET /v26.0/{ig-user-id}/media) — the same
|
|
6
|
+
// routes any Instagram Platform client uses — so every screen is data-coupled to real twin state.
|
|
7
|
+
// Archetype A (passthrough), transcribed from the X and LinkedIn mirrors: one serving code path, so
|
|
8
|
+
// API↔UI parity cannot drift.
|
|
9
|
+
//
|
|
10
|
+
// SIGN-IN. instagram.com's login (username, then password) with the password replaced by the access
|
|
11
|
+
// token the twin issued through /_twin/tokens: the mirror is a client like any other and presents a
|
|
12
|
+
// registered token rather than bypassing the auth gate. The username is checked against the token's
|
|
13
|
+
// own account (`/me?fields=username`). Both are kept in the TAB'S sessionStorage.
|
|
14
|
+
//
|
|
15
|
+
// ROUTES are `#/…` hash routes, reached by LINKS (`<a href="#/…">`) — never by assigning location — so
|
|
16
|
+
// under a World's <base> the served shell's own link handler keeps them on the shell
|
|
17
|
+
// (world-core's mirror-shell.ts).
|
|
18
|
+
//
|
|
19
|
+
// PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
|
|
20
|
+
// fetch adapter as its API backend and reads every byte of state back over the wire.
|
|
21
|
+
import { readFile } from 'node:fs/promises';
|
|
22
|
+
import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
|
|
23
|
+
import { createInstagramTwinFetch } from "./instagram-server.js";
|
|
24
|
+
const CLIENT_ENTRY = () => new URL('../client/instagram-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
|
|
25
|
+
const CLIENT_CSS = () => new URL('../client/instagram-mirror.css', import.meta.url).pathname;
|
|
26
|
+
/** The version the mirror's reads pin — the twin's newest. */
|
|
27
|
+
export const MIRROR_VERSION = 'v26.0';
|
|
28
|
+
/** The account fields the profile header reads. */
|
|
29
|
+
export const PROFILE_FIELDS = 'id,username,name,biography,website,followers_count,follows_count,media_count,profile_picture_url';
|
|
30
|
+
/** The media fields the grid and the viewer read. */
|
|
31
|
+
export const REEL_FIELDS = 'id,media_type,media_product_type,media_url,thumbnail_url,permalink,caption,timestamp,shortcode,username,like_count,comments_count,is_shared_to_feed';
|
|
32
|
+
/** instagram.com's count: "1,234" below ten thousand, then "12.5K", "1.2M" (one decimal, trailing .0 dropped). */
|
|
33
|
+
export function compactCount(n) {
|
|
34
|
+
const v = typeof n === 'number' && Number.isFinite(n) ? n : 0;
|
|
35
|
+
const one = (x, unit) => `${(Math.floor(x * 10) / 10).toFixed(1).replace(/\.0$/, '')}${unit}`;
|
|
36
|
+
if (v >= 1_000_000)
|
|
37
|
+
return one(v / 1_000_000, 'M');
|
|
38
|
+
if (v >= 10_000)
|
|
39
|
+
return one(v / 1_000, 'K');
|
|
40
|
+
return v.toLocaleString('en-US');
|
|
41
|
+
}
|
|
42
|
+
/** instagram.com's age of a post: "now", "5m", "3h", "2d", "3w", then the date ("March 4" / "March 4, 2025"). */
|
|
43
|
+
export function timeAgo(timestamp, nowMs) {
|
|
44
|
+
const ms = Date.parse(String(timestamp ?? '').replace(/([+-]\d\d)(\d\d)$/, '$1:$2'));
|
|
45
|
+
if (!Number.isFinite(ms))
|
|
46
|
+
return '';
|
|
47
|
+
const s = Math.max(0, Math.floor((nowMs - ms) / 1000));
|
|
48
|
+
if (s < 60)
|
|
49
|
+
return 'now';
|
|
50
|
+
if (s < 3600)
|
|
51
|
+
return `${Math.floor(s / 60)}m`;
|
|
52
|
+
if (s < 86_400)
|
|
53
|
+
return `${Math.floor(s / 3600)}h`;
|
|
54
|
+
if (s < 7 * 86_400)
|
|
55
|
+
return `${Math.floor(s / 86_400)}d`;
|
|
56
|
+
if (s < 30 * 86_400)
|
|
57
|
+
return `${Math.floor(s / (7 * 86_400))}w`;
|
|
58
|
+
const d = new Date(ms);
|
|
59
|
+
const month = d.toLocaleString('en-US', { month: 'long', timeZone: 'UTC' });
|
|
60
|
+
return d.getUTCFullYear() === new Date(nowMs).getUTCFullYear() ? `${month} ${d.getUTCDate()}` : `${month} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
|
|
61
|
+
}
|
|
62
|
+
/** A caption as instagram.com draws it: `#tag` and `@user` as links, everything else text. */
|
|
63
|
+
export function captionSegments(caption) {
|
|
64
|
+
const source = typeof caption === 'string' ? caption : '';
|
|
65
|
+
const out = [];
|
|
66
|
+
let last = 0;
|
|
67
|
+
for (const m of source.matchAll(/(^|[^\w&])([#@])([\p{L}\p{N}_.]*[\p{L}\p{N}_])/gu)) {
|
|
68
|
+
const at = (m.index ?? 0) + m[1].length;
|
|
69
|
+
if (at > last)
|
|
70
|
+
out.push({ kind: 'text', value: source.slice(last, at) });
|
|
71
|
+
out.push({ kind: m[2] === '#' ? 'hashtag' : 'mention', value: `${m[2]}${m[3]}` });
|
|
72
|
+
last = at + 1 + m[3].length;
|
|
73
|
+
}
|
|
74
|
+
if (last < source.length)
|
|
75
|
+
out.push({ kind: 'text', value: source.slice(last) });
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
export function profileFromRead(me) {
|
|
79
|
+
return {
|
|
80
|
+
id: String(me.id), username: String(me.username),
|
|
81
|
+
...(typeof me.name === 'string' && me.name !== '' ? { name: me.name } : {}),
|
|
82
|
+
...(typeof me.biography === 'string' && me.biography !== '' ? { biography: me.biography } : {}),
|
|
83
|
+
...(typeof me.website === 'string' && me.website !== '' ? { website: me.website } : {}),
|
|
84
|
+
...(typeof me.profile_picture_url === 'string' ? { avatarUrl: me.profile_picture_url } : {}),
|
|
85
|
+
...(typeof me.media_count === 'number' ? { posts: me.media_count } : {}),
|
|
86
|
+
...(typeof me.followers_count === 'number' ? { followers: me.followers_count } : {}),
|
|
87
|
+
...(typeof me.follows_count === 'number' ? { following: me.follows_count } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Whether a media is a Reel (the Content Publishing guide: request media_product_type, since a
|
|
91
|
+
* published Reel's media_type reads VIDEO). */
|
|
92
|
+
export function isReel(m) {
|
|
93
|
+
return m.media_product_type === 'REELS';
|
|
94
|
+
}
|
|
95
|
+
/** What a profile's tab shows: the Reels tab every Reel; the Posts tab only those shared to the feed
|
|
96
|
+
* (IG Media: `is_shared_to_feed` false "indicates the reel can only appear in the Reels tab"). */
|
|
97
|
+
export function tabReels(reels, tab) {
|
|
98
|
+
return tab === 'reels' ? reels : reels.filter((r) => r.is_shared_to_feed !== false);
|
|
99
|
+
}
|
|
100
|
+
/** The mirror's own route for a Reel: instagram.com's `/reel/<shortcode>/`. */
|
|
101
|
+
export function reelRoute(shortcode) {
|
|
102
|
+
return `#/reel/${encodeURIComponent(shortcode)}/`;
|
|
103
|
+
}
|
|
104
|
+
/** The read the grid and the viewer draw: the account's media, newest first. */
|
|
105
|
+
export function mediaQuery(userId, after) {
|
|
106
|
+
return `/${MIRROR_VERSION}/${userId}/media?fields=${REEL_FIELDS}&limit=24${after ? `&after=${encodeURIComponent(after)}` : ''}`;
|
|
107
|
+
}
|
|
108
|
+
const APP_SHELL = `<!doctype html>
|
|
109
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
110
|
+
<base href="/"><title>Instagram</title><link rel="stylesheet" href="assets/styles.css"></head>
|
|
111
|
+
<body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
|
|
112
|
+
let clientBundle = null;
|
|
113
|
+
/** Build the React/TSX mirror client to browser JS; memoized at module scope, so a pack on its own
|
|
114
|
+
* does exactly one `Bun.build`. */
|
|
115
|
+
export function buildInstagramMirrorClient() {
|
|
116
|
+
if (!clientBundle) {
|
|
117
|
+
clientBundle = bundleClient(CLIENT_ENTRY()).catch((error) => { clientBundle = null; throw error; });
|
|
118
|
+
}
|
|
119
|
+
return clientBundle;
|
|
120
|
+
}
|
|
121
|
+
/** Serve the Instagram mirror UI (React app) + its backing Graph API on one origin. */
|
|
122
|
+
export async function createInstagramMirrorServer(options) {
|
|
123
|
+
const twin = createInstagramTwinFetch(options);
|
|
124
|
+
const server = await serveHttp({
|
|
125
|
+
hostname: '127.0.0.1',
|
|
126
|
+
port: options.port ?? 0,
|
|
127
|
+
idleTimeout: 60,
|
|
128
|
+
async fetch(request) {
|
|
129
|
+
const url = new URL(request.url);
|
|
130
|
+
if (request.method === 'GET' && url.pathname === '/assets/app.js') {
|
|
131
|
+
try {
|
|
132
|
+
return new Response(await buildInstagramMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return new Response(String(error), { status: 500 });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
|
|
139
|
+
return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
140
|
+
}
|
|
141
|
+
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
|
|
142
|
+
return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
143
|
+
}
|
|
144
|
+
// Everything else -> the twin's OWN FETCH ADAPTER, the same closure createInstagramTwinServer serves.
|
|
145
|
+
return twin(request);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const port = server.port ?? options.port ?? 0;
|
|
149
|
+
return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
|
|
150
|
+
}
|
|
151
|
+
/** The app-shell HTML (pure). */
|
|
152
|
+
export function instagramMirrorHtml() {
|
|
153
|
+
return APP_SHELL;
|
|
154
|
+
}
|
|
155
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
156
|
+
export function instagramMirrorStyles() {
|
|
157
|
+
return readFile(CLIENT_CSS(), 'utf8');
|
|
158
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface InstagramTwinFetchOptions {
|
|
2
|
+
root?: string;
|
|
3
|
+
readOnly?: boolean;
|
|
4
|
+
}
|
|
5
|
+
/** The most one read of the blob seam returns: an open-ended `bytes=N-` is answered with at most
|
|
6
|
+
* this much (a player asks again from where it stands), and any longer answer — a whole file, a long
|
|
7
|
+
* closed or suffix range — is streamed in reads of this size, so no request holds a large file. */
|
|
8
|
+
export declare const RANGE_CAP: number;
|
|
9
|
+
export declare function createInstagramTwinFetch(options?: InstagramTwinFetchOptions): (request: Request) => Promise<Response>;
|
|
10
|
+
export declare function createInstagramTwinServer(options?: {
|
|
11
|
+
root?: string;
|
|
12
|
+
port?: number;
|
|
13
|
+
readOnly?: boolean;
|
|
14
|
+
}): Promise<import("@volter/world-core").HttpServer>;
|
|
@@ -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.js";
|
|
18
|
+
import { blobKey, reelPosterSvg } from "./instagram-media.js";
|
|
19
|
+
import { createTwinFetchFromHandler, ownFields, projectResources, readResourceBlobRange, resourceBlobSize, runWithCorrelationId, serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
|
|
20
|
+
const SERVICE = 'instagram';
|
|
21
|
+
const RUPLOAD_PATH = /^\/ig-api-upload\//;
|
|
22
|
+
const REEL_BYTES = /^\/o1\/v\/t16\/f2\/m86\/([0-9a-f]{64})\.mp4$/;
|
|
23
|
+
const REEL_POSTER = /^\/v\/t51\.71878-15\/([0-9a-f]{64})_poster\.svg$/;
|
|
24
|
+
const PROFILE_PICTURE = /^\/v\/t51\.2885-19\/([0-9a-f]{64})\.(jpg|png)$/;
|
|
25
|
+
/** The most one read of the blob seam returns: an open-ended `bytes=N-` is answered with at most
|
|
26
|
+
* this much (a player asks again from where it stands), and any longer answer — a whole file, a long
|
|
27
|
+
* closed or suffix range — is streamed in reads of this size, so no request holds a large file. */
|
|
28
|
+
export const RANGE_CAP = 8 * 1024 * 1024;
|
|
29
|
+
async function rangeBody(key, start, end, root) {
|
|
30
|
+
if (end - start + 1 <= RANGE_CAP) {
|
|
31
|
+
const bytes = await readResourceBlobRange(SERVICE, key, start, end, root);
|
|
32
|
+
return bytes === null ? null : new Blob([new Uint8Array(bytes)]);
|
|
33
|
+
}
|
|
34
|
+
let at = start;
|
|
35
|
+
return new ReadableStream({
|
|
36
|
+
async pull(controller) {
|
|
37
|
+
if (at > end) {
|
|
38
|
+
controller.close();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const chunk = await readResourceBlobRange(SERVICE, key, at, Math.min(end, at + RANGE_CAP - 1), root);
|
|
42
|
+
if (chunk === null || chunk.length === 0) {
|
|
43
|
+
controller.error(new Error('the stored bytes went away mid-read'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
at += chunk.length;
|
|
47
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** A stored digest as a response, honouring a single `Range: bytes=` (206 / 416) as a CDN does. */
|
|
52
|
+
async function bytesResponse(request, sha256, contentType, root) {
|
|
53
|
+
const key = blobKey(sha256);
|
|
54
|
+
const total = await resourceBlobSize(SERVICE, key, root);
|
|
55
|
+
if (total === null)
|
|
56
|
+
return notFound();
|
|
57
|
+
const base = { 'content-type': contentType, 'accept-ranges': 'bytes', 'cache-control': 'public, max-age=604800, immutable' };
|
|
58
|
+
let start = 0;
|
|
59
|
+
let end = total - 1;
|
|
60
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
|
|
61
|
+
if (range && (range[1] !== '' || range[2] !== '')) {
|
|
62
|
+
if (range[1] === '') {
|
|
63
|
+
start = Math.max(0, total - Number(range[2]));
|
|
64
|
+
}
|
|
65
|
+
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)
|
|
70
|
+
return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
|
|
71
|
+
}
|
|
72
|
+
const partial = start !== 0 || end !== total - 1;
|
|
73
|
+
const headers = { ...base, 'content-length': String(end - start + 1), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) };
|
|
74
|
+
if (request.method === 'HEAD')
|
|
75
|
+
return new Response(null, { status: partial ? 206 : 200, headers });
|
|
76
|
+
const body = await rangeBody(key, start, end, root);
|
|
77
|
+
if (body === null)
|
|
78
|
+
return notFound();
|
|
79
|
+
return new Response(body, { status: partial ? 206 : 200, headers });
|
|
80
|
+
}
|
|
81
|
+
const notFound = () => new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
82
|
+
/** The live media whose Reel is this digest, if any. */
|
|
83
|
+
function mediaWithVideo(sha256, root) {
|
|
84
|
+
return projectResources(SERVICE, root).map((r) => ({ r, f: ownFields(r) }))
|
|
85
|
+
.find(({ r, f }) => r.type === 'media' && f._deleted !== true && f.deleted !== true && f._video?.sha256 === sha256)?.f;
|
|
86
|
+
}
|
|
87
|
+
function userWithPicture(sha256, root) {
|
|
88
|
+
return projectResources(SERVICE, root).map((r) => ({ r, f: ownFields(r) }))
|
|
89
|
+
.find(({ r, f }) => r.type === 'ig_user' && f._profile_picture?.sha256 === sha256)?.f;
|
|
90
|
+
}
|
|
91
|
+
export function createInstagramTwinFetch(options = {}) {
|
|
92
|
+
const adapted = createTwinFetchFromHandler(handleInstagramTwinRequest, {
|
|
93
|
+
...options,
|
|
94
|
+
extras: (request) => ({ publicBase: twinPublicBase(request) }),
|
|
95
|
+
manifest: statefulTwinManifest({ vendor: 'instagram', twinOf: 'the Instagram Platform content publishing API (Reels, resumable upload)', stores: 'professional accounts, published Reels, and staged media containers' }),
|
|
96
|
+
});
|
|
97
|
+
const serve = async (request) => {
|
|
98
|
+
const url = new URL(request.url);
|
|
99
|
+
const reading = request.method === 'GET' || request.method === 'HEAD';
|
|
100
|
+
const reel = reading ? REEL_BYTES.exec(url.pathname) : null;
|
|
101
|
+
if (reel)
|
|
102
|
+
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)
|
|
107
|
+
return notFound();
|
|
108
|
+
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' } });
|
|
109
|
+
}
|
|
110
|
+
const picture = reading ? PROFILE_PICTURE.exec(url.pathname) : null;
|
|
111
|
+
if (picture) {
|
|
112
|
+
const user = userWithPicture(picture[1], options.root);
|
|
113
|
+
return user ? bytesResponse(request, picture[1], String(user._profile_picture.media_type), options.root) : notFound();
|
|
114
|
+
}
|
|
115
|
+
if ((request.method === 'POST' || request.method === 'GET') && RUPLOAD_PATH.test(url.pathname)) {
|
|
116
|
+
const bytes = request.method === 'POST' ? new Uint8Array(await request.arrayBuffer()) : undefined;
|
|
117
|
+
const headers = {};
|
|
118
|
+
request.headers.forEach((value, key) => { headers[key] = value; });
|
|
119
|
+
const invoke = () => handleInstagramTwinRequest({
|
|
120
|
+
method: request.method,
|
|
121
|
+
path: url.pathname + (url.search || ''),
|
|
122
|
+
headers,
|
|
123
|
+
...(bytes ? { bytes } : {}),
|
|
124
|
+
readOnly: options.readOnly ?? false,
|
|
125
|
+
occurredAt: worldNow(),
|
|
126
|
+
publicBase: twinPublicBase(request),
|
|
127
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
128
|
+
});
|
|
129
|
+
const requestId = request.headers.get('x-twins-request-id') ?? undefined;
|
|
130
|
+
const result = await (requestId ? runWithCorrelationId(requestId, invoke) : invoke());
|
|
131
|
+
return new Response(JSON.stringify(result.body), { status: result.status, headers: { 'content-type': 'application/json; charset=UTF-8', ...result.headers } });
|
|
132
|
+
}
|
|
133
|
+
return adapted(request);
|
|
134
|
+
};
|
|
135
|
+
// every answer carries the World's instant as its HTTP Date (an HTTP server's own header), so a
|
|
136
|
+
// client — the mirror's relative times — reads the World's now rather than its own clock
|
|
137
|
+
return async (request) => {
|
|
138
|
+
const res = await serve(request);
|
|
139
|
+
const headers = new Headers(res.headers);
|
|
140
|
+
headers.set('date', new Date(worldNow()).toUTCString());
|
|
141
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export async function createInstagramTwinServer(options = {}) {
|
|
145
|
+
return serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: createInstagramTwinFetch(options) });
|
|
146
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type InstagramResponse } from './instagram-errors.js';
|
|
2
|
+
export type InstagramRequest = {
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
body?: string;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
readOnly?: boolean;
|
|
8
|
+
occurredAt?: string;
|
|
9
|
+
root?: string;
|
|
10
|
+
/** The raw bytes of a rupload POST (instagram-server.ts reads them; the JSON adapter would corrupt binary). */
|
|
11
|
+
bytes?: Uint8Array;
|
|
12
|
+
/** Where this twin is reached (`twinPublicBase`). The rupload uri and media URLs are minted from it;
|
|
13
|
+
* a direct handler call with none mints Meta's own hosts. */
|
|
14
|
+
publicBase?: string;
|
|
15
|
+
};
|
|
16
|
+
type Resource = Record<string, any>;
|
|
17
|
+
/** The newest Graph API version (the references' "The latest version is: v26.0"). */
|
|
18
|
+
export declare const LATEST_VERSION = "v26.0";
|
|
19
|
+
/** The Graph API changelog's version table (read 2026-09-27): each version's release and expiry. A
|
|
20
|
+
* version is usable until its expiry, judged at the request's (World) instant — its release date is
|
|
21
|
+
* not held against a World whose clock is set back (an app's code names today's versions). */
|
|
22
|
+
export declare const GRAPH_VERSIONS: ReadonlyArray<{
|
|
23
|
+
major: number;
|
|
24
|
+
released: string;
|
|
25
|
+
expires: string | null;
|
|
26
|
+
}>;
|
|
27
|
+
/** The versions usable at `nowMs`, oldest first. */
|
|
28
|
+
export declare function usableVersions(nowMs: number): number[];
|
|
29
|
+
/** Content Publishing guide: "100 API-published posts within a 24-hour moving period"; the IG User
|
|
30
|
+
* Media Publish and Content Publishing Limit references: 50, "quota_total … (currently 50)". The twin
|
|
31
|
+
* serves and enforces the references' 50 — the stricter figure, and the one the endpoint reports. */
|
|
32
|
+
export declare const PUBLISH_QUOTA_TOTAL = 50;
|
|
33
|
+
export declare const PUBLISH_QUOTA_DURATION_S = 86400;
|
|
34
|
+
/** IG User Media: "An Instagram account can only create 400 containers within a rolling 24 hour period". */
|
|
35
|
+
export declare const CONTAINER_QUOTA = 400;
|
|
36
|
+
/** IG User Media, caption: "Maximum 2200 characters, 30 hashtags, and 20 @ tags." */
|
|
37
|
+
export declare const MAX_CAPTION_CHARS = 2200;
|
|
38
|
+
export declare const MAX_HASHTAGS = 30;
|
|
39
|
+
export declare const MAX_MENTIONS = 20;
|
|
40
|
+
/** Meta's timestamp format: `2019-09-26T22:36:43+0000`. */
|
|
41
|
+
export declare function igTimestamp(iso: string): string;
|
|
42
|
+
/** The SHA-256 a token is held under — never the token itself. */
|
|
43
|
+
export declare function tokenDigest(token: string): string;
|
|
44
|
+
/** instagram.com's 11-character shortcode for a media id (a digest: stable for the id). */
|
|
45
|
+
export declare function shortcodeFor(mediaId: string): string;
|
|
46
|
+
/** Where a held Reel's bytes are served: instagram's CDN path shape under the twin's base. */
|
|
47
|
+
export declare function reelVideoUrl(req: Pick<InstagramRequest, 'publicBase'>, sha256: string): string;
|
|
48
|
+
/** A held media as the IG Media node answers it: its vendor fields, the URLs of the bytes the twin
|
|
49
|
+
* holds, no `_` bookkeeping. */
|
|
50
|
+
export declare function projectMedia(req: Pick<InstagramRequest, 'publicBase'>, all: Resource[], media: Resource): Record<string, unknown>;
|
|
51
|
+
export declare function handleInstagramTwinRequest(req: InstagramRequest): Promise<InstagramResponse>;
|
|
52
|
+
export {};
|