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
4 changes: 2 additions & 2 deletions examples/with-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
}
},
"devDependencies": {
"@clerk/clerk-js": "^5.102.1",
"react": "catalog:",
"react-dom": "catalog:",
"zudoku": "workspace:*",
"@clerk/clerk-js": "^5.120.0"
"zudoku": "workspace:*"
}
}
4 changes: 2 additions & 2 deletions packages/zudoku/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
"@apidevtools/json-schema-ref-parser": "14.1.1",
"@envelop/core": "5.3.2",
"@graphql-typed-document-node/core": "3.2.0",
"@hono/node-server": "1.19.9",
"@lekoarts/rehype-meta-as-attributes": "3.0.3",
"@mdx-js/react": "3.1.1",
"@mdx-js/rollup": "3.1.0",
Expand Down Expand Up @@ -260,7 +261,6 @@
"dotenv": "^17.2.3",
"embla-carousel-react": "8.6.0",
"estree-util-value-to-estree": "3.4.1",
"express": "5.2.1",
"fast-equals": "5.2.2",
"glob": "13.0.0",
"glob-parent": "6.0.2",
Expand All @@ -270,6 +270,7 @@
"gray-matter": "4.0.3",
"hast-util-to-jsx-runtime": "^2.3.6",
"hast-util-to-string": "3.0.1",
"hono": "4.11.4",
"http-terminator": "3.2.0",
"javascript-stringify": "2.1.0",
"json-schema-to-typescript-lite": "15.0.0",
Expand Down Expand Up @@ -323,7 +324,6 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@types/estree": "1.0.8",
"@types/express": "5.0.6",
"@types/glob-parent": "5.1.3",
"@types/har-format": "1.2.16",
"@types/hast": "^3.0.4",
Expand Down
214 changes: 84 additions & 130 deletions packages/zudoku/src/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { Transform } from "node:stream";
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { HelmetData } from "@zudoku/react-helmet-async";
import type express from "express";
import logger from "loglevel";
import { renderToPipeableStream, renderToStaticMarkup } from "react-dom/server";
import { renderToReadableStream, renderToStaticMarkup } from "react-dom/server";
import {
createStaticHandler,
createStaticRouter,
Expand All @@ -13,52 +11,42 @@ import {
import "vite/modulepreload-polyfill";
import { BootstrapStatic, ServerError } from "zudoku/__internal";
import { NO_DEHYDRATE } from "../lib/components/cache.js";
import type { PrerenderResponse } from "../vite/prerender/PrerenderResponse.js";
import { getRoutesByConfig } from "./main.js";
export { getRoutesByConfig };

export const render = async ({
export const handleRequest = async ({
template,
request: baseRequest,
response,
request,
routes,
basePath,
bypassProtection,
}: {
template: string;
request: express.Request | Request;
response: express.Response | PrerenderResponse;
request: Request;
routes: RouteObject[];
basePath?: string;
bypassProtection?: boolean;
}) => {
}): Promise<Response> => {
const { query, dataRoutes } = createStaticHandler(routes, {
basename: basePath,
});
const queryClient = new QueryClient();

const request =
baseRequest instanceof Request
? baseRequest
: createFetchRequest(baseRequest, response);

const context = await query(request);
let status = 200;

if (context instanceof Response) {
if ([301, 302, 303, 307, 308].includes(context.status)) {
return response.redirect(
import.meta.env.PROD ? context.status : 307,
context.headers.get("Location") ?? "",
);
return new Response(null, {
status: context.status,
headers: { Location: context.headers.get("Location") ?? "" },
});
}

throw context;
} else if (context.errors) {
// when throwing a Response from a loader it will be caught here
// unfortunately it is not `instanceof Response` for some reason
const firstError = Object.values(context.errors).find(isRouteErrorResponse);
}

let status = 200;
if (context.errors) {
const firstError = Object.values(context.errors).find(isRouteErrorResponse);
if (firstError?.status) {
status = firstError.status;
}
Expand All @@ -77,115 +65,81 @@ export const render = async ({
/>
);

const { pipe } = renderToPipeableStream(App, {
onShellError(error) {
response.status(500);
response.set({ "Content-Type": "text/html" });

const html = renderToStaticMarkup(<ServerError error={error} />);

response.send(html);
},
onAllReady() {
response.set({ "Content-Type": "text/html" });
response.status(status);

const transformStream = new Transform({
transform(chunk, encoding, callback) {
response.write(chunk, encoding);
callback();
},
});

const [htmlStart, htmlEnd] = template.split("<!--app-html-->");

if (!htmlStart) {
throw new Error("No <!--app-html--> found in template");
}

response.write(
htmlStart.replace(
"<!--app-helmet-->",
[
helmetContext.helmet.title.toString(),
helmetContext.helmet.meta.toString(),
helmetContext.helmet.link.toString(),
helmetContext.helmet.style.toString(),
helmetContext.helmet.script.toString(),
].join("\n"),
),
);

transformStream.on("finish", () => {
const dehydrated = dehydrate(queryClient, {
shouldDehydrateQuery: (query) =>
!query.queryKey.includes(NO_DEHYDRATE),
});

if (!htmlEnd) return response.end();

const closingTag = "</body>";
const idx = htmlEnd.lastIndexOf(closingTag);

if (idx === -1) return response.end(htmlEnd);

const serialized = JSON.stringify(dehydrated)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e");

response.write(htmlEnd.slice(0, idx));
response.write("<script>window.DATA=");
response.write(serialized);
response.write("</script>");
response.end(htmlEnd.slice(idx));
});

pipe(transformStream);
},
onError(error) {
status = 500;
if (import.meta.env.PROD) {
throw error;
}
logger.error(error);
},
});
};

export function createFetchRequest(
req: express.Request,
res: express.Response | PrerenderResponse,
): Request {
const origin = `${req.protocol}://${req.get("host")}`;
// Note: This had to take originalUrl into account for presumably vite's proxying
const url = new URL(req.originalUrl || req.url, origin);
try {
const reactStream = await renderToReadableStream(App, {
onError(error) {
status = 500;
if (import.meta.env.PROD) {
logger.error("SSR Error:", error);
}
},
});

const controller = new AbortController();
res.on("close", () => controller.abort());
await reactStream.allReady;

const headers = new Headers();
const [htmlStart, htmlEnd] = template.split("<!--app-html-->");
if (!htmlStart) {
throw new Error("No <!--app-html--> found in template");
}

for (const [key, values] of Object.entries(req.headers)) {
if (values) {
if (Array.isArray(values)) {
for (const value of values) {
headers.append(key, value);
const encoder = new TextEncoder();
const reader = reactStream.getReader();

const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const helmetHtml = [
helmetContext.helmet?.title?.toString() ?? "",
helmetContext.helmet?.meta?.toString() ?? "",
helmetContext.helmet?.link?.toString() ?? "",
helmetContext.helmet?.style?.toString() ?? "",
helmetContext.helmet?.script?.toString() ?? "",
].join("\n");

controller.enqueue(
encoder.encode(htmlStart.replace("<!--app-helmet-->", helmetHtml)),
);

while (true) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(value);
}
} else {
headers.set(key, values);
}
}
}

const init: RequestInit = {
method: req.method,
headers,
signal: controller.signal,
};
if (htmlEnd) {
const dehydrated = dehydrate(queryClient, {
shouldDehydrateQuery: (q) => !q.queryKey.includes(NO_DEHYDRATE),
});
const serialized = JSON.stringify(dehydrated)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e");

const closingTag = "</body>";
const idx = htmlEnd.lastIndexOf(closingTag);

if (idx === -1) {
controller.enqueue(encoder.encode(htmlEnd));
} else {
controller.enqueue(encoder.encode(htmlEnd.slice(0, idx)));
controller.enqueue(
encoder.encode(`<script>window.DATA=${serialized}</script>`),
);
controller.enqueue(encoder.encode(htmlEnd.slice(idx)));
}
}

if (req.method !== "GET" && req.method !== "HEAD") {
init.body = req.body;
controller.close();
},
});

return new Response(stream, {
status,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
} catch (error) {
const html = renderToStaticMarkup(<ServerError error={error} />);
return new Response(html, {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}

return new Request(url.href, init);
}
};
6 changes: 5 additions & 1 deletion packages/zudoku/src/cli/build/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export async function build(argv: Arguments) {

const dir = path.resolve(process.cwd(), argv.dir);
try {
await runBuild({ dir });
await runBuild({
dir,
ssr: argv.ssr,
adapter: argv.adapter as "node" | "cloudflare" | "vercel" | undefined,
});
} catch (error) {
logger.error("❌ Build failed");
logger.error(error instanceof Error ? error.message : String(error));
Expand Down
13 changes: 13 additions & 0 deletions packages/zudoku/src/cli/cmds/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { DEFAULT_PREVIEW_PORT } from "../preview/handler.js";
export type Arguments = {
dir: string;
preview?: boolean | number;
ssr?: boolean;
adapter?: string;
};

export default {
Expand All @@ -28,6 +30,17 @@ export default {
if (value === true) return DEFAULT_PREVIEW_PORT;
return undefined;
},
})
.option("ssr", {
type: "boolean",
describe: "Build for server-side rendering",
default: false,
})
.option("adapter", {
type: "string",
describe: "SSR adapter (node, cloudflare, vercel)",
choices: ["node", "cloudflare", "vercel"] as const,
default: "node",
}),
handler: async (argv: Arguments) => {
process.env.NODE_ENV = "production";
Expand Down
Loading