Skip to content

Commit 4355c6a

Browse files
committed
feat(router): use real middleware in dev
also, intercept the writes to the response and transform it as soon as the html head was processed
1 parent 32d9079 commit 4355c6a

File tree

4 files changed

+392
-124
lines changed

4 files changed

+392
-124
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { Render } from '@qwik.dev/core/server';
2+
import type { RendererOptions } from '@qwik.dev/router';
3+
import type { Connect, ModuleNode, ViteDevServer } from 'vite';
4+
import { build } from '../build';
5+
import type { BuildContext } from '../types';
6+
import { formatError } from './format-error';
7+
import { wrapResponseForHtmlTransform } from './html-transform-wrapper';
8+
9+
export const makeRouterDevMiddleware =
10+
(server: ViteDevServer, ctx: BuildContext): Connect.NextHandleFunction =>
11+
async (req, res, next) => {
12+
// This middleware is the fallback for Vite dev mode; it renders the application
13+
14+
// TODO more flexible entry points, like importing `render` from `src/server`
15+
// TODO pick a better name, entry.server-renderer perhaps?
16+
const mod = (await server.ssrLoadModule('src/entry.ssr')) as { default: Render };
17+
if (!mod.default) {
18+
console.error('No default export found in src/entry.ssr');
19+
return next();
20+
}
21+
const renderer = mod.default;
22+
if (ctx!.isDirty) {
23+
await build(ctx!);
24+
ctx!.isDirty = false;
25+
}
26+
27+
// entry.ts files
28+
const entry = ctx!.entries.find((e) => req.url === `${server.config.base}${e.chunkFileName}`);
29+
if (entry) {
30+
const entryContents = await server.transformRequest(
31+
`/@fs${entry.filePath.startsWith('/') ? '' : '/'}${entry.filePath}`
32+
);
33+
34+
if (entryContents) {
35+
// For entry files, we don't need HTML transformation, so use original response
36+
res.setHeader('Content-Type', 'text/javascript');
37+
res.end(entryContents.code);
38+
} else {
39+
next();
40+
}
41+
return;
42+
}
43+
// serve a placeholder service worker, because we can't provide CJS bundles in dev mode
44+
// once we support ESM service workers, we can remove this
45+
if (req.url === `${server.config.base}service-worker.js`) {
46+
// For service worker, we don't need HTML transformation, so use original response
47+
res.setHeader('Content-Type', 'text/javascript');
48+
res.end(
49+
`/* Qwik Router Dev Service Worker */` +
50+
`self.addEventListener('install', () => self.skipWaiting());` +
51+
`self.addEventListener('activate', (ev) => ev.waitUntil(self.clients.claim()));`
52+
);
53+
return;
54+
}
55+
56+
// Now we can stream the render
57+
const { createQwikRouter } = (await server.ssrLoadModule(
58+
'@qwik.dev/router/middleware/node'
59+
)) as typeof import('@qwik.dev/router/middleware/node');
60+
try {
61+
const render = (async (opts: RendererOptions) => {
62+
return await renderer(opts as any);
63+
}) as Render;
64+
const { router, staticFile, notFound } = createQwikRouter({ render });
65+
66+
// Wrap the response to enable HTML transformation
67+
const wrappedRes = wrapResponseForHtmlTransform(req, res, server);
68+
69+
staticFile(req, wrappedRes, () => {
70+
router(req, wrappedRes, () => {
71+
notFound(req, wrappedRes, next);
72+
});
73+
});
74+
} catch (e: any) {
75+
if (e instanceof Error) {
76+
server.ssrFixStacktrace(e);
77+
formatError(e);
78+
}
79+
next(e);
80+
return;
81+
}
82+
};
83+
84+
const CSS_EXTENSIONS = ['.css', '.scss', '.sass', '.less', '.styl', '.stylus'];
85+
const JS_EXTENSIONS = /\.[mc]?[tj]sx?$/;
86+
const isCssPath = (url: string) => CSS_EXTENSIONS.some((ext) => url.endsWith(ext));
87+
88+
/**
89+
* Qwik handles CSS imports itself, meaning vite doesn't get to see them, so we need to manually
90+
* inject the CSS URLs.
91+
*/
92+
const getCssUrls = (server: ViteDevServer) => {
93+
const cssModules = new Set<ModuleNode>();
94+
const cssImportedByCSS = new Set<string>();
95+
96+
Array.from(server.moduleGraph.fileToModulesMap.entries()).forEach(([_name, modules]) => {
97+
modules.forEach((mod) => {
98+
const [pathId, query] = mod.url.split('?');
99+
100+
if (!query && isCssPath(pathId)) {
101+
const isEntryCSS = mod.importers.size === 0;
102+
const hasCSSImporter = Array.from(mod.importers).some((importer) => {
103+
const importerPath = (importer as typeof mod).url || (importer as typeof mod).file;
104+
105+
const isCSS = importerPath && isCssPath(importerPath);
106+
107+
if (isCSS && mod.url) {
108+
cssImportedByCSS.add(mod.url);
109+
}
110+
111+
return isCSS;
112+
});
113+
114+
const hasJSImporter = Array.from(mod.importers).some((importer) => {
115+
const importerPath = (importer as typeof mod).url || (importer as typeof mod).file;
116+
return importerPath && JS_EXTENSIONS.test(importerPath);
117+
});
118+
119+
if ((isEntryCSS || hasJSImporter) && !hasCSSImporter && !cssImportedByCSS.has(mod.url)) {
120+
cssModules.add(mod);
121+
}
122+
}
123+
});
124+
});
125+
return [...cssModules].map(
126+
({ url, lastHMRTimestamp }) => `${url}${lastHMRTimestamp ? `?t=${lastHMRTimestamp}` : ''}`
127+
);
128+
};
129+
130+
export const getRouterIndexTags = (server: ViteDevServer) => {
131+
const cssUrls = getCssUrls(server);
132+
return cssUrls.map((url) => ({
133+
tag: 'link',
134+
attrs: { rel: 'stylesheet', href: url },
135+
}));
136+
};
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import type { ViteDevServer } from 'vite';
2+
import type { IncomingMessage, ServerResponse } from 'node:http';
3+
import { OutgoingHttpHeaders, OutgoingHttpHeader } from 'node:http';
4+
5+
enum State {
6+
/** Collecting content until <body> is found */
7+
BUFFERING,
8+
/** Transforming the head portion */
9+
PROCESSING_HEAD,
10+
/** Streaming transformed content until </body> */
11+
STREAMING_BODY,
12+
/** Passing through content unchanged */
13+
PASSTHROUGH,
14+
}
15+
16+
/**
17+
* Patches a response object to intercept HTML streams and transform them using Vite's
18+
* transformIndexHtml.
19+
*/
20+
class HtmlTransformPatcher {
21+
private state: State = State.BUFFERING;
22+
private buffer = '';
23+
private bodyStartIndex = -1;
24+
private bodyTagEndIndex = -1;
25+
private isHtmlResponse = false;
26+
27+
private appendToBody = '';
28+
private response: ServerResponse;
29+
private server: ViteDevServer;
30+
private request: IncomingMessage;
31+
private origWrite: ServerResponse['write'];
32+
private origEnd: ServerResponse['end'];
33+
private origSetHeader: ServerResponse['setHeader'];
34+
private origWriteHead: ServerResponse['writeHead'];
35+
36+
private processingPromise: Promise<void> | null = null;
37+
38+
constructor(req: IncomingMessage, res: ServerResponse, server: ViteDevServer) {
39+
this.request = req;
40+
this.response = res;
41+
this.server = server;
42+
this.origWrite = this.response.write.bind(this.response);
43+
this.origEnd = this.response.end.bind(this.response);
44+
this.origSetHeader = this.response.setHeader.bind(this.response);
45+
this.origWriteHead = this.response.writeHead.bind(this.response);
46+
47+
// Now overwrite methods to detect HTML content type and intercept writes
48+
49+
this.response.setHeader = (name: string, value: string | number | string[]) => {
50+
if (name.toLowerCase() === 'content-type') {
51+
const contentType = String(value).toLowerCase();
52+
this.isHtmlResponse = contentType.includes('text/html');
53+
}
54+
return this.origSetHeader(name, value);
55+
};
56+
57+
this.response.writeHead = (
58+
statusCode: number,
59+
statusMessage?: string | OutgoingHttpHeaders | OutgoingHttpHeader[],
60+
headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]
61+
) => {
62+
if (headers && typeof headers === 'object') {
63+
for (const [key, value] of Object.entries(headers)) {
64+
if (key.toLowerCase() === 'content-type') {
65+
const contentType = String(value).toLowerCase();
66+
this.isHtmlResponse = contentType.includes('text/html');
67+
}
68+
}
69+
}
70+
return this.origWriteHead(statusCode, statusMessage as any, headers as any);
71+
};
72+
73+
this.response.write = this.handleWrite.bind(this);
74+
75+
this.response.end = (chunk?: any, encoding?: any, callback?: any): ServerResponse => {
76+
this.handleEnd(chunk, encoding, callback).catch((error) => {
77+
console.error('Error in handleEnd:', error);
78+
// Fallback: end the original response
79+
this.transitionToPassthrough();
80+
this.origEnd(chunk, encoding, callback);
81+
});
82+
83+
return this.response;
84+
};
85+
}
86+
87+
private handleWrite(chunk: string | Buffer | ArrayBufferLike, encoding?: any, callback?: any) {
88+
if (!this.isHtmlResponse || this.state === State.PASSTHROUGH) {
89+
return this.origWrite(chunk, encoding, callback);
90+
}
91+
92+
if (typeof encoding === 'function') {
93+
callback = encoding;
94+
encoding = undefined;
95+
}
96+
97+
// Handle different chunk types properly
98+
let data: string;
99+
if (
100+
chunk instanceof ArrayBuffer ||
101+
chunk instanceof Uint8Array ||
102+
chunk instanceof Uint16Array ||
103+
chunk instanceof Uint32Array
104+
) {
105+
data = new TextDecoder().decode(chunk);
106+
} else if (Buffer.isBuffer(chunk)) {
107+
data = chunk.toString(encoding || 'utf8');
108+
} else if (typeof chunk === 'string') {
109+
data = chunk;
110+
} else {
111+
data = chunk?.toString() || '';
112+
}
113+
this.buffer += data;
114+
115+
switch (this.state) {
116+
case State.BUFFERING:
117+
const bodyMatch = this.buffer.match(/<body[^>]*>/i);
118+
if (bodyMatch) {
119+
this.state = State.PROCESSING_HEAD;
120+
this.bodyStartIndex = this.buffer.indexOf(bodyMatch[0]);
121+
this.bodyTagEndIndex = this.bodyStartIndex + bodyMatch[0].length;
122+
this.processingPromise = this.processHead();
123+
}
124+
break;
125+
126+
case State.PROCESSING_HEAD:
127+
break;
128+
129+
case State.STREAMING_BODY:
130+
this.handleStreamingBodyState();
131+
break;
132+
133+
default:
134+
throw new Error(`Invalid state: ${this.state}`);
135+
}
136+
callback?.();
137+
return true;
138+
}
139+
140+
private async processHead() {
141+
try {
142+
const headPortion = this.buffer.slice(0, this.bodyTagEndIndex);
143+
const fakeHtml = headPortion + '[FAKE_BODY]</body></html>';
144+
145+
// Let Vite transform the HTML
146+
const transformedHtml = await this.server.transformIndexHtml(
147+
this.request.url || '/',
148+
fakeHtml
149+
);
150+
151+
// Find the [FAKE_BODY] marker in the transformed result
152+
const fakeBodyIndex = transformedHtml.indexOf('[FAKE_BODY]');
153+
const bodyEndIndex = transformedHtml.indexOf('</body>', fakeBodyIndex);
154+
if (fakeBodyIndex === -1 || bodyEndIndex === -1) {
155+
throw new Error('Transformed HTML does not contain [FAKE_BODY]...</body>');
156+
}
157+
158+
// Extract the transformed head and body tags
159+
const transformedHead = transformedHtml.substring(0, fakeBodyIndex);
160+
this.appendToBody = transformedHtml.substring(
161+
fakeBodyIndex + '[FAKE_BODY]'.length,
162+
bodyEndIndex
163+
);
164+
this.buffer = transformedHead + this.buffer.slice(this.bodyTagEndIndex);
165+
166+
if (this.appendToBody.length > 0) {
167+
this.state = State.STREAMING_BODY;
168+
this.handleStreamingBodyState();
169+
return;
170+
}
171+
172+
this.transitionToPassthrough();
173+
return;
174+
} catch (error) {
175+
console.error('Error transforming HTML:', error);
176+
this.transitionToPassthrough();
177+
return;
178+
}
179+
}
180+
181+
private handleStreamingBodyState() {
182+
const bodyEndMatch = this.buffer.match(/<\/body>/i);
183+
184+
if (bodyEndMatch) {
185+
const bodyEndPos = this.buffer.indexOf(bodyEndMatch[0]);
186+
this.buffer =
187+
this.buffer.slice(0, bodyEndPos) + this.appendToBody + this.buffer.slice(bodyEndPos);
188+
189+
this.transitionToPassthrough();
190+
return;
191+
}
192+
193+
// keep the last 6 characters of the buffer to detect `</body>`
194+
this.flushBuffer(6);
195+
}
196+
197+
private transitionToPassthrough() {
198+
this.state = State.PASSTHROUGH;
199+
this.flushBuffer();
200+
}
201+
202+
private flushBuffer(keep: number = 0): void {
203+
if (this.buffer.length > keep) {
204+
if (keep > 0) {
205+
this.origWrite(this.buffer.slice(0, -keep));
206+
this.buffer = this.buffer.slice(-keep);
207+
} else {
208+
this.origWrite(this.buffer);
209+
this.buffer = '';
210+
}
211+
}
212+
}
213+
214+
private async handleEnd(chunk?: any, encoding?: any, callback?: any): Promise<void> {
215+
if (typeof encoding === 'function') {
216+
callback = encoding;
217+
encoding = undefined;
218+
}
219+
if (chunk) {
220+
this.handleWrite(chunk, encoding);
221+
}
222+
await this.processingPromise;
223+
// just in case
224+
this.flushBuffer();
225+
226+
this.origEnd(callback);
227+
}
228+
}
229+
230+
/** Patches a response to enable HTML transformation using Vite's transformIndexHtml */
231+
export function wrapResponseForHtmlTransform(
232+
request: IncomingMessage,
233+
response: ServerResponse,
234+
server: ViteDevServer
235+
): ServerResponse {
236+
new HtmlTransformPatcher(request, response, server);
237+
return response;
238+
}

0 commit comments

Comments
 (0)