@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.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +39 -0
  3. package/dist/client/linkedin-mirror.bundle.js +323 -0
  4. package/dist/client/linkedin-mirror.d.ts +43 -0
  5. package/dist/client/linkedin-mirror.js +393 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +30 -0
  8. package/dist/src/index.d.ts +10 -0
  9. package/dist/src/index.js +74 -0
  10. package/dist/src/linkedin-budget.d.ts +36 -0
  11. package/dist/src/linkedin-budget.js +87 -0
  12. package/dist/src/linkedin-capabilities.d.ts +4 -0
  13. package/dist/src/linkedin-capabilities.js +1027 -0
  14. package/dist/src/linkedin-conformance.d.ts +8 -0
  15. package/dist/src/linkedin-conformance.js +58 -0
  16. package/dist/src/linkedin-connector.d.ts +66 -0
  17. package/dist/src/linkedin-connector.js +326 -0
  18. package/dist/src/linkedin-errors.d.ts +21 -0
  19. package/dist/src/linkedin-errors.js +38 -0
  20. package/dist/src/linkedin-media.d.ts +125 -0
  21. package/dist/src/linkedin-media.js +331 -0
  22. package/dist/src/linkedin-mirror-ui.d.ts +59 -0
  23. package/dist/src/linkedin-mirror-ui.js +174 -0
  24. package/dist/src/linkedin-server.d.ts +10 -0
  25. package/dist/src/linkedin-server.js +154 -0
  26. package/dist/src/linkedin-twin.d.ts +23 -0
  27. package/dist/src/linkedin-twin.js +1220 -0
  28. package/package.json +58 -0
  29. package/src/cli.ts +28 -0
  30. package/src/index.ts +118 -0
  31. package/src/linkedin-budget.ts +108 -0
  32. package/src/linkedin-capabilities.ts +1057 -0
  33. package/src/linkedin-conformance.ts +73 -0
  34. package/src/linkedin-connector.ts +314 -0
  35. package/src/linkedin-errors.ts +58 -0
  36. package/src/linkedin-media.ts +363 -0
  37. package/src/linkedin-mirror-ui.ts +182 -0
  38. package/src/linkedin-server.ts +150 -0
  39. package/src/linkedin-twin.ts +1143 -0
@@ -0,0 +1,150 @@
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.ts';
16
+ import { assetAsOf, blobKey, readAsset as readStoredAsset, videoPosterSvg, type AssetRecord } from './linkedin-media.ts';
17
+ import { createTwinFetchFromHandler, readResourceBlobRange, resourceBlobSize, runWithCorrelationId, serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
18
+
19
+ const SERVICE = 'linkedin';
20
+
21
+ export interface LinkedinTwinFetchOptions {
22
+ root?: string;
23
+ readOnly?: boolean;
24
+ }
25
+
26
+ const UPLOAD_PATH = /^\/dms-uploads\//;
27
+ 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)$/;
28
+ const VIDEO_BYTES = /^\/playlist\/([A-Za-z0-9_-]{8,40})\/mp4-\d+p-30fp-crf28\/0\/([0-9a-f]{64})$/;
29
+
30
+ /** The most one read of the blob seam returns: an open-ended `bytes=N-` is answered with at most
31
+ * this much (a player asks again from where it stands), and any longer answer — a whole file, a long
32
+ * closed or suffix range — is streamed in reads of this size, so no request holds a large file. */
33
+ const RANGE_CAP = 8 * 1024 * 1024;
34
+
35
+ /** [start, end] of a stored digest as a body: one read when it fits the cap, else a stream of capped reads. */
36
+ async function rangeBody(key: string, start: number, end: number, root?: string): Promise<Blob | ReadableStream<Uint8Array> | null> {
37
+ if (end - start + 1 <= RANGE_CAP) {
38
+ const bytes = await readResourceBlobRange(SERVICE, key, start, end, root);
39
+ return bytes === null ? null : new Blob([new Uint8Array(bytes)]);
40
+ }
41
+ let at = start;
42
+ return new ReadableStream<Uint8Array>({
43
+ async pull(controller) {
44
+ if (at > end) { controller.close(); return; }
45
+ const chunk = await readResourceBlobRange(SERVICE, key, at, Math.min(end, at + RANGE_CAP - 1), root);
46
+ if (chunk === null || chunk.length === 0) { controller.error(new Error('the stored bytes went away mid-read')); return; }
47
+ at += chunk.length;
48
+ controller.enqueue(new Uint8Array(chunk));
49
+ },
50
+ });
51
+ }
52
+
53
+ /**
54
+ * A stored digest as a response, read through the kernel's ranged reads (which walk the branch's
55
+ * ancestors), honouring a single `Range: bytes=` (206 / 416) as a CDN does.
56
+ */
57
+ async function bytesResponse(request: Request, sha256: string, contentType: string, root?: string): Promise<Response> {
58
+ const key = blobKey(sha256);
59
+ const total = await resourceBlobSize(SERVICE, key, root);
60
+ if (total === null) return notFound();
61
+ const base = { 'content-type': contentType, 'accept-ranges': 'bytes', 'cache-control': 'public, max-age=604800, immutable' };
62
+ let start = 0;
63
+ let end = total - 1;
64
+ const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
65
+ if (range && (range[1] !== '' || range[2] !== '')) {
66
+ if (range[1] === '') { start = Math.max(0, total - Number(range[2])); } 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) 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') return new Response(null, { status: partial ? 206 : 200, headers });
75
+ const body = await rangeBody(key, start, end, root);
76
+ if (body === null) return notFound();
77
+ return new Response(body, { status: partial ? 206 : 200, headers });
78
+ }
79
+
80
+ /** An asset as it stands at the world's instant (a finalized video becomes AVAILABLE with time). */
81
+ async function readAsset(id: string, root?: string): Promise<AssetRecord | undefined> {
82
+ const stored = await readStoredAsset(id, root);
83
+ return stored ? assetAsOf(stored, Date.parse(worldNow())) : undefined;
84
+ }
85
+
86
+ const notFound = (): Response => Response.json({ status: 404, code: 'NOT_FOUND', message: 'Not Found' }, { status: 404 });
87
+
88
+ export function createLinkedinTwinFetch(options: LinkedinTwinFetchOptions = {}): (request: Request) => Promise<Response> {
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: Request): Promise<Response> => {
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') return notFound();
101
+ const thumbnail = image[2] === 'ads-video-thumbnail_720_1280';
102
+ if (thumbnail && asset.kind !== 'video') return notFound();
103
+ if (thumbnail && image[3] === 'system') {
104
+ if (asset.thumbnail_sha256) return notFound();
105
+ 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' } });
106
+ }
107
+ const sha = thumbnail ? asset.thumbnail_sha256 : asset.kind === 'image' ? asset.sha256 : undefined;
108
+ if (!sha || sha !== image[3]) return notFound();
109
+ return bytesResponse(request, sha, (thumbnail ? asset.thumbnail_media_type : asset.media_type) ?? 'application/octet-stream', options.root);
110
+ }
111
+ const video = reading ? VIDEO_BYTES.exec(url.pathname) : null;
112
+ if (video) {
113
+ const asset = await readAsset(video[1]!, options.root);
114
+ if (!asset || asset.kind !== 'video' || asset.status !== 'AVAILABLE' || asset.sha256 !== video[2]) return notFound();
115
+ return bytesResponse(request, asset.sha256, 'video/mp4', options.root);
116
+ }
117
+ if (request.method === 'PUT' && UPLOAD_PATH.test(url.pathname)) {
118
+ const bytes = new Uint8Array(await request.arrayBuffer());
119
+ const headers: Record<string, string> = {};
120
+ request.headers.forEach((value, key) => { headers[key] = value; });
121
+ const invoke = () => handleLinkedinTwinRequest({
122
+ method: request.method,
123
+ path: url.pathname + (url.search || ''),
124
+ headers,
125
+ bytes,
126
+ readOnly: options.readOnly ?? false,
127
+ occurredAt: worldNow(),
128
+ publicBase: twinPublicBase(request),
129
+ ...(options.root !== undefined ? { root: options.root } : {}),
130
+ });
131
+ const requestId = request.headers.get('x-twins-request-id') ?? undefined;
132
+ const result = await (requestId ? runWithCorrelationId(requestId, invoke) : invoke());
133
+ const empty = result.body === null || result.body === undefined || result.body === '';
134
+ return new Response(empty ? null : JSON.stringify(result.body), { status: result.status, headers: { ...(empty ? {} : { 'content-type': 'application/json' }), ...result.headers } });
135
+ }
136
+ return adapted(request);
137
+ };
138
+ // every answer carries the World's instant as its HTTP Date (an HTTP server's own header), so a
139
+ // client — the mirror's relative times — reads the World's now rather than its own clock
140
+ return async (request: Request): Promise<Response> => {
141
+ const res = await serve(request);
142
+ const headers = new Headers(res.headers);
143
+ headers.set('date', new Date(worldNow()).toUTCString());
144
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
145
+ };
146
+ }
147
+
148
+ export async function createLinkedinTwinServer(options: { root?: string; port?: number; readOnly?: boolean } = {}) {
149
+ return serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: createLinkedinTwinFetch(options) });
150
+ }