Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make requests to the VM's methods reject after a given period of time if no answer is forthcoming from the iframe #12

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/connection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CONNECT_INTERVAL, CONNECT_MAX_ATTEMPTS } from './constants';
import { genID } from './helpers';
import { VM } from './vm';
import type { VMOptions } from './interfaces';

const connections: Connection[] = [];

Expand All @@ -10,13 +11,13 @@ export class Connection {
pending: Promise<VM>;
vm?: VM;

constructor(element: HTMLIFrameElement) {
constructor(element: HTMLIFrameElement, options?: VMOptions) {
this.id = genID();
this.element = element;
this.pending = new Promise<VM>((resolve, reject) => {
const listenForSuccess = ({ data, ports }: MessageEvent) => {
if (data?.action === 'SDK_INIT_SUCCESS' && data.id === this.id) {
this.vm = new VM(ports[0], data.payload);
this.vm = new VM(ports[0], {...data.payload, ...(options || {})});
resolve(this.vm);
cleanup();
}
Expand Down
15 changes: 14 additions & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PROJECT_TEMPLATES, UI_SIDEBAR_VIEWS, UI_THEMES, UI_VIEWS } from './constants';
import type { VM } from './vm'

export interface Project {
title: string;
Expand Down Expand Up @@ -51,6 +52,18 @@ export interface ProjectSettings {
};
}

/**
* @typedef { VM } VM
*/
export interface VMOptions {
/**
* Time (in milliseconds) until a call to one of a {@link VM}'s methods will be considered to have failed, and the returned Promise rejected.
*
* Defaults to 5000 (5 seconds).
*/
requestTimeout?: number;
}

export interface ProjectOptions {
/**
* Show a UI dialog asking users to click a button to run the project.
Expand Down Expand Up @@ -192,7 +205,7 @@ export interface OpenOptions extends ProjectOptions {
zenMode?: boolean;
}

export interface EmbedOptions extends ProjectOptions {
export interface EmbedOptions extends ProjectOptions, VMOptions {
/**
* Height of the embed iframe
*/
Expand Down
12 changes: 6 additions & 6 deletions src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Project, OpenOptions, EmbedOptions } from './interfaces';
import type { Project, OpenOptions, EmbedOptions, VMOptions } from './interfaces';
import type { VM } from './vm';
import { Connection, getConnection } from './connection';
import { openNewProject, createProjectFrameHTML } from './generate';
Expand All @@ -7,11 +7,11 @@ import { embedUrl, findElement, openTarget, openUrl, replaceAndEmbed } from './h
/**
* Get a VM instance for an existing StackBlitz project iframe.
*/
export function connect(frameEl: HTMLIFrameElement): Promise<VM> {
export function connect(frameEl: HTMLIFrameElement, options?: VMOptions): Promise<VM> {
if (!frameEl?.contentWindow) {
return Promise.reject('Provided element is not an iframe.');
}
const connection = getConnection(frameEl) ?? new Connection(frameEl);
const connection = getConnection(frameEl) ?? new Connection(frameEl, options);
return connection.pending;
}

Expand Down Expand Up @@ -64,7 +64,7 @@ export function embedProject(
// HTML needs to be written after iframe is embedded
frame.contentDocument?.write(html);

return connect(frame);
return connect(frame, options);
}

/**
Expand All @@ -83,7 +83,7 @@ export function embedProjectId(

replaceAndEmbed(element, frame, options);

return connect(frame);
return connect(frame, options);
}

/**
Expand All @@ -102,5 +102,5 @@ export function embedGithubProject(

replaceAndEmbed(element, frame, options);

return connect(frame);
return connect(frame, options);
}
2 changes: 1 addition & 1 deletion src/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { UI_SIDEBAR_VIEWS, UI_THEMES, UI_VIEWS } from './constants';

export type ParamOptions = Omit<
OpenOptions & EmbedOptions,
'origin' | 'newWindow' | 'height' | 'width'
'origin' | 'newWindow' | 'height' | 'width' | 'requestTimeout'
>;

/**
Expand Down
44 changes: 35 additions & 9 deletions src/rdc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,29 @@ interface PendingResolvers {
export class RDC {
private port: MessagePort;
private pending: PendingResolvers = {};
private requestTimeout: number;

constructor(port: MessagePort) {
constructor(port: MessagePort, requestTimeout: number) {
this.port = port;
this.requestTimeout = requestTimeout;
this.port.onmessage = this.messageListener.bind(this);
}

public request<TResult = null>({ type, payload }: RequestData): Promise<TResult | null> {
return new Promise((resolve, reject) => {
const id = genID();
this.pending[id] = { resolve, reject };
const timeout = setTimeout(() => {
this.rejectPending(id, type, 'request timed out')
}, this.requestTimeout);
this.pending[id] = {
resolve: (result: TResult | null) => {
resolve(result);
clearTimeout(timeout)
},
reject: (e: any) => {
reject(e);
clearTimeout(timeout)
} };
this.port.postMessage({
type,
payload: {
Expand All @@ -51,6 +64,22 @@ export class RDC {
});
}

private resolvePending(payload: MessagePayloadWithMetadata): void{
const id = payload.__reqid;

if (this.pending[id]) {
this.pending[id].resolve(this.cleanResult(payload));
delete this.pending[id];
}
}

private rejectPending(id: string, type: string, error: string | undefined): void{
if (this.pending[id]) {
this.pending[id].reject(error ? `${type}: ${error}` : type);
delete this.pending[id];
}
}

private messageListener(event: MessageEvent<MessageData>) {
if (typeof event.data.payload?.__reqid !== 'string') {
return;
Expand All @@ -59,13 +88,10 @@ export class RDC {
const { type, payload } = event.data;
const { __reqid: id, __success: success, __error: error } = payload;

if (this.pending[id]) {
if (success) {
this.pending[id].resolve(this.cleanResult(payload));
} else {
this.pending[id].reject(error ? `${type}: ${error}` : type);
}
delete this.pending[id];
if(success){
this.resolvePending(payload);
}else{
this.rejectPending(id, type, error);
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ export interface FsDiff {
export class VM {
private _rdc: RDC;

constructor(port: MessagePort, config: { previewOrigin?: string }) {
this._rdc = new RDC(port);
constructor(port: MessagePort, config: { previewOrigin?: string, requestTimeout?: number }) {
const requestTimeout = typeof config.requestTimeout === 'number' ? config.requestTimeout : 5000;
this._rdc = new RDC(port, requestTimeout);

Object.defineProperty(this.preview, 'origin', {
value: typeof config.previewOrigin === 'string' ? config.previewOrigin : null,
Expand Down
34 changes: 34 additions & 0 deletions test/e2e/applyFsDiffFailure.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';
import type { Project } from '@stackblitz/sdk';

test('vm.getFsSnapshot and failing vm.applyFsDiff', async ({ page }) => {
await page.goto('/test/pages/blank.html');

const project: Project = {
title: 'Test Project',
template: 'html',
files: {
'index.html': `<h1>Hello World</h1>`,
'styles.css': `body { color: lime }`,
},
};

// Embed a project, retrieve a snapshot of its files, and execute applyFsDiff, which fails within the embed
const err = await page.evaluate(
async ([project]) => {
const vm = await window.StackBlitzSDK.embedProject('embed', project);
await vm.getFsSnapshot();
await window.testSDK.makeRequestFail('SDK_APPLY_FS_DIFF');
try{
await vm.applyFsDiff({create: {'index.html': '<h2>Hello world</h2>'}, destroy: []})
return {error: false};
}catch(e){
return {error: true}
}
},
[project]
);

// Expect the Promise from applyFsDiff to have been rejected
expect(err).toEqual({error: true})
})
22 changes: 21 additions & 1 deletion test/embed/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getRequestHandler } from '$test/server/request';
import { getFailingRequestHandler } from '$test/server/failing-request-handler';
import { getTestProject } from '$test/unit/utils/project';

import './styles.css';
Expand All @@ -8,10 +9,12 @@ function getProjectData() {
return JSON.parse(data) || getTestProject();
}

const failingRequestHandler = getFailingRequestHandler();

window.addEventListener('message', (event: MessageEvent) => {
if (event.data.action === 'SDK_INIT' && typeof event.data.id === 'string') {
const project = getProjectData();
const handleRequest = getRequestHandler(project);
const handleRequest = failingRequestHandler.createHandler(getRequestHandler(project));
const sdkChannel = new MessageChannel();

sdkChannel.port1.onmessage = function (event) {
Expand All @@ -32,5 +35,22 @@ window.addEventListener('message', (event: MessageEvent) => {
'*',
[sdkChannel.port2]
);
} else if(event.data.test_action === 'TEST_INIT'){
const testSdkChannel = new MessageChannel();

testSdkChannel.port1.onmessage = function ({ data }) {
if(data?.test_action === 'TEST_MAKE_REQUEST_FAIL'){
failingRequestHandler.makeRequestFail(data.type);
this.postMessage({test_action: 'TEST_MAKE_REQUEST_FAIL_SUCCESS'})
}
};

window.parent.postMessage(
{
test_action: 'TEST_INIT_SUCCESS'
},
'*',
[testSdkChannel.port2]
);
}
});
1 change: 1 addition & 0 deletions test/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
interface Window {
StackBlitzSDK: typeof import('@stackblitz/sdk').default;
testSDK: typeof import('./server/test-sdk').default
}

type StackBlitzSDK = typeof import('@stackblitz/sdk').default;
4 changes: 3 additions & 1 deletion test/pages/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import StackBlitzSDK from '@stackblitz/sdk';
import testSDK from '$test/server/test-sdk';

(window as any).StackBlitzSDK = StackBlitzSDK;
window.StackBlitzSDK = StackBlitzSDK;
window.testSDK = testSDK;
18 changes: 18 additions & 0 deletions test/server/failing-request-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { FailingRequestHandler, HandleRootRequest, AnyRequestData } from './types'

export function getFailingRequestHandler(): FailingRequestHandler{
const failingTypes: AnyRequestData['type'][] = [];

function makeRequestFail(type: AnyRequestData['type']): void{
failingTypes.push(type);
}
function createHandler(rootRequestHandler: HandleRootRequest): HandleRootRequest{
return (data: AnyRequestData) => {
if(failingTypes.some(t => data.type === t)){
throw new Error(`Request of type ${data.type} has failed`)
}
return rootRequestHandler(data);
}
}
return { makeRequestFail, createHandler };
}
31 changes: 31 additions & 0 deletions test/server/test-sdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { AnyRequestData } from './types';

async function makeRequestFail(type: AnyRequestData['type']): Promise<void>{
const iframeElement = document.getElementById('embed') as HTMLIFrameElement;
const port = await new Promise<MessagePort>((res) => {
const successListener = ({ data, ports }: MessageEvent) => {
if(data?.test_action === 'TEST_INIT_SUCCESS'){
window.removeEventListener('message', successListener);
res(ports[0]);
}
};
window.addEventListener('message', successListener);
iframeElement.contentWindow?.postMessage({test_action: 'TEST_INIT'}, '*');
})
await new Promise<void>((res) => {
const successListener = ({ data }: MessageEvent<any>) => {
if(data?.test_action === 'TEST_MAKE_REQUEST_FAIL_SUCCESS'){
port.close();
res();
}
}
port.onmessage = successListener;
port.postMessage({test_action: 'TEST_MAKE_REQUEST_FAIL', type})
})
}

const testSdk = {
makeRequestFail
};

export default testSdk;
5 changes: 5 additions & 0 deletions test/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,8 @@ export type HandlerContext = AppStateContext & ProjectContext & MessageContext;
export type HandleRequest = (data: RequestData, context: HandlerContext) => ResponseData;

export type HandleRootRequest = (data: AnyRequestData) => InitResponseData | ResponseData | null;

export interface FailingRequestHandler{
makeRequestFail(type: AnyRequestData['type']): void;
createHandler(rootRequestHandler: HandleRootRequest): HandleRootRequest
}
20 changes: 19 additions & 1 deletion test/unit/rdc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import { RDC } from '$src/rdc';
function getRdc({ error, delay }: { error?: string; delay?: number } = {}) {
const channel = new MessageChannel();

const rdc = new RDC(channel.port2, 1000);

if(delay === Infinity){
return rdc;
}

channel.port1.onmessage = function (event) {
const message = getResponseMessage({ ...event.data, error });
setTimeout(() => this.postMessage(message), delay);
};

return new RDC(channel.port2);
return rdc;
}

function getResponseMessage({
Expand Down Expand Up @@ -65,6 +71,18 @@ describe('RDC', () => {
).resolves.toBe(null);
});

test('never receives a value', async () => {
const rdc = getRdc({ delay: Infinity });
await expect(
rdc.request({
type: 'TEST',
payload: {},
})
).rejects.toBe(
'TEST: request timed out'
);
});

test('receives an error message', async () => {
const rdc = getRdc({ error: 'something went wrong' });
await expect(rdc.request({ type: 'TEST', payload: {} })).rejects.toBe(
Expand Down