@browserless/capture 10.11.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.md ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2019 Microlink <hello@microlink.io> (microlink.io)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ <div align="center">
2
+ <img style="width: 500px; margin:3rem 0 1.5rem;" src="https://github.com/microlinkhq/browserless/raw/master/static/logo-banner.png#gh-light-mode-only" alt="browserless">
3
+ <img style="width: 500px; margin:3rem 0 1.5rem;" src="https://github.com/microlinkhq/browserless/raw/master/static/logo-banner-light.png#gh-dark-mode-only" alt="browserless">
4
+ <br><br>
5
+ <a href="https://microlink.io"><img src="https://img.shields.io/badge/powered_by-microlink.io-blue?style=flat-square&color=%23EA407B" alt="Powered by microlink.io"></a>
6
+ <img src="https://img.shields.io/github/tag/microlinkhq/browserless.svg?style=flat-square" alt="Last version">
7
+ <a href="https://coveralls.io/github/microlinkhq/browserless"><img src="https://img.shields.io/coveralls/microlinkhq/browserless.svg?style=flat-square" alt="Coverage Status"></a>
8
+ <a href="https://www.npmjs.org/package/@browserless/capture"><img src="https://img.shields.io/npm/dm/@browserless/capture.svg?style=flat-square" alt="NPM Status"></a>
9
+ <br><br>
10
+ </div>
11
+
12
+ > @browserless/capture: Record a Puppeteer page using the extension + `tabCapture` approach.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ npm install @browserless/capture --save
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```js
23
+ const createBrowser = require('browserless')
24
+ const createCapture = require('@browserless/capture')
25
+
26
+ const browser = createBrowser({
27
+ headless: 'new',
28
+ ignoreDefaultArgs: ['--disable-extensions'],
29
+ args: [
30
+ '--screen-info={2560x1600 devicePixelRatio=2}',
31
+ `--allowlisted-extension-id=${createCapture.extensionId}`,
32
+ `--disable-extensions-except=${createCapture.extensionPath}`,
33
+ `--load-extension=${createCapture.extensionPath}`
34
+ ]
35
+ })
36
+
37
+ const browserless = await browser.createContext()
38
+ const page = await browserless.page()
39
+ const capture = createCapture({ goto: browserless.goto })
40
+
41
+ const video = await capture(page)('https://example.com', {
42
+ duration: 5000,
43
+ type: 'mp4',
44
+ path: '/tmp/demo.mp4'
45
+ })
46
+ ```
47
+
48
+ API shape is intentionally simple, similar to `page.screenshot()`/`page.pdf()`:
49
+
50
+ ```js
51
+ const capture = createCapture({ goto })
52
+ await capture(page)(url, opts)
53
+ ```
54
+
55
+ Returns a `Buffer` and writes to `opts.path` when provided.
56
+
57
+ ## Options
58
+
59
+ | Option | Type | Default | Description |
60
+ | --- | --- | --- | --- |
61
+ | `type` | `'webm' \| 'mp4'` | `'webm'` | Output type selector mapped to MediaRecorder mime type. |
62
+ | `path` | `string` | `undefined` | Write the captured media to disk. |
63
+ | `duration` | `number` | `3000` | Capture duration in milliseconds. |
64
+ | `audio` | `boolean \| object` | `false` | Capture audio. When object, it is used as audio track constraints. |
65
+ | `video` | `boolean \| object` | `true` | Capture video. When object, it is used as video track constraints. |
66
+
67
+ ## Exports
68
+
69
+ - `capture.extensionPath`: Absolute path to the bundled extension.
70
+ - `capture.extensionId`: Extension ID used by the package.
71
+ - `capture.types`: Supported values for `type`.
72
+
73
+ `capture` uses `goto(...).device.viewport` as the capture viewport source.
74
+ When `video` is `true` or omitted, video constraints are inferred from that viewport to keep capture framing aligned with screenshot/pdf rendering.
75
+ When `video` is an object, that object is used as the video constraints.
76
+ When `audio` is an object, that object is used as the audio constraints.
77
+ The inferred constraints also account for `deviceScaleFactor`, so output video pixels match screenshot pixel density.
78
+ Bitrate hints are not configurable; capture uses Chrome MediaRecorder defaults.
79
+ MediaRecorder chunk size is internal and fixed at `250ms`.
80
+ `type` is mapped internally to the MediaRecorder mime type.
81
+ When `type` is `'mp4'`, the running Chromium build must support MP4 MediaRecorder output.
82
+ For strict screenshot/poster parity in headless mode, launch Chrome with matching `--screen-info`.
83
+
84
+ ## License
85
+
86
+ **@browserless/capture** © [Microlink](https://microlink.io), released under the [MIT](https://github.com/microlinkhq/browserless/blob/master/LICENSE.md) License.
@@ -0,0 +1,115 @@
1
+ /* global chrome */
2
+
3
+ const MESSAGE_KEY = '__browserless_capture__'
4
+ const OFFSCREEN_PATH = 'offscreen.html'
5
+ const MESSAGE_TIMEOUT = 10_000
6
+ const OFFSCREEN_ALREADY_EXISTS_RE = /already exists|single offscreen document/i
7
+
8
+ let offscreenDocumentPromise
9
+
10
+ const sendToOffscreen = payload =>
11
+ new Promise((resolve, reject) => {
12
+ const timer = setTimeout(() => {
13
+ reject(new Error(`Timed out waiting for offscreen response after ${MESSAGE_TIMEOUT}ms`))
14
+ }, MESSAGE_TIMEOUT)
15
+
16
+ const done = callback => value => {
17
+ clearTimeout(timer)
18
+ callback(value)
19
+ }
20
+
21
+ chrome.runtime.sendMessage(
22
+ {
23
+ [MESSAGE_KEY]: true,
24
+ ...payload
25
+ },
26
+ done(response => {
27
+ if (chrome.runtime.lastError) {
28
+ return reject(new Error(chrome.runtime.lastError.message))
29
+ }
30
+
31
+ if (!response || !response.ok) {
32
+ return reject(new Error((response && response.error) || 'No response from offscreen.'))
33
+ }
34
+
35
+ resolve(response.value)
36
+ })
37
+ )
38
+ })
39
+
40
+ const getMediaStreamId = tabId =>
41
+ new Promise((resolve, reject) => {
42
+ if (!Number.isInteger(tabId)) {
43
+ reject(new TypeError('Missing tab id for recording session.'))
44
+ return
45
+ }
46
+
47
+ chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, streamId => {
48
+ if (chrome.runtime.lastError || !streamId) {
49
+ return reject(
50
+ new Error(chrome.runtime.lastError?.message || 'Unable to obtain tab media stream id')
51
+ )
52
+ }
53
+
54
+ resolve(streamId)
55
+ })
56
+ })
57
+
58
+ const getOffscreenContexts = async offscreenUrl => {
59
+ if (typeof chrome.runtime.getContexts !== 'function') return []
60
+ return chrome.runtime
61
+ .getContexts({
62
+ contextTypes: ['OFFSCREEN_DOCUMENT'],
63
+ documentUrls: [offscreenUrl]
64
+ })
65
+ .catch(() => [])
66
+ }
67
+
68
+ const ensureOffscreenDocument = async () => {
69
+ if (!chrome.offscreen || typeof chrome.offscreen.createDocument !== 'function') return
70
+ if (offscreenDocumentPromise) return offscreenDocumentPromise
71
+
72
+ offscreenDocumentPromise = (async () => {
73
+ const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_PATH)
74
+ const contexts = await getOffscreenContexts(offscreenUrl)
75
+ if (contexts.length > 0) return
76
+
77
+ try {
78
+ await chrome.offscreen.createDocument({
79
+ url: OFFSCREEN_PATH,
80
+ reasons: ['USER_MEDIA'],
81
+ justification: 'Record tab media without opening a visible extension tab.'
82
+ })
83
+ } catch (error) {
84
+ if (OFFSCREEN_ALREADY_EXISTS_RE.test(error?.message || '')) return
85
+
86
+ const refreshedContexts = await getOffscreenContexts(offscreenUrl)
87
+ if (refreshedContexts.length > 0) return
88
+
89
+ throw error
90
+ }
91
+ })()
92
+
93
+ try {
94
+ await offscreenDocumentPromise
95
+ } finally {
96
+ offscreenDocumentPromise = undefined
97
+ }
98
+ }
99
+
100
+ globalThis.START_RECORDING = async settings => {
101
+ const [streamId] = await Promise.all([
102
+ getMediaStreamId(settings && settings.tabId),
103
+ ensureOffscreenDocument()
104
+ ])
105
+ return sendToOffscreen({
106
+ action: 'START_RECORDING',
107
+ settings: { ...settings, streamId }
108
+ })
109
+ }
110
+
111
+ globalThis.STOP_RECORDING = async index => {
112
+ try {
113
+ await sendToOffscreen({ action: 'STOP_RECORDING', index })
114
+ } catch (error) {}
115
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "Browserless Capture",
3
+ "version": "0.1.0",
4
+ "key": "ackedhmjjinfocdcekpnbdocpmiffaac",
5
+ "manifest_version": 3,
6
+ "permissions": ["tabs", "tabCapture", "offscreen", "debugger"],
7
+ "background": { "service_worker": "background.js" }
8
+ }
@@ -0,0 +1 @@
1
+ <script src="offscreen.js"></script>
@@ -0,0 +1,185 @@
1
+ /* global chrome, WebSocket, MediaRecorder, navigator */
2
+
3
+ const MESSAGE_KEY = '__browserless_capture__'
4
+ const SOCKET_CONNECT_TIMEOUT = 10_000
5
+ const recorders = {}
6
+ const NOOP = () => {}
7
+
8
+ const toErrorMessage = error => {
9
+ if (typeof error === 'string') return error
10
+ if (error && typeof error.message === 'string') return error.message
11
+ return 'Unknown extension error'
12
+ }
13
+
14
+ const waitForSocketOpen = client =>
15
+ new Promise((resolve, reject) => {
16
+ if (client.readyState === WebSocket.OPEN) return resolve()
17
+
18
+ const timer = setTimeout(() => {
19
+ cleanup()
20
+ reject(new Error(`Timed out opening websocket after ${SOCKET_CONNECT_TIMEOUT}ms`))
21
+ }, SOCKET_CONNECT_TIMEOUT)
22
+
23
+ const cleanup = () => {
24
+ clearTimeout(timer)
25
+ client.removeEventListener('open', onOpen)
26
+ client.removeEventListener('error', onError)
27
+ client.removeEventListener('close', onClose)
28
+ }
29
+
30
+ const onOpen = () => {
31
+ cleanup()
32
+ resolve()
33
+ }
34
+
35
+ const onError = () => {
36
+ cleanup()
37
+ reject(new Error('Unable to connect to websocket recorder endpoint'))
38
+ }
39
+
40
+ const onClose = () => {
41
+ cleanup()
42
+ reject(new Error('Websocket recorder endpoint closed before opening'))
43
+ }
44
+
45
+ client.addEventListener('open', onOpen, { once: true })
46
+ client.addEventListener('error', onError, { once: true })
47
+ client.addEventListener('close', onClose, { once: true })
48
+ })
49
+
50
+ const buildTrackConstraints = ({ streamId, constraints }) => {
51
+ const source = constraints && typeof constraints === 'object' ? constraints : {}
52
+ const mandatory = source.mandatory && typeof source.mandatory === 'object' ? source.mandatory : {}
53
+ const { mandatory: _mandatory, ...rest } = source
54
+
55
+ return {
56
+ ...rest,
57
+ mandatory: {
58
+ ...mandatory,
59
+ chromeMediaSource: 'tab',
60
+ chromeMediaSourceId: streamId
61
+ }
62
+ }
63
+ }
64
+
65
+ const assertMimeTypeSupported = mimeType => {
66
+ if (!mimeType) return
67
+ if (typeof MediaRecorder.isTypeSupported !== 'function') return
68
+ if (MediaRecorder.isTypeSupported(mimeType)) return
69
+
70
+ throw new Error(`Unsupported MediaRecorder mimeType "${mimeType}" in this Chromium build.`)
71
+ }
72
+
73
+ const START_RECORDING = async ({
74
+ index,
75
+ port,
76
+ streamId,
77
+ video,
78
+ audio,
79
+ frameSize,
80
+ mimeType,
81
+ videoConstraints,
82
+ audioConstraints
83
+ }) => {
84
+ if (!port) throw new Error('Missing websocket port for recording session.')
85
+ if (!streamId) throw new Error('Missing tab media stream id for recording session.')
86
+ assertMimeTypeSupported(mimeType)
87
+
88
+ const client = new WebSocket(`ws://127.0.0.1:${port}/?index=${index}`, [])
89
+
90
+ let audioStreamConstraints = false
91
+ let videoStreamConstraints = false
92
+
93
+ if (audio) {
94
+ audioStreamConstraints = buildTrackConstraints({
95
+ streamId,
96
+ constraints: audioConstraints
97
+ })
98
+ }
99
+
100
+ if (video) {
101
+ videoStreamConstraints = buildTrackConstraints({
102
+ streamId,
103
+ constraints: videoConstraints
104
+ })
105
+ }
106
+
107
+ const mediaStreamPromise = navigator.mediaDevices.getUserMedia({
108
+ audio: audioStreamConstraints,
109
+ video: videoStreamConstraints
110
+ })
111
+
112
+ let stream
113
+ try {
114
+ ;[stream] = await Promise.all([mediaStreamPromise, waitForSocketOpen(client)])
115
+ } catch (error) {
116
+ mediaStreamPromise
117
+ .then(openedStream => openedStream.getTracks().forEach(track => track.stop()))
118
+ .catch(NOOP)
119
+
120
+ if (client.readyState === WebSocket.CONNECTING || client.readyState === WebSocket.OPEN) {
121
+ client.close()
122
+ }
123
+
124
+ throw error
125
+ }
126
+
127
+ const recorder = new MediaRecorder(stream, { mimeType })
128
+
129
+ const pending = new Set()
130
+
131
+ recorder.ondataavailable = event => {
132
+ if (!event.data.size) return
133
+
134
+ const task = (async () => {
135
+ const buffer = await event.data.arrayBuffer()
136
+ if (client.readyState === WebSocket.OPEN) client.send(buffer)
137
+ })()
138
+
139
+ pending.add(task)
140
+ task.finally(() => pending.delete(task))
141
+ }
142
+
143
+ recorder.onerror = () => recorder.stop()
144
+
145
+ recorder.onstop = async () => {
146
+ await Promise.allSettled(pending)
147
+ stream.getTracks().forEach(track => track.stop())
148
+ if (client.readyState === WebSocket.OPEN) client.close()
149
+ delete recorders[index]
150
+ }
151
+
152
+ stream.onremovetrack = () => {
153
+ try {
154
+ recorder.stop()
155
+ } catch (error) {}
156
+ }
157
+
158
+ recorders[index] = recorder
159
+ recorder.start(frameSize)
160
+ }
161
+
162
+ const STOP_RECORDING = index => {
163
+ const recorder = recorders[index]
164
+ if (!recorder || recorder.state === 'inactive') return
165
+ recorder.stop()
166
+ }
167
+
168
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
169
+ if (!message || !message[MESSAGE_KEY]) return
170
+
171
+ if (message.action === 'START_RECORDING') {
172
+ START_RECORDING(message.settings)
173
+ .then(value => sendResponse({ ok: true, value }))
174
+ .catch(error => sendResponse({ ok: false, error: toErrorMessage(error) }))
175
+ return true
176
+ }
177
+
178
+ if (message.action === 'STOP_RECORDING') {
179
+ Promise.resolve()
180
+ .then(() => STOP_RECORDING(message.index))
181
+ .then(() => sendResponse({ ok: true }))
182
+ .catch(error => sendResponse({ ok: false, error: toErrorMessage(error) }))
183
+ return true
184
+ }
185
+ })
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@browserless/capture",
3
+ "description": "Record browser pages as videos with browserless-style API: createCapture({ goto })(page)(url, opts).",
4
+ "homepage": "https://browserless.js.org/#/?id=capturepage-options",
5
+ "version": "10.11.0",
6
+ "main": "src/index.js",
7
+ "author": {
8
+ "email": "hello@microlink.io",
9
+ "name": "microlink.io",
10
+ "url": "https://microlink.io"
11
+ },
12
+ "repository": {
13
+ "directory": "packages/capture",
14
+ "type": "git",
15
+ "url": "git+https://github.com/microlinkhq/browserless.git#master"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/microlinkhq/browserless/issues"
19
+ },
20
+ "keywords": [
21
+ "browserless",
22
+ "capture",
23
+ "video",
24
+ "recording",
25
+ "webm",
26
+ "mp4",
27
+ "headless",
28
+ "chrome",
29
+ "puppeteer",
30
+ "automation"
31
+ ],
32
+ "dependencies": {
33
+ "@browserless/goto": "^10.11.0",
34
+ "debug-logfmt": "~1.4.8",
35
+ "ws": "~8.18.3"
36
+ },
37
+ "devDependencies": {
38
+ "ava": "5"
39
+ },
40
+ "engines": {
41
+ "node": ">= 12"
42
+ },
43
+ "files": [
44
+ "extension",
45
+ "src"
46
+ ],
47
+ "license": "MIT",
48
+ "ava": {
49
+ "serial": true,
50
+ "timeout": "2m",
51
+ "workerThreads": false
52
+ },
53
+ "scripts": {
54
+ "bench:runtime": "node benchmarks/runtime-startup.js",
55
+ "coverage": "exit 0",
56
+ "test": "ava"
57
+ }
58
+ }
package/src/capture.js ADDED
@@ -0,0 +1,303 @@
1
+ 'use strict'
2
+
3
+ const { setTimeout } = require('timers/promises')
4
+ const fs = require('fs/promises')
5
+ const debug = require('debug-logfmt')('browserless:capture')
6
+
7
+ const { closeServer, createWebSocketServer } = require('./util')
8
+ const extension = require('./extension')
9
+
10
+ const { INTERNAL_FRAME_SIZE, NOOP } = require('./constants')
11
+
12
+ let currentIndex = 0
13
+
14
+ const runWithDuration = async (label, fn, fields) => {
15
+ const duration = debug.duration(label)
16
+
17
+ try {
18
+ const value = await fn()
19
+ duration(fields)
20
+ return value
21
+ } catch (error) {
22
+ duration({
23
+ ...(fields || {}),
24
+ error: error && error.message
25
+ })
26
+ throw error
27
+ }
28
+ }
29
+
30
+ const createRecordingSession = ({ wss, index }) => {
31
+ const { promise, resolve, reject } = Promise.withResolvers()
32
+ let socket
33
+ let isSettled = false
34
+
35
+ const chunks = []
36
+
37
+ const done = (error, value) => {
38
+ if (isSettled) return
39
+ isSettled = true
40
+ wss.removeListener('connection', onConnection)
41
+
42
+ if (socket) {
43
+ socket.removeAllListeners('message').removeAllListeners('close').removeAllListeners('error')
44
+ }
45
+
46
+ if (error) return reject(error)
47
+ resolve(value)
48
+ }
49
+
50
+ const onConnection = (ws, req) => {
51
+ const url = new URL(req.url, 'ws://127.0.0.1')
52
+ if (url.searchParams.get('index') !== String(index)) return
53
+ socket = ws
54
+ socket
55
+ .on('message', buffer => chunks.push(buffer))
56
+ .once('error', error => done(error))
57
+ .once('close', () => done(null, Buffer.concat(chunks)))
58
+ }
59
+
60
+ wss.on('connection', onConnection)
61
+
62
+ return promise
63
+ }
64
+
65
+ const MIME_TYPES_BY_TYPE = Object.freeze({
66
+ webm: Object.freeze({
67
+ video: 'video/webm',
68
+ audio: 'audio/webm'
69
+ }),
70
+ mp4: Object.freeze({
71
+ video: 'video/mp4',
72
+ audio: 'audio/mp4'
73
+ })
74
+ })
75
+
76
+ const SUPPORTED_TYPES = Object.freeze(Object.keys(MIME_TYPES_BY_TYPE))
77
+
78
+ const getMimeType = ({ type, audio, video }) => {
79
+ const normalizedType =
80
+ type === undefined || type === null
81
+ ? 'webm'
82
+ : String(type).trim().toLowerCase().replace(/^\./, '')
83
+
84
+ const mimeTypes = MIME_TYPES_BY_TYPE[normalizedType]
85
+
86
+ if (!mimeTypes) {
87
+ throw new TypeError(
88
+ `Unsupported \`type\` "${type}". Supported types: ${SUPPORTED_TYPES.join(', ')}.`
89
+ )
90
+ }
91
+
92
+ return audio && !video ? mimeTypes.audio : mimeTypes.video
93
+ }
94
+
95
+ const getVideoConstraints = (videoConstraints, viewport) => {
96
+ if (videoConstraints) return videoConstraints
97
+
98
+ const dpr = Math.max(Number(viewport.deviceScaleFactor) || 1, 1)
99
+ const width = Math.round(viewport.width * dpr)
100
+ const height = Math.round(viewport.height * dpr)
101
+
102
+ return {
103
+ mandatory: {
104
+ minWidth: width,
105
+ minHeight: height,
106
+ maxWidth: width,
107
+ maxHeight: height
108
+ }
109
+ }
110
+ }
111
+
112
+ const isTrackObject = value => value && typeof value === 'object' && !Array.isArray(value)
113
+
114
+ const getOpts = (value, defaultValue, name) => {
115
+ const resolvedValue = value === undefined ? defaultValue : value
116
+
117
+ if (typeof resolvedValue === 'boolean') {
118
+ return {
119
+ enabled: resolvedValue,
120
+ constraints: undefined
121
+ }
122
+ }
123
+
124
+ if (isTrackObject(resolvedValue)) {
125
+ const constraints =
126
+ Object.keys(resolvedValue).length === 0
127
+ ? undefined
128
+ : resolvedValue.constraints !== undefined
129
+ ? resolvedValue.constraints
130
+ : resolvedValue
131
+
132
+ if (constraints !== undefined && !isTrackObject(constraints)) {
133
+ throw new TypeError(`Expected \`${name}.constraints\` to be an object`)
134
+ }
135
+
136
+ return {
137
+ enabled: true,
138
+ constraints
139
+ }
140
+ }
141
+
142
+ throw new TypeError(`Expected \`${name}\` to be a boolean or an object`)
143
+ }
144
+
145
+ const getTargetId = async page => {
146
+ if (!page || typeof page.target !== 'function') return
147
+
148
+ const target = page.target()
149
+ if (!target || typeof target.createCDPSession !== 'function') return
150
+
151
+ const session = await target.createCDPSession().catch(NOOP)
152
+ if (!session) return
153
+
154
+ try {
155
+ const result = await session.send('Target.getTargetInfo').catch(NOOP)
156
+ return result && result.targetInfo && result.targetInfo.targetId
157
+ } finally {
158
+ await session.detach().catch(NOOP)
159
+ }
160
+ }
161
+
162
+ module.exports = async (page, opts, viewport) => {
163
+ const { path: outputPath, duration = 3000, audio, video, type } = opts
164
+
165
+ const audioOpts = getOpts(audio, false, 'audio')
166
+ const videoOpts = getOpts(video, true, 'video')
167
+
168
+ if (!audioOpts.enabled && !videoOpts.enabled) {
169
+ throw new TypeError('At least one of `audio` or `video` must be true')
170
+ }
171
+
172
+ const browser = page.browser()
173
+ const index = currentIndex++
174
+
175
+ const streamMimeType = getMimeType({
176
+ type,
177
+ audio: audioOpts.enabled,
178
+ video: videoOpts.enabled
179
+ })
180
+
181
+ const resolvedVideoConstraints = getVideoConstraints(videoOpts.constraints, viewport)
182
+
183
+ let worker
184
+ let workerPromise
185
+ let wsServerPromise
186
+ let wss
187
+ let port
188
+ let recordingPromise
189
+ let isRecordingStarted = false
190
+ let captureError
191
+ let buffer = Buffer.alloc(0)
192
+ let targetId
193
+
194
+ try {
195
+ const targetIdPromise = runWithDuration('getPageTargetId', () => getTargetId(page))
196
+ workerPromise = runWithDuration('extension.open', () => extension.open({ browser }))
197
+ wsServerPromise = runWithDuration('createWebSocketServer', () => createWebSocketServer())
198
+
199
+ const [_targetId, _worker, wsServer] = await Promise.all([
200
+ targetIdPromise,
201
+ workerPromise,
202
+ wsServerPromise
203
+ ])
204
+
205
+ targetId = _targetId
206
+ worker = _worker
207
+ ;({ wss, port } = wsServer)
208
+ if (!targetId) throw new Error('Cannot resolve page target id.')
209
+
210
+ recordingPromise = createRecordingSession({ wss, index })
211
+
212
+ const tabId = await runWithDuration(
213
+ 'extension.getTabIdFromTargetId',
214
+ () =>
215
+ extension.getTabIdFromTargetId({
216
+ worker,
217
+ targetId
218
+ }),
219
+ { targetId }
220
+ )
221
+ if (!Number.isInteger(tabId)) {
222
+ throw new Error('Cannot resolve tab id for the current page target.')
223
+ }
224
+
225
+ await runWithDuration(
226
+ 'extension.startRecording',
227
+ () =>
228
+ extension.startRecording({
229
+ extension: worker,
230
+ settings: {
231
+ index,
232
+ port,
233
+ tabId,
234
+ video: videoOpts.enabled,
235
+ audio: audioOpts.enabled,
236
+ frameSize: INTERNAL_FRAME_SIZE,
237
+ mimeType: streamMimeType,
238
+ videoConstraints: resolvedVideoConstraints,
239
+ audioConstraints: audioOpts.constraints
240
+ }
241
+ }),
242
+ { index, tabId }
243
+ )
244
+ isRecordingStarted = true
245
+ await runWithDuration('durationwait', () => setTimeout(duration), { duration })
246
+ } catch (error) {
247
+ if (!worker && workerPromise) {
248
+ worker = await runWithDuration('awaitWorkerAfterError', () => workerPromise.catch(NOOP))
249
+ }
250
+ if (!wss && wsServerPromise) {
251
+ ;({ wss, port } =
252
+ (await runWithDuration('awaitWebSocketServerAfterError', () =>
253
+ wsServerPromise.catch(NOOP)
254
+ )) || {})
255
+ }
256
+ captureError = error
257
+ } finally {
258
+ const stopPromise = worker
259
+ ? runWithDuration(
260
+ 'extension.stopRecording',
261
+ () => extension.stopRecording({ extension: worker, index }).catch(NOOP),
262
+ { index }
263
+ )
264
+ : Promise.resolve()
265
+
266
+ if (recordingPromise) {
267
+ if (isRecordingStarted) {
268
+ const recordingResultPromise = recordingPromise.catch(error => {
269
+ if (!captureError) throw error
270
+ return Buffer.alloc(0)
271
+ })
272
+ const [, recordingResult] = await runWithDuration('stop+recordingPromise', () =>
273
+ Promise.all([stopPromise, recordingResultPromise])
274
+ )
275
+ buffer = recordingResult
276
+ } else {
277
+ recordingPromise.catch(NOOP)
278
+ await runWithDuration('stopWithoutRecording', () => stopPromise)
279
+ }
280
+ } else {
281
+ await runWithDuration('stopWithoutPromise', () => stopPromise)
282
+ }
283
+
284
+ await runWithDuration('closeServer', () => closeServer(wss))
285
+ }
286
+
287
+ if (captureError) throw captureError
288
+
289
+ if (buffer.length === 0) {
290
+ throw new Error(
291
+ 'No video data was captured. Increase `duration` or verify playback in the tab.'
292
+ )
293
+ }
294
+
295
+ if (outputPath) {
296
+ await runWithDuration('writeFile', () => fs.writeFile(outputPath, buffer), {
297
+ outputPath,
298
+ bytes: buffer.length
299
+ })
300
+ }
301
+
302
+ return buffer
303
+ }
@@ -0,0 +1,19 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+
5
+ const EXTENSION_ID = 'jjndjgheafjngoipoacpjgeicjeomjli'
6
+
7
+ const EXTENSION_PATH = path.join(__dirname, '..', 'extension')
8
+
9
+ const TYPES = Object.freeze(['webm', 'mp4'])
10
+ const INTERNAL_FRAME_SIZE = 250
11
+ const NOOP = () => {}
12
+
13
+ module.exports = {
14
+ EXTENSION_ID,
15
+ EXTENSION_PATH,
16
+ TYPES,
17
+ INTERNAL_FRAME_SIZE,
18
+ NOOP
19
+ }
@@ -0,0 +1,117 @@
1
+ 'use strict'
2
+
3
+ const { EXTENSION_ID, EXTENSION_PATH, NOOP } = require('./constants')
4
+
5
+ const BACKGROUND_PATH = `chrome-extension://${EXTENSION_ID}/background.js`
6
+ const workerRuntimes = new WeakMap()
7
+ const openingWorkerRuntimes = new WeakMap()
8
+
9
+ const isCacheableBrowser = browser =>
10
+ browser && (typeof browser === 'object' || typeof browser === 'function')
11
+
12
+ const createWorkerRuntime = browser => {
13
+ const findWorkerTarget = () => {
14
+ if (!browser || typeof browser.targets !== 'function') return
15
+ return browser
16
+ .targets()
17
+ .find(target => target.type() === 'service_worker' && target.url() === BACKGROUND_PATH)
18
+ }
19
+
20
+ const getWorker = async () => {
21
+ let target = findWorkerTarget()
22
+
23
+ if (!target && typeof browser.waitForTarget === 'function') {
24
+ target = await browser
25
+ .waitForTarget(
26
+ target => target.type() === 'service_worker' && target.url() === BACKGROUND_PATH,
27
+ { timeout: 1000 }
28
+ )
29
+ .catch(NOOP)
30
+ }
31
+
32
+ if (!target || typeof target.worker !== 'function') return
33
+ return target.worker().catch(NOOP)
34
+ }
35
+
36
+ const evaluate = async (fn, arg) => {
37
+ const worker = await getWorker()
38
+ if (!worker || typeof worker.evaluate !== 'function') {
39
+ throw new Error('Unable to access capture extension service worker runtime.')
40
+ }
41
+ return worker.evaluate(fn, arg)
42
+ }
43
+
44
+ return {
45
+ evaluate
46
+ }
47
+ }
48
+
49
+ const open = async ({ browser }) => {
50
+ if (isCacheableBrowser(browser)) {
51
+ const cachedRuntime = workerRuntimes.get(browser)
52
+ if (cachedRuntime) return cachedRuntime
53
+
54
+ const cachedOpening = openingWorkerRuntimes.get(browser)
55
+ if (cachedOpening) return cachedOpening
56
+ }
57
+
58
+ const openPromise = (async () => {
59
+ const workerRuntime = createWorkerRuntime(browser)
60
+ const isWorkerReady = await workerRuntime
61
+ .evaluate(
62
+ () =>
63
+ typeof globalThis.START_RECORDING === 'function' &&
64
+ typeof globalThis.STOP_RECORDING === 'function'
65
+ )
66
+ .catch(() => false)
67
+
68
+ if (!isWorkerReady) {
69
+ throw new Error(
70
+ `Unable to connect to capture extension service worker. Launch Chromium with extension support using \`${EXTENSION_PATH}\`.`
71
+ )
72
+ }
73
+
74
+ if (isCacheableBrowser(browser)) workerRuntimes.set(browser, workerRuntime)
75
+ return workerRuntime
76
+ })()
77
+
78
+ if (isCacheableBrowser(browser)) {
79
+ openingWorkerRuntimes.set(browser, openPromise)
80
+ }
81
+
82
+ try {
83
+ return await openPromise
84
+ } finally {
85
+ if (isCacheableBrowser(browser)) openingWorkerRuntimes.delete(browser)
86
+ }
87
+ }
88
+
89
+ const getTabIdFromTargetId = async ({ worker, targetId }) => {
90
+ try {
91
+ return worker.evaluate(async targetId => {
92
+ if (
93
+ !globalThis.chrome.debugger ||
94
+ typeof globalThis.chrome.debugger.getTargets !== 'function'
95
+ ) {
96
+ return
97
+ }
98
+
99
+ const targets = await globalThis.chrome.debugger.getTargets()
100
+ const target = targets.find(target => target && target.id === targetId)
101
+ return Number.isInteger(target && target.tabId) ? target.tabId : undefined
102
+ }, targetId)
103
+ } catch (_) {}
104
+ }
105
+
106
+ const startRecording = async ({ extension, settings }) =>
107
+ extension.evaluate(settings => globalThis.START_RECORDING(settings), settings)
108
+
109
+ const stopRecording = async ({ extension, index }) =>
110
+ extension.evaluate(index => globalThis.STOP_RECORDING(index), index)
111
+
112
+ module.exports = {
113
+ open,
114
+ getTabIdFromTargetId,
115
+ startRecording,
116
+ stopRecording
117
+ }
package/src/index.js ADDED
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+
3
+ const debug = require('debug-logfmt')('browserless:capture')
4
+ const createGoto = require('@browserless/goto')
5
+
6
+ const { EXTENSION_ID, EXTENSION_PATH, TYPES } = require('./constants')
7
+ const runCapture = require('./capture')
8
+
9
+ module.exports = ({ goto, ...gotoOpts } = {}) => {
10
+ goto = goto || createGoto(gotoOpts)
11
+ return function capture (page) {
12
+ return async (url, opts = {}) => {
13
+ const duration = debug.duration({ url }, opts)
14
+ const { device } = await goto(page, { ...opts, url })
15
+ const result = await runCapture(page, opts, device.viewport)
16
+ duration.info()
17
+ return result
18
+ }
19
+ }
20
+ }
21
+
22
+ module.exports.extensionPath = EXTENSION_PATH
23
+ module.exports.extensionId = EXTENSION_ID
24
+ module.exports.types = TYPES
package/src/util.js ADDED
@@ -0,0 +1,49 @@
1
+ 'use strict'
2
+
3
+ const { WebSocketServer } = require('ws')
4
+
5
+ const closeServer = wss => {
6
+ const { promise, resolve } = Promise.withResolvers()
7
+
8
+ if (!wss) {
9
+ resolve()
10
+ return promise
11
+ }
12
+
13
+ try {
14
+ wss.close(() => resolve())
15
+ } catch (error) {
16
+ resolve()
17
+ }
18
+
19
+ return promise
20
+ }
21
+
22
+ const createWebSocketServer = () => {
23
+ const { promise, resolve, reject } = Promise.withResolvers()
24
+ const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 })
25
+
26
+ const onListening = () => {
27
+ cleanup()
28
+ const { port } = wss.address()
29
+ resolve({ wss, port })
30
+ }
31
+
32
+ const onError = error => {
33
+ cleanup()
34
+ reject(error)
35
+ }
36
+
37
+ const cleanup = () => {
38
+ wss.removeListener('listening', onListening).removeListener('error', onError)
39
+ }
40
+
41
+ wss.once('listening', onListening).once('error', onError)
42
+
43
+ return promise
44
+ }
45
+
46
+ module.exports = {
47
+ closeServer,
48
+ createWebSocketServer
49
+ }