Skip to content

Commit

Permalink
feat: sandpack service worker (#8481)
Browse files Browse the repository at this point in the history
  • Loading branch information
danilowoz committed Jul 9, 2024
1 parent fe16273 commit 131c89f
Show file tree
Hide file tree
Showing 11 changed files with 715 additions and 4 deletions.
12 changes: 12 additions & 0 deletions .codesandbox/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@
"typecheck": {
"name": "typecheck",
"command": "yarn typecheck"
},
"lint": {
"name": "lint",
"command": "yarn lint"
},
"start:sandpack-sandbox": {
"name": "start:sandpack-sandbox",
"command": "cd packages/app && yarn start:sandpack-sandbox"
},
"start:sandpack-core": {
"name": "start:sandpack-core",
"command": "cd packages/sandpack-core && yarn start"
}
}
}
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"normalizr": "^3.2.3",
"onigasm": "^2.2.1",
"ot": "^0.0.15",
"outvariant": "^1.4.2",
"overmind": "^27.0.0-1624124645626",
"overmind-graphql": "^8.0.0-1615750082257",
"overmind-react": "^28.0.0-1624124645626",
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/sandbox/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import handleExternalResources from './external-resources';
import setScreen, { resetScreen } from './status-screen';
import { showRunOnClick } from './status-screen/run-on-click';
import { SCRIPT_VERSION } from '.';
import { startServiceWorker } from './worker';

let manager: Manager | null = null;
let actionsEnabled = false;
Expand Down Expand Up @@ -535,6 +536,7 @@ interface CompileOptions {
clearConsoleDisabled?: boolean;
reactDevTools?: 'legacy' | 'latest';
teamId?: string;
experimental_enableServiceWorker?: boolean;
}

async function compile(opts: CompileOptions) {
Expand All @@ -556,8 +558,13 @@ async function compile(opts: CompileOptions) {
clearConsoleDisabled = false,
reactDevTools,
teamId,
experimental_enableServiceWorker = false,
} = opts;

if (experimental_enableServiceWorker) {
await startServiceWorker();
}

if (firstLoad) {
// Clear the console on first load, but don't clear the console on HMR updates
if (!clearConsoleDisabled) {
Expand Down
111 changes: 111 additions & 0 deletions packages/app/src/sandbox/worker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { invariant } from 'outvariant';

import { debug, getServiceWorker, preventStaleTermination } from './utils';
import {
CHANNEL_NAME,
IPreviewInitMessage,
IPreviewReadyMessage,
IPreviewResponseMessage,
IWorkerInitMessage,
} from './types';
import { DeferredPromise } from './promise';

// Create a message channel for communication with the Service Worker.
const workerChannel = new MessageChannel();

const workerReadyPromise = new DeferredPromise<ServiceWorker>();

workerReadyPromise.then(worker => {
debug('[relay] worker is ready, initializing MessageChannel...');

// Always post the initial MessageChannel message to the worker
// as soon as the worker is ready. This is done once.
const workerInitMessage: IWorkerInitMessage = {
$channel: CHANNEL_NAME,
$type: 'worker/init',
};
worker.postMessage(workerInitMessage, [workerChannel.port2]);

return worker;
});

const parentPortPromise = new DeferredPromise<MessagePort>();
window.addEventListener(
'message',
(event: MessageEvent<IPreviewInitMessage>) => {
if (event.data.$type === 'preview/init') {
const parentPort = event.ports[0];
parentPort.onmessage = async (evt: MessageEvent) => {
if (
typeof evt.data === 'object' &&
evt.data.$channel === CHANNEL_NAME &&
evt.data.$type === 'preview/response'
) {
const msg: IPreviewResponseMessage = evt.data;
workerChannel.port1.postMessage(msg);
}
};
parentPortPromise.resolve(parentPort);
}
}
);

workerChannel.port1.onmessage = async event => {
const data = event.data;

// console.debug("incoming message from the worker", event.data);

if (data.$channel === CHANNEL_NAME) {
// Pause the message handling until the parent has taken control of the preview.
const port = await parentPortPromise;

// Route all data to the parent.
const message = data;
port.postMessage(message);
}
};

export async function startServiceWorker() {
const worker = await getServiceWorker().catch(error => {
console.error(
'[relay] Failed to ensure the relay has a Service Worker registered. See details below.'
);
console.error(error);
});

await navigator.serviceWorker.ready;

invariant(
worker,
'[relay] Failed to retrieve the worker instance: worker not found'
);
preventStaleTermination(worker);

if (process.env.NODE_ENV === 'development') {
window.addEventListener('beforeunload', async () => {
const registrations = await navigator.serviceWorker.getRegistrations();

for (const registration of registrations) {
// eslint-disable-next-line no-await-in-loop
await registration.unregister();
}

debug('[relay] Unregister all SW');
});
}

workerReadyPromise.resolve(worker);

debug('[relay] Worker ready');

// Wait until the parent sends the init event
// via the MessageChannel, acknowledging that it recognized the relay.
const parentPort = await parentPortPromise;
debug('[relay] Parent port received', parentPort);

const readyMessage: IPreviewReadyMessage = {
$channel: CHANNEL_NAME,
$type: 'preview/ready',
};
parentPort.postMessage(readyMessage);
}
118 changes: 118 additions & 0 deletions packages/app/src/sandbox/worker/promise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Copied from https://github.com/open-draft/deferred-promise
* because the current Babel configuration doesn't support static block in classes
*/

type PromiseState = 'pending' | 'fulfilled' | 'rejected'

type Executor<Value> = ConstructorParameters<typeof Promise<Value>>[0]
type ResolveFunction<Value> = Parameters<Executor<Value>>[0]
type RejectFunction<Reason> = Parameters<Executor<Reason>>[1]

type DeferredPromiseExecutor<Input = never, Output = Input> = {
(resolve?: ResolveFunction<Input>, reject?: RejectFunction<any>): void

resolve: ResolveFunction<Input>
reject: RejectFunction<any>
result?: Output
state: PromiseState
rejectionReason?: unknown
}
function createDeferredExecutor<
Input = never,
Output = Input
>(): DeferredPromiseExecutor<Input, Output> {
const executor = <DeferredPromiseExecutor<Input, Output>>((
resolve,
reject
) => {
executor.state = 'pending'

executor.resolve = (data) => {
if (executor.state !== 'pending') {
return
}

executor.result = data as Output

const onFulfilled = <Value>(value: Value) => {
executor.state = 'fulfilled'
return value
}

// eslint-disable-next-line
return resolve(
data instanceof Promise ? data : Promise.resolve(data).then(onFulfilled)
)
}

executor.reject = (reason) => {
if (executor.state !== 'pending') {
return
}

queueMicrotask(() => {
executor.state = 'rejected'
})

// eslint-disable-next-line
return reject((executor.rejectionReason = reason))
}
})

return executor
}

export class DeferredPromise<Input, Output = Input> extends Promise<Input> {
executor: DeferredPromiseExecutor;

public resolve: ResolveFunction<Output>;
public reject: RejectFunction<Output>;

constructor(executor: Executor<Input> | null = null) {
const deferredExecutor = createDeferredExecutor();
super((originalResolve, originalReject) => {
deferredExecutor(originalResolve, originalReject);
// eslint-disable-next-line
executor?.(deferredExecutor.resolve, deferredExecutor.reject);
});

this.executor = deferredExecutor;
this.resolve = this.executor.resolve;
this.reject = this.executor.reject;
}

public get state() {
return this.executor.state;
}

public get rejectionReason() {
return this.executor.rejectionReason;
}

public then<ThenResult = Input, CatchResult = never>(
onFulfilled?: (value: Input) => ThenResult | PromiseLike<ThenResult>,
onRejected?: (reason: any) => CatchResult | PromiseLike<CatchResult>
) {
return this.decorate(super.then(onFulfilled, onRejected));
}

public catch<CatchResult = never>(
onRejected?: (reason: any) => CatchResult | PromiseLike<CatchResult>
) {
return this.decorate(super.catch(onRejected));
}

public finally(onfinally?: () => void | Promise<any>) {
return this.decorate(super.finally(onfinally));
}

decorate<ChildInput>(
promise: Promise<ChildInput>
): DeferredPromise<ChildInput, Output> {
return Object.defineProperties(promise, {
resolve: { configurable: true, value: this.resolve },
reject: { configurable: true, value: this.reject },
}) as DeferredPromise<ChildInput, Output>;
}
}
Loading

0 comments on commit 131c89f

Please sign in to comment.