@mayurajs/browser-viewer 0.0.0-stage → 1.2.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 +201 -0
- package/NOTICE +8 -0
- package/README.md +69 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/page.d.ts +5 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +41 -0
- package/dist/page.js.map +1 -0
- package/dist/screencast.d.ts +73 -0
- package/dist/screencast.d.ts.map +1 -0
- package/dist/screencast.js +235 -0
- package/dist/screencast.js.map +1 -0
- package/dist/serve.d.ts +31 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +89 -0
- package/dist/serve.js.map +1 -0
- package/dist/viewer.d.ts +50 -0
- package/dist/viewer.d.ts.map +1 -0
- package/dist/viewer.js +225 -0
- package/dist/viewer.js.map +1 -0
- package/package.json +43 -4
- package/src/index.ts +6 -0
- package/src/page.ts +41 -0
- package/src/screencast.ts +228 -0
- package/src/serve.ts +92 -0
- package/src/viewer.ts +213 -0
package/dist/viewer.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { MayuraError } from 'mayura';
|
|
2
|
+
import { viewerPage } from './page.js';
|
|
3
|
+
import { browserScreencast } from './screencast.js';
|
|
4
|
+
const boundary = 'mayura-frame';
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const origin = /^https?:\/\/[a-z0-9.-]+(?::\d{1,5})?$/iu;
|
|
7
|
+
function bound(value, name, fallback, min, max) {
|
|
8
|
+
const result = value ?? fallback;
|
|
9
|
+
if (!Number.isSafeInteger(result) || result < min || result > max)
|
|
10
|
+
throw new MayuraError('INVALID_CONFIG', `browserViewer(): ${name} is ${min.toLocaleString('en-US')} to ${max.toLocaleString('en-US')}.`);
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
function base64Url(bytes) {
|
|
14
|
+
let binary = '';
|
|
15
|
+
for (const byte of bytes)
|
|
16
|
+
binary += String.fromCharCode(byte);
|
|
17
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
|
|
18
|
+
}
|
|
19
|
+
/** A request's body as text, read no further than `max` bytes; undefined when it is longer. */
|
|
20
|
+
async function bodyText(request, max) {
|
|
21
|
+
if (!request.body)
|
|
22
|
+
return '';
|
|
23
|
+
const reader = request.body.getReader();
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let size = 0;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const { value, done } = await reader.read();
|
|
28
|
+
if (done)
|
|
29
|
+
break;
|
|
30
|
+
size += value.byteLength;
|
|
31
|
+
if (size > max) {
|
|
32
|
+
await reader.cancel().catch(() => undefined);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
chunks.push(value);
|
|
36
|
+
}
|
|
37
|
+
const bytes = new Uint8Array(size);
|
|
38
|
+
let offset = 0;
|
|
39
|
+
for (const chunk of chunks) {
|
|
40
|
+
bytes.set(chunk, offset);
|
|
41
|
+
offset += chunk.byteLength;
|
|
42
|
+
}
|
|
43
|
+
return new TextDecoder().decode(bytes);
|
|
44
|
+
}
|
|
45
|
+
async function digest(token) {
|
|
46
|
+
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(token)));
|
|
47
|
+
return [...hash].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A live viewer for `mayura/browser` browsers, as web-standard request handling for any server: `share(browser)` gives
|
|
51
|
+
* a link with a secret token to a page showing the browser's active tab as it changes (an MJPEG stream of its
|
|
52
|
+
* screencast), and with `interact` a person can use the page too. Links expire, and end with the browser; nothing is
|
|
53
|
+
* shown that is not shared. For a server of its own on Node, see `serveBrowserViewer`.
|
|
54
|
+
*/
|
|
55
|
+
export function browserViewer(options = {}) {
|
|
56
|
+
const basePath = options.basePath ?? '/';
|
|
57
|
+
if (typeof basePath !== 'string' || !/^\/(?:[A-Za-z0-9._~-]+\/)*$/u.test(basePath))
|
|
58
|
+
throw new MayuraError('INVALID_CONFIG', 'browserViewer(): basePath is a path such as / or /viewer/, starting and ending with /.');
|
|
59
|
+
const frameAncestors = options.frameAncestors ?? [];
|
|
60
|
+
if (!Array.isArray(frameAncestors) || frameAncestors.length > 16 || frameAncestors.some(item => typeof item !== 'string' || !origin.test(item))) {
|
|
61
|
+
throw new MayuraError('INVALID_CONFIG', 'browserViewer(): frameAncestors are origins such as https://app.example.');
|
|
62
|
+
}
|
|
63
|
+
const maxViewers = bound(options.maxViewersPerShare, 'maxViewersPerShare', 2, 1, 16);
|
|
64
|
+
const maxShares = bound(options.maxShares, 'maxShares', 16, 1, 256);
|
|
65
|
+
const maxInput = bound(options.maxInputPerSecond, 'maxInputPerSecond', 60, 1, 500);
|
|
66
|
+
const { basePath: _basePath, frameAncestors: _frameAncestors, maxViewersPerShare: _viewers, maxShares: _shares, maxInputPerSecond: _input, ...castOptions } = options;
|
|
67
|
+
// Checked now, rather than when someone first watches.
|
|
68
|
+
if (castOptions.webSocket !== undefined && typeof castOptions.webSocket !== 'function')
|
|
69
|
+
throw new MayuraError('INVALID_CONFIG', 'browserViewer(): webSocket must be a function.');
|
|
70
|
+
bound(castOptions.quality, 'quality', 60, 1, 100);
|
|
71
|
+
bound(castOptions.maxWidth, 'maxWidth', 1_280, 320, 3_840);
|
|
72
|
+
bound(castOptions.maxHeight, 'maxHeight', 800, 240, 2_160);
|
|
73
|
+
bound(castOptions.maxFps, 'maxFps', 5, 1, 30);
|
|
74
|
+
bound(castOptions.maxFrameBytes, 'maxFrameBytes', 2_097_152, 65_536, 16_777_216);
|
|
75
|
+
const ancestors = frameAncestors.length ? frameAncestors.join(' ') : "'none'";
|
|
76
|
+
const shares = new Map(); // by the token's SHA-256
|
|
77
|
+
let closed = false;
|
|
78
|
+
const revoke = (key) => {
|
|
79
|
+
const share = shares.get(key);
|
|
80
|
+
if (!share)
|
|
81
|
+
return;
|
|
82
|
+
shares.delete(key);
|
|
83
|
+
share.revoked = true;
|
|
84
|
+
clearTimeout(share.timer);
|
|
85
|
+
for (const cast of share.casts)
|
|
86
|
+
cast.close();
|
|
87
|
+
};
|
|
88
|
+
/** The share a token names, while it is open and its browser has not ended. */
|
|
89
|
+
const find = (key) => {
|
|
90
|
+
const share = shares.get(key);
|
|
91
|
+
if (share?.browser.ended) {
|
|
92
|
+
revoke(key);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return share;
|
|
96
|
+
};
|
|
97
|
+
const headers = (extra = {}) => ({
|
|
98
|
+
'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff',
|
|
99
|
+
'content-security-policy': `default-src 'none'; frame-ancestors ${ancestors}; base-uri 'none'; form-action 'none'`, ...extra,
|
|
100
|
+
});
|
|
101
|
+
const text = (status, body) => new Response(body, { status, headers: headers({ 'content-type': 'text/plain; charset=utf-8' }) });
|
|
102
|
+
const stream = async (share) => {
|
|
103
|
+
if (share.casts.size >= maxViewers)
|
|
104
|
+
return text(429, 'Too many people are watching this browser.');
|
|
105
|
+
let cast;
|
|
106
|
+
try {
|
|
107
|
+
cast = await browserScreencast(share.browser, { ...castOptions, interact: share.interact });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return text(410, 'This browser cannot be shown.');
|
|
111
|
+
}
|
|
112
|
+
// Revoked while connecting.
|
|
113
|
+
if (share.revoked) {
|
|
114
|
+
cast.close();
|
|
115
|
+
return text(404, 'Not found.');
|
|
116
|
+
}
|
|
117
|
+
share.casts.add(cast);
|
|
118
|
+
const body = new ReadableStream({
|
|
119
|
+
pull: async (controller) => {
|
|
120
|
+
const frame = await cast.next();
|
|
121
|
+
if (!frame) {
|
|
122
|
+
share.casts.delete(cast);
|
|
123
|
+
controller.close();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
controller.enqueue(encoder.encode(`--${boundary}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.data.byteLength}\r\n\r\n`));
|
|
127
|
+
controller.enqueue(frame.data);
|
|
128
|
+
controller.enqueue(encoder.encode('\r\n'));
|
|
129
|
+
},
|
|
130
|
+
cancel: () => { share.casts.delete(cast); cast.close(); },
|
|
131
|
+
}, { highWaterMark: 0 });
|
|
132
|
+
return new Response(body, { status: 200, headers: headers({ 'content-type': `multipart/x-mixed-replace; boundary=${boundary}` }) });
|
|
133
|
+
};
|
|
134
|
+
const input = async (share, request) => {
|
|
135
|
+
if (!share.interact)
|
|
136
|
+
return text(403, 'This link is view only.');
|
|
137
|
+
// A JSON body cannot be sent across sites without a preflight, which is never allowed; nor from another site.
|
|
138
|
+
if (!/^application\/json(?:;|$)/iu.test(request.headers.get('content-type') ?? ''))
|
|
139
|
+
return text(415, 'Send JSON.');
|
|
140
|
+
const site = request.headers.get('sec-fetch-site');
|
|
141
|
+
if (site !== null && site !== 'same-origin')
|
|
142
|
+
return text(403, 'Not from this page.');
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
share.inputs = share.inputs.filter(time => now - time < 1_000);
|
|
145
|
+
if (share.inputs.length >= maxInput)
|
|
146
|
+
return text(429, 'Too many inputs.');
|
|
147
|
+
share.inputs.push(now);
|
|
148
|
+
const raw = await bodyText(request, 4_096);
|
|
149
|
+
if (raw === undefined)
|
|
150
|
+
return text(413, 'Too large.');
|
|
151
|
+
let event;
|
|
152
|
+
try {
|
|
153
|
+
event = JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return text(400, 'Send JSON.');
|
|
157
|
+
}
|
|
158
|
+
// Input goes to the page as the newest viewer shows it.
|
|
159
|
+
const cast = [...share.casts].at(-1);
|
|
160
|
+
if (!cast)
|
|
161
|
+
return text(409, 'Nothing is being watched.');
|
|
162
|
+
try {
|
|
163
|
+
await cast.input(event);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
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.');
|
|
167
|
+
}
|
|
168
|
+
return new Response(null, { status: 204, headers: headers() });
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
share: async (browser, shareOptions = {}) => {
|
|
172
|
+
if (closed)
|
|
173
|
+
throw new MayuraError('INVALID_CONFIG', 'The viewer was closed.');
|
|
174
|
+
if (!browser || typeof browser.tabs !== 'function')
|
|
175
|
+
throw new MayuraError('INVALID_CONFIG', 'share() needs a browser.');
|
|
176
|
+
if (!browser.cdp)
|
|
177
|
+
throw new MayuraError('INVALID_CONFIG', `The ${browser.provider} provider's browsers cannot be joined: each connection starts a browser of its own.`);
|
|
178
|
+
if (browser.ended)
|
|
179
|
+
throw new MayuraError('INVALID_INPUT', 'The browser has ended.');
|
|
180
|
+
if (shareOptions.interact !== undefined && typeof shareOptions.interact !== 'boolean')
|
|
181
|
+
throw new MayuraError('INVALID_CONFIG', 'share(): interact must be a boolean.');
|
|
182
|
+
const expiresInMs = shareOptions.expiresInMs ?? 3_600_000;
|
|
183
|
+
if (!Number.isSafeInteger(expiresInMs) || expiresInMs < 1_000 || expiresInMs > 86_400_000)
|
|
184
|
+
throw new MayuraError('INVALID_CONFIG', 'share(): expiresInMs is 1 s to 24 hours.');
|
|
185
|
+
for (const key of [...shares.keys()])
|
|
186
|
+
find(key);
|
|
187
|
+
if (shares.size >= maxShares)
|
|
188
|
+
throw new MayuraError('LIMIT_EXCEEDED', 'Too many shares are open: revoke one first.');
|
|
189
|
+
const token = base64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
190
|
+
const key = await digest(token);
|
|
191
|
+
const expiresAt = Date.now() + expiresInMs;
|
|
192
|
+
const timer = setTimeout(() => revoke(key), expiresInMs);
|
|
193
|
+
// An open link does not keep the process running.
|
|
194
|
+
timer.unref?.();
|
|
195
|
+
shares.set(key, { browser, interact: shareOptions.interact === true, timer, casts: new Set(), inputs: [], revoked: false });
|
|
196
|
+
return Object.freeze({ path: `${basePath}v/${token}/`, interact: shareOptions.interact === true, expiresAt, revoke: () => revoke(key) });
|
|
197
|
+
},
|
|
198
|
+
handle: async (request) => {
|
|
199
|
+
const { pathname } = new URL(request.url);
|
|
200
|
+
if (!pathname.startsWith(`${basePath}v/`))
|
|
201
|
+
return undefined;
|
|
202
|
+
const match = /^([A-Za-z0-9_-]{43})\/(stream|input)?$/u.exec(pathname.slice(basePath.length + 2));
|
|
203
|
+
const share = match ? find(await digest(match[1])) : undefined;
|
|
204
|
+
if (!match || !share)
|
|
205
|
+
return text(404, 'Not found.');
|
|
206
|
+
const part = match[2];
|
|
207
|
+
if (part === 'input')
|
|
208
|
+
return request.method === 'POST' ? input(share, request) : text(405, 'Use POST.');
|
|
209
|
+
if (request.method !== 'GET')
|
|
210
|
+
return text(405, 'Use GET.');
|
|
211
|
+
if (part === 'stream')
|
|
212
|
+
return stream(share);
|
|
213
|
+
const nonce = base64Url(crypto.getRandomValues(new Uint8Array(16)));
|
|
214
|
+
return new Response(viewerPage({ interact: share.interact, nonce }), { status: 200, headers: headers({
|
|
215
|
+
'content-type': 'text/html; charset=utf-8',
|
|
216
|
+
'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'`,
|
|
217
|
+
}) });
|
|
218
|
+
},
|
|
219
|
+
get size() { for (const key of [...shares.keys()])
|
|
220
|
+
find(key); return shares.size; },
|
|
221
|
+
close: () => { closed = true; for (const key of [...shares.keys()])
|
|
222
|
+
revoke(key); },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=viewer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"viewer.js","sourceRoot":"","sources":["../src/viewer.ts"],"names":[],"mappings":"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","sourcesContent":["import { MayuraError } from 'mayura';\nimport type { Browser } from 'mayura/browser';\nimport { viewerPage } from './page.js';\nimport { browserScreencast, type Screencast, type ScreencastOptions, type ViewerInput } from './screencast.js';\n\nexport interface BrowserViewerOptions extends Omit<ScreencastOptions, 'interact' | 'signal'> {\n /** Where the viewer is mounted in your server, such as `/viewer/`; `/` by default. */\n readonly basePath?: string;\n /**\n * Who may show the viewer page in a frame (its CSP `frame-ancestors`), such as `['https://app.example']`; nobody by\n * default.\n */\n readonly frameAncestors?: readonly string[];\n /** How many people may watch one share at once; 2 by default (1 to 16). */\n readonly maxViewersPerShare?: number;\n /** How many shares may be open at once; 16 by default (1 to 256). */\n readonly maxShares?: number;\n /** Input events a second, per share; 60 by default (1 to 500). */\n readonly maxInputPerSecond?: number;\n}\n\nexport interface ViewerShareOptions {\n /** Let whoever has the link use the browser (mouse, keyboard, typing); off by default. */\n readonly interact?: boolean;\n /** How long the link works, in milliseconds; 1 hour by default (1 s to 24 hours). It also stops when the browser ends. */\n readonly expiresInMs?: number;\n}\n\nexport interface ViewerShare {\n /** The viewer page's path, under `basePath`, with the link's token in it: treat it as a secret. */\n readonly path: string;\n readonly interact: boolean;\n /** When the link stops working, in milliseconds since the epoch. */\n readonly expiresAt: number;\n /** Stops the link now, and ends what is being watched through it. */\n revoke(): void;\n}\n\nexport interface BrowserViewer {\n /** A link to watch the browser, and with `interact` to use it, for whoever has it. */\n share(browser: Browser, options?: ViewerShareOptions): Promise<ViewerShare>;\n /** Answers a request for a viewer path (`<basePath>v/...`); undefined for any other, for your server to answer. */\n handle(request: Request): Promise<Response | undefined>;\n /** How many shares are open. */\n readonly size: number;\n /** Revokes every share, and makes no more. */\n close(): void;\n}\n\ninterface Share {\n readonly browser: Browser; readonly interact: boolean; readonly timer: ReturnType<typeof setTimeout>;\n readonly casts: Set<Screencast>; inputs: number[]; revoked: boolean;\n}\n\nconst boundary = 'mayura-frame';\nconst encoder = new TextEncoder();\nconst origin = /^https?:\\/\\/[a-z0-9.-]+(?::\\d{1,5})?$/iu;\n\nfunction bound(value: number | undefined, name: string, fallback: number, min: number, max: number): number {\n const result = value ?? fallback;\n 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')}.`);\n return result;\n}\nfunction base64Url(bytes: Uint8Array): string {\n let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');\n}\n/** A request's body as text, read no further than `max` bytes; undefined when it is longer. */\nasync function bodyText(request: Request, max: number): Promise<string | undefined> {\n if (!request.body) return '';\n const reader = request.body.getReader(); const chunks: Uint8Array[] = []; let size = 0;\n for (;;) {\n const { value, done } = await reader.read();\n if (done) break;\n size += value.byteLength;\n if (size > max) { await reader.cancel().catch(() => undefined); return undefined; }\n chunks.push(value);\n }\n const bytes = new Uint8Array(size); let offset = 0;\n for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }\n return new TextDecoder().decode(bytes);\n}\nasync function digest(token: string): Promise<string> {\n const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(token)));\n return [...hash].map(byte => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * A live viewer for `mayura/browser` browsers, as web-standard request handling for any server: `share(browser)` gives\n * a link with a secret token to a page showing the browser's active tab as it changes (an MJPEG stream of its\n * screencast), and with `interact` a person can use the page too. Links expire, and end with the browser; nothing is\n * shown that is not shared. For a server of its own on Node, see `serveBrowserViewer`.\n */\nexport function browserViewer(options: BrowserViewerOptions = {}): BrowserViewer {\n const basePath = options.basePath ?? '/';\n 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 /.');\n const frameAncestors = options.frameAncestors ?? [];\n if (!Array.isArray(frameAncestors) || frameAncestors.length > 16 || frameAncestors.some(item => typeof item !== 'string' || !origin.test(item))) {\n throw new MayuraError('INVALID_CONFIG', 'browserViewer(): frameAncestors are origins such as https://app.example.');\n }\n const maxViewers = bound(options.maxViewersPerShare, 'maxViewersPerShare', 2, 1, 16);\n const maxShares = bound(options.maxShares, 'maxShares', 16, 1, 256);\n const maxInput = bound(options.maxInputPerSecond, 'maxInputPerSecond', 60, 1, 500);\n const { basePath: _basePath, frameAncestors: _frameAncestors, maxViewersPerShare: _viewers, maxShares: _shares, maxInputPerSecond: _input, ...castOptions } = options;\n // Checked now, rather than when someone first watches.\n if (castOptions.webSocket !== undefined && typeof castOptions.webSocket !== 'function') throw new MayuraError('INVALID_CONFIG', 'browserViewer(): webSocket must be a function.');\n bound(castOptions.quality, 'quality', 60, 1, 100); bound(castOptions.maxWidth, 'maxWidth', 1_280, 320, 3_840); bound(castOptions.maxHeight, 'maxHeight', 800, 240, 2_160);\n bound(castOptions.maxFps, 'maxFps', 5, 1, 30); bound(castOptions.maxFrameBytes, 'maxFrameBytes', 2_097_152, 65_536, 16_777_216);\n const ancestors = frameAncestors.length ? frameAncestors.join(' ') : \"'none'\";\n\n const shares = new Map<string, Share>(); // by the token's SHA-256\n let closed = false;\n const revoke = (key: string) => {\n const share = shares.get(key);\n if (!share) return;\n shares.delete(key); share.revoked = true; clearTimeout(share.timer);\n for (const cast of share.casts) cast.close();\n };\n /** The share a token names, while it is open and its browser has not ended. */\n const find = (key: string): Share | undefined => {\n const share = shares.get(key);\n if (share?.browser.ended) { revoke(key); return undefined; }\n return share;\n };\n\n const headers = (extra: Record<string, string> = {}): Record<string, string> => ({\n 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff',\n 'content-security-policy': `default-src 'none'; frame-ancestors ${ancestors}; base-uri 'none'; form-action 'none'`, ...extra,\n });\n const text = (status: number, body: string) => new Response(body, { status, headers: headers({ 'content-type': 'text/plain; charset=utf-8' }) });\n\n const stream = async (share: Share): Promise<Response> => {\n if (share.casts.size >= maxViewers) return text(429, 'Too many people are watching this browser.');\n let cast: Screencast;\n try { cast = await browserScreencast(share.browser, { ...castOptions, interact: share.interact }); }\n catch { return text(410, 'This browser cannot be shown.'); }\n // Revoked while connecting.\n if (share.revoked) { cast.close(); return text(404, 'Not found.'); }\n share.casts.add(cast);\n const body = new ReadableStream<Uint8Array>({\n pull: async controller => {\n const frame = await cast.next();\n if (!frame) { share.casts.delete(cast); controller.close(); return; }\n controller.enqueue(encoder.encode(`--${boundary}\\r\\nContent-Type: image/jpeg\\r\\nContent-Length: ${frame.data.byteLength}\\r\\n\\r\\n`));\n controller.enqueue(frame.data); controller.enqueue(encoder.encode('\\r\\n'));\n },\n cancel: () => { share.casts.delete(cast); cast.close(); },\n }, { highWaterMark: 0 });\n return new Response(body, { status: 200, headers: headers({ 'content-type': `multipart/x-mixed-replace; boundary=${boundary}` }) });\n };\n\n const input = async (share: Share, request: Request): Promise<Response> => {\n if (!share.interact) return text(403, 'This link is view only.');\n // A JSON body cannot be sent across sites without a preflight, which is never allowed; nor from another site.\n if (!/^application\\/json(?:;|$)/iu.test(request.headers.get('content-type') ?? '')) return text(415, 'Send JSON.');\n const site = request.headers.get('sec-fetch-site');\n if (site !== null && site !== 'same-origin') return text(403, 'Not from this page.');\n const now = Date.now();\n share.inputs = share.inputs.filter(time => now - time < 1_000);\n if (share.inputs.length >= maxInput) return text(429, 'Too many inputs.');\n share.inputs.push(now);\n const raw = await bodyText(request, 4_096);\n if (raw === undefined) return text(413, 'Too large.');\n let event: ViewerInput;\n try { event = JSON.parse(raw) as ViewerInput; } catch { return text(400, 'Send JSON.'); }\n // Input goes to the page as the newest viewer shows it.\n const cast = [...share.casts].at(-1);\n if (!cast) return text(409, 'Nothing is being watched.');\n try { await cast.input(event); }\n 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.'); }\n return new Response(null, { status: 204, headers: headers() });\n };\n\n return {\n share: async (browser: Browser, shareOptions: ViewerShareOptions = {}) => {\n if (closed) throw new MayuraError('INVALID_CONFIG', 'The viewer was closed.');\n if (!browser || typeof browser.tabs !== 'function') throw new MayuraError('INVALID_CONFIG', 'share() needs a browser.');\n 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.`);\n if (browser.ended) throw new MayuraError('INVALID_INPUT', 'The browser has ended.');\n if (shareOptions.interact !== undefined && typeof shareOptions.interact !== 'boolean') throw new MayuraError('INVALID_CONFIG', 'share(): interact must be a boolean.');\n const expiresInMs = shareOptions.expiresInMs ?? 3_600_000;\n if (!Number.isSafeInteger(expiresInMs) || expiresInMs < 1_000 || expiresInMs > 86_400_000) throw new MayuraError('INVALID_CONFIG', 'share(): expiresInMs is 1 s to 24 hours.');\n for (const key of [...shares.keys()]) find(key);\n if (shares.size >= maxShares) throw new MayuraError('LIMIT_EXCEEDED', 'Too many shares are open: revoke one first.');\n const token = base64Url(crypto.getRandomValues(new Uint8Array(32)));\n const key = await digest(token);\n const expiresAt = Date.now() + expiresInMs;\n const timer = setTimeout(() => revoke(key), expiresInMs);\n // An open link does not keep the process running.\n (timer as { unref?: () => void }).unref?.();\n shares.set(key, { browser, interact: shareOptions.interact === true, timer, casts: new Set(), inputs: [], revoked: false });\n return Object.freeze({ path: `${basePath}v/${token}/`, interact: shareOptions.interact === true, expiresAt, revoke: () => revoke(key) });\n },\n handle: async (request: Request) => {\n const { pathname } = new URL(request.url);\n if (!pathname.startsWith(`${basePath}v/`)) return undefined;\n const match = /^([A-Za-z0-9_-]{43})\\/(stream|input)?$/u.exec(pathname.slice(basePath.length + 2));\n const share = match ? find(await digest(match[1]!)) : undefined;\n if (!match || !share) return text(404, 'Not found.');\n const part = match[2];\n if (part === 'input') return request.method === 'POST' ? input(share, request) : text(405, 'Use POST.');\n if (request.method !== 'GET') return text(405, 'Use GET.');\n if (part === 'stream') return stream(share);\n const nonce = base64Url(crypto.getRandomValues(new Uint8Array(16)));\n return new Response(viewerPage({ interact: share.interact, nonce }), { status: 200, headers: headers({\n 'content-type': 'text/html; charset=utf-8',\n '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'`,\n }) });\n },\n get size() { for (const key of [...shares.keys()]) find(key); return shares.size; },\n close: () => { closed = true; for (const key of [...shares.keys()]) revoke(key); },\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mayurajs/browser-viewer",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "A live viewer for mayura/browser browsers: a screencast of the active tab, shared by link, optionally interactive.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Aryabh",
|
|
7
|
+
"email": "dev@rokad.co"
|
|
8
|
+
},
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/KartikeyAI/mayura.git",
|
|
13
|
+
"directory": "extensions/browser-viewer"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/KartikeyAI/mayura#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/KartikeyAI/mayura/issues"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22.12.0 <23 || >=24.14.1 <25"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src/**/*.ts",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"NOTICE"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"mayura": "^1.2.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"provenance": true
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { browserScreencast } from './screencast.js';
|
|
2
|
+
export type { Screencast, ScreencastFrame, ScreencastOptions, ViewerInput } from './screencast.js';
|
|
3
|
+
export { browserViewer } from './viewer.js';
|
|
4
|
+
export type { BrowserViewer, BrowserViewerOptions, ViewerShare, ViewerShareOptions } from './viewer.js';
|
|
5
|
+
export { serveBrowserViewer } from './serve.js';
|
|
6
|
+
export type { ServeBrowserViewerOptions, ServedBrowserViewer } from './serve.js';
|
package/src/page.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The viewer page: the stream as an image, and with interact, the person's mouse and keys sent back as input. Its
|
|
2
|
+
// script and style run only by the page's nonce; it reaches nothing but its own stream and input.
|
|
3
|
+
const script = `
|
|
4
|
+
const view = document.getElementById('view'); const state = document.getElementById('state');
|
|
5
|
+
const send = event => fetch('input', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(event) }).catch(() => undefined);
|
|
6
|
+
const at = event => { const box = view.getBoundingClientRect(); return { x: Math.min(1, Math.max(0, (event.clientX - box.left) / box.width)), y: Math.min(1, Math.max(0, (event.clientY - box.top) / box.height)) }; };
|
|
7
|
+
const mods = event => (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
|
|
8
|
+
const button = event => ['left', 'middle', 'right'][event.button] ?? 'left';
|
|
9
|
+
let moved = 0;
|
|
10
|
+
view.addEventListener('mousedown', event => { event.preventDefault(); view.focus(); send({ type: 'mouse', action: 'down', ...at(event), button: button(event), clicks: Math.min(3, Math.max(1, event.detail)), modifiers: mods(event) }); });
|
|
11
|
+
view.addEventListener('mouseup', event => { event.preventDefault(); send({ type: 'mouse', action: 'up', ...at(event), button: button(event), clicks: Math.min(3, Math.max(1, event.detail)), modifiers: mods(event) }); });
|
|
12
|
+
view.addEventListener('mousemove', event => { const now = Date.now(); if (now - moved < 50) return; moved = now; send({ type: 'mouse', action: 'move', ...at(event), modifiers: mods(event) }); });
|
|
13
|
+
view.addEventListener('wheel', event => { event.preventDefault(); send({ type: 'wheel', ...at(event), dx: Math.round(event.deltaX), dy: Math.round(event.deltaY) }); }, { passive: false });
|
|
14
|
+
view.addEventListener('contextmenu', event => event.preventDefault());
|
|
15
|
+
for (const action of ['down', 'up']) view.addEventListener('key' + action, event => {
|
|
16
|
+
if (event.isComposing) return;
|
|
17
|
+
event.preventDefault(); send({ type: 'key', action, key: event.key, modifiers: mods(event) });
|
|
18
|
+
});
|
|
19
|
+
view.addEventListener('paste', event => { const text = event.clipboardData?.getData('text/plain'); if (text) { event.preventDefault(); send({ type: 'text', text: text.slice(0, 1000) }); } });
|
|
20
|
+
state.textContent = 'Live: you can use this browser. Click the page to type into it.';
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export function viewerPage({ interact, nonce }: { readonly interact: boolean; readonly nonce: string }): string {
|
|
24
|
+
return `<!doctype html>
|
|
25
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer">
|
|
26
|
+
<title>Browser viewer</title>
|
|
27
|
+
<style nonce="${nonce}">
|
|
28
|
+
:root { color-scheme: light dark; --bg: #f6f6f4; --fg: #1d1d1b; --muted: #6b6b66; --edge: #d9d9d4; }
|
|
29
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #151514; --fg: #ecece8; --muted: #9a9a94; --edge: #33332f; } }
|
|
30
|
+
* { box-sizing: border-box; }
|
|
31
|
+
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, sans-serif; }
|
|
32
|
+
header { padding: 8px 16px; color: var(--muted); border-bottom: 1px solid var(--edge); }
|
|
33
|
+
main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 16px; }
|
|
34
|
+
img { max-width: 100%; max-height: calc(100vh - 80px); border: 1px solid var(--edge); background: #fff; outline: none; }
|
|
35
|
+
img:focus-visible { outline: 2px solid #3b6fd8; }
|
|
36
|
+
</style></head>
|
|
37
|
+
<body><header id="state">Live: view only.</header>
|
|
38
|
+
<main><img id="view" src="stream" alt="The browser's page, live"${interact ? ' tabindex="0"' : ''}></main>
|
|
39
|
+
${interact ? `<script nonce="${nonce}">${script}</script>` : ''}
|
|
40
|
+
</body></html>`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { MayuraError } from 'mayura';
|
|
2
|
+
import { connectCdp, type Browser, type CdpConnection, type CdpSocketFactory } from 'mayura/browser';
|
|
3
|
+
|
|
4
|
+
export interface ScreencastOptions {
|
|
5
|
+
/** JPEG quality, 1 to 100; 60 by default. */
|
|
6
|
+
readonly quality?: number;
|
|
7
|
+
/** The widest frame, in pixels; 1,280 by default (320 to 3,840). */
|
|
8
|
+
readonly maxWidth?: number;
|
|
9
|
+
/** The tallest frame, in pixels; 800 by default (240 to 2,160). */
|
|
10
|
+
readonly maxHeight?: number;
|
|
11
|
+
/** The most frames a second; 5 by default (1 to 30). */
|
|
12
|
+
readonly maxFps?: number;
|
|
13
|
+
/** Frames larger than this are skipped, in bytes; 2 MiB by default. */
|
|
14
|
+
readonly maxFrameBytes?: number;
|
|
15
|
+
/** Lets `input` use the page, as a person would: off by default. */
|
|
16
|
+
readonly interact?: boolean;
|
|
17
|
+
/** For runtimes whose WebSocket cannot send the headers a browser's CDP connection needs. */
|
|
18
|
+
readonly webSocket?: CdpSocketFactory;
|
|
19
|
+
/** Ends the screencast. */
|
|
20
|
+
readonly signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ScreencastFrame {
|
|
24
|
+
/** A JPEG of the page as shown. */
|
|
25
|
+
readonly data: Uint8Array;
|
|
26
|
+
readonly mediaType: 'image/jpeg';
|
|
27
|
+
/** The page's size in CSS pixels, which `input` coordinates are fractions of. */
|
|
28
|
+
readonly width: number;
|
|
29
|
+
readonly height: number;
|
|
30
|
+
/** The tab shown, as `browser.tabs()` names it. */
|
|
31
|
+
readonly tab: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a person does in the viewer. Coordinates are fractions of the frame, 0 to 1 from the top left. */
|
|
35
|
+
export type ViewerInput =
|
|
36
|
+
| { readonly type: 'mouse'; readonly action: 'down' | 'up' | 'move'; readonly x: number; readonly y: number; readonly button?: 'left' | 'middle' | 'right'; readonly clicks?: number; readonly modifiers?: number }
|
|
37
|
+
| { readonly type: 'wheel'; readonly x: number; readonly y: number; readonly dx: number; readonly dy: number }
|
|
38
|
+
| { readonly type: 'key'; readonly action: 'down' | 'up'; readonly key: string; readonly modifiers?: number }
|
|
39
|
+
| { readonly type: 'text'; readonly text: string };
|
|
40
|
+
|
|
41
|
+
/** The frames of a browser's active tab as it changes, following the tab the browser has active. */
|
|
42
|
+
export interface Screencast extends AsyncIterable<ScreencastFrame> {
|
|
43
|
+
/** The next frame, waiting for one; undefined once the screencast ended. */
|
|
44
|
+
next(): Promise<ScreencastFrame | undefined>;
|
|
45
|
+
/** Uses the page shown, for screencasts with `interact`. */
|
|
46
|
+
input(event: ViewerInput, options?: { readonly signal?: AbortSignal }): Promise<void>;
|
|
47
|
+
/** True once closed, or once its browser or connection ended. */
|
|
48
|
+
readonly ended: boolean;
|
|
49
|
+
close(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const named: Readonly<Record<string, { readonly code: string; readonly keyCode: number; readonly text?: string }>> = {
|
|
53
|
+
Enter: { code: 'Enter', keyCode: 13, text: '\r' }, Tab: { code: 'Tab', keyCode: 9 }, Escape: { code: 'Escape', keyCode: 27 },
|
|
54
|
+
Backspace: { code: 'Backspace', keyCode: 8 }, Delete: { code: 'Delete', keyCode: 46 },
|
|
55
|
+
ArrowUp: { code: 'ArrowUp', keyCode: 38 }, ArrowDown: { code: 'ArrowDown', keyCode: 40 }, ArrowLeft: { code: 'ArrowLeft', keyCode: 37 }, ArrowRight: { code: 'ArrowRight', keyCode: 39 },
|
|
56
|
+
Home: { code: 'Home', keyCode: 36 }, End: { code: 'End', keyCode: 35 }, PageUp: { code: 'PageUp', keyCode: 33 }, PageDown: { code: 'PageDown', keyCode: 34 },
|
|
57
|
+
Shift: { code: 'ShiftLeft', keyCode: 16 }, Control: { code: 'ControlLeft', keyCode: 17 }, Alt: { code: 'AltLeft', keyCode: 18 }, Meta: { code: 'MetaLeft', keyCode: 91 },
|
|
58
|
+
};
|
|
59
|
+
const buttons = { left: 'left', middle: 'middle', right: 'right' } as const;
|
|
60
|
+
const mouseTypes = { down: 'mousePressed', up: 'mouseReleased', move: 'mouseMoved' } as const;
|
|
61
|
+
|
|
62
|
+
function bound(value: number | undefined, name: string, fallback: number, min: number, max: number): number {
|
|
63
|
+
const result = value ?? fallback;
|
|
64
|
+
if (!Number.isSafeInteger(result) || result < min || result > max) throw new MayuraError('INVALID_CONFIG', `browserScreencast(): ${name} is ${min.toLocaleString('en-US')} to ${max.toLocaleString('en-US')}.`);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
const fraction = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
68
|
+
const modifiersOf = (value: unknown): number => {
|
|
69
|
+
if (value === undefined) return 0;
|
|
70
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > 15) throw new MayuraError('INVALID_INPUT', 'modifiers is 0 to 15 (Alt 1, Control 2, Meta 4, Shift 8).');
|
|
71
|
+
return value as number;
|
|
72
|
+
};
|
|
73
|
+
function decode(base64: string): Uint8Array {
|
|
74
|
+
const binary = atob(base64); const bytes = new Uint8Array(binary.length);
|
|
75
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
76
|
+
return bytes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A screencast of a `mayura/browser` browser: the active tab's frames as JPEGs, over a CDP connection of its own, and,
|
|
81
|
+
* with `interact`, a way for a person to use the page. Frames come no faster than `maxFps`, and only as fast as they
|
|
82
|
+
* are taken. The browser's origins hold for whatever a person does. It needs a browser other clients can join
|
|
83
|
+
* (`browser.cdp`).
|
|
84
|
+
*/
|
|
85
|
+
export async function browserScreencast(browser: Browser, options: ScreencastOptions = {}): Promise<Screencast> {
|
|
86
|
+
if (!browser || typeof browser.tabs !== 'function') throw new MayuraError('INVALID_CONFIG', 'browserScreencast() needs a browser.');
|
|
87
|
+
const quality = bound(options.quality, 'quality', 60, 1, 100);
|
|
88
|
+
const maxWidth = bound(options.maxWidth, 'maxWidth', 1_280, 320, 3_840);
|
|
89
|
+
const maxHeight = bound(options.maxHeight, 'maxHeight', 800, 240, 2_160);
|
|
90
|
+
const maxFps = bound(options.maxFps, 'maxFps', 5, 1, 30);
|
|
91
|
+
const maxFrameBytes = bound(options.maxFrameBytes, 'maxFrameBytes', 2_097_152, 65_536, 16_777_216);
|
|
92
|
+
if (options.interact !== undefined && typeof options.interact !== 'boolean') throw new MayuraError('INVALID_CONFIG', 'browserScreencast(): interact must be a boolean.');
|
|
93
|
+
const cdp = browser.cdp;
|
|
94
|
+
if (!cdp) throw new MayuraError('INVALID_CONFIG', `The ${browser.provider} provider's browsers cannot be joined: each connection starts a browser of its own.`);
|
|
95
|
+
if (browser.ended) throw new MayuraError('INVALID_INPUT', 'The browser has ended.');
|
|
96
|
+
|
|
97
|
+
const connection: CdpConnection = await connectCdp(cdp.url, { headers: cdp.headers, ...(options.webSocket ? { webSocket: options.webSocket } : {}), ...(options.signal ? { signal: options.signal } : {}) });
|
|
98
|
+
let ended = false;
|
|
99
|
+
let shown: { readonly tab: string; readonly sessionId: string } | undefined;
|
|
100
|
+
let size = { width: 0, height: 0 };
|
|
101
|
+
let waiting: { readonly frame: ScreencastFrame; readonly ack: number; readonly sessionId: string } | undefined;
|
|
102
|
+
let taker: ((frame: ScreencastFrame | undefined) => void) | undefined;
|
|
103
|
+
let lastAck = 0; let ackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
let followTimer: ReturnType<typeof setTimeout> | undefined; let failures = 0;
|
|
105
|
+
|
|
106
|
+
const end = () => {
|
|
107
|
+
if (ended) return;
|
|
108
|
+
ended = true;
|
|
109
|
+
clearTimeout(ackTimer); clearTimeout(followTimer);
|
|
110
|
+
options.signal?.removeEventListener('abort', end);
|
|
111
|
+
waiting = undefined; const take = taker; taker = undefined; take?.(undefined);
|
|
112
|
+
connection.close();
|
|
113
|
+
};
|
|
114
|
+
options.signal?.addEventListener('abort', end, { once: true });
|
|
115
|
+
void connection.closed.then(end);
|
|
116
|
+
|
|
117
|
+
const ack = (sessionId: string, id: number) => { void connection.send('Page.screencastFrameAck', { sessionId: id }, { sessionId }).catch(() => undefined); };
|
|
118
|
+
const offFrames = connection.on('Page.screencastFrame', (params, sessionId) => {
|
|
119
|
+
if (ended || !shown || sessionId !== shown.sessionId) return;
|
|
120
|
+
const id = params['sessionId']; const data = params['data']; const metadata = params['metadata'] as { deviceWidth?: unknown; deviceHeight?: unknown } | undefined;
|
|
121
|
+
if (typeof id !== 'number' || typeof data !== 'string') return;
|
|
122
|
+
// Base64 is 4/3 of the bytes: a frame too large is skipped, unread.
|
|
123
|
+
if (data.length > Math.ceil(maxFrameBytes / 3) * 4) { ack(sessionId, id); return; }
|
|
124
|
+
const width = typeof metadata?.deviceWidth === 'number' && metadata.deviceWidth > 0 ? metadata.deviceWidth : size.width;
|
|
125
|
+
const height = typeof metadata?.deviceHeight === 'number' && metadata.deviceHeight > 0 ? metadata.deviceHeight : size.height;
|
|
126
|
+
let bytes: Uint8Array;
|
|
127
|
+
try { bytes = decode(data); } catch { ack(sessionId, id); return; }
|
|
128
|
+
size = { width, height };
|
|
129
|
+
const frame: ScreencastFrame = Object.freeze({ data: bytes, mediaType: 'image/jpeg' as const, width, height, tab: shown.tab });
|
|
130
|
+
if (taker) { const take = taker; taker = undefined; take(frame); schedule(sessionId, id); }
|
|
131
|
+
else waiting = { frame, ack: id, sessionId };
|
|
132
|
+
});
|
|
133
|
+
void connection.closed.then(offFrames);
|
|
134
|
+
// The next frame is asked for once this one is taken, and no sooner than maxFps allows.
|
|
135
|
+
const schedule = (sessionId: string, id: number) => {
|
|
136
|
+
const wait = Math.max(0, lastAck + 1_000 / maxFps - Date.now());
|
|
137
|
+
ackTimer = setTimeout(() => { lastAck = Date.now(); ack(sessionId, id); }, wait);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const show = async (tab: string) => {
|
|
141
|
+
const previous = shown; shown = undefined; waiting = undefined;
|
|
142
|
+
if (previous) await connection.send('Target.detachFromTarget', { sessionId: previous.sessionId }).catch(() => undefined);
|
|
143
|
+
const { sessionId } = await connection.send<{ sessionId: string }>('Target.attachToTarget', { targetId: tab, flatten: true });
|
|
144
|
+
shown = { tab, sessionId };
|
|
145
|
+
await connection.send('Page.startScreencast', { format: 'jpeg', quality, maxWidth, maxHeight, everyNthFrame: 1 }, { sessionId });
|
|
146
|
+
};
|
|
147
|
+
// Follows the tab the browser has active, as its agent moves between tabs.
|
|
148
|
+
const follow = async () => {
|
|
149
|
+
if (ended) return;
|
|
150
|
+
if (browser.ended) { end(); return; }
|
|
151
|
+
try {
|
|
152
|
+
const active = (await browser.tabs(options.signal ? { signal: options.signal } : {})).find(tab => tab.active);
|
|
153
|
+
if (active && active.tab !== shown?.tab) await show(active.tab);
|
|
154
|
+
failures = 0;
|
|
155
|
+
} catch {
|
|
156
|
+
if (browser.ended || ++failures >= 3) { end(); return; }
|
|
157
|
+
}
|
|
158
|
+
if (!ended) followTimer = setTimeout(() => void follow(), 1_000);
|
|
159
|
+
};
|
|
160
|
+
await follow();
|
|
161
|
+
|
|
162
|
+
const next = (): Promise<ScreencastFrame | undefined> => {
|
|
163
|
+
if (ended) return Promise.resolve(undefined);
|
|
164
|
+
if (waiting) { const { frame, ack: id, sessionId } = waiting; waiting = undefined; schedule(sessionId, id); return Promise.resolve(frame); }
|
|
165
|
+
if (taker) return Promise.reject(new MayuraError('INVALID_INPUT', 'Take one frame at a time.'));
|
|
166
|
+
return new Promise(resolve => { taker = resolve; });
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const input = async (event: ViewerInput, inputOptions: { readonly signal?: AbortSignal } = {}): Promise<void> => {
|
|
170
|
+
if (options.interact !== true) throw new MayuraError('PERMISSION_DENIED', 'This screencast is view only.');
|
|
171
|
+
if (ended) throw new MayuraError('INVALID_INPUT', 'The screencast has ended.');
|
|
172
|
+
const on = shown;
|
|
173
|
+
if (!on || size.width === 0) throw new MayuraError('INVALID_INPUT', 'No page is shown yet.');
|
|
174
|
+
const send = (method: string, params: Record<string, unknown>) => connection.send(method, params, { sessionId: on.sessionId, ...(inputOptions.signal ? { signal: inputOptions.signal } : {}) });
|
|
175
|
+
const item = event as Record<string, unknown> | null;
|
|
176
|
+
if (!item || typeof item !== 'object') throw new MayuraError('INVALID_INPUT', 'An input is an object with a type.');
|
|
177
|
+
if (item['type'] === 'mouse' || item['type'] === 'wheel') {
|
|
178
|
+
if (!fraction(item['x']) || !fraction(item['y'])) throw new MayuraError('INVALID_INPUT', 'x and y are fractions of the frame, 0 to 1.');
|
|
179
|
+
const point = { x: Math.round(item['x'] * size.width), y: Math.round(item['y'] * size.height) };
|
|
180
|
+
if (item['type'] === 'wheel') {
|
|
181
|
+
const { dx, dy } = item;
|
|
182
|
+
if (typeof dx !== 'number' || typeof dy !== 'number' || !Number.isFinite(dx) || !Number.isFinite(dy) || Math.abs(dx) > 10_000 || Math.abs(dy) > 10_000) throw new MayuraError('INVALID_INPUT', 'dx and dy are pixels, -10,000 to 10,000.');
|
|
183
|
+
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', ...point, deltaX: dx, deltaY: dy });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const type = mouseTypes[item['action'] as keyof typeof mouseTypes];
|
|
187
|
+
const button = item['button'] === undefined ? 'left' : buttons[item['button'] as keyof typeof buttons];
|
|
188
|
+
const clicks = item['clicks'] ?? 1;
|
|
189
|
+
if (!Object.hasOwn(mouseTypes, item['action'] as string) || !button || !Object.hasOwn(buttons, button) || !Number.isSafeInteger(clicks) || (clicks as number) < 1 || (clicks as number) > 3) {
|
|
190
|
+
throw new MayuraError('INVALID_INPUT', 'A mouse input has action down, up or move, a button left, middle or right, and clicks 1 to 3.');
|
|
191
|
+
}
|
|
192
|
+
await send('Input.dispatchMouseEvent', { type, ...point, modifiers: modifiersOf(item['modifiers']), ...(type === 'mouseMoved' ? {} : { button, clickCount: clicks }) });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (item['type'] === 'key') {
|
|
196
|
+
const key = item['key'];
|
|
197
|
+
if ((item['action'] !== 'down' && item['action'] !== 'up') || typeof key !== 'string') throw new MayuraError('INVALID_INPUT', 'A key input has action down or up, and a key.');
|
|
198
|
+
const modifiers = modifiersOf(item['modifiers']);
|
|
199
|
+
const definition = Object.hasOwn(named, key) ? named[key]! : [...key].length === 1 && /^[^\p{Cc}]$/u.test(key) ? {
|
|
200
|
+
code: /^[a-z]$/iu.test(key) ? `Key${key.toUpperCase()}` : /^\d$/u.test(key) ? `Digit${key}` : '', keyCode: /^[a-z\d]$/iu.test(key) ? key.toUpperCase().charCodeAt(0) : 0,
|
|
201
|
+
// Typed as text unless Control, Alt or Meta is held.
|
|
202
|
+
...(modifiers & 7 ? {} : { text: key }),
|
|
203
|
+
} : undefined;
|
|
204
|
+
if (!definition) throw new MayuraError('INVALID_INPUT', 'key is one character, or a key such as Enter, Tab, Backspace or ArrowDown.');
|
|
205
|
+
const common = { key, code: definition.code, windowsVirtualKeyCode: definition.keyCode, modifiers };
|
|
206
|
+
await send('Input.dispatchKeyEvent', item['action'] === 'up' ? { type: 'keyUp', ...common }
|
|
207
|
+
: definition.text !== undefined ? { type: 'keyDown', ...common, text: definition.text, unmodifiedText: definition.text } : { type: 'rawKeyDown', ...common });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (item['type'] === 'text') {
|
|
211
|
+
if (typeof item['text'] !== 'string' || item['text'].length === 0 || item['text'].length > 1_000) throw new MayuraError('INVALID_INPUT', 'text is 1 to 1,000 characters.');
|
|
212
|
+
await send('Input.insertText', { text: item['text'] });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
throw new MayuraError('INVALID_INPUT', 'type is mouse, wheel, key or text.');
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const screencast: Screencast = {
|
|
219
|
+
next, input,
|
|
220
|
+
get ended() { return ended; },
|
|
221
|
+
close: end,
|
|
222
|
+
[Symbol.asyncIterator]: () => ({
|
|
223
|
+
next: async () => { const frame = await next(); return frame ? { value: frame, done: false } : { value: undefined, done: true }; },
|
|
224
|
+
return: async () => { end(); return { value: undefined, done: true }; },
|
|
225
|
+
}),
|
|
226
|
+
};
|
|
227
|
+
return screencast;
|
|
228
|
+
}
|