diff --git a/package.json b/package.json index 69fef0026..e4dfe486d 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "port-authority": "^2.0.1", "prism-svelte": "^0.5.0", "prismjs": "^1.29.0", - "ws": "^8.12.1" + "ws": "^8.12.1", + "yootils": "^0.3.1" }, "packageManager": "pnpm@7.27.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0210e2615..3f5a2b52d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,7 @@ specifiers: typescript: ~4.9.5 vite: ^4.2.0 ws: ^8.12.1 + yootils: ^0.3.1 dependencies: '@codemirror/autocomplete': 6.4.2_lc2v3dpzp2l5pdzwtgfaudkm3e @@ -72,6 +73,7 @@ dependencies: prism-svelte: 0.5.0 prismjs: 1.29.0 ws: 8.12.1 + yootils: 0.3.1 devDependencies: '@playwright/test': 1.31.2 @@ -2004,3 +2006,7 @@ packages: /yallist/4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true + + /yootils/0.3.1: + resolution: {integrity: sha512-A7AMeJfGefk317I/3tBoUYRcDcNavKEkpiPN/nQsBz/viI2GvT7BtrqdPD6rGqBFN8Ax7v4obf+Cl32JF9DDVw==} + dev: false diff --git a/src/lib/client/adapters/webcontainer/index.js b/src/lib/client/adapters/webcontainer/index.js index 096157768..e2081d186 100644 --- a/src/lib/client/adapters/webcontainer/index.js +++ b/src/lib/client/adapters/webcontainer/index.js @@ -1,6 +1,7 @@ import { WebContainer } from '@webcontainer/api'; import base64 from 'base64-js'; import AnsiToHtml from 'ansi-to-html'; +import * as yootils from 'yootils'; import { escape_html, get_depth } from '../../../utils.js'; import { ready } from '../common/index.js'; @@ -25,12 +26,7 @@ export async function create(base, error, progress, logs) { progress.set({ value: 0, text: 'loading files' }); - /** - * 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 | undefined} - */ - let running; + const q = yootils.queue(1); /** Paths and contents of the currently loaded file stubs */ let current_stubs = stubs_to_map([]); @@ -118,116 +114,111 @@ export async function create(base, error, progress, logs) { } return { - reset: async (stubs) => { - await running; - /** @type {Function} */ - let resolve = () => {}; - running = new Promise((fulfil) => (resolve = fulfil)); - - /** @type {import('$lib/types').Stub[]} */ - const to_write = []; - - for (const stub of stubs) { - if (stub.type === 'file') { - const current = /** @type {import('$lib/types').FileStub} */ ( - current_stubs.get(stub.name) - ); - - if (current?.contents !== stub.contents) { + reset: (stubs) => { + return q.add(async () => { + /** @type {import('$lib/types').Stub[]} */ + const to_write = []; + + for (const stub of stubs) { + if (stub.type === 'file') { + const current = /** @type {import('$lib/types').FileStub} */ ( + current_stubs.get(stub.name) + ); + + if (current?.contents !== stub.contents) { + to_write.push(stub); + } + } else { + // always add directories, otherwise convert_stubs_to_tree will fail to_write.push(stub); } - } else { - // always add directories, otherwise convert_stubs_to_tree will fail - to_write.push(stub); - } - - current_stubs.delete(stub.name); - } - // Don't delete the node_modules folder when switching from one exercise to another - // where, as this crashes the dev server. - ['/node_modules', '/node_modules/.bin'].forEach((name) => current_stubs.delete(name)); - - const to_delete = Array.from(current_stubs.keys()); - current_stubs = stubs_to_map(stubs); - - // 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 will_restart = launched && to_write.some(will_restart_vite_dev_server); - 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 to_delete) { - await vm.fs.rm(file, { force: true, recursive: true }); - } + current_stubs.delete(stub.name); + } - await vm.mount(convert_stubs_to_tree(to_write)); - await promise; - await new Promise((f) => setTimeout(f, 200)); // wait for chokidar + // Don't delete the node_modules folder when switching from one exercise to another + // where, as this crashes the dev server. + ['/node_modules', '/node_modules/.bin'].forEach((name) => current_stubs.delete(name)); + + const to_delete = Array.from(current_stubs.keys()); + current_stubs = stubs_to_map(stubs); + + // 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 will_restart = launched && to_write.some(will_restart_vite_dev_server); + const promise = will_restart + ? new Promise((fulfil, reject) => { + const error_unsub = vm.on('error', (error) => { + error_unsub(); + 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}`); + fulfil(undefined); + }); + + setTimeout(() => { + reject(new Error('Timed out resetting WebContainer')); + }, 10000); + }) + : Promise.resolve(); + + for (const file of to_delete) { + await vm.fs.rm(file, { force: true, recursive: true }); + } - resolve(); + await vm.mount(convert_stubs_to_tree(to_write)); + await promise; + await new Promise((f) => setTimeout(f, 200)); // wait for chokidar - // Also trigger a reload of the iframe in case new files were added / old ones deleted, - // because that can result in a broken UI state - const should_reload = !launched || will_restart || to_delete.length > 0; + // Also trigger a reload of the iframe in case new files were added / old ones deleted, + // because that can result in a broken UI state + const should_reload = !launched || will_restart || to_delete.length > 0; - await launch(); + await launch(); - return should_reload; + return should_reload; + }); }, - update: async (file) => { - await running; + update: (file) => { + return q.add(async () => { + /** @type {import('@webcontainer/api').FileSystemTree} */ + const root = {}; - /** @type {import('@webcontainer/api').FileSystemTree} */ - const root = {}; + let tree = root; - let tree = root; + const path = file.name.split('/').slice(1); + const basename = /** @type {string} */ (path.pop()); - const path = file.name.split('/').slice(1); - const basename = /** @type {string} */ (path.pop()); + for (const part of path) { + if (!tree[part]) { + /** @type {import('@webcontainer/api').FileSystemTree} */ + const directory = {}; - for (const part of path) { - if (!tree[part]) { - /** @type {import('@webcontainer/api').FileSystemTree} */ - const directory = {}; + tree[part] = { + directory + }; + } - tree[part] = { - directory - }; + tree = /** @type {import('@webcontainer/api').DirectoryNode} */ (tree[part]).directory; } - tree = /** @type {import('@webcontainer/api').DirectoryNode} */ (tree[part]).directory; - } - - tree[basename] = to_file(file); + tree[basename] = to_file(file); - await vm.mount(root); + await vm.mount(root); - current_stubs.set(file.name, file); + current_stubs.set(file.name, file); - await new Promise((f) => setTimeout(f, 200)); // wait for chokidar + // we need to stagger sequential updates, just enough that the HMR + // wires don't get crossed. 50ms seems to be enough of a delay + // to avoid glitches without noticeably affecting update speed + await new Promise((f) => setTimeout(f, 50)); - return will_restart_vite_dev_server(file); + return will_restart_vite_dev_server(file); + }); } }; }