Skip to content

Commit 74d61f1

Browse files
authored
Merge pull request #119 from kojibai/main
v42.2.0
2 parents ed0bde9 + bc65d37 commit 74d61f1

19 files changed

Lines changed: 2038 additions & 626 deletions

api/ssr.ts

Lines changed: 115 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// api/ssr.ts
22
import fs from "node:fs";
33
import path from "node:path";
4-
import { pathToFileURL } from "node:url";
4+
import { fileURLToPath, pathToFileURL } from "node:url";
55
import { PassThrough } from "node:stream";
6+
import { randomUUID } from "node:crypto";
67
import type { IncomingMessage, ServerResponse } from "node:http";
78
import type { PipeableStream } from "react-dom/server";
89

@@ -31,6 +32,8 @@ type ManifestEntry = {
3132
src?: string;
3233
};
3334

35+
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
36+
3437
function pickRender(mod: unknown): RenderFn | null {
3538
if (!mod || typeof mod !== "object") return null;
3639
const m = mod as SsrModule;
@@ -49,14 +52,15 @@ function toPublicPath(file?: string): string | null {
4952

5053
function buildSsrHead(): string {
5154
const parts: string[] = [];
52-
const manifestPath = path.join(process.cwd(), "dist", "client", ".vite", "manifest.json");
55+
const manifestPaths = [
56+
path.join(ROOT_DIR, "dist", ".vite", "manifest.json"),
57+
path.join(ROOT_DIR, "dist", "client", ".vite", "manifest.json"),
58+
];
5359

5460
try {
55-
if (fs.existsSync(manifestPath)) {
56-
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<
57-
string,
58-
ManifestEntry
59-
>;
61+
const manifestPath = manifestPaths.find((candidate) => fs.existsSync(candidate));
62+
if (manifestPath) {
63+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, ManifestEntry>;
6064
const entryKey =
6165
Object.keys(manifest).find((key) => manifest[key]?.src?.includes("entry-client")) ??
6266
Object.keys(manifest).find((key) => manifest[key]?.isEntry);
@@ -127,28 +131,107 @@ function formatErrorForDebug(err: unknown): string {
127131
}
128132
}
129133

130-
export default async function handler(req: IncomingMessage, res: ServerResponse) {
131-
const ABORT_DELAY_MS = 10_000;
134+
type TemplateParts = { head: string; tail: string };
132135

133-
try {
134-
const url = absoluteUrl(req);
135-
const DEBUG = url.searchParams.has("__ssr_debug");
136+
function minimalTemplate(): TemplateParts {
137+
return {
138+
head:
139+
"<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body><div id=\"root\">",
140+
tail: "</div></body></html>",
141+
};
142+
}
136143

137-
const templatePath = path.join(process.cwd(), "dist", "server", "template.html");
144+
function loadTemplate(): TemplateParts {
145+
const templatePath = path.join(ROOT_DIR, "dist", "server", "template.html");
146+
try {
138147
const template = fs.readFileSync(templatePath, "utf8");
139148
const templateWithHead = template.replace("<!--ssr-head-->", buildSsrHead());
140-
const { head, tail } = splitTemplate(templateWithHead);
149+
return splitTemplate(templateWithHead);
150+
} catch (err) {
151+
console.error("SSR template load failed:", err);
152+
return minimalTemplate();
153+
}
154+
}
155+
156+
function resolveEntryServerPath(): string | null {
157+
const serverDir = path.join(ROOT_DIR, "dist", "server");
158+
const candidates = ["entry-server.js", "entry-server.mjs", "entry-server.cjs"];
159+
160+
for (const name of candidates) {
161+
const candidate = path.join(serverDir, name);
162+
if (fs.existsSync(candidate)) return candidate;
163+
}
164+
165+
try {
166+
const files = fs.readdirSync(serverDir);
167+
const match = files.find((file) => /^entry-server\.(mjs|cjs|js)$/i.test(file));
168+
return match ? path.join(serverDir, match) : null;
169+
} catch {
170+
return null;
171+
}
172+
}
173+
174+
function respondWithShell(
175+
res: ServerResponse,
176+
parts: TemplateParts,
177+
requestId: string,
178+
opts: { debug: boolean; error?: unknown }
179+
) {
180+
if (!res.headersSent) {
181+
res.statusCode = 200;
182+
res.setHeader("content-type", "text/html; charset=utf-8");
183+
res.setHeader("cache-control", "no-store");
184+
res.setHeader("x-ssr", "0");
185+
res.setHeader("x-ssr-request-id", requestId);
186+
if (opts.debug) {
187+
res.setHeader("x-ssr-debug", "1");
188+
}
189+
}
141190

142-
const entryPath = path.join(process.cwd(), "dist", "server", "entry-server.js");
191+
if (opts.debug && opts.error) {
192+
const payload = escapeHtml(formatErrorForDebug(opts.error));
193+
const debugTemplate = minimalTemplate();
194+
const style =
195+
"<style>body{font-family:ui-monospace,Menlo,Monaco,Consolas,monospace;padding:24px;background:#0b0b0c;color:#f2f2f2;}pre{white-space:pre-wrap;word-break:break-word;background:#151519;border:1px solid #2a2a33;padding:16px;border-radius:8px;}</style>";
196+
res.end(`${debugTemplate.head}${style}<pre>${payload}</pre>${debugTemplate.tail}`);
197+
return;
198+
}
199+
200+
res.end(parts.head + parts.tail);
201+
}
202+
203+
export default async function handler(req: IncomingMessage, res: ServerResponse) {
204+
const ABORT_DELAY_MS = 10_000;
205+
const requestId = randomUUID();
206+
207+
try {
208+
const url = absoluteUrl(req);
209+
const DEBUG = url.searchParams.has("__ssr_debug") || req.headers["x-ssr-debug"] === "1";
210+
const templateParts = loadTemplate();
211+
const { head, tail } = templateParts;
212+
213+
const entryPath = resolveEntryServerPath();
214+
if (!entryPath) {
215+
const err = new Error("SSR entry-server bundle not found in dist/server");
216+
console.error(`[SSR ${requestId}] entry not found`);
217+
respondWithShell(res, templateParts, requestId, { debug: DEBUG, error: err });
218+
return;
219+
}
143220
const entryUrl = pathToFileURL(entryPath).href;
144221

145-
const mod = (await import(entryUrl)) as unknown;
146-
const render = pickRender(mod);
222+
let render: RenderFn | null = null;
223+
try {
224+
const mod = (await import(entryUrl)) as unknown;
225+
render = pickRender(mod);
226+
} catch (err) {
227+
console.error(`[SSR ${requestId}] entry import failed:`, err);
228+
respondWithShell(res, templateParts, requestId, { debug: DEBUG, error: err });
229+
return;
230+
}
147231

148232
if (!render) {
149-
res.statusCode = 500;
150-
res.setHeader("content-type", "text/plain; charset=utf-8");
151-
res.end("SSR entry missing render() export");
233+
console.error(`[SSR ${requestId}] entry missing render() export`);
234+
respondWithShell(res, templateParts, requestId, { debug: DEBUG });
152235
return;
153236
}
154237

@@ -158,6 +241,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
158241
res.setHeader("content-type", "text/html; charset=utf-8");
159242
res.setHeader("cache-control", "no-store");
160243
res.setHeader("x-ssr", "1");
244+
res.setHeader("x-ssr-request-id", requestId);
161245

162246
let shellFlushed = false;
163247
let pipeable: PipeableStream | null = null;
@@ -213,10 +297,8 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
213297
// If shell failed before onShellReady, we still want to return an HTML document.
214298
// Debug mode: show error details in the browser.
215299
if (!shellFlushed) {
216-
if (DEBUG) {
217-
if (!res.writableEnded) res.end(`<pre>${escapeHtml(formatErrorForDebug(err))}</pre>`);
218-
} else {
219-
if (!res.writableEnded) res.end(head + tail);
300+
if (!res.writableEnded) {
301+
respondWithShell(res, templateParts, requestId, { debug: DEBUG, error: err });
220302
}
221303
return;
222304
}
@@ -233,12 +315,16 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
233315
};
234316

235317
// Start React SSR stream
236-
pipeable = render(url.pathname + url.search, null, opts);
318+
try {
319+
pipeable = render(url.pathname + url.search, null, opts);
320+
} catch (err) {
321+
console.error(`[SSR ${requestId}] render crashed:`, err);
322+
respondWithShell(res, templateParts, requestId, { debug: DEBUG, error: err });
323+
}
237324
} catch (err) {
238-
console.error("SSR function crashed:", err);
239-
res.statusCode = 500;
240-
res.setHeader("content-type", "text/plain; charset=utf-8");
241-
res.end("SSR function crashed");
325+
console.error(`[SSR ${requestId}] function crashed:`, err);
326+
const DEBUG = req.headers["x-ssr-debug"] === "1";
327+
respondWithShell(res, loadTemplate(), requestId, { debug: DEBUG, error: err });
242328
}
243329
}
244330

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"dev:ssr": "NODE_ENV=development node server.mjs",
2020
"build:clean": "rm -rf dist .vite && pnpm run build:ssr",
2121
"build": "tsc -b && vite build",
22-
"build:ssr": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist/server && vite build --ssr src/entry-server-exports.ts --outDir dist/server && node scripts/ssr-pack.mjs",
22+
"build:ssr": "rm -rf dist/server && tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist/server && vite build --ssr src/entry-server-exports.ts --outDir dist/server && node scripts/ssr-pack.mjs",
2323
"lint": "eslint .",
2424
"preview": "NODE_ENV=production node server.mjs",
2525
"test": "node --test"

public/sw.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
// Update this version string manually to keep the app + cache versions in sync.
1616
// The value is forwarded to the UI via the service worker "SW_ACTIVATED" message.
17-
const APP_VERSION = "42.0.0"; // update on release
17+
const APP_VERSION = "42.2.0"; // update on release
1818
const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build
1919
const PREFIX = "PHINETWORK";
2020

@@ -168,6 +168,13 @@ async function warmUrls(urls, { mapShell = false } = {}) {
168168
const url = normalizeWarmUrl(raw);
169169
if (!url) return;
170170

171+
const hasExtension = url.pathname.split("/").pop()?.includes(".");
172+
173+
if (mapShell && shell && !hasExtension) {
174+
await mapShellToRoute(url.href, shell);
175+
return;
176+
}
177+
171178
const cacheName = cacheBucketFor(url);
172179
const req = new Request(url.href, { cache: "reload" });
173180

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1219,7 +1219,7 @@ export function AppChrome(): React.JSX.Element {
12191219
});
12201220

12211221
await Promise.all(
1222-
[...OFFLINE_ASSETS_TO_WARM, ...SHELL_ROUTES_TO_WARM].map(async (url) => {
1222+
[...OFFLINE_ASSETS_TO_WARM, ...APP_SHELL_HINTS].map(async (url) => {
12231223
try {
12241224
await fetch(url, { cache: "no-cache", signal: aborter.signal });
12251225
} catch {

0 commit comments

Comments
 (0)