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
39 changes: 32 additions & 7 deletions lib/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,52 @@ export async function run() {
appType: 'custom',
});

app.get('/_matcha/data', async (req, res) => {
const target = typeof req.query.url === 'string' ? req.query.url : null;

if (!target) {
return res.status(400).json({ error: 'Missing url query parameter' });
}

try {
const { getRouteData } = await vite.ssrLoadModule('/src/entry-server.tsx');
const { route, props } = await getRouteData(target);

if (!route?.getServerSideProps) {
return res.status(404).json({ error: 'No getServerSideProps for route' });
}

res.status(200).json(props);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
console.error(e);
res.status(500).json({ error: (e as Error).message });
}
});

app.use(vite.middlewares);

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

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

// 2. Apply Vite HTML transforms (injects HMR client, etc.)
template = await vite.transformIndexHtml(url, template);
template = await vite.transformIndexHtml(req.originalUrl, template);

// 3. Load server entry via Vite (enables HMR for SSR)
const { render } = await vite.ssrLoadModule('/src/entry-server.tsx');

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

// Serialize initial props so the client hydrates with the same data.
const propsScript = `<script>window.__INITIAL_PROPS__=${JSON.stringify(props).replace(/</g, '\\u003c')}</script>`;

// 5. Inject rendered HTML
const html = template.replace('<!--ssr-outlet-->', appHtml);
// 5. Inject rendered HTML and initial props
const html = template
.replace('<!--ssr-outlet-->', appHtml)
.replace('</head>', `${propsScript}</head>`);

res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
Expand All @@ -47,4 +73,3 @@ export async function run() {
console.log('Dev server running at http://localhost:3000');
});
}

85 changes: 71 additions & 14 deletions lib/commands/serve.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,89 @@
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';

export const description = 'Serve the built static files from dist/public/';
interface Route {
path: string;
getServerSideProps?: unknown;
}

interface RenderResult {
html: string;
props: Record<string, unknown>;
}

interface RouteDataResult {
route: Route | undefined;
props: Record<string, unknown>;
}

export const description = 'Serve the built app with static pages and SSR routes';

export async function run() {
const app = express();
const root = process.cwd();
const distPath = path.resolve(root, 'dist/public');
const publicDir = path.resolve(root, 'dist/public');
const serverEntryPath = path.resolve(root, 'dist/server/entry-server.js');
const templatePath = path.resolve(publicDir, '_template.html');

const template = await readFile(templatePath, 'utf-8');
const serverEntryUrl = pathToFileURL(serverEntryPath).href;
const { render, getRouteData } = await import(serverEntryUrl) as {
render: (target: string) => Promise<RenderResult>;
getRouteData: (target: string) => Promise<RouteDataResult>;
};

app.use(express.static(publicDir, { index: false, redirect: false }));

app.get('/_matcha/data', async (req, res) => {
const target = typeof req.query.url === 'string' ? req.query.url : null;

// Serve static files
app.use(express.static(distPath));
if (!target) {
return res.status(400).json({ error: 'Missing url query parameter' });
}

try {
const { route, props } = await getRouteData(target);

if (!route?.getServerSideProps) {
return res.status(404).json({ error: 'No getServerSideProps for route' });
}

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

// Try /path/index.html for clean URLs
const indexPath = path.resolve(distPath, urlPath.slice(1), 'index.html');
res.status(200).json(props);
} catch (e) {
console.error(e);
res.status(500).json({ error: (e as Error).message });
}
});

app.use('*all', async (req, res) => {
const requestUrl = req.originalUrl;
const parsed = new URL(requestUrl, 'http://localhost');
const urlPath = parsed.pathname;

const indexPath = path.resolve(publicDir, 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);
}

// Fallback to root index.html (SPA fallback)
res.sendFile(path.resolve(distPath, 'index.html'));
try {
const { html: appHtml, props } = await render(requestUrl);
const propsScript = `<script>window.__INITIAL_PROPS__=${JSON.stringify(props).replace(/</g, '\\u003c')}</script>`;
const html = template
.replace('<!--ssr-outlet-->', appHtml)
.replace('</head>', `${propsScript}</head>`);

res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
console.error(e);
res.status(500).end((e as Error).message);
}
});

app.listen(3000, () => {
console.log('Serving dist/public/ at http://localhost:3000');
console.log('Serving MatchaStack at http://localhost:3000');
});
}
39 changes: 28 additions & 11 deletions lib/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Plugin, build } from 'vite';
import { readFile, writeFile, rm, mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

interface Route {
path: string;
getServerSideProps?: unknown;
}

interface RenderResult {
Expand All @@ -29,11 +30,18 @@ function stripServerCode(code: string): string {
/^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,
''
);

// Remove getStaticProps from route objects and imports
code = code.replace(/,?\s*getStaticProps:\s*\w+/g, '');
code = code.replace(/,\s*\{\s*getStaticProps\s+as\s+\w+\s*\}/g, '');
code = code.replace(/\{\s*getStaticProps\s+as\s+\w+\s*\},?\s*/g, '');
code = code.replace(/,?\s*getStaticProps:\s*[^,}]+/g, '');
code = code.replace(/,?\s*getServerSideProps:\s*[^,}]+/g, ', hasServerSideProps: true');

// Remove Node.js built-in imports (now unused after stripping getStaticProps)
code = code.replace(/^import\s+.*\s+from\s+['"]node:.*['"];?\n/gm, '');
Expand All @@ -45,6 +53,7 @@ export default function matcha(): Plugin {
let root: string;
let outDir: string;
let isSsr: boolean;
let command: 'build' | 'serve';

return {
name: 'matcha',
Expand All @@ -53,10 +62,14 @@ export default function matcha(): Plugin {
root = config.root;
outDir = config.build.outDir;
isSsr = Boolean(config.build.ssr);
command = config.command;
},

transform(code, id) {
// Only strip on client builds, only for src/ files
// Only strip on production client builds, only for src/ files.
// In dev, Vite uses the same source modules for ssrLoadModule(),
// so stripping here would remove server data loaders too.
if (command !== 'build') return;
if (isSsr) return;
if (!id.includes('/src/')) return;
if (!id.match(/\.(tsx?|jsx?)$/)) return;
Expand All @@ -69,7 +82,7 @@ export default function matcha(): Plugin {

async closeBundle() {
const distDir = resolve(root, outDir);
const serverOutDir = resolve(distDir, 'server');
const serverOutDir = resolve(distDir, '..', 'server');

// 1. Build server entry (SSR build)
await build({
Expand Down Expand Up @@ -97,9 +110,16 @@ export default function matcha(): Plugin {
// 3. Read the template
const templatePath = resolve(distDir, 'index.html');
const template = await readFile(templatePath, 'utf-8');
const runtimeTemplatePath = resolve(distDir, '_template.html');
await writeFile(runtimeTemplatePath, template);

// 4. Render each route
for (const route of routes) {
if (route.getServerSideProps) {
console.log(`[matcha] skipping SSR route at build time: ${route.path}`);
continue;
}

const { html: appHtml, props } = await render(route.path);

// Determine output directory
Expand All @@ -126,10 +146,7 @@ export default function matcha(): Plugin {
console.log(`[matcha] ${route.path} → ${htmlPath.replace(root + '/', '')}`);
}

// 5. Clean up server build
await rm(serverOutDir, { recursive: true });

console.log(`[matcha] SSG complete: ${routes.length} pages`);
console.log(`[matcha] Build complete: ${routes.length} routes processed`);
},
};
}
Loading