Skip to content
Draft
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
15 changes: 6 additions & 9 deletions lib/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export async function run() {
appType: 'custom',
});

app.use(vite.middlewares);

app.get('/__matcha_props', async (req, res) => {
const rawPath = req.query.path;
const routePath = typeof rawPath === 'string' ? rawPath : '/';
Expand All @@ -26,11 +24,10 @@ export async function run() {
}

try {
const parsedPath = new URL(routePath, 'http://localhost').pathname;
const { loadStaticProps, loadServerSideProps } = await vite.ssrLoadModule('/src/entry-server.tsx');
const props = {
...(await loadStaticProps(parsedPath)),
...(await loadServerSideProps(parsedPath)),
...(await loadStaticProps(routePath)),
...(await loadServerSideProps(routePath)),
};

res
Expand All @@ -47,12 +44,12 @@ export async function run() {
}
});

app.use(vite.middlewares);

app.use('*all', async (req, res) => {
const url = req.originalUrl;

try {
const requestUrl = new URL(url, 'http://localhost');

// 1. Read index.html
let template = fs.readFileSync(path.resolve(root, 'index.html'), 'utf-8');

Expand All @@ -63,10 +60,10 @@ export async function run() {
const { render, routes } = await vite.ssrLoadModule('/src/entry-server.tsx');

// 4. Render the app
const { html: appHtml, props } = await render(requestUrl.pathname);
const { html: appHtml, props } = await render(url);

// 5. Inject rendered HTML
const propsScript = `<script>window.__INITIAL_PROPS__=${JSON.stringify(props)}</script>`;
const propsScript = `<script>window.__INITIAL_PROPS__=${JSON.stringify(props).replace(/</g, '\\u003c')}</script>`;
const ssrRoutes = (routes as Array<{ path: string; getServerSideProps?: unknown }>)
.filter((route) => Boolean(route.getServerSideProps))
.map((route) => route.path);
Expand Down
28 changes: 14 additions & 14 deletions lib/commands/serve.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'node:path';
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import express from 'express';
import { pathToFileURL } from 'node:url';

Expand All @@ -22,9 +23,6 @@ export async function run() {
ssrFunction = await import(pathToFileURL(ssrFunctionPath).href) as SsrFunctionModule;
}

// Serve static files
app.use(express.static(distPath));

app.get('/__matcha_props', async (req, res) => {
if (!ssrFunction) {
res.status(404).json({ error: 'SSR runtime not available' });
Expand All @@ -38,14 +36,13 @@ export async function run() {
return;
}

const pathname = new URL(routePath, 'http://localhost').pathname;
if (!ssrFunction.isSsrRoute(pathname)) {
if (!ssrFunction.isSsrRoute(routePath)) {
res.status(404).json({ error: 'Route is not SSR' });
return;
}

try {
const props = await ssrFunction.renderRouteProps(pathname);
const props = await ssrFunction.renderRouteProps(routePath);
res
.status(200)
.set({
Expand All @@ -59,28 +56,31 @@ export async function run() {
}
});

// Handle clean URLs: /about → /about/index.html
app.use(express.static(distPath, { index: false, redirect: false }));

// Handle clean URLs: /about -> /about/index.html
app.use('*all', async (req, res) => {
const urlPath = req.originalUrl.split('?')[0] ?? '';
const requestUrl = req.originalUrl;
const urlPath = requestUrl.split('?')[0] ?? '';

// Try /path/index.html for clean URLs
const indexPath = path.resolve(distPath, urlPath.slice(1), 'index.html');
if (fs.existsSync(indexPath)) {
return res.sendFile(indexPath);
const html = await readFile(indexPath, 'utf-8');
return res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
}

if (ssrFunction && ssrFunction.isSsrRoute(urlPath)) {
if (ssrFunction && ssrFunction.isSsrRoute(requestUrl)) {
try {
const html = await ssrFunction.renderSsrPage(urlPath);
const html = await ssrFunction.renderSsrPage(requestUrl);
return res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
console.error(e);
return res.status(500).end((e as Error).message);
}
}

// Fallback to root index.html (SPA fallback)
res.sendFile(path.resolve(distPath, 'index.html'));
const html = await readFile(path.resolve(distPath, 'index.html'), 'utf-8');
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
});

app.listen(3000, () => {
Expand Down
85 changes: 70 additions & 15 deletions lib/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,62 @@ interface RenderResult {
props: Record<string, unknown>;
}

/**
* Strip server-only code from client builds:
* - getStaticProps/getServerSideProps exports
* - Node.js built-in imports
*/
function stripServerCode(code: string): string {
code = code.replace(
/^export\s+const\s+getStaticProps\s*=[\s\S]*?^\};?\n/gm,
''
);
code = code.replace(
/^export\s+(async\s+)?function\s+getStaticProps[\s\S]*?^\}\n/gm,
''
);
code = code.replace(
/^export\s+const\s+getServerSideProps\s*=[\s\S]*?^\};?\n/gm,
''
);
code = code.replace(
/^export\s+(async\s+)?function\s+getServerSideProps[\s\S]*?^\}\n/gm,
''
);

code = code.replace(/,?\s*getStaticProps:\s*[^,}]+/g, '');
code = code.replace(/,?\s*getServerSideProps:\s*[^,}]+/g, ', hasServerSideProps: true');
code = code.replace(/^import\s+.*\s+from\s+['"]node:.*['"];?\n/gm, '');

return code;
}

export default function matcha(): Plugin {
let root: string;
let outDir: string;
let isSsr: boolean;
let command: 'build' | 'serve';

return {
name: 'matcha',

configResolved(config) {
root = config.root;
outDir = config.build.outDir;
isSsr = Boolean(config.build.ssr);
command = config.command;
},

transform(code, id) {
if (command !== 'build') return;
if (isSsr) return;
if (!id.includes('/src/')) return;
if (!id.match(/\.(tsx?|jsx?)$/)) return;

const stripped = stripServerCode(code);
if (stripped !== code) {
return { code: stripped, map: null };
}
},

async closeBundle() {
Expand Down Expand Up @@ -78,8 +124,17 @@ function normalizePath(routePath) {
return routePath === '/' ? routePath : routePath.replace(/\\/$/, '');
}

function isSsrRoute(routePath) {
return ssrRoutes.includes(normalizePath(routePath));
function toRouteTarget(routeTarget) {
const parsed = new URL(routeTarget, 'http://localhost');
const pathname = normalizePath(parsed.pathname);
return {
pathname,
target: \`\${pathname}\${parsed.search}\`,
};
}

function isSsrRoute(routeTarget) {
return ssrRoutes.includes(toRouteTarget(routeTarget).pathname);
}

function staticPropsFilePath(routePath) {
Expand All @@ -96,27 +151,27 @@ async function loadCachedStaticProps(routePath) {
}
}

export async function renderSsrPage(routePath) {
const normalizedPath = normalizePath(routePath);
export async function renderSsrPage(routeTarget) {
const { pathname, target } = toRouteTarget(routeTarget);
const [template, staticProps] = await Promise.all([
readFile(templatePath, 'utf-8'),
loadCachedStaticProps(normalizedPath),
loadCachedStaticProps(pathname),
]);
const serverProps = await loadServerSideProps(normalizedPath);
const serverProps = await loadServerSideProps(target);
const props = { ...staticProps, ...serverProps };
const { html: appHtml } = renderWithProps(normalizedPath, props);
const propsScript = \`<script>window.__INITIAL_PROPS__=\${JSON.stringify(props)}</script>\`;
const { html: appHtml } = renderWithProps(target, props);
const propsScript = \`<script>window.__INITIAL_PROPS__=\${JSON.stringify(props).replace(/</g, '\\\\u003c')}</script>\`;
const routesScript = ${JSON.stringify(ssrRoutesScript)};

return template
.replace('<!--ssr-outlet-->', appHtml)
.replace('</head>', \`\${propsScript}\${routesScript}</head>\`);
}

export async function renderRouteProps(routePath) {
const normalizedPath = normalizePath(routePath);
const staticProps = await loadCachedStaticProps(normalizedPath);
const serverProps = await loadServerSideProps(normalizedPath);
export async function renderRouteProps(routeTarget) {
const { pathname, target } = toRouteTarget(routeTarget);
const staticProps = await loadCachedStaticProps(pathname);
const serverProps = await loadServerSideProps(target);
return { ...staticProps, ...serverProps };
}

Expand All @@ -135,12 +190,12 @@ export { isSsrRoute, ssrRoutes };`;
await writeFile(propsPath, JSON.stringify(staticProps));

if (ssrRoutes.includes(route.path)) {
console.log(`[matcha] ${route.path} SSR runtime`);
console.log(`[matcha] ${route.path} -> SSR runtime`);
continue;
}

const { html: appHtml, props } = await render(route.path);
const propsScript = `<script>window.__INITIAL_PROPS__=${JSON.stringify(props)}</script>`;
const propsScript = `<script>window.__INITIAL_PROPS__=${JSON.stringify(props).replace(/</g, '\\u003c')}</script>`;
const finalHtml = template
.replace('<!--ssr-outlet-->', appHtml)
.replace('</head>', `${propsScript}${ssrRoutesScript}</head>`);
Expand All @@ -149,7 +204,7 @@ export { isSsrRoute, ssrRoutes };`;
await writeFile(htmlPath, finalHtml);

renderedCount += 1;
console.log(`[matcha] ${route.path} ${htmlPath.replace(root + '/', '')}`);
console.log(`[matcha] ${route.path} -> ${htmlPath.replace(root + '/', '')}`);
}

console.log(`[matcha] Static pages: ${renderedCount}, SSR pages: ${ssrRoutes.length}`);
Expand Down
Loading