@volter/twin-linkedin 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 +39 -0
- package/dist/client/linkedin-mirror.bundle.js +323 -0
- package/dist/client/linkedin-mirror.d.ts +43 -0
- package/dist/client/linkedin-mirror.js +393 -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 +74 -0
- package/dist/src/linkedin-budget.d.ts +36 -0
- package/dist/src/linkedin-budget.js +87 -0
- package/dist/src/linkedin-capabilities.d.ts +4 -0
- package/dist/src/linkedin-capabilities.js +1027 -0
- package/dist/src/linkedin-conformance.d.ts +8 -0
- package/dist/src/linkedin-conformance.js +58 -0
- package/dist/src/linkedin-connector.d.ts +66 -0
- package/dist/src/linkedin-connector.js +326 -0
- package/dist/src/linkedin-errors.d.ts +21 -0
- package/dist/src/linkedin-errors.js +38 -0
- package/dist/src/linkedin-media.d.ts +125 -0
- package/dist/src/linkedin-media.js +331 -0
- package/dist/src/linkedin-mirror-ui.d.ts +59 -0
- package/dist/src/linkedin-mirror-ui.js +174 -0
- package/dist/src/linkedin-server.d.ts +10 -0
- package/dist/src/linkedin-server.js +154 -0
- package/dist/src/linkedin-twin.d.ts +23 -0
- package/dist/src/linkedin-twin.js +1220 -0
- package/package.json +58 -0
- package/src/cli.ts +28 -0
- package/src/index.ts +118 -0
- package/src/linkedin-budget.ts +108 -0
- package/src/linkedin-capabilities.ts +1057 -0
- package/src/linkedin-conformance.ts +73 -0
- package/src/linkedin-connector.ts +314 -0
- package/src/linkedin-errors.ts +58 -0
- package/src/linkedin-media.ts +363 -0
- package/src/linkedin-mirror-ui.ts +182 -0
- package/src/linkedin-server.ts +150 -0
- package/src/linkedin-twin.ts +1143 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// LINKEDIN MIRROR UI — linkedin.com's own view of the twin's state: an organization's company page
|
|
2
|
+
// (its header with logo, name and follower line, and its Posts feed), a post card with commentary
|
|
3
|
+
// and an inline video player or image or article card, and a single post's page. A React/TSX app
|
|
4
|
+
// bundled by Bun that renders by consuming the twin's OWN LinkedIn API on the same origin
|
|
5
|
+
// (GET /v2/userinfo, /rest/organizationAcls, /rest/organizations, /rest/networkSizes,
|
|
6
|
+
// /rest/posts?q=author, /rest/posts/{urn}, /rest/images/{urn}, /rest/videos/{urn}) — the same routes
|
|
7
|
+
// any LinkedIn client uses — so every screen is data-coupled to real twin state. Archetype A
|
|
8
|
+
// (passthrough), transcribed from the X mirror: one serving code path, so API↔UI parity cannot drift.
|
|
9
|
+
//
|
|
10
|
+
// SIGN-IN. linkedin.com's own two-step sign-in (email, then password) with the password leg replaced
|
|
11
|
+
// by the access token the twin issued through /_twin/tokens: the mirror is a client like any other
|
|
12
|
+
// and presents a registered token rather than bypassing the auth gate. The email is checked against
|
|
13
|
+
// the token's own member (`/v2/userinfo`), and the page it opens is the organization that member
|
|
14
|
+
// administers (`organizationAcls?q=roleAssignee`). Both are kept in the TAB'S sessionStorage.
|
|
15
|
+
//
|
|
16
|
+
// PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
|
|
17
|
+
// fetch adapter as its API backend and reads every byte of state back over the wire.
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
|
|
20
|
+
import { createLinkedinTwinFetch } from "./linkedin-server.js";
|
|
21
|
+
const CLIENT_ENTRY = () => new URL('../client/linkedin-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
|
|
22
|
+
const CLIENT_CSS = () => new URL('../client/linkedin-mirror.css', import.meta.url).pathname;
|
|
23
|
+
/** The version the mirror's reads pin — the twin's newest. */
|
|
24
|
+
export const MIRROR_VERSION = '202609';
|
|
25
|
+
/** linkedin.com's feed timestamp: "now", "5m", "3h", "2d", "1w", "3mo", "1yr". `nowMs` is passed in. */
|
|
26
|
+
export function relativeTime(ms, nowMs) {
|
|
27
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms))
|
|
28
|
+
return '';
|
|
29
|
+
const s = Math.max(0, Math.floor((nowMs - ms) / 1000));
|
|
30
|
+
if (s < 60)
|
|
31
|
+
return 'now';
|
|
32
|
+
if (s < 3600)
|
|
33
|
+
return `${Math.floor(s / 60)}m`;
|
|
34
|
+
if (s < 86_400)
|
|
35
|
+
return `${Math.floor(s / 3600)}h`;
|
|
36
|
+
if (s < 7 * 86_400)
|
|
37
|
+
return `${Math.floor(s / 86_400)}d`;
|
|
38
|
+
if (s < 30 * 86_400)
|
|
39
|
+
return `${Math.floor(s / (7 * 86_400))}w`;
|
|
40
|
+
if (s < 365 * 86_400)
|
|
41
|
+
return `${Math.floor(s / (30 * 86_400))}mo`;
|
|
42
|
+
return `${Math.floor(s / (365 * 86_400))}yr`;
|
|
43
|
+
}
|
|
44
|
+
/** "12,480 followers" — the follower line under a page's name and a post's author. */
|
|
45
|
+
export function followerLine(count) {
|
|
46
|
+
const n = typeof count === 'number' && Number.isFinite(count) ? count : 0;
|
|
47
|
+
return `${n.toLocaleString('en-US')} ${n === 1 ? 'follower' : 'followers'}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A post's commentary as linkedin.com draws it, from the little text format LinkedIn stores:
|
|
51
|
+
* `{hashtag|\#|coding}` is the hashtag `#coding`, `@[Devtestco](urn:li:organization:2414183)` the
|
|
52
|
+
* mention `Devtestco`, a URL a link; everything else plain text (backslash escapes undone).
|
|
53
|
+
*/
|
|
54
|
+
export function commentarySegments(commentary) {
|
|
55
|
+
const source = typeof commentary === 'string' ? commentary : '';
|
|
56
|
+
const out = [];
|
|
57
|
+
const re = /\{hashtag\|\\?#\|([^}]+)\}|@\[([^\]]+)\]\((urn:li:[A-Za-z]+:[A-Za-z0-9_-]+)\)|(https?:\/\/[^\s]+)/g;
|
|
58
|
+
let last = 0;
|
|
59
|
+
const text = (s) => { if (s !== '')
|
|
60
|
+
out.push({ kind: 'text', value: s.replace(/\\([()[\]{}<>@|~_*#\\])/g, '$1') }); };
|
|
61
|
+
for (const m of source.matchAll(re)) {
|
|
62
|
+
const at = m.index ?? 0;
|
|
63
|
+
text(source.slice(last, at));
|
|
64
|
+
if (m[1] !== undefined)
|
|
65
|
+
out.push({ kind: 'hashtag', value: `#${m[1]}` });
|
|
66
|
+
else if (m[2] !== undefined)
|
|
67
|
+
out.push({ kind: 'mention', value: m[2], urn: m[3] });
|
|
68
|
+
else {
|
|
69
|
+
const value = m[4].replace(/[.,;:!?)\]'"]+$/, '');
|
|
70
|
+
out.push({ kind: 'url', value });
|
|
71
|
+
last = at + value.length;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
last = at + m[0].length;
|
|
75
|
+
}
|
|
76
|
+
text(source.slice(last));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Whether a commentary needs linkedin.com's "…more" fold: over three lines or ~210 characters. */
|
|
80
|
+
export function needsFold(commentary) {
|
|
81
|
+
const s = typeof commentary === 'string' ? commentary : '';
|
|
82
|
+
return s.split('\n').length > 3 || s.length > 210;
|
|
83
|
+
}
|
|
84
|
+
/** The kind of asset a post's content names, from its URN. */
|
|
85
|
+
export function mediaKind(post) {
|
|
86
|
+
const content = post.content;
|
|
87
|
+
if (content?.article)
|
|
88
|
+
return 'article';
|
|
89
|
+
const id = content?.media?.id;
|
|
90
|
+
if (typeof id !== 'string')
|
|
91
|
+
return undefined;
|
|
92
|
+
if (id.startsWith('urn:li:video:'))
|
|
93
|
+
return 'video';
|
|
94
|
+
if (id.startsWith('urn:li:image:'))
|
|
95
|
+
return 'image';
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
/** A URL's host, as an article card prints its source ("github.com"). */
|
|
99
|
+
export function sourceHost(url) {
|
|
100
|
+
try {
|
|
101
|
+
return new URL(String(url)).host.replace(/^www\./, '');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function orgFromReads(pub, admin, size, logo) {
|
|
108
|
+
return {
|
|
109
|
+
id: Number(pub.id), urn: `urn:li:organization:${pub.id}`, name: String(pub.localizedName), vanity: String(pub.vanityName),
|
|
110
|
+
...(logo && typeof logo.downloadUrl === 'string' ? { logoUrl: logo.downloadUrl } : {}),
|
|
111
|
+
...(size && typeof size.firstDegreeSize === 'number' ? { followers: size.firstDegreeSize } : {}),
|
|
112
|
+
...(admin && typeof admin.localizedDescription === 'string' ? { description: admin.localizedDescription } : {}),
|
|
113
|
+
...(typeof pub.localizedWebsite === 'string' ? { website: pub.localizedWebsite } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** The mirror's own route for a post: linkedin.com's `/feed/update/<urn>`. */
|
|
117
|
+
export function postRoute(urn) {
|
|
118
|
+
return `#/feed/update/${encodeURIComponent(urn)}`;
|
|
119
|
+
}
|
|
120
|
+
/** The finder read a company page's feed draws. */
|
|
121
|
+
export function postsQuery(orgUrn, start = 0) {
|
|
122
|
+
return `/rest/posts?q=author&author=${encodeURIComponent(orgUrn)}&count=10&start=${start}&sortBy=CREATED`;
|
|
123
|
+
}
|
|
124
|
+
const APP_SHELL = `<!doctype html>
|
|
125
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
126
|
+
<base href="/"><title>LinkedIn</title><link rel="stylesheet" href="assets/styles.css"></head>
|
|
127
|
+
<body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
|
|
128
|
+
let clientBundle = null;
|
|
129
|
+
/** Build the React/TSX mirror client to browser JS; memoized at module scope, so a pack on its own
|
|
130
|
+
* does exactly one `Bun.build`. */
|
|
131
|
+
export function buildLinkedinMirrorClient() {
|
|
132
|
+
if (!clientBundle) {
|
|
133
|
+
clientBundle = bundleClient(CLIENT_ENTRY()).catch((error) => { clientBundle = null; throw error; });
|
|
134
|
+
}
|
|
135
|
+
return clientBundle;
|
|
136
|
+
}
|
|
137
|
+
/** Serve the LinkedIn mirror UI (React app) + its backing LinkedIn API on one origin. */
|
|
138
|
+
export async function createLinkedinMirrorServer(options) {
|
|
139
|
+
const twin = createLinkedinTwinFetch(options);
|
|
140
|
+
const server = await serveHttp({
|
|
141
|
+
hostname: '127.0.0.1',
|
|
142
|
+
port: options.port ?? 0,
|
|
143
|
+
idleTimeout: 60,
|
|
144
|
+
async fetch(request) {
|
|
145
|
+
const url = new URL(request.url);
|
|
146
|
+
if (request.method === 'GET' && url.pathname === '/assets/app.js') {
|
|
147
|
+
try {
|
|
148
|
+
return new Response(await buildLinkedinMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
return new Response(String(error), { status: 500 });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
|
|
155
|
+
return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
156
|
+
}
|
|
157
|
+
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
|
|
158
|
+
return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
159
|
+
}
|
|
160
|
+
// Everything else -> the twin's OWN FETCH ADAPTER, the same closure createLinkedinTwinServer serves.
|
|
161
|
+
return twin(request);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const port = server.port ?? options.port ?? 0;
|
|
165
|
+
return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
|
|
166
|
+
}
|
|
167
|
+
/** The app-shell HTML (pure). */
|
|
168
|
+
export function linkedinMirrorHtml() {
|
|
169
|
+
return APP_SHELL;
|
|
170
|
+
}
|
|
171
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
172
|
+
export function linkedinMirrorStyles() {
|
|
173
|
+
return readFile(CLIENT_CSS(), 'utf8');
|
|
174
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface LinkedinTwinFetchOptions {
|
|
2
|
+
root?: string;
|
|
3
|
+
readOnly?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function createLinkedinTwinFetch(options?: LinkedinTwinFetchOptions): (request: Request) => Promise<Response>;
|
|
6
|
+
export declare function createLinkedinTwinServer(options?: {
|
|
7
|
+
root?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
}): Promise<import("@volter/world-core").HttpServer>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// LinkedIn 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 LinkedIn's
|
|
6
|
+
// wire exceeds that adapter's text-in/JSON-out shape, both about media BYTES:
|
|
7
|
+
//
|
|
8
|
+
// - the upload PUTs to the signed `/dms-uploads/…` URLs carry raw bytes, which the adapter's
|
|
9
|
+
// `request.text()` would corrupt, so they are read here and handed to the same handler as `bytes`;
|
|
10
|
+
// - the asset bytes themselves, read from the blob seam by digest at LinkedIn's own path shapes —
|
|
11
|
+
// media.licdn.com's `/dms/image/<asset>/<rendition>/0/<digest>` for an image or a video's
|
|
12
|
+
// thumbnail, dms.licdn.com's `/playlist/<asset>/<rendition>/0/<digest>` for a video file — with
|
|
13
|
+
// HTTP Range (206), so a <video> can seek, through the kernel's ranged reads. Those URLs are public at LinkedIn (a feed's <img> and
|
|
14
|
+
// <video> carry no bearer), so these routes are too.
|
|
15
|
+
import { handleLinkedinTwinRequest } from "./linkedin-twin.js";
|
|
16
|
+
import { assetAsOf, blobKey, readAsset as readStoredAsset, videoPosterSvg } from "./linkedin-media.js";
|
|
17
|
+
import { createTwinFetchFromHandler, readResourceBlobRange, resourceBlobSize, runWithCorrelationId, serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
|
|
18
|
+
const SERVICE = 'linkedin';
|
|
19
|
+
const UPLOAD_PATH = /^\/dms-uploads\//;
|
|
20
|
+
const IMAGE_BYTES = /^\/dms\/image\/([A-Za-z0-9_-]{8,40})\/(image-shrink_1280|ads-video-thumbnail_720_1280)\/0\/([0-9a-f]{64}|system)$/;
|
|
21
|
+
const VIDEO_BYTES = /^\/playlist\/([A-Za-z0-9_-]{8,40})\/mp4-\d+p-30fp-crf28\/0\/([0-9a-f]{64})$/;
|
|
22
|
+
/** The most one read of the blob seam returns: an open-ended `bytes=N-` is answered with at most
|
|
23
|
+
* this much (a player asks again from where it stands), and any longer answer — a whole file, a long
|
|
24
|
+
* closed or suffix range — is streamed in reads of this size, so no request holds a large file. */
|
|
25
|
+
const RANGE_CAP = 8 * 1024 * 1024;
|
|
26
|
+
/** [start, end] of a stored digest as a body: one read when it fits the cap, else a stream of capped reads. */
|
|
27
|
+
async function rangeBody(key, start, end, root) {
|
|
28
|
+
if (end - start + 1 <= RANGE_CAP) {
|
|
29
|
+
const bytes = await readResourceBlobRange(SERVICE, key, start, end, root);
|
|
30
|
+
return bytes === null ? null : new Blob([new Uint8Array(bytes)]);
|
|
31
|
+
}
|
|
32
|
+
let at = start;
|
|
33
|
+
return new ReadableStream({
|
|
34
|
+
async pull(controller) {
|
|
35
|
+
if (at > end) {
|
|
36
|
+
controller.close();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const chunk = await readResourceBlobRange(SERVICE, key, at, Math.min(end, at + RANGE_CAP - 1), root);
|
|
40
|
+
if (chunk === null || chunk.length === 0) {
|
|
41
|
+
controller.error(new Error('the stored bytes went away mid-read'));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
at += chunk.length;
|
|
45
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A stored digest as a response, read through the kernel's ranged reads (which walk the branch's
|
|
51
|
+
* ancestors), honouring a single `Range: bytes=` (206 / 416) as a CDN does.
|
|
52
|
+
*/
|
|
53
|
+
async function bytesResponse(request, sha256, contentType, root) {
|
|
54
|
+
const key = blobKey(sha256);
|
|
55
|
+
const total = await resourceBlobSize(SERVICE, key, root);
|
|
56
|
+
if (total === null)
|
|
57
|
+
return notFound();
|
|
58
|
+
const base = { 'content-type': contentType, 'accept-ranges': 'bytes', 'cache-control': 'public, max-age=604800, immutable' };
|
|
59
|
+
let start = 0;
|
|
60
|
+
let end = total - 1;
|
|
61
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
|
|
62
|
+
if (range && (range[1] !== '' || range[2] !== '')) {
|
|
63
|
+
if (range[1] === '') {
|
|
64
|
+
start = Math.max(0, total - Number(range[2]));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
start = Number(range[1]);
|
|
68
|
+
end = range[2] === '' ? Math.min(total - 1, start + RANGE_CAP - 1) : Math.min(Number(range[2]), total - 1);
|
|
69
|
+
}
|
|
70
|
+
if (start >= total || start > end)
|
|
71
|
+
return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
|
|
72
|
+
}
|
|
73
|
+
const partial = start !== 0 || end !== total - 1;
|
|
74
|
+
const headers = { ...base, 'content-length': String(end - start + 1), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) };
|
|
75
|
+
if (request.method === 'HEAD')
|
|
76
|
+
return new Response(null, { status: partial ? 206 : 200, headers });
|
|
77
|
+
const body = await rangeBody(key, start, end, root);
|
|
78
|
+
if (body === null)
|
|
79
|
+
return notFound();
|
|
80
|
+
return new Response(body, { status: partial ? 206 : 200, headers });
|
|
81
|
+
}
|
|
82
|
+
/** An asset as it stands at the world's instant (a finalized video becomes AVAILABLE with time). */
|
|
83
|
+
async function readAsset(id, root) {
|
|
84
|
+
const stored = await readStoredAsset(id, root);
|
|
85
|
+
return stored ? assetAsOf(stored, Date.parse(worldNow())) : undefined;
|
|
86
|
+
}
|
|
87
|
+
const notFound = () => Response.json({ status: 404, code: 'NOT_FOUND', message: 'Not Found' }, { status: 404 });
|
|
88
|
+
export function createLinkedinTwinFetch(options = {}) {
|
|
89
|
+
const adapted = createTwinFetchFromHandler(handleLinkedinTwinRequest, {
|
|
90
|
+
...options,
|
|
91
|
+
extras: (request) => ({ publicBase: twinPublicBase(request) }),
|
|
92
|
+
manifest: statefulTwinManifest({ vendor: 'linkedin', twinOf: 'the LinkedIn Community Management API (organization posts, images, videos)', stores: 'organizations, admin roles, posts, and uploaded images and videos' }),
|
|
93
|
+
});
|
|
94
|
+
const serve = async (request) => {
|
|
95
|
+
const url = new URL(request.url);
|
|
96
|
+
const reading = request.method === 'GET' || request.method === 'HEAD';
|
|
97
|
+
const image = reading ? IMAGE_BYTES.exec(url.pathname) : null;
|
|
98
|
+
if (image) {
|
|
99
|
+
const asset = await readAsset(image[1], options.root);
|
|
100
|
+
if (!asset || asset.status !== 'AVAILABLE')
|
|
101
|
+
return notFound();
|
|
102
|
+
const thumbnail = image[2] === 'ads-video-thumbnail_720_1280';
|
|
103
|
+
if (thumbnail && asset.kind !== 'video')
|
|
104
|
+
return notFound();
|
|
105
|
+
if (thumbnail && image[3] === 'system') {
|
|
106
|
+
if (asset.thumbnail_sha256)
|
|
107
|
+
return notFound();
|
|
108
|
+
return new Response(request.method === 'HEAD' ? null : videoPosterSvg(asset.width ?? 0, asset.height ?? 0), { headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=604800' } });
|
|
109
|
+
}
|
|
110
|
+
const sha = thumbnail ? asset.thumbnail_sha256 : asset.kind === 'image' ? asset.sha256 : undefined;
|
|
111
|
+
if (!sha || sha !== image[3])
|
|
112
|
+
return notFound();
|
|
113
|
+
return bytesResponse(request, sha, (thumbnail ? asset.thumbnail_media_type : asset.media_type) ?? 'application/octet-stream', options.root);
|
|
114
|
+
}
|
|
115
|
+
const video = reading ? VIDEO_BYTES.exec(url.pathname) : null;
|
|
116
|
+
if (video) {
|
|
117
|
+
const asset = await readAsset(video[1], options.root);
|
|
118
|
+
if (!asset || asset.kind !== 'video' || asset.status !== 'AVAILABLE' || asset.sha256 !== video[2])
|
|
119
|
+
return notFound();
|
|
120
|
+
return bytesResponse(request, asset.sha256, 'video/mp4', options.root);
|
|
121
|
+
}
|
|
122
|
+
if (request.method === 'PUT' && UPLOAD_PATH.test(url.pathname)) {
|
|
123
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
124
|
+
const headers = {};
|
|
125
|
+
request.headers.forEach((value, key) => { headers[key] = value; });
|
|
126
|
+
const invoke = () => handleLinkedinTwinRequest({
|
|
127
|
+
method: request.method,
|
|
128
|
+
path: url.pathname + (url.search || ''),
|
|
129
|
+
headers,
|
|
130
|
+
bytes,
|
|
131
|
+
readOnly: options.readOnly ?? false,
|
|
132
|
+
occurredAt: worldNow(),
|
|
133
|
+
publicBase: twinPublicBase(request),
|
|
134
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
135
|
+
});
|
|
136
|
+
const requestId = request.headers.get('x-twins-request-id') ?? undefined;
|
|
137
|
+
const result = await (requestId ? runWithCorrelationId(requestId, invoke) : invoke());
|
|
138
|
+
const empty = result.body === null || result.body === undefined || result.body === '';
|
|
139
|
+
return new Response(empty ? null : JSON.stringify(result.body), { status: result.status, headers: { ...(empty ? {} : { 'content-type': 'application/json' }), ...result.headers } });
|
|
140
|
+
}
|
|
141
|
+
return adapted(request);
|
|
142
|
+
};
|
|
143
|
+
// every answer carries the World's instant as its HTTP Date (an HTTP server's own header), so a
|
|
144
|
+
// client — the mirror's relative times — reads the World's now rather than its own clock
|
|
145
|
+
return async (request) => {
|
|
146
|
+
const res = await serve(request);
|
|
147
|
+
const headers = new Headers(res.headers);
|
|
148
|
+
headers.set('date', new Date(worldNow()).toUTCString());
|
|
149
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export async function createLinkedinTwinServer(options = {}) {
|
|
153
|
+
return serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: createLinkedinTwinFetch(options) });
|
|
154
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type LinkedinResponse } from './linkedin-errors.js';
|
|
2
|
+
export type LinkedinRequest = {
|
|
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 an upload PUT (linkedin-server.ts reads them; the JSON adapter would corrupt binary). */
|
|
11
|
+
bytes?: Uint8Array;
|
|
12
|
+
/** Where this twin is reached (`twinPublicBase`). Upload and download URLs are minted from it; a
|
|
13
|
+
* direct handler call with none mints LinkedIn's own hosts. */
|
|
14
|
+
publicBase?: string;
|
|
15
|
+
};
|
|
16
|
+
type Resource = Record<string, any>;
|
|
17
|
+
/** The API versions LinkedIn serves on 2026-09-26 (the versioned docs' moniker list). */
|
|
18
|
+
export declare const ACTIVE_VERSIONS: readonly ["202510", "202511", "202601", "202602", "202603", "202604", "202605", "202606", "202607", "202608", "202609"];
|
|
19
|
+
export declare function templateHashtags(commentary: string): string;
|
|
20
|
+
/** A stored post as LinkedIn answers it: its vendor fields, no `_` bookkeeping. */
|
|
21
|
+
export declare function projectPost(post: Resource): Record<string, unknown>;
|
|
22
|
+
export declare function handleLinkedinTwinRequest(req: LinkedinRequest): Promise<LinkedinResponse>;
|
|
23
|
+
export {};
|