Skip to content

Commit 50c5c35

Browse files
committed
feat(repl): Perform SSR in a dedicated worker
This allows any SSR code with any imports to run in the REPL without interfering with the main REPL thread, as well as not polluting the main thread with SSR code.
1 parent 592d453 commit 50c5c35

File tree

11 files changed

+379
-228
lines changed

11 files changed

+379
-228
lines changed

packages/docs/src/repl/README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,17 @@ The codebase keeps bundling and heavy work off the main thread by using a web wo
1818

1919
## Key directories & files
2020

21+
- `repl-sw.ts` and `register-repl-sw.ts`
22+
- Service worker script and registration helper. The script is actually served via the route `/src/routes/repl/repl-sw.js/entry.ts`, which becomes the served path `/repl/repl-sw.js`.
23+
2124
- `bundler/`
2225
- `bundled.tsx` — provides the current development version of Qwik, so that the REPL can show the latest features, and it can work while developing offline.
2326
- `bundler-registry.ts` — maintains the per-version Qwik WebWorkers.
2427
- `bundler-worker.ts` — bundles and runs the REPL user code.
25-
- `repl-instance.ts` — orchestration or single-instance logic for a REPL embed on a page.
26-
27-
- `repl-sw.ts` and `register-repl-sw.ts`
28-
- Service worker script and registration helper. The script is actually served via the route `/src/routes/repl/repl-sw.js/entry.ts`, which becomes the served path `/repl/repl-sw.js`.
28+
- `repl-ssr-worker.ts` — executes the SSR bundle in a separate worker served from `/repl/`, so that the imports can be intercepted by the service worker.
2929

3030
- `ui/`
31+
- `repl-instance.ts` — orchestration or single-instance logic for a REPL embed on a page.
3132
- `editor.tsx`, `monaco.tsx` — Monaco editor wiring and editor UI glue.
3233
- `repl-*.tsx` — UI components for console, input/output panels, tabs, share URL, version display, etc.
3334

@@ -44,19 +45,23 @@ The codebase keeps bundling and heavy work off the main thread by using a web wo
4445
- The iframe requests `/repl/[id]/`
4546
- The service worker intercepts and sends a message to the REPL instance
4647
- The REPL instance messages the bundler to bundle the user code
47-
- The bundler messages the REPL with the result
48-
- The REPL messages the service worker with the requested file
49-
- The service worker fullfils the request
48+
- The bundler messages the REPL instance with the result
49+
- The REPL instance then runs the SSR bundle in a separate worker, again with messages
50+
- The REPL instance messages the service worker with the requested file
51+
- The service worker fulfills the request
5052

5153
```mermaid
5254
flowchart TD
5355
Iframe["Iframe - /repl/[replId]/"]
5456
ServiceWorker["Service Worker (/repl/*)"]
5557
ReplInstance["REPL instance"]
5658
BundlerWorker["Bundler Worker"]
59+
SSRWorker["SSR Worker"]
5760
5861
ReplInstance -->|REPL user code| BundlerWorker
5962
BundlerWorker -->|ReplResult| ReplInstance
63+
ReplInstance -->|SSR bundled code| SSRWorker
64+
SSRWorker -->|HTML result| ReplInstance
6065
ServiceWorker -->|message: fetch pathname| ReplInstance
6166
Iframe -->|HTTP request| ServiceWorker
6267
ReplInstance -->|file contents| ServiceWorker
@@ -72,5 +77,3 @@ pnpm docs.dev
7277
```
7378

7479
Then visit `/playground`.
75-
76-
- Resource limits & sandboxing: Running arbitrary user code in the browser can be risky — ensure sandboxed iframes and timeouts/limits for executed code to avoid locking the page.

packages/docs/src/repl/bundler/bundler-worker.ts

Lines changed: 2 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
import { rollup, type OutputAsset, type OutputChunk } from '@rollup/browser';
2-
import * as prettierHtmlPlugin from 'prettier/plugins/html.js';
3-
import * as prettierTsxPlugin from 'prettier/plugins/typescript.js';
4-
// @ts-expect-error prettier/standalone has no types
5-
import prettier from 'prettier/standalone.mjs';
62
import type { PkgUrls, ReplInputOptions, ReplModuleOutput, ReplResult } from '../types';
73
import { definesPlugin, replCss, replMinify, replResolver } from './rollup-plugins';
84
import { QWIK_PKG_NAME } from '../repl-constants';
@@ -111,8 +107,6 @@ const getOutput = (o: OutputChunk | OutputAsset) => {
111107
return f;
112108
};
113109

114-
const prettierPlugins = [prettierHtmlPlugin, prettierTsxPlugin];
115-
116110
async function performBundle(message: BundleMessage): Promise<ReplResult> {
117111
const { buildId } = message;
118112
const { srcInputs, buildMode, entryStrategy, replId, debug } = message.data;
@@ -232,87 +226,8 @@ async function performBundle(message: BundleMessage): Promise<ReplResult> {
232226

233227
result.ssrModules = ssrBundle.output.map(getOutput);
234228

235-
start = performance.now();
236-
// Execute SSR to generate HTML
237-
result.html = await executeSSR(result, `${baseUrl}build/`, result.manifest);
238-
result.events.push({
239-
start,
240-
end: performance.now(),
241-
kind: 'console-log',
242-
scope: 'build',
243-
message: [`SSR: ${(performance.now() - start).toFixed(2)}ms`],
244-
});
245-
246-
// Format HTML - move this to the UI
247-
if (buildMode !== 'production') {
248-
try {
249-
result.html = await prettier.format(result.html, {
250-
parser: 'html',
251-
plugins: prettierPlugins,
252-
});
253-
} catch (e) {
254-
console.warn('HTML formatting failed:', e);
255-
}
256-
}
229+
// SSR execution moved to separate SSR worker
230+
result.html = '';
257231

258232
return result;
259233
}
260-
261-
async function executeSSR(result: ReplResult, base: string, manifest: any) {
262-
// Create a blob URL for the SSR module
263-
const ssrModule = result.ssrModules.find((m) => m.path.endsWith('.js'));
264-
if (!ssrModule || typeof ssrModule.code !== 'string') {
265-
return;
266-
}
267-
const blob = new Blob([ssrModule.code], { type: 'application/javascript' });
268-
const url = URL.createObjectURL(blob);
269-
270-
try {
271-
const module = await import(/*@vite-ignore*/ url);
272-
const server = module.default;
273-
274-
const render = typeof server === 'function' ? server : server?.render;
275-
if (typeof render !== 'function') {
276-
throw new Error('Server module does not export default render function');
277-
}
278-
279-
const orig: Record<string, any> = {};
280-
281-
const wrapConsole = (kind: 'log' | 'warn' | 'error' | 'debug') => {
282-
orig[kind] = console[kind];
283-
console[kind] = (...args: any[]) => {
284-
result.events.push({
285-
kind: `console-${kind}` as any,
286-
scope: 'ssr',
287-
message: args.map((a) => String(a)),
288-
start: performance.now(),
289-
});
290-
orig[kind](...args);
291-
};
292-
};
293-
wrapConsole('log');
294-
wrapConsole('warn');
295-
wrapConsole('error');
296-
wrapConsole('debug');
297-
298-
const ssrResult = await render({
299-
base,
300-
manifest,
301-
prefetchStrategy: null,
302-
}).catch((e: any) => {
303-
console.error('SSR failed', e);
304-
return {
305-
html: `<html><h1>SSR Error</h1><pre><code>${String(e).replaceAll('<', '&lt;')}</code></pre></html>`,
306-
};
307-
});
308-
309-
console.log = orig.log;
310-
console.warn = orig.warn;
311-
console.error = orig.error;
312-
console.debug = orig.debug;
313-
314-
return ssrResult.html;
315-
} finally {
316-
URL.revokeObjectURL(url);
317-
}
318-
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// SSR Worker - handles server-side rendering execution
2+
// MUST be served from /repl/ so that its imports are intercepted by the REPL service worker
3+
import type { QwikManifest } from '@builder.io/qwik/optimizer';
4+
5+
// Worker message types
6+
interface MessageBase {
7+
type: string;
8+
}
9+
10+
export interface InitSSRMessage extends MessageBase {
11+
type: 'run-ssr';
12+
replId: string;
13+
entry: string;
14+
baseUrl: string;
15+
manifest: QwikManifest | undefined;
16+
}
17+
18+
export interface SSRResultMessage extends MessageBase {
19+
type: 'ssr-result';
20+
html: string;
21+
events: any[];
22+
}
23+
24+
export interface SSRErrorMessage extends MessageBase {
25+
type: 'ssr-error';
26+
error: string;
27+
stack?: string;
28+
}
29+
30+
type IncomingMessage = InitSSRMessage;
31+
export type OutgoingMessage = SSRResultMessage | SSRErrorMessage;
32+
33+
let replId: string;
34+
35+
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
36+
const { type } = e.data;
37+
38+
switch (type) {
39+
case 'run-ssr':
40+
replId = e.data.replId;
41+
try {
42+
const result = await executeSSR(e.data);
43+
const message: SSRResultMessage = {
44+
type: 'ssr-result',
45+
html: result.html,
46+
events: result.events,
47+
};
48+
self.postMessage(message);
49+
} catch (error) {
50+
console.error(`SSR worker for %s failed`, replId, error);
51+
const message: SSRErrorMessage = {
52+
type: 'ssr-error',
53+
error: (error as Error)?.message || String(error),
54+
stack: (error as Error)?.stack,
55+
};
56+
self.postMessage(message);
57+
}
58+
break;
59+
60+
default:
61+
console.warn('Unknown SSR worker message type:', type);
62+
}
63+
};
64+
65+
async function executeSSR(message: InitSSRMessage): Promise<{ html: string; events: any[] }> {
66+
const { baseUrl, manifest, entry } = message;
67+
68+
const module = await import(/* @vite-ignore */ `/repl/${replId}-ssr/${entry}`);
69+
const server = module.default;
70+
71+
const render = typeof server === 'function' ? server : server?.render;
72+
if (typeof render !== 'function') {
73+
throw new Error(`Server module ${entry} does not export default render function`);
74+
}
75+
76+
const events: any[] = [];
77+
const orig: Record<string, any> = {};
78+
79+
const wrapConsole = (kind: 'log' | 'warn' | 'error' | 'debug') => {
80+
orig[kind] = console[kind];
81+
console[kind] = (...args: any[]) => {
82+
events.push({
83+
kind: `console-${kind}` as any,
84+
scope: 'ssr',
85+
message: args.map((a) => String(a)),
86+
start: performance.now(),
87+
});
88+
orig[kind](...args);
89+
};
90+
};
91+
wrapConsole('log');
92+
wrapConsole('warn');
93+
wrapConsole('error');
94+
wrapConsole('debug');
95+
96+
const ssrResult = await render({
97+
base: baseUrl,
98+
manifest,
99+
prefetchStrategy: null,
100+
}).catch((e: any) => {
101+
console.error('SSR failed', e);
102+
return {
103+
html: `<html><h1>SSR Error</h1><pre><code>${String(e).replaceAll('<', '&lt;')}</code></pre></html>`,
104+
};
105+
});
106+
107+
// Restore console methods
108+
console.log = orig.log;
109+
console.warn = orig.warn;
110+
console.error = orig.error;
111+
console.debug = orig.debug;
112+
113+
return {
114+
html: ssrResult.html,
115+
events,
116+
};
117+
}

packages/docs/src/repl/bundler/rollup-plugins.ts

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,16 @@ export const replResolver = (
3131
target: 'client' | 'ssr'
3232
): Plugin => {
3333
const srcInputs = options.srcInputs;
34-
const resolveId = (id: string) => {
34+
const getSrcPath = (id: string) => {
3535
return srcInputs.find((i) => i.path === id)?.path;
3636
};
3737

3838
return {
3939
name: 'repl-resolver',
4040

4141
resolveId(id, importer) {
42-
// Entry point
43-
if (!importer) {
42+
// Re-resolve
43+
if (id.startsWith('@qwik/')) {
4444
return id;
4545
}
4646
if (
@@ -53,18 +53,23 @@ export const replResolver = (
5353
if (id === '@builder.io/qwik/server') {
5454
return '@qwik/dist/server.mjs';
5555
}
56-
if (id === '@builder.io/qwik/preloader') {
56+
if (id.includes('@builder.io/qwik/preloader')) {
5757
return '@qwik/dist/preloader.mjs';
5858
}
59-
if (id === '@builder.io/qwik/qwikloader') {
59+
if (id.includes('@builder.io/qwik/qwikloader')) {
6060
return '@qwik/dist/qwikloader.js';
6161
}
6262
// Simple relative file resolution
63-
if (id.startsWith('./')) {
64-
const extensions = ['', '.tsx', '.ts'];
65-
id = id.slice(1);
63+
if (/^[./]/.test(id)) {
64+
const fileId =
65+
id.startsWith('.') && importer
66+
? (importer.replace(/\/[^/]+$/, '') + '/' + id)
67+
.replace(/\/\.\//g, '/')
68+
.replace(/\/[^/]+\/\.\.\//g, '/')
69+
: id;
70+
const extensions = ['', '.tsx', '.ts', '.jsx', '.js'];
6671
for (const ext of extensions) {
67-
const path = resolveId(id + ext);
72+
const path = getSrcPath(fileId + ext);
6873
if (path) {
6974
return path;
7075
}
@@ -88,16 +93,6 @@ export const replResolver = (
8893
}
8994
throw new Error(`Unable to load Qwik module: ${id}`);
9095
}
91-
if (id === '@builder.io/qwik/qwikloader.js') {
92-
// entry point, doesn't get resolved above somehow
93-
const url = deps[QWIK_PKG_NAME]['/dist/qwikloader.js'];
94-
if (url) {
95-
const rsp = await fetch(url);
96-
if (rsp.ok) {
97-
return rsp.text();
98-
}
99-
}
100-
}
10196
// We're the fallback, we know all the files
10297
if (/\.[jt]sx?$/.test(id)) {
10398
throw new Error(`load: unknown module ${id}`);

0 commit comments

Comments
 (0)