mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae8d48bcee | ||
|
|
517199471a | ||
|
|
15f8bce988 | ||
|
|
164a279306 | ||
|
|
79e4a3ddc8 | ||
|
|
d2ffbf9618 | ||
|
|
4ac19f81b3 | ||
|
|
b303b89758 | ||
|
|
c6ac3fd1a9 | ||
|
|
fe6f84e06d | ||
|
|
9d6426b2e0 | ||
|
|
d34a429dea | ||
|
|
b69583c181 | ||
|
|
50f50b2672 | ||
|
|
fb8c73be76 | ||
|
|
18468a5658 | ||
|
|
048eab3b49 | ||
|
|
ca774c77c8 | ||
|
|
a4897c20d7 | ||
|
|
bed14a72af | ||
|
|
93c06934bd | ||
|
|
1e665fbe7e | ||
|
|
30812f8a8e | ||
|
|
dd0531091b | ||
|
|
a2901bfb2e | ||
|
|
418c7887ee | ||
|
|
12407d473d | ||
|
|
36a46cfd66 | ||
|
|
0868a92e62 | ||
|
|
822d6f9431 | ||
|
|
994093b981 | ||
|
|
9110e86997 | ||
|
|
bb1fb3a7d6 | ||
|
|
e34e7be6e0 | ||
|
|
34c03b1f73 | ||
|
|
0eb9ce012e | ||
|
|
966bc3ed58 | ||
|
|
08d859010c | ||
|
|
47349e7ab3 | ||
|
|
3266066826 | ||
|
|
6503da7e49 | ||
|
|
2a0782517c | ||
|
|
d4cf260aed | ||
|
|
e6b4733c5f | ||
|
|
689e7e24d4 | ||
|
|
9085a199cf | ||
|
|
d536b1921f | ||
|
|
2b0aac820c | ||
|
|
ac98139096 | ||
|
|
d50948ddee | ||
|
|
42bac75ae2 | ||
|
|
c77745f34e | ||
|
|
ed5d10c491 | ||
|
|
18d0c14aa1 | ||
|
|
1124d3dfda | ||
|
|
90105cb148 | ||
|
|
73daf22b2f | ||
|
|
25021507a0 | ||
|
|
8570c1c70a | ||
|
|
5270a2a9a0 | ||
|
|
b87a9d8282 | ||
|
|
46afc65cc6 | ||
|
|
dfc5559625 | ||
|
|
d37ac57cc5 | ||
|
|
fb9c217af2 | ||
|
|
0a64312bf8 | ||
|
|
b404dbd42a |
@@ -56,6 +56,9 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Server and Tooling Tests
|
||||
run: pnpm exec turbo run test:ci --filter=server --filter=@reactive-resume/tooling
|
||||
|
||||
- name: Install Playwright Browser
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
@@ -73,8 +76,38 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: pnpm test:e2e:ci
|
||||
- name: Run Baseline E2E Tests
|
||||
env:
|
||||
FLAG_SEMANTIC_CSS_AUTHORING: "false"
|
||||
FLAG_SEMANTIC_CSS_DEFAULT: "false"
|
||||
run: pnpm exec playwright test --grep-invert "@semantic-css"
|
||||
|
||||
- name: Run Semantic CSS Opt-In Acceptance
|
||||
env:
|
||||
FLAG_SEMANTIC_CSS_AUTHORING: "true"
|
||||
FLAG_SEMANTIC_CSS_DEFAULT: "false"
|
||||
run: |
|
||||
pnpm exec playwright test \
|
||||
tests/e2e/specs/semantic-css/legacy-conversion.spec.ts \
|
||||
tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts \
|
||||
tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts \
|
||||
tests/e2e/specs/semantic-css/revision-conflict.spec.ts \
|
||||
tests/e2e/specs/semantic-css/template-visual.spec.ts
|
||||
|
||||
- name: Run Semantic CSS Default-On Acceptance
|
||||
env:
|
||||
FLAG_SEMANTIC_CSS_AUTHORING: "true"
|
||||
FLAG_SEMANTIC_CSS_DEFAULT: "true"
|
||||
run: pnpm exec playwright test tests/e2e/specs/semantic-css/default-mode.spec.ts
|
||||
|
||||
- name: Run Semantic CSS Dormant Acceptance
|
||||
env:
|
||||
FLAG_SEMANTIC_CSS_AUTHORING: "false"
|
||||
FLAG_SEMANTIC_CSS_DEFAULT: "false"
|
||||
run: |
|
||||
pnpm exec playwright test \
|
||||
tests/e2e/specs/semantic-css/dormant-mode.spec.ts \
|
||||
tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
|
||||
@@ -51,6 +51,7 @@ temp
|
||||
.cursor
|
||||
.codegraph
|
||||
.superpowers
|
||||
.worktrees
|
||||
.migration
|
||||
|
||||
# Local Storage Data
|
||||
@@ -67,3 +68,4 @@ ds-bundle/
|
||||
packages/ui/.ds-compiled.css
|
||||
packages/ui/.ds-tw-raw.css
|
||||
packages/ui/dist/
|
||||
i18n.cache
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
config:
|
||||
default: true
|
||||
MD007: false
|
||||
MD009: false
|
||||
MD010: false
|
||||
MD012: false
|
||||
MD013: false
|
||||
MD001: false
|
||||
MD022: false
|
||||
MD024: false
|
||||
MD025: false
|
||||
MD028: false
|
||||
MD031: false
|
||||
MD032: false
|
||||
MD033: false
|
||||
MD034: false
|
||||
MD036: false
|
||||
MD040: false
|
||||
MD041: false
|
||||
MD046: false
|
||||
MD060: false
|
||||
|
||||
frontMatter: "^---[\\s\\S]*?---"
|
||||
gitignore: true
|
||||
globs:
|
||||
- "**/*.{md,mdx}"
|
||||
ignores:
|
||||
- ".design-sync/**"
|
||||
- "node_modules/**"
|
||||
- ".turbo/**"
|
||||
- "dist/**"
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 1: Harden public PDF response contract
|
||||
|
||||
## Implemented
|
||||
|
||||
- Pinned successful public PDF responses to `Content-Type: application/pdf` at the HTTP boundary.
|
||||
- Kept `Cache-Control: private, no-store` hardcoded at that boundary for successful, validation-error, and service-error responses.
|
||||
- Removed the unused `cacheControl` member from `createPublicResumePdf` and all affected tests/mocks.
|
||||
- Updated the route regression to return a `text/plain` file from the service mock while asserting the HTTP response remains `application/pdf`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter server test -- src/http/public-resume-pdf.test.ts` — 13 files / 71 tests passed.
|
||||
- `dotenvx run -f .env.local -- pnpm exec vitest run packages/api/src/features/resume/public-pdf.test.ts` — 1 file / 7 tests passed.
|
||||
- `pnpm exec biome check apps/server/src/http/public-resume-pdf.ts apps/server/src/http/public-resume-pdf.test.ts packages/api/src/features/resume/public-pdf.ts packages/api/src/features/resume/public-pdf.test.ts` — passed.
|
||||
- `pnpm --filter server typecheck` and `pnpm --filter @reactive-resume/api typecheck` — passed.
|
||||
|
||||
## Note
|
||||
|
||||
The API package test script did not scope to the supplied test path and initially ran the package suite, which has two unrelated baseline failures: `src/features/ai/url-policy.test.ts` and `src/features/resume/export.test.ts` (missing required env). The target test was then run directly with `.env.local` and passed.
|
||||
|
||||
## Self-review
|
||||
|
||||
`git diff --check` passed. The diff is restricted to the public PDF service contract, HTTP response header, and their focused tests.
|
||||
@@ -12,7 +12,7 @@ Internal packages are source-consumed through `package.json` export maps that po
|
||||
|
||||
- **Node.js 24** (matches Dockerfile `ARG NODE_VERSION=24`). Use `nvm install 24 && nvm use 24` if needed.
|
||||
- **Docker** is required to run PostgreSQL. Start it with `sudo dockerd &` if the daemon isn't running.
|
||||
- **pnpm 11.1.2** is managed via corepack (`corepack enable`).
|
||||
- **pnpm 11.17.0** is managed via corepack (`corepack enable`).
|
||||
|
||||
### Codebase map
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ If your company would like to sponsor Reactive Resume, email [hello@amruthpillai
|
||||
- Professionally designed templates
|
||||
- A4 and Letter size support
|
||||
- Customizable colors, fonts, and spacing
|
||||
- Custom CSS for advanced styling
|
||||
- Structured Style Rules for section and text styling
|
||||
|
||||
**Privacy & Control**
|
||||
|
||||
@@ -240,11 +240,11 @@ Other ways to support:
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#amruthpillai/reactive-resume&type=date&legend=top-left">
|
||||
<a href="https://www.star-history.com/?repos=amruthpillai%2Freactive-resume&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=BF8sVMes0z5BhdkMhtFklhxeikeGUrSyW-CcY9E_RCQI5zqUHEbMRwcB075fUewbAtlNoCnDlWhDWjrDGhTcXMojsS2I0RCqcL-Y9p3Ez3H1A2QpRMthjFilP0YOCJEE9AZqRrqzlvj1uU2y5ixarXOuUXuuSw5DkLMViSMD8Ldl0H3BEgclnjWw4fI4" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
+47
-32
@@ -7,6 +7,7 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsdown",
|
||||
"start": "node dist/index.mjs",
|
||||
"docs:gen": "tsx src/openapi/generate-spec.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -17,53 +18,67 @@
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.8",
|
||||
"@ai-sdk/google": "^4.0.8",
|
||||
"@ai-sdk/openai": "^4.0.7",
|
||||
"@ai-sdk/openai-compatible": "^3.0.5",
|
||||
"@aws-sdk/client-s3": "^3.1079.0",
|
||||
"@better-auth/api-key": "^1.6.23",
|
||||
"@better-auth/drizzle-adapter": "^1.6.23",
|
||||
"@better-auth/infra": "^0.3.4",
|
||||
"@better-auth/oauth-provider": "^1.6.23",
|
||||
"@better-auth/passkey": "^1.6.23",
|
||||
"@hono/node-server": "^2.0.8",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@orpc/client": "^1.14.6",
|
||||
"@orpc/experimental-ratelimit": "^1.14.6",
|
||||
"@orpc/json-schema": "^1.14.6",
|
||||
"@orpc/openapi": "^1.14.6",
|
||||
"@orpc/server": "^1.14.6",
|
||||
"@orpc/zod": "^1.14.6",
|
||||
"@ai-sdk/anthropic": "^4.0.24",
|
||||
"@ai-sdk/cerebras": "^3.0.17",
|
||||
"@ai-sdk/cohere": "^4.0.15",
|
||||
"@ai-sdk/deepseek": "^3.0.16",
|
||||
"@ai-sdk/fireworks": "^3.0.18",
|
||||
"@ai-sdk/google": "^4.0.28",
|
||||
"@ai-sdk/groq": "^4.0.16",
|
||||
"@ai-sdk/mistral": "^4.0.17",
|
||||
"@ai-sdk/openai": "^4.0.24",
|
||||
"@ai-sdk/openai-compatible": "^3.0.17",
|
||||
"@ai-sdk/perplexity": "^4.0.17",
|
||||
"@ai-sdk/togetherai": "^3.0.18",
|
||||
"@ai-sdk/xai": "^4.0.22",
|
||||
"@aws-sdk/client-s3": "^3.1098.0",
|
||||
"@better-auth/api-key": "^1.6.25",
|
||||
"@better-auth/drizzle-adapter": "^1.6.25",
|
||||
"@better-auth/infra": "^0.3.7",
|
||||
"@better-auth/oauth-provider": "^1.6.25",
|
||||
"@better-auth/passkey": "^1.6.25",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@hono/node-server": "^2.0.12",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@orpc/client": "^1.14.13",
|
||||
"@orpc/experimental-ratelimit": "^1.14.13",
|
||||
"@orpc/json-schema": "^1.14.13",
|
||||
"@orpc/openapi": "^1.14.13",
|
||||
"@orpc/server": "^1.14.13",
|
||||
"@orpc/zod": "^1.14.13",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/mcp": "workspace:*",
|
||||
"@reactive-resume/pdf": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"ai": "^7.0.15",
|
||||
"ai": "^7.0.42",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.23",
|
||||
"better-auth": "1.6.25",
|
||||
"canonicalize": "^3.0.0",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"es-toolkit": "^1.50.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"hono": "^4.12.27",
|
||||
"hono": "^4.12.32",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"node-html-parser": "^8.0.4",
|
||||
"node-html-parser": "^9.0.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"ollama-ai-provider-v2": "^3.6.0",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"pg": "^8.22.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
"react": "^19.2.7",
|
||||
"react-email": "^6.6.6",
|
||||
"react": "^19.2.8",
|
||||
"react-email": "^6.9.1",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.35.3",
|
||||
@@ -74,13 +89,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"tsdown": "^0.22.3",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"tsdown": "^0.22.14",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -37,9 +37,7 @@ async function checkDatabase() {
|
||||
return { status: "healthy" };
|
||||
}
|
||||
|
||||
async function checkStorage() {
|
||||
return getStorageService().healthcheck();
|
||||
}
|
||||
const checkStorage = () => getStorageService().healthcheck();
|
||||
|
||||
export async function handleHealth() {
|
||||
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export async function handleMcp(request: Request) {
|
||||
try {
|
||||
await authenticateRequest(request);
|
||||
|
||||
const server = await createMcpServer(request);
|
||||
const server = createMcpServer(request);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMcpServer(request: Request) {
|
||||
export function createMcpServer(request: Request) {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "reactive-resume",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
auth: {},
|
||||
env: {
|
||||
APP_URL: "https://rxresu.me",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@better-auth/oauth-provider", () => ({
|
||||
oauthProviderAuthServerMetadata: vi.fn(() => vi.fn(() => Response.json({}))),
|
||||
oauthProviderOpenIdConfigMetadata: vi.fn(() => vi.fn(() => Response.json({}))),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: mocks.auth,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/mcp/server-card", () => ({
|
||||
buildMcpServerCard: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../app-version", () => ({
|
||||
appVersion: "test",
|
||||
}));
|
||||
|
||||
describe("handleOAuthProtectedResource", () => {
|
||||
it("advertises the mounted auth issuer as the authorization server", async () => {
|
||||
const { handleOAuthProtectedResource } = await import("./metadata");
|
||||
|
||||
const response = await handleOAuthProtectedResource();
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
resource: "https://rxresu.me",
|
||||
authorization_servers: ["https://rxresu.me/api/auth"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import { env } from "@reactive-resume/env/server";
|
||||
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
|
||||
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
|
||||
export const handleOAuthAuthorizationServer = oauthProviderAuthServerMetadata(auth);
|
||||
export const handleOpenIdConfiguration = oauthProviderOpenIdConfigMetadata(auth);
|
||||
|
||||
export function handleWellKnownFallback() {
|
||||
return new Response("OK", { status: 200 });
|
||||
@@ -20,19 +20,11 @@ export function handleMcpServerCard() {
|
||||
});
|
||||
}
|
||||
|
||||
export function handleOAuthAuthorizationServer(request: Request) {
|
||||
return oauthAuthorizationServerHandler(request);
|
||||
}
|
||||
|
||||
export function handleOpenIdConfiguration(request: Request) {
|
||||
return openIdConfigurationHandler(request);
|
||||
}
|
||||
|
||||
export async function handleOAuthProtectedResource() {
|
||||
export function handleOAuthProtectedResource() {
|
||||
const metadata = {
|
||||
resource: env.APP_URL,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [env.APP_URL, `${env.APP_URL}/api/auth`],
|
||||
authorization_servers: [`${env.APP_URL}/api/auth`],
|
||||
};
|
||||
|
||||
return Response.json(metadata, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -33,6 +33,8 @@ describe("handleUpload", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
|
||||
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
|
||||
expect(response.headers.get("Cross-Origin-Resource-Policy")).toBe("same-site");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not serve private agent attachment keys through the public uploads route", async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname, normalize } from "node:path";
|
||||
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
export async function handleUpload(request: Request) {
|
||||
const { userId, filePath } = parseRouteParams(request.url);
|
||||
@@ -41,7 +40,6 @@ export async function handleUpload(request: Request) {
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
headers.set("Access-Control-Allow-Origin", env.APP_URL);
|
||||
|
||||
return new Response(toArrayBuffer(storedFile.data), { headers });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
env: { APP_URL: "https://rxresu.me" },
|
||||
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
}));
|
||||
@@ -12,10 +17,25 @@ vi.mock("node:fs/promises", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@hono/node-server/serve-static", () => ({
|
||||
serveStatic: vi.fn(() => vi.fn()),
|
||||
serveStatic: mocks.serveStatic,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
type StaticOptions = {
|
||||
onFound?: (
|
||||
path: string,
|
||||
context: {
|
||||
req: { path: string };
|
||||
header: (name: string, value: string) => void;
|
||||
},
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const { handleWebApp } = await import("./web");
|
||||
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
|
||||
|
||||
describe("web app fallback classification", () => {
|
||||
beforeEach(() => {
|
||||
@@ -32,22 +52,77 @@ describe("web app fallback classification", () => {
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/auth/login",
|
||||
"/dashboard",
|
||||
"/builder/resume-1",
|
||||
"/agent",
|
||||
"/templates",
|
||||
"/templates/azurill.pdf",
|
||||
])("serves noindex shell for known app prefix %s", async (pathname) => {
|
||||
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
|
||||
>
|
||||
</head>
|
||||
<body><div id="app"></div></body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const response = await handleWebApp(new Request("http://server.internal/?utm_source=search"));
|
||||
const html = await response.text();
|
||||
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
|
||||
expect(html).toContain('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
|
||||
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
|
||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
||||
expect(html).toContain('id="reactive-resume-structured-data"');
|
||||
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
|
||||
expect(html).toContain('"url":"https://rxresu.me/"');
|
||||
expect(html).not.toContain("utm_source");
|
||||
|
||||
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
|
||||
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
|
||||
});
|
||||
|
||||
it("caches versioned homepage media immutably", async () => {
|
||||
const headers = new Headers();
|
||||
|
||||
await staticOptions?.onFound?.("", {
|
||||
req: { path: "/videos/timelapse-v1.mp4" },
|
||||
header: (name, value) => headers.set(name, value),
|
||||
});
|
||||
|
||||
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
|
||||
const unversionedHeaders = new Headers();
|
||||
await staticOptions?.onFound?.("", {
|
||||
req: { path: "/videos/timelapse.mp4" },
|
||||
header: (name, value) => unversionedHeaders.set(name, value),
|
||||
});
|
||||
|
||||
expect(unversionedHeaders.get("Cache-Control")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it.each(["/auth/login", "/dashboard", "/builder/resume-1", "/agent", "/templates", "/templates/azurill.pdf"])(
|
||||
"serves noindex shell for known app prefix %s",
|
||||
async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
},
|
||||
);
|
||||
|
||||
it("serves noindex shell for public resume shaped routes", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
|
||||
|
||||
@@ -66,19 +141,18 @@ describe("web app fallback classification", () => {
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/api/foo",
|
||||
"/mcp/foo",
|
||||
"/uploads/foo",
|
||||
])("does not treat reserved two-segment path %s as a public resume", async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
it.each(["/api/foo", "/mcp/foo", "/uploads/foo"])(
|
||||
"does not treat reserved two-segment path %s as a public resume",
|
||||
async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("returns plain 404 for missing asset-looking paths", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
function resolveWebDistPath() {
|
||||
const candidates = [
|
||||
@@ -31,8 +32,6 @@ const reservedPublicResumeSegments = new Set([
|
||||
"templates",
|
||||
]);
|
||||
|
||||
export const serveWebDistStatic = serveStatic({ root: staticRoot });
|
||||
|
||||
function isAssetPath(pathname: string): boolean {
|
||||
return pathname.split("/").pop()?.includes(".") ?? false;
|
||||
}
|
||||
@@ -52,12 +51,130 @@ function isPublicResumePath(pathname: string): boolean {
|
||||
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
|
||||
}
|
||||
|
||||
const BASE_SECURITY_HEADERS = {
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Content-Security-Policy-Report-Only":
|
||||
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
||||
};
|
||||
|
||||
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
|
||||
const ROOT_DESCRIPTION =
|
||||
"Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.";
|
||||
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
|
||||
const ROOT_FAQ_ITEMS = [
|
||||
{
|
||||
question: "Is Reactive Resume really free?",
|
||||
answer:
|
||||
"Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.",
|
||||
},
|
||||
{
|
||||
question: "How is my data protected?",
|
||||
answer:
|
||||
"Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.",
|
||||
},
|
||||
{
|
||||
question: "Can I export my resume to PDF?",
|
||||
answer:
|
||||
"Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.",
|
||||
},
|
||||
{
|
||||
question: "Is Reactive Resume available in multiple languages?",
|
||||
answer:
|
||||
"Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings page, or using the language switcher in the top right corner. If you don't see your language, or you would like to improve the existing translations, you can contribute to the translations on Crowdin.",
|
||||
},
|
||||
{
|
||||
question: "What makes Reactive Resume different from other resume builders?",
|
||||
answer:
|
||||
"Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.",
|
||||
},
|
||||
{
|
||||
question: "How do I share my resume?",
|
||||
answer:
|
||||
"You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function createRootSeoMarkup(canonicalUrl: string) {
|
||||
const origin = new URL(canonicalUrl).origin;
|
||||
const imageUrl = `${origin}/opengraph/banner.jpg`;
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
},
|
||||
{
|
||||
"@type": ["SoftwareApplication", "WebApplication"],
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
description: ROOT_DESCRIPTION,
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
isAccessibleForFree: true,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
codeRepository: "https://github.com/amruthpillai/reactive-resume",
|
||||
},
|
||||
{
|
||||
"@type": "Project",
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
sameAs: ["https://github.com/amruthpillai/reactive-resume"],
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return `
|
||||
<link rel="canonical" href="${canonicalUrl}">
|
||||
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Reactive Resume">
|
||||
<meta property="og:title" content="${ROOT_TITLE}">
|
||||
<meta property="og:description" content="${ROOT_DESCRIPTION}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${ROOT_TITLE}">
|
||||
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
|
||||
`;
|
||||
}
|
||||
|
||||
export const serveWebDistStatic = serveStatic({
|
||||
root: staticRoot,
|
||||
onFound: (_path, context) => {
|
||||
if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) {
|
||||
context.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function getFallbackResponseHeaders(pathname: string) {
|
||||
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8" };
|
||||
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
|
||||
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
|
||||
return {
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
"X-Robots-Tag": "noindex, follow",
|
||||
...BASE_SECURITY_HEADERS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,5 +206,8 @@ export async function handleWebApp(request: Request) {
|
||||
if (isHead) return new Response(null, { status: 200, headers });
|
||||
|
||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||
return new Response(html, { headers });
|
||||
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
||||
const responseHtml = pathname === "/" ? html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`) : html;
|
||||
|
||||
return new Response(responseHtml, { headers });
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -33,7 +33,10 @@ const promptAssetsPlugin: TsdownPlugin = {
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts" },
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"stylesheet-preflight-worker": "src/workers/stylesheet-preflight.ts",
|
||||
},
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"]
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||
"tasks": {
|
||||
"test:ci": {
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" type="image/png" sizes="180x180 any" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
||||
|
||||
<title>Reactive Resume</title>
|
||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
|
||||
@@ -56,6 +56,6 @@
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" data-cfasync="false" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+921
-46
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+915
-40
File diff suppressed because it is too large
Load Diff
+937
-62
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+922
-47
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+921
-46
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+924
-49
File diff suppressed because it is too large
Load Diff
+924
-49
File diff suppressed because it is too large
Load Diff
+924
-49
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+924
-49
File diff suppressed because it is too large
Load Diff
+924
-49
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+923
-48
File diff suppressed because it is too large
Load Diff
+913
-38
File diff suppressed because it is too large
Load Diff
+56
-42
@@ -16,20 +16,28 @@
|
||||
"lingui:extract": "lingui extract --clean --overwrite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^4.0.16",
|
||||
"@ai-sdk/react": "^4.0.45",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@better-auth/api-key": "^1.6.23",
|
||||
"@better-auth/infra": "^0.3.4",
|
||||
"@better-auth/oauth-provider": "^1.6.23",
|
||||
"@better-auth/passkey": "^1.6.23",
|
||||
"@better-auth/api-key": "^1.6.25",
|
||||
"@better-auth/infra": "^0.3.7",
|
||||
"@better-auth/oauth-provider": "^1.6.25",
|
||||
"@better-auth/passkey": "^1.6.25",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lingui/core": "^6.4.0",
|
||||
"@lingui/react": "^6.4.0",
|
||||
"@orpc/client": "^1.14.6",
|
||||
"@orpc/server": "^1.14.6",
|
||||
"@orpc/tanstack-query": "^1.14.6",
|
||||
"@lingui/core": "^6.6.0",
|
||||
"@lingui/react": "^6.6.0",
|
||||
"@orpc/client": "^1.14.13",
|
||||
"@orpc/server": "^1.14.13",
|
||||
"@orpc/tanstack-query": "^1.14.13",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/ai": "workspace:*",
|
||||
@@ -43,39 +51,41 @@
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/ui": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-form": "^1.33.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-form": "^1.33.2",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-router": "^1.170.17",
|
||||
"@tiptap/extension-color": "^3.27.1",
|
||||
"@tiptap/extension-highlight": "^3.27.1",
|
||||
"@tiptap/extension-text-align": "^3.27.1",
|
||||
"@tiptap/extension-text-style": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@tiptap/extension-color": "^3.29.2",
|
||||
"@tiptap/extension-highlight": "^3.29.2",
|
||||
"@tiptap/extension-text-align": "^3.29.2",
|
||||
"@tiptap/extension-text-style": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/react": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"@uiw/react-color-colorful": "^2.10.3",
|
||||
"ai": "^7.0.15",
|
||||
"better-auth": "1.6.23",
|
||||
"ai": "^7.0.42",
|
||||
"better-auth": "1.6.25",
|
||||
"buffer": "^6.0.3",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"es-toolkit": "^1.49.0",
|
||||
"fuse.js": "^7.4.2",
|
||||
"immer": "^11.1.11",
|
||||
"es-toolkit": "^1.50.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"immer": "^11.1.15",
|
||||
"js-cookie": "^3.0.8",
|
||||
"motion": "^12.42.2",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"motion": "^12.43.0",
|
||||
"pdfjs-dist": "6.2.108",
|
||||
"pg": "^8.22.0",
|
||||
"prettier": "^3.9.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-easy-crop": "^6.1.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-easy-crop": "^6.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.12.1",
|
||||
"react-window": "^2.2.7",
|
||||
"react-resizable-panels": "^4.12.2",
|
||||
"react-window": "^2.3.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -86,23 +96,27 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^8.0.1",
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.4.0",
|
||||
"@lingui/cli": "^6.4.0",
|
||||
"@lingui/format-po": "^6.4.0",
|
||||
"@lingui/vite-plugin": "^6.4.0",
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.6.0",
|
||||
"@lingui/cli": "^6.6.0",
|
||||
"@lingui/format-po": "^6.6.0",
|
||||
"@lingui/vite-plugin": "^6.6.0",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/router-plugin": "^1.168.19",
|
||||
"@tanstack/devtools-vite": "^0.8.3",
|
||||
"@tanstack/react-devtools": "^0.10.9",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.23",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260704.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3"
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -11,6 +11,15 @@ type CountUpProps = {
|
||||
"aria-atomic"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
|
||||
export function CountUp({
|
||||
to,
|
||||
@@ -31,15 +40,6 @@ export function CountUp({
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const maxDecimals = getDecimalPlaces(to);
|
||||
|
||||
const formatValue = useCallback(
|
||||
|
||||
@@ -176,7 +176,7 @@ export function ChipInput({
|
||||
});
|
||||
if (nextValues.length === 0) return;
|
||||
|
||||
const newChips = Array.from(new Set([...chips, ...nextValues]));
|
||||
const newChips = [...new Set([...chips, ...nextValues])];
|
||||
setChips(newChips);
|
||||
},
|
||||
[chips, setChips],
|
||||
@@ -269,7 +269,7 @@ export function ChipInput({
|
||||
const oldIndex = chips.indexOf(active.id as string);
|
||||
const newIndex = chips.indexOf(over.id as string);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const newOrder = Array.from(chips);
|
||||
const newOrder = [...chips];
|
||||
const [removed] = newOrder.splice(oldIndex, 1);
|
||||
newOrder.splice(newIndex, 0, removed);
|
||||
handleReorder(newOrder);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CellComponentProps } from "react-window";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ProhibitIcon } from "@phosphor-icons/react";
|
||||
import Fuse from "fuse.js";
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Grid } from "react-window";
|
||||
import { icons } from "@reactive-resume/schema/icons";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
@@ -14,6 +14,7 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
const columnCount = 8;
|
||||
const columnWidth = 36;
|
||||
const rowHeight = 36;
|
||||
const iconSearch = new Fuse(icons, { threshold: 0.35 });
|
||||
|
||||
type IconSearchInputProps = {
|
||||
value: string;
|
||||
@@ -21,7 +22,7 @@ type IconSearchInputProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function _IconSearchInput(props: IconSearchInputProps) {
|
||||
function IconSearchInput(props: IconSearchInputProps) {
|
||||
return (
|
||||
<Input
|
||||
spellCheck={false}
|
||||
@@ -41,10 +42,6 @@ function _IconSearchInput(props: IconSearchInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const IconSearchInput = memo(_IconSearchInput);
|
||||
|
||||
IconSearchInput.displayName = "IconSearchInput";
|
||||
|
||||
type IconCellComponentProps = CellComponentProps & {
|
||||
icons: IconName[];
|
||||
onChange: (icon: IconName) => void;
|
||||
@@ -70,18 +67,9 @@ function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: Ic
|
||||
);
|
||||
}
|
||||
|
||||
function useIconSearch() {
|
||||
const fuse = useMemo(() => new Fuse(icons, { threshold: 0.35 }), []);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): IconName[] => {
|
||||
if (!query.trim()) return Array.from(icons);
|
||||
return fuse.search(query).map((result) => result.item);
|
||||
},
|
||||
[fuse],
|
||||
);
|
||||
|
||||
return search;
|
||||
function searchIcons(query: string): IconName[] {
|
||||
if (!query.trim()) return [...icons];
|
||||
return iconSearch.search(query).map((result) => result.item);
|
||||
}
|
||||
|
||||
type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onChange"> & {
|
||||
@@ -91,12 +79,10 @@ type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onCh
|
||||
};
|
||||
|
||||
export function IconPicker({ value, onChange, popoverProps, ...props }: IconPickerProps) {
|
||||
const searchIcons = useIconSearch();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const searchedIcons = useMemo(() => searchIcons(search), [search, searchIcons]);
|
||||
const rowCount = useMemo(() => Math.ceil(searchedIcons.length / columnCount), [searchedIcons]);
|
||||
const searchedIcons = useMemo(() => searchIcons(search), [search]);
|
||||
const rowCount = Math.ceil(searchedIcons.length / columnCount);
|
||||
|
||||
return (
|
||||
<Popover {...popoverProps}>
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveHighlightToolbarState } from "./rich-input";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { resolveHighlightToolbarState } from "./rich-input.utils";
|
||||
|
||||
describe("Tiptap editing", () => {
|
||||
it("creates a new paragraph when Enter is pressed", () => {
|
||||
const editor = new Editor({ extensions: [StarterKit], content: "<p>First</p>" });
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1);
|
||||
editor.commands.enter();
|
||||
|
||||
expect(editor.getHTML()).toBe("<p>First</p><p></p>");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("creates and splits list items", () => {
|
||||
const editor = new Editor({ extensions: [StarterKit], content: "<p>First</p>" });
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1);
|
||||
editor.commands.toggleBulletList();
|
||||
editor.commands.enter();
|
||||
|
||||
expect(editor.getHTML()).toBe("<ul><li><p>First</p></li><li><p></p></li></ul><p></p>");
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHighlightToolbarState", () => {
|
||||
it("shows legacy colorless highlights as default yellow and clearable", () => {
|
||||
|
||||
@@ -57,9 +57,9 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/libs/locale";
|
||||
import { ColorPicker } from "./color-picker";
|
||||
import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils";
|
||||
|
||||
const defaultTextColor = "rgba(0, 0, 0, 1)";
|
||||
const defaultHighlightColor = "rgba(255, 255, 0, 1)";
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -351,12 +351,6 @@ function useEditorToolbarState(editor: Editor) {
|
||||
|
||||
type EditorToolbarState = ReturnType<typeof useEditorToolbarState>;
|
||||
|
||||
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
|
||||
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
|
||||
|
||||
return { visibleHighlightColor, canClearHighlight: isHighlight };
|
||||
}
|
||||
|
||||
type EditorToolbarProps = {
|
||||
editor: Editor;
|
||||
isFullscreen: boolean;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const defaultHighlightColor = "rgba(255, 255, 0, 1)";
|
||||
|
||||
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
|
||||
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
|
||||
|
||||
return { visibleHighlightColor, canClearHighlight: isHighlight };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user