Compare commits

...
67 Commits
Author SHA1 Message Date
Amruth Pillai ae8d48bcee chore: update translations 2026-07-30 12:59:19 +02:00
Amruth Pillai 517199471a docs: add changelog of v5.2.5 2026-07-30 12:56:28 +02:00
Santhi Prakash 15f8bce988 docs: align pnpm version with packageManager field (#3278) 2026-07-30 12:47:00 +02:00
Amruth Pillai 164a279306 chore: update dependencies 2026-07-30 12:45:49 +02:00
github-actions[bot]andCrowdin Bot 79e4a3ddc8 Sync Translations from Crowdin (#3279)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-30 12:41:45 +02:00
Amruth PillaiandCursor Agent d2ffbf9618 feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-30 12:39:15 +02:00
4ac19f81b3 fix: clamp page margin values to [0, 100] to prevent crash on paste (#3277)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-29 18:13:42 +02:00
Amruth Pillai b303b89758 fix(web): restore Tiptap Enter and list editing 2026-07-28 15:10:24 +02:00
Amruth Pillai c6ac3fd1a9 docs: remove redirects 2026-07-28 09:39:23 +02:00
Amruth Pillai fe6f84e06d docs: resolve final comparison review findings 2026-07-28 09:19:16 +02:00
Amruth Pillai 9d6426b2e0 docs: publish resume builder comparison cluster 2026-07-28 09:04:43 +02:00
Amruth Pillai d34a429dea docs: remove unsupported deployment comparisons 2026-07-28 08:59:56 +02:00
Amruth Pillai b69583c181 docs: compare career and template resume tools 2026-07-28 08:57:31 +02:00
Amruth Pillai 50f50b2672 fix(server): use public URL for homepage metadata 2026-07-28 08:56:43 +02:00
Amruth Pillai fb8c73be76 docs: narrow unsupported AI comparison claims 2026-07-28 08:53:18 +02:00
Amruth Pillai 18468a5658 docs: tighten AI comparison sourcing 2026-07-28 08:50:03 +02:00
Amruth Pillai 048eab3b49 fix(web): preserve bootstrap attribute order 2026-07-28 08:48:14 +02:00
Amruth Pillai ca774c77c8 docs: compare AI and ATS resume builders 2026-07-28 08:46:25 +02:00
Amruth Pillai a4897c20d7 docs: canonicalize getting started routes 2026-07-28 08:41:53 +02:00
Amruth Pillai bed14a72af docs: compare mainstream resume builders 2026-07-28 08:39:18 +02:00
Amruth Pillai 93c06934bd fix(web): preserve Rocket Loader exclusion in build 2026-07-28 08:38:05 +02:00
Amruth Pillai 1e665fbe7e perf(web): remove homepage video from the LCP path 2026-07-28 08:35:07 +02:00
Amruth Pillai 30812f8a8e docs: compare Reactive Resume with design editors 2026-07-28 08:33:08 +02:00
Amruth Pillai dd0531091b fix(server): limit immutable media cache headers 2026-07-28 08:31:26 +02:00
Amruth Pillai a2901bfb2e docs: plan SEO comparison content cluster 2026-07-28 08:08:29 +02:00
Amruth Pillai 418c7887ee fix(server): emit initial homepage SEO metadata 2026-07-28 08:07:53 +02:00
Amruth Pillai 12407d473d docs: plan SEO and AEO performance improvements 2026-07-28 07:36:27 +02:00
Amruth Pillai 36a46cfd66 docs: design SEO comparison content cluster 2026-07-28 07:34:36 +02:00
Amruth Pillai 0868a92e62 docs: design SEO and AEO performance improvements 2026-07-28 07:31:35 +02:00
Amruth Pillai 822d6f9431 chore: bump version to 5.2.4 2026-07-28 07:00:16 +02:00
Amruth Pillai 994093b981 chore: update translations 2026-07-27 20:57:54 +02:00
Amruth Pillai 9110e86997 refactor: ponytail audit 2026-07-27 20:26:16 +02:00
ServaTilisandClaude Opus 4.8 bb1fb3a7d6 perf(api): lazy-load PDF renderer to cut server cold-start (#3244)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 13:42:07 +02:00
Diego Vega Centeno e34e7be6e0 fix(pdf): add flex to skill name to participate in layout sizing (#3253) 2026-07-27 13:39:14 +02:00
Rakshit Kaintura 34c03b1f73 fix(auth): map oauth login to correct user id instead of account id (#3256) 2026-07-27 13:38:30 +02:00
EMRANandAmruth Pillai 0eb9ce012e fix: profile picture delete icon should reset image correctly (#3176) (#3258)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-27 13:31:57 +02:00
autofix-ci[bot] 966bc3ed58 [autofix.ci] apply automated fixes 2026-07-27 11:23:26 +00:00
EMRAN 08d859010c fix: allow award title unbold via custom styles (#3250) (#3257) 2026-07-27 13:22:37 +02:00
Emanuele TonelloandAmruth Pillai 47349e7ab3 feat: support editing AI provider models in the UI and auto-fill LinkedIn job postings (#3259)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-27 13:21:57 +02:00
Amruth Pillai 3266066826 chore: update dependencies 2026-07-27 13:19:54 +02:00
autofix-ci[bot] 6503da7e49 [autofix.ci] apply automated fixes 2026-07-27 11:14:46 +00:00
落尘 2a0782517c fix: clamp custom style numeric inputs (#3262) 2026-07-27 13:13:48 +02:00
cielhaidir d4cf260aed fix(api): support HTTPS job posting fetches (#3267) 2026-07-27 13:13:25 +02:00
Santhi Prakash e6b4733c5f docs(contributing): fix troubleshooting accordion code block formatting (#3269) 2026-07-27 13:12:44 +02:00
Diego Vega CentenoandAmruth Pillai 689e7e24d4 Filter invalid style intents to preserve valid custom styles (#3241)
* Filter invalid style intents to preserve valid custom styles

* fix: preserve valid custom style rules

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-09 15:50:34 +02:00
github-actions[bot]andCrowdin Bot 9085a199cf Sync Translations from Crowdin (#3243)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-09 15:35:39 +02:00
Andrea Accardo d536b1921f fix: bullet list indentation on page break (#3242)
Signed-off-by: aaccardo <hackardo@gmail.com>
2026-07-09 15:33:40 +02:00
Amruth Pillai 2b0aac820c chore(i18n): sync translations from crowdin 2026-07-09 01:24:39 +02:00
Amruth Pillai ac98139096 docs: pin v4 migration script checkout 2026-07-09 01:17:56 +02:00
Amruth Pillai d50948ddee chore(i18n): update application timeline translations 2026-07-09 01:17:56 +02:00
Amruth Pillai 42bac75ae2 Update README.md 2026-07-09 00:58:11 +02:00
Amruth Pillai c77745f34e Update README.md 2026-07-09 00:56:40 +02:00
Andrea AccardoandAmruth Pillai ed5d10c491 fix: solve list marker page break (#3177) (#3236)
Signed-off-by: aaccardo <hackardo@gmail.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-07-09 00:47:50 +02:00
Amruth Pillai 18d0c14aa1 feat: add application timeline history (#3237)
* feat: add application timeline history

* fix: address application timeline review

* fix: keep application tracker e2e stable

* fix: use stable timeline e2e selector

* fix: target timeline note input in e2e
2026-07-09 00:36:45 +02:00
Amruth Pillai 1124d3dfda Fix OAuth metadata authorization server list 2026-07-08 22:15:11 +02:00
Amruth Pillai 90105cb148 chore: integrate improve-integration 2026-07-08 19:08:31 +02:00
Amruth Pillai 73daf22b2f docs: publish MCP registry metadata 2026-07-07 18:50:07 +02:00
github-actions[bot]andCrowdin Bot 25021507a0 [skip ci] chore(i18n): sync translations from crowdin (#3233)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-07 18:21:35 +02:00
Amruth Pillai 8570c1c70a fix: render cover letter exports without resume chrome 2026-07-07 17:47:52 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 5270a2a9a0 docs: tighten SEO titles and descriptions across new docs (#3232)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-07 15:08:12 +00:00
github-actions[bot]andCrowdin Bot b87a9d8282 Sync Translations from Crowdin (#3231)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-07 17:06:14 +02:00
Amruth Pillai 46afc65cc6 Add application tracker REST and MCP parity
Add comprehensive Application Tracker REST and MCP coverage, document the MCP workflow, add Markdown/ActionLint checks, bump the release version, and fill all extracted translations.
2026-07-07 17:02:39 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> dfc5559625 docs: document expanded command palette entity search (#3225)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-06 00:32:00 +02:00
github-actions[bot]andCrowdin Bot d37ac57cc5 [skip ci] chore(i18n): sync translations from crowdin (#3224)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-06 00:30:34 +02:00
Amruth Pillai fb9c217af2 feat: add command palette entity search 2026-07-06 00:29:06 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 0a64312bf8 docs: lengthen short SEO descriptions on two guides (#3222)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-05 21:45:25 +00:00
Amruth Pillai b404dbd42a Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot

Add an Applications module at /dashboard/applications: pipeline board
(dnd-kit), table view with bulk actions, Insights (fit tiles, funnel,
sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and
Add/Edit/Detail slide-overs. Each application links a live Reactive
Resume.

AI "Application Copilot" (applications.ai.*): job-posting autofill,
resume↔job match score (fit ring), resume tailoring, and cover-letter /
follow-up drafting — via the user's configured provider.

Board cards + table rows get context menus (edit / move / archive /
delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox.

Also includes local TanStack devtools setup and toolchain bumps.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* feat(applications): close follow-up gaps + squash migrations

Finish the deferred/open items on the applications tracker:

- Cover-letter upload re-enabled. Fix the storage blocker by deriving the
  key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE)
  instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image
  avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add
  coverLetterUrl/coverLetterName columns + Documents-section upload/remove.
- Contacts editor in the detail sheet (add/edit/remove, keyed per app).
- Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more").
- Extract new Lingui messages across locales.
- Guard coverLetterUrl to http(s)/relative at the API boundary.

Squash the five branch-only application-table migrations (create -> +tags
-> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via
drizzle-kit generate.

Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f

* chore: update dependencies

* fix(web): address React Doctor findings — compiler, purity, query, component structure

prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces,
handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to
module scope so they aren't rebuilt on every render.

react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the
non-assignment form to unblock auto-memoization.

set-state-in-effect: derive updatedAtLabel at render time instead of syncing
it through useState + useEffect.

query-destructure-result: destructure useQuery results at call site in
resume-analysis and resume-thumbnail to follow TanStack Query v5 convention.

only-export-components: extract non-component exports to sibling .ts files so
Fast Refresh can preserve component state:
  - getNextWeights → typography/get-next-weights.ts
  - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts
  - getLocaleOptions → features/locale/locale-options.tsx
  - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts
  - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts
  - computeDelta + getSparklinePoints → statistics.utils.ts

no-multi-comp: split multi-component files into focused companions:
  - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx
  - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx
  - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx
  - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts

fix(tests): add Resume type import to section-builder mocks and cast partial
mock data as unknown as Resume to satisfy stricter type checking; fix
noExplicitAny Biome errors in the same mocks.

* feat(applications): improve performance

* chore: fix knip issues

* perf(builder): halve per-keystroke render cost

Section-form fields called `form.handleSubmit()` on every keystroke, which
re-validated the whole form and toggled submit state — firing the render
cascade twice per character (~6809 renders/keystroke, FPS dropping to 9).

Persist via a form-level `listeners.onChange` instead and drop the per-field
`handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume
subscriptions to metadata slices so they no longer re-render on content edits.

Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview,
and design controls verified working; 449/449 web tests pass.

* perf(home): eliminate hero CLS from unreserved video box

The hero <section> is `flex items-center` (shrink-to-fit), so the video
wrapper's width depended on the video's intrinsic size, which only resolves
after the media loads. aspect-ratio couldn't reserve height without a definite
width, so the video grew from ~190px to ~563px after first paint and shoved the
centered hero text down ~373px (CLS ~0.095).

Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an
explicit aspect ratio + width/height on the video so its box is reserved before
load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl.

* docs: add application tracker guides

* chore(db): squash application migrations

* fix(email): import React in auth template for server-side rendering compatibility

* chore(release): v5.2.1

* Refactor resume rendering and builder workflows

* fix: address application tracker review findings
2026-07-05 23:44:04 +02:00
734 changed files with 165769 additions and 15276 deletions
+35 -2
View File
@@ -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()
+2
View File
@@ -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
+31
View File
@@ -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.
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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"
}
}
+56
View File
@@ -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],
+25 -6
View File
@@ -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));
+1 -3
View File
@@ -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&registryFingerprint=${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();
},
);
});
+72
View File
@@ -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,
);
}
}
+1 -1
View File
@@ -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,
});
+1 -1
View File
@@ -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);
});
+20
View File
@@ -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]);
}
+147
View File
@@ -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"],
});
});
});
+80
View File
@@ -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"),
});
}
+4 -46
View File
@@ -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 });
+42
View File
@@ -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 -12
View File
@@ -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, {
+9 -2
View File
@@ -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();
+29
View File
@@ -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);
});
});
+2 -5
View File
@@ -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",
+2
View File
@@ -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 () => {
-2
View File
@@ -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 });
}
+98 -24
View File
@@ -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"));
+124 -4
View File
@@ -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: [],
});
});
}
+4 -1
View File
@@ -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",
+6 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+915 -40
View File
File diff suppressed because it is too large Load Diff
+937 -62
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+922 -47
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+921 -46
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+924 -49
View File
File diff suppressed because it is too large Load Diff
+924 -49
View File
File diff suppressed because it is too large Load Diff
+924 -49
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+924 -49
View File
File diff suppressed because it is too large Load Diff
+924 -49
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+923 -48
View File
File diff suppressed because it is too large Load Diff
+913 -38
View File
File diff suppressed because it is too large Load Diff
+56 -42
View File
@@ -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(
+2 -2
View File
@@ -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);
+8 -22
View File
@@ -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", () => {
+1 -7
View File
@@ -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