@browserless/ai 13.9.3

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.
@@ -0,0 +1,274 @@
1
+ 'use strict'
2
+
3
+ const { createReadStream, createWriteStream, mkdirSync, statSync } = require('node:fs')
4
+ const { createHmac, createHash } = require('node:crypto')
5
+ const { Readable, Transform } = require('node:stream')
6
+ const { pipeline } = require('node:stream/promises')
7
+ const path = require('node:path')
8
+
9
+ const UNSIGNED = 'UNSIGNED-PAYLOAD'
10
+ const PART_SIZE = 16 * 1024 * 1024
11
+ const DEFAULT_KEY = 'browserless-ai-nano.zip'
12
+
13
+ const encodeRfc3986 = value =>
14
+ encodeURIComponent(value).replace(
15
+ /[!'()*]/g,
16
+ char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
17
+ )
18
+
19
+ const hmac = (key, data) => createHmac('sha256', key).update(data, 'utf8').digest()
20
+ const sha256Hex = data => createHash('sha256').update(data).digest('hex')
21
+ const amzNow = () => new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
22
+ const signingKey = (secret, date, region) =>
23
+ hmac(hmac(hmac(hmac(`AWS4${secret}`, date), region), 's3'), 'aws4_request')
24
+
25
+ const parseS3Url = value => {
26
+ if (!value) return {}
27
+ try {
28
+ const url = new URL(value)
29
+ const parts = url.pathname.split('/').filter(Boolean)
30
+ return {
31
+ endpoint: url.origin,
32
+ bucket: parts[0],
33
+ key: parts.slice(1).join('/')
34
+ }
35
+ } catch {
36
+ return {}
37
+ }
38
+ }
39
+
40
+ const credentials = () => {
41
+ const fromEnv = parseS3Url(process.env.R2_ENDPOINT)
42
+ const accountId = process.env.R2_ACCOUNT_ID || process.env.CLOUDFLARE_ACCOUNT_ID
43
+ return {
44
+ bucket: process.env.R2_BUCKET || fromEnv.bucket,
45
+ key: process.env.R2_KEY || fromEnv.key || DEFAULT_KEY,
46
+ accessKey: process.env.R2_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID,
47
+ secretKey: process.env.R2_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY,
48
+ endpoint:
49
+ fromEnv.endpoint ||
50
+ process.env.R2_ENDPOINT ||
51
+ (accountId ? `https://${accountId}.r2.cloudflarestorage.com` : undefined),
52
+ region: process.env.R2_REGION || 'auto'
53
+ }
54
+ }
55
+
56
+ const objectUrl = opts => `${opts.endpoint}/${opts.bucket}/${opts.key}`
57
+
58
+ const objectPath = (bucket, key) =>
59
+ `/${encodeRfc3986(bucket)}/${key.split('/').map(encodeRfc3986).join('/')}`
60
+
61
+ const canonicalQuery = query =>
62
+ Object.keys(query)
63
+ .sort()
64
+ .map(
65
+ name =>
66
+ `${encodeRfc3986(name)}=${query[name] === '' ? '' : encodeRfc3986(String(query[name]))}`
67
+ )
68
+ .join('&')
69
+
70
+ const sign = ({
71
+ method,
72
+ endpoint,
73
+ pathname,
74
+ query = {},
75
+ extraHeaders = {},
76
+ accessKey,
77
+ secretKey,
78
+ region
79
+ }) => {
80
+ const amzDate = amzNow()
81
+ const date = amzDate.slice(0, 8)
82
+ const host = new URL(endpoint).host
83
+ const headers = {
84
+ host,
85
+ 'x-amz-content-sha256': UNSIGNED,
86
+ 'x-amz-date': amzDate,
87
+ ...extraHeaders
88
+ }
89
+ const names = Object.keys(headers)
90
+ .map(name => name.toLowerCase())
91
+ .sort()
92
+ const headerMap = Object.fromEntries(
93
+ Object.entries(headers).map(([name, value]) => [name.toLowerCase(), String(value).trim()])
94
+ )
95
+ const canonicalHeaders = names.map(name => `${name}:${headerMap[name]}\n`).join('')
96
+ const signedHeaders = names.join(';')
97
+ const canonicalRequest = [
98
+ method,
99
+ pathname,
100
+ canonicalQuery(query),
101
+ canonicalHeaders,
102
+ signedHeaders,
103
+ UNSIGNED
104
+ ].join('\n')
105
+ const scope = `${date}/${region}/s3/aws4_request`
106
+ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n')
107
+ const signature = createHmac('sha256', signingKey(secretKey, date, region))
108
+ .update(stringToSign)
109
+ .digest('hex')
110
+ headers.authorization = `AWS4-HMAC-SHA256 Credential=${accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
111
+ return headers
112
+ }
113
+
114
+ const signedFetch = async (
115
+ opts,
116
+ { method, query = {}, body, contentLength, contentType, signal }
117
+ ) => {
118
+ const pathname = objectPath(opts.bucket, opts.key)
119
+ const extraHeaders = {}
120
+ if (contentLength != null) extraHeaders['content-length'] = String(contentLength)
121
+ if (contentType) extraHeaders['content-type'] = contentType
122
+ const headers = sign({
123
+ method,
124
+ endpoint: opts.endpoint,
125
+ pathname,
126
+ query,
127
+ extraHeaders,
128
+ accessKey: opts.accessKey,
129
+ secretKey: opts.secretKey,
130
+ region: opts.region || 'auto'
131
+ })
132
+ const search = canonicalQuery(query)
133
+ const url = `${opts.endpoint}${pathname}${search ? `?${search}` : ''}`
134
+ return fetch(url, {
135
+ method,
136
+ headers,
137
+ body,
138
+ signal,
139
+ duplex: body && typeof body !== 'string' ? 'half' : undefined
140
+ })
141
+ }
142
+
143
+ const request = async (opts, init) => {
144
+ const res = await signedFetch(opts, init)
145
+ const text = await res.text()
146
+ if (!res.ok) throw new Error(`R2 ${init.method} ${res.status}: ${text.slice(0, 500)}`)
147
+ return { headers: res.headers, text }
148
+ }
149
+
150
+ const xmlText = (xml, tag) => {
151
+ const match = xml.match(new RegExp(`<${tag}>([^<]+)</${tag}>`))
152
+ if (!match) throw new Error(`missing <${tag}> in R2 response`)
153
+ return match[1]
154
+ }
155
+
156
+ const prettyBytes = n => {
157
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`
158
+ if (n >= 1e6) return `${(n / 1e6).toFixed(2)} MB`
159
+ return `${n} B`
160
+ }
161
+
162
+ const createReporter = label => {
163
+ const interactive = process.stderr.isTTY && !process.env.CI
164
+ let last = 0
165
+ return (loaded, total, done = false) => {
166
+ const now = Date.now()
167
+ if (!done && now - last < (interactive ? 200 : 5000)) return
168
+ last = now
169
+ const pct = total ? Math.min(100, Math.floor((loaded / total) * 100)) : 0
170
+ const line = `${label} ${prettyBytes(loaded)}/${prettyBytes(total)} ${pct}%`
171
+ process.stderr.write(interactive ? `\r${line}` : `${line}\n`)
172
+ if (done && interactive) process.stderr.write('\n')
173
+ }
174
+ }
175
+
176
+ const toNodeStream = body => {
177
+ if (!body) throw new Error('empty S3 body')
178
+ if (typeof body.pipe === 'function') return body
179
+ if (typeof body.getReader === 'function') return Readable.fromWeb(body)
180
+ return Readable.from(body)
181
+ }
182
+
183
+ const DOWNLOAD_TIMEOUT = 15 * 60 * 1000
184
+
185
+ const downloadFile = async (opts, dest) => {
186
+ const res = await signedFetch(opts, {
187
+ method: 'GET',
188
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT)
189
+ })
190
+ if (!res.ok) {
191
+ const text = await res.text()
192
+ throw new Error(`R2 GET ${res.status}: ${text.slice(0, 500)}`)
193
+ }
194
+ const total = Number(res.headers.get('content-length')) || 0
195
+ const report = createReporter('downloading')
196
+ let loaded = 0
197
+ mkdirSync(path.dirname(dest), { recursive: true })
198
+ await pipeline(
199
+ toNodeStream(res.body),
200
+ new Transform({
201
+ transform (chunk, _enc, cb) {
202
+ loaded += chunk.length
203
+ report(loaded, total)
204
+ cb(null, chunk)
205
+ }
206
+ }),
207
+ createWriteStream(dest)
208
+ )
209
+ report(loaded, total, true)
210
+ }
211
+
212
+ const uploadFile = async (opts, file) => {
213
+ const { size } = statSync(file)
214
+ const report = createReporter('uploading')
215
+ if (size <= PART_SIZE) {
216
+ report(0, size)
217
+ await request(opts, {
218
+ method: 'PUT',
219
+ body: createReadStream(file),
220
+ contentLength: size,
221
+ contentType: 'application/zip'
222
+ })
223
+ report(size, size, true)
224
+ return
225
+ }
226
+
227
+ const created = await request(opts, {
228
+ method: 'POST',
229
+ query: { uploads: '' },
230
+ contentType: 'application/zip'
231
+ })
232
+ const uploadId = xmlText(created.text, 'UploadId')
233
+ const parts = []
234
+ try {
235
+ let partNumber = 1
236
+ for (let start = 0; start < size; start += PART_SIZE, partNumber++) {
237
+ const end = Math.min(start + PART_SIZE, size) - 1
238
+ const length = end - start + 1
239
+ report(start, size)
240
+ const { headers } = await request(opts, {
241
+ method: 'PUT',
242
+ query: { partNumber, uploadId },
243
+ body: createReadStream(file, { start, end }),
244
+ contentLength: length
245
+ })
246
+ const etag = headers.get('etag')
247
+ if (!etag) throw new Error(`part ${partNumber} missing ETag`)
248
+ parts.push({ partNumber, etag })
249
+ report(end + 1, size)
250
+ }
251
+ report(size, size, true)
252
+ const body =
253
+ '<CompleteMultipartUpload>' +
254
+ parts
255
+ .map(
256
+ part =>
257
+ `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`
258
+ )
259
+ .join('') +
260
+ '</CompleteMultipartUpload>'
261
+ await request(opts, {
262
+ method: 'POST',
263
+ query: { uploadId },
264
+ body,
265
+ contentLength: Buffer.byteLength(body),
266
+ contentType: 'application/xml'
267
+ })
268
+ } catch (error) {
269
+ await request(opts, { method: 'DELETE', query: { uploadId } }).catch(() => {})
270
+ throw error
271
+ }
272
+ }
273
+
274
+ module.exports = { credentials, downloadFile, objectUrl, uploadFile }
@@ -0,0 +1,28 @@
1
+ 'use strict'
2
+
3
+ const path = require('node:path')
4
+ const os = require('node:os')
5
+ const { existsSync, readdirSync, statSync } = require('node:fs')
6
+
7
+ const cacheRoot = () =>
8
+ process.env.XDG_CACHE_HOME
9
+ ? path.join(process.env.XDG_CACHE_HOME, 'browserless-ai')
10
+ : path.join(os.homedir(), '.cache', 'browserless-ai')
11
+
12
+ const findDir = (root, predicate) => {
13
+ if (!existsSync(root)) return
14
+ if (predicate(root)) return root
15
+ if (!statSync(root).isDirectory()) return
16
+ for (const name of readdirSync(root)) {
17
+ if (name === '_metadata') continue
18
+ const next = path.join(root, name)
19
+ if (statSync(next).isDirectory()) {
20
+ const found = findDir(next, predicate)
21
+ if (found) return found
22
+ }
23
+ }
24
+ }
25
+
26
+ const hasFile = (root, file) => findDir(root, current => existsSync(path.join(current, file)))
27
+
28
+ module.exports = { cacheRoot, findDir, hasFile }
package/src/index.js ADDED
@@ -0,0 +1,313 @@
1
+ 'use strict'
2
+
3
+ const debug = require('debug-logfmt')('browserless:ai')
4
+ const { crc32 } = require('node:zlib')
5
+ const path = require('node:path')
6
+ const fs = require('node:fs')
7
+ const os = require('node:os')
8
+
9
+ const { cacheRoot, hasFile } = require('./find-dir')
10
+ const runAi = require('./run-ai')
11
+
12
+ const withContext = async (getBrowserless, fn) => {
13
+ let teardown
14
+ const browserless = await getBrowserless(done => (teardown = done))
15
+ try {
16
+ return await fn(browserless)
17
+ } finally {
18
+ if (teardown) await teardown()
19
+ }
20
+ }
21
+
22
+ const createMethod =
23
+ (getBrowserless, spec) =>
24
+ (url, { timeout = TIMEOUT, ...opts } = {}) =>
25
+ withContext(getBrowserless, browserless =>
26
+ browserless.evaluate(page => page.evaluate(runAi, { ...opts, ...spec }), { timeout })(url)
27
+ )
28
+
29
+ const OVERRIDE_SEP = process.platform === 'win32' ? '|' : ':'
30
+
31
+ const FEATURES = 'OnDeviceModelForceCpuBackend,OptimizationHints'
32
+
33
+ const TIMEOUT = 300000
34
+
35
+ const readVarint = (buf, offset) => {
36
+ let value = 0
37
+ let shift = 0
38
+ while (offset < buf.length) {
39
+ const byte = buf[offset++]
40
+ value |= (byte & 0x7f) << shift
41
+ if ((byte & 0x80) === 0) return { value, offset }
42
+ shift += 7
43
+ }
44
+ throw new Error('truncated protobuf varint')
45
+ }
46
+
47
+ const writeVarint = n => {
48
+ const bytes = []
49
+ while (n > 0x7f) {
50
+ bytes.push((n & 0x7f) | 0x80)
51
+ n >>>= 7
52
+ }
53
+ bytes.push(n)
54
+ return Buffer.from(bytes)
55
+ }
56
+
57
+ const skipTextSafety = buf => {
58
+ if (!buf.length || buf[0] !== 0x0a) return buf
59
+ const { value: length, offset } = readVarint(buf, 1)
60
+ const inner = buf.subarray(offset, offset + length)
61
+ if (inner.subarray(-2).equals(Buffer.from([0x28, 0x01]))) return buf
62
+ const patched = Buffer.concat([inner, Buffer.from([0x28, 0x01])])
63
+ return Buffer.concat([
64
+ Buffer.from([0x0a]),
65
+ writeVarint(patched.length),
66
+ patched,
67
+ buf.subarray(offset + length)
68
+ ])
69
+ }
70
+
71
+ const zipStore = entries => {
72
+ const locals = []
73
+ const centrals = []
74
+ let offset = 0
75
+ for (const [name, data] of entries) {
76
+ const nameBuf = Buffer.from(name)
77
+ const crc = crc32(data)
78
+ const local = Buffer.alloc(30)
79
+ local.writeUInt32LE(0x04034b50, 0)
80
+ local.writeUInt16LE(20, 4)
81
+ local.writeUInt32LE(crc, 14)
82
+ local.writeUInt32LE(data.length, 18)
83
+ local.writeUInt32LE(data.length, 22)
84
+ local.writeUInt16LE(nameBuf.length, 26)
85
+ locals.push(local, nameBuf, data)
86
+ const central = Buffer.alloc(46)
87
+ central.writeUInt32LE(0x02014b50, 0)
88
+ central.writeUInt16LE(20, 4)
89
+ central.writeUInt16LE(20, 6)
90
+ central.writeUInt32LE(crc, 16)
91
+ central.writeUInt32LE(data.length, 20)
92
+ central.writeUInt32LE(data.length, 24)
93
+ central.writeUInt16LE(nameBuf.length, 28)
94
+ central.writeUInt32LE(offset, 42)
95
+ centrals.push(central, nameBuf)
96
+ offset += 30 + nameBuf.length + data.length
97
+ }
98
+ const localBuf = Buffer.concat(locals)
99
+ const centralBuf = Buffer.concat(centrals)
100
+ const end = Buffer.alloc(22)
101
+ end.writeUInt32LE(0x06054b50, 0)
102
+ end.writeUInt16LE(entries.length, 8)
103
+ end.writeUInt16LE(entries.length, 10)
104
+ end.writeUInt32LE(centralBuf.length, 12)
105
+ end.writeUInt32LE(localBuf.length, 16)
106
+ return Buffer.concat([localBuf, centralBuf, end])
107
+ }
108
+
109
+ const ADAPTATIONS = [
110
+ {
111
+ name: 'prompt',
112
+ target: 49,
113
+ flag: 'OPTIMIZATION_TARGET_MODEL_EXECUTION_FEATURE_PROMPT_API',
114
+ skipSafety: true
115
+ },
116
+ {
117
+ name: 'summarize',
118
+ target: 51,
119
+ flag: 'OPTIMIZATION_TARGET_MODEL_EXECUTION_FEATURE_SUMMARIZE',
120
+ skipSafety: true
121
+ },
122
+ {
123
+ name: 'detect',
124
+ target: 2,
125
+ flag: 'OPTIMIZATION_TARGET_LANGUAGE_DETECTION',
126
+ skipSafety: false
127
+ }
128
+ ]
129
+
130
+ const packAdaptation = (dir, { name, skipSafety }) => {
131
+ const dest = path.join(
132
+ os.tmpdir(),
133
+ `browserless-ai-${name}-${process.pid}-${process.hrtime.bigint()}.crx3`
134
+ )
135
+ const read = file => {
136
+ const filePath = path.join(dir, file)
137
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath) : Buffer.alloc(0)
138
+ }
139
+ let config = read('on_device_model_execution_config.pb')
140
+ if (config.length && skipSafety) config = skipTextSafety(config)
141
+ const files = [
142
+ ['model.tflite', read('model.tflite')],
143
+ ['model-info.pb', read('model-info.pb')]
144
+ ]
145
+ if (config.length) files.push(['on_device_model_execution_config.pb', config])
146
+ fs.writeFileSync(dest, zipStore(files))
147
+ return dest
148
+ }
149
+
150
+ const resolveAdaptations = adaptationPath => {
151
+ const stat = fs.statSync(adaptationPath)
152
+ if (stat.isFile()) {
153
+ return [`OPTIMIZATION_TARGET_LANGUAGE_DETECTION${OVERRIDE_SEP}${path.resolve(adaptationPath)}`]
154
+ }
155
+
156
+ const pairs = []
157
+ for (const feature of ADAPTATIONS) {
158
+ const dir =
159
+ hasFile(path.join(adaptationPath, feature.name), 'model-info.pb') ||
160
+ hasFile(path.join(adaptationPath, String(feature.target)), 'model-info.pb')
161
+ if (dir) pairs.push(`${feature.flag}${OVERRIDE_SEP}${packAdaptation(dir, feature)}`)
162
+ debug('adaptation', { name: feature.name, dir: dir || false })
163
+ }
164
+ return pairs
165
+ }
166
+
167
+ const chromeSupport = (...parts) =>
168
+ process.platform === 'darwin'
169
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', ...parts)
170
+ : undefined
171
+
172
+ const resolveModelPath = dir => {
173
+ if (dir) return hasFile(dir, 'weights.bin')
174
+ const chrome = chromeSupport('OptGuideOnDeviceModel')
175
+ return hasFile(cacheRoot(), 'weights.bin') || (chrome && hasFile(chrome, 'weights.bin'))
176
+ }
177
+
178
+ const resolveAdaptationPath = dir => {
179
+ if (dir) return fs.existsSync(dir) ? dir : undefined
180
+ if (hasFile(cacheRoot(), 'model-info.pb')) return cacheRoot()
181
+ const store = chromeSupport('optimization_guide_model_store')
182
+ return store && fs.existsSync(store) ? store : undefined
183
+ }
184
+
185
+ const launch = ({
186
+ dir = process.env.BROWSERLESS_AI_DIR,
187
+ userDataDir = process.env.BROWSERLESS_AI_PROFILE,
188
+ timeout = TIMEOUT,
189
+ protocolTimeout = timeout
190
+ } = {}) => {
191
+ const modelPath = resolveModelPath(dir)
192
+ const adaptationPath = resolveAdaptationPath(dir)
193
+
194
+ const { defaultArgs } = require('browserless').driver
195
+ const args = defaultArgs
196
+ .filter(arg => arg !== '--no-startup-window')
197
+ .map(arg => (arg.startsWith('--enable-features=') ? `${arg},${FEATURES}` : arg))
198
+ if (process.env.BROWSERLESS_AI_DUMPIO) {
199
+ args.push('--enable-logging=stderr', '--vmodule=optimization_guide*=1,on_device_model*=2')
200
+ }
201
+ args.push('--disable-model-download-verification')
202
+ if (modelPath) {
203
+ args.push(`--optimization-guide-ondevice-model-execution-override=${modelPath}`)
204
+ }
205
+ if (adaptationPath) {
206
+ const pairs = resolveAdaptations(adaptationPath)
207
+ if (pairs.length) args.push(`--optimization-guide-model-override=${pairs.join(',')}`)
208
+ }
209
+ const weights = modelPath && path.join(modelPath, 'weights.bin')
210
+ debug('launch', {
211
+ dir: dir || false,
212
+ modelPath: modelPath || false,
213
+ weightsBytes: weights && fs.existsSync(weights) ? fs.statSync(weights).size : 0,
214
+ adaptationPath: adaptationPath || false,
215
+ overrides: args.filter(
216
+ arg =>
217
+ arg.includes('optimization-guide') ||
218
+ arg.includes('PromptAPI') ||
219
+ arg.includes('Summarization')
220
+ )
221
+ })
222
+ return {
223
+ timeout,
224
+ protocolTimeout,
225
+ ...(userDataDir && { userDataDir }),
226
+ ...(process.env.BROWSERLESS_AI_DUMPIO && { dumpio: true }),
227
+ ...(process.env.CI && { headless: false }),
228
+ args
229
+ }
230
+ }
231
+
232
+ const createMethods = getBrowserless => {
233
+ const prompt = createMethod(getBrowserless, { api: 'prompt' })
234
+ return {
235
+ prompt,
236
+ extract: (url, opts = {}) => {
237
+ if (!opts.schema && !opts.responseConstraint) {
238
+ return Promise.reject(new Error('extract requires schema'))
239
+ }
240
+ return prompt(url, { temperature: 0, topK: 1, ...opts })
241
+ },
242
+ summarize: createMethod(getBrowserless, { api: 'summarize' }),
243
+ translate: createMethod(getBrowserless, { api: 'translate' }),
244
+ detectLanguage: createMethod(getBrowserless, { api: 'detectLanguage' }),
245
+ capabilities: ({ timeout = TIMEOUT, url = 'https://example.com' } = {}) =>
246
+ withContext(getBrowserless, async browserless => {
247
+ const ctors = await browserless.evaluate(
248
+ page =>
249
+ page.evaluate(() => ({
250
+ languageModel: typeof globalThis.LanguageModel,
251
+ summarizer: typeof globalThis.Summarizer,
252
+ translator: typeof globalThis.Translator,
253
+ languageDetector: typeof globalThis.LanguageDetector
254
+ })),
255
+ { timeout: Math.min(timeout, 30000) }
256
+ )(url)
257
+ debug('ctors', ctors)
258
+ const started = Date.now()
259
+ let available
260
+ let lastError
261
+ for (;;) {
262
+ const left = timeout - (Date.now() - started)
263
+ if (left <= 0) break
264
+ try {
265
+ available = await browserless.evaluate(
266
+ page => page.evaluate(runAi, { api: 'availability' }),
267
+ { timeout: Math.min(20000, left) }
268
+ )(url)
269
+ lastError = undefined
270
+ } catch (error) {
271
+ lastError = error
272
+ if (left <= 20000) throw error
273
+ await new Promise(resolve => setTimeout(resolve, 2000))
274
+ continue
275
+ }
276
+ const apis = available.apis || available
277
+ const pending = ['languageModel', 'summarizer', 'languageDetector'].some(
278
+ api => apis[api] === 'downloading'
279
+ )
280
+ if (!pending) break
281
+ await new Promise(resolve => setTimeout(resolve, 2000))
282
+ }
283
+ if (!available) throw lastError || new Error('capabilities timed out')
284
+ debug('capabilities', available.apis || available, available.env)
285
+ return available.apis || available
286
+ })
287
+ }
288
+ }
289
+
290
+ const createAi = (input = {}) => {
291
+ if (typeof input === 'function') return createMethods(input)
292
+
293
+ const browser = require('browserless')(launch(input))
294
+ const methods = createMethods(async teardown => {
295
+ const browserless = await browser.createContext()
296
+ teardown(() => browserless.destroyContext())
297
+ return browserless
298
+ })
299
+ methods.close = () => browser.close()
300
+ return methods
301
+ }
302
+
303
+ module.exports = createAi
304
+ module.exports.launch = launch
305
+ module.exports.unpack = require('./unpack')
306
+ module.exports.download = dest => {
307
+ const { credentials, downloadFile } = require('../scripts/util')
308
+ const env = credentials()
309
+ if (!env.endpoint || !env.bucket || !env.accessKey || !env.secretKey) {
310
+ throw new Error('set R2_ENDPOINT, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY')
311
+ }
312
+ return downloadFile(env, dest)
313
+ }