@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 +21 -0
- package/README.md +86 -0
- package/extension/background.js +115 -0
- package/extension/manifest.json +8 -0
- package/extension/offscreen.html +1 -0
- package/extension/offscreen.js +185 -0
- package/package.json +58 -0
- package/src/capture.js +303 -0
- package/src/constants.js +19 -0
- package/src/extension.js +117 -0
- package/src/index.js +24 -0
- package/src/util.js +49 -0
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 @@
|
|
|
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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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
|
+
}
|
package/src/extension.js
ADDED
|
@@ -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
|
+
}
|