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
21 changes: 18 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ RUN apt update \
&& bun install \
&& bun run build

FROM nginxinc/nginx-unprivileged:mainline-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/snort/packages/app/build /usr/share/nginx/html
FROM oven/bun:latest AS runtime
WORKDIR /app

# Copy built assets
COPY --from=build /src/snort/packages/app/build /app/build

# Copy server files
COPY --from=build /src/snort/packages/app/package.json /app/package.json
COPY --from=build /src/snort/packages/app/server.ts /app/server.ts

# Install production dependencies
RUN cd /app && bun install --production

# Expose port
EXPOSE 3000

# Start SSR server
CMD ["bun", "run", "server"]
102 changes: 102 additions & 0 deletions SSR_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# SSR (Server-Side Rendering) Setup for Snort

This document describes the SSR implementation for the Snort application to improve SEO by rendering threads and profiles from the server side.

## Overview

The SSR implementation uses:
- **Vite** for build tooling with SSR mode support
- **React 19** with `react-dom/server` for server-side rendering
- **React Router DOM** with `StaticRouter` for routing in SSR
- **Express** as the SSR server

## Project Structure

```
packages/app/
├── src/
│ ├── entry-server.tsx # SSR entry point
│ ├── index.tsx # Client entry point
│ └── ...
├── server.ts # SSR server (Express)
├── index.html # HTML template
├── package.json # Dependencies and scripts
└── vite.config.ts # Vite configuration with SSR support
```

## Build Commands

```bash
# Build both client and server bundles
bun run build

# Build only client bundle
bun run build:client

# Build only server bundle (SSR)
bun run build:ssr

# Start SSR server in development
bun run server
```

## How SSR Works

1. **Client Build**: Creates the standard client-side bundle in `build/client/`
2. **Server Build**: Creates the SSR bundle in `build/server/`
3. **SSR Server**: The Express server (`server.ts`) handles requests:
- In development: Uses Vite's dev server middleware for hot reloading
- In production: Serves pre-built client assets and renders SSR HTML

## Key Files

### `src/entry-server.tsx`
Server-side entry point that renders the React app to a string using `renderToString`. This is where the app is rendered on the server before being sent to the client.

### `server.ts`
Express server that:
- Handles all incoming requests
- Renders the React app to HTML on the server
- Injects the rendered HTML into the template
- Sends the complete HTML to the client

### `vite.config.ts`
Vite configuration updated to support:
- SSR build mode (`--mode ssr`)
- Separate output directories for client and server
- SSR manifest generation for proper asset loading

## Benefits

1. **Improved SEO**: Search engines can crawl the fully rendered HTML
2. **Faster First Paint**: Content is available immediately without waiting for JavaScript
3. **Better Social Sharing**: Open Graph and Twitter Card meta tags are properly rendered
4. **Enhanced User Experience**: Users see content faster on slow connections

## Deployment

### Using Docker

```bash
# Build the Docker image
docker build -t snort-ssr .

# Run the container
docker run -p 3000:3000 snort-ssr
```

### Environment Variables

- `PORT`: Server port (default: 3000)
- `NODE_ENV`: Set to `production` for production mode

## Migration Notes

The existing Cloudflare Functions middleware (`functions/_middleware.ts`) for OpenGraph data fetching is still compatible and can be used alongside SSR for enhanced metadata.

## Future Enhancements

- Add streaming SSR with `renderToPipeableStream`
- Implement data prefetching on the server
- Add dynamic meta tag generation for profiles and threads
- Consider Next.js or Remix for a more complete SSR framework if needed
5 changes: 3 additions & 2 deletions packages/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
</head>

<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<div id="root"><!--app-html--></div>
<!--ssr-cache-->
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
8 changes: 7 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@
},
"scripts": {
"start": "bunx --bun vite",
"build": "bunx vite build",
"build": "bun run build:client && bun run build:ssr",
"build:client": "bunx vite build",
"build:ssr": "bunx vite build --mode ssr",
"serve": "bunx --bun vite preview",
"server": "bun run build && bun --watch server/prod.ts",
"server:prod": "bun run build && bun server/prod.ts",
"intl-extract": "bunx --bun formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --flatten true",
"intl-compile": "bunx --bun formatjs compile src/lang.json --ast --out-file src/translations/en.json",
"eslint": "bunx --bun eslint ."
Expand All @@ -72,6 +76,7 @@
"@formatjs/cli": "^6.7.4",
"@types/config": "^3.3.5",
"@types/debug": "^4.1.12",
"@types/express": "^5.0.0",
"@types/latlon-geohash": "^2.0.4",
"@types/node": "^24.8.1",
"@types/react": "^19.2.2",
Expand All @@ -88,6 +93,7 @@
"babel-plugin-formatjs": "^10.5.41",
"@tailwindcss/vite": "^4.1.18",
"config": "^4.1.1",
"express": "^5.1.0",
"prop-types": "^15.8.1",
"rollup-plugin-visualizer": "^6.0.5",
"tailwindcss": "^4.1.14",
Expand Down
59 changes: 59 additions & 0 deletions packages/app/server/prod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Production server — Bun.serve with static file serving + SSR.
* Uses Bun for high-performance production serving.
*/

import { renderPage } from "./ssr-render.js";

const port = Number(process.env.PORT) || 3000;

const templateHtml = await Bun.file("./build/client/index.html").text();

const ssr: typeof import("../dist/server/entry-server.js") =
// @ts-ignore built SSR output has no declarations
await import("../dist/server/entry-server.js");

const server = Bun.serve({
port,
async fetch(req: Request) {
const url = new URL(req.url);
const pathname = url.pathname;

// Serve static files from build/client
const staticFile = Bun.file(`./build/client${pathname}`);
if (pathname !== "/" && (await staticFile.exists())) {
return new Response(staticFile, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}

// SSR for everything else
try {
const result = await renderPage(
url.pathname + url.search,
templateHtml,
ssr,
req.headers.get("accept-language"),
req.headers.get("cookie"),
);
return new Response(result.html, {
status: result.status,
headers: { "Content-Type": "text/html" },
});
} catch {
console.error(`[${req.method}] ${pathname} 500`);
return new Response("Internal Server Error", { status: 500 });
}
},
});

console.log(`Server running at http://localhost:${server.port}`);

function shutdown() {
server.stop();
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
60 changes: 60 additions & 0 deletions packages/app/server/ssr-render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Shared SSR render logic used by both dev and prod servers.
*
* Route loaders are invoked automatically by createStaticHandler inside
* ssr.render(), so no manual seeding step is needed here.
*/

type SSRModule = typeof import("../src/entry-server");

export interface SSRResult {
html: string;
status: number;
}

/**
* Render a URL to a full HTML page.
*
* @param url The request URL path (e.g. "/thread/...")
* @param template The index.html template string (with placeholders)
* @param ssr The loaded SSR module (entry-server exports)
* @param acceptLanguage The Accept-Language header value from the request
* @param cookie The Cookie header value from the request
*/
export async function renderPage(
url: string,
template: string,
ssr: SSRModule,
acceptLanguage?: string | null,
cookie?: string | null,
): Promise<SSRResult> {
const locale = ssr.detectLocale(acceptLanguage ?? null, cookie);

let appHtml = "";
let head = "";
let cacheScript = "";
let lang = locale;
let dir = "ltr";
try {
const result = await ssr.render(url, locale, acceptLanguage, cookie);
appHtml = result.html;
head = result.head;
cacheScript = result.cacheScript;
lang = result.lang;
dir = result.dir;
} catch (renderErr) {
console.warn(
`SSR render failed for ${url}, falling back to client shell:`,
(renderErr as Error).message,
);
}

const html = template
.replace("<!--ssr-lang-->", lang)
.replace("<!--ssr-dir-->", dir)
.replace("<!--ssr-head-->", head)
.replace("<!--ssr-cache-->", cacheScript)
.replace("<!--app-html-->", appHtml);

return { html, status: 200 };
}
Loading