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,931 lines (1,671 loc) 48.9 kB
/** * @module vm * * This module enables compiling and running JavaScript source code in an * isolated execution context optionally with a user supplied context object. * * Example usage: * ```js * import fs from 'socket:fs/promises' * import vm from 'socket:vm' * * const data = await fs.readFile('data.json') * const context = { data, value: null } * const source = ` * const text = new TextDecoder().decode(data) * const json = JSON.parse(text) * * // get `.value` from parsed JSON and set it to global `value` * // that exists on the user context * value = json.value * ` * const result = await vm.runInContext(source, context) * console.log(context.value) // set from `json.value` in VM context * ``` */ /* eslint-disable no-new-func */ /* global ErrorEvent, EventTarget, MessagePort */ import { maybeMakeError } from './ipc.js' import { SharedWorker } from './shared-worker/index.js' import { isESMSource } from './util.js' import application from './application.js' import globals from './internal/globals.js' import process from './process.js' import console from './console.js' import crypto from './crypto.js' import os from './os.js' import gc from './gc.js' const AsyncFunction = (async () => {}).constructor const Uint8ArrayPrototype = Uint8Array.prototype const TypedArrayPrototype = Object.getPrototypeOf(Uint8ArrayPrototype) const TypedArray = TypedArrayPrototype.constructor const kContextTag = Symbol('socket.vm.Context') const kWorkerContextReady = Symbol('socket.vm.ContextWorker.ready') const VM_WINDOW_INDEX = 47 const VM_WINDOW_TITLE = 'socket:vm' const VM_WINDOW_PATH = '/socket/vm/index.html' let contextWorker = null let contextWindow = null // A weak mapping of context objects to `Script` instances where "context" // objects own the `Script` until the "context" is no longer stronglyheld // in which the `Script` eventually becomes garbage collected triggering // the `gc.finalizer` callback to be invoked cleaning up any allocated // resources for the script context such as iframes and workers or any // resources created in the script "world" in the VM realm const scripts = new WeakMap() // A weak mapping of created contexts const contexts = new WeakMap() // a weak mapping of created global objects const globalObjects = new WeakMap() // a shared context when one is not given const sharedContext = createContext({}) // blob URL caches key by content hash const blobURLCache = new Map() // A weak mapping of values to reference objects const references = Object.assign(new WeakMap(), { // A mapping of reference IDs to weakly held `Reference` instances index: new Map() }) function isTypedArray (object) { return object instanceof TypedArray } function isArrayBuffer (object) { return object instanceof ArrayBuffer } function convertSourceToString (source) { if (source && typeof source !== 'string') { if (typeof source.valueOf === 'function') { source = source.valueOf() } if (typeof source.toString === 'function') { source = source.toString() } } if (typeof source !== 'string') { throw new TypeError( 'Expecting Script source to be a string ' + `or a value that can be converted to one. Received: ${source}` ) } return source } /** * Shared broadcast for virtual machaines * @type {BroadcastChannel} */ export const channel = new BroadcastChannel('socket.runtime.vm') /** * @ignore * @param {object[]} transfer * @param {object} object * @param {object=} [options] * @return {object[]} */ export function findMessageTransfers (transfers, object, options = null) { if (isTypedArray(object) || ArrayBuffer.isView(object)) { add(object.buffer) } else if (isArrayBuffer(object)) { add(object) } else if (Array.isArray(object)) { for (const value of object) { findMessageTransfers(transfers, value, options) } } else if (object && typeof object === 'object') { if ( object instanceof MessagePort || ( typeof object.postMessage === 'function' && Object.getPrototypeOf(object).constructor.name === 'MessagePort' ) ) { add(object) } else { for (const key in object) { if ( key.startsWith('__vmScriptReferenceArgs_') && options?.ignoreScriptReferenceArgs === true ) { continue } findMessageTransfers(transfers, object[key], options) } } } return transfers function add (value) { if (!transfers.includes(value)) { transfers.push(value) } } } /** * @ignore * @param {object} context */ export function applyInputContextReferences (context) { if (!context || typeof context !== 'object') { return } visitObject(context) function visitObject (object) { for (const key in object) { const value = object[key] if (value && typeof value === 'object') { if (value.__vmScriptReference__ === true && value.id) { const reference = getReference(value.id) if (reference) { object[key] = reference.value Object.defineProperty(context, value.id, { configurable: false, enumerable: false, value: reference.value }) } } else { visitObject(value) } } } } } /** * @ignore * @param {object} context */ export function applyOutputContextReferences (context) { if (!context || typeof context !== 'object') { return } visitObject(context) function visitObject (object) { if (object.__vmScriptReference__ && 'value' in object) { object = object.value } if (!object || typeof object !== 'object') { return } const keys = new Set(Object.keys(object)) if ( object && typeof object === 'object' && !(object instanceof Reference) && Object.getPrototypeOf(object) !== Object.prototype && Object.getPrototypeOf(object) !== Array.prototype ) { const prototype = Object.getPrototypeOf(object) if (prototype) { const descriptors = Object.getOwnPropertyDescriptors(prototype) if (descriptors) { for (const key of Object.keys(descriptors)) { if (key !== 'constructor') { keys.add(key) } } } } } for (const key of keys) { if (key.startsWith('__vmScriptReferenceArgs_')) { Reflect.deleteProperty(object, key) continue } let value = object[key] if (value && typeof value === 'object') { if (Symbol.toStringTag in value) { const tag = typeof value[Symbol.toStringTag] === 'function' ? value[Symbol.toStringTag]() : value[Symbol.toStringTag] if (tag === 'Module') { value = object[key] = { ...value } } } if (!(value.__vmScriptReference__ === true && value.id)) { visitObject(value) } } if (typeof value === 'function') { const reference = getReference(value) ?? createReference(value, context) object[key] = reference.toJSON() } } } } /** * @ignore * @param {object} context */ export function filterNonTransferableValues (context) { if (!context || typeof context !== 'object') { return } visitObject(context) function visitObject (object) { for (const key in object) { const value = object[key] if (value && typeof value === 'object') { visitObject(value) } else if (typeof value === 'function') { const reference = getReference(value) if (reference) { object[key] = reference.toJSON() } else { Reflect.deleteProperty(object, key) } } } } } /** * @ignore * @param {object=} [currentContext] * @param {object=} [updatedContext] * @param {object=} [contextReference] * @return {{ deletions: string[], merges: string[] }} */ export function applyContextDifferences ( currentContext = null, updatedContext = null, contextReference = null, preserveScriptArgs = false ) { if (!currentContext || typeof currentContext !== 'object') { return } const deletions = [] const merges = [] const script = scripts.get(contextReference ?? currentContext) for (const key in updatedContext) { const updatedValue = Reflect.get(updatedContext, key) if (updatedValue?.__vmScriptReference__ === true) { const reference = updatedValue if (reference.type === 'function') { const ref = getReference(reference.id) if (ref) { Reflect.set(currentContext, key, ref.value) } else if (script) { const container = { [key]: function (...args) { const isConstructorCall = this instanceof container[key] const scriptReferenceArgsKey = `__vmScriptReferenceArgs_${reference.id}__` Reflect.set(contextReference, scriptReferenceArgsKey, args) Reflect.set(contextReference, reference.id, reference) const promise = new Promise((resolve, reject) => { const promise = script.runInContext(contextReference, { mode: 'classic', source: `${isConstructorCall ? 'new ' : ''}globalObject['${reference.id}'](...globalObject['${scriptReferenceArgsKey}'])` }) promise.then(resolve).catch(reject) }) promise.finally(() => { Reflect.deleteProperty(contextReference, reference.id) Reflect.deleteProperty(contextReference, scriptReferenceArgsKey) }) if (!isConstructorCall) { return promise.then((result) => { if (result?.__vmScriptReference__ === true) { return result.value } return result }) } return new Proxy(function () {}, { get (target, property, receiver) { return new Proxy(function () {}, { apply (target, __, argumentList) { return apply(promise) function apply (result) { if (!result?.then) { return result } return result .then((result) => { if (result?.value) { applyContextDifferences(result, result, contextReference) if (typeof result.value === 'object' && property in result.value) { if (typeof result.value[property] === 'function') { return result.value[property](...argumentList) } return result.value[property] } return result.value } else { return result } }) .then(apply) } } }) }, apply (target, thisArg, argumentList) { return promise .then((result) => typeof result === 'function' ? result(...args) : result ) .then((result) => typeof result === 'function' ? isConstructorCall ? result.call(thisArg, ...argumentList) : result.bind(thisArg, ...argumentList) : result ) .then((result) => { applyContextDifferences(result, result, contextReference) return result }) } }) } } // wrap into container for named function, called in tail with an // intentional omission of `await` for an async call stack collapse // this preserves naming in `console.log`: // [AsyncFunction: functionName] // while also removing an unneeded tail call in a stack trace const containerForNamedFunction = { [key]: function (...args) { if (this instanceof containerForNamedFunction[key]) { return new container[key](...args) } return container[key].call(this, ...args) } } // the reference ID was created on the other side, just use it here // instead of creating a new one which will preserve the binding // between this caller context and the realm world where the script // execution is actually occuring putReference(new Reference( reference.id, containerForNamedFunction[key], contextReference, { external: true } )) // emplace an actual function on `currentContext` at the property // `key` which will do the actual proxy call to the VM script Reflect.set(currentContext, key, containerForNamedFunction[key]) } } } else if (Reflect.has(currentContext, key)) { const currentValue = Reflect.get(currentContext, key) if ( currentValue && typeof currentValue === 'object' && updatedValue && typeof updatedValue === 'object' ) { merges.push([key, currentValue, updatedValue]) } else { Reflect.set(currentContext, key, updatedValue) } } else { Reflect.set(currentContext, key, updatedValue) } } for (const key in currentContext) { if (!preserveScriptArgs && key.startsWith('__vmScriptReferenceArgs_')) { Reflect.deleteProperty(currentContext, key) } if (!Reflect.has(updatedContext, key)) { deletions.push(key) Reflect.deleteProperty(currentContext, key) } } for (const merge of merges) { const [key, currentValue, updatedValue] = merge if (isTypedArray(updatedValue) || isArrayBuffer(updatedValue)) { Reflect.set(currentContext, key, updatedValue) } else if (!Array.isArray(currentValue) && Array.isArray(updatedValue)) { Reflect.set(currentContext, key, updatedValue) } else if (Array.isArray(currentValue) && Array.isArray(updatedValue)) { currentValue.length = updatedValue.length for (let i = 0; i < updatedValue.length; ++i) { Reflect.set(currentValue, i, Reflect.get(updatedValue, i)) } } else { applyContextDifferences(currentValue, updatedValue, contextReference) } } return { deletions, merges: merges.map((merge) => merge[0]) } } /** * Wrap a JavaScript function source. * @ignore * @param {string} source * @param {object=} [options] */ export function wrapFunctionSource (source, options = null) { source = source.trim() if ( source.startsWith('{') || source.startsWith('async') || source.startsWith('function') || source.startsWith('class') ) { source = `(${source})` } else if (source.includes('return') || source.includes(';') || source.includes('throw')) { source = `{\n${source}\n}` } else if (source.includes('\n')) { const parts = source.trim().split('\n') const last = parts.pop() const tmp = last.trim() if ( !/^(if|return|while|do|switch|let|var|const|for)/.test(tmp) && !/^[{|;|/]/.test(tmp) && !/}$/.test(tmp) && !/\w\s*=\*\w/.test(tmp) ) { source = parts.concat(`return ${last}`).join('\n') } source = `{\n${source}\n}` } return ` with (this) { return (${options?.async ? 'async' : ''} (arguments) => ${source})(typeof arguments !== 'undefined' ? arguments : []); } //# sourceURL=${options?.filename || 'wrapped-function-source.js'} `.trim() } /** * A container for a context worker message channel that looks like a "worker". * @ignore */ export class ContextWorkerInterface extends EventTarget { #channel = null constructor () { super() this.#channel = new MessageChannel() } get channel () { return this.#channel } get port () { return this.#channel.port1 } destroy () { try { this.#channel.port1.close() this.#channel.port2.close() } catch {} this.#channel = null } } /** * A container proxy for a context worker message channel that * looks like a "worker". * @ignore */ export class ContextWorkerInterfaceProxy extends EventTarget { #globals = null constructor (globals) { super() this.#globals = globals gc.ref(this) } get port () { return this.#globals.get('vm.contextWorker')?.channel?.port2 } [gc.finalizer] () { return { args: [this.port], async handle (port) { if (port) { try { port.close() } catch {} } } } } } /** * Global reserved values that a script context may not modify. * @type {string[]} */ export const RESERVED_GLOBAL_INTRINSICS = [ '__args', '__globals', 'top', 'self', 'this', 'window', 'webkit', 'chrome', 'external', 'postMessage', 'Infinity', 'NaN', 'undefined', 'eval', 'isFinite', 'isNaN', 'parseFloat', 'parseInt', 'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent', 'AggregateError', 'Array', 'ArrayBuffer', 'Atomics', 'BigInt', 'BigInt64Array', 'BigUint64Array', 'Boolean', 'DataView', 'Date', 'Error', 'EvalError', 'FinalizationRegistry', 'Float32Array', 'Float64Array', 'Function', 'Int8Array', 'Int16Array', 'Int32Array', 'Map', 'Number', 'Object', 'Promise', 'Proxy', 'RangeError', 'ReferenceError', 'RegExp', 'Set', 'SharedArrayBuffer', 'String', 'Symbol', 'SyntaxError', 'TypeError', 'Uint8Array', 'Uint8ClampedArray', 'Uint16Array', 'Uint32Array', 'URIError', 'WeakMap', 'WeakRef', 'WeakSet', 'Atomics', 'JSON', 'Math', 'Reflect' ] /** * A unique reference to a value owner by a "context object" and a * `Script` instance. */ export class Reference { /** * Predicate function to determine if a `value` is an internal or external * script reference value. * @param {amy} value * @return {boolean} */ static isReference (value) { if (references.has(value)) { return true } if (value?.__vmScriptReference__ === true && typeof value?.id === 'string') { return true } return false } /** * The underlying reference ID. * @ignore * @type {string} */ #id = null /** * The underling primitive type of the reference value. * @ignore * @type {'undefined'|'object'|'number'|'boolean'|'function'|'symbol'} */ #type = 'undefined' /** * A strong reference to the underlying value. * @ignore * @type {any?} */ #value = null /** * A weak reference to the underlying "context object", if available. * @ignore * @type {WeakRef?} */ #context = null /** * A boolean value to indicate if the underlying reference value is an * intrinsic value. * @type {boolean} */ #isIntrinsic = false /** * The intrinsic type this reference may be an instance of or directly refer to. * @type {function|object} */ #intrinsicType = null /** * A boolean value to indicate if the underlying reference value is an * external reference value. * @type {boolean} */ #isExternal = false /** * `Reference` class constructor. * @param {string} id * @param {any} value * @param {object=} [context] * @param {object=} [options] */ constructor (id, value, context = null, options) { this.#id = id this.#type = value !== null ? typeof value : 'undefined' this.#value = value !== null && value !== undefined ? value : null this.#context = context !== null && context !== undefined ? new WeakRef(context) : null this.#intrinsicType = getIntrinsicType(this.#value) this.#isIntrinsic = isIntrinsic(this.#value) this.#isExternal = options?.external === true } /** * The unique id of the reference * @type {string} */ get id () { return this.#id } /** * The underling primitive type of the reference value. * @ignore * @type {'undefined'|'object'|'number'|'boolean'|'function'|'symbol'} */ get type () { return this.#type } /** * The underlying value of the reference. * @type {any?} */ get value () { return this.#value } /** * The name of the type. * @type {string?} */ get name () { if (this.type === 'function') { return this.value.name } if (this.value && this.type === 'object') { const prototype = Reflect.getPrototypeOf(this.value) if (prototype?.constructor?.name) { return prototype.constructor.name } } return null } /** * The `Script` this value belongs to, if available. * @type {Script?} */ get script () { return scripts.get(this.context) ?? null } /** * The "context object" this reference value belongs to. * @type {object?} */ get context () { return this.#context?.deref?.() ?? null } /** * A boolean value to indicate if the underlying reference value is an * intrinsic value. * @type {boolean} */ get isIntrinsic () { return this.#isIntrinsic } /** * A boolean value to indicate if the underlying reference value is an * external reference value. * @type {boolean} */ get isExternal () { return this.#isExternal } /** * The intrinsic type this reference may be an instance of or directly refer to. * @type {function|object} */ get intrinsicType () { return this.#intrinsicType } /** * Releases strongly held value and weak references * to the "context object". */ release () { this.#value = null this.#context = null } /** * Converts this `Reference` to a JSON object. * @param {boolean=} [includeValue = false] */ toJSON (includeValue = false) { const { isIntrinsic, name, type, id } = this const intrinsicType = getIntrinsicTypeString(this.intrinsicType) let { value } = this const json = { __vmScriptReference__: true, id, type, name, isIntrinsic, intrinsicType } if (includeValue) { if ( value && typeof value === 'object' && Symbol.toStringTag in value ) { const tag = typeof value[Symbol.toStringTag] === 'function' ? value[Symbol.toStringTag]() : value[Symbol.toStringTag] if (tag === 'Module') { value = { ...value } } } json.value = value } return json } } /** * @typedef {{ * filename?: string, * context?: object * }} ScriptOptions */ /** * A `Script` is a container for raw JavaScript to be executed in * a completely isolated virtual machine context, optionally with * user supplied context. Context objects references are not actually * shared, but instead provided to the script execution context using the * structured cloning algorithm used by the Message Channel API. Context * differences are computed and applied after execution so the user supplied * context object realizes context changes after script execution. All script * sources run in an "async" context so a "top level await" should work. */ export class Script extends EventTarget { #id = null #ready = null #source = null #context = null #filename = '<script>' /** * `Script` class constructor * @param {string} source * @param {ScriptOptions} [options] */ constructor (source, options = null) { super() if (typeof source !== 'string') { throw new TypeError('Script source must be a string') } if (!source) { throw new TypeError('Script source cannot be empty') } this.#id = crypto.randomBytes(8).toString('base64') this.#source = source this.#context = options?.context ?? {} if (typeof options?.filename === 'string' && options.filename) { this.#filename = options.filename } gc.ref(this) this.#ready = getContextWindow() .then(() => getContextWorker()) .catch((error) => { this.dispatchEvent(new ErrorEvent('error', { error })) }) } /** * The script identifier. */ get id () { return this.#id } /** * The source for this script. * @type {string} */ get source () { return this.#source } /** * The filename for this script. * @type {string} */ get filename () { return this.#filename } /** * A promise that resolves when the script is ready. * @type {Promise<Boolean>} */ get ready () { return this.#ready } /** * The default script context object * @type {object} */ get context () { return this.#context } /** * Implements `gc.finalizer` for gc'd resource cleanup. * @return {gc.Finalizer} * @ignore */ [gc.finalizer] () { return { args: [this.id], async handle (id) { const worker = await getContextWorker() worker.port.postMessage({ id, type: 'destroy' }) } } } /** * Destroy the script execution context. * @return {Promise} */ async destroy () { await this.ready const worker = await getContextWorker() worker.port.postMessage({ id: this.#id, type: 'destroy' }) } /** * Run `source` JavaScript in given context. The script context execution * context is preserved until the `context` object that points to it is * garbage collected or there are no longer any references to it and its * associated `Script` instance. * @param {ScriptOptions=} [options] * @param {object=} [context] * @return {Promise<any>} */ async runInContext (context, options = null) { await this.ready const contextReference = createContext(context ?? this.context) context = { ...(context ?? this.#context) } const filename = options?.filename || this.filename const worker = await getContextWorker() const source = options?.source ?? this.#source const transfer = [] const nonce = crypto.randomBytes(8).toString('base64') const mode = options?.type ?? options?.mode ?? detectFunctionSourceType(source) const id = this.#id return await new Promise((resolve, reject) => { findMessageTransfers(transfer, context) filterNonTransferableValues(context) worker.port.postMessage({ type: 'client', id }) worker.port.postMessage({ type: 'script', filename, context, source, nonce, mode, id }, { transfer }) worker.port.addEventListener('message', onMessage) function onMessage (event) { if ( event.data?.id === id && event.data?.nonce === nonce && event.data?.type === 'result' ) { worker.port.removeEventListener('message', onMessage) if (event.data.context) { applyContextDifferences(contextReference, event.data.context, contextReference) } if (event.data.err) { reject(maybeMakeError(event.data.err)) } else { const { data } = event const result = { data: data.data } // check if result data is an external reference const isReference = Reference.isReference(result.data) const name = isReference ? result.data.name : null if (name) { result[name] = result.data data[name] = data.data delete data.data delete result.data } applyContextDifferences(result, data, contextReference) if (name) { resolve(result[name]) } else { resolve(result.data) } } } } }) } /** * Run `source` JavaScript in new context. The script context is destroyed after * execution. This is typically a "one off" isolated run. * @param {ScriptOptions=} [options] * @param {object=} [context] * @return {Promise<any>} */ async runInNewContext (context, options = null) { await this.ready const contextReference = context ?? this.context context = { ...context } const filename = options?.filename || this.filename const worker = await getContextWorker() const source = options?.source ?? this.#source const transfer = [] const nonce = crypto.randomBytes(8).toString('base64') const mode = options?.type ?? options?.mode ?? detectFunctionSourceType(source) const id = crypto.randomBytes(8).toString('base64') const result = await new Promise((resolve, reject) => { findMessageTransfers(transfer, context) filterNonTransferableValues(context) worker.port.postMessage({ type: 'client', id }) worker.port.postMessage({ type: 'script', filename, context, source, nonce, mode, id }, { transfer }) worker.port.addEventListener('message', onMessage) function onMessage (event) { if ( event.data?.id === id && event.data?.nonce === nonce && event.data?.type === 'result' ) { worker.port.removeEventListener('message', onMessage) if (event.data.context) { applyContextDifferences(contextReference, event.data.context, contextReference) } if (event.data.err) { reject(maybeMakeError(event.data.err)) } else { const { data } = event const result = { data: data.data } // check if result data is an external reference const isReference = Reference.isReference(result.data) const name = isReference ? result.data.name : null if (name) { result[name] = result.data data[name] = data.data delete data.data delete result.data } applyContextDifferences(result, data, contextReference) if (name) { resolve(result[name]) } else { resolve(result.data) } } } } }) worker.port.postMessage({ id, type: 'destroy' }) return result } /** * Run `source` JavaScript in this current context (`globalThis`). * @param {ScriptOptions=} [options] * @return {Promise<any>} */ async runInThisContext (options = null) { const filename = options?.filename || this.filename const fn = compileFunction(options?.source ?? this.#source, { async: true, filename }) return await fn() } } /** * Gets the VM context window. * This function will create it if it does not already exist. * @return {Promise<import('./window.js').ApplicationWindow} */ export async function getContextWindow () { if (contextWindow) { await contextWindow.ready return contextWindow } const existingContextWindow = await application.getWindow(VM_WINDOW_INDEX, { max: false }) const pendingContextWindow = ( existingContextWindow ?? application.createWindow({ canExit: false, headless: !process.env.SOCKET_RUNTIME_VM_DEBUG, // @ts-ignore debug: Boolean(process.env.SOCKET_RUNTIME_VM_DEBUG), index: VM_WINDOW_INDEX, title: VM_WINDOW_TITLE, path: VM_WINDOW_PATH, config: { webview_watch_reload: false } }) ) const promises = [] promises.push(Promise.resolve(pendingContextWindow)) if (!existingContextWindow) { promises.push(new Promise((resolve) => { const timeout = setTimeout(resolve, 500) channel.addEventListener('message', function onMessage (event) { if (event.data?.ready === VM_WINDOW_INDEX) { clearTimeout(timeout) resolve(null) channel.removeEventListener('message', onMessage) } }) })) } const ready = Promise.all(promises) contextWindow = pendingContextWindow contextWindow.ready = ready await ready contextWindow = await pendingContextWindow contextWindow.ready = ready return contextWindow } /** * Gets the `SharedWorker` that for the VM context. * @return {Promise<SharedWorker>} */ export async function getContextWorker () { if (contextWorker) { await contextWorker.ready await contextWorker[kWorkerContextReady] return contextWorker } if (os.platform() === 'win32' && !process.env.COREWEBVIEW2_22_AVAILABLE) { if (globalThis.window && globalThis.top === globalThis.window) { // inside global top window contextWorker = new ContextWorkerInterface() contextWorker[kWorkerContextReady] = Promise.resolve(contextWorker) globals.register('vm.contextWorker', contextWorker) } else if ( globalThis.window && globalThis.top !== globalThis.window && globalThis.location.pathname === new URL(VM_WINDOW_PATH).pathname ) { // inside realm frame // @ts-ignore contextWorker = new ContextWorkerInterfaceProxy(globalThis.top.__globals) contextWorker[kWorkerContextReady] = Promise.resolve(contextWorker) } else { throw new TypeError('Unable to determine VM context worker') } } else { contextWorker = new SharedWorker(`${globalThis.origin}/socket/vm/worker.js`, { type: 'module' }) contextWorker[kWorkerContextReady] = new Promise((resolve, reject) => { contextWorker.addEventListener('error', (event) => { reject(new Error('Failed to initialize VM Context SharedWorker', { cause: event.error ?? event })) }, { once: true }) contextWorker.port.addEventListener('message', (event) => { if (event.data === 'VM_SHARED_WORKER_ACK') { resolve(contextWorker) } }, { once: true }) }) } contextWorker.port.start() contextWorker.addEventListener('message', (event) => { if (event.data?.type === 'terminate-worker') { if (typeof contextWorker?.destroy === 'function') { contextWorker.destroy() } // unref contextWorker = null if ( globalThis.window && globalThis.top !== globalThis.window && globalThis.location.pathname === new URL(VM_WINDOW_PATH).pathname ) { // @ts-ignore globalThis.top.__globals.register('vm.contextWorker', contextWorker) } } }) await contextWorker.ready await contextWorker[kWorkerContextReady] return contextWorker } /** * Terminates the VM script context window. * @ignore */ export async function terminateContextWindow () { const pendingContextWindow = getContextWindow() if (contextWindow.frame?.parentElement) { contextWindow.frame.parentElement.removeChild(contextWindow.frame) } contextWindow = null const currentContextWindow = await pendingContextWindow await currentContextWindow.close() const existingContextWindow = await application.getWindow(VM_WINDOW_INDEX, { max: false }) if (existingContextWindow) { await existingContextWindow.close() } } /** * Terminates the VM script context worker. * @ignore */ export async function terminateContextWorker () { if (!contextWorker) { return } const worker = await getContextWorker() worker.port.postMessage({ type: 'terminate-worker' }) } /** * Creates a prototype object of known global reserved intrinsics. * @ignore */ export function createIntrinsics (options) { const descriptors = Object.create(null) const propertyNames = Object.getOwnPropertyNames(globalThis) const propertySymbols = Object.getOwnPropertySymbols(globalThis) for (const property of propertyNames) { const intrinsic = Object.getOwnPropertyDescriptor(globalThis, property) const descriptor = Object.assign(Object.create(null), { configurable: options?.configurable === true, enumerable: true, value: intrinsic.value ?? globalThis[property] ?? undefined }) descriptors[property] = descriptor } for (const symbol of propertySymbols) { descriptors[symbol] = { configurable: options?.configurable === true, enumberale: false, value: globalThis[symbol] } } return Object.create(null, descriptors) } /** * Returns `true` if value is an intrinsic, otherwise `false`. * @param {any} value * @return {boolean} */ export function isIntrinsic (value) { if (value === undefined) { return true } if (value === null) { return null } for (const key of RESERVED_GLOBAL_INTRINSICS) { const intrinsic = globalThis[key] if (intrinsic === value) { return true } else if (typeof intrinsic === 'function' && typeof value === 'object') { const prototype = Object.getPrototypeOf(value) if (prototype === intrinsic.prototype) { return true } } } return false } /** * Get the intrinsic type of a given `value`. * @param {any} * @return {function|object|null|undefined} */ export function getIntrinsicType (value) { if (value === undefined) { return undefined } if (value === null) { return null } for (const key of RESERVED_GLOBAL_INTRINSICS) { const intrinsic = globalThis[key] if (intrinsic === value) { return intrinsic } else if (typeof intrinsic === 'function' && typeof value === 'object') { const prototype = Object.getPrototypeOf(value) if (prototype === intrinsic.prototype) { return intrinsic } } } return undefined } /** * Get the intrinsic type string of a given `value`. * @param {any} * @return {string|null} */ export function getIntrinsicTypeString (value) { if (value === null) { return null } if (value === undefined) { return 'undefined' } for (const key of RESERVED_GLOBAL_INTRINSICS) { const intrinsic = globalThis[key] if (intrinsic === value) { return key } else if (typeof intrinsic === 'function' && typeof value === 'object') { const prototype = Object.getPrototypeOf(value) if (prototype === intrinsic.prototype) { return key } } } return null } /** * Creates a global proxy object for context execution. * @ignore * @param {object} context * @param {object=} [options] * @return {Proxy} */ export function createGlobalObject (context, options = null) { const existing = context && globals.get(context) if (existing) { return existing } const prototype = Object.getPrototypeOf(globalThis) const intrinsics = createIntrinsics(options) const descriptors = Object.getOwnPropertyDescriptors(intrinsics) const globalObject = Object.create(prototype, descriptors) const symbols = Object.getOwnPropertySymbols(intrinsics) const target = Object.create(null) // restore symbols for (const symbol of symbols) { try { Object.defineProperty(globalObject, symbol, intrinsics[symbol]) } catch {} } const handler = {} const traps = { get (_, property) { if (property === 'console') { return console } if (context && property in context) { return Reflect.get(context, property) } if (property === 'top') { return null } if (property in globalObject) { return Reflect.get(globalObject, property) } }, set (_, property, value) { if (context) { if (Reflect.isExtensible(context)) { Reflect.set(context, property, value) return true } return false } if (property === 'top') { return false } Reflect.set(globalObject, property, value) return true }, getPrototypeOf (_) { if (context) { return Reflect.getPrototypeOf(context) } return prototype }, setPrototypeOf () { return false }, defineProperty (_, property, descriptor) { if (RESERVED_GLOBAL_INTRINSICS.includes(property)) { return true } if (context) { return ( Reflect.defineProperty(context, property, descriptor) && Reflect.getOwnPropertyDescriptor(context, property) !== undefined ) } return ( Reflect.defineProperty(globalObject, property, descriptor) && Reflect.getOwnPropertyDescriptor(globalObject, property) !== undefined ) }, deleteProperty (_, property) { if (RESERVED_GLOBAL_INTRINSICS.includes(property)) { return false } if (context) { return Reflect.deleteProperty(context, property) } return Reflect.deleteProperty(globalObject, property) }, getOwnPropertyDescriptor (_, property) { if (context) { const descriptor = Reflect.getOwnPropertyDescriptor(context, property) if (descriptor) { return descriptor } } if (property === 'top') { return { enumerable: false, configurable: false, value: null } } return Reflect.getOwnPropertyDescriptor(globalObject, property) }, has (_, property) { if (context && Reflect.has(context, property)) { return true } if (property === 'top') { return true } return Reflect.has(globalObject, property) }, isExtensible (_) { if (context) { return Reflect.isExtensible(context) } return true }, ownKeys (_) { const keys = [] if (context) { keys.push(...Reflect.ownKeys(context)) } keys.push(...Reflect .ownKeys(globalObject) .filter((key) => { if (key === 'top') return false return true }) ) return Array.from(new Set(keys)) }, preventExtensions (_) { if (context) { Reflect.preventExtensions(context) return true } return false } } if (Array.isArray(options?.traps)) { for (const trap of options.traps) { if (typeof traps[trap] === 'function') { handler[trap] = traps[trap] } } } else if (options?.traps && typeof options?.traps === 'object') { for (const key in traps) { if (options.traps[key] !== false) { handler[key] = traps[key] } } } else { for (const key in traps) { handler[key] = traps[key] } } const proxy = new Proxy(target, handler) if (context) { globalObjects.set(context, proxy) } return proxy } /** * @ignore * @param {string} source * @return {boolean} */ export function detectFunctionSourceType (source) { if (isESMSource(source)) { return 'module' } return 'classic' } /** * Compiles `source` with `options` into a function. * @ignore * @param {string} source * @param {object=} [options] * @return {function} */ export function compileFunction (source, options = null) { source = convertSourceToString(source) options = { ...options } // detect source type naively if (!options?.type) { options.type = detectFunctionSourceType(source) } if (options?.type === 'module') { const hash = crypto.murmur3(source) let url = null if (blobURLCache.has(hash)) { url = blobURLCache.get(hash) } else { const blob = new Blob([source], { type: 'text/javascript' }) url = URL.createObjectURL(blob) blobURLCache.set(hash, url) } const moduleSource = ` const module = await import("${url}") const exports = {} if (module.default !== undefined) { exports.default = module.default } for (const key in module) { exports[key] = module[key] } return exports ` return compileFunction(moduleSource, { ...options, type: 'classic', async: true, wrap: false }) } const globalObject = ( globalObjects.get(options?.context) ?? createGlobalObject(options?.context) ) const wrappedSource = options?.wrap === false ? source : wrapFunctionSource(source, options) const args = Array.from(options?.scope || []).concat(wrappedSource) const compiled = options?.async === true // @ts-ignore ? new AsyncFunction(...args) // @ts-ignore : new Function(...args) return compiled.bind(globalObject, globalObject) } /** * Run `source` JavaScript in given context. The script context execution * context is preserved until the `context` object that points to it is * garbage collected or there are no longer any references to it and its * associated `Script` instance. * @param {string|object|function} source * @param {object=} [context] * @param {ScriptOptions=} [options] * @return {Promise<any>} */ export async function runInContext (source, context, options) { source = convertSourceToString(source) context = ( context?.context ?? options?.context ?? context ?? sharedContext ) const script = scripts.get(context) ?? new Script(source, options) scripts.set(context, script) const result = await script.runInContext(context, { ...options, source }) return result } /** * Run `source` JavaScript in new context. The script context is destroyed after * execution. This is typically a "one off" isolated run. * @param {string} source * @param {object=} [context] * @param {ScriptOptions=} [options] * @return {Promise<any>} */ export async function runInNewContext (source, context, options) { source = convertSourceToString(source) context = options?.context ?? context?.context ?? context ?? {} const script = new Script(source, options) scripts.set(script.context, script) const result = await script.runInNewContext(context, options) await script.destroy() return result } /** * Run `source` JavaScript in this current context (`globalThis`). * @param {string} source * @param {ScriptOptions=} [options] * @return {Promise<any>} */ export async function runInThisContext (source, options) { source = convertSourceToString(source) const script = new Script(source, options) const result = await script.runInThisContext(options) await script.destroy() return result } /** * @ignore * @param {Reference} reference */ export function putReference (reference) { const { value } = reference if ( value && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean' ) { references.set(value, reference) } references.index.set(reference.id, reference) } /** * Create a `Reference` for a `value` in a script `context`. * @param {any} value * @param {object} context * @param {object=} [options] * @return {Reference} */ export function createReference (value, context, options = null) { const id = crypto.randomBytes(8).toString('base64') const reference = new Reference(id, value, context, options) putReference(reference) return reference } /** * Get a script context by ID or values * @param {string|object|function} id * @return {Reference?} */ export function getReference (id) { return references.get(id) ?? references.index.get(id) } /** * Remove a script context reference by ID. * @param {string} id */ export function removeReference (id) { const reference = getReference(id) references.index.delete(id) if (reference) { references.delete(reference.value) } } /** * Get all transferable values in the `object` hierarchy. * @param {object} object * @return {object[]} */ export function getTransferables (object) { const transferables = [] findMessageTransfers(transferables, object) return transferables } /** * @ignore * @param {object} object * @return {object} */ export function createContext (object) { if (isContext(object)) { return object } else if (object && typeof object === 'object') { contexts.set(object, kContextTag) } return object } /** * Returns `true` if `object` is a "context" object. * @param {object} * @return {boolean} */ export function isContext (object) { return contexts.has(object) } export default { createGlobalObject, compileFunction, createReference, getContextWindow, getContextWorker, getReference, getTransferables, putReference, Reference, removeReference, runInContext, runInNewContext, runInThisContext, Script, createContext, isContext, channel }