|
| 1 | +import type { Render } from '@qwik.dev/core/server'; |
| 2 | +import type { DocumentHeadValue, RendererOptions, RendererOutputOptions } from '@qwik.dev/router'; |
| 3 | +import type { Connect, ModuleNode, Plugin, ViteDevServer } from 'vite'; |
| 4 | +import type { BuildContext } from '../types'; |
| 5 | +import { formatError } from './format-error'; |
| 6 | +import { build } from '../build'; |
| 7 | + |
| 8 | +export const makeRouterDevMiddleware = |
| 9 | + (server: ViteDevServer, ctx: BuildContext): Connect.NextHandleFunction => |
| 10 | + async (req, res, next) => { |
| 11 | + // TODO more flexible entry points, like importing `render` from `src/server` |
| 12 | + const mod = (await server.ssrLoadModule('src/entry.ssr')) as { default: Render }; |
| 13 | + if (!mod.default) { |
| 14 | + console.error('No default export found in src/entry.ssr'); |
| 15 | + return next(); |
| 16 | + } |
| 17 | + const renderer = mod.default; |
| 18 | + if (ctx!.isDirty) { |
| 19 | + await build(ctx!); |
| 20 | + ctx!.isDirty = false; |
| 21 | + } |
| 22 | + |
| 23 | + // entry.ts files |
| 24 | + const entry = ctx!.entries.find((e) => req.url === `${server.config.base}${e.chunkFileName}`); |
| 25 | + if (entry) { |
| 26 | + const entryContents = await server.transformRequest( |
| 27 | + `/@fs${entry.filePath.startsWith('/') ? '' : '/'}${entry.filePath}` |
| 28 | + ); |
| 29 | + |
| 30 | + if (entryContents) { |
| 31 | + res.setHeader('Content-Type', 'text/javascript'); |
| 32 | + res.end(entryContents.code); |
| 33 | + } else { |
| 34 | + next(); |
| 35 | + } |
| 36 | + return; |
| 37 | + } |
| 38 | + // in dev mode, serve a placeholder service worker |
| 39 | + if (req.url === `${server.config.base}service-worker.js`) { |
| 40 | + res.setHeader('Content-Type', 'text/javascript'); |
| 41 | + res.end( |
| 42 | + `/* Qwik Router Dev Service Worker */` + |
| 43 | + `self.addEventListener('install', () => self.skipWaiting());` + |
| 44 | + `self.addEventListener('activate', (ev) => ev.waitUntil(self.clients.claim()));` |
| 45 | + ); |
| 46 | + return; |
| 47 | + } |
| 48 | + |
| 49 | + const documentHead = { |
| 50 | + /** |
| 51 | + * Vite normally injects imported CSS files into the HTML, but we render our own HTML so we |
| 52 | + * need to add them manually. |
| 53 | + * |
| 54 | + * Note: It's possible that new CSS files are created during render, we can't find those here. |
| 55 | + * For now, we ignore this possibility, it would mean needing a callback at the end of the |
| 56 | + * render before the `<body>` is closed. |
| 57 | + */ |
| 58 | + links: getCssUrls(server).map((url) => { |
| 59 | + return { rel: 'stylesheet', href: url }; |
| 60 | + }), |
| 61 | + scripts: [ |
| 62 | + // Vite normally injects this |
| 63 | + { type: 'module', src: '/@vite/client' }, |
| 64 | + ], |
| 65 | + } satisfies DocumentHeadValue; |
| 66 | + |
| 67 | + // Grab tags from other plugins |
| 68 | + await getExtraHeadContent(server, documentHead); |
| 69 | + |
| 70 | + // Now we can stream the render |
| 71 | + const { createQwikRouter } = (await server.ssrLoadModule( |
| 72 | + '@qwik.dev/router/middleware/node' |
| 73 | + )) as typeof import('@qwik.dev/router/middleware/node'); |
| 74 | + try { |
| 75 | + const render = (async (opts: RendererOptions) => { |
| 76 | + return await renderer({ |
| 77 | + ...opts, |
| 78 | + serverData: { ...opts.serverData, documentHead }, |
| 79 | + } as RendererOutputOptions as any); |
| 80 | + }) as Render; |
| 81 | + const { router, staticFile, notFound } = createQwikRouter({ render }); |
| 82 | + staticFile(req, res, () => { |
| 83 | + router(req, res, () => { |
| 84 | + notFound(req, res, next); |
| 85 | + }); |
| 86 | + }); |
| 87 | + } catch (e: any) { |
| 88 | + if (e instanceof Error) { |
| 89 | + server.ssrFixStacktrace(e); |
| 90 | + formatError(e); |
| 91 | + } |
| 92 | + next(e); |
| 93 | + return; |
| 94 | + } |
| 95 | + }; |
| 96 | + |
| 97 | +const CSS_EXTENSIONS = ['.css', '.scss', '.sass', '.less', '.styl', '.stylus']; |
| 98 | +const JS_EXTENSIONS = /\.[mc]?[tj]sx?$/; |
| 99 | +const isCssPath = (url: string) => CSS_EXTENSIONS.some((ext) => url.endsWith(ext)); |
| 100 | + |
| 101 | +function getCssUrls(server: ViteDevServer) { |
| 102 | + const cssModules = new Set<ModuleNode>(); |
| 103 | + const cssImportedByCSS = new Set<string>(); |
| 104 | + |
| 105 | + Array.from(server.moduleGraph.fileToModulesMap.entries()).forEach(([_name, modules]) => { |
| 106 | + modules.forEach((mod) => { |
| 107 | + const [pathId, query] = mod.url.split('?'); |
| 108 | + |
| 109 | + if (!query && isCssPath(pathId)) { |
| 110 | + const isEntryCSS = mod.importers.size === 0; |
| 111 | + const hasCSSImporter = Array.from(mod.importers).some((importer) => { |
| 112 | + const importerPath = (importer as typeof mod).url || (importer as typeof mod).file; |
| 113 | + |
| 114 | + const isCSS = importerPath && isCssPath(importerPath); |
| 115 | + |
| 116 | + if (isCSS && mod.url) { |
| 117 | + cssImportedByCSS.add(mod.url); |
| 118 | + } |
| 119 | + |
| 120 | + return isCSS; |
| 121 | + }); |
| 122 | + |
| 123 | + const hasJSImporter = Array.from(mod.importers).some((importer) => { |
| 124 | + const importerPath = (importer as typeof mod).url || (importer as typeof mod).file; |
| 125 | + return importerPath && JS_EXTENSIONS.test(importerPath); |
| 126 | + }); |
| 127 | + |
| 128 | + if ((isEntryCSS || hasJSImporter) && !hasCSSImporter && !cssImportedByCSS.has(mod.url)) { |
| 129 | + cssModules.add(mod); |
| 130 | + } |
| 131 | + } |
| 132 | + }); |
| 133 | + }); |
| 134 | + return [...cssModules].map( |
| 135 | + ({ url, lastHMRTimestamp }) => `${url}${lastHMRTimestamp ? `?t=${lastHMRTimestamp}` : ''}` |
| 136 | + ); |
| 137 | +} |
| 138 | + |
| 139 | +async function getExtraHeadContent(server: ViteDevServer, documentHead: Record<string, unknown[]>) { |
| 140 | + const fakeHTML = '<!DOCTYPE html><html><head>HEAD</head><body>BODY</body></html>'; |
| 141 | + for (const { name: pluginName, transformIndexHtml } of server.config.plugins) { |
| 142 | + const handler = |
| 143 | + transformIndexHtml && 'handler' in transformIndexHtml |
| 144 | + ? transformIndexHtml.handler |
| 145 | + : transformIndexHtml; |
| 146 | + if (typeof handler === 'function') { |
| 147 | + const result = await (handler.call({} as any, fakeHTML, { |
| 148 | + server, |
| 149 | + path: '/', |
| 150 | + filename: 'index.html', |
| 151 | + command: 'serve', |
| 152 | + }) as ReturnType<Extract<Plugin['transformIndexHtml'], Function>>); |
| 153 | + if (result) { |
| 154 | + if (typeof result === 'string' || ('html' in result && result.html)) { |
| 155 | + console.warn( |
| 156 | + `qwik-router: plugin ${pluginName} returned a string for transformIndexHtml, unsupported by qwik-router:`, |
| 157 | + result |
| 158 | + ); |
| 159 | + } else { |
| 160 | + const tags = 'tags' in result ? result.tags : result; |
| 161 | + if (!Array.isArray(tags)) { |
| 162 | + console.warn( |
| 163 | + `qwik-router: plugin ${pluginName} returned a non-array for tags in transformIndexHtml:`, |
| 164 | + result |
| 165 | + ); |
| 166 | + } else { |
| 167 | + // Note that we don't support the injectTo option |
| 168 | + for (const { tag, attrs, children } of tags) { |
| 169 | + if (!attrs && !children) { |
| 170 | + console.warn( |
| 171 | + `qwik-router: plugin ${pluginName} returned a tag with no attrs in transformIndexHtml:`, |
| 172 | + tag, |
| 173 | + result |
| 174 | + ); |
| 175 | + continue; |
| 176 | + } |
| 177 | + const collectionName = |
| 178 | + tag === 'link' |
| 179 | + ? 'links' |
| 180 | + : tag === 'script' |
| 181 | + ? 'scripts' |
| 182 | + : tag === 'style' |
| 183 | + ? 'styles' |
| 184 | + : tag === 'meta' |
| 185 | + ? 'meta' |
| 186 | + : null; |
| 187 | + if (collectionName) { |
| 188 | + if (children && typeof children !== 'string') { |
| 189 | + console.warn( |
| 190 | + `qwik-router: plugin ${pluginName} returned a tag with children that is not a string in transformIndexHtml:`, |
| 191 | + tag, |
| 192 | + result |
| 193 | + ); |
| 194 | + } else { |
| 195 | + (documentHead[collectionName] ||= []).push( |
| 196 | + children ? { ...attrs, dangerouslySetInnerHTML: children } : attrs |
| 197 | + ); |
| 198 | + } |
| 199 | + } else { |
| 200 | + console.warn( |
| 201 | + `qwik-router: plugin ${pluginName} returned an unsupported tag in transformIndexHtml:`, |
| 202 | + tag, |
| 203 | + result |
| 204 | + ); |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | + } |
| 212 | +} |
0 commit comments