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

feat: sandpack service worker #8481

Merged
merged 15 commits into from
Jul 9, 2024
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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could potentially be a breaking change, but not sure if it would have a real impact?

'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
Loading