Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion workspaces/example/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@
</ul>
</fieldset>

<fieldset>
<legend>Config `rewrites`</legend>
<ul>
<li>
<a href="/demo" target="_blank">/demo -> Infos Index</a>
</li>
</ul>
</fieldset>

<fieldset>
<legend>Config `previewRewrites`</legend>
<ul>
<li>
<a href="http://localhost:4173/" target="_blank">http://localhost:4173/ -> /fruits/home.html</a>
</li>
</ul>
</fieldset>
</body>

</html>
</html>
10 changes: 4 additions & 6 deletions workspaces/example/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
/**
Expand All @@ -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
Expand All @@ -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') } },
});
170 changes: 100 additions & 70 deletions workspaces/plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -33,6 +35,7 @@ export function createMpaPlugin<
let inputMap: Record<string, string> = {};
let virtualPageMap: Record<string, Page> = {};
let tplSet: Set<string> = new Set();
let rewriteReg: RegExp;

/**
* Update pages configurations.
Expand Down Expand Up @@ -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,
}),
);

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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)}`,
Expand All @@ -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',
Expand All @@ -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);
}
},
};
}
Expand Down
22 changes: 22 additions & 0 deletions workspaces/plugin/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { UrlWithStringQuery } from 'node:url';
import type { Rewrite } from 'connect-history-api-fallback';
import type { Page } from './api-types';

/**
Expand All @@ -11,3 +13,23 @@ export function createPages<
>(pages: Page<Name, Filename, Tpl> | Page<Name, Filename, Tpl>[]) {
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,
});
}