From b9ffe7ffa0d99471aecf33844e853e9051bc2522 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 10 Mar 2023 12:51:44 -0500 Subject: [PATCH 01/28] tidy up --- src/routes/tutorial/[slug]/Output.svelte | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/routes/tutorial/[slug]/Output.svelte b/src/routes/tutorial/[slug]/Output.svelte index 7e9152b42..0a3d9bc68 100644 --- a/src/routes/tutorial/[slug]/Output.svelte +++ b/src/routes/tutorial/[slug]/Output.svelte @@ -76,12 +76,11 @@ reload_iframe = result || state.status === 'switch'; } } else { - const _adapter = create_adapter(state.stubs, (p, s) => { + adapter = create_adapter(state.stubs, (p, s) => { progress = p; status = s; }); - adapter = _adapter; - await _adapter.init; + await adapter.init; set_iframe_src(adapter.base + path); } From ffde120ae3f3c17cc36ac453d6f3ab4e63cc0451 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 10 Mar 2023 14:53:30 -0500 Subject: [PATCH 02/28] restart process immediately when it dies --- src/lib/client/adapters/webcontainer/index.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/lib/client/adapters/webcontainer/index.js b/src/lib/client/adapters/webcontainer/index.js index 0ef49a1d4..927f95aa3 100644 --- a/src/lib/client/adapters/webcontainer/index.js +++ b/src/lib/client/adapters/webcontainer/index.js @@ -89,13 +89,7 @@ export async function create(stubs, callback) { process.output.pipeTo(console_stream('dev')); // keep restarting dev server (can crash in case of illegal +files for example) - process.exit.then((code) => { - if (code !== 0) { - setTimeout(() => { - run_dev(); - }, 2000); - } - }); + process.exit.then(run_dev); } }); From cfe1d2f4ac87b863cdfb34ab326ea5549c786fc5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 10 Mar 2023 15:12:40 -0500 Subject: [PATCH 03/28] create adapter in onMount --- .../tutorial/common/src/routes/+page.svelte | 0 src/lib/client/adapters/webcontainer/index.js | 8 +++---- src/routes/tutorial/[slug]/Output.svelte | 24 ++++++++----------- src/routes/tutorial/[slug]/adapter.js | 5 ++-- 4 files changed, 15 insertions(+), 22 deletions(-) create mode 100644 content/tutorial/common/src/routes/+page.svelte diff --git a/content/tutorial/common/src/routes/+page.svelte b/content/tutorial/common/src/routes/+page.svelte new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/client/adapters/webcontainer/index.js b/src/lib/client/adapters/webcontainer/index.js index 927f95aa3..4906994c8 100644 --- a/src/lib/client/adapters/webcontainer/index.js +++ b/src/lib/client/adapters/webcontainer/index.js @@ -16,11 +16,10 @@ function console_stream(label) { } /** - * @param {import('$lib/types').Stub[]} stubs * @param {(progress: number, status: string) => void} callback * @returns {Promise} */ -export async function create(stubs, callback) { +export async function create(callback) { if (/safari/i.test(navigator.userAgent) && !/chrome/i.test(navigator.userAgent)) { throw new Error('WebContainers are not supported by Safari'); } @@ -35,7 +34,7 @@ export async function create(stubs, callback) { let running; /** Paths and contents of the currently loaded file stubs */ - let current_stubs = stubs_to_map(stubs); + let current_stubs = stubs_to_map([]); /** @type {boolean} Track whether there was an error from vite dev server */ let vite_error = false; @@ -51,8 +50,7 @@ export async function create(stubs, callback) { }, 'unzip.cjs': { file: { contents: common.unzip } - }, - ...convert_stubs_to_tree(stubs) + } }); callback(3 / 5, 'unzipping files'); diff --git a/src/routes/tutorial/[slug]/Output.svelte b/src/routes/tutorial/[slug]/Output.svelte index 0a3d9bc68..6328eeb41 100644 --- a/src/routes/tutorial/[slug]/Output.svelte +++ b/src/routes/tutorial/[slug]/Output.svelte @@ -25,6 +25,11 @@ let adapter; onMount(() => { + adapter = create_adapter((p, s) => { + progress = p; + status = s; + }); + const unsub = state.subscribe(async (state) => { if (state.status === 'set' || state.status === 'switch') { loading = true; @@ -68,21 +73,12 @@ */ async function reset_adapter(state) { let reload_iframe = true; - if (adapter) { - const result = await adapter.reset(state.stubs); - if (result === 'cancelled') { - return; - } else { - reload_iframe = result || state.status === 'switch'; - } - } else { - adapter = create_adapter(state.stubs, (p, s) => { - progress = p; - status = s; - }); - await adapter.init; - set_iframe_src(adapter.base + path); + const result = await adapter.reset(state.stubs); + if (result === 'cancelled') { + return; + } else { + reload_iframe = result || state.status === 'switch'; } await new Promise((fulfil, reject) => { diff --git a/src/routes/tutorial/[slug]/adapter.js b/src/routes/tutorial/[slug]/adapter.js index d9124ffe3..88168a4ab 100644 --- a/src/routes/tutorial/[slug]/adapter.js +++ b/src/routes/tutorial/[slug]/adapter.js @@ -1,9 +1,8 @@ /** - * @param {import('$lib/types').Stub[]} initial_stubs * @param {(progress: number, status: string) => void} callback * @returns {import('$lib/types').Adapter} */ -export function create_adapter(initial_stubs, callback) { +export function create_adapter(callback) { /** * @typedef {{ type: 'reset'; stubs: import('$lib/types').Stub[]; } | { type: 'update'; stubs: import('$lib/types').FileStub[]; }} State */ @@ -16,7 +15,7 @@ export function create_adapter(initial_stubs, callback) { async function init() { const module = await import('$lib/client/adapters/webcontainer/index.js'); - adapter_promise = module.create(initial_stubs, callback); + adapter_promise = module.create(callback); adapter_base = (await adapter_promise).base; } From 0945dc5370ff8b04d4b3558dfcda46ba738ed123 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 10 Mar 2023 15:25:26 -0500 Subject: [PATCH 04/28] make adapter a singleton --- src/lib/types/index.d.ts | 1 - src/routes/tutorial/[slug]/Output.svelte | 18 ++---------------- src/routes/tutorial/[slug]/adapter.js | 15 ++++++++++++++- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/lib/types/index.d.ts b/src/lib/types/index.d.ts index 394f858be..5e4f74fae 100644 --- a/src/lib/types/index.d.ts +++ b/src/lib/types/index.d.ts @@ -27,7 +27,6 @@ export interface AdapterInternal { export interface Adapter extends AdapterInternal { reset(files: Array): Promise; update(file: Array): Promise; - init: Promise; } export interface Scope { diff --git a/src/routes/tutorial/[slug]/Output.svelte b/src/routes/tutorial/[slug]/Output.svelte index 6328eeb41..b088c8b06 100644 --- a/src/routes/tutorial/[slug]/Output.svelte +++ b/src/routes/tutorial/[slug]/Output.svelte @@ -4,7 +4,7 @@ import { browser, dev } from '$app/environment'; import Chrome from './Chrome.svelte'; import Loading from './Loading.svelte'; - import { create_adapter } from './adapter'; + import { adapter, progress } from './adapter'; import { state } from './state.js'; /** @type {string} */ @@ -18,18 +18,7 @@ /** @type {Error | null} */ let error = null; - let progress = 0; - let status = 'initialising'; - - /** @type {import('$lib/types').Adapter} Will be defined after first afterNavigate */ - let adapter; - onMount(() => { - adapter = create_adapter((p, s) => { - progress = p; - status = s; - }); - const unsub = state.subscribe(async (state) => { if (state.status === 'set' || state.status === 'switch') { loading = true; @@ -54,9 +43,6 @@ function destroy() { unsub(); - if (adapter) { - adapter.destroy(); - } } document.addEventListener('pagehide', destroy); @@ -184,7 +170,7 @@ {/if} {#if loading || error} - + {/if} diff --git a/src/routes/tutorial/[slug]/adapter.js b/src/routes/tutorial/[slug]/adapter.js index 88168a4ab..7b8c86060 100644 --- a/src/routes/tutorial/[slug]/adapter.js +++ b/src/routes/tutorial/[slug]/adapter.js @@ -1,3 +1,17 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export const progress = writable({ + value: 0, + text: 'initialising' +}); + +/** @type {import('$lib/types').Adapter} */ +// @ts-expect-error +export const adapter = browser + ? create_adapter((value, text) => { progress.set({ value, text }); }) + : undefined; + /** * @param {(progress: number, status: string) => void} callback * @returns {import('$lib/types').Adapter} @@ -46,7 +60,6 @@ export function create_adapter(callback) { current = init().then(() => true); return { - init: current.then(() => {}), get base() { return adapter_base; }, From 08de011e35b96e16fe496ff0db5c7f1a567ffb1e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 10 Mar 2023 16:01:41 -0500 Subject: [PATCH 05/28] WIP refactor --- src/routes/tutorial/[slug]/Output.svelte | 29 +++---- src/routes/tutorial/[slug]/adapter.js | 104 +++++++---------------- 2 files changed, 42 insertions(+), 91 deletions(-) diff --git a/src/routes/tutorial/[slug]/Output.svelte b/src/routes/tutorial/[slug]/Output.svelte index b088c8b06..a3060eea4 100644 --- a/src/routes/tutorial/[slug]/Output.svelte +++ b/src/routes/tutorial/[slug]/Output.svelte @@ -4,7 +4,7 @@ import { browser, dev } from '$app/environment'; import Chrome from './Chrome.svelte'; import Loading from './Loading.svelte'; - import { adapter, progress } from './adapter'; + import { base, reset, update, progress } from './adapter'; import { state } from './state.js'; /** @type {string} */ @@ -34,7 +34,7 @@ loading = false; } else if (state.status === 'update' && state.last_updated) { - const reload = await adapter.update([state.last_updated]); + const reload = await update([state.last_updated]); if (reload === true) { schedule_iframe_reload(); } @@ -60,7 +60,7 @@ async function reset_adapter(state) { let reload_iframe = true; - const result = await adapter.reset(state.stubs); + const result = await reset(state.stubs); if (result === 'cancelled') { return; } else { @@ -71,7 +71,7 @@ let called = false; window.addEventListener('message', function handler(e) { - if (e.origin !== adapter.base) return; + if (e.origin !== $base) return; if (e.data.type === 'ping') { window.removeEventListener('message', handler); called = true; @@ -83,7 +83,7 @@ if (!called) { // Updating the iframe too soon sometimes results in a blank screen, // so we try again after a short delay if we haven't heard back - set_iframe_src(adapter.base + path); + set_iframe_src($base + path); } }, 5000); @@ -96,10 +96,8 @@ if (reload_iframe) { await new Promise((fulfil) => setTimeout(fulfil, 200)); - set_iframe_src(adapter.base + path); + set_iframe_src($base + path); } - - return adapter; } /** @type {any} */ @@ -107,7 +105,7 @@ function schedule_iframe_reload() { clearTimeout(reload_timeout); reload_timeout = setTimeout(() => { - set_iframe_src(adapter.base + path); + set_iframe_src($base + path); }, 1000); } @@ -116,8 +114,7 @@ /** @param {MessageEvent} e */ async function handle_message(e) { - if (!adapter) return; - if (e.origin !== adapter.base) return; + if (e.origin !== $base) return; if (e.data.type === 'ping') { path = e.data.data.path ?? path; @@ -128,7 +125,7 @@ // we lost contact, refresh the page loading = true; - set_iframe_src(adapter.base + path); + set_iframe_src($base + path); loading = false; }, 1000); } else if (e.data.type === 'ping-pause') { @@ -153,13 +150,13 @@ {path} {loading} on:refresh={() => { - set_iframe_src(adapter.base + path); + set_iframe_src($base + path); }} on:change={(e) => { - if (adapter) { - const url = new URL(e.detail.value, adapter.base); + if ($base) { + const url = new URL(e.detail.value, $base); path = url.pathname + url.search + url.hash; - set_iframe_src(adapter.base + path); + set_iframe_src($base + path); } }} /> diff --git a/src/routes/tutorial/[slug]/adapter.js b/src/routes/tutorial/[slug]/adapter.js index 7b8c86060..9832f1ea7 100644 --- a/src/routes/tutorial/[slug]/adapter.js +++ b/src/routes/tutorial/[slug]/adapter.js @@ -6,84 +6,38 @@ export const progress = writable({ text: 'initialising' }); -/** @type {import('$lib/types').Adapter} */ -// @ts-expect-error -export const adapter = browser - ? create_adapter((value, text) => { progress.set({ value, text }); }) - : undefined; +/** @type {import('svelte/store').Writable} */ +export const base = writable(null); -/** - * @param {(progress: number, status: string) => void} callback - * @returns {import('$lib/types').Adapter} - */ -export function create_adapter(callback) { - /** - * @typedef {{ type: 'reset'; stubs: import('$lib/types').Stub[]; } | { type: 'update'; stubs: import('$lib/types').FileStub[]; }} State - */ - - /** @type {State | undefined} */ - let state; - /** @type {Promise} */ - let adapter_promise; - let adapter_base = ''; - - async function init() { - const module = await import('$lib/client/adapters/webcontainer/index.js'); - adapter_promise = module.create(callback); - adapter_base = (await adapter_promise).base; - } +let ready = new Promise(() => {}); - // Keep track of what's currently running, and what's next - /** @type {Promise} */ - let current; - let token = {}; - async function next() { - const current_token = (token = {}); - await current; - if (current_token !== token || !state) return 'cancelled'; +if (browser) { + ready = new Promise(async (fulfil, reject) => { + try { + const module = await import('$lib/client/adapters/webcontainer/index.js'); + const adapter = await module.create((value, text) => { progress.set({ value, text }); }); - const _state = state; - state = undefined; - current = (async () => { - if (_state.type === 'reset') { - const adapter = await adapter_promise; - return await adapter.reset(_state.stubs); - } else { - const adapter = await adapter_promise; - return await adapter.update(_state.stubs); - } - })(); + base.set(adapter.base); - return current; - } - - current = init().then(() => true); - - return { - get base() { - return adapter_base; - }, - update: async (stubs) => { - if (state) { - // add new stubs (which have up-to-date content) to existing stubs - const new_stubs = new Set(stubs.map((stub) => stub.name)); - state = { - ...state, - // @ts-expect-error TS doesn't understand that the union type will be well-formed - stubs: state.stubs.filter((stub) => !new_stubs.has(stub.name)).concat(stubs) - }; - } else { - state = { type: 'update', stubs }; - } - return next(); - }, - reset: async (stubs) => { - state = { type: 'reset', stubs }; - return next(); - }, - destroy: async () => { - const adapter = await adapter_promise; - return adapter.destroy(); + fulfil(adapter); + } catch (error) { + reject(error); } - }; + }) } + +/** + * @param {import('$lib/types').Stub[]} files + */ +export async function reset(files) { + const adapter = await ready; + return adapter.reset(files); +} + +/** + * @param {import('$lib/types').Stub[]} files + */ +export async function update(files) { + const adapter = await ready; + return adapter.update(files); +} \ No newline at end of file From 6eab3c51b1b8e6dbd1b2aaf567114c0a258e67c3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 10 Mar 2023 16:30:11 -0500 Subject: [PATCH 06/28] add an event mechanism for reloading iframe --- src/routes/tutorial/[slug]/adapter.js | 38 +++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/routes/tutorial/[slug]/adapter.js b/src/routes/tutorial/[slug]/adapter.js index 9832f1ea7..236c1dedb 100644 --- a/src/routes/tutorial/[slug]/adapter.js +++ b/src/routes/tutorial/[slug]/adapter.js @@ -18,6 +18,7 @@ if (browser) { const adapter = await module.create((value, text) => { progress.set({ value, text }); }); base.set(adapter.base); + publish('reload'); fulfil(adapter); } catch (error) { @@ -26,12 +27,41 @@ if (browser) { }) } +/** @typedef {'reload'} EventName */ + +/** @type {Map void>>} */ +let subscriptions = new Map([['reload', new Set()]]); + +/** + * + * @param {EventName} event + * @param {() => void} callback + */ +export function subscribe(event, callback) { + subscriptions.get(event)?.add(callback); + + return () =>{ + subscriptions.get(event)?.delete(callback); + }; +} + +/** + * @param {EventName} event + */ +function publish(event) { + subscriptions.get(event)?.forEach(fn => fn()); +} + /** * @param {import('$lib/types').Stub[]} files */ export async function reset(files) { const adapter = await ready; - return adapter.reset(files); + const should_reload = await adapter.reset(files); + + if (should_reload) { + publish('reload'); + } } /** @@ -39,5 +69,9 @@ export async function reset(files) { */ export async function update(files) { const adapter = await ready; - return adapter.update(files); + const should_reload = await adapter.update(files); + + if (should_reload) { + publish('reload'); + } } \ No newline at end of file From 6e1aca271bd6c972042a80641497a6d960cc88ae Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 10 Mar 2023 16:50:44 -0500 Subject: [PATCH 07/28] call adapter methods directly --- src/routes/tutorial/[slug]/Output.svelte | 92 ++---------------------- src/routes/tutorial/[slug]/adapter.js | 21 ++++-- src/routes/tutorial/[slug]/state.js | 15 +++- 3 files changed, 35 insertions(+), 93 deletions(-) diff --git a/src/routes/tutorial/[slug]/Output.svelte b/src/routes/tutorial/[slug]/Output.svelte index a3060eea4..052a4e31d 100644 --- a/src/routes/tutorial/[slug]/Output.svelte +++ b/src/routes/tutorial/[slug]/Output.svelte @@ -4,8 +4,7 @@ import { browser, dev } from '$app/environment'; import Chrome from './Chrome.svelte'; import Loading from './Loading.svelte'; - import { base, reset, update, progress } from './adapter'; - import { state } from './state.js'; + import { base, error, progress, subscribe } from './adapter'; /** @type {string} */ export let path; @@ -15,34 +14,13 @@ let loading = true; let initial = true; - /** @type {Error | null} */ - let error = null; - onMount(() => { - const unsub = state.subscribe(async (state) => { - if (state.status === 'set' || state.status === 'switch') { - loading = true; - - try { - clearTimeout(timeout); - await reset_adapter(state); - initial = false; - } catch (e) { - error = /** @type {Error} */ (e); - console.error(e); - } - - loading = false; - } else if (state.status === 'update' && state.last_updated) { - const reload = await update([state.last_updated]); - if (reload === true) { - schedule_iframe_reload(); - } - } + const unsubscribe = subscribe('reload', () => { + set_iframe_src($base + path); }); function destroy() { - unsub(); + unsubscribe(); } document.addEventListener('pagehide', destroy); @@ -53,62 +31,6 @@ clearTimeout(timeout); }); - /** - * Loads the adapter initially or resets it. This method can throw. - * @param {import('./state').State} state - */ - async function reset_adapter(state) { - let reload_iframe = true; - - const result = await reset(state.stubs); - if (result === 'cancelled') { - return; - } else { - reload_iframe = result || state.status === 'switch'; - } - - await new Promise((fulfil, reject) => { - let called = false; - - window.addEventListener('message', function handler(e) { - if (e.origin !== $base) return; - if (e.data.type === 'ping') { - window.removeEventListener('message', handler); - called = true; - fulfil(undefined); - } - }); - - setTimeout(() => { - if (!called) { - // Updating the iframe too soon sometimes results in a blank screen, - // so we try again after a short delay if we haven't heard back - set_iframe_src($base + path); - } - }, 5000); - - setTimeout(() => { - if (!called) { - reject(new Error('Timed out (re)setting adapter')); - } - }, 10000); - }); - - if (reload_iframe) { - await new Promise((fulfil) => setTimeout(fulfil, 200)); - set_iframe_src($base + path); - } - } - - /** @type {any} */ - let reload_timeout; - function schedule_iframe_reload() { - clearTimeout(reload_timeout); - reload_timeout = setTimeout(() => { - set_iframe_src($base + path); - }, 1000); - } - /** @type {any} */ let timeout; @@ -148,7 +70,7 @@ { set_iframe_src($base + path); }} @@ -166,8 +88,8 @@