mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 14:01:42 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
handleUpload: vi.fn(),
|
||||
handleMcp: vi.fn(),
|
||||
handleResumePdfDownload: vi.fn(),
|
||||
handlePublicResumePdf: vi.fn(),
|
||||
handleMcpServerCard: vi.fn(),
|
||||
handleOAuthAuthorizationServer: vi.fn(),
|
||||
handleOAuthProtectedResource: vi.fn(),
|
||||
@@ -69,6 +70,15 @@ vi.mock("./resume-pdf", () => ({
|
||||
handleResumePdfDownload: mocks.handleResumePdfDownload,
|
||||
}));
|
||||
|
||||
vi.mock("./public-resume-pdf", () => ({
|
||||
handlePublicResumePdf: mocks.handlePublicResumePdf,
|
||||
}));
|
||||
|
||||
const transportEnv = (remoteAddress: string) =>
|
||||
({
|
||||
incoming: { socket: { remoteAddress } },
|
||||
}) as never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||
@@ -79,6 +89,7 @@ beforeEach(() => {
|
||||
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
|
||||
mocks.handlePublicResumePdf.mockResolvedValue(new Response("public-pdf"));
|
||||
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||
@@ -117,6 +128,51 @@ describe("createApp", () => {
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const first = new Request("http://localhost:3001/api/resumes/jane/resume/pdf?reason=render-data-hash", {
|
||||
headers: { "x-forwarded-for": "198.51.100.1" },
|
||||
});
|
||||
const rotated = new Request("http://localhost:3001/api/resumes/jane/resume/pdf?reason=render-data-hash", {
|
||||
headers: { "x-forwarded-for": "198.51.100.2" },
|
||||
});
|
||||
const env = transportEnv("203.0.113.9");
|
||||
|
||||
const response = await app.fetch(first, env);
|
||||
await app.fetch(rotated, env);
|
||||
|
||||
await expect(response.text()).resolves.toBe("public-pdf");
|
||||
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(1, first, "jane", "resume", "203.0.113.9");
|
||||
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(2, rotated, "jane", "resume", "203.0.113.9");
|
||||
expect(mocks.handleResumePdfDownload).not.toHaveBeenCalled();
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the transport address to RPC and OpenAPI and fails closed when it is unavailable", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const trustedRpcRequest = new Request("http://localhost:3001/api/rpc", {
|
||||
headers: { "cf-connecting-ip": "198.51.100.1" },
|
||||
});
|
||||
const unknownRpcRequest = new Request("http://localhost:3001/api/rpc", {
|
||||
headers: { "cf-connecting-ip": "198.51.100.2" },
|
||||
});
|
||||
const trustedOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume/style-projection");
|
||||
const unknownOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume/style-projection");
|
||||
|
||||
await app.fetch(trustedRpcRequest, transportEnv("203.0.113.9"));
|
||||
await app.fetch(unknownRpcRequest);
|
||||
await app.fetch(trustedOpenApiRequest, transportEnv("203.0.113.9"));
|
||||
await app.fetch(unknownOpenApiRequest);
|
||||
|
||||
expect(mocks.handleRpc).toHaveBeenNthCalledWith(1, trustedRpcRequest, "203.0.113.9");
|
||||
expect(mocks.handleRpc).toHaveBeenNthCalledWith(2, unknownRpcRequest, "unknown");
|
||||
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(1, trustedOpenApiRequest, "203.0.113.9");
|
||||
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||
import type { Context } from "hono";
|
||||
import { isIP } from "node:net";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { Hono } from "hono";
|
||||
import { handleMcp } from "../mcp/handler";
|
||||
import { handleOpenApi } from "../openapi/handler";
|
||||
@@ -15,18 +19,33 @@ import { handleUpload } from "../static/uploads";
|
||||
import { handleWebApp, serveWebDistStatic } from "../static/web";
|
||||
import { handleAuth, handleOAuth } from "./auth";
|
||||
import { handleHealth } from "./health";
|
||||
import { handlePublicResumePdf } from "./public-resume-pdf";
|
||||
import { handleResumePdfDownload } from "./resume-pdf";
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
|
||||
|
||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw));
|
||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw));
|
||||
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
|
||||
try {
|
||||
const address = getConnInfo(context).remote.address?.trim();
|
||||
return address && isIP(address) ? address : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono<ServerEnvironment>();
|
||||
|
||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
|
||||
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
||||
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
||||
app.get("/api/health", () => handleHealth());
|
||||
app.get("/api/resumes/:username/:slug/pdf", (c) =>
|
||||
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), getTrustedClient(c)),
|
||||
);
|
||||
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
|
||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createPublicResumePdf: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/public-pdf", () => ({
|
||||
createPublicResumePdf: mocks.createPublicResumePdf,
|
||||
PUBLIC_RESUME_PDF_MISMATCH_REASONS: [
|
||||
"missing-projection",
|
||||
"format-version",
|
||||
"language-version",
|
||||
"semantic-tree-version",
|
||||
"registry-fingerprint",
|
||||
"adapter-fingerprint",
|
||||
"render-data-hash",
|
||||
"invalid-projection",
|
||||
],
|
||||
}));
|
||||
|
||||
const { handlePublicResumePdf } = await import("./public-resume-pdf");
|
||||
const trustedClient = "203.0.113.9";
|
||||
|
||||
describe("handlePublicResumePdf", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns the authorized fallback PDF with strict mismatch metadata and cache policy", async () => {
|
||||
const body = new File(["%PDF"], "Ada_Lovelace.pdf", { type: "text/plain" });
|
||||
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
||||
body,
|
||||
filename: "Ada_Lovelace.pdf",
|
||||
});
|
||||
const registry = "0".repeat(64);
|
||||
const adapter = "1".repeat(64);
|
||||
const request = new Request(
|
||||
`https://example.com/api/resumes/jane/resume/pdf?reason=render-data-hash®istryFingerprint=${registry}&adapterFingerprint=${adapter}`,
|
||||
{ headers: { "x-forwarded-for": "203.0.113.7" } },
|
||||
);
|
||||
|
||||
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(response.headers.get("Content-Disposition")).toBe('inline; filename="Ada_Lovelace.pdf"');
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(await response.text()).toBe("%PDF");
|
||||
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
|
||||
username: "jane",
|
||||
slug: "resume",
|
||||
requestHeaders: request.headers,
|
||||
trustedClient,
|
||||
mismatchReason: "render-data-hash",
|
||||
clientRegistryFingerprint: registry,
|
||||
clientAdapterFingerprint: adapter,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a missing mismatch reason and keeps password/private responses uncacheable", async () => {
|
||||
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
||||
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
|
||||
filename: "resume.pdf",
|
||||
});
|
||||
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
|
||||
|
||||
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mismatchReason: "missing-projection" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ code: "BAD_REQUEST" }, 400],
|
||||
[{ code: "NEED_PASSWORD" }, 401],
|
||||
[{ code: "NOT_FOUND" }, 404],
|
||||
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
|
||||
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
|
||||
])("maps controlled API errors without caching the response", async (error, status) => {
|
||||
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
|
||||
|
||||
const response = await handlePublicResumePdf(
|
||||
new Request("https://example.com/api/resumes/jane/resume/pdf"),
|
||||
"jane",
|
||||
"resume",
|
||||
trustedClient,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(status);
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
});
|
||||
|
||||
it.each(["?reason=private-source", "?registryFingerprint=unsafe", "?adapterFingerprint=unsafe"])(
|
||||
"rejects invalid fallback metadata before the API service",
|
||||
async (search) => {
|
||||
const response = await handlePublicResumePdf(
|
||||
new Request(`https://example.com/api/resumes/jane/resume/pdf${search}`),
|
||||
"jane",
|
||||
"resume",
|
||||
trustedClient,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(mocks.createPublicResumePdf).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PublicResumePdfMismatchReason } from "@reactive-resume/api/features/resume/public-pdf";
|
||||
import {
|
||||
createPublicResumePdf,
|
||||
PUBLIC_RESUME_PDF_MISMATCH_REASONS,
|
||||
} from "@reactive-resume/api/features/resume/public-pdf";
|
||||
|
||||
const noStoreResponse = (body: string, status: number) =>
|
||||
new Response(body, { status, headers: { "Cache-Control": "private, no-store" } });
|
||||
|
||||
const errorStatus = (error: unknown): number => {
|
||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||
if (code === "BAD_REQUEST") return 400;
|
||||
if (code === "NEED_PASSWORD") return 401;
|
||||
if (code === "NOT_FOUND") return 404;
|
||||
if (code === "RATE_LIMIT_EXCEEDED") return 429;
|
||||
return 500;
|
||||
};
|
||||
|
||||
const fingerprint = (value: string | null): string | undefined => {
|
||||
if (value === null) return;
|
||||
return /^[a-f0-9]{64}$/.test(value) ? value : undefined;
|
||||
};
|
||||
|
||||
export async function handlePublicResumePdf(
|
||||
request: Request,
|
||||
username: string,
|
||||
slug: string,
|
||||
trustedClient = "unknown",
|
||||
): Promise<Response> {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const rawReason = searchParams.get("reason") ?? "missing-projection";
|
||||
if (!PUBLIC_RESUME_PDF_MISMATCH_REASONS.includes(rawReason as PublicResumePdfMismatchReason)) {
|
||||
return noStoreResponse("Invalid fallback metadata", 400);
|
||||
}
|
||||
const rawRegistryFingerprint = searchParams.get("registryFingerprint");
|
||||
const rawAdapterFingerprint = searchParams.get("adapterFingerprint");
|
||||
const clientRegistryFingerprint = fingerprint(rawRegistryFingerprint);
|
||||
const clientAdapterFingerprint = fingerprint(rawAdapterFingerprint);
|
||||
if (
|
||||
(rawRegistryFingerprint !== null && clientRegistryFingerprint === undefined) ||
|
||||
(rawAdapterFingerprint !== null && clientAdapterFingerprint === undefined)
|
||||
) {
|
||||
return noStoreResponse("Invalid fallback metadata", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createPublicResumePdf({
|
||||
username,
|
||||
slug,
|
||||
requestHeaders: request.headers,
|
||||
trustedClient,
|
||||
mismatchReason: rawReason as PublicResumePdfMismatchReason,
|
||||
...(clientRegistryFingerprint ? { clientRegistryFingerprint } : {}),
|
||||
...(clientAdapterFingerprint ? { clientAdapterFingerprint } : {}),
|
||||
});
|
||||
|
||||
return new Response(result.body, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${result.filename.replaceAll('"', "")}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const status = errorStatus(error);
|
||||
return noStoreResponse(
|
||||
status === 500 ? "Failed to generate public resume PDF" : "Public resume PDF unavailable",
|
||||
status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { generateOpenApiDocumentation } from "./generate-spec";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const committedSpec = new URL("../../../../docs/spec.json", import.meta.url);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("keeps the committed OpenAPI specification synchronized without rewriting it", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "openapi-documentation-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const firstTarget = join(directory, "first.json");
|
||||
const secondTarget = join(directory, "second.json");
|
||||
const before = await readFile(committedSpec, "utf8");
|
||||
|
||||
await generateOpenApiDocumentation(firstTarget);
|
||||
await generateOpenApiDocumentation(secondTarget);
|
||||
|
||||
expect(await readFile(firstTarget, "utf8")).toBe(before);
|
||||
expect(await readFile(secondTarget, "utf8")).toBe(before);
|
||||
expect(await readFile(committedSpec, "utf8")).toBe(before);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export async function generateOpenApiDocumentation(
|
||||
target = fileURLToPath(new URL("../../../../docs/spec.json", import.meta.url)),
|
||||
) {
|
||||
const packageJson = JSON.parse(await readFile(new URL("../../../../package.json", import.meta.url), "utf8")) as {
|
||||
version: string;
|
||||
};
|
||||
process.env.APP_URL ??= "https://rxresu.me";
|
||||
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_docs";
|
||||
process.env.AUTH_SECRET ??= "documentation-generation-isolated-process-only";
|
||||
const { generateOpenApiSpec } = await import("./generator");
|
||||
const spec = await generateOpenApiSpec({ appUrl: "https://rxresu.me", version: packageJson.version });
|
||||
await writeFile(target, `${JSON.stringify(spec, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await generateOpenApiDocumentation(process.argv[2]);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import z from "zod";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
|
||||
type GeneratedSpecView = {
|
||||
components?: { schemas?: Record<string, unknown> };
|
||||
paths?: Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
requestBody?: {
|
||||
content?: Record<string, { schema?: unknown }>;
|
||||
};
|
||||
}
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
async function generateSpec() {
|
||||
process.env.APP_URL ??= "https://rxresu.me";
|
||||
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_test";
|
||||
process.env.AUTH_SECRET ??= "openapi-generator-test-process-only";
|
||||
const { generateOpenApiSpec } = await import("./generator");
|
||||
return generateOpenApiSpec({
|
||||
appUrl: "https://rxresu.me",
|
||||
version: "9.8.7",
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
|
||||
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
|
||||
}
|
||||
|
||||
function containsImpossibleSchema(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(containsImpossibleSchema);
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const object = value as Record<string, unknown>;
|
||||
const negated = object.not;
|
||||
if (typeof negated === "object" && negated !== null && Object.keys(negated).length === 0) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(object).some(containsImpossibleSchema);
|
||||
}
|
||||
|
||||
function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
|
||||
const impossibleRequests: string[] = [];
|
||||
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(operations)) {
|
||||
for (const [mediaType, content] of Object.entries(operation.requestBody?.content ?? {})) {
|
||||
if (containsImpossibleSchema(content.schema)) {
|
||||
impossibleRequests.push(`${method.toUpperCase()} ${path} (${mediaType})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return impossibleRequests;
|
||||
}
|
||||
|
||||
describe("generateOpenApiSpec", () => {
|
||||
it("uses caller-provided application URL and version", async () => {
|
||||
const spec = await generateSpec();
|
||||
|
||||
expect(spec.info).toMatchObject({
|
||||
title: "Reactive Resume",
|
||||
version: "9.8.7",
|
||||
});
|
||||
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
|
||||
expect(spec.externalDocs).toEqual({
|
||||
url: "https://docs.rxresu.me",
|
||||
description: "Reactive Resume Documentation",
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("uses the canonical input-side ResumeData schema in update requests", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
|
||||
|
||||
expect(spec.components?.schemas?.ResumeData).toEqual(canonicalInputSchema);
|
||||
expect(getRequestSchema(spec, "/resumes/{id}", "put")).toMatchObject({
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the custom-section type and item correlation", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
|
||||
const mismatched = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(schema.safeParse(mismatched).success).toBe(false);
|
||||
});
|
||||
|
||||
it("does not publish impossible request schemas", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks every request body media type for impossible schemas", () => {
|
||||
const spec: GeneratedSpecView = {
|
||||
paths: {
|
||||
"/documents": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: { type: "object" } },
|
||||
"multipart/form-data": { schema: { not: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
|
||||
});
|
||||
|
||||
it("documents imported data as an accepted ResumeData input", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
required: ["data"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
|
||||
export const openAPIRouter = {
|
||||
...router,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
},
|
||||
};
|
||||
|
||||
const { $schema: _dialect, ...resumeDataInputSchema } = createResumeDataJsonSchema();
|
||||
type ResumeDataInputJsonSchema = Parameters<typeof JSON_SCHEMA_INPUT_REGISTRY.add<typeof resumeDataSchema>>[1];
|
||||
JSON_SCHEMA_INPUT_REGISTRY.add(resumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
|
||||
const importResumeInputSchema = openAPIRouter.resume.import["~orpc"].inputSchema;
|
||||
if (importResumeInputSchema) {
|
||||
JSON_SCHEMA_INPUT_REGISTRY.add(importResumeInputSchema, {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
required: ["data"],
|
||||
});
|
||||
}
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [
|
||||
new ZodToJsonSchemaConverter({
|
||||
interceptors: [
|
||||
({ options, next }) => {
|
||||
const [required, schema] = next();
|
||||
const impossible =
|
||||
Object.keys(schema).length === 1 &&
|
||||
typeof schema.not === "object" &&
|
||||
schema.not !== null &&
|
||||
Object.keys(schema.not).length === 0;
|
||||
return options.strategy === "input" && impossible ? [required, {}] : [required, schema];
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
type GenerateOpenApiSpecOptions = {
|
||||
appUrl: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
|
||||
return await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${appUrl}/api/openapi` }],
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema, strategy: "input" },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header",
|
||||
description: "The API key to authenticate requests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { onError } from "@orpc/server";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { appVersion } from "../app-version";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
const openAPIRouter = {
|
||||
...router,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
},
|
||||
};
|
||||
import { generateOpenApiSpec, openAPIRouter } from "./generator";
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
plugins: [
|
||||
@@ -36,46 +25,15 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
],
|
||||
});
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
});
|
||||
|
||||
export async function handleOpenApi(request: Request) {
|
||||
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
|
||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version: appVersion,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${env.APP_URL}/api/openapi` }],
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header",
|
||||
description: "The API key to authenticate requests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
|
||||
return Response.json(spec);
|
||||
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
|
||||
}
|
||||
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await openAPIHandler.handle(request, {
|
||||
prefix: "/api/openapi",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { stylesheetPreflightRunner } from "../services/stylesheet-preflight";
|
||||
import { getRequestLocale } from "./locale";
|
||||
|
||||
const rpcHandler = new RPCHandler(router, {
|
||||
@@ -14,11 +15,17 @@ const rpcHandler = new RPCHandler(router, {
|
||||
],
|
||||
});
|
||||
|
||||
export async function handleRpc(request: Request) {
|
||||
export async function handleRpc(request: Request, trustedClient = "unknown") {
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
context: {
|
||||
locale: getRequestLocale(request),
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
trustedClient,
|
||||
stylesheetPreflightRunner,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createStylesheetPreflightRunner, STYLESHEET_PREFLIGHT_LIMITS } from "./stylesheet-preflight";
|
||||
|
||||
const validStylesheet = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;",
|
||||
} as const;
|
||||
|
||||
const input = {
|
||||
data: defaultResumeData,
|
||||
template: defaultResumeData.metadata.template,
|
||||
stylesheet: validStylesheet,
|
||||
} as const;
|
||||
|
||||
const memoryExhaustionWorker = new URL(
|
||||
`data:text/javascript,${encodeURIComponent(`
|
||||
const retained = [];
|
||||
while (true) {
|
||||
const batch = Array.from({ length: 100_000 }, (_, index) => ({ batch: retained.length, index }));
|
||||
retained.push(batch);
|
||||
}
|
||||
`)}`,
|
||||
);
|
||||
|
||||
const failedWorker = new URL(
|
||||
`data:text/javascript,${encodeURIComponent('throw new Error("sensitive worker details");')}`,
|
||||
);
|
||||
|
||||
const delayedSuccessfulWorker = new URL(
|
||||
`data:text/javascript,${encodeURIComponent(`
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
parentPort.postMessage({ type: "ready" });
|
||||
setTimeout(() => {
|
||||
parentPort.postMessage({
|
||||
ok: true,
|
||||
pageCount: 1,
|
||||
byteCount: Number(workerData.input.data.basics.name),
|
||||
diagnostics: [],
|
||||
});
|
||||
}, 300);
|
||||
`)}`,
|
||||
);
|
||||
|
||||
const delayedReadyWorker = new URL(
|
||||
`data:text/javascript,${encodeURIComponent(`
|
||||
import { parentPort } from "node:worker_threads";
|
||||
setTimeout(() => {
|
||||
parentPort.postMessage({ type: "ready" });
|
||||
setTimeout(() => {
|
||||
parentPort.postMessage({
|
||||
ok: true,
|
||||
pageCount: 1,
|
||||
byteCount: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
}, 10);
|
||||
}, 50);
|
||||
`)}`,
|
||||
);
|
||||
|
||||
const neverCompletesWorker = new URL(
|
||||
`data:text/javascript,${encodeURIComponent(`
|
||||
import { parentPort } from "node:worker_threads";
|
||||
parentPort.postMessage({ type: "ready" });
|
||||
setInterval(() => {}, 1_000);
|
||||
`)}`,
|
||||
);
|
||||
|
||||
const synchronousFailureWorker = new URL("https://example.com/stylesheet-preflight.mjs");
|
||||
|
||||
const numberedInput = (number: number) => ({
|
||||
...input,
|
||||
data: {
|
||||
...input.data,
|
||||
basics: {
|
||||
...input.data.basics,
|
||||
name: String(number),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const invalidInput = () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.customSections = [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-shaped-item", hidden: false, content: "<p>Missing company</p>" }],
|
||||
} as never,
|
||||
];
|
||||
return { ...input, data };
|
||||
};
|
||||
|
||||
describe("stylesheet PDF preflight worker", () => {
|
||||
it("keeps the production resource policy fixed and immutable", () => {
|
||||
expect(STYLESHEET_PREFLIGHT_LIMITS).toEqual({
|
||||
timeoutMs: 5_000,
|
||||
maxPages: 20,
|
||||
maxBytes: 10_000_000,
|
||||
maxPageWidthPt: 2_000,
|
||||
maxPageHeightPt: 20_000,
|
||||
maxPageAreaPt2: 20_000_000,
|
||||
maxOldGenerationMb: 256,
|
||||
maxConcurrentWorkers: 1,
|
||||
maxQueuedRequests: 32,
|
||||
});
|
||||
expect(Object.isFrozen(STYLESHEET_PREFLIGHT_LIMITS)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a bounded candidate render in an isolated worker", async () => {
|
||||
const runner = createStylesheetPreflightRunner({ timeoutMs: 15_000 });
|
||||
|
||||
const result = await runner.run(input);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
pageCount: 1,
|
||||
byteCount: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw new Error(`Expected successful preflight, received ${result.code}.`);
|
||||
expect(result.byteCount).toBeGreaterThan(0);
|
||||
expect(runner.activeWorkerCount).toBe(0);
|
||||
}, 45_000);
|
||||
|
||||
it("preserves structured resume-data failures across the worker boundary", async () => {
|
||||
const runner = createStylesheetPreflightRunner({ timeoutMs: 15_000 });
|
||||
|
||||
const result = runner.run(invalidInput());
|
||||
|
||||
await expect(result).rejects.toMatchObject({
|
||||
name: "ZodError",
|
||||
issues: expect.arrayContaining([expect.objectContaining({ path: ["customSections", 0, "items", 0, "company"] })]),
|
||||
});
|
||||
expect(runner.activeWorkerCount).toBe(0);
|
||||
expect(runner.queuedPreflightCount).toBe(0);
|
||||
}, 20_000);
|
||||
|
||||
it("terminates a worker when the render exceeds its deadline", async () => {
|
||||
const runner = createStylesheetPreflightRunner({ timeoutMs: 1 }, neverCompletesWorker);
|
||||
|
||||
const result = await runner.run(input);
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" }));
|
||||
expect(runner.activeWorkerCount).toBe(0);
|
||||
expect(runner.queuedPreflightCount).toBe(0);
|
||||
});
|
||||
|
||||
it("starts the authored render deadline after the worker runtime is ready", async () => {
|
||||
const runner = createStylesheetPreflightRunner({ timeoutMs: 20 }, delayedReadyWorker);
|
||||
|
||||
await expect(runner.run(input)).resolves.toEqual(expect.objectContaining({ ok: true, pageCount: 1 }));
|
||||
expect(runner.activeWorkerCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns deterministic output byte and page limit codes", async () => {
|
||||
const byteRunner = createStylesheetPreflightRunner({ maxBytes: 16 });
|
||||
const pageRunner = createStylesheetPreflightRunner({ maxPages: 0 });
|
||||
|
||||
await expect(byteRunner.run(input)).resolves.toEqual(
|
||||
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT" }),
|
||||
);
|
||||
await expect(pageRunner.run(input)).resolves.toEqual(
|
||||
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_PAGE_LIMIT" }),
|
||||
);
|
||||
expect(byteRunner.activeWorkerCount).toBe(0);
|
||||
expect(pageRunner.activeWorkerCount).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
it("maps worker heap exhaustion to a controlled memory-limit result", async () => {
|
||||
const runner = createStylesheetPreflightRunner(
|
||||
{ maxOldGenerationMb: 8, timeoutMs: 10_000 },
|
||||
memoryExhaustionWorker,
|
||||
);
|
||||
|
||||
const result = await runner.run(input);
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_MEMORY_LIMIT" }));
|
||||
expect(runner.activeWorkerCount).toBe(0);
|
||||
}, 15_000);
|
||||
|
||||
it("does not expose internal errors from a failed worker", async () => {
|
||||
const runner = createStylesheetPreflightRunner({}, failedWorker);
|
||||
|
||||
const result = await runner.run(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||
message: "The PDF preflight worker failed.",
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(runner.activeWorkerCount).toBe(0);
|
||||
expect(runner.queuedPreflightCount).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds concurrent workers and queued requests without charging queue time to the worker deadline", async () => {
|
||||
const runner = createStylesheetPreflightRunner(
|
||||
{
|
||||
timeoutMs: 500,
|
||||
maxConcurrentWorkers: 1,
|
||||
maxQueuedRequests: 2,
|
||||
},
|
||||
delayedSuccessfulWorker,
|
||||
);
|
||||
const completionOrder: number[] = [];
|
||||
const accepted = [1, 2, 3].map((number) =>
|
||||
runner.run(numberedInput(number)).then((result) => {
|
||||
if (result.ok) completionOrder.push(result.byteCount);
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
const rejected = runner.run(numberedInput(4));
|
||||
|
||||
expect(runner.activeWorkerCount).toBe(1);
|
||||
expect(runner.queuedPreflightCount).toBe(2);
|
||||
await expect(rejected).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||
message: "The PDF preflight queue is full.",
|
||||
}),
|
||||
);
|
||||
const results = await Promise.all(accepted);
|
||||
|
||||
expect(results.every((result) => result.ok)).toBe(true);
|
||||
expect(completionOrder).toEqual([1, 2, 3]);
|
||||
expect(runner.activeWorkerCount).toBe(0);
|
||||
expect(runner.queuedPreflightCount).toBe(0);
|
||||
}, 5_000);
|
||||
|
||||
it("does not leak a slot when the worker constructor throws synchronously", async () => {
|
||||
const runner = createStylesheetPreflightRunner({}, synchronousFailureWorker);
|
||||
|
||||
await expect(runner.run(input)).resolves.toEqual(
|
||||
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_WORKER_FAILED" }),
|
||||
);
|
||||
expect(runner.activeWorkerCount).toBe(0);
|
||||
expect(runner.queuedPreflightCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import type {
|
||||
PdfPreflightFailure,
|
||||
PdfPreflightResult,
|
||||
StylesheetPreflightInput,
|
||||
StylesheetPreflightRunner,
|
||||
} from "@reactive-resume/pdf/server";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { STYLESHEET_PREFLIGHT_LIMITS } from "@reactive-resume/pdf/preflight-reference";
|
||||
|
||||
export { STYLESHEET_PREFLIGHT_LIMITS } from "@reactive-resume/pdf/preflight-reference";
|
||||
|
||||
type StylesheetPreflightLimits = {
|
||||
timeoutMs: number;
|
||||
maxPages: number;
|
||||
maxBytes: number;
|
||||
maxPageWidthPt: number;
|
||||
maxPageHeightPt: number;
|
||||
maxPageAreaPt2: number;
|
||||
maxOldGenerationMb: number;
|
||||
maxConcurrentWorkers: number;
|
||||
maxQueuedRequests: number;
|
||||
};
|
||||
|
||||
const SOURCE_WORKER_LOADER_HEAP_MB = 256;
|
||||
const SOURCE_WORKER_STARTUP_TIMEOUT_MS = 30_000;
|
||||
const WORKER_STARTUP_TIMEOUT_MS = 15_000;
|
||||
|
||||
type SerializedPreflightCause = {
|
||||
name: string;
|
||||
message: string;
|
||||
issues: readonly unknown[];
|
||||
};
|
||||
|
||||
type StylesheetPreflightWorkerMessage =
|
||||
| PdfPreflightResult
|
||||
| { type: "ready" }
|
||||
| { type: "preflight_error"; cause: SerializedPreflightCause };
|
||||
|
||||
export type NodeStylesheetPreflightRunner = StylesheetPreflightRunner & {
|
||||
readonly activeWorkerCount: number;
|
||||
readonly queuedPreflightCount: number;
|
||||
};
|
||||
|
||||
const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const workerFailure = (error: Error): PdfPreflightFailure => {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === "ERR_WORKER_OUT_OF_MEMORY" || /heap out of memory/i.test(error.message)
|
||||
? failure("STYLESHEET_PREFLIGHT_MEMORY_LIMIT", "The PDF preflight worker exceeded its memory limit.")
|
||||
: failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.");
|
||||
};
|
||||
|
||||
const sourceWorkerExecArgv = () => {
|
||||
const importIndex = process.execArgv.findIndex(
|
||||
(argument, index, arguments_) =>
|
||||
(argument === "--import" && arguments_[index + 1]?.includes("tsx")) ||
|
||||
(argument.startsWith("--import=") && argument.includes("tsx")),
|
||||
);
|
||||
const inherited = process.execArgv[importIndex];
|
||||
if (inherited?.startsWith("--import=")) return [inherited];
|
||||
if (inherited === "--import") return [inherited, process.execArgv[importIndex + 1] as string];
|
||||
return ["--import", import.meta.resolve("tsx")];
|
||||
};
|
||||
|
||||
const workerLocation = () => {
|
||||
const source = import.meta.url.endsWith(".ts");
|
||||
return {
|
||||
source,
|
||||
url: source
|
||||
? new URL("../workers/stylesheet-preflight.ts", import.meta.url)
|
||||
: new URL("./stylesheet-preflight-worker.mjs", import.meta.url),
|
||||
...(source
|
||||
? {
|
||||
execArgv: sourceWorkerExecArgv(),
|
||||
env: {
|
||||
...process.env,
|
||||
TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export function createStylesheetPreflightRunner(
|
||||
overrides: Partial<StylesheetPreflightLimits> = {},
|
||||
testWorkerUrl?: URL,
|
||||
): NodeStylesheetPreflightRunner {
|
||||
const limits = Object.freeze({ ...STYLESHEET_PREFLIGHT_LIMITS, ...overrides });
|
||||
let activeWorkerCount = 0;
|
||||
// ponytail: Keep admission process-local and bounded; upgrade to a distributed/pooled queue only for multi-process coordination.
|
||||
const queue: Array<{
|
||||
input: StylesheetPreflightInput;
|
||||
resolve: (result: PdfPreflightResult) => void;
|
||||
reject: (cause: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
const runWorker = (
|
||||
input: StylesheetPreflightInput,
|
||||
resolve: (result: PdfPreflightResult) => void,
|
||||
reject: (cause: unknown) => void,
|
||||
): boolean => {
|
||||
// The URL seam is internal to the server package and keeps worker failure tests independent from the PDF renderer.
|
||||
const location = testWorkerUrl ? { source: false, url: testWorkerUrl } : workerLocation();
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(location.url, {
|
||||
name: "stylesheet-preflight",
|
||||
workerData: { input, limits },
|
||||
resourceLimits: {
|
||||
// The source-only tsx compiler heap is outside the production render budget.
|
||||
maxOldGenerationSizeMb: limits.maxOldGenerationMb + (location.source ? SOURCE_WORKER_LOADER_HEAP_MB : 0),
|
||||
},
|
||||
...("execArgv" in location ? { execArgv: location.execArgv } : {}),
|
||||
...("env" in location ? { env: location.env } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
resolve(workerFailure(error instanceof Error ? error : new Error("Failed to start PDF preflight worker.")));
|
||||
return false;
|
||||
}
|
||||
|
||||
activeWorkerCount += 1;
|
||||
let settled = false;
|
||||
let renderTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (renderTimer) clearTimeout(renderTimer);
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
worker.off("message", onMessage);
|
||||
worker.off("error", onError);
|
||||
worker.off("exit", onExit);
|
||||
activeWorkerCount -= 1;
|
||||
};
|
||||
|
||||
const finish = async (result: PdfPreflightResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
await worker.terminate().catch(() => undefined);
|
||||
cleanup();
|
||||
resolve(result);
|
||||
drainQueue();
|
||||
};
|
||||
const fail = async (cause: SerializedPreflightCause) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
await worker.terminate().catch(() => undefined);
|
||||
cleanup();
|
||||
reject(Object.assign(new Error(cause.message), { name: cause.name, issues: cause.issues }));
|
||||
drainQueue();
|
||||
};
|
||||
|
||||
const onMessage = (message: StylesheetPreflightWorkerMessage) => {
|
||||
if ("type" in message) {
|
||||
if (message.type === "preflight_error") {
|
||||
void fail(message.cause);
|
||||
return;
|
||||
}
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
renderTimer = setTimeout(() => {
|
||||
void finish(failure("STYLESHEET_PREFLIGHT_TIMEOUT", "The PDF preflight exceeded its deadline."));
|
||||
}, limits.timeoutMs);
|
||||
return;
|
||||
}
|
||||
void finish(message);
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
void finish(workerFailure(error));
|
||||
};
|
||||
const onExit = () => {
|
||||
if (!settled) {
|
||||
void finish(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed."));
|
||||
}
|
||||
};
|
||||
|
||||
worker.on("message", onMessage);
|
||||
worker.once("error", onError);
|
||||
worker.once("exit", onExit);
|
||||
startupTimer = setTimeout(
|
||||
() => {
|
||||
void finish(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed."));
|
||||
},
|
||||
location.source ? SOURCE_WORKER_STARTUP_TIMEOUT_MS : WORKER_STARTUP_TIMEOUT_MS,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
function drainQueue() {
|
||||
while (activeWorkerCount < limits.maxConcurrentWorkers && queue.length > 0) {
|
||||
const next = queue.shift();
|
||||
if (!next) return;
|
||||
runWorker(next.input, next.resolve, next.reject);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get activeWorkerCount() {
|
||||
return activeWorkerCount;
|
||||
},
|
||||
get queuedPreflightCount() {
|
||||
return queue.length;
|
||||
},
|
||||
|
||||
run(input: StylesheetPreflightInput): Promise<PdfPreflightResult> {
|
||||
return new Promise<PdfPreflightResult>((resolve, reject) => {
|
||||
if (activeWorkerCount < limits.maxConcurrentWorkers) {
|
||||
runWorker(input, resolve, reject);
|
||||
return;
|
||||
}
|
||||
if (queue.length >= limits.maxQueuedRequests) {
|
||||
resolve(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight queue is full."));
|
||||
return;
|
||||
}
|
||||
queue.push({ input, resolve, reject });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const stylesheetPreflightRunner = createStylesheetPreflightRunner();
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import z from "zod";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { handleSchemaJson } from "./schema";
|
||||
|
||||
describe("handleSchemaJson", () => {
|
||||
it("publishes the custom-section type and item correlation", async () => {
|
||||
const response = handleSchemaJson();
|
||||
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
|
||||
const mismatched = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(schema.safeParse(mismatched).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
export function handleSchemaJson() {
|
||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
||||
|
||||
return Response.json(resumeDataJSONSchema, {
|
||||
return Response.json(createResumeDataJsonSchema(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/schema+json; charset=utf-8",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { PdfPreflightPageLimits, PdfPreflightResult, RenderPreflightPdfResult } from "@reactive-resume/pdf/server";
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
|
||||
type StylesheetPreflightInspectionLimits = PdfPreflightPageLimits & {
|
||||
maxPages: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
type PdfLoadingTask = {
|
||||
promise: PromiseLike<{ numPages: number }>;
|
||||
destroy(): Promise<void>;
|
||||
};
|
||||
|
||||
type LoadPdf = (options: { data: Uint8Array }) => PdfLoadingTask;
|
||||
|
||||
export async function inspectPreflightPdf(
|
||||
rendered: Extract<RenderPreflightPdfResult, { ok: true }>,
|
||||
limits: StylesheetPreflightInspectionLimits,
|
||||
loadPdf: LoadPdf = getDocument,
|
||||
): Promise<PdfPreflightResult> {
|
||||
if (rendered.bytes.byteLength > limits.maxBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT",
|
||||
message: "The rendered PDF exceeds the preflight byte limit.",
|
||||
diagnostics: rendered.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
const byteCount = rendered.bytes.byteLength;
|
||||
let loadingTask: PdfLoadingTask;
|
||||
try {
|
||||
loadingTask = loadPdf({ data: rendered.bytes });
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
|
||||
message: "PDF inspection failed.",
|
||||
diagnostics: rendered.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let document: { numPages: number };
|
||||
try {
|
||||
document = await loadingTask.promise;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
|
||||
message: "PDF inspection failed.",
|
||||
diagnostics: rendered.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
if (document.numPages > limits.maxPages) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_PAGE_LIMIT",
|
||||
message: "The rendered PDF exceeds the preflight page limit.",
|
||||
diagnostics: rendered.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
pageCount: document.numPages,
|
||||
byteCount,
|
||||
diagnostics: rendered.diagnostics,
|
||||
};
|
||||
} finally {
|
||||
await loadingTask.destroy().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
|
||||
|
||||
const warning = {
|
||||
code: "EXTREME_VALUE",
|
||||
severity: "warning",
|
||||
message: "The authored value is unusually large.",
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 10, offset: 9 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const rendered = {
|
||||
ok: true as const,
|
||||
bytes: new TextEncoder().encode("%PDF-1.7"),
|
||||
diagnostics: [warning],
|
||||
};
|
||||
|
||||
const limits = {
|
||||
maxPages: 20,
|
||||
maxBytes: 10_000_000,
|
||||
maxPageWidthPt: 2_000,
|
||||
maxPageHeightPt: 20_000,
|
||||
maxPageAreaPt2: 20_000_000,
|
||||
};
|
||||
|
||||
describe("stylesheet preflight PDF inspection", () => {
|
||||
it("preserves compiler warnings and hides parser exceptions", async () => {
|
||||
const destroy = vi.fn(async () => undefined);
|
||||
|
||||
const result = await inspectPreflightPdf(rendered, limits, () => ({
|
||||
promise: Promise.reject(new Error("sensitive parser details")),
|
||||
destroy,
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
|
||||
message: "PDF inspection failed.",
|
||||
diagnostics: [warning],
|
||||
});
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves compiler warnings when PDF.js fails before returning a loading task", async () => {
|
||||
const result = await inspectPreflightPdf(rendered, limits, () => {
|
||||
throw new Error("sensitive parser setup details");
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
|
||||
message: "PDF inspection failed.",
|
||||
diagnostics: [warning],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { PdfPreflightPageLimits, PdfPreflightResult, StylesheetPreflightInput } from "@reactive-resume/pdf/server";
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import * as React from "react";
|
||||
import { renderPreflightPdf } from "@reactive-resume/pdf/server";
|
||||
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
|
||||
|
||||
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||
|
||||
type StylesheetPreflightWorkerLimits = PdfPreflightPageLimits & {
|
||||
maxPages: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
type StylesheetPreflightWorkerData = {
|
||||
input: StylesheetPreflightInput;
|
||||
limits: StylesheetPreflightWorkerLimits;
|
||||
};
|
||||
|
||||
type SerializedPreflightCause = {
|
||||
name: string;
|
||||
message: string;
|
||||
issues: readonly unknown[];
|
||||
};
|
||||
|
||||
const send = (result: PdfPreflightResult) => {
|
||||
parentPort?.postMessage(result);
|
||||
};
|
||||
|
||||
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
||||
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
||||
return;
|
||||
}
|
||||
return { name: cause.name, message: cause.message, issues: cause.issues };
|
||||
};
|
||||
|
||||
parentPort?.postMessage({ type: "ready" });
|
||||
|
||||
async function run(): Promise<PdfPreflightResult> {
|
||||
const { input, limits } = workerData as StylesheetPreflightWorkerData;
|
||||
const rendered = await renderPreflightPdf(input, limits);
|
||||
return rendered.ok ? inspectPreflightPdf(rendered, limits) : rendered;
|
||||
}
|
||||
|
||||
if (parentPort) {
|
||||
void run()
|
||||
.then(send)
|
||||
.catch((cause: unknown) => {
|
||||
const serializedCause = serializeZodCause(cause);
|
||||
if (serializedCause) {
|
||||
parentPort?.postMessage({ type: "preflight_error", cause: serializedCause });
|
||||
return;
|
||||
}
|
||||
send({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||
message: "The PDF preflight worker failed.",
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user