diff --git a/workspaces/example/index.html b/workspaces/example/index.html index b074198..8369e1a 100644 --- a/workspaces/example/index.html +++ b/workspaces/example/index.html @@ -67,6 +67,23 @@ +
+ Config `rewrites` + +
+ +
+ Config `previewRewrites` + +
- \ No newline at end of file + diff --git a/workspaces/example/vite.config.ts b/workspaces/example/vite.config.ts index 83795c3..bb1a699 100644 --- a/workspaces/example/vite.config.ts +++ b/workspaces/example/vite.config.ts @@ -76,10 +76,8 @@ export default defineConfig({ */ rewrites: [ { - from: new RegExp( - normalizePath(`/${base}/(apple|banana|strawberries|home)`), - ), - to: (ctx) => normalizePath(`/${base}/fruits/${ctx.match[1]}.html`), + from: /\/demo/, + to: () => '/infos/index.html', }, ], /** @@ -88,7 +86,7 @@ export default defineConfig({ */ previewRewrites: [ // If there's no index.html, you need to manually set rules for history fallback like: - { from: /.*/, to: '/home.html' }, + { from: /\//, to: '/fruits/home.html' }, ], /** * Sometimes you might want to reload `pages` config or restart ViteDevServer when @@ -113,7 +111,7 @@ export default defineConfig({ ], build: { sourcemap: true }, server: { port: 5173, open: true }, - preview: { port: 5173 }, + preview: { port: 4173 }, optimizeDeps: { force: true }, resolve: { alias: { '@': path.resolve('src') } }, }); diff --git a/workspaces/plugin/src/plugin.ts b/workspaces/plugin/src/plugin.ts index e6b5d21..508555e 100644 --- a/workspaces/plugin/src/plugin.ts +++ b/workspaces/plugin/src/plugin.ts @@ -2,8 +2,10 @@ import ejs from 'ejs'; import color from 'picocolors'; import fs from 'fs'; import path from 'path'; +import nodeUrl from 'node:url'; import history, { Rewrite } from 'connect-history-api-fallback'; import { name as pkgName } from '../package.json'; +import { evaluateRewriteRule } from './utils'; import type { MpaOptions, AllowedEvent, Page, WatchOptions, ScanOptions } from './api-types'; import { type ResolvedConfig, type Plugin, normalizePath, createFilter, ViteDevServer } from 'vite'; @@ -33,6 +35,7 @@ export function createMpaPlugin< let inputMap: Record = {}; let virtualPageMap: Record = {}; let tplSet: Set = new Set(); + let rewriteReg: RegExp; /** * Update pages configurations. @@ -76,30 +79,7 @@ export function createMpaPlugin< // Override the index (default /index.html). index: normalizePath(`/${base}/index.html`), htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], - rewrites: rewrites.concat([ - { - from: new RegExp(normalizePath(`/${base}/(${Object.keys(inputMap).join('|')})`)), - to: ctx => { - return normalizePath(`/${base}/${inputMap[ctx.match[1]]}`); - }, - }, - { - from: /\/$/, - /** - * Support /dir/ without explicit index.html - * @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/htmlFallback.ts#L13 - */ - to({ parsedUrl, request }: any) { - const rewritten = decodeURIComponent(parsedUrl.pathname) + 'index.html'; - - if (fs.existsSync(rewritten.replace(base, ''))) { - return rewritten; - } - - return request.url; - }, - }, - ]), + rewrites, }), ); @@ -149,6 +129,7 @@ export function createMpaPlugin< name: pluginName, config(config) { configInit(pages); // Init + rewriteReg = new RegExp(`${normalizePath(`/${config.base}/`)}(${Object.keys(inputMap).join('|')})(?:\\.html?)?(\\?|#|$).*`); return { appType: 'mpa', @@ -193,15 +174,12 @@ export function createMpaPlugin< transform, configureServer(server) { const { - config, watcher, middlewares, pluginContainer, transformIndexHtml, } = server; - const base = normalizePath(`/${config.base || '/'}/`); - if (watchOptions) { const { events, @@ -218,7 +196,7 @@ export function createMpaPlugin< if (events && !events.includes(type)) return; if (!isMatch(filename)) return; - const file = path.relative(config.root, filename); + const file = path.relative(resolvedConfig.root, filename); verbose && console.log( `[${pluginName}]: ${color.green(`file ${type}`)} - ${color.dim(file)}`, @@ -237,7 +215,7 @@ export function createMpaPlugin< watcher.on('change', file => { if ( file.endsWith('.html') && - tplSet.has(path.relative(config.root, file)) + tplSet.has(path.relative(resolvedConfig.root, file)) ) { server.ws.send({ type: 'full-reload', @@ -246,53 +224,105 @@ export function createMpaPlugin< } }); - // History fallback - useHistoryFallbackMiddleware(middlewares, rewrites); + return () => { + // Handle html file redirected by history fallback. + middlewares.use(async (req, res, next) => { + const { + method, + headers: { + accept, + }, + originalUrl, + url, + } = req; - // Handle html file redirected by history fallback. - middlewares.use(async (req, res, next) => { - const url = req.url!; - // filename in page configuration can't start with '/', because the key of inputMap is relative path. - const fileName = url.replace(base, '').replace(/[?#].*$/s, ''); // clean url + if (!method || !['GET', 'HEAD'].includes(method) || !accept || !originalUrl || !url) { + return next(); + } - if ( - res.writableEnded || - !fileName.endsWith('.html') || // HTML Fallback Middleware appends '.html' to URLs - !virtualPageMap[fileName] - ) { - return next(); // This allows vite handling unmatched paths. - } + // Filter non-html request + if (!/.*(text\/html|application\/xhtml\+xml).*/.test(accept)) { + return next(); + } - /** - * The following 2 lines fixed #12. - * When using cypress for e2e testing, we should manually set response header and status code. - * Otherwise, it causes cypress testing process of cross-entry-page jumping hanging, which results in a timeout error. - */ - res.setHeader('Content-Type', 'text/html'); - res.statusCode = 200; - - // load file - let loadResult = await pluginContainer.load(fileName); - if (!loadResult) { - throw new Error(`Failed to load url ${fileName}`); - } - loadResult = typeof loadResult === 'string' - ? loadResult - : loadResult.code; + const parsedUrl = nodeUrl.parse(originalUrl); + const { pathname } = parsedUrl; - res.end( - await transformIndexHtml( - url, - // No transform applied, keep code as-is - transform(loadResult, fileName) ?? loadResult, - req.originalUrl, - ), - ); - }); + if (!pathname) { + return next(); + } + + // Custom rewrites + if (rewrites?.length) { + const rewrite = rewrites.find(item => { + return item.from.test(pathname); + }); + + if (rewrite) { + const match = pathname.match(rewrite.from); + + if (match) { + const rewriteTarget = evaluateRewriteRule(parsedUrl, match, rewrite.to); + + if (verbose) { + console.log( + `[${pluginName}]: Custom Rewriting ${method} ${color.blue(pathname)} to ${color.blue(rewriteTarget)}`, + ); + } + + req.url = rewriteTarget; + return next(); + } + } + } + + const inputMapKey = pathname.match(rewriteReg)?.[1]; + const fileName = inputMapKey ? inputMap[inputMapKey] : null; + + if (!fileName) { + return next(); // This allows vite handling unmatched paths. + } + + // print rewriting log if verbose is true + if (verbose) { + console.log( + `[${pluginName}]: Rewriting ${method} ${color.blue(pathname)} to ${color.blue(normalizePath(`/${resolvedConfig.base}/${fileName}`))}`, + ); + } + + /** + * The following 2 lines fixed #12. + * When using cypress for e2e testing, we should manually set response header and status code. + * Otherwise, it causes cypress testing process of cross-entry-page jumping hanging, which results in a timeout error. + */ + res.setHeader('Content-Type', 'text/html'); + res.statusCode = 200; + + // load file + let loadResult = await pluginContainer.load(fileName); + if (!loadResult) { + throw new Error(`Failed to load url ${fileName}`); + } + loadResult = typeof loadResult === 'string' + ? loadResult + : loadResult.code; + + res.end( + await transformIndexHtml( + url, + // No transform applied, keep code as-is + transform(loadResult, fileName) ?? loadResult, + originalUrl, + ), + ); + }); + }; }, configurePreviewServer(server) { - // History Fallback - useHistoryFallbackMiddleware(server.middlewares, previewRewrites); + // History fallback, custom middlewares + if (previewRewrites?.length) { + useHistoryFallbackMiddleware(server.middlewares, previewRewrites); + } }, }; } diff --git a/workspaces/plugin/src/utils.ts b/workspaces/plugin/src/utils.ts index 81343b3..e8d34db 100644 --- a/workspaces/plugin/src/utils.ts +++ b/workspaces/plugin/src/utils.ts @@ -1,3 +1,5 @@ +import type { UrlWithStringQuery } from 'node:url'; +import type { Rewrite } from 'connect-history-api-fallback'; import type { Page } from './api-types'; /** @@ -11,3 +13,23 @@ export function createPages< >(pages: Page | Page[]) { return Array.isArray(pages) ? pages : [pages]; } + +/** + * @see https://github.com/bripkens/connect-history-api-fallback/blob/6b58bc97d4a2ff2be0a68dc661df5c7857758a55/lib/index.js#L89-L101 + */ +export function evaluateRewriteRule( + parsedUrl: UrlWithStringQuery, + match: RegExpMatchArray, + rule: Rewrite['to'], +) { + if (typeof rule === 'string') { + return rule; + } else if (typeof rule !== 'function') { + throw new Error('Rewrite rule can only be of type string or function.'); + } + + return rule({ + parsedUrl, + match, + }); +}