UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

1,003 lines (854 loc) 28.5 kB
/* global XMLHttpRequest, requestAnimationFrame, Blob, DataTransfer, DragEvent, FileList, MessageEvent, reportError */ /* eslint-disable import/first */ // mark when runtime did init console.assert( !globalThis.__RUNTIME_INIT_NOW__, 'socket:internal/init.js was imported twice. ' + 'This could lead to undefined behavior.' ) console.assert( !globalThis.process?.versions?.node, 'socket:internal/init.js was imported in Node.js. ' + 'This could lead to undefined behavior.' ) import './primitives.js' import ipc from '../ipc.js' ipc.sendSync('platform.event', 'beforeruntimeinit') import { CustomEvent, ErrorEvent } from '../events.js' import { IllegalConstructor } from '../util.js' import { URL, protocols } from '../url.js' import * as asyncHooks from './async/hooks.js' import { Deferred } from '../async.js' import { rand64 } from '../crypto.js' import location from '../location.js' import * as sw from '../shared-worker/index.js' import mime from '../mime.js' import path from '../path.js' import os from '../os.js' import fs from '../fs/promises.js' import { createFileSystemDirectoryHandle, createFileSystemFileHandle } from '../fs/web.js' const GlobalWorker = globalThis.Worker || class Worker extends EventTarget {} // init `SharedWorker` window context sw.getContextWindow() // only patch a webview or worker context if ((globalThis.window || globalThis.self) === globalThis) { if (typeof globalThis.queueMicrotask === 'function') { const originalQueueMicrotask = globalThis.queueMicrotask const promise = Promise.resolve() globalThis.queueMicrotask = queueMicrotask globalThis.addEventListener('queuemicrotaskerror', (e) => { throw e.error }) function queueMicrotask (...args) { if (args.length === 0) { throw new TypeError('Not enough arguments') } const [callback] = args if (typeof callback !== 'function') { throw new TypeError( 'Argument 1 (\'callback\') to globalThis.queueMicrotask ' + 'must be a function' ) } originalQueueMicrotask(task) function task () { try { return asyncHooks.wrap(callback, 'Microtask').call(globalThis) } catch (error) { // XXX(@jwerle): `queueMicrotask()` is broken in WebKit WebViews // If an error is thrown, it does not bubble to the `globalThis` // object, but is instead silently discarded. This is misleading // and results in confusion for developers trying to debug something // they may have done wrong. Here we will rethrow the exception out // of microtask execution context with the best function we can // possibly use to report the exception to the `globalThis` object // as an `Unhandled Promise Rejection` error const event = new ErrorEvent('queuemicrotaskerror', { error }) promise.then(() => globalThis.dispatchEvent(event)) } } } } } // webview only features if ((globalThis.window) === globalThis) { globalThis.addEventListener('platformdrop', async (event) => { const handles = [] let target = globalThis if ( typeof event.detail?.x === 'number' && typeof event.detail?.y === 'number' ) { target = ( globalThis.document.elementFromPoint(event.detail.x, event.detail.y) ?? globalThis ) } if (Array.isArray(event.detail?.files)) { for (const file of event.detail.files) { if (typeof file === 'string') { try { const stats = await fs.stat(file) if (stats.isDirectory()) { handles.push(await createFileSystemDirectoryHandle(file, { writable: false })) } else { handles.push(await createFileSystemFileHandle(file, { writable: false })) } } catch (err) { try { // try to read from navigator const response = await fetch(file) if (response.ok) { const lastModified = Date.now() const buffer = new Uint8Array(await response.arrayBuffer()) const types = await mime.lookup(path.extname(file).slice(1)) const type = types[0]?.mime ?? '' handles.push(await createFileSystemFileHandle( new File(buffer, { lastModified, type }), { writable: false } )) } else { console.warn('platformdrop: ', err) } } catch (err) { console.warn('platformdrop: ', err) } } } } } const dataTransfer = new DataTransfer() const files = [] for (const handle of handles) { if (typeof handle.getFile === 'function') { const file = handle.getFile() const buffer = new Uint8Array(await file.arrayBuffer()) files.push(new File(buffer, file.name, { lastModified: file.lastModified, type: file.type })) } } const fileList = Object.create(FileList.prototype, { length: { configurable: false, enumerable: false, get: () => files.length }, item: { configurable: false, enumerable: false, value: (index) => files[index] ?? null }, [Symbol.iterator]: { configurable: false, enumerable: false, get: () => files[Symbol.iterator] } }) for (let i = 0; i < handles.length; ++i) { const file = files[i] if (file) { dataTransfer.items.add(file) } else { dataTransfer.items.add(handles[i].name, 'text/directory') } } Object.defineProperties(dataTransfer, { files: { configurable: false, enumerable: false, value: fileList } }) const dropEvent = new DragEvent('drop', { dataTransfer }) Object.defineProperty(dropEvent, 'detail', { value: { handles } }) let index = 0 for (const item of dropEvent.dataTransfer.items) { const handle = handles[index++] Object.defineProperties(item, { getAsFileSystemHandle: { configurable: false, enumerable: false, value: async () => handle } }) } target.dispatchEvent(dropEvent) if (handles.length) { globalThis.dispatchEvent(new CustomEvent('dropfiles', { detail: { handles } })) } }) // TODO: move this somewhere more appropriate if (os.platform() === 'ios') { const timing = { duration: 346 // TODO: document this } const keyboard = { opened: false, offset: 0, height: 0 } const bezier = { show (t) { const p1 = 0.9 const p2 = 0.95 return 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t }, hide (t) { const p1 = 0.86 const p2 = 0.95 return 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t } } globalThis.addEventListener('keyboard', function (event) { const { detail } = event keyboard.height = detail.value.height if (keyboard.offset === 0) { keyboard.offset = document.body.offsetHeight } if (detail.value.event === 'will-show' && !keyboard.opened) { let start = null requestAnimationFrame(function animate (timestamp) { if (!start) start = timestamp const elapsed = timestamp - start const progress = Math.min(elapsed / timing.duration, 1) const easeProgress = bezier.show(progress) const currentHeight = keyboard.offset - (easeProgress * keyboard.height) globalThis.document.body.style.height = `${currentHeight}px` if (progress < 1) { keyboard.opened = true requestAnimationFrame(animate) } }) } if (detail.value.event === 'will-hide' && keyboard.opened) { keyboard.opened = false const { offsetHeight } = globalThis.document.body let start = null requestAnimationFrame(function animate (timestamp) { if (!start) start = timestamp const elapsed = timestamp - start let progress = Math.min(elapsed / timing.duration, 1) const easeProgress = bezier.hide(progress) const currentHeight = offsetHeight + (easeProgress * keyboard.height) if (currentHeight <= 0) progress = 1 globalThis.document.body.style.height = `${currentHeight}px` if (progress < 1) { requestAnimationFrame(animate) } else { keyboard.opened = false } }) } }) } } class RuntimeWorker extends GlobalWorker { /** * Internal worker pool * @ignore */ static pool = globalThis.top?.Worker?.pool ?? new Map() /** * Handles `Symbol.species` * @ignore */ static get [Symbol.species] () { return GlobalWorker } #id = null #objectURL = null #onglobaldata = null /** * `RuntimeWorker` class worker. * @ignore * @param {string|URL} filename * @param {object=} [options] */ constructor (filename, options, ...args) { options = { ...options } if (typeof filename === 'string' && !URL.canParse(filename, location.href)) { const blob = new Blob([filename], { type: 'text/javascript' }) filename = URL.createObjectURL(blob).toString() } else if (String(filename).startsWith('blob')) { const request = new XMLHttpRequest() request.open('GET', String(filename), false) request.send() const blob = new Blob([request.responseText || request.response], { type: 'text/javascript' }) filename = URL .createObjectURL(blob) .toString() } const workerType = options[Symbol.for('socket.runtime.internal.worker.type')] ?? 'worker' const url = encodeURIComponent(new URL(filename, location.href).toString()) const id = String(rand64()) const topClient = globalThis.__args.client.top || globalThis.__args.client const __args = { ...globalThis.__args, client: {} } const preload = ` Object.defineProperty(globalThis, '__args', { configurable: false, enumerable: false, value: ${JSON.stringify(__args)} }) globalThis.__args.client.id = '${id}' globalThis.__args.client.type = 'worker' globalThis.__args.client.frameType = 'none' globalThis.__args.client.parent = ${JSON.stringify({ id: globalThis.__args?.client?.id, top: null, type: globalThis.__args?.client?.type, parent: null, frameType: globalThis.__args?.client?.frameType })} globalThis.__args.client.top = ${JSON.stringify({ id: topClient?.id, top: null, type: topClient?.type, parent: null, frameType: topClient?.frameType })} globalThis.__args.client.parent.top = globalThis.__args.client.top Object.defineProperty(globalThis, 'isWorkerScope', { configurable: false, enumerable: false, writable: false, value: true }) Object.defineProperty(globalThis, 'isSocketRuntime', { configurable: false, enumerable: false, writable: false, value: true }) Object.defineProperty(globalThis, 'RUNTIME_WORKER_ID', { configurable: false, enumerable: false, writable: false, value: '${id}' }) Object.defineProperty(globalThis, 'RUNTIME_WORKER_TYPE', { configurable: false, enumerable: false, writable: false, value: '${workerType}' }) Object.defineProperty(globalThis, 'RUNTIME_WORKER_LOCATION', { configurable: false, enumerable: false, writable: true, value: decodeURIComponent('${url}') }) Object.defineProperty(globalThis, 'RUNTIME_WORKER_MESSAGE_EVENT_BACKLOG', { configurable: false, enumerable: false, value: [] }) globalThis.addEventListener('message', onInitialWorkerMessages) function onInitialWorkerMessages (event) { RUNTIME_WORKER_MESSAGE_EVENT_BACKLOG.push(event) } try { await import('${location.origin}/socket/internal/init.js') const hooks = await import('${location.origin}/socket/hooks.js') hooks.onReady(() => { globalThis.removeEventListener('message', onInitialWorkerMessages) }) await import('${location.origin}/socket/internal/worker.js?source=${url}') } catch (err) { globalThis.reportError(err) } `.trim() const objectURL = URL.createObjectURL( new Blob([preload.trim()], { type: 'text/javascript' }) ) // level 1 worker `EventTarget` 'message' listener let onmessage = null // events are routed through this `EventTarget` const eventTarget = new EventTarget() super(objectURL, { ...options, type: 'module' }, ...args) RuntimeWorker.pool.set(id, new WeakRef(this)) this.#id = id this.#objectURL = objectURL this.#onglobaldata = (event) => { const data = new Uint8Array(event.detail.data).buffer this.postMessage({ __runtime_worker_event: { type: event.type, detail: { ...event.detail, data } } }, [data]) } globalThis.addEventListener('data', this.#onglobaldata) const postMessage = this.postMessage.bind(this) const addEventListener = this.addEventListener.bind(this) const removeEventListener = this.removeEventListener.bind(this) const postMessageQueue = [] let isReady = false this.postMessage = (...args) => { if (!isReady) { postMessageQueue.push(args) } else { return postMessage(...args) } } this.addEventListener = (eventName, ...args) => { if (eventName === 'message') { return eventTarget.addEventListener(eventName, ...args) } return addEventListener(eventName, ...args) } this.removeEventListener = (eventName, ...args) => { if (eventName === 'message') { return eventTarget.removeEventListener(eventName, ...args) } return removeEventListener(eventName, ...args) } Object.defineProperty(this, 'onmessage', { configurable: false, get: () => onmessage, set: (value) => { if (typeof onmessage === 'function') { eventTarget.removeEventListener('message', onmessage) } if (value === null || typeof value === 'function') { onmessage = value eventTarget.addEventListener('message', onmessage) } } }) queueMicrotask(() => { addEventListener('message', (event) => { const { data } = event if (data?.__runtime_worker_init === true) { isReady = true for (const args of postMessageQueue) { postMessage(...args) } postMessageQueue.splice(0, postMessageQueue.length) } else if (data?.__runtime_worker_ipc_request) { const request = data.__runtime_worker_ipc_request if ( typeof request?.message === 'string' && request.message.startsWith('ipc://') ) { queueMicrotask(async () => { try { // eslint-disable-next-line no-use-before-define const transfer = [] const message = ipc.Message.from(request.message, request.bytes) const options = { bytes: message.bytes } const promise = ipc.send(message.name, message.rawParams, options) if (message.get('resolve') === false) { return } const result = await promise ipc.findMessageTransfers(transfer, result) this.postMessage({ __runtime_worker_ipc_result: { message: message.toJSON(), result: result.toJSON() } }, { transfer }) } catch (err) { globalThis.reportError(err) } }) } } else { return eventTarget.dispatchEvent(new MessageEvent(event.type, event)) } }) }) } get id () { return this.#id } get objectURL () { return this.#objectURL } terminate () { globalThis.removeEventListener('data', this.#onglobaldata) return super.terminate() } } // patch `globalThis.Worker` if (globalThis.Worker === GlobalWorker) { globalThis.Worker = RuntimeWorker } // patch `globalThis.XMLHttpRequest` if (typeof globalThis.XMLHttpRequest === 'function') { const { open, send, setRequestHeader } = globalThis.XMLHttpRequest.prototype const additionalHeaders = {} const headerFilters = ['user-agent'] const isAsync = Symbol('isAsync') let queue = null if ( typeof globalThis.__args.config.webview_fetch_headers_filter === 'string' && globalThis.__args.config.webview_fetch_headers_filter.length > 0 ) { const filters = globalThis.__args.config.webview_fetch_headers_filter.split(' ') for (const filter of filters) { headerFilters.push(new RegExp(filter.replace(/\*/g, '(.*)').replace(/\.\.\*/g, '.*'), 'i')) } } for (const key in globalThis.__args.config) { if (key.startsWith('webview_fetch_headers_')) { const name = key.replace('webview_fetch_headers_', '') additionalHeaders[name] = globalThis.__args.config[name] } } globalThis.XMLHttpRequest.prototype.setRequestHeader = function (name, value) { if (testHeaderFilters(name)) { try { setRequestHeader.call(this, name, value) } catch {} } function testHeaderFilters (header) { if (header.toLowerCase().startsWith('sec-')) { return false } for (const filter of headerFilters) { if (typeof filter === 'string') { if (filter === name.toLowerCase()) { return false } } else if (filter.test(header)) { return false } } return true } } globalThis.XMLHttpRequest.prototype.open = function (method, url, isAsyncRequest, ...args) { Object.defineProperty(this, isAsync, { configurable: false, enumerable: false, writable: false, value: isAsyncRequest !== false }) if (typeof url === 'string') { try { url = new URL(url, location.origin) } catch {} } const value = open.call(this, method, url.toString(), isAsyncRequest !== false, ...args) if ( method !== 'OPTIONS' && ( globalThis.__args?.config?.webview_fetch_allow_runtime_headers === true || (url.protocol && /(socket|ipc|node|npm):/.test(url.protocol)) || (url.protocol && protocols.handlers.has(url.protocol.slice(0, -1))) || url.hostname === globalThis.__args.config.meta_bundle_identifier ) ) { for (const key in additionalHeaders) { this.setRequestHeader(key, additionalHeaders[key]) } if (globalThis.__args?.client) { this.setRequestHeader('Runtime-Client-ID', globalThis.__args.client.id) } if (typeof globalThis.RUNTIME_WORKER_LOCATION === 'string') { this.setRequestHeader('Runtime-Worker-Location', globalThis.RUNTIME_WORKER_LOCATION) } if (globalThis.top && globalThis.top !== globalThis) { this.setRequestHeader('Runtime-Frame-Type', 'nested') } else if (!globalThis.window && globalThis.self === globalThis) { this.setRequestHeader('Runtime-Frame-Type', 'worker') if (globalThis.clients && globalThis.FetchEvent) { this.setRequestHeader('Runtime-Worker-Type', 'serviceworker') } else { this.setRequestHeader('Runtime-Worker-Type', 'worker') } } else { this.setRequestHeader('Runtime-Frame-Type', 'top-level') } return value } } globalThis.XMLHttpRequest.prototype.send = async function (...args) { if (!this[isAsync]) { return await send.call(this, ...args) } if (!queue) { // eslint-disable-next-line no-use-before-define queue = new ConcurrentQueue( // eslint-disable-next-line no-use-before-define parseInt(config.webview_xhr_concurrency) ) } await queue.push(new Promise((resolve) => { this.addEventListener('error', resolve) this.addEventListener('readystatechange', () => { if ( this.readyState === globalThis.XMLHttpRequest.DONE || this.readyState === globalThis.XMLHttpRequest.UNSENT ) { resolve() } }) })) return await send.call(this, ...args) } } import hooks, { RuntimeInitEvent } from '../hooks.js' import { config } from '../application.js' import globals from './globals.js' import '../console.js' hooks.onApplicationResume((e) => { for (const ref of RuntimeWorker.pool.values()) { const worker = ref.deref() if (worker) { worker.postMessage({ __runtime_worker_event: { type: 'applicationresume' } }) } } }) hooks.onApplicationPause((e) => { for (const ref of RuntimeWorker.pool.values()) { const worker = ref.deref() if (worker) { worker.postMessage({ __runtime_worker_event: { type: 'applicationpause' } }) } } }) hooks.onApplicationURL((e) => { for (const ref of RuntimeWorker.pool.values()) { const worker = ref.deref() if (worker) { worker.postMessage({ __runtime_worker_event: { type: 'applicationurl', detail: { data: e.data, url: String(e.url ?? '') } } }) } } }) ipc.sendSync('platform.event', { value: 'load', 'location.href': globalThis.location.href }) class ConcurrentQueue extends EventTarget { concurrency = Infinity pending = [] constructor (concurrency) { super() if (typeof concurrency === 'number' && concurrency > 0) { this.concurrency = concurrency } this.addEventListener('error', (event) => { if (event.defaultPrevented !== true) { // @ts-ignore const { error, type } = event globalThis.dispatchEvent?.(new ErrorEvent(type, { error })) } }) } async wait () { if (this.pending.length < this.concurrency) return const offset = (this.pending.length - this.concurrency) + 1 const pending = this.pending.slice(0, offset) await Promise.all(pending) } peek () { return this.pending[0] } timeout (request, timer) { let timeout = null const onresolve = () => { clearTimeout(timeout) const index = this.pending.indexOf(request) if (index > -1) { this.pending.splice(index, 1) } } timeout = setTimeout(onresolve, timer || 32) return onresolve } async push (request, timer) { await this.wait() this.pending.push(request) request.then(this.timeout(request, timer)) } } class RuntimeXHRPostQueue extends ConcurrentQueue { async dispatch (id, seq, params, headers, options = null) { if (options?.workerId) { if (RuntimeWorker.pool.has(options.workerId)) { const worker = RuntimeWorker.pool.get(options.workerId)?.deref?.() if (worker) { worker.postMessage({ __runtime_worker_event: { type: 'runtime-xhr-post-queue', detail: { id, seq, params, headers } } }) return } } } const promise = new Deferred() await this.push(promise, 8) if (typeof params !== 'object') { params = {} } const result = await ipc.request('post', { id }, { responseType: 'arraybuffer' }) promise.resolve() if (result.err) { this.dispatchEvent(new ErrorEvent('error', { error: result.err })) } else { const { data } = result const detail = { headers, params, data, id } globalThis.dispatchEvent(new CustomEvent('data', { detail })) } } } hooks.onLoad(async () => { const registeredServiceWorkers = new Set() const serviceWorkerScripts = config['webview_service-workers'] const pending = [] if ( globalThis.window && !globalThis.__RUNTIME_SERVICE_WORKER_CONTEXT__ && globalThis.location.pathname !== '/socket/service-worker/index.html' && String(config.permissions_allow_service_worker) !== 'false' && String(config.webview_auto_register_service_workers) !== 'false' ) { const pendingServiceRegistrations = [] if (typeof config['webview_service-workers'] === 'string') { for (const scriptURL of serviceWorkerScripts.trim().split(' ')) { pendingServiceRegistrations.push({ scriptURL: scriptURL.trim(), options: {} }) } } for (const key in config) { if (key.startsWith('webview_service-workers_')) { const scope = key.replace('webview_service-workers_', '') const scriptURL = config[key].trim() pendingServiceRegistrations.push({ scriptURL, options: { scope } }) } } for (const registration of pendingServiceRegistrations) { const { options } = registration let { scriptURL } = registration if (!scriptURL.startsWith('/') && scriptURL.startsWith('.')) { if (!URL.canParse(scriptURL, globalThis.location.href)) { scriptURL = `./${scriptURL}` } } const url = new URL(scriptURL, globalThis.location.origin) const scope = options.scope ?? new URL('.', url).pathname if (!globalThis.location.pathname.startsWith(scope)) { continue } if (registeredServiceWorkers.has(scriptURL)) { continue } const promise = globalThis.navigator.serviceWorker.register(scriptURL, options) registeredServiceWorkers.add(scriptURL) pending.push(promise) promise .then((registration) => { if (!registration) { console.warn( 'ServiceWorker failed to register in preload: %s', scriptURL ) } }) .catch((err) => { console.error( 'ServiceWorker registration error occurred in preload: %s:', scriptURL, err ) }) } } await Promise.all(pending) if (typeof globalThis.dispatchEvent === 'function') { globalThis.__RUNTIME_INIT_NOW__ = performance.now() globalThis.dispatchEvent(new RuntimeInitEvent()) } if (globalThis.document) { const beginRuntimePreload = globalThis.document.querySelector('meta[name=begin-runtime-preload]') if (beginRuntimePreload) { let current = beginRuntimePreload while (current) { const next = current.nextElementSibling current.remove() if (current.tagName === 'META' && current.name === 'end-runtime-preload') { current = null } else { current = next } } } } }) // async preload modules hooks.onReady(async () => { try { // precache 'fs.constants' and 'os.constants' await ipc.request('fs.constants', {}, { cache: true }) await ipc.request('os.constants', {}, { cache: true }) await import('../diagnostics.js') await import('../process/signal.js') await import('../fs/fds.js') await import('../constants.js') const errors = await import('../errors.js') // lazily install this const errno = await import('../errno.js') errors.ErrnoError.errno = errno } catch (err) { console.error(err.stack || err) } }) // symbolic globals globals.register('RuntimeXHRPostQueue', new RuntimeXHRPostQueue()) globals.register('RuntimeExecution', new asyncHooks.CoreAsyncResource('RuntimeExecution')) // prevent further construction if this class is indirectly referenced RuntimeXHRPostQueue.prototype.constructor = IllegalConstructor Object.defineProperty(globalThis, '__globals', { enumerable: false, configurable: false, value: globals }) ipc.send('platform.event', 'runtimeinit') .then(() => { globals.get('RuntimeReadyPromiseResolvers')?.resolve() }, reportError) export default { location }