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