🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

web-request-rpc

Package Overview
Dependencies
Maintainers
5
Versions
33
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

web-request-rpc - npm Package Compare versions

Comparing version
2.0.3
to
3.0.0
+222
lib/Client.js
/*!
* 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);
}
}
}
/*!
* 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};
/*!
* 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);
}
}
/*!
* 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.

{
"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
}
}
# 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.
/*!
* 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);
}
}
}
/*!
* 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};
/*!
* 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);
}
}
/*!
* 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;
}
/*!
* 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();
}
}
/*!
* 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();
}
}
}
/*!
* 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);
}
}
}