Skip to content

Instantly share code, notes, and snippets.

@SomeHats
Last active February 26, 2025 16:13
Show Gist options
  • Select an option

  • Save SomeHats/14dfe042842269eb8dd20cd42fa01a1c to your computer and use it in GitHub Desktop.

Select an option

Save SomeHats/14dfe042842269eb8dd20cd42fa01a1c to your computer and use it in GitHub Desktop.

Revisions

  1. SomeHats revised this gist Aug 25, 2023. No changes.
  2. SomeHats revised this gist Aug 25, 2023. No changes.
  3. SomeHats revised this gist Aug 25, 2023. 2 changed files with 208 additions and 0 deletions.
    205 changes: 205 additions & 0 deletions runDevBrowser.mjs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,205 @@
    /* eslint-disable no-console */
    /***
    * This is a little server that emulates the protocol used by cloudflare's browser rendering API. In
    * local development, you can run this server, and connect to it instead of cloudflare's (strictly
    * limited) API. e.g. in your worker you might use a function like this:
    *
    * ```ts
    * import { Browser, launch as launchPuppeteer } from '@cloudflare/puppeteer'
    * function launchBrowser(env: Environment) {
    * if (env.LOCAL_BROWSER_ORIGIN) {
    * return launchPuppeteer({
    * fetch: (input, init) => {
    * const request = new Request(input, init)
    * return fetch(`${LOCAL_BROWSER_ORIGIN}${request.url}`, request)
    * },
    * })
    * } else {
    * return launchPuppeteer(env.BROWSER)
    * }
    * }
    * ```
    *
    * Run this file with node:
    * `node runDevBrowser.mjs`
    */
    import { createServer } from 'http'
    import * as puppeteer from 'puppeteer'
    import { WebSocket, WebSocketServer } from 'ws'

    const browsers = new Map()

    setInterval(async () => {
    for (const [id, browser] of browsers) {
    if (browser.lastUsed < Date.now() - 10000) {
    console.log(`closing browser session: ${id}`)
    await browser.instance.close()
    browsers.delete(id)
    }
    }
    }, 1000)

    const httpServer = createServer(async (req, res) => {
    try {
    console.log(req.method, req.url)
    const [path] = req.url.split('?')
    switch (path) {
    case '/v1/acquire': {
    const id = `local-${Math.random().toString(36).slice(2)}`
    const browser = await puppeteer.launch({ headless: 'new' })
    console.log(`launching browser session: ${id}`)
    browsers.set(id, { lastUsed: Date.now(), instance: browser })
    sendJson(res, { sessionId: id })
    return
    }
    case '/v1/connectDevtools':
    return
    default:
    console.log('not found:', req.url)
    res.statusCode = 404
    res.end('not found')
    return
    }
    } catch (err) {
    console.log(err)
    res.statusCode = 500
    res.end('error')
    }
    })

    const wsServer = new WebSocketServer({ noServer: true })
    httpServer.on('upgrade', (req, socketToWorker, head) => {
    console.log('UPGRADE', req.url)
    const [path, search] = req.url.split('?')
    if (path !== '/v1/connectDevtools') {
    socketToWorker.destroy()
    return
    }
    const searchParams = new URLSearchParams(search)
    const sessionId = searchParams.get('browser_session')

    const browser = browsers.get(sessionId)
    if (!browser) {
    console.log('browser not found')
    socketToWorker.destroy()
    return
    }

    const browserWsUrl = browser.instance.wsEndpoint()
    const socketToBrowser = new WebSocket(browserWsUrl)
    let _socketToWorker = null
    socketToBrowser.on('error', (err) => console.log('browser socket error', err))
    socketToBrowser.on('close', () => {
    console.log('b: close')
    if (_socketToWorker) _socketToWorker.close()
    })
    socketToBrowser.on('open', () => {
    const chunksFromWorker = []

    wsServer.handleUpgrade(req, socketToWorker, head, (socketToWorker) => {
    _socketToWorker = socketToWorker

    socketToWorker.on('error', (err) => console.log('worker socket error', err))
    socketToWorker.on('message', (data) => {
    browser.lastUsed = Date.now()
    if (data.toString('utf8') === 'ping') return
    chunksFromWorker.push(new Uint8Array(data))
    const message = chunking.chunksToMessage(chunksFromWorker, sessionId)
    if (message) {
    socketToBrowser.send(message)
    }
    })
    socketToWorker.on('close', () => {
    console.log('w: close')
    socketToBrowser.close()
    })

    socketToBrowser.on('message', (data) => {
    const chunks = chunking.messageToChunks(data.toString('utf8'))
    for (const chunk of chunks) {
    socketToWorker.send(chunk)
    }
    })
    })
    })
    })

    function sendJson(res, data) {
    res.setHeader('content-type', 'application/json')
    res.end(JSON.stringify(data))
    }

    httpServer.listen(8789, () => {
    console.log('Listening on port 8789')
    })

    /**
    * Chunking - adapted from https://github.com/cloudflare/puppeteer/blob/main/src/common/chunking.ts#L27
    * @license Apache-2.0
    */
    const chunking = (() => {
    const HEADER_SIZE = 4 // Uint32
    const MAX_MESSAGE_SIZE = 1048575 // Workers size is < 1MB
    const FIRST_CHUNK_DATA_SIZE = MAX_MESSAGE_SIZE - HEADER_SIZE

    const messageToChunks = (data) => {
    const encoder = new TextEncoder()
    const encodedUint8Array = encoder.encode(data)

    // We only include the header into the first chunk
    const firstChunk = new Uint8Array(
    Math.min(MAX_MESSAGE_SIZE, HEADER_SIZE + encodedUint8Array.length)
    )
    const view = new DataView(firstChunk.buffer)
    view.setUint32(0, encodedUint8Array.length, true)
    firstChunk.set(encodedUint8Array.slice(0, FIRST_CHUNK_DATA_SIZE), HEADER_SIZE)

    const chunks = [firstChunk]
    for (let i = FIRST_CHUNK_DATA_SIZE; i < data.length; i += MAX_MESSAGE_SIZE) {
    chunks.push(encodedUint8Array.slice(i, i + MAX_MESSAGE_SIZE))
    }
    return chunks
    }

    const chunksToMessage = (chunks, sessionid) => {
    if (chunks.length === 0) {
    return null
    }

    const emptyBuffer = new Uint8Array(0)
    const firstChunk = chunks[0] || emptyBuffer
    const view = new DataView(firstChunk.buffer)
    const expectedBytes = view.getUint32(0, true)

    let totalBytes = -HEADER_SIZE
    for (let i = 0; i < chunks.length; ++i) {
    const curChunk = chunks[i] || emptyBuffer
    totalBytes += curChunk.length

    if (totalBytes > expectedBytes) {
    throw new Error(
    `Should have gotten the exact number of bytes but we got more. SessionID: ${sessionid}`
    )
    }
    if (totalBytes === expectedBytes) {
    const chunksToCombine = chunks.splice(0, i + 1)
    chunksToCombine[0] = firstChunk.subarray(HEADER_SIZE)

    const combined = new Uint8Array(expectedBytes)
    let offset = 0
    for (let j = 0; j <= i; ++j) {
    const chunk = chunksToCombine[j] || emptyBuffer
    combined.set(chunk, offset)
    offset += chunk.length
    }
    const decoder = new TextDecoder()
    // return decoder.decode(combined)
    const message = decoder.decode(combined)
    return message
    }
    }
    return null
    }

    return { chunksToMessage, messageToChunks }
    })()
    3 changes: 3 additions & 0 deletions runDevBrowser.ts
    Original file line number Diff line number Diff line change
    @@ -19,6 +19,9 @@
    * }
    * }
    * ```
    *
    * Run this file with `tsx` - https://www.npmjs.com/package/tsx: `tsx runDevBrowser.ts`
    * Or use the JavaScript version (runDevBrowser.mjs) below
    */
    import { ServerResponse, createServer } from 'http'
    import * as puppeteer from 'puppeteer'
  4. SomeHats created this gist Aug 23, 2023.
    202 changes: 202 additions & 0 deletions runDevBrowser.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,202 @@
    /* eslint-disable no-console */
    /***
    * This is a little server that emulates the protocol used by cloudflare's browser rendering API. In
    * local development, you can run this server, and connect to it instead of cloudflare's (strictly
    * limited) API. e.g. in your worker you might use a function like this:
    *
    * ```ts
    * import { Browser, launch as launchPuppeteer } from '@cloudflare/puppeteer'
    * function launchBrowser(env: Environment) {
    * if (env.LOCAL_BROWSER_ORIGIN) {
    * return launchPuppeteer({
    * fetch: (input, init) => {
    * const request = new Request(input, init)
    * return fetch(`${LOCAL_BROWSER_ORIGIN}${request.url}`, request)
    * },
    * })
    * } else {
    * return launchPuppeteer(env.BROWSER)
    * }
    * }
    * ```
    */
    import { ServerResponse, createServer } from 'http'
    import * as puppeteer from 'puppeteer'
    import { WebSocket, WebSocketServer } from 'ws'

    const browsers = new Map<string, { lastUsed: number; instance: puppeteer.Browser }>()

    setInterval(async () => {
    for (const [id, browser] of browsers) {
    if (browser.lastUsed < Date.now() - 10000) {
    console.log(`closing browser session: ${id}`)
    await browser.instance.close()
    browsers.delete(id)
    }
    }
    }, 1000)

    const httpServer = createServer(async (req, res) => {
    try {
    console.log(req.method, req.url)
    const [path] = req.url!.split('?')
    switch (path) {
    case '/v1/acquire': {
    const id = `local-${Math.random().toString(36).slice(2)}`
    const browser = await puppeteer.launch({ headless: 'new' })
    console.log(`launching browser session: ${id}`)
    browsers.set(id, { lastUsed: Date.now(), instance: browser })
    sendJson(res, { sessionId: id })
    return
    }
    case '/v1/connectDevtools':
    return
    default:
    console.log('not found:', req.url)
    res.statusCode = 404
    res.end('not found')
    return
    }
    } catch (err) {
    console.log(err)
    res.statusCode = 500
    res.end('error')
    }
    })

    const wsServer = new WebSocketServer({ noServer: true })
    httpServer.on('upgrade', (req, socketToWorker, head) => {
    console.log('UPGRADE', req.url)
    const [path, search] = req.url!.split('?')
    if (path !== '/v1/connectDevtools') {
    socketToWorker.destroy()
    return
    }
    const searchParams = new URLSearchParams(search)
    const sessionId = searchParams.get('browser_session')

    const browser = browsers.get(sessionId!)
    if (!browser) {
    console.log('browser not found')
    socketToWorker.destroy()
    return
    }

    const browserWsUrl = browser.instance.wsEndpoint()
    const socketToBrowser = new WebSocket(browserWsUrl)
    let _socketToWorker: WebSocket | null = null
    socketToBrowser.on('error', (err) => console.log('browser socket error', err))
    socketToBrowser.on('close', () => {
    console.log('b: close')
    if (_socketToWorker) _socketToWorker.close()
    })
    socketToBrowser.on('open', () => {
    const chunksFromWorker: Uint8Array[] = []

    wsServer.handleUpgrade(req, socketToWorker, head, (socketToWorker) => {
    _socketToWorker = socketToWorker

    socketToWorker.on('error', (err) => console.log('worker socket error', err))
    socketToWorker.on('message', (data) => {
    browser.lastUsed = Date.now()
    if (data.toString('utf8') === 'ping') return
    chunksFromWorker.push(new Uint8Array(data as ArrayBuffer))
    const message = chunking.chunksToMessage(chunksFromWorker, sessionId!)
    if (message) {
    socketToBrowser.send(message)
    }
    })
    socketToWorker.on('close', () => {
    console.log('w: close')
    socketToBrowser.close()
    })

    socketToBrowser.on('message', (data) => {
    const chunks = chunking.messageToChunks(data.toString('utf8'))
    for (const chunk of chunks) {
    socketToWorker.send(chunk)
    }
    })
    })
    })
    })

    function sendJson(res: ServerResponse, data: unknown) {
    res.setHeader('content-type', 'application/json')
    res.end(JSON.stringify(data))
    }

    httpServer.listen(8789, () => {
    console.log('Listening on port 8789')
    })

    /**
    * Chunking - adapted from https://github.com/cloudflare/puppeteer/blob/main/src/common/chunking.ts#L27
    * @license Apache-2.0
    */
    const chunking = (() => {
    const HEADER_SIZE = 4 // Uint32
    const MAX_MESSAGE_SIZE = 1048575 // Workers size is < 1MB
    const FIRST_CHUNK_DATA_SIZE = MAX_MESSAGE_SIZE - HEADER_SIZE

    const messageToChunks = (data: string): Uint8Array[] => {
    const encoder = new TextEncoder()
    const encodedUint8Array = encoder.encode(data)

    // We only include the header into the first chunk
    const firstChunk = new Uint8Array(
    Math.min(MAX_MESSAGE_SIZE, HEADER_SIZE + encodedUint8Array.length)
    )
    const view = new DataView(firstChunk.buffer)
    view.setUint32(0, encodedUint8Array.length, true)
    firstChunk.set(encodedUint8Array.slice(0, FIRST_CHUNK_DATA_SIZE), HEADER_SIZE)

    const chunks: Uint8Array[] = [firstChunk]
    for (let i = FIRST_CHUNK_DATA_SIZE; i < data.length; i += MAX_MESSAGE_SIZE) {
    chunks.push(encodedUint8Array.slice(i, i + MAX_MESSAGE_SIZE))
    }
    return chunks
    }

    const chunksToMessage = (chunks: Uint8Array[], sessionid: string): string | null => {
    if (chunks.length === 0) {
    return null
    }

    const emptyBuffer = new Uint8Array(0)
    const firstChunk = chunks[0] || emptyBuffer
    const view = new DataView(firstChunk.buffer)
    const expectedBytes = view.getUint32(0, true)

    let totalBytes = -HEADER_SIZE
    for (let i = 0; i < chunks.length; ++i) {
    const curChunk = chunks[i] || emptyBuffer
    totalBytes += curChunk.length

    if (totalBytes > expectedBytes) {
    throw new Error(
    `Should have gotten the exact number of bytes but we got more. SessionID: ${sessionid}`
    )
    }
    if (totalBytes === expectedBytes) {
    const chunksToCombine = chunks.splice(0, i + 1)
    chunksToCombine[0] = firstChunk.subarray(HEADER_SIZE)

    const combined = new Uint8Array(expectedBytes)
    let offset = 0
    for (let j = 0; j <= i; ++j) {
    const chunk = chunksToCombine[j] || emptyBuffer
    combined.set(chunk, offset)
    offset += chunk.length
    }
    const decoder = new TextDecoder()
    // return decoder.decode(combined)
    const message = decoder.decode(combined)
    return message
    }
    }
    return null
    }

    return { chunksToMessage, messageToChunks }
    })()