@mayurajs/browser-viewer 0.0.0-stage → 1.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/src/serve.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { MayuraError } from 'mayura';
2
+ import type { Browser } from 'mayura/browser';
3
+ import { browserViewer, type BrowserViewerOptions, type ViewerShare, type ViewerShareOptions } from './viewer.js';
4
+
5
+ export interface ServeBrowserViewerOptions extends Omit<BrowserViewerOptions, 'basePath'> {
6
+ /** The address to listen on; `127.0.0.1` by default. Another needs `remote`. */
7
+ readonly host?: string;
8
+ /** The port; any free one by default. */
9
+ readonly port?: number;
10
+ /**
11
+ * Listen beyond this machine. Links carry their token in the clear over plain HTTP: put TLS in front, and give
12
+ * `publicUrl`.
13
+ */
14
+ readonly remote?: boolean;
15
+ /** The origin people reach this server at, such as `https://viewer.example`, for the links; this server's by default. */
16
+ readonly publicUrl?: string;
17
+ }
18
+
19
+ export interface ServedBrowserViewer {
20
+ /** Where the viewer is reached. */
21
+ readonly url: string;
22
+ /** A link to watch the browser, and with `interact` to use it: `url` is the whole link, a secret. */
23
+ share(browser: Browser, options?: ViewerShareOptions): Promise<ViewerShare & { readonly url: string }>;
24
+ /** Revokes every share and stops the server. */
25
+ close(): Promise<void>;
26
+ }
27
+
28
+ const loopback = new Set(['127.0.0.1', '::1', 'localhost']);
29
+
30
+ /**
31
+ * A live viewer of its own on Node: an HTTP server answering `browserViewer`'s pages, on this machine only unless
32
+ * `remote`. Node only; on other runtimes, mount `browserViewer().handle` in your server.
33
+ */
34
+ export async function serveBrowserViewer(options: ServeBrowserViewerOptions = {}): Promise<ServedBrowserViewer> {
35
+ const host = options.host ?? '127.0.0.1';
36
+ if (typeof host !== 'string' || host === '') throw new MayuraError('INVALID_CONFIG', 'serveBrowserViewer(): host is an address to listen on.');
37
+ if (!loopback.has(host) && options.remote !== true) throw new MayuraError('INVALID_CONFIG', 'serveBrowserViewer(): listening beyond this machine needs remote: true, with TLS in front.');
38
+ const port = options.port ?? 0;
39
+ if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) throw new MayuraError('INVALID_CONFIG', 'serveBrowserViewer(): port is 0 to 65,535.');
40
+ let publicUrl: URL | undefined;
41
+ if (options.publicUrl !== undefined) {
42
+ try { publicUrl = new URL(options.publicUrl); } catch { publicUrl = undefined; }
43
+ if (!publicUrl || (publicUrl.protocol !== 'https:' && !loopback.has(publicUrl.hostname.replace(/^\[|\]$/gu, ''))) || publicUrl.pathname !== '/' || publicUrl.search || publicUrl.hash || publicUrl.username || publicUrl.password) {
44
+ throw new MayuraError('INVALID_CONFIG', 'serveBrowserViewer(): publicUrl is an https origin (http:// only on this machine).');
45
+ }
46
+ }
47
+ const { host: _host, port: _port, remote: _remote, publicUrl: _publicUrl, ...viewerOptions } = options;
48
+ const viewer = browserViewer(viewerOptions);
49
+ const { createServer } = await import('node:http');
50
+ const { Readable } = await import('node:stream');
51
+
52
+ const server = createServer((req, res) => {
53
+ void (async () => {
54
+ const chunks: Buffer[] = []; let size = 0;
55
+ if (req.method === 'POST') {
56
+ for await (const chunk of req as AsyncIterable<Buffer>) { size += chunk.byteLength; if (size > 4_096) break; chunks.push(chunk); }
57
+ if (size > 4_096) { res.writeHead(413, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end('Too large.'); return; }
58
+ }
59
+ const headers = new Headers();
60
+ for (const [name, value] of Object.entries(req.headers)) if (typeof value === 'string') headers.set(name, value);
61
+ const request = new Request(new URL(req.url ?? '/', 'http://viewer.invalid'), {
62
+ method: req.method ?? 'GET', headers, ...(req.method === 'POST' ? { body: Buffer.concat(chunks) } : {}),
63
+ });
64
+ const response = await viewer.handle(request) ?? new Response('Not found.', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
65
+ res.writeHead(response.status, Object.fromEntries(response.headers));
66
+ if (!response.body) { res.end(); return; }
67
+ // A stream's headers go now: its first frame may be a while coming.
68
+ res.flushHeaders();
69
+ const body = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
70
+ res.on('close', () => body.destroy());
71
+ body.on('error', () => res.destroy());
72
+ body.pipe(res);
73
+ })().catch(() => { if (!res.headersSent) res.writeHead(500); res.end(); });
74
+ });
75
+ await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(port, host, () => { server.off('error', reject); resolve(); }); });
76
+ const address = server.address();
77
+ const listening = typeof address === 'object' && address ? `http://${address.family === 'IPv6' ? `[${address.address}]` : address.address}:${address.port}` : `http://${host}:${port}`;
78
+ const url = publicUrl ? publicUrl.href : `${listening}/`;
79
+
80
+ return {
81
+ url,
82
+ share: async (browser, shareOptions) => {
83
+ const share = await viewer.share(browser, shareOptions);
84
+ return Object.freeze({ path: share.path, interact: share.interact, expiresAt: share.expiresAt, revoke: share.revoke, url: new URL(share.path.slice(1), url).href });
85
+ },
86
+ close: async () => {
87
+ viewer.close();
88
+ server.closeAllConnections();
89
+ await new Promise<void>(resolve => server.close(() => resolve()));
90
+ },
91
+ };
92
+ }
package/src/viewer.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { MayuraError } from 'mayura';
2
+ import type { Browser } from 'mayura/browser';
3
+ import { viewerPage } from './page.js';
4
+ import { browserScreencast, type Screencast, type ScreencastOptions, type ViewerInput } from './screencast.js';
5
+
6
+ export interface BrowserViewerOptions extends Omit<ScreencastOptions, 'interact' | 'signal'> {
7
+ /** Where the viewer is mounted in your server, such as `/viewer/`; `/` by default. */
8
+ readonly basePath?: string;
9
+ /**
10
+ * Who may show the viewer page in a frame (its CSP `frame-ancestors`), such as `['https://app.example']`; nobody by
11
+ * default.
12
+ */
13
+ readonly frameAncestors?: readonly string[];
14
+ /** How many people may watch one share at once; 2 by default (1 to 16). */
15
+ readonly maxViewersPerShare?: number;
16
+ /** How many shares may be open at once; 16 by default (1 to 256). */
17
+ readonly maxShares?: number;
18
+ /** Input events a second, per share; 60 by default (1 to 500). */
19
+ readonly maxInputPerSecond?: number;
20
+ }
21
+
22
+ export interface ViewerShareOptions {
23
+ /** Let whoever has the link use the browser (mouse, keyboard, typing); off by default. */
24
+ readonly interact?: boolean;
25
+ /** How long the link works, in milliseconds; 1 hour by default (1 s to 24 hours). It also stops when the browser ends. */
26
+ readonly expiresInMs?: number;
27
+ }
28
+
29
+ export interface ViewerShare {
30
+ /** The viewer page's path, under `basePath`, with the link's token in it: treat it as a secret. */
31
+ readonly path: string;
32
+ readonly interact: boolean;
33
+ /** When the link stops working, in milliseconds since the epoch. */
34
+ readonly expiresAt: number;
35
+ /** Stops the link now, and ends what is being watched through it. */
36
+ revoke(): void;
37
+ }
38
+
39
+ export interface BrowserViewer {
40
+ /** A link to watch the browser, and with `interact` to use it, for whoever has it. */
41
+ share(browser: Browser, options?: ViewerShareOptions): Promise<ViewerShare>;
42
+ /** Answers a request for a viewer path (`<basePath>v/...`); undefined for any other, for your server to answer. */
43
+ handle(request: Request): Promise<Response | undefined>;
44
+ /** How many shares are open. */
45
+ readonly size: number;
46
+ /** Revokes every share, and makes no more. */
47
+ close(): void;
48
+ }
49
+
50
+ interface Share {
51
+ readonly browser: Browser; readonly interact: boolean; readonly timer: ReturnType<typeof setTimeout>;
52
+ readonly casts: Set<Screencast>; inputs: number[]; revoked: boolean;
53
+ }
54
+
55
+ const boundary = 'mayura-frame';
56
+ const encoder = new TextEncoder();
57
+ const origin = /^https?:\/\/[a-z0-9.-]+(?::\d{1,5})?$/iu;
58
+
59
+ function bound(value: number | undefined, name: string, fallback: number, min: number, max: number): number {
60
+ const result = value ?? fallback;
61
+ if (!Number.isSafeInteger(result) || result < min || result > max) throw new MayuraError('INVALID_CONFIG', `browserViewer(): ${name} is ${min.toLocaleString('en-US')} to ${max.toLocaleString('en-US')}.`);
62
+ return result;
63
+ }
64
+ function base64Url(bytes: Uint8Array): string {
65
+ let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte);
66
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
67
+ }
68
+ /** A request's body as text, read no further than `max` bytes; undefined when it is longer. */
69
+ async function bodyText(request: Request, max: number): Promise<string | undefined> {
70
+ if (!request.body) return '';
71
+ const reader = request.body.getReader(); const chunks: Uint8Array[] = []; let size = 0;
72
+ for (;;) {
73
+ const { value, done } = await reader.read();
74
+ if (done) break;
75
+ size += value.byteLength;
76
+ if (size > max) { await reader.cancel().catch(() => undefined); return undefined; }
77
+ chunks.push(value);
78
+ }
79
+ const bytes = new Uint8Array(size); let offset = 0;
80
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
81
+ return new TextDecoder().decode(bytes);
82
+ }
83
+ async function digest(token: string): Promise<string> {
84
+ const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(token)));
85
+ return [...hash].map(byte => byte.toString(16).padStart(2, '0')).join('');
86
+ }
87
+
88
+ /**
89
+ * A live viewer for `mayura/browser` browsers, as web-standard request handling for any server: `share(browser)` gives
90
+ * a link with a secret token to a page showing the browser's active tab as it changes (an MJPEG stream of its
91
+ * screencast), and with `interact` a person can use the page too. Links expire, and end with the browser; nothing is
92
+ * shown that is not shared. For a server of its own on Node, see `serveBrowserViewer`.
93
+ */
94
+ export function browserViewer(options: BrowserViewerOptions = {}): BrowserViewer {
95
+ const basePath = options.basePath ?? '/';
96
+ if (typeof basePath !== 'string' || !/^\/(?:[A-Za-z0-9._~-]+\/)*$/u.test(basePath)) throw new MayuraError('INVALID_CONFIG', 'browserViewer(): basePath is a path such as / or /viewer/, starting and ending with /.');
97
+ const frameAncestors = options.frameAncestors ?? [];
98
+ if (!Array.isArray(frameAncestors) || frameAncestors.length > 16 || frameAncestors.some(item => typeof item !== 'string' || !origin.test(item))) {
99
+ throw new MayuraError('INVALID_CONFIG', 'browserViewer(): frameAncestors are origins such as https://app.example.');
100
+ }
101
+ const maxViewers = bound(options.maxViewersPerShare, 'maxViewersPerShare', 2, 1, 16);
102
+ const maxShares = bound(options.maxShares, 'maxShares', 16, 1, 256);
103
+ const maxInput = bound(options.maxInputPerSecond, 'maxInputPerSecond', 60, 1, 500);
104
+ const { basePath: _basePath, frameAncestors: _frameAncestors, maxViewersPerShare: _viewers, maxShares: _shares, maxInputPerSecond: _input, ...castOptions } = options;
105
+ // Checked now, rather than when someone first watches.
106
+ if (castOptions.webSocket !== undefined && typeof castOptions.webSocket !== 'function') throw new MayuraError('INVALID_CONFIG', 'browserViewer(): webSocket must be a function.');
107
+ bound(castOptions.quality, 'quality', 60, 1, 100); bound(castOptions.maxWidth, 'maxWidth', 1_280, 320, 3_840); bound(castOptions.maxHeight, 'maxHeight', 800, 240, 2_160);
108
+ bound(castOptions.maxFps, 'maxFps', 5, 1, 30); bound(castOptions.maxFrameBytes, 'maxFrameBytes', 2_097_152, 65_536, 16_777_216);
109
+ const ancestors = frameAncestors.length ? frameAncestors.join(' ') : "'none'";
110
+
111
+ const shares = new Map<string, Share>(); // by the token's SHA-256
112
+ let closed = false;
113
+ const revoke = (key: string) => {
114
+ const share = shares.get(key);
115
+ if (!share) return;
116
+ shares.delete(key); share.revoked = true; clearTimeout(share.timer);
117
+ for (const cast of share.casts) cast.close();
118
+ };
119
+ /** The share a token names, while it is open and its browser has not ended. */
120
+ const find = (key: string): Share | undefined => {
121
+ const share = shares.get(key);
122
+ if (share?.browser.ended) { revoke(key); return undefined; }
123
+ return share;
124
+ };
125
+
126
+ const headers = (extra: Record<string, string> = {}): Record<string, string> => ({
127
+ 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff',
128
+ 'content-security-policy': `default-src 'none'; frame-ancestors ${ancestors}; base-uri 'none'; form-action 'none'`, ...extra,
129
+ });
130
+ const text = (status: number, body: string) => new Response(body, { status, headers: headers({ 'content-type': 'text/plain; charset=utf-8' }) });
131
+
132
+ const stream = async (share: Share): Promise<Response> => {
133
+ if (share.casts.size >= maxViewers) return text(429, 'Too many people are watching this browser.');
134
+ let cast: Screencast;
135
+ try { cast = await browserScreencast(share.browser, { ...castOptions, interact: share.interact }); }
136
+ catch { return text(410, 'This browser cannot be shown.'); }
137
+ // Revoked while connecting.
138
+ if (share.revoked) { cast.close(); return text(404, 'Not found.'); }
139
+ share.casts.add(cast);
140
+ const body = new ReadableStream<Uint8Array>({
141
+ pull: async controller => {
142
+ const frame = await cast.next();
143
+ if (!frame) { share.casts.delete(cast); controller.close(); return; }
144
+ controller.enqueue(encoder.encode(`--${boundary}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.data.byteLength}\r\n\r\n`));
145
+ controller.enqueue(frame.data); controller.enqueue(encoder.encode('\r\n'));
146
+ },
147
+ cancel: () => { share.casts.delete(cast); cast.close(); },
148
+ }, { highWaterMark: 0 });
149
+ return new Response(body, { status: 200, headers: headers({ 'content-type': `multipart/x-mixed-replace; boundary=${boundary}` }) });
150
+ };
151
+
152
+ const input = async (share: Share, request: Request): Promise<Response> => {
153
+ if (!share.interact) return text(403, 'This link is view only.');
154
+ // A JSON body cannot be sent across sites without a preflight, which is never allowed; nor from another site.
155
+ if (!/^application\/json(?:;|$)/iu.test(request.headers.get('content-type') ?? '')) return text(415, 'Send JSON.');
156
+ const site = request.headers.get('sec-fetch-site');
157
+ if (site !== null && site !== 'same-origin') return text(403, 'Not from this page.');
158
+ const now = Date.now();
159
+ share.inputs = share.inputs.filter(time => now - time < 1_000);
160
+ if (share.inputs.length >= maxInput) return text(429, 'Too many inputs.');
161
+ share.inputs.push(now);
162
+ const raw = await bodyText(request, 4_096);
163
+ if (raw === undefined) return text(413, 'Too large.');
164
+ let event: ViewerInput;
165
+ try { event = JSON.parse(raw) as ViewerInput; } catch { return text(400, 'Send JSON.'); }
166
+ // Input goes to the page as the newest viewer shows it.
167
+ const cast = [...share.casts].at(-1);
168
+ if (!cast) return text(409, 'Nothing is being watched.');
169
+ try { await cast.input(event); }
170
+ catch (error) { return text(error instanceof MayuraError && error.code === 'INVALID_INPUT' ? 400 : 409, error instanceof MayuraError && error.code === 'INVALID_INPUT' ? error.message : 'The browser did not take that.'); }
171
+ return new Response(null, { status: 204, headers: headers() });
172
+ };
173
+
174
+ return {
175
+ share: async (browser: Browser, shareOptions: ViewerShareOptions = {}) => {
176
+ if (closed) throw new MayuraError('INVALID_CONFIG', 'The viewer was closed.');
177
+ if (!browser || typeof browser.tabs !== 'function') throw new MayuraError('INVALID_CONFIG', 'share() needs a browser.');
178
+ if (!browser.cdp) throw new MayuraError('INVALID_CONFIG', `The ${browser.provider} provider's browsers cannot be joined: each connection starts a browser of its own.`);
179
+ if (browser.ended) throw new MayuraError('INVALID_INPUT', 'The browser has ended.');
180
+ if (shareOptions.interact !== undefined && typeof shareOptions.interact !== 'boolean') throw new MayuraError('INVALID_CONFIG', 'share(): interact must be a boolean.');
181
+ const expiresInMs = shareOptions.expiresInMs ?? 3_600_000;
182
+ if (!Number.isSafeInteger(expiresInMs) || expiresInMs < 1_000 || expiresInMs > 86_400_000) throw new MayuraError('INVALID_CONFIG', 'share(): expiresInMs is 1 s to 24 hours.');
183
+ for (const key of [...shares.keys()]) find(key);
184
+ if (shares.size >= maxShares) throw new MayuraError('LIMIT_EXCEEDED', 'Too many shares are open: revoke one first.');
185
+ const token = base64Url(crypto.getRandomValues(new Uint8Array(32)));
186
+ const key = await digest(token);
187
+ const expiresAt = Date.now() + expiresInMs;
188
+ const timer = setTimeout(() => revoke(key), expiresInMs);
189
+ // An open link does not keep the process running.
190
+ (timer as { unref?: () => void }).unref?.();
191
+ shares.set(key, { browser, interact: shareOptions.interact === true, timer, casts: new Set(), inputs: [], revoked: false });
192
+ return Object.freeze({ path: `${basePath}v/${token}/`, interact: shareOptions.interact === true, expiresAt, revoke: () => revoke(key) });
193
+ },
194
+ handle: async (request: Request) => {
195
+ const { pathname } = new URL(request.url);
196
+ if (!pathname.startsWith(`${basePath}v/`)) return undefined;
197
+ const match = /^([A-Za-z0-9_-]{43})\/(stream|input)?$/u.exec(pathname.slice(basePath.length + 2));
198
+ const share = match ? find(await digest(match[1]!)) : undefined;
199
+ if (!match || !share) return text(404, 'Not found.');
200
+ const part = match[2];
201
+ if (part === 'input') return request.method === 'POST' ? input(share, request) : text(405, 'Use POST.');
202
+ if (request.method !== 'GET') return text(405, 'Use GET.');
203
+ if (part === 'stream') return stream(share);
204
+ const nonce = base64Url(crypto.getRandomValues(new Uint8Array(16)));
205
+ return new Response(viewerPage({ interact: share.interact, nonce }), { status: 200, headers: headers({
206
+ 'content-type': 'text/html; charset=utf-8',
207
+ 'content-security-policy': `default-src 'none'; img-src 'self'; connect-src 'self'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; frame-ancestors ${ancestors}; base-uri 'none'; form-action 'none'`,
208
+ }) });
209
+ },
210
+ get size() { for (const key of [...shares.keys()]) find(key); return shares.size; },
211
+ close: () => { closed = true; for (const key of [...shares.keys()]) revoke(key); },
212
+ };
213
+ }