Skip to content

[fix] webcontainer tweaks #114

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

Merged
merged 13 commits into from
Nov 30, 2022
Merged
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
2 changes: 2 additions & 0 deletions src/lib/client/adapters/filesystem/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export async function create(stubs) {
});

await new Promise((f) => setTimeout(f, 100)); // wait for chokidar

return true; // always reload page, not worth optimizing for local dev at this point
},

/** @param {import('$lib/types').FileStub[]} stubs */
Expand Down
226 changes: 112 additions & 114 deletions src/lib/client/adapters/webcontainer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,39 @@ import { ready } from '../common/index.js';

/** @type {import('@webcontainer/api').WebContainer} Web container singleton */
let vm;
/** Keep track of startup progress, so we don't repeat previous steps in case of a timeout */
let step = 0;
/** @type {string} path to the web container server instance */
let base;
/** @type {Promise<import('$lib/types').Adapter> | undefined} */
let instance;

/**
* Keeps track of the latest create/reset to ensure things are not processed in parallel.
* (if this turns out to be insufficient, we can use a queue)
* @type {Promise<any> | undefined}
* @param {import('$lib/types').Stub[]} stubs
* @returns {Promise<import('$lib/types').Adapter>}
*/
let running;
/** @type {Set<string>} Paths of the currently loaded file stubs */
let current = new Set();
export async function create(stubs) {
if (!instance) {
instance = _create(stubs);
} else {
const adapter = await instance;
await adapter.reset(stubs);
}
return instance;
}

/**
* @param {import('$lib/types').Stub[]} stubs
* @returns {Promise<import('$lib/types').Adapter>}
*/
export async function create(stubs) {
async function _create(stubs) {
/**
* Keeps track of the latest create/reset to ensure things are not processed in parallel.
* (if this turns out to be insufficient, we can use a queue)
* @type {Promise<any> | undefined}
*/
let running;
/** @type {Map<string, string>} Paths and contents of the currently loaded file stubs */
let current = stubs_to_map(stubs);
/** @type {boolean} Track whether there was an error from vite dev server */
let vite_error = false;

const tree = convert_stubs_to_tree(stubs);

const common = await ready;
Expand All @@ -36,21 +51,14 @@ export async function create(stubs) {
throw new Error('WebContainers are not supported by Safari');
}

await running; // wait for any previous create to finish

const init = new Promise(async (fulfil, reject) => {
if (base) {
// startup was successful in the meantime
fulfil(base);
}

const base = await new Promise(async (fulfil, reject) => {
/** @type {any} */
let timeout;
function reset_timeout() {
clearTimeout(timeout);
timeout = setTimeout(() => {
reject(new Error('Timed out starting WebContainer'));
}, 8000);
}, 15000);
}

reset_timeout();
Expand All @@ -59,11 +67,9 @@ export async function create(stubs) {
// if there was an error later on or a timeout and the user tries again
if (!vm) {
console.log('loading webcontainer');

const WebContainer = await load();

console.log('booting webcontainer');

vm = await WebContainer.boot();
}

Expand All @@ -72,73 +78,63 @@ export async function create(stubs) {
reject(new Error(error.message));
});

const ready_unsub = vm.on('server-ready', (port, _base) => {
base = _base;
const ready_unsub = vm.on('server-ready', (port, base) => {
ready_unsub();
console.log(`server ready on port ${port} at ${performance.now()}: ${_base}`);
fulfil(_base);
console.log(`server ready on port ${port} at ${performance.now()}: ${base}`);
fulfil(base); // this will be the last thing that happens if everything goes well
});

if (step < 1) {
reset_timeout();
step = 1;
console.log('loading files');

await vm.loadFiles(tree);

current = new Set(stub_filenames(stubs));
}

if (step < 2) {
reset_timeout();
step = 2;
console.log('unpacking modules');

const unzip = await vm.run(
{
command: 'node',
args: ['unzip.cjs']
},
{
stderr: (data) => console.error(`[unzip] ${data}`)
}
);

const code = await unzip.onExit;
reset_timeout();
console.log('loading files');
await vm.loadFiles(tree);

if (code !== 0) {
reject(new Error('Failed to initialize WebContainer'));
reset_timeout();
console.log('unpacking modules');
const unzip = await vm.run(
{
command: 'node',
args: ['unzip.cjs']
},
{
stderr: (data) => console.error(`[unzip] ${data}`)
}
);
const code = await unzip.onExit;
if (code !== 0) {
reject(new Error('Failed to initialize WebContainer'));
}

if (step < 3) {
reset_timeout();
step = 3;
console.log('starting dev server');

await vm.run({ command: 'chmod', args: ['a+x', 'node_modules/vite/bin/vite.js'] });
reset_timeout();
console.log('starting dev server');
await vm.run({ command: 'chmod', args: ['a+x', 'node_modules/vite/bin/vite.js'] });
await run_dev();

await vm.run(
async function run_dev() {
const process = await vm.run(
{ command: 'turbo', args: ['run', 'dev'] },
{
stdout: () => {
if (!base) {
reset_timeout();
}
},
stderr: (data) => console.error(`[dev] ${data}`)
stderr: (data) => {
vite_error = true;
console.error(`[dev] ${data}`);
}
}
);
// keep restarting dev server (can crash in case of illegal +files for example)
process.onExit.then((code) => {
if (code !== 0) {
setTimeout(() => {
run_dev();
}, 2000);
}
});
}
});

running = init.catch(() => {});
base = await init;

if (stub_filenames(stubs).some((name) => !current.has(name))) {
await reset(stubs);
}

/**
* Deletes old files and adds new ones
* @param {import('$lib/types').Stub[]} stubs
Expand All @@ -148,65 +144,61 @@ export async function create(stubs) {
/** @type {Function} */
let resolve = () => {};
running = new Promise((fulfil) => (resolve = fulfil));
vite_error = false;

const old = current;
current = new Set(stub_filenames(stubs));
const new_stubs = stubs.filter(
(stub) => stub.type !== 'file' || old.get(stub.name) !== stub.contents
);
current = stubs_to_map(stubs);

for (const stub of stubs) {
if (stub.type === 'file') {
old.delete(stub.name);
}
}

// For some reason, server-ready is fired again on resetting the files here.
// For some reason, server-ready is fired again when the vite dev server is restarted.
// We need to wait for it to finish before we can continue, else we might
// request files from Vite before it's ready, leading to a timeout.
const promise = new Promise((fulfil, reject) => {
const error_unsub = vm.on('error', (error) => {
error_unsub();
resolve();
reject(new Error(error.message));
});

const ready_unsub = vm.on('server-ready', (port, base) => {
ready_unsub();
console.log(`server ready on port ${port} at ${performance.now()}: ${base}`);
resolve();
fulfil(undefined);
});

setTimeout(() => {
resolve();
reject(new Error('Timed out resetting WebContainer'));
}, 10000);
});

for (const file of old) {
// TODO this fails with a cryptic error
// index.svelte:155 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'rmSync')
// at Object.rm (webcontainer.e2e246a845f9e80283581d6b944116e399af6950.js:6:121171)
// at MessagePort._0x4ec3f4 (webcontainer.e2e246a845f9e80283581d6b944116e399af6950.js:6:110957)
// at MessagePort.nrWrapper (headless:5:29785)
// await vm.fs.rm(file);

// temporary workaround
try {
await vm.run({
command: 'node',
args: ['-e', `fs.rmSync('${file.slice(1)}')`]
});
} catch (e) {
console.error(e);
}
const will_restart = new_stubs.some(
(stub) =>
stub.type === 'file' &&
(stub.name === '/vite.config.js' || stub.name === '/svelte.config.js')
);
const promise = will_restart
? new Promise((fulfil, reject) => {
const error_unsub = vm.on('error', (error) => {
error_unsub();
resolve();
reject(new Error(error.message));
});

const ready_unsub = vm.on('server-ready', (port, base) => {
ready_unsub();
console.log(`server ready on port ${port} at ${performance.now()}: ${base}`);
resolve();
fulfil(undefined);
});

setTimeout(() => {
resolve();
reject(new Error('Timed out resetting WebContainer'));
}, 10000);
})
: Promise.resolve();

for (const file of old.keys()) {
await vm.fs.rm(file, { force: true, recursive: true });
}

await vm.loadFiles(convert_stubs_to_tree(stubs));

await vm.loadFiles(convert_stubs_to_tree(new_stubs));
await promise;

await new Promise((f) => setTimeout(f, 200)); // wait for chokidar

resolve();

return will_restart || vite_error;
}

/**
Expand Down Expand Up @@ -307,9 +299,15 @@ function to_file(stub) {
}

/**
*
* @param {import('$lib/types').Stub[]} stubs
* @returns {Map<string, string>}
*/
function stub_filenames(stubs) {
return stubs.filter((stub) => stub.type === 'file').map((stub) => stub.name);
function stubs_to_map(stubs) {
const map = new Map();
for (const stub of stubs) {
if (stub.type === 'file') {
map.set(stub.name, stub.contents);
}
}
return map;
}
3 changes: 2 additions & 1 deletion src/lib/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export type Stub = FileStub | DirectoryStub;

export interface Adapter {
base: string;
reset(files: Array<Stub>): Promise<void>;
/** Returns `false` if the reset was in such a way that a reload of the iframe isn't needed */
reset(files: Array<Stub>): Promise<boolean>;
update(file: Array<FileStub>): Promise<void>;
destroy(): Promise<void>;
}
Expand Down
13 changes: 8 additions & 5 deletions src/routes/tutorial/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -141,19 +141,18 @@
* @param {import('$lib/types').Stub[]} stubs
*/
async function reset_adapter(stubs) {
let reload_iframe = true;
if (adapter) {
await adapter.reset(stubs);
return adapter;
reload_iframe = await adapter.reset(stubs);
} else {
const module = PUBLIC_USE_FILESYSTEM
? await import('$lib/client/adapters/filesystem/index.js')
: await import('$lib/client/adapters/webcontainer/index.js');

adapter = await module.create(stubs);
set_iframe_src(adapter.base);
}

set_iframe_src(adapter.base);

await new Promise((fulfil, reject) => {
let called = false;

Expand Down Expand Up @@ -181,6 +180,11 @@
}, 10000);
});

if (reload_iframe) {
await new Promise((fulfil) => setTimeout(fulfil, 200));
set_iframe_src(adapter.base);
}

return adapter;
}

Expand Down Expand Up @@ -222,7 +226,6 @@
async function load_files(stubs) {
adapter = await reset_adapter(stubs);
update_complete_states(stubs);
set_iframe_src(adapter.base);
}

/**
Expand Down
10 changes: 9 additions & 1 deletion src/routes/tutorial/[slug]/Loading.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,15 @@
<div class="loading" class:error>
{#if error}
{@html get_error_message(error)}
<button on:click={() => dispatch('reload')}>Reload</button>
<button
on:click={() => {
if (initial) {
location.reload();
} else {
dispatch('reload');
}
}}>Reload</button
>
{:else}
{#if initial}
<p>initializing... this may take a few seconds</p>
Expand Down