Skip to content
On this page

Last updated:

Cloudflare Workers DOM polyfill for three.js

Recently tried running threepipe/three.js inside a Cloudflare Worker without Browser Rendering support

Since the worker runtime doesn't include many browser APIs, we need to polyfill them, so the import works for three.js and threepipe.

Managed to get file import, gltf export working without texture transformations. No 2d/3d rendering, no dynamic web worker support for loading files like rhino3dm, draco etc.

Here's the one that works as of three.js r158 and threepipe v0.0.50.

It includes basic aliases/classes with empty implementation mostly generated by copilot autocomplete with working implementation of FileReader and createObjectURL.

HTML Elements

typescript
class ImageData {
    width: number
    height: number
    data: Uint8ClampedArray

    constructor(width: number, height: number, data?: Uint8ClampedArray) {
        this.width = width
        this.height = height
        this.data = data || new Uint8ClampedArray(width * height * 4)
    }

}

class HTMLElement {
    id: string | undefined
    classList: Set<string> = new Set()
    style: { [key: string]: string } = {}

    constructor(public tagName: string) {
    }

    children: HTMLElement[] = []

    appendChild(child: HTMLElement) {
        this.children.push(child)
        child.parentElement = this
    }

    parentElement: HTMLElement | null = null

    removeChild(child: HTMLElement) {
        const index = this.children.indexOf(child)
        if (index !== -1) {
            this.children.splice(index, 1)
            child.parentElement = null
        }
    }

    _listenerMap: { [type: string]: ((event: any) => void)[] } = {}

    addEventListener(type: string, listener: (event: any) => void) {
        if (!this._listenerMap[type]) {
            this._listenerMap[type] = []
        }
        this._listenerMap[type].push(listener)
    }

    removeEventListener(type: string, listener: (event: any) => void) {
        if (!this._listenerMap[type]) return
        const index = this._listenerMap[type].indexOf(listener)
        if (index !== -1) {
            this._listenerMap[type].splice(index, 1)
        }
    }

    dispatchEvent(event: { type: string, [key: string]: any }) {
        if (!this._listenerMap[event.type]) return
        for (const listener of this._listenerMap[event.type]) {
            listener(event)
        }
    }

    get ownerDocument() {
        return document
    }
}

class HTMLDocument extends HTMLElement {
    _allElements = [] as HTMLElement[]
    body = new HTMLElement('body')

    createElement(tagName: string) {
        const elem =
            tagName.toLowerCase() === 'canvas' ? new HTMLCanvasElement() :
                tagName.toLowerCase() === 'img' ? new HTMLImageElement() :
                    new HTMLElement(tagName)
        document._allElements.push(elem)
        return elem
    }

    createElementNS(namespace: string, tagName: string) {
        return this.createElement(tagName)
    }

    getElementById(id: string) {
        return document._allElements.find(el => el.id === id) || null
    }

    querySelector(selector: string) {
        return selector[0] === '#' ?
            document._allElements.find(el => el.id === selector.slice(1)) || null :
            selector[0] === '.' ?
                document._allElements.find(el => el.classList.has(selector.slice(1))) || null :
                document._allElements.find(el => el.tagName.toLowerCase() === selector.toLowerCase()) || null
    }
}

class HTMLCanvasElement extends HTMLElement {
    constructor() {
        super('canvas');
    }

    getContext(contextId: string) {
        if (contextId === '2d') {
            return {
                fillRect: () => {
                },
                clearRect: () => {
                },
                drawImage: () => {
                },
                createImageData: (width: number, height: number) => new ImageData(width, height),
                putImageData: () => {
                },
            }
        }
        if (contextId === 'webgl2') {
            return {
                createBuffer: () => ({}),
                bindBuffer: () => {
                },
                bufferData: () => {
                },
                createShader: () => ({}),
                shaderSource: () => {
                },
                compileShader: () => {
                },
                getShaderParameter: () => true,
                createProgram: () => ({}),
                attachShader: () => {
                },
                linkProgram: () => {
                },
                useProgram: () => {
                },
                getAttribLocation: () => 0,
                getUniformLocation: () => ({}),
                enableVertexAttribArray: () => {
                },
                vertexAttribPointer: () => {
                },
                getExtension: (name: string) => null,
                getParameter: (param: string) => '0',
                createTexture: () => ({}),
                bindTexture: () => {
                },
                texParameter: () => {
                },
                texParameteri: () => {
                },
                texImage2D: () => {
                },
                texSubImage2D: () => {
                },
                clearColor: () => {
                },
                clearDepth: () => {
                },
                clearStencil: () => {
                },
            }
        }
        throw new Error(`Context ${contextId} not supported`)
    }
}

class HTMLImageElement extends HTMLElement {
    constructor() {
        super('img');
    }

    complete: boolean = false
    onload: ((this: HTMLImageElement, ev?: any) => any) | null = null
    onerror: ((this: HTMLImageElement, ev?: any) => any) | null = null
    _src: string = ''
    set src(value: string) {
        this._src = value;
        setTimeout(() => {
            this.complete = true
            const ev = new Event('load')
            if (this.onload) {
                this.onload(ev)
            }
            this.dispatchEvent(ev)
        }, 1)
    }

    get src() {
        return this._src;
    }
}

URL.createObjectURL and URL.removeObjectURL

typescript
const registeredBlobs = new Map<string, any>()
URL.createObjectURL = (obj) => {
    const key = Math.random().toString(36).substring(2)
    registeredBlobs.set(key, obj)
    return `blob:http://localhost/${key}`
}
URL.revokeObjectURL = (url: string) => {
    const key = url.split('/').pop()
    if (key) registeredBlobs.delete(key)
}

const fetch1 = fetch
const fetch2 = async (urlOrReq: string | Request, options?: RequestInit) => {
    const url = typeof urlOrReq === 'string' ? urlOrReq : urlOrReq.url
    if (url.startsWith('blob:')) {
        const url2 = new URL(url)
        const key = url2.pathname.split('/').pop()
        if (key && registeredBlobs.has(key)) {
            return new Response(registeredBlobs.get(key))
        }
        throw new Error(`Blob not found for URL: ${url}`)
    }
    return fetch1(url, options)
}

FileReader

typescript

class FileReader {
    result: string | ArrayBuffer | null = null
    onload: ((this: FileReader, ev?: any) => any) | null = null
    onloadend: ((this: FileReader, ev?: any) => any) | null = null

    _read(data: string | ArrayBuffer) {
        setTimeout(() => {
            this.result = data
            if (this.onload) {
                // this.onload(new CustomEvent('load', { detail: { target: this, result: data } }))
                this.onload()
            }
            if (this.onloadend) {
                // this.onloadend(new CustomEvent('loadend', { detail: { target: this, result: data } }))
                this.onloadend()
            }
        }, 1)
    }

    readAsArrayBuffer(file: File | Blob) {
        file.arrayBuffer().then((buffer) => {
            this._read(buffer);
        })
    }

    readAsDataURL(file: File | Blob) {
        file.arrayBuffer().then((buffer) => {
            const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)))
            this._read(`data:${file.type};base64,${base64}`);
        })
    }

    readAsText(file: File | Blob, encoding?: string) {
        file.text().then((text) => {
            this._read(text);
        })
    }
}

Global Variables

typescript
const document = new HTMLDocument('document')
globalThis.document = document
globalThis.ImageData = ImageData
globalThis.window = globalThis
globalThis.location = {
    href: 'http://localhost/',
    origin: 'http://localhost',
    protocol: 'http:',
    host: 'localhost',
    hostname: 'localhost',
    port: '80',
    pathname: '/',
    search: '',
    hash: '',
    assign: (url: string) => {
        globalThis.location.href = url
    },
    reload: () => {
        // no-op
    },
    replace: (url: string) => {
        globalThis.location.href = url
    }
}
globalThis.HTMLElement = HTMLElement
globalThis.FileReader = FileReader
globalThis.fetch = fetch2 as any
globalThis.ReadableStream = undefined // for FileLoader

Made with ❤️ using the awesome vitepress