web-request-rpc
Advanced tools
+222
| /*! | ||
| * Copyright (c) 2017-2024 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import * as utils from './utils.js'; | ||
| // 30 second default timeout | ||
| const RPC_CLIENT_CALL_TIMEOUT = 30000; | ||
| class Injector { | ||
| constructor(client) { | ||
| this.client = client; | ||
| this._apis = new Map(); | ||
| } | ||
| /** | ||
| * Defines a named API that will use an RPC client to implement its | ||
| * functions. Each of these functions will be asynchronous and return a | ||
| * Promise with the result from the RPC server. | ||
| * | ||
| * This function will return an interface with functions defined according | ||
| * to those provided in the given `definition`. The `name` parameter can be | ||
| * used to obtain this cached interface via `.get(name)`. | ||
| * | ||
| * @param {string} name - The name of the API. | ||
| * @param {object} definition - The definition for the API. | ||
| * @param {Array} definition.functions - An array of function names (as | ||
| * strings) or objects. | ||
| * @param {object} definition.containing - Object of the form: | ||
| * {name: <functionName>, options: <rpcClientOptions>}. | ||
| * | ||
| * @returns {object} An interface with the functions provided via | ||
| * `definition` that will make RPC calls to an RPC server to provide their | ||
| * implementation. | ||
| */ | ||
| define(name, definition) { | ||
| if(!(name && typeof name === 'string')) { | ||
| throw new TypeError('`name` must be a non-empty string.'); | ||
| } | ||
| // TODO: support Web IDL as a definition format? | ||
| if(!(definition && typeof definition === 'object' && | ||
| Array.isArray(definition.functions))) { | ||
| throw new TypeError( | ||
| '`definition.function` must be an array of function names or ' + | ||
| 'function definition objects to be defined.'); | ||
| } | ||
| const self = this; | ||
| const api = {}; | ||
| definition.functions.forEach(fn => { | ||
| if(typeof fn === 'string') { | ||
| fn = {name: fn, options: {}}; | ||
| } | ||
| api[fn.name] = async function() { | ||
| return self.client.send( | ||
| name + '.' + fn.name, [...arguments], fn.options); | ||
| }; | ||
| }); | ||
| self._apis[name] = api; | ||
| return api; | ||
| } | ||
| /** | ||
| * Get a named API, defining it if necessary when a definition is provided. | ||
| * | ||
| * @param {string} name - The name of the API. | ||
| * @param {object} [definition] - The definition for the API; if the API is | ||
| * already defined, this definition is ignored. | ||
| * | ||
| * @returns {object} The interface. | ||
| */ | ||
| get(name, definition) { | ||
| const api = this._apis[name]; | ||
| if(!api) { | ||
| if(definition) { | ||
| return this.define(name, definition); | ||
| } | ||
| throw new Error(`API "${name}" has not been defined.`); | ||
| } | ||
| return this._apis[name]; | ||
| } | ||
| } | ||
| export class Client { | ||
| constructor() { | ||
| this.origin = null; | ||
| this._handle = null; | ||
| this._listener = null; | ||
| // all pending requests | ||
| this._pending = new Map(); | ||
| } | ||
| /** | ||
| * Connects to a Web Request RPC server. | ||
| * | ||
| * The Promise will resolve to an RPC injector that can be used to get or | ||
| * define APIs to enable communication with the server. | ||
| * | ||
| * @param {string} origin - The origin to send messages to. | ||
| * @param {object} options - The options to use. | ||
| * @param {object|Promise} [options.handle] - A handle to the window (or a | ||
| * Promise that resolves to a handle) to send messages to | ||
| * (defaults to `window.opener || window.parent`). | ||
| * | ||
| * @returns {Promise} Resolves to an RPC injector once connected. | ||
| */ | ||
| async connect(origin, options) { | ||
| if(this._listener) { | ||
| throw new Error('Already connected.'); | ||
| } | ||
| options = options || {}; | ||
| // TODO: validate `origin` and `options.handle` | ||
| const self = this; | ||
| self.origin = utils.parseUrl(origin).origin; | ||
| self._handle = options.handle || window.opener || window.parent; | ||
| const pending = self._pending; | ||
| self._listener = utils.createMessageListener({ | ||
| origin: self.origin, | ||
| handle: self._handle, | ||
| expectRequest: false, | ||
| listener: message => { | ||
| // ignore messages that have no matching, pending request | ||
| if(!pending.has(message.id)) { | ||
| return; | ||
| } | ||
| // resolve or reject Promise associated with message | ||
| const {resolve, reject, cancelTimeout} = pending.get(message.id); | ||
| cancelTimeout(); | ||
| if('result' in message) { | ||
| return resolve(message.result); | ||
| } | ||
| reject(utils.deserializeError(message.error)); | ||
| } | ||
| }); | ||
| window.addEventListener('message', self._listener); | ||
| return new Injector(self); | ||
| } | ||
| /** | ||
| * Performs a RPC by sending a message to the Web Request RPC server and | ||
| * awaiting a response. | ||
| * | ||
| * @param {string} qualifiedMethodName - The fully-qualified name of the | ||
| * method to call. | ||
| * @param {object} parameters - The parameters for the method. | ||
| * @param {object} options - The options to use. | ||
| * @param {number} [options.timeout] - A timeout, in milliseconds, for | ||
| * awaiting a response; a non-positive timeout (<= 0) will cause an | ||
| * indefinite wait. | ||
| * | ||
| * @returns {Promise} Resolves to the result (or error) of the call. | ||
| */ | ||
| async send(qualifiedMethodName, parameters, { | ||
| timeout = RPC_CLIENT_CALL_TIMEOUT | ||
| }) { | ||
| if(!this._listener) { | ||
| throw new Error('RPC client not connected.'); | ||
| } | ||
| const self = this; | ||
| const message = { | ||
| jsonrpc: '2.0', | ||
| id: utils.uuidv4(), | ||
| method: qualifiedMethodName, | ||
| params: parameters | ||
| }; | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| if(utils.isHandlePromise(self._handle)) { | ||
| const handle = await self._handle; | ||
| handle.postMessage(message, self.origin); | ||
| } else { | ||
| self._handle.postMessage(message, self.origin); | ||
| } | ||
| // return Promise that will resolve once a response message has been | ||
| // received or once a timeout occurs | ||
| return new Promise((resolve, reject) => { | ||
| const pending = self._pending; | ||
| let cancelTimeout; | ||
| if(timeout > 0) { | ||
| const timeoutId = setTimeout(() => { | ||
| pending.delete(message.id); | ||
| reject(new Error('RPC call timed out.')); | ||
| }, timeout); | ||
| cancelTimeout = () => { | ||
| pending.delete(message.id); | ||
| clearTimeout(timeoutId); | ||
| }; | ||
| } else { | ||
| cancelTimeout = () => { | ||
| pending.delete(message.id); | ||
| }; | ||
| } | ||
| pending.set(message.id, {resolve, reject, cancelTimeout}); | ||
| }); | ||
| } | ||
| /** | ||
| * Disconnects from the remote Web Request RPC server and closes down this | ||
| * client. | ||
| */ | ||
| close() { | ||
| if(this._listener) { | ||
| window.removeEventListener('message', this._listener); | ||
| this._handle = this.origin = this._listener = null; | ||
| // reject all pending calls | ||
| for(const value of this._pending.values()) { | ||
| value.reject(new Error('RPC client closed.')); | ||
| } | ||
| this._pending = new Map(); | ||
| } | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2017-2024 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| export class EventEmitter { | ||
| constructor({deserialize = e => e, waitUntil = async () => {}} = {}) { | ||
| this._listeners = []; | ||
| this._deserialize = deserialize; | ||
| this._waitUntil = waitUntil; | ||
| } | ||
| async emit(event) { | ||
| event = this._deserialize(event); | ||
| (this._listeners[event.type] || []).forEach(l => l(event)); | ||
| return this._waitUntil(event); | ||
| } | ||
| addEventListener(eventType, fn) { | ||
| if(!this._listeners[eventType]) { | ||
| this._listeners[eventType] = [fn]; | ||
| } else { | ||
| this._listeners[eventType].push(fn); | ||
| } | ||
| } | ||
| removeEventListener(eventType, fn) { | ||
| const listeners = this._listeners[eventType]; | ||
| if(!listeners) { | ||
| return; | ||
| } | ||
| const idx = listeners.indexOf(fn); | ||
| if(idx !== -1) { | ||
| listeners.splice(idx, 1); | ||
| } | ||
| } | ||
| } |
+14
| /*! | ||
| * JSON-RPC for Web Request Polyfills. | ||
| * | ||
| * Copyright (c) 2017-2024 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| export {Client} from './Client.js'; | ||
| export {EventEmitter} from './EventEmitter.js'; | ||
| export {Server} from './Server.js'; | ||
| export {WebApp} from './WebApp.js'; | ||
| export {WebAppContext} from './WebAppContext.js'; | ||
| export {WebAppWindow} from './WebAppWindow.js'; | ||
| import * as utils from './utils.js'; | ||
| export {utils}; |
+142
| /*! | ||
| * Copyright (c) 2017-2024 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import * as utils from './utils.js'; | ||
| export class Server { | ||
| constructor() { | ||
| this.origin = null; | ||
| this._handle = null; | ||
| this._apis = new Map(); | ||
| } | ||
| /** | ||
| * Provides an implementation for a named API. All functions in the given | ||
| * API will be made callable via RPC clients connected to this server. | ||
| * | ||
| * @param {string} name - The name of the API. | ||
| * @param {object} api - The API to add. | ||
| */ | ||
| define(name, api) { | ||
| if(!(name && typeof name === 'string')) { | ||
| throw new TypeError('`name` must be a non-empty string.'); | ||
| } | ||
| if(!(api && api !== 'object')) { | ||
| throw new TypeError('`api` must be an object.'); | ||
| } | ||
| if(name in this._apis) { | ||
| throw new Error(`The "${name}" API is already defined.`); | ||
| } | ||
| this._apis[name] = api; | ||
| } | ||
| /** | ||
| * Listens for RPC messages from clients from a particular origin and | ||
| * window handle and uses them to execute API calls based on predefined | ||
| * APIs. | ||
| * | ||
| * If messages are not from the given origin or window handle, they are | ||
| * ignored. If the messages refer to named APIs that have not been defined | ||
| * then an error message is sent in response. These error messages can | ||
| * be suppressed by using the `ignoreUnknownApi` option. | ||
| * | ||
| * If a message refers to an unknown method on a known named API, then an | ||
| * error message is sent in response. | ||
| * | ||
| * @param {string} origin - The origin to listen for. | ||
| * @param {object} options - The options to use. | ||
| * @param {object|Promise} [options.handle] - A handle to the window (or a | ||
| * Promise that resolves to a handle) to listen for messages from | ||
| * (defaults to `window.opener || window.parent`). | ||
| * @param {boolean} [options.ignoreUnknownApi] - `true` to ignore unknown API | ||
| * messages. | ||
| */ | ||
| async listen(origin, options) { | ||
| if(this._listener) { | ||
| throw new Error('Already listening.'); | ||
| } | ||
| options = options || {}; | ||
| // TODO: validate `origin` and `options.handle` | ||
| const self = this; | ||
| self.origin = utils.parseUrl(origin).origin; | ||
| self._handle = options.handle || window.opener || window.parent; | ||
| const ignoreUnknownApi = (options.ignoreUnknownApi === 'true') || false; | ||
| self._listener = utils.createMessageListener({ | ||
| origin: self.origin, | ||
| handle: self._handle, | ||
| expectRequest: true, | ||
| listener: message => { | ||
| const {name, method} = utils.destructureMethodName(message.method); | ||
| const api = self._apis[name]; | ||
| // do not allow calling "private" methods (starts with `_`) | ||
| if(method && method.startsWith('_')) { | ||
| return sendMethodNotFound(self._handle, self.origin, message); | ||
| } | ||
| // API not found but ignore flag is on | ||
| if(!api && ignoreUnknownApi) { | ||
| // API not registered, ignore the message rather than raise error | ||
| return; | ||
| } | ||
| // no ignore flag and unknown API or unknown specific method | ||
| if(!api || typeof api[method] !== 'function') { | ||
| return sendMethodNotFound(self._handle, self.origin, message); | ||
| } | ||
| // API and specific function found | ||
| const fn = api[method]; | ||
| (async () => { | ||
| const response = { | ||
| jsonrpc: '2.0', | ||
| id: message.id | ||
| }; | ||
| try { | ||
| response.result = await fn.apply(api, message.params); | ||
| } catch(e) { | ||
| response.error = utils.serializeError(e); | ||
| } | ||
| // if server did not `close` while we waited for a response | ||
| if(self._handle) { | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| if(utils.isHandlePromise(self._handle)) { | ||
| self._handle.then(h => h.postMessage(response, self.origin)); | ||
| } else { | ||
| self._handle.postMessage(response, self.origin); | ||
| } | ||
| } | ||
| })(); | ||
| } | ||
| }); | ||
| window.addEventListener('message', self._listener); | ||
| } | ||
| close() { | ||
| if(this._listener) { | ||
| window.removeEventListener('message', this._listener); | ||
| this._handle = this.origin = this._listener = null; | ||
| } | ||
| } | ||
| } | ||
| function sendMethodNotFound(handle, origin, message) { | ||
| const response = { | ||
| jsonrpc: '2.0', | ||
| id: message.id, | ||
| error: Object.assign({}, utils.RPC_ERRORS.MethodNotFound) | ||
| }; | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| if(utils.isHandlePromise(handle)) { | ||
| return handle.then(h => h.postMessage(response, origin)); | ||
| } else { | ||
| return handle.postMessage(response, origin); | ||
| } | ||
| } |
+209
| /*! | ||
| * Utilities for Web Request RPC. | ||
| * | ||
| * Copyright (c) 2017-2024 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| export const RPC_ERRORS = { | ||
| ParseError: { | ||
| message: 'Parse error', | ||
| code: -32700 | ||
| }, | ||
| InvalidRequest: { | ||
| message: 'Invalid Request', | ||
| code: -32600 | ||
| }, | ||
| MethodNotFound: { | ||
| message: 'Method not found', | ||
| code: -32601 | ||
| }, | ||
| InvalidParams: { | ||
| message: 'Invalid params', | ||
| code: -32602 | ||
| }, | ||
| InternalError: { | ||
| message: 'Internal Error', | ||
| code: -32603 | ||
| }, | ||
| ServerError: { | ||
| message: 'Server error', | ||
| code: -32000 | ||
| } | ||
| }; | ||
| export function parseUrl(url, base) { | ||
| if(base === undefined) { | ||
| base = window.location.href; | ||
| } | ||
| if(typeof URL === 'function') { | ||
| return new URL(url, base); | ||
| } | ||
| if(typeof url !== 'string') { | ||
| throw new TypeError('"url" must be a string.'); | ||
| } | ||
| // FIXME: rudimentary relative URL resolution | ||
| if(!url.includes(':')) { | ||
| if(base.startsWith('http') && !url.startsWith('/')) { | ||
| url = base + '/' + url; | ||
| } else { | ||
| url = base + url; | ||
| } | ||
| } | ||
| // `URL` API not supported, use DOM to parse URL | ||
| const parser = document.createElement('a'); | ||
| parser.href = url; | ||
| let origin = (parser.protocol || window.location.protocol) + '//'; | ||
| if(parser.host) { | ||
| // use hostname when using default ports | ||
| // (IE adds always adds port to `parser.host`) | ||
| if((parser.protocol === 'http:' && parser.port === '80') || | ||
| (parser.protocol === 'https:' && parser.port === '443')) { | ||
| origin += parser.hostname; | ||
| } else { | ||
| origin += parser.host; | ||
| } | ||
| } else { | ||
| origin += window.location.host; | ||
| } | ||
| // ensure pathname begins with `/` | ||
| let pathname = parser.pathname; | ||
| if(!pathname.startsWith('/')) { | ||
| pathname = '/' + pathname; | ||
| } | ||
| return { | ||
| // TODO: is this safe for general use on every browser that doesn't | ||
| // support WHATWG URL? | ||
| host: parser.host || window.location.host, | ||
| hostname: parser.hostname, | ||
| origin, | ||
| protocol: parser.protocol, | ||
| pathname | ||
| }; | ||
| } | ||
| export function originMatches(url, origin) { | ||
| return parseUrl(url, origin).origin === origin; | ||
| } | ||
| // https://gist.github.com/LeverOne/1308368 | ||
| export function uuidv4(a, b) { | ||
| /* eslint-disable-next-line space-infix-ops,semi-spacing,max-len,curly */ | ||
| for(b=a='';a++<36;b+=a*51&52?(a^15?8^Math.random()*(a^20?16:4):4).toString(16):'-');return b; | ||
| } | ||
| export function isValidOrigin(url, origin) { | ||
| if(!originMatches(url, origin)) { | ||
| throw new Error( | ||
| `Origin mismatch. Url "${url}" does not have an origin of "${origin}".`); | ||
| } | ||
| } | ||
| export function isValidMessage(message) { | ||
| return ( | ||
| message && typeof message === 'object' && | ||
| message.jsonrpc === '2.0' && | ||
| message.id && typeof message.id === 'string'); | ||
| } | ||
| export function isValidRequest(message) { | ||
| return isValidMessage(message) && Array.isArray(message.params); | ||
| } | ||
| export function isValidResponse(message) { | ||
| return ( | ||
| isValidMessage(message) && | ||
| !!('result' in message ^ 'error' in message) && | ||
| (!('error' in message) || isValidError(message.error))); | ||
| } | ||
| export function isValidError(error) { | ||
| return ( | ||
| error && typeof error === 'object' && | ||
| typeof error.code === 'number' && | ||
| typeof error.message === 'string'); | ||
| } | ||
| export function serializeError(error) { | ||
| const err = { | ||
| message: error.message | ||
| }; | ||
| if(error.constructor.name !== 'Error') { | ||
| err.constructor = error.constructor.name; | ||
| } | ||
| if('name' in error) { | ||
| err.name = error.name; | ||
| } | ||
| if('code' in error) { | ||
| err.code = error.code; | ||
| } else { | ||
| err.code = RPC_ERRORS.ServerError.code; | ||
| } | ||
| if('details' in error) { | ||
| err.details = error.details; | ||
| } | ||
| return err; | ||
| } | ||
| export function deserializeError(error) { | ||
| let err; | ||
| // special case known types, otherwise use generic Error | ||
| if(error.constructor === 'DOMException') { | ||
| err = new DOMException(error.message, error.name); | ||
| // ignore code, name will set it | ||
| } else { | ||
| err = new Error(error.message); | ||
| if('code' in error) { | ||
| err.code = error.code; | ||
| } | ||
| } | ||
| if(error.details) { | ||
| err.details = error.details; | ||
| } | ||
| return err; | ||
| } | ||
| export function createMessageListener( | ||
| {listener, origin, handle, expectRequest}) { | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| if(isHandlePromise(handle)) { | ||
| const promise = handle; | ||
| handle = false; | ||
| promise.then(h => handle = h); | ||
| } | ||
| return e => { | ||
| // ignore messages from a non-matching handle or origin | ||
| // or that don't follow the protocol | ||
| if(!(e.source === handle && e.origin === origin && | ||
| ((expectRequest && isValidRequest(e.data)) || | ||
| (!expectRequest && isValidResponse(e.data))))) { | ||
| return; | ||
| } | ||
| listener(e.data, e); | ||
| }; | ||
| } | ||
| export function destructureMethodName(fqMethodName) { | ||
| // fully-qualified method name is: `<api-name>.<method-name>` | ||
| // where `<api-name>` is all but the last dot-delimited segment and | ||
| // `<method-name>` is the last dot-delimited segment | ||
| /* eslint-disable-next-line prefer-const */ | ||
| let [name, ...rest] = fqMethodName.split('.'); | ||
| const method = rest.pop(); | ||
| name = [name, ...rest].join('.'); | ||
| return {name, method}; | ||
| } | ||
| export function isHandlePromise(handle) { | ||
| try { | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| return typeof handle.then === 'function'; | ||
| } catch(e) {} | ||
| return false; | ||
| } |
| /*! | ||
| * A WebApp is a remote application that runs in a WebAppContext. | ||
| * | ||
| * Copyright (c) 2017-2024 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {Client} from './Client.js'; | ||
| import {parseUrl} from './utils.js'; | ||
| import {Server} from './Server.js'; | ||
| export class WebApp { | ||
| constructor(relyingOrigin) { | ||
| // this is the origin that created the WebAppContext to run it in | ||
| // TODO: better name? `contextOrigin`? | ||
| this.relyingOrigin = parseUrl(relyingOrigin).origin; | ||
| this.client = null; | ||
| this.injector = null; | ||
| this.client = new Client(); | ||
| this.server = new Server(); | ||
| this._control = null; | ||
| this._connected = false; | ||
| } | ||
| /** | ||
| * Connects this WebApp to the relying origin that instantiated it. Once | ||
| * connected, the WebApp can start servicing calls from that origin. | ||
| * | ||
| * @returns {Promise} Resolves to an injector for creating custom client APIs | ||
| * once the connection is ready. | ||
| */ | ||
| async connect() { | ||
| this.injector = await this.client.connect(this.relyingOrigin); | ||
| this._connected = true; | ||
| this._control = this.injector.define('core.control', { | ||
| functions: ['ready', 'show', 'hide'] | ||
| }); | ||
| this.server.listen(this.relyingOrigin); | ||
| return this.injector; | ||
| } | ||
| /** | ||
| * Must be called after `connect` when this WebApp is ready to start | ||
| * receiving calls from the remote end. | ||
| * | ||
| * @returns {object} The WebApp. | ||
| */ | ||
| async ready() { | ||
| if(!this._connected) { | ||
| throw new Error('WebApp not connected. Did you call ".connect()"?'); | ||
| } | ||
| await this._control.ready(); | ||
| return this; | ||
| } | ||
| /** | ||
| * Closes this WebApp's connection to the relying origin. | ||
| */ | ||
| close() { | ||
| if(this._connected) { | ||
| this.server.close(); | ||
| this.client.close(); | ||
| this._connected = false; | ||
| } | ||
| } | ||
| /** | ||
| * Shows the UI for this WebApp on the relying origin. | ||
| * | ||
| * @returns {*} The control show result. | ||
| */ | ||
| async show() { | ||
| if(!this._connected) { | ||
| throw new Error( | ||
| 'Cannot "show" yet; not connected. Did you call ".connect()"?'); | ||
| } | ||
| return this._control.show(); | ||
| } | ||
| /** | ||
| * Hides the UI for this WebApp on the relying origin. | ||
| * | ||
| * @returns {*} The control hide result. | ||
| */ | ||
| async hide() { | ||
| if(!this._connected) { | ||
| throw new Error( | ||
| 'Cannot "hide" yet; not connected. Did you call ".connect()?"'); | ||
| } | ||
| return this._control.hide(); | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2017-2024 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {Client} from './Client.js'; | ||
| import {parseUrl} from './utils.js'; | ||
| import {Server} from './Server.js'; | ||
| import {WebAppWindow} from './WebAppWindow.js'; | ||
| // 10 seconds | ||
| const WEB_APP_CONTEXT_LOAD_TIMEOUT = 10000; | ||
| export class WebAppContext { | ||
| constructor() { | ||
| this.client = new Client(); | ||
| this.server = new Server(); | ||
| this.injector = null; | ||
| this.control = null; | ||
| this.loaded = false; | ||
| this.closed = false; | ||
| } | ||
| /** | ||
| * Creates a window (or attaches to an existing one) that loads a page that | ||
| * is expected to understand the web request RPC protocol. This method | ||
| * returns a Promise that will resolve once the page uses RPC to indicate | ||
| * that it is ready to be communicated with or once a timeout occurs. | ||
| * | ||
| * The Promise will resolve to an RPC injector that can be used to get or | ||
| * define APIs to enable communication with the WebApp running in the | ||
| * WebAppContext. | ||
| * | ||
| * @param {string} url - The URL to the page to connect to. | ||
| * @param {object} options - The options to use. | ||
| * @param {number} [options.timeout] - The timeout for waiting for the client | ||
| * to be ready. | ||
| * @param {object|Promise} [options.handle] - A window handle to connect to; | ||
| * may be a Promise that that resolves to a handle. | ||
| * @param {object} [options.iframe] - An iframe element to connect to. | ||
| * @param {object} [options.dialog] - An dialog to use. | ||
| * @param {boolean} [options.popup] - `true` to popup. | ||
| * @param {object} [options.windowControl] - A window control interface to | ||
| * connect to. | ||
| * @param {string} [options.className] - A className to assign to the window | ||
| * for CSS purposes. | ||
| * @param {Function} [options.customize] - A function to customize the dialog | ||
| * that loads the window after its construction: customize(options). | ||
| * @param {object} [options.bounds] - A bounding rectangle | ||
| * (top, left, width, height) to use when creating a popup window. | ||
| * | ||
| * @returns {Promise} Resolves to an RPC injector once the window is ready. | ||
| */ | ||
| async createWindow( | ||
| url, { | ||
| timeout = WEB_APP_CONTEXT_LOAD_TIMEOUT, | ||
| iframe, | ||
| dialog = null, | ||
| popup = false, | ||
| handle, | ||
| windowControl, | ||
| className, | ||
| customize, | ||
| // top, left, width, height | ||
| bounds | ||
| } = {}) { | ||
| // disallow loading the same WebAppContext more than once | ||
| if(this.loaded) { | ||
| throw new Error('AppContext already loaded.'); | ||
| } | ||
| this.loaded = true; | ||
| // create control API for WebApp to call via its own RPC client | ||
| this.control = new WebAppWindow(url, { | ||
| timeout, | ||
| dialog, | ||
| iframe, | ||
| popup, | ||
| handle, | ||
| windowControl, | ||
| className, | ||
| customize, | ||
| bounds | ||
| }); | ||
| // if the local window closes, close the control window as well | ||
| window.addEventListener('pagehide', () => this.close(), {once: true}); | ||
| // define control class; this enables the WebApp that is running in the | ||
| // WebAppContext to control its UI or close itself down | ||
| this.server.define('core.control', this.control); | ||
| // listen for calls from the window, ignoring calls to unknown APIs | ||
| // to allow those to be handled by other servers | ||
| const origin = parseUrl(url).origin; | ||
| this.server.listen(origin, { | ||
| handle: this.control.handle, | ||
| ignoreUnknownApi: true | ||
| }); | ||
| // wait for control to be ready | ||
| await this.control._private.isReady(); | ||
| // connect to the WebAppContext and return the injector | ||
| this.injector = await this.client.connect(origin, { | ||
| handle: this.control.handle | ||
| }); | ||
| return this.injector; | ||
| } | ||
| close() { | ||
| if(!this.closed) { | ||
| this.closed = true; | ||
| this.control._private.destroy(); | ||
| this.server.close(); | ||
| this.client.close(); | ||
| } | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2017-2023 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {WebAppWindowInlineDialog} from './WebAppWindowInlineDialog.js'; | ||
| import {WebAppWindowPopupDialog} from './WebAppWindowPopupDialog.js'; | ||
| // default timeout is 60 seconds | ||
| const LOAD_WINDOW_TIMEOUT = 60000; | ||
| /** | ||
| * Provides a window and API for remote Web applications. This API is typically | ||
| * used by RPC WebApps that run in a WebAppContext to indicate when they are | ||
| * ready and to show/hide their UI. | ||
| */ | ||
| export class WebAppWindow { | ||
| constructor( | ||
| url, { | ||
| timeout = LOAD_WINDOW_TIMEOUT, | ||
| dialog = null, | ||
| handle, | ||
| popup = false, | ||
| className = null, | ||
| customize = null, | ||
| // top, left, width, height | ||
| bounds | ||
| } = {}) { | ||
| this.visible = false; | ||
| this.dialog = dialog; | ||
| this.handle = null; | ||
| this.popup = popup; | ||
| this.windowControl = null; | ||
| this._destroyed = false; | ||
| this._ready = false; | ||
| this._private = {}; | ||
| this._timeoutId = null; | ||
| if(handle && handle._dialog) { | ||
| this.dialog = dialog = handle._dialog; | ||
| } | ||
| // private to allow caller to track readiness | ||
| this._private._readyPromise = new Promise((resolve, reject) => { | ||
| // reject if timeout reached | ||
| this._timeoutId = setTimeout( | ||
| () => reject(new DOMException( | ||
| 'Loading Web application window timed out.', 'TimeoutError')), | ||
| timeout); | ||
| this._private._resolveReady = value => { | ||
| clearTimeout(this.timeoutId); | ||
| this._timeoutId = null; | ||
| resolve(value); | ||
| }; | ||
| this._private._rejectReady = err => { | ||
| clearTimeout(this.timeoutId); | ||
| this._timeoutId = null; | ||
| reject(err); | ||
| }; | ||
| }); | ||
| this._private.isReady = async () => { | ||
| return this._private._readyPromise; | ||
| }; | ||
| // private to disallow destruction via client | ||
| this._private.destroy = () => { | ||
| // window not ready yet, but destroyed | ||
| if(this._timeoutId) { | ||
| this._private._rejectReady(new DOMException( | ||
| 'Web application window closed before ready.', 'AbortError')); | ||
| } | ||
| if(!this._destroyed) { | ||
| this.dialog.destroy(); | ||
| this.dialog = null; | ||
| this._destroyed = true; | ||
| } | ||
| }; | ||
| if(customize) { | ||
| if(!typeof customize === 'function') { | ||
| throw new TypeError('`options.customize` must be a function.'); | ||
| } | ||
| } | ||
| if(!this.dialog) { | ||
| if(this.popup) { | ||
| this.dialog = new WebAppWindowPopupDialog({url, handle, bounds}); | ||
| } else { | ||
| this.dialog = new WebAppWindowInlineDialog({url, handle, className}); | ||
| } | ||
| } | ||
| if(this.popup && bounds) { | ||
| // resize / re-position popup window as requested | ||
| let {x, y, width = 500, height = 400} = bounds; | ||
| width = Math.min(width, window.innerWidth); | ||
| // ~30 pixels must be added when resizing for window titlebar | ||
| height = Math.min(height + 30, window.innerHeight); | ||
| x = Math.floor(x !== undefined ? | ||
| x : window.screenX + (window.innerWidth - width) / 2); | ||
| // ~15 pixels must be added to account for window titlebar | ||
| y = Math.floor(y !== undefined ? | ||
| y : window.screenY + (window.innerHeight - height) / 2 + 15); | ||
| this.dialog.handle.resizeTo(width, height); | ||
| this.dialog.handle.moveTo(x, y); | ||
| } | ||
| this.handle = this.dialog.handle; | ||
| if(customize) { | ||
| try { | ||
| customize({ | ||
| dialog: this.dialog.dialog, | ||
| container: this.dialog.container, | ||
| iframe: this.dialog.iframe, | ||
| webAppWindow: this | ||
| }); | ||
| } catch(e) { | ||
| console.error(e); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Called by the client when it is ready to receive messages. | ||
| */ | ||
| ready() { | ||
| this._ready = true; | ||
| this._private._resolveReady(true); | ||
| } | ||
| /** | ||
| * Called by the client when it wants to show UI. | ||
| */ | ||
| show() { | ||
| if(!this.visible) { | ||
| this.visible = true; | ||
| // disable scrolling on body | ||
| const body = document.querySelector('body'); | ||
| this._bodyOverflowStyle = body.style.overflow; | ||
| body.style.overflow = 'hidden'; | ||
| if(!this._destroyed) { | ||
| this.dialog.show(); | ||
| } else if(this.windowControl.show) { | ||
| this.windowControl.show(); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Called by the client when it wants to hide UI. | ||
| */ | ||
| hide() { | ||
| if(this.visible) { | ||
| this.visible = false; | ||
| // restore `overflow` style on body | ||
| const body = document.querySelector('body'); | ||
| if(this._bodyOverflowStyle) { | ||
| body.style.overflow = this._bodyOverflowStyle; | ||
| } else { | ||
| body.style.overflow = ''; | ||
| } | ||
| if(!this._destroyed) { | ||
| this.dialog.close(); | ||
| } else if(this.windowControl.hide) { | ||
| this.windowControl.hide(); | ||
| } | ||
| } | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| export class WebAppWindowDialog { | ||
| constructor() { | ||
| this._closeEventListeners = new Set(); | ||
| } | ||
| addEventListener(name, listener) { | ||
| if(name !== 'close') { | ||
| throw new Error(`Unknown event "${name}".`); | ||
| } | ||
| if(typeof listener !== 'function') { | ||
| throw new TypeError('"listener" must be a function.'); | ||
| } | ||
| this._closeEventListeners.add(listener); | ||
| } | ||
| removeEventListener(name, listener) { | ||
| if(name !== 'close') { | ||
| throw new Error(`Unknown event "${name}".`); | ||
| } | ||
| if(typeof listener !== 'function') { | ||
| throw new TypeError('"listener" must be a function.'); | ||
| } | ||
| this._closeEventListeners.delete(listener); | ||
| } | ||
| show() {} | ||
| close() { | ||
| // emit event to all `close` event listeners | ||
| for(const listener of this._closeEventListeners) { | ||
| listener({}); | ||
| } | ||
| } | ||
| destroy() { | ||
| this._closeEventListeners.clear(); | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {WebAppWindowDialog} from './WebAppWindowDialog.js'; | ||
| export class WebAppWindowInlineDialog extends WebAppWindowDialog { | ||
| constructor({url, handle, className}) { | ||
| super(); | ||
| this.url = url; | ||
| this.handle = handle; | ||
| // create a top-level dialog overlay | ||
| this.dialog = document.createElement('dialog'); | ||
| applyStyle(this.dialog, { | ||
| position: 'fixed', | ||
| top: 0, | ||
| left: 0, | ||
| width: '100%', | ||
| height: '100%', | ||
| 'max-width': '100%', | ||
| 'max-height': '100%', | ||
| display: 'none', | ||
| margin: 0, | ||
| padding: 0, | ||
| border: 'none', | ||
| background: 'transparent', | ||
| color: 'black', | ||
| 'box-sizing': 'border-box', | ||
| overflow: 'hidden', | ||
| // prevent focus bug in chrome | ||
| 'user-select': 'none', | ||
| 'z-index': 1000000 | ||
| }); | ||
| this.dialog.className = 'web-app-window'; | ||
| if(typeof className === 'string') { | ||
| this.dialog.className = this.dialog.className + ' ' + className; | ||
| } | ||
| // ensure backdrop is transparent by default | ||
| const style = document.createElement('style'); | ||
| style.appendChild( | ||
| document.createTextNode(`dialog.web-app-window::backdrop { | ||
| background-color: transparent; | ||
| }`)); | ||
| // create flex container for iframe | ||
| this.container = document.createElement('div'); | ||
| applyStyle(this.container, { | ||
| position: 'relative', | ||
| width: '100%', | ||
| height: '100%', | ||
| margin: 0, | ||
| padding: 0, | ||
| display: 'flex', | ||
| 'flex-direction': 'column' | ||
| }); | ||
| this.container.className = 'web-app-window-backdrop'; | ||
| // create iframe | ||
| this.iframe = document.createElement('iframe'); | ||
| this.iframe.src = url; | ||
| this.iframe.scrolling = 'auto'; | ||
| applyStyle(this.iframe, { | ||
| position: 'fixed', | ||
| top: 0, | ||
| left: 0, | ||
| width: '100%', | ||
| height: '100%', | ||
| border: 'none', | ||
| background: 'transparent', | ||
| overflow: 'hidden', | ||
| margin: 0, | ||
| padding: 0, | ||
| 'flex-grow': 1, | ||
| // prevent focus bug in chrome | ||
| 'user-select': 'none' | ||
| }); | ||
| // assemble dialog | ||
| this.dialog.appendChild(style); | ||
| this.container.appendChild(this.iframe); | ||
| this.dialog.appendChild(this.container); | ||
| // a.document.appendChild(this.iframe); | ||
| // handle cancel (user pressed escape) | ||
| this.dialog.addEventListener('cancel', e => { | ||
| e.preventDefault(); | ||
| this.hide(); | ||
| }); | ||
| // attach to DOM | ||
| document.body.appendChild(this.dialog); | ||
| this.handle = this.iframe.contentWindow; | ||
| } | ||
| show() { | ||
| this.dialog.style.display = 'block'; | ||
| if(this.dialog.showModal) { | ||
| this.dialog.showModal(); | ||
| } | ||
| /* Note: Hack to solve chromium bug that sometimes (race condition) causes | ||
| mouse events to go to the underlying page instead of the iframe. This bug | ||
| generally manifests by showing a very quick flash of unstyled content / | ||
| background followed by a "not-allowed" mouse cursor over the page and over | ||
| the dialog and iframe, preventing interaction with the page until | ||
| the user right-clicks or causes some other render event in the page (a | ||
| render event inside the iframe does not seem to help resolve the bug). | ||
| Could be related to bug: tinyurl.com/2p9c66z9 | ||
| Or could be related to the "Paint Holding" chromium feature. | ||
| We have found experimentally, that resetting accepting pointer events on | ||
| the dialog and allowing enough frames for rendering (16 ms is insufficient | ||
| but 32 ms seems to work), the bug resolves. */ | ||
| try { | ||
| this.dialog.style.pointerEvents = 'none'; | ||
| } catch(e) {} | ||
| setTimeout(() => { | ||
| try { | ||
| this.dialog.style.pointerEvents = ''; | ||
| } catch(e) {} | ||
| }, 32); | ||
| } | ||
| close() { | ||
| this.dialog.style.display = 'none'; | ||
| if(this.dialog.close) { | ||
| try { | ||
| this.dialog.close(); | ||
| } catch(e) { | ||
| console.error(e); | ||
| } | ||
| } | ||
| super.close(); | ||
| } | ||
| destroy() { | ||
| this.dialog.parentNode.removeChild(this.dialog); | ||
| super.destroy(); | ||
| } | ||
| } | ||
| function applyStyle(element, style) { | ||
| for(const name in style) { | ||
| element.style[name] = style[name]; | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {WebAppWindowDialog} from './WebAppWindowDialog.js'; | ||
| export class WebAppWindowPopupDialog extends WebAppWindowDialog { | ||
| constructor({url, handle, bounds = {width: 500, height: 400}}) { | ||
| super(); | ||
| this.url = url; | ||
| this.handle = handle; | ||
| this._locationChanging = false; | ||
| if(!handle) { | ||
| this._openWindow({url, name: 'web-app-window', bounds}); | ||
| } | ||
| this.destroyed = false; | ||
| this._removeListeners = () => {}; | ||
| } | ||
| show() {} | ||
| close() { | ||
| this.destroy(); | ||
| } | ||
| destroy() { | ||
| if(this.handle && !this.destroyed) { | ||
| this.handle.close(); | ||
| super.close(); | ||
| this.handle = null; | ||
| this.destroyed = true; | ||
| this._removeListeners(); | ||
| super.destroy(); | ||
| } | ||
| } | ||
| isClosed() { | ||
| return !this.handle || this.handle.closed; | ||
| } | ||
| _openWindow({url, name, bounds}) { | ||
| const {x, y} = bounds; | ||
| let {width = 500, height = 400} = bounds; | ||
| width = Math.min(width, window.innerWidth); | ||
| height = Math.min(height, window.innerHeight); | ||
| const left = Math.floor(x !== undefined ? | ||
| x : window.screenX + (window.innerWidth - width) / 2); | ||
| const top = Math.floor(y !== undefined ? | ||
| y : window.screenY + (window.innerHeight - height) / 2); | ||
| const features = | ||
| 'popup=yes,menubar=no,location=no,resizable=no,scrollbars=no,status=no,' + | ||
| `width=${width},height=${height},left=${left},top=${top}`; | ||
| this._locationChanging = true; | ||
| this.handle = window.open(url, name, features); | ||
| this._addListeners(); | ||
| } | ||
| setLocation(url) { | ||
| this.url = url; | ||
| this._locationChanging = true; | ||
| this.handle.location.replace(url); | ||
| } | ||
| _addListeners() { | ||
| const destroyDialog = () => this.destroy(); | ||
| // when a new URL loads in the dialog, clear the location changing flag | ||
| const loadDialog = () => { | ||
| this._locationChanging = false; | ||
| }; | ||
| // when the dialog URL changes... | ||
| const unloadDialog = () => { | ||
| if(this._locationChanging) { | ||
| // a location change was expected, return | ||
| return; | ||
| } | ||
| // a location change was NOT expected, destroy the dialog | ||
| this.destroy(); | ||
| }; | ||
| this.handle.addEventListener('unload', unloadDialog); | ||
| this.handle.addEventListener('load', loadDialog); | ||
| // before the current window unloads, destroy the child dialog | ||
| window.addEventListener('beforeUnload', destroyDialog, {once: true}); | ||
| // poll to check for closed window handle; necessary because cross domain | ||
| // windows will not emit any close-related events we can use here | ||
| const intervalId = setInterval(() => { | ||
| if(this.isClosed()) { | ||
| this.destroy(); | ||
| clearInterval(intervalId); | ||
| } | ||
| }, 250); | ||
| // create listener clean up function | ||
| this._removeListeners = () => { | ||
| clearInterval(intervalId); | ||
| this.handle.removeListener('unload', unloadDialog); | ||
| this.handle.removeListener('load', loadDialog); | ||
| window.removeEventListener('beforeUnload', destroyDialog); | ||
| }; | ||
| } | ||
| } |
+1
-1
@@ -14,3 +14,3 @@ Copyright (c) 2016-2018, Digital Bazaar, Inc. | ||
| * Neither the name of jsonld-signatures nor the names of its | ||
| * Neither the name of the copyright holder nor the names of its | ||
| contributors may be used to endorse or promote products derived from | ||
@@ -17,0 +17,0 @@ this software without specific prior written permission. |
+17
-3
| { | ||
| "name": "web-request-rpc", | ||
| "version": "2.0.3", | ||
| "version": "3.0.0", | ||
| "description": "Web Request RPC", | ||
| "main": "index.js", | ||
| "type": "module", | ||
| "main": "./lib/index.js", | ||
| "exports": "./lib/index.js", | ||
| "files": [ | ||
| "lib/**/*.js" | ||
| ], | ||
| "scripts": { | ||
| "lint": "eslint --ext .cjs,.js ." | ||
| }, | ||
| "repository": { | ||
@@ -19,3 +27,9 @@ "type": "git", | ||
| }, | ||
| "homepage": "https://github.com/digitalbazaar/web-request-rpc" | ||
| "homepage": "https://github.com/digitalbazaar/web-request-rpc", | ||
| "devDependencies": { | ||
| "eslint": "^8.57.0", | ||
| "eslint-config-digitalbazaar": "^5.2.0", | ||
| "eslint-plugin-jsdoc": "^48.4.0", | ||
| "eslint-plugin-unicorn": "^54.0.0" | ||
| } | ||
| } |
| { | ||
| "env": { | ||
| "browser": true | ||
| } | ||
| } |
-170
| # web-request-rpc ChangeLog | ||
| ## 2.0.3 - 2023-01-26 | ||
| ### Fixed | ||
| - Fix popup window resize bugs. When calling `window.open`, the height | ||
| and width used are for the total content area not including any | ||
| title bar, when calling `resizeTo`, the height and width are for the | ||
| total content area and any title bar, etc. Additionally, the window | ||
| bounds parameters are ignored in Firefox when opening a new window | ||
| if the parent window is maximized, so the resizeTo/moveTo APIs must | ||
| always be called on popups. | ||
| ## 2.0.2 - 2022-11-17 | ||
| ### Fixed | ||
| - Apply workaround for chromium bug where mouse events are sent to the | ||
| underlying page instead of an element in an iframe that is over the | ||
| page. | ||
| ## 2.0.1 - 2022-11-09 | ||
| ### Fixed | ||
| - Mark inline dialog and iframe as unselectable to avoid chromium focus bug. | ||
| ## 2.0.0 - 2022-06-13 | ||
| ### Changed | ||
| - **BREAKING**: Enable the use of 1p popup dialogs or iframes when creating | ||
| new web app windows. | ||
| ## 1.1.7 - 2021-01-22 | ||
| ### Fixed | ||
| - Fix typo. | ||
| ## 1.1.6 - 2021-01-22 | ||
| ### Fixed | ||
| - Fix CSS dialog issue with Chrome 88. | ||
| ## 1.1.5 - 2019-10-01 | ||
| ### Fixed | ||
| - Allow iframes for WebAppWindows to enable scrolling automatically | ||
| to better emulate normal pages. | ||
| ## 1.1.4 - 2019-06-07 | ||
| ### Fixed | ||
| - Ensure legacy URL parser prefixes `/` to pathname. | ||
| ## 1.1.3 - 2019-06-07 | ||
| ### Fixed | ||
| - Fix URL constructor feature detection. | ||
| ## 1.1.2 - 2018-10-14 | ||
| ### Changed | ||
| - Use percentages instead of viewport units. | ||
| ## 1.1.1 - 2018-10-10 | ||
| ### Fixed | ||
| - Ensure default web app window iframe cannot be larger than | ||
| viewport. | ||
| ## 1.1.0 - 2018-10-10 | ||
| ### Added | ||
| - Add `web-app-window-backdrop` class for customization. | ||
| ## 1.0.4 - 2018-10-09 | ||
| ### Fixed | ||
| - Use `fixed` positioning for WebAppWindow iframe to | ||
| fix mobile CSS issues. | ||
| ## 1.0.3 - 2018-09-27 | ||
| ### Fixed | ||
| - Make early closing of WebAppContext more robust and | ||
| expose `closed` flag. | ||
| - Prevent timeout from firing when the WebAppContext | ||
| is intentionally closed. | ||
| ## 1.0.2 - 2018-08-20 | ||
| ### Fixed | ||
| - Disable body overflow when showing UI. | ||
| ### Changed | ||
| - Make default loading timeout 60 seconds. | ||
| ## 1.0.1 - 2018-07-30 | ||
| ### Fixed | ||
| - Fix bugs with tracking pending requests; ensure to | ||
| terminate all pending requests when client closes. | ||
| ## 1.0.0 - 2018-07-20 | ||
| ## 0.1.7 - 2018-03-22 | ||
| ### Changed | ||
| - Improve error marshalling. | ||
| - Do not bundle dialog polyfill or require HTML5 Dialog. | ||
| ## 0.1.6 - 2017-09-03 | ||
| ### Added | ||
| - Add ability to pass a Promise that resolves to a window handle. | ||
| ## 0.1.5 - 2017-09-01 | ||
| ### Fixed | ||
| - Include `.js` extension on imports. | ||
| ## 0.1.4 - 2017-08-31 | ||
| ### Fixed | ||
| - Fix display bug on Edge by defaulting iframe | ||
| for WebAppWindow to 100% width+height. | ||
| ## 0.1.3 - 2017-08-29 | ||
| ### Fixed | ||
| - Do not pass undefined `base`, it breaks Safari. | ||
| ## 0.1.2 - 2017-08-24 | ||
| ### Fixed | ||
| - Ensure dialogPolyfill is loaded. | ||
| ## 0.1.1 - 2017-08-24 | ||
| ### Added | ||
| - Add hook to enable customization of WebAppWindow. | ||
| ## 0.1.0 - 2017-08-18 | ||
| ## 0.0.6 - 2017-08-18 | ||
| ### Fixed | ||
| - Add `dialog-polyfill` dependency. | ||
| ## 0.0.5 - 2017-08-18 | ||
| ### Fixed | ||
| - Fixuse of `const`. | ||
| ## 0.0.4 - 2017-08-18 | ||
| ### Changed | ||
| - Make WebAppWindow backdrop transparent by default. | ||
| ## 0.0.3 - 2017-08-14 | ||
| ### Changed | ||
| - Rename `ClientWindow` to `WebAppWindow` to avoid confusion. | ||
| ## 0.0.2 - 2017-08-10 | ||
| ## 0.0.1 - 2017-08-10 | ||
| ### Added | ||
| - Add core files. | ||
| - See git history for changes previous to this release. |
-220
| /*! | ||
| * Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| 'use strict'; | ||
| import * as utils from './utils.js'; | ||
| // 30 second default timeout | ||
| const RPC_CLIENT_CALL_TIMEOUT = 30000; | ||
| export class Client { | ||
| constructor() { | ||
| this.origin = null; | ||
| this._handle = null; | ||
| this._listener = null; | ||
| // all pending requests | ||
| this._pending = new Map(); | ||
| } | ||
| /** | ||
| * Connects to a Web Request RPC server. | ||
| * | ||
| * The Promise will resolve to an RPC injector that can be used to get or | ||
| * define APIs to enable communication with the server. | ||
| * | ||
| * @param origin the origin to send messages to. | ||
| * @param options the options to use: | ||
| * [handle] a handle to the window (or a Promise that resolves to | ||
| * a handle) to send messages to | ||
| * (defaults to `window.opener || window.parent`). | ||
| * | ||
| * @return a Promise that resolves to an RPC injector once connected. | ||
| */ | ||
| async connect(origin, options) { | ||
| if(this._listener) { | ||
| throw new Error('Already connected.'); | ||
| } | ||
| options = options || {}; | ||
| // TODO: validate `origin` and `options.handle` | ||
| const self = this; | ||
| self.origin = utils.parseUrl(origin).origin; | ||
| self._handle = options.handle || window.opener || window.parent; | ||
| const pending = self._pending; | ||
| self._listener = utils.createMessageListener({ | ||
| origin: self.origin, | ||
| handle: self._handle, | ||
| expectRequest: false, | ||
| listener: message => { | ||
| // ignore messages that have no matching, pending request | ||
| if(!pending.has(message.id)) { | ||
| return; | ||
| } | ||
| // resolve or reject Promise associated with message | ||
| const {resolve, reject, cancelTimeout} = pending.get(message.id); | ||
| cancelTimeout(); | ||
| if('result' in message) { | ||
| return resolve(message.result); | ||
| } | ||
| reject(utils.deserializeError(message.error)); | ||
| } | ||
| }); | ||
| window.addEventListener('message', self._listener); | ||
| return new Injector(self); | ||
| } | ||
| /** | ||
| * Performs a RPC by sending a message to the Web Request RPC server and | ||
| * awaiting a response. | ||
| * | ||
| * @param qualifiedMethodName the fully-qualified name of the method to call. | ||
| * @param parameters the parameters for the method. | ||
| * @param options the options to use: | ||
| * [timeout] a timeout, in milliseconds, for awaiting a response; | ||
| * a non-positive timeout (<= 0) will cause an indefinite wait. | ||
| * | ||
| * @return a Promise that resolves to the result (or error) of the call. | ||
| */ | ||
| async send(qualifiedMethodName, parameters, { | ||
| timeout = RPC_CLIENT_CALL_TIMEOUT | ||
| }) { | ||
| if(!this._listener) { | ||
| throw new Error('RPC client not connected.'); | ||
| } | ||
| const self = this; | ||
| const message = { | ||
| jsonrpc: '2.0', | ||
| id: utils.uuidv4(), | ||
| method: qualifiedMethodName, | ||
| params: parameters | ||
| }; | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| if(utils.isHandlePromise(self._handle)) { | ||
| const handle = await self._handle; | ||
| handle.postMessage(message, self.origin); | ||
| } else { | ||
| self._handle.postMessage(message, self.origin); | ||
| } | ||
| // return Promise that will resolve once a response message has been | ||
| // received or once a timeout occurs | ||
| return new Promise((resolve, reject) => { | ||
| const pending = self._pending; | ||
| let cancelTimeout; | ||
| if(timeout > 0) { | ||
| const timeoutId = setTimeout(() => { | ||
| pending.delete(message.id); | ||
| reject(new Error('RPC call timed out.')); | ||
| }, timeout); | ||
| cancelTimeout = () => { | ||
| pending.delete(message.id); | ||
| clearTimeout(timeoutId); | ||
| }; | ||
| } else { | ||
| cancelTimeout = () => { | ||
| pending.delete(message.id); | ||
| }; | ||
| } | ||
| pending.set(message.id, {resolve, reject, cancelTimeout}); | ||
| }); | ||
| } | ||
| /** | ||
| * Disconnects from the remote Web Request RPC server and closes down this | ||
| * client. | ||
| */ | ||
| close() { | ||
| if(this._listener) { | ||
| window.removeEventListener('message', this._listener); | ||
| this._handle = this.origin = this._listener = null; | ||
| // reject all pending calls | ||
| for(const value of this._pending.values()) { | ||
| value.reject(new Error('RPC client closed.')); | ||
| } | ||
| this._pending = new Map(); | ||
| } | ||
| } | ||
| } | ||
| class Injector { | ||
| constructor(client) { | ||
| this.client = client; | ||
| this._apis = new Map(); | ||
| } | ||
| /** | ||
| * Defines a named API that will use an RPC client to implement its | ||
| * functions. Each of these functions will be asynchronous and return a | ||
| * Promise with the result from the RPC server. | ||
| * | ||
| * This function will return an interface with functions defined according | ||
| * to those provided in the given `definition`. The `name` parameter can be | ||
| * used to obtain this cached interface via `.get(name)`. | ||
| * | ||
| * @param name the name of the API. | ||
| * @param definition the definition for the API, including: | ||
| * functions: an array of function names (as strings) or objects | ||
| * containing: {name: <functionName>, options: <rpcClientOptions>}. | ||
| * | ||
| * @return an interface with the functions provided via `definition` that | ||
| * will make RPC calls to an RPC server to provide their | ||
| * implementation. | ||
| */ | ||
| define(name, definition) { | ||
| if(!(name && typeof name === 'string')) { | ||
| throw new TypeError('`name` must be a non-empty string.'); | ||
| } | ||
| // TODO: support Web IDL as a definition format? | ||
| if(!(definition && typeof definition === 'object' && | ||
| Array.isArray(definition.functions))) { | ||
| throw new TypeError( | ||
| '`definition.function` must be an array of function names or ' + | ||
| 'function definition objects to be defined.'); | ||
| } | ||
| const self = this; | ||
| const api = {}; | ||
| definition.functions.forEach(fn => { | ||
| if(typeof fn === 'string') { | ||
| fn = {name: fn, options: {}}; | ||
| } | ||
| api[fn.name] = async function() { | ||
| return self.client.send( | ||
| name + '.' + fn.name, [...arguments], fn.options); | ||
| }; | ||
| }); | ||
| self._apis[name] = api; | ||
| return api; | ||
| } | ||
| /** | ||
| * Get a named API, defining it if necessary when a definition is provided. | ||
| * | ||
| * @param name the name of the API. | ||
| * @param [definition] the definition for the API; if the API is already | ||
| * defined, this definition is ignored. | ||
| * | ||
| * @return the interface. | ||
| */ | ||
| get(name, definition) { | ||
| const api = this._apis[name]; | ||
| if(!api) { | ||
| if(definition) { | ||
| return this.define(name, definition); | ||
| } | ||
| throw new Error(`API "${name}" has not been defined.`); | ||
| } | ||
| return this._apis[name]; | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| 'use strict'; | ||
| export class EventEmitter { | ||
| constructor({deserialize = e => e, waitUntil = async () => {}} = {}) { | ||
| this._listeners = []; | ||
| this._deserialize = deserialize; | ||
| this._waitUntil = waitUntil; | ||
| } | ||
| async emit(event) { | ||
| event = this._deserialize(event); | ||
| (this._listeners[event.type] || []).forEach(l => l(event)); | ||
| return this._waitUntil(event); | ||
| } | ||
| addEventListener(eventType, fn) { | ||
| if(!this._listeners[eventType]) { | ||
| this._listeners[eventType] = [fn]; | ||
| } else { | ||
| this._listeners[eventType].push(fn); | ||
| } | ||
| } | ||
| removeEventListener(eventType, fn) { | ||
| const listeners = this._listeners[eventType]; | ||
| if(!listeners) { | ||
| return; | ||
| } | ||
| const idx = listeners.indexOf(fn); | ||
| if(idx !== -1) { | ||
| listeners.splice(idx, 1); | ||
| } | ||
| } | ||
| } |
-16
| /*! | ||
| * JSON-RPC for Web Request Polyfills. | ||
| * | ||
| * Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| 'use strict'; | ||
| export {Client} from './Client.js'; | ||
| export {EventEmitter} from './EventEmitter.js'; | ||
| export {Server} from './Server.js'; | ||
| export {WebApp} from './WebApp.js'; | ||
| export {WebAppContext} from './WebAppContext.js'; | ||
| export {WebAppWindow} from './WebAppWindow.js'; | ||
| import * as utils from './utils.js'; | ||
| export {utils}; |
-143
| /*! | ||
| * Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| 'use strict'; | ||
| import * as utils from './utils.js'; | ||
| export class Server { | ||
| constructor() { | ||
| this.origin = null; | ||
| this._handle = null; | ||
| this._apis = new Map(); | ||
| } | ||
| /** | ||
| * Provides an implementation for a named API. All functions in the given | ||
| * API will be made callable via RPC clients connected to this server. | ||
| * | ||
| * @param name the name of the API. | ||
| * @param api the API to add. | ||
| */ | ||
| define(name, api) { | ||
| if(!(name && typeof name === 'string')) { | ||
| throw new TypeError('`name` must be a non-empty string.'); | ||
| } | ||
| if(!(api && api !== 'object')) { | ||
| throw new TypeError('`api` must be an object.'); | ||
| } | ||
| if(name in this._apis) { | ||
| throw new Error(`The "${name}" API is already defined.`); | ||
| } | ||
| this._apis[name] = api; | ||
| } | ||
| /** | ||
| * Listens for RPC messages from clients from a particular origin and | ||
| * window handle and uses them to execute API calls based on predefined | ||
| * APIs. | ||
| * | ||
| * If messages are not from the given origin or window handle, they are | ||
| * ignored. If the messages refer to named APIs that have not been defined | ||
| * then an error message is sent in response. These error messages can | ||
| * be suppressed by using the `ignoreUnknownApi` option. | ||
| * | ||
| * If a message refers to an unknown method on a known named API, then an | ||
| * error message is sent in response. | ||
| * | ||
| * @param origin the origin to listen for. | ||
| * @param options the options to use: | ||
| * [handle] a handle to the window (or a Promise that resolves to | ||
| * a handle) to listen for messages from | ||
| * (defaults to `window.opener || window.parent`). | ||
| * [ignoreUnknownApi] `true` to ignore unknown API messages. | ||
| */ | ||
| async listen(origin, options) { | ||
| if(this._listener) { | ||
| throw new Error('Already listening.'); | ||
| } | ||
| options = options || {}; | ||
| // TODO: validate `origin` and `options.handle` | ||
| const self = this; | ||
| self.origin = utils.parseUrl(origin).origin; | ||
| self._handle = options.handle || window.opener || window.parent; | ||
| const ignoreUnknownApi = (options.ignoreUnknownApi === 'true') || false; | ||
| self._listener = utils.createMessageListener({ | ||
| origin: self.origin, | ||
| handle: self._handle, | ||
| expectRequest: true, | ||
| listener: message => { | ||
| const {name, method} = utils.destructureMethodName(message.method); | ||
| const api = self._apis[name]; | ||
| // do not allow calling "private" methods (starts with `_`) | ||
| if(method && method.startsWith('_')) { | ||
| return sendMethodNotFound(self._handle, self.origin, message); | ||
| } | ||
| // API not found but ignore flag is on | ||
| if(!api && ignoreUnknownApi) { | ||
| // API not registered, ignore the message rather than raise error | ||
| return; | ||
| } | ||
| // no ignore flag and unknown API or unknown specific method | ||
| if(!api || typeof api[method] !== 'function') { | ||
| return sendMethodNotFound(self._handle, self.origin, message); | ||
| } | ||
| // API and specific function found | ||
| const fn = api[method]; | ||
| (async () => { | ||
| const response = { | ||
| jsonrpc: '2.0', | ||
| id: message.id | ||
| }; | ||
| try { | ||
| response.result = await fn.apply(api, message.params); | ||
| } catch(e) { | ||
| response.error = utils.serializeError(e); | ||
| } | ||
| // if server did not `close` while we waited for a response | ||
| if(self._handle) { | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| if(utils.isHandlePromise(self._handle)) { | ||
| self._handle.then(h => h.postMessage(response, self.origin)); | ||
| } else { | ||
| self._handle.postMessage(response, self.origin); | ||
| } | ||
| } | ||
| })(); | ||
| } | ||
| }); | ||
| window.addEventListener('message', self._listener); | ||
| } | ||
| close() { | ||
| if(this._listener) { | ||
| window.removeEventListener('message', this._listener); | ||
| this._handle = this.origin = this._listener = null; | ||
| } | ||
| } | ||
| } | ||
| function sendMethodNotFound(handle, origin, message) { | ||
| const response = { | ||
| jsonrpc: '2.0', | ||
| id: message.id, | ||
| error: Object.assign({}, utils.RPC_ERRORS.MethodNotFound) | ||
| }; | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| if(utils.isHandlePromise(handle)) { | ||
| return handle.then(h => h.postMessage(response, origin)); | ||
| } else { | ||
| return handle.postMessage(response, origin); | ||
| } | ||
| } |
-210
| /*! | ||
| * Utilities for Web Request RPC. | ||
| * | ||
| * Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| /* global URL */ | ||
| 'use strict'; | ||
| export const RPC_ERRORS = { | ||
| ParseError: { | ||
| message: 'Parse error', | ||
| code: -32700 | ||
| }, | ||
| InvalidRequest: { | ||
| message: 'Invalid Request', | ||
| code: -32600 | ||
| }, | ||
| MethodNotFound: { | ||
| message: 'Method not found', | ||
| code: -32601 | ||
| }, | ||
| InvalidParams: { | ||
| message: 'Invalid params', | ||
| code: -32602 | ||
| }, | ||
| InternalError: { | ||
| message: 'Internal Error', | ||
| code: -32603 | ||
| }, | ||
| ServerError: { | ||
| message: 'Server error', | ||
| code: -32000 | ||
| } | ||
| }; | ||
| export function parseUrl(url, base) { | ||
| if(base === undefined) { | ||
| base = window.location.href; | ||
| } | ||
| if(typeof URL === 'function') { | ||
| return new URL(url, base); | ||
| } | ||
| if(typeof url !== 'string') { | ||
| throw new TypeError('"url" must be a string.'); | ||
| } | ||
| // FIXME: rudimentary relative URL resolution | ||
| if(!url.includes(':')) { | ||
| if(base.startsWith('http') && !url.startsWith('/')) { | ||
| url = base + '/' + url; | ||
| } else { | ||
| url = base + url; | ||
| } | ||
| } | ||
| // `URL` API not supported, use DOM to parse URL | ||
| const parser = document.createElement('a'); | ||
| parser.href = url; | ||
| let origin = (parser.protocol || window.location.protocol) + '//'; | ||
| if(parser.host) { | ||
| // use hostname when using default ports | ||
| // (IE adds always adds port to `parser.host`) | ||
| if((parser.protocol === 'http:' && parser.port === '80') || | ||
| (parser.protocol === 'https:' && parser.port === '443')) { | ||
| origin += parser.hostname; | ||
| } else { | ||
| origin += parser.host; | ||
| } | ||
| } else { | ||
| origin += window.location.host; | ||
| } | ||
| // ensure pathname begins with `/` | ||
| let pathname = parser.pathname; | ||
| if(!pathname.startsWith('/')) { | ||
| pathname = '/' + pathname; | ||
| } | ||
| return { | ||
| // TODO: is this safe for general use on every browser that doesn't | ||
| // support WHATWG URL? | ||
| host: parser.host || window.location.host, | ||
| hostname: parser.hostname, | ||
| origin: origin, | ||
| protocol: parser.protocol, | ||
| pathname: pathname | ||
| }; | ||
| } | ||
| export function originMatches(url, origin) { | ||
| return parseUrl(url, origin).origin === origin; | ||
| } | ||
| // https://gist.github.com/LeverOne/1308368 | ||
| export function uuidv4(a,b) { | ||
| for(b=a='';a++<36;b+=a*51&52?(a^15?8^Math.random()*(a^20?16:4):4).toString(16):'-');return b; | ||
| } | ||
| export function isValidOrigin(url, origin) { | ||
| if(!originMatches(url, origin)) { | ||
| throw new Error( | ||
| `Origin mismatch. Url "${url}" does not have an origin of "${origin}".`); | ||
| } | ||
| } | ||
| export function isValidMessage(message) { | ||
| return ( | ||
| message && typeof message === 'object' && | ||
| message.jsonrpc === '2.0' && | ||
| message.id && typeof message.id === 'string'); | ||
| } | ||
| export function isValidRequest(message) { | ||
| return isValidMessage(message) && Array.isArray(message.params); | ||
| } | ||
| export function isValidResponse(message) { | ||
| return ( | ||
| isValidMessage(message) && | ||
| !!('result' in message ^ 'error' in message) && | ||
| (!('error' in message) || isValidError(message.error))); | ||
| } | ||
| export function isValidError(error) { | ||
| return ( | ||
| error && typeof error === 'object' && | ||
| typeof error.code === 'number' && | ||
| typeof error.message === 'string'); | ||
| } | ||
| export function serializeError(error) { | ||
| const err = { | ||
| message: error.message | ||
| }; | ||
| if(error.constructor.name !== 'Error') { | ||
| err.constructor = error.constructor.name; | ||
| } | ||
| if('name' in error) { | ||
| err.name = error.name; | ||
| } | ||
| if('code' in error) { | ||
| err.code = error.code; | ||
| } else { | ||
| err.code = RPC_ERRORS.ServerError.code; | ||
| } | ||
| if('details' in error) { | ||
| err.details = error.details; | ||
| } | ||
| return err; | ||
| } | ||
| export function deserializeError(error) { | ||
| let err; | ||
| // special case known types, otherwise use generic Error | ||
| if(error.constructor === 'DOMException') { | ||
| err = new DOMException(error.message, error.name) | ||
| // ignore code, name will set it | ||
| } else { | ||
| err = new Error(error.message); | ||
| if('code' in error) { | ||
| err.code = error.code; | ||
| } | ||
| } | ||
| if(error.details) { | ||
| err.details = error.details; | ||
| } | ||
| return err; | ||
| } | ||
| export function createMessageListener( | ||
| {listener, origin, handle, expectRequest}) { | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| if(isHandlePromise(handle)) { | ||
| const promise = handle; | ||
| handle = false; | ||
| promise.then(h => handle = h); | ||
| } | ||
| return e => { | ||
| // ignore messages from a non-matching handle or origin | ||
| // or that don't follow the protocol | ||
| if(!(e.source === handle && e.origin === origin && | ||
| ((expectRequest && isValidRequest(e.data)) || | ||
| (!expectRequest && isValidResponse(e.data))))) { | ||
| return; | ||
| } | ||
| listener(e.data, e); | ||
| }; | ||
| } | ||
| export function destructureMethodName(fqMethodName) { | ||
| // fully-qualified method name is: `<api-name>.<method-name>` | ||
| // where `<api-name>` is all but the last dot-delimited segment and | ||
| // `<method-name>` is the last dot-delimited segment | ||
| let [name, ...rest] = fqMethodName.split('.'); | ||
| const method = rest.pop(); | ||
| name = [name, ...rest].join('.'); | ||
| return {name, method}; | ||
| } | ||
| export function isHandlePromise(handle) { | ||
| try { | ||
| // HACK: we can't just `Promise.resolve(handle)` because Chrome has | ||
| // a bug that throws an exception if the handle is cross domain | ||
| return typeof handle.then === 'function'; | ||
| } catch(e) {} | ||
| return false; | ||
| } |
-87
| /*! | ||
| * A WebApp is a remote application that runs in a WebAppContext. | ||
| * | ||
| * Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| 'use strict'; | ||
| import {Client} from './Client.js'; | ||
| import {Server} from './Server.js'; | ||
| import {parseUrl} from './utils.js'; | ||
| export class WebApp { | ||
| constructor(relyingOrigin) { | ||
| // this is the origin that created the WebAppContext to run it in | ||
| // TODO: better name? `contextOrigin`? | ||
| this.relyingOrigin = parseUrl(relyingOrigin).origin; | ||
| this.client = null; | ||
| this.injector = null; | ||
| this.client = new Client(); | ||
| this.server = new Server(); | ||
| this._control = null; | ||
| this._connected = false; | ||
| } | ||
| /** | ||
| * Connects this WebApp to the relying origin that instantiated it. Once | ||
| * connected, the WebApp can start servicing calls from that origin. | ||
| * | ||
| * @return a Promise that resolves to an injector for creating custom client | ||
| * APIs once the connection is ready. | ||
| */ | ||
| async connect() { | ||
| this.injector = await this.client.connect(this.relyingOrigin); | ||
| this._connected = true; | ||
| this._control = this.injector.define('core.control', { | ||
| functions: ['ready', 'show', 'hide'] | ||
| }); | ||
| this.server.listen(this.relyingOrigin); | ||
| return this.injector; | ||
| } | ||
| /** | ||
| * Must be called after `connect` when this WebApp is ready to start | ||
| * receiving calls from the remote end. | ||
| */ | ||
| async ready() { | ||
| if(!this._connected) { | ||
| throw new Error('WebApp not connected. Did you call ".connect()"?'); | ||
| } | ||
| await this._control.ready(); | ||
| return this; | ||
| } | ||
| /** | ||
| * Closes this WebApp's connection to the relying origin. | ||
| */ | ||
| close() { | ||
| if(this._connected) { | ||
| this.server.close(); | ||
| this.client.close(); | ||
| this._connected = false; | ||
| } | ||
| } | ||
| /** | ||
| * Shows the UI for this WebApp on the relying origin. | ||
| */ | ||
| async show() { | ||
| if(!this._connected) { | ||
| throw new Error( | ||
| 'Cannot "show" yet; not connected. Did you call ".connect()"?'); | ||
| } | ||
| return this._control.show(); | ||
| } | ||
| /** | ||
| * Hides the UI for this WebApp on the relying origin. | ||
| */ | ||
| async hide() { | ||
| if(!this._connected) { | ||
| throw new Error( | ||
| 'Cannot "hide" yet; not connected. Did you call ".connect()?"'); | ||
| } | ||
| return this._control.hide(); | ||
| } | ||
| } |
-113
| /*! | ||
| * Copyright (c) 2017-2022 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {Client} from './Client.js'; | ||
| import {Server} from './Server.js'; | ||
| import {WebAppWindow} from './WebAppWindow.js'; | ||
| import {parseUrl} from './utils.js'; | ||
| // 10 seconds | ||
| const WEB_APP_CONTEXT_LOAD_TIMEOUT = 10000; | ||
| export class WebAppContext { | ||
| constructor() { | ||
| this.client = new Client(); | ||
| this.server = new Server(); | ||
| this.injector = null; | ||
| this.control = null; | ||
| this.loaded = false; | ||
| this.closed = false; | ||
| } | ||
| /** | ||
| * Creates a window (or attaches to an existing one) that loads a page that | ||
| * is expected to understand the web request RPC protocol. This method | ||
| * returns a Promise that will resolve once the page uses RPC to indicate | ||
| * that it is ready to be communicated with or once a timeout occurs. | ||
| * | ||
| * The Promise will resolve to an RPC injector that can be used to get or | ||
| * define APIs to enable communication with the WebApp running in the | ||
| * WebAppContext. | ||
| * | ||
| * @param url the URL to the page to connect to. | ||
| * @param options the options to use: | ||
| * [timeout] the timeout for waiting for the client to be ready. | ||
| * [handle] a window handle to connect to; may be a Promise that | ||
| * that resolves to a handle. | ||
| * [iframe] an iframe element to connect to. | ||
| * [windowControl] a window control interface to connect to. | ||
| * [className] a className to assign to the window for CSS purposes. | ||
| * [customize(options)] a function to customize the dialog that | ||
| * loads the window after its construction. | ||
| * [bounds] a bounding rectangle (top, left, width, height) to | ||
| * use when creating a popup window. | ||
| * | ||
| * @return a Promise that resolves to an RPC injector once the window is | ||
| * ready. | ||
| */ | ||
| async createWindow( | ||
| url, { | ||
| timeout = WEB_APP_CONTEXT_LOAD_TIMEOUT, | ||
| iframe, | ||
| dialog = null, | ||
| popup = false, | ||
| handle, | ||
| windowControl, | ||
| className, | ||
| customize, | ||
| // top, left, width, height | ||
| bounds | ||
| } = {}) { | ||
| // disallow loading the same WebAppContext more than once | ||
| if(this.loaded) { | ||
| throw new Error('AppContext already loaded.'); | ||
| } | ||
| this.loaded = true; | ||
| // create control API for WebApp to call via its own RPC client | ||
| this.control = new WebAppWindow(url, { | ||
| timeout, | ||
| dialog, | ||
| iframe, | ||
| popup, | ||
| handle, | ||
| windowControl, | ||
| className, | ||
| customize, | ||
| bounds | ||
| }); | ||
| // if the local window closes, close the control window as well | ||
| window.addEventListener('pagehide', () => this.close(), {once: true}); | ||
| // define control class; this enables the WebApp that is running in the | ||
| // WebAppContext to control its UI or close itself down | ||
| this.server.define('core.control', this.control); | ||
| // listen for calls from the window, ignoring calls to unknown APIs | ||
| // to allow those to be handled by other servers | ||
| const origin = parseUrl(url).origin; | ||
| this.server.listen(origin, { | ||
| handle: this.control.handle, | ||
| ignoreUnknownApi: true | ||
| }); | ||
| // wait for control to be ready | ||
| await this.control._private.isReady(); | ||
| // connect to the WebAppContext and return the injector | ||
| this.injector = await this.client.connect(origin, { | ||
| handle: this.control.handle | ||
| }); | ||
| return this.injector; | ||
| } | ||
| close() { | ||
| if(!this.closed) { | ||
| this.closed = true; | ||
| this.control._private.destroy(); | ||
| this.server.close(); | ||
| this.client.close(); | ||
| } | ||
| } | ||
| } |
-165
| /*! | ||
| * Copyright (c) 2017-2023 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {WebAppWindowInlineDialog} from './WebAppWindowInlineDialog.js'; | ||
| import {WebAppWindowPopupDialog} from './WebAppWindowPopupDialog.js'; | ||
| // default timeout is 60 seconds | ||
| const LOAD_WINDOW_TIMEOUT = 60000; | ||
| /** | ||
| * Provides a window and API for remote Web applications. This API is typically | ||
| * used by RPC WebApps that run in a WebAppContext to indicate when they are | ||
| * ready and to show/hide their UI. | ||
| */ | ||
| export class WebAppWindow { | ||
| constructor( | ||
| url, { | ||
| timeout = LOAD_WINDOW_TIMEOUT, | ||
| dialog = null, | ||
| handle, | ||
| popup = false, | ||
| className = null, | ||
| customize = null, | ||
| // top, left, width, height | ||
| bounds | ||
| } = {}) { | ||
| this.visible = false; | ||
| this.dialog = dialog; | ||
| this.handle = null; | ||
| this.popup = popup; | ||
| this.windowControl = null; | ||
| this._destroyed = false; | ||
| this._ready = false; | ||
| this._private = {}; | ||
| this._timeoutId = null; | ||
| if(handle && handle._dialog) { | ||
| this.dialog = dialog = handle._dialog; | ||
| } | ||
| // private to allow caller to track readiness | ||
| this._private._readyPromise = new Promise((resolve, reject) => { | ||
| // reject if timeout reached | ||
| this._timeoutId = setTimeout( | ||
| () => reject(new DOMException( | ||
| 'Loading Web application window timed out.', 'TimeoutError')), | ||
| timeout); | ||
| this._private._resolveReady = value => { | ||
| clearTimeout(this.timeoutId); | ||
| this._timeoutId = null; | ||
| resolve(value); | ||
| }; | ||
| this._private._rejectReady = err => { | ||
| clearTimeout(this.timeoutId); | ||
| this._timeoutId = null; | ||
| reject(err); | ||
| }; | ||
| }); | ||
| this._private.isReady = async () => { | ||
| return this._private._readyPromise; | ||
| }; | ||
| // private to disallow destruction via client | ||
| this._private.destroy = () => { | ||
| // window not ready yet, but destroyed | ||
| if(this._timeoutId) { | ||
| this._private._rejectReady(new DOMException( | ||
| 'Web application window closed before ready.', 'AbortError')); | ||
| } | ||
| if(!this._destroyed) { | ||
| this.dialog.destroy(); | ||
| this.dialog = null; | ||
| this._destroyed = true; | ||
| } | ||
| }; | ||
| if(customize) { | ||
| if(!typeof customize === 'function') { | ||
| throw new TypeError('`options.customize` must be a function.'); | ||
| } | ||
| } | ||
| if(!this.dialog) { | ||
| if(this.popup) { | ||
| this.dialog = new WebAppWindowPopupDialog({url, handle, bounds}); | ||
| } else { | ||
| this.dialog = new WebAppWindowInlineDialog({url, handle, className}); | ||
| } | ||
| } | ||
| if(this.popup && bounds) { | ||
| // resize / re-position popup window as requested | ||
| let {x, y, width = 500, height = 400} = bounds; | ||
| width = Math.min(width, window.innerWidth); | ||
| // ~30 pixels must be added when resizing for window titlebar | ||
| height = Math.min(height + 30, window.innerHeight); | ||
| x = Math.floor(x !== undefined ? | ||
| x : window.screenX + (window.innerWidth - width) / 2); | ||
| // ~15 pixels must be added to account for window titlebar | ||
| y = Math.floor(y !== undefined ? | ||
| y : window.screenY + (window.innerHeight - height) / 2 + 15); | ||
| this.dialog.handle.resizeTo(width, height); | ||
| this.dialog.handle.moveTo(x, y); | ||
| } | ||
| this.handle = this.dialog.handle; | ||
| if(customize) { | ||
| try { | ||
| customize({ | ||
| dialog: this.dialog.dialog, | ||
| container: this.dialog.container, | ||
| iframe: this.dialog.iframe, | ||
| webAppWindow: this | ||
| }); | ||
| } catch(e) { | ||
| console.error(e); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Called by the client when it is ready to receive messages. | ||
| */ | ||
| ready() { | ||
| this._ready = true; | ||
| this._private._resolveReady(true); | ||
| } | ||
| /** | ||
| * Called by the client when it wants to show UI. | ||
| */ | ||
| show() { | ||
| if(!this.visible) { | ||
| this.visible = true; | ||
| // disable scrolling on body | ||
| const body = document.querySelector('body'); | ||
| this._bodyOverflowStyle = body.style.overflow; | ||
| body.style.overflow = 'hidden'; | ||
| if(!this._destroyed) { | ||
| this.dialog.show(); | ||
| } else if(this.windowControl.show) { | ||
| this.windowControl.show(); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Called by the client when it wants to hide UI. | ||
| */ | ||
| hide() { | ||
| if(this.visible) { | ||
| this.visible = false; | ||
| // restore `overflow` style on body | ||
| const body = document.querySelector('body'); | ||
| if(this._bodyOverflowStyle) { | ||
| body.style.overflow = this._bodyOverflowStyle; | ||
| } else { | ||
| body.style.overflow = ''; | ||
| } | ||
| if(!this._destroyed) { | ||
| this.dialog.close(); | ||
| } else if(this.windowControl.hide) { | ||
| this.windowControl.hide(); | ||
| } | ||
| } | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| export class WebAppWindowDialog { | ||
| constructor() { | ||
| this._closeEventListeners = new Set(); | ||
| } | ||
| addEventListener(name, listener) { | ||
| if(name !== 'close') { | ||
| throw new Error(`Unknown event "${name}".`); | ||
| } | ||
| if(typeof listener !== 'function') { | ||
| throw new TypeError('"listener" must be a function.'); | ||
| } | ||
| this._closeEventListeners.add(listener); | ||
| } | ||
| removeEventListener(name, listener) { | ||
| if(name !== 'close') { | ||
| throw new Error(`Unknown event "${name}".`); | ||
| } | ||
| if(typeof listener !== 'function') { | ||
| throw new TypeError('"listener" must be a function.'); | ||
| } | ||
| this._closeEventListeners.delete(listener); | ||
| } | ||
| show() {} | ||
| close() { | ||
| // emit event to all `close` event listeners | ||
| for(const listener of this._closeEventListeners) { | ||
| listener({}); | ||
| } | ||
| } | ||
| destroy() { | ||
| this._closeEventListeners.clear(); | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {WebAppWindowDialog} from './WebAppWindowDialog.js'; | ||
| export class WebAppWindowInlineDialog extends WebAppWindowDialog { | ||
| constructor({url, handle, className}) { | ||
| super(); | ||
| this.url = url; | ||
| this.handle = handle; | ||
| // create a top-level dialog overlay | ||
| this.dialog = document.createElement('dialog'); | ||
| applyStyle(this.dialog, { | ||
| position: 'fixed', | ||
| top: 0, | ||
| left: 0, | ||
| width: '100%', | ||
| height: '100%', | ||
| 'max-width': '100%', | ||
| 'max-height': '100%', | ||
| display: 'none', | ||
| margin: 0, | ||
| padding: 0, | ||
| border: 'none', | ||
| background: 'transparent', | ||
| color: 'black', | ||
| 'box-sizing': 'border-box', | ||
| overflow: 'hidden', | ||
| // prevent focus bug in chrome | ||
| 'user-select': 'none', | ||
| 'z-index': 1000000 | ||
| }); | ||
| this.dialog.className = 'web-app-window'; | ||
| if(typeof className === 'string') { | ||
| this.dialog.className = this.dialog.className + ' ' + className; | ||
| } | ||
| // ensure backdrop is transparent by default | ||
| const style = document.createElement('style'); | ||
| style.appendChild( | ||
| document.createTextNode(`dialog.web-app-window::backdrop { | ||
| background-color: transparent; | ||
| }`)); | ||
| // create flex container for iframe | ||
| this.container = document.createElement('div'); | ||
| applyStyle(this.container, { | ||
| position: 'relative', | ||
| width: '100%', | ||
| height: '100%', | ||
| margin: 0, | ||
| padding: 0, | ||
| display: 'flex', | ||
| 'flex-direction': 'column' | ||
| }); | ||
| this.container.className = 'web-app-window-backdrop'; | ||
| // create iframe | ||
| this.iframe = document.createElement('iframe'); | ||
| this.iframe.src = url; | ||
| this.iframe.scrolling = 'auto'; | ||
| applyStyle(this.iframe, { | ||
| position: 'fixed', | ||
| top: 0, | ||
| left: 0, | ||
| width: '100%', | ||
| height: '100%', | ||
| border: 'none', | ||
| background: 'transparent', | ||
| overflow: 'hidden', | ||
| margin: 0, | ||
| padding: 0, | ||
| 'flex-grow': 1, | ||
| // prevent focus bug in chrome | ||
| 'user-select': 'none' | ||
| }); | ||
| // assemble dialog | ||
| this.dialog.appendChild(style); | ||
| this.container.appendChild(this.iframe); | ||
| this.dialog.appendChild(this.container); | ||
| // a.document.appendChild(this.iframe); | ||
| // handle cancel (user pressed escape) | ||
| this.dialog.addEventListener('cancel', e => { | ||
| e.preventDefault(); | ||
| this.hide(); | ||
| }); | ||
| // attach to DOM | ||
| document.body.appendChild(this.dialog); | ||
| this.handle = this.iframe.contentWindow; | ||
| } | ||
| show() { | ||
| this.dialog.style.display = 'block'; | ||
| if(this.dialog.showModal) { | ||
| this.dialog.showModal(); | ||
| } | ||
| /* Note: Hack to solve chromium bug that sometimes (race condition) causes | ||
| mouse events to go to the underlying page instead of the iframe. This bug | ||
| generally manifests by showing a very quick flash of unstyled content / | ||
| background followed by a "not-allowed" mouse cursor over the page and over | ||
| the dialog and iframe, preventing interaction with the page until | ||
| the user right-clicks or causes some other render event in the page (a | ||
| render event inside the iframe does not seem to help resolve the bug). | ||
| Could be related to bug: tinyurl.com/2p9c66z9 | ||
| Or could be related to the "Paint Holding" chromium feature. | ||
| We have found experimentally, that resetting accepting pointer events on | ||
| the dialog and allowing enough frames for rendering (16 ms is insufficient | ||
| but 32 ms seems to work), the bug resolves. */ | ||
| try { | ||
| this.dialog.style.pointerEvents = 'none'; | ||
| } catch(e) {} | ||
| setTimeout(() => { | ||
| try { | ||
| this.dialog.style.pointerEvents = ''; | ||
| } catch(e) {} | ||
| }, 32); | ||
| } | ||
| close() { | ||
| this.dialog.style.display = 'none'; | ||
| if(this.dialog.close) { | ||
| try { | ||
| this.dialog.close(); | ||
| } catch(e) { | ||
| console.error(e); | ||
| } | ||
| } | ||
| super.close(); | ||
| } | ||
| destroy() { | ||
| this.dialog.parentNode.removeChild(this.dialog); | ||
| super.destroy(); | ||
| } | ||
| } | ||
| function applyStyle(element, style) { | ||
| for(const name in style) { | ||
| element.style[name] = style[name]; | ||
| } | ||
| } |
| /*! | ||
| * Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved. | ||
| */ | ||
| import {WebAppWindowDialog} from './WebAppWindowDialog.js'; | ||
| export class WebAppWindowPopupDialog extends WebAppWindowDialog { | ||
| constructor({url, handle, bounds = {width: 500, height: 400}}) { | ||
| super(); | ||
| this.url = url; | ||
| this.handle = handle; | ||
| this._locationChanging = false; | ||
| if(!handle) { | ||
| this._openWindow({url, name: 'web-app-window', bounds}); | ||
| } | ||
| this.destroyed = false; | ||
| this._removeListeners = () => {}; | ||
| } | ||
| show() {} | ||
| close() { | ||
| this.destroy(); | ||
| } | ||
| destroy() { | ||
| if(this.handle && !this.destroyed) { | ||
| this.handle.close(); | ||
| super.close(); | ||
| this.handle = null; | ||
| this.destroyed = true; | ||
| this._removeListeners(); | ||
| super.destroy(); | ||
| } | ||
| } | ||
| isClosed() { | ||
| return !this.handle || this.handle.closed; | ||
| } | ||
| _openWindow({url, name, bounds}) { | ||
| const {x, y} = bounds; | ||
| let {width = 500, height = 400} = bounds; | ||
| width = Math.min(width, window.innerWidth); | ||
| height = Math.min(height, window.innerHeight); | ||
| const left = Math.floor(x !== undefined ? | ||
| x : window.screenX + (window.innerWidth - width) / 2); | ||
| const top = Math.floor(y !== undefined ? | ||
| y : window.screenY + (window.innerHeight - height) / 2); | ||
| const features = | ||
| 'popup=yes,menubar=no,location=no,resizable=no,scrollbars=no,status=no,' + | ||
| `width=${width},height=${height},left=${left},top=${top}`; | ||
| this._locationChanging = true; | ||
| this.handle = window.open(url, name, features); | ||
| this._addListeners(); | ||
| } | ||
| setLocation(url) { | ||
| this.url = url; | ||
| this._locationChanging = true; | ||
| this.handle.location.replace(url); | ||
| } | ||
| _addListeners() { | ||
| const destroyDialog = () => this.destroy(); | ||
| // when a new URL loads in the dialog, clear the location changing flag | ||
| const loadDialog = () => { | ||
| this._locationChanging = false; | ||
| }; | ||
| // when the dialog URL changes... | ||
| const unloadDialog = () => { | ||
| if(this._locationChanging) { | ||
| // a location change was expected, return | ||
| return; | ||
| } | ||
| // a location change was NOT expected, destroy the dialog | ||
| this.destroy(); | ||
| }; | ||
| this.handle.addEventListener('unload', unloadDialog); | ||
| this.handle.addEventListener('load', loadDialog); | ||
| // before the current window unloads, destroy the child dialog | ||
| window.addEventListener('beforeUnload', destroyDialog, {once: true}); | ||
| // poll to check for closed window handle; necessary because cross domain | ||
| // windows will not emit any close-related events we can use here | ||
| const intervalId = setInterval(() => { | ||
| if(this.isClosed()) { | ||
| this.destroy(); | ||
| clearInterval(intervalId); | ||
| } | ||
| }, 250); | ||
| // create listener clean up function | ||
| this._removeListeners = () => { | ||
| clearInterval(intervalId); | ||
| this.handle.removeListener('unload', unloadDialog); | ||
| this.handle.removeListener('load', loadDialog); | ||
| window.removeEventListener('beforeUnload', destroyDialog); | ||
| } | ||
| } | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
1170
0.86%Yes
NaN42632
-5.34%4
Infinity%14
-12.5%2
Infinity%