@jetbrains/websandbox
Version:
A sandbox library for runnung javascript inside HTML5 sandboxed iframe
199 lines (171 loc) • 6.45 kB
text/typescript
import Connection from './connection';
// @ts-expect-error loader-based input
import CompiledFrameScript from 'compile-code-loader!./frame.ts';
import { API } from './types';
export interface SandboxOptions {
// A selector or DOM node where iframe will be appended
frameContainer: string | Element;
// A class that <iframe/> element will has
frameClassName?: string;
/*
A url of iframe content.
If set, "frameContent", "codeToRunBeforeInit", "initialStyles", "baseUrl" won't take any effect.
In order to work properly, html file by frameSrc should have ./frame.js code bundled
*/
frameSrc?: string | null;
// A content of sandbox iFrame
frameContent?: string;
// A js code to run before any other iFrame code (will be injected in <head/>)
codeToRunBeforeInit?: string | null,
// A CSS markup to inject into iFrame <head/>
initialStyles?: string | null,
// A URL that will be used as base url for all relative pathes in tags like <script/>, <link/>. See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
baseUrl?: string | null,
// Is sandboxed iFrame allowed to capture pointer. See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe
allowPointerLock?: boolean,
// Is iFrame allowed to go fullscreen. See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe
allowFullScreen?: boolean,
// Additional attributes to add into sandboxed iFrame
sandboxAdditionalAttributes?: string,
// Additional attributes to add into sandboxed iFrame
allowAdditionalAttributes?: string
}
export const BaseOptions: SandboxOptions = {
frameContainer: 'body',
frameClassName: 'websandbox__frame',
frameSrc: null,
frameContent: `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body></body>
</html>
`,
codeToRunBeforeInit: null,
initialStyles: null,
baseUrl: null,
allowPointerLock: false,
allowFullScreen: false,
sandboxAdditionalAttributes: ''
};
class Websandbox {
options: SandboxOptions;
iframe: HTMLIFrameElement;
promise: Promise<unknown>;
connection: Connection | null = null;
removeMessageListener: () => void = () => {};
/**
* Creates sandbox instancea
* @param localApi Api of this side. Will be available for sandboxed code as remoteApi
* @param options Options of created sandbox
*/
static create(localApi: API, options: Partial<SandboxOptions> = {}) {
return new Websandbox(localApi, options);
}
/**
* {Constructor}
* @param localApi
* @param options
*/
constructor(localApi: API, options: Partial<SandboxOptions>) {
this.validateOptions(options);
this.options = {...BaseOptions, ...options};
this.iframe = this.createIframe();
this.promise = new Promise(resolve => {
this.connection = new Connection(
this.iframe.contentWindow!.postMessage.bind(this.iframe.contentWindow),
listener => {
const sourceCheckListener = (event: MessageEvent) => {
if (event.source !== this.iframe.contentWindow) {
return;
}
return listener(event);
};
window.addEventListener('message', sourceCheckListener);
this.removeMessageListener = () => window.removeEventListener('message', sourceCheckListener);
},
{allowedSenderOrigin: 'null'}
);
this.connection.setServiceMethods({
iframeInitialized: () => {
return this.connection!
.setLocalApi(localApi)
.then(() => resolve(this));
}
});
});
}
validateOptions(options: Partial<SandboxOptions>) {
if (options.frameSrc && (options.frameContent || options.initialStyles || options.baseUrl || options.codeToRunBeforeInit)) {
throw new Error('You can not set both "frameSrc" and any of frameContent,initialStyles,baseUrl,codeToRunBeforeInit options');
}
if ('frameContent' in options && !options.frameContent?.includes('<head>')) {
throw new Error('Websandbox: iFrame content must have "<head>" tag.');
}
}
_prepareFrameContent(options: SandboxOptions) {
let frameContent = options.frameContent ?? '';
if (options.codeToRunBeforeInit) {
frameContent = frameContent
.replace('<head>', `<head>\n<script>${options.codeToRunBeforeInit}</script>`) ?? '';
}
frameContent = frameContent
.replace('<head>', `<head>\n<script>${CompiledFrameScript}</script>`) ?? '';
if (options.initialStyles) {
frameContent = frameContent
.replace('</head>', `<style>${options.initialStyles}</style>\n</head>`);
}
if (options.baseUrl) {
frameContent = frameContent
.replace('<head>', `<head>\n<base target="_parent" href="${options.baseUrl}"/>`);
}
return frameContent;
}
createIframe() {
const containerSelector = this.options.frameContainer;
const container = typeof containerSelector === 'string'
? document.querySelector(containerSelector)
: containerSelector;
if (!container) {
throw new Error('Websandbox: Cannot find container for sandbox ' + container);
}
const frame = document.createElement('iframe');
frame.sandbox = `allow-scripts ${this.options.sandboxAdditionalAttributes}`;
frame.allow = `${this.options.allowAdditionalAttributes}`;
frame.className = this.options.frameClassName ?? '';
if (this.options.allowFullScreen) {
frame.allowFullscreen = true;
}
if (this.options.frameSrc) {
frame.src = this.options.frameSrc;
container.appendChild(frame);
return frame;
}
frame.setAttribute('srcdoc', this._prepareFrameContent(this.options));
container.appendChild(frame);
return frame;
}
destroy() {
this.iframe.remove();
this.removeMessageListener();
}
_runCode(code: string) {
return this.connection!.callRemoteServiceMethod('runCode', code);
}
_runFunction(fn: Function) {
return this._runCode(`(${fn.toString()})()`);
}
run(codeOrFunction: string | Function) {
if ((codeOrFunction as Function).name) {
return this._runFunction(codeOrFunction as Function);
}
return this._runCode(codeOrFunction as string);
}
importScript(path: string) {
return this.connection!.callRemoteServiceMethod('importScript', path);
}
injectStyle(style: string) {
return this.connection!.callRemoteServiceMethod('injectStyle', style);
}
}
export default Websandbox;