mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b412d84d | ||
|
|
7eea6675c0 | ||
|
|
273e17c0d3 | ||
|
|
17cddbad65 | ||
|
|
7557ab13ab | ||
|
|
c66560ee12 | ||
|
|
24c882fa9f | ||
|
|
86fff7237f | ||
|
|
266bc291eb | ||
|
|
6ec4da7914 | ||
|
|
75e9446134 | ||
|
|
39e88dd365 | ||
|
|
3596102c63 | ||
|
|
c77684d317 | ||
|
|
62f8270b3e | ||
|
|
5b1297fa2b | ||
|
|
dd7623f11e | ||
|
|
63e8c3ca33 | ||
|
|
e62090cce0 | ||
|
|
0510c7103b | ||
|
|
1a5c5252d1 | ||
|
|
9df2a5287d | ||
|
|
6d8d8f6e55 | ||
|
|
22c60c64b6 | ||
|
|
affa1d6646 | ||
|
|
c71f3b0b92 | ||
|
|
6c4a4b2aa5 | ||
|
|
1294d3354a | ||
|
|
42fc78dca1 | ||
|
|
aa7af040fb | ||
|
|
5f63dc876b | ||
|
|
014ceee31f | ||
|
|
70dff5bf49 | ||
|
|
c5787fe155 | ||
|
|
286e165a60 | ||
|
|
00dafd0c68 | ||
|
|
d251d602fb | ||
|
|
e35ff83911 | ||
|
|
62b0a1d533 | ||
|
|
0a8fe05653 | ||
|
|
de7baa5faf | ||
|
|
48555f58e5 | ||
|
|
0daf868cd4 | ||
|
|
e574d4005f | ||
|
|
fda4e500b3 | ||
|
|
adfc9b527b | ||
|
|
71aadbd73d | ||
|
|
0713cf20d4 | ||
|
|
334ea48bc7 | ||
|
|
69c23211a0 | ||
|
|
143aaa741b | ||
|
|
e4cc6a8e57 | ||
|
|
92a0e3ddb8 | ||
|
|
4ebe9e5a67 | ||
|
|
0abee1048c | ||
|
|
83a407bc10 | ||
|
|
02973a1eb1 | ||
|
|
2e04e71f4a | ||
|
|
978cbaf1f3 | ||
|
|
3cd228bd84 | ||
|
|
64ac3ff328 | ||
|
|
846b7856a7 | ||
|
|
7a60a42a04 | ||
|
|
b0de64ad13 | ||
|
|
b321e01658 | ||
|
|
6a01207b6b | ||
|
|
2f6a8904e4 | ||
|
|
56c9eb2ff4 | ||
|
|
33103536ae | ||
|
|
a93e7bd190 | ||
|
|
4cd4b8c193 | ||
|
|
be9285aa33 | ||
|
|
6787175a8a | ||
|
|
42e83cc676 | ||
|
|
62f4532157 | ||
|
|
fabe22089d | ||
|
|
fa38f3e84a | ||
|
|
0606e0072b | ||
|
|
bdfb854602 |
@@ -3,6 +3,7 @@
|
|||||||
.gitignore
|
.gitignore
|
||||||
.cursor
|
.cursor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.vite-hooks
|
||||||
|
|
||||||
# Local configuration and runtime state
|
# Local configuration and runtime state
|
||||||
.env*
|
.env*
|
||||||
|
|||||||
+28
-9
@@ -1,7 +1,10 @@
|
|||||||
# --- Application ---
|
# --- Application ---
|
||||||
# Port used by the web server in local development and self-hosted containers.
|
# Public port used by the production server and the Vite web server in local development.
|
||||||
PORT="3000"
|
PORT="3000"
|
||||||
|
|
||||||
|
# Port used by the Hono server in local development. Vite proxies API requests to this port.
|
||||||
|
SERVER_PORT="3001"
|
||||||
|
|
||||||
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
|
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
|
||||||
# OpenGraph metadata, and absolute upload URLs.
|
# OpenGraph metadata, and absolute upload URLs.
|
||||||
APP_URL="http://localhost:3000"
|
APP_URL="http://localhost:3000"
|
||||||
@@ -9,7 +12,7 @@ APP_URL="http://localhost:3000"
|
|||||||
# --- Database (PostgreSQL) ---
|
# --- Database (PostgreSQL) ---
|
||||||
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
|
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
|
||||||
# when running directly on your machine, `localhost` is typical.
|
# when running directly on your machine, `localhost` is typical.
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
|
||||||
|
|
||||||
# --- Authentication ---
|
# --- Authentication ---
|
||||||
# Generated using `openssl rand -hex 32`
|
# Generated using `openssl rand -hex 32`
|
||||||
@@ -48,15 +51,11 @@ OAUTH_USER_INFO_URL=""
|
|||||||
# Space-separated scopes requested from the custom OAuth provider.
|
# Space-separated scopes requested from the custom OAuth provider.
|
||||||
OAUTH_SCOPES="openid profile email"
|
OAUTH_SCOPES="openid profile email"
|
||||||
|
|
||||||
# Comma-separated extra hosts/origins allowed for dynamic OAuth client redirect URIs.
|
|
||||||
# By default, only the APP_URL origin is allowed.
|
|
||||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=""
|
|
||||||
|
|
||||||
# --- Email (optional) ---
|
# --- Email (optional) ---
|
||||||
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
|
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
|
||||||
# email to the console instead.
|
# email to the console instead.
|
||||||
SMTP_HOST="localhost"
|
SMTP_HOST=""
|
||||||
SMTP_PORT="1025"
|
SMTP_PORT=""
|
||||||
SMTP_USER=""
|
SMTP_USER=""
|
||||||
SMTP_PASS=""
|
SMTP_PASS=""
|
||||||
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
||||||
@@ -65,15 +64,23 @@ SMTP_SECURE="false"
|
|||||||
# --- Storage (optional) ---
|
# --- Storage (optional) ---
|
||||||
# If all S3 keys are disabled, the app uses local filesystem storage instead.
|
# If all S3 keys are disabled, the app uses local filesystem storage instead.
|
||||||
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
|
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
|
||||||
|
# LOCAL_STORAGE_PATH overrides where local uploads/cache are written.
|
||||||
|
# Defaults to /app/data in the official Docker image; in dev, defaults to <workspace>/data.
|
||||||
|
# LOCAL_STORAGE_PATH="/app/data"
|
||||||
|
|
||||||
# Seaweedfs
|
# Seaweedfs
|
||||||
S3_ACCESS_KEY_ID="seaweedfs"
|
S3_ACCESS_KEY_ID="seaweedfs"
|
||||||
S3_SECRET_ACCESS_KEY="seaweedfs"
|
S3_SECRET_ACCESS_KEY="seaweedfs"
|
||||||
S3_REGION="us-east-1"
|
S3_REGION="us-east-1"
|
||||||
S3_ENDPOINT="http://localhost:8333"
|
S3_ENDPOINT="http://seaweedfs:8333"
|
||||||
S3_BUCKET="reactive-resume"
|
S3_BUCKET="reactive-resume"
|
||||||
S3_FORCE_PATH_STYLE="true"
|
S3_FORCE_PATH_STYLE="true"
|
||||||
|
|
||||||
|
# --- AI Agent Workspace (optional) ---
|
||||||
|
# Required only for the authenticated /agent workspace and saved AI providers.
|
||||||
|
REDIS_URL="redis://redis:6379"
|
||||||
|
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
|
||||||
|
|
||||||
# --- Feature Flags ---
|
# --- Feature Flags ---
|
||||||
# This flag disables new signups, both on the web app and the server.
|
# This flag disables new signups, both on the web app and the server.
|
||||||
FLAG_DISABLE_SIGNUPS="false"
|
FLAG_DISABLE_SIGNUPS="false"
|
||||||
@@ -86,6 +93,18 @@ FLAG_DISABLE_EMAIL_AUTH="false"
|
|||||||
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
||||||
FLAG_DISABLE_IMAGE_PROCESSING="false"
|
FLAG_DISABLE_IMAGE_PROCESSING="false"
|
||||||
|
|
||||||
|
# Allows dynamic OAuth client registration to use any parseable redirect URI,
|
||||||
|
# including custom schemes, private hosts, and non-loopback http:// URLs.
|
||||||
|
# WARNING: Enabling this on a public or multi-tenant deployment can enable phishing
|
||||||
|
# or token exfiltration. Only enable this on a trusted, self-hosted instance.
|
||||||
|
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI="false"
|
||||||
|
|
||||||
|
# Allows AI providers to be configured with any base URL, including http:// and
|
||||||
|
# private/loopback addresses (e.g. http://localhost:11434 for a local Ollama instance).
|
||||||
|
# WARNING: Enabling this on a multi-tenant deployment is a Server-Side Request Forgery (SSRF)
|
||||||
|
# risk. Only enable this on a trusted, single-tenant self-hosted instance.
|
||||||
|
FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
|
||||||
|
|
||||||
# --- Others ---
|
# --- Others ---
|
||||||
# Google Cloud API Key (optional)
|
# Google Cloud API Key (optional)
|
||||||
# For font-list generation tooling.
|
# For font-list generation tooling.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
"guid": "open-collective",
|
"guid": "open-collective",
|
||||||
"type": "payment-provider",
|
"type": "payment-provider",
|
||||||
"description": "Open Collective",
|
"description": "Open Collective",
|
||||||
"address": "https://opencollective.com/reactive-resume"
|
"address": "https://opencollective.com/reactive-resume/donate"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ body:
|
|||||||
- Onyx
|
- Onyx
|
||||||
- Pikachu
|
- Pikachu
|
||||||
- Rhyhorn
|
- Rhyhorn
|
||||||
|
- Scizor
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ name: Build Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -12,17 +17,34 @@ env:
|
|||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
mode:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
nightly: ${{ steps.mode.outputs.nightly }}
|
||||||
|
release: ${{ steps.mode.outputs.release }}
|
||||||
|
matrix: ${{ steps.mode.outputs.matrix }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Determine publishing mode
|
||||||
|
id: mode
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||||
|
echo "nightly=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"}]}' >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "nightly=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"},{"platform":"linux/arm64","runner":"ubuntu-24.04-arm","arch":"arm64"}]}' >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: mode
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix: ${{ fromJSON(needs.mode.outputs.matrix) }}
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
arch: amd64
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
arch: arm64
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
@@ -97,7 +119,9 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
merge:
|
merge:
|
||||||
needs: build
|
needs:
|
||||||
|
- mode
|
||||||
|
- build
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@@ -160,16 +184,25 @@ jobs:
|
|||||||
docker.io/${{ env.IMAGE }}
|
docker.io/${{ env.IMAGE }}
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,prefix=sha-
|
type=sha,prefix=sha-
|
||||||
type=raw,value=latest
|
type=raw,value=nightly,enable=${{ needs.mode.outputs.nightly == 'true' }}
|
||||||
type=raw,value=v${{ steps.version.outputs.version }}
|
type=raw,value=nightly-{{date 'YYYYMMDDHHmmss' tz='UTC'}},enable=${{ needs.mode.outputs.nightly == 'true' }}
|
||||||
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }}
|
type=raw,value=latest,enable=${{ needs.mode.outputs.release == 'true' }}
|
||||||
type=raw,value=v${{ steps.semver.outputs.major }}
|
type=raw,value=v${{ steps.version.outputs.version }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||||
|
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||||
|
type=raw,value=v${{ steps.semver.outputs.major }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||||
|
|
||||||
- name: Create manifest list and push
|
- name: Create manifest list and push
|
||||||
id: manifest
|
id: manifest
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${{ needs.mode.outputs.nightly }}" == "true" ]]; then
|
||||||
|
FINAL_TAG="nightly"
|
||||||
|
else
|
||||||
|
FINAL_TAG="v${{ steps.version.outputs.version }}"
|
||||||
|
fi
|
||||||
|
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
--annotation "index:org.opencontainers.image.licenses=MIT" \
|
--annotation "index:org.opencontainers.image.licenses=MIT" \
|
||||||
@@ -184,8 +217,9 @@ jobs:
|
|||||||
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
|
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
|
||||||
|
|
||||||
# Get the digest of the multi-arch manifest
|
# Get the digest of the multi-arch manifest
|
||||||
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||||
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||||
|
echo "final_tag=$FINAL_TAG" >> "$GITHUB_OUTPUT"
|
||||||
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
|
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
|
||||||
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
|
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -202,10 +236,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
|
||||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
|
||||||
|
|
||||||
- name: Redeploy Stack
|
- name: Redeploy Stack
|
||||||
|
if: ${{ needs.mode.outputs.release == 'true' }}
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
key: ${{ secrets.SSH_KEY }}
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
|||||||
+7
-2
@@ -3,7 +3,7 @@ node_modules
|
|||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
|
||||||
# Build Outputs
|
# Build Outputs
|
||||||
.output
|
dist
|
||||||
.vercel
|
.vercel
|
||||||
.wrangler
|
.wrangler
|
||||||
|
|
||||||
@@ -44,10 +44,15 @@ temp
|
|||||||
|
|
||||||
# AI
|
# AI
|
||||||
.codex
|
.codex
|
||||||
|
.agents
|
||||||
.claude
|
.claude
|
||||||
.cursor
|
.cursor
|
||||||
docs/superpowers
|
.codegraph
|
||||||
|
.superpowers
|
||||||
|
|
||||||
# Local Storage Data
|
# Local Storage Data
|
||||||
/data
|
/data
|
||||||
/apps/web/data
|
/apps/web/data
|
||||||
|
|
||||||
|
# Git Hooks
|
||||||
|
.vite-hooks/
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const betaPackages = ["drizzle-orm", "drizzle-kit", "drizzle-zod"];
|
const betaPackages = ["drizzle-zod"];
|
||||||
|
const rcPackages = ["drizzle-orm", "drizzle-kit"];
|
||||||
|
|
||||||
/** @type {import('npm-check-updates').RunOptions} */
|
/** @type {import('npm-check-updates').RunOptions} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -10,6 +11,7 @@ module.exports = {
|
|||||||
packageManager: "pnpm",
|
packageManager: "pnpm",
|
||||||
target: (packageName) => {
|
target: (packageName) => {
|
||||||
if (betaPackages.includes(packageName)) return "@beta";
|
if (betaPackages.includes(packageName)) return "@beta";
|
||||||
|
if (rcPackages.includes(packageName)) return "@rc";
|
||||||
return "latest";
|
return "latest";
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
|
|
||||||
set -x
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$LEFTHOOK" = "0" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
call_lefthook()
|
|
||||||
{
|
|
||||||
if test -n "$LEFTHOOK_BIN"
|
|
||||||
then
|
|
||||||
"$LEFTHOOK_BIN" "$@"
|
|
||||||
elif lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
lefthook "$@"
|
|
||||||
elif /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
/Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook "$@"
|
|
||||||
else
|
|
||||||
dir="$(git rev-parse --show-toplevel)"
|
|
||||||
osArch=$(uname | tr '[:upper:]' '[:lower:]')
|
|
||||||
cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
|
|
||||||
if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
|
|
||||||
then
|
|
||||||
"$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
|
|
||||||
elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
|
|
||||||
then
|
|
||||||
"$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
|
|
||||||
elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
|
|
||||||
then
|
|
||||||
"$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
|
|
||||||
elif test -f "$dir/node_modules/lefthook/bin/index.js"
|
|
||||||
then
|
|
||||||
"$dir/node_modules/lefthook/bin/index.js" "$@"
|
|
||||||
elif go tool lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
go tool lefthook "$@"
|
|
||||||
elif bundle exec lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
bundle exec lefthook "$@"
|
|
||||||
elif yarn lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
yarn lefthook "$@"
|
|
||||||
elif pnpm lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
pnpm lefthook "$@"
|
|
||||||
elif swift package lefthook >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
|
|
||||||
elif command -v mint >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
mint run csjones/lefthook-plugin "$@"
|
|
||||||
elif uv run lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
uv run lefthook "$@"
|
|
||||||
elif mise exec -- lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
mise exec -- lefthook "$@"
|
|
||||||
elif devbox run lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
devbox run lefthook "$@"
|
|
||||||
else
|
|
||||||
echo "Can't find lefthook in PATH"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
call_lefthook run "commit-msg" "$@"
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
|
|
||||||
set -x
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$LEFTHOOK" = "0" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
call_lefthook()
|
|
||||||
{
|
|
||||||
if test -n "$LEFTHOOK_BIN"
|
|
||||||
then
|
|
||||||
"$LEFTHOOK_BIN" "$@"
|
|
||||||
elif lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
lefthook "$@"
|
|
||||||
elif /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
/Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook "$@"
|
|
||||||
else
|
|
||||||
dir="$(git rev-parse --show-toplevel)"
|
|
||||||
osArch=$(uname | tr '[:upper:]' '[:lower:]')
|
|
||||||
cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
|
|
||||||
if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
|
|
||||||
then
|
|
||||||
"$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
|
|
||||||
elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
|
|
||||||
then
|
|
||||||
"$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
|
|
||||||
elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
|
|
||||||
then
|
|
||||||
"$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
|
|
||||||
elif test -f "$dir/node_modules/lefthook/bin/index.js"
|
|
||||||
then
|
|
||||||
"$dir/node_modules/lefthook/bin/index.js" "$@"
|
|
||||||
elif go tool lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
go tool lefthook "$@"
|
|
||||||
elif bundle exec lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
bundle exec lefthook "$@"
|
|
||||||
elif yarn lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
yarn lefthook "$@"
|
|
||||||
elif pnpm lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
pnpm lefthook "$@"
|
|
||||||
elif swift package lefthook >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
|
|
||||||
elif command -v mint >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
mint run csjones/lefthook-plugin "$@"
|
|
||||||
elif uv run lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
uv run lefthook "$@"
|
|
||||||
elif mise exec -- lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
mise exec -- lefthook "$@"
|
|
||||||
elif devbox run lefthook -h >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
devbox run lefthook "$@"
|
|
||||||
else
|
|
||||||
echo "Can't find lefthook in PATH"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
call_lefthook run "pre-commit" "$@"
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Cursor Cloud specific instructions
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (TanStack Start / React 19 / Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000, with `apps/server` mounting the API/auth/MCP/static routes and serving the built web app.
|
||||||
|
|
||||||
|
Internal packages are source-consumed through `package.json` export maps that point at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **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`).
|
||||||
|
|
||||||
|
### Codebase map
|
||||||
|
|
||||||
|
- `apps/web` owns TanStack Start routes, Vite config, PWA setup, oRPC browser client wiring, web features, and the resume builder UI.
|
||||||
|
- `apps/server` owns the production Hono app, route composition, auth/RPC/MCP/OpenAPI handlers, static uploads, schema JSON, web-dist fallback serving, and startup checks.
|
||||||
|
- `packages/api` contains oRPC routers, DTOs, rate limiting, and feature-owned API modules under `packages/api/src/features/*`. The router export at `@reactive-resume/api/routers` aggregates those feature routers for `/api/rpc`.
|
||||||
|
- `packages/auth` contains Better Auth config, auth helper functions, and exported auth types. The server auth adapter in `apps/server/src/http/auth.ts` delegates to `auth.handler`.
|
||||||
|
- `packages/db` contains the Drizzle client and schema. Migration files live at the repo root in `migrations/`.
|
||||||
|
- `packages/env` defines server environment validation and auto-loads the root `.env` for app/server code.
|
||||||
|
- `packages/schema` contains Zod schemas and typed resume/page/template models.
|
||||||
|
- `packages/pdf` contains the React PDF document, font registration, shared template primitives, template implementations, and browser/server PDF generation adapters. PDF.js viewer UI stays in `apps/web`.
|
||||||
|
- `packages/resume` contains pure resume-domain behavior such as JSON Patch helpers and social-network icon mapping.
|
||||||
|
- `packages/docx` contains DOCX export generation.
|
||||||
|
- `packages/mcp` contains MCP tools, prompts, resources, server-card generation, and tool metadata.
|
||||||
|
- `packages/ui` contains shared Base UI/shadcn-style components and hooks.
|
||||||
|
- `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, and `packages/config` provide focused support surfaces. Prefer their existing exports over adding cross-package shortcuts.
|
||||||
|
- Development-only scripts live in `tooling/`, not under `packages/`, so packages only contain code bundled by the app/runtime.
|
||||||
|
|
||||||
|
### Web app conventions
|
||||||
|
|
||||||
|
- Routes are file-based under `apps/web/src/routes`. Do not hand-edit `apps/web/src/routeTree.gen.ts`; it is generated by TanStack Router tooling.
|
||||||
|
- Server-owned HTTP behavior lives in `apps/server/src/{http,rpc,mcp,openapi,static,startup}`. Keep API/RPC/auth/MCP/static route wiring in `apps/server`, not in web routes.
|
||||||
|
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context where possible instead of refetching these concerns ad hoc.
|
||||||
|
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
|
||||||
|
- Browser-only resume preview code lives under `apps/web/src/features/resume/preview`, and public resume PDF viewer code lives under `apps/web/src/features/resume/public`. Keep PDF.js/canvas/browser APIs out of SSR paths and out of `packages/pdf`.
|
||||||
|
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
|
||||||
|
|
||||||
|
### Package and feature boundaries
|
||||||
|
|
||||||
|
- Workspace dependencies must go through package names and package export maps. Do not import another workspace's `src` tree through repository paths, `@reactive-resume/*/src/*`, or TypeScript path aliases.
|
||||||
|
- `turbo boundaries` is the executable package-boundary check. Workspace-level `turbo.json` files declare coarse tags:
|
||||||
|
- `app:web` for the TanStack Start app.
|
||||||
|
- `app:server` and `runtime:server` for the Node/Hono process.
|
||||||
|
- `runtime:server` for server-only packages such as API/auth/db/env/email/MCP.
|
||||||
|
- `runtime:browser` for browser-only shared UI.
|
||||||
|
- `runtime:universal` for environment-neutral domain packages.
|
||||||
|
- `role:domain`, `role:infra`, `role:adapter`, `role:api`, `role:rendering`, and `role:tooling` for package intent.
|
||||||
|
- Browser/server runtime-specific code should live behind explicit export subpaths such as `@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, or `@reactive-resume/env/server`. Keep root exports environment-neutral unless the package is intentionally server-only.
|
||||||
|
- Wildcard exports are allowed only for leaf libraries whose public surface is intentionally file-like, currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume model files. Prefer explicit exports for packages that own runtime behavior.
|
||||||
|
- Add new API procedures and business logic inside the owning `packages/api/src/features/*` module. Keep route wiring, DTO usage, helpers, and services colocated by feature/capability, then expose only intentional public surfaces through `packages/api/package.json`. Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures.
|
||||||
|
- Add database columns/tables in `packages/db/src/schema/*`, then generate root-level migrations with `dotenvx run -f .env.local -- pnpm db:generate`.
|
||||||
|
- Add or change resume data shape in `packages/schema/src/resume/*` first, then update API DTOs, importers, PDF rendering, and web forms that consume that shape.
|
||||||
|
- Add or rename templates in all relevant places: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, template source under `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}`.
|
||||||
|
- Resume JSON Patch behavior belongs in `@reactive-resume/resume/patch`; do not put resume-domain helpers in `@reactive-resume/utils`.
|
||||||
|
- DOCX export behavior belongs in `@reactive-resume/docx`; do not put DOCX builders in `@reactive-resume/utils`.
|
||||||
|
- Shared PDF section filtering lives in `packages/pdf/src/templates/shared/filtering.ts`. Keep template-specific visual exceptions in the owning template directory unless multiple templates need the same behavior.
|
||||||
|
- `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation behavior.
|
||||||
|
- PDF generation helpers live behind `@reactive-resume/pdf/browser` and `@reactive-resume/pdf/server`; locale-specific section-title resolution stays in the caller.
|
||||||
|
- MCP implementation belongs in `@reactive-resume/mcp`; app packages must not import MCP implementation from another app's source tree.
|
||||||
|
- `packages/utils` has narrowly exported helpers. If another package needs a utility, add an explicit export path instead of importing private files.
|
||||||
|
|
||||||
|
Placement decision tree:
|
||||||
|
|
||||||
|
1. If the change is a web route, route loader, or user-facing web workflow, start in `apps/web/src/routes` or `apps/web/src/features`.
|
||||||
|
2. If the change is a server HTTP route/adapter, startup check, static handler, MCP transport, or OpenAPI/well-known handler, start in `apps/server/src`.
|
||||||
|
3. If it is authenticated API behavior, put the contract and implementation in the owning `packages/api/src/features/*` module.
|
||||||
|
4. If it is pure resume data behavior with no DB, HTTP, DOM, or PDF renderer dependency, put it in `packages/resume`.
|
||||||
|
5. If it renders resume PDFs, put shared React PDF/template code in `packages/pdf`; put PDF.js viewer/canvas UI in `apps/web/src/features/resume`.
|
||||||
|
6. If it creates DOCX exports, put it in `packages/docx`.
|
||||||
|
7. If it exposes MCP tools/prompts/resources, put it in `packages/mcp`.
|
||||||
|
8. If it is a generic UI primitive or hook, put it in `packages/ui`; if it is workflow-specific UI, keep it in the owning web feature.
|
||||||
|
9. If it is a narrow cross-cutting helper, add an explicit `packages/utils` export only after checking that no domain package is a better owner.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
PostgreSQL runs via Docker Compose:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo docker compose -f compose.dev.yml up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
|
||||||
|
|
||||||
|
**Important**: `drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly — it does **not** auto-load the `.env` file. Run migration commands through `dotenvx`, for example `dotenvx run -f .env.local -- pnpm db:migrate`, so `DATABASE_URL` is present in the process environment.
|
||||||
|
|
||||||
|
The production server runs migrations during startup before serving traffic. Manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env`. The three required variables are:
|
||||||
|
|
||||||
|
- `APP_URL` (default `http://localhost:3000`)
|
||||||
|
- `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`)
|
||||||
|
- `AUTH_SECRET` (any non-empty string)
|
||||||
|
|
||||||
|
S3/SeaweedFS is optional. If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. The checked-in `.env.example` sets SeaweedFS defaults, so either start the `seaweedfs` compose service too or comment out those S3 vars to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
|
||||||
|
|
||||||
|
When running dev servers or migration commands, prefix the command with `dotenvx run -f .env.local --`. For example: `dotenvx run -f .env.local -- pnpm dev`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need this prefix by default. If one of those commands fails because a specific environment variable is required, rerun it with the `dotenvx run -f .env.local --` prefix.
|
||||||
|
|
||||||
|
### Common commands
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Install deps | `pnpm install` |
|
||||||
|
| Start Postgres only | `sudo docker compose -f compose.dev.yml up -d postgres` |
|
||||||
|
| Start Postgres + SeaweedFS | `sudo docker compose -f compose.dev.yml up -d postgres seaweedfs seaweedfs_create_bucket` |
|
||||||
|
| Generate migrations | `dotenvx run -f .env.local -- pnpm db:generate` |
|
||||||
|
| Run migrations | `dotenvx run -f .env.local -- pnpm db:migrate` |
|
||||||
|
| Dev server | `dotenvx run -f .env.local -- pnpm dev` (starts on port 3000) |
|
||||||
|
| Web dev server only | `dotenvx run -f .env.local -- pnpm dev:web` |
|
||||||
|
| Lint/format | `pnpm check` (Biome) |
|
||||||
|
| Boundary check | `pnpm exec turbo boundaries` |
|
||||||
|
| Tests | `pnpm test` (Vitest) |
|
||||||
|
| Build | `pnpm build` |
|
||||||
|
| Typecheck | `pnpm typecheck` |
|
||||||
|
|
||||||
|
For focused validation, prefer package filters before repo-wide commands, for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm --filter web typecheck
|
||||||
|
pnpm --filter @reactive-resume/pdf test
|
||||||
|
pnpm --filter @reactive-resume/api test
|
||||||
|
pnpm exec turbo boundaries
|
||||||
|
```
|
||||||
|
|
||||||
|
Vitest test paths are package-relative when running through `pnpm --filter <package> test -- <path>`.
|
||||||
|
|
||||||
|
### Gotchas
|
||||||
|
|
||||||
|
- The server startup path auto-runs migrations before serving traffic, so `pnpm db:migrate` is mainly needed for first-time setup, migration debugging, or applying migrations without starting the app.
|
||||||
|
- Email sending requires SMTP config; without it, emails are logged to console. This is fine for dev — the app still functions, but email verification links appear in server logs.
|
||||||
|
- The `lefthook.yml` pre-commit hook runs `biome check` on staged files. Run `pnpm check` before committing to avoid hook failures.
|
||||||
|
- `pnpm check` is write-capable (`biome check --write --unsafe .`). Call that out when using it, and use narrower Biome commands if you need a non-mutating inspection.
|
||||||
|
- Biome uses tabs, double quotes, line width 120, organized import groups, and sorted Tailwind classes for `clsx`, `cva`, and `cn`.
|
||||||
|
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
|
||||||
|
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
|
||||||
|
- **New env vars require a `turbo.json` entry.** Turborepo 2.x runs in strict env mode by default — it filters out env vars that are not listed in `globalEnv` (or task-level `env`/`passThroughEnv`). Any new environment variable added to `packages/env/src/server.ts` must also be added to the `globalEnv` array in `turbo.json`, or the variable will be `undefined` inside child processes at runtime even if it is correctly set in the OS/container environment.
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
---
|
||||||
|
version: alpha
|
||||||
|
name: Reactive Resume
|
||||||
|
description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support.
|
||||||
|
colors:
|
||||||
|
primary: "#343434"
|
||||||
|
primary-foreground: "#FBFBFB"
|
||||||
|
secondary: "#F7F7F7"
|
||||||
|
secondary-foreground: "#343434"
|
||||||
|
background: "#FFFFFF"
|
||||||
|
foreground: "#252525"
|
||||||
|
muted: "#F7F7F7"
|
||||||
|
muted-foreground: "#8E8E8E"
|
||||||
|
card: "#FFFFFF"
|
||||||
|
card-foreground: "#252525"
|
||||||
|
border: "#EBEBEB"
|
||||||
|
input: "#EBEBEB"
|
||||||
|
ring: "#B5B5B5"
|
||||||
|
destructive: "#DC2626"
|
||||||
|
on-destructive: "#FFFFFF"
|
||||||
|
typography:
|
||||||
|
heading:
|
||||||
|
fontFamily: IBM Plex Sans Variable
|
||||||
|
fontSize: 1rem
|
||||||
|
fontWeight: 500
|
||||||
|
body:
|
||||||
|
fontFamily: IBM Plex Sans Variable
|
||||||
|
fontSize: 0.875rem
|
||||||
|
fontWeight: 400
|
||||||
|
body-sm:
|
||||||
|
fontFamily: IBM Plex Sans Variable
|
||||||
|
fontSize: 0.75rem
|
||||||
|
fontWeight: 400
|
||||||
|
label:
|
||||||
|
fontFamily: IBM Plex Sans Variable
|
||||||
|
fontSize: 0.8rem
|
||||||
|
fontWeight: 500
|
||||||
|
hero-heading:
|
||||||
|
fontFamily: IBM Plex Sans Variable
|
||||||
|
fontSize: 3.75rem
|
||||||
|
fontWeight: 700
|
||||||
|
letterSpacing: -0.025em
|
||||||
|
rounded:
|
||||||
|
sm: 0.18rem
|
||||||
|
md: 0.24rem
|
||||||
|
lg: 0.3rem
|
||||||
|
xl: 0.42rem
|
||||||
|
2xl: 0.54rem
|
||||||
|
3xl: 0.66rem
|
||||||
|
4xl: 0.78rem
|
||||||
|
spacing:
|
||||||
|
xs: 4px
|
||||||
|
sm: 8px
|
||||||
|
md: 16px
|
||||||
|
lg: 24px
|
||||||
|
xl: 32px
|
||||||
|
2xl: 48px
|
||||||
|
components:
|
||||||
|
button-default:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.primary-foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 10px
|
||||||
|
height: 36px
|
||||||
|
button-outline:
|
||||||
|
backgroundColor: "{colors.background}"
|
||||||
|
textColor: "{colors.foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 10px
|
||||||
|
height: 36px
|
||||||
|
button-secondary:
|
||||||
|
backgroundColor: "{colors.secondary}"
|
||||||
|
textColor: "{colors.secondary-foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 10px
|
||||||
|
height: 36px
|
||||||
|
button-ghost:
|
||||||
|
backgroundColor: "{colors.background}"
|
||||||
|
textColor: "{colors.foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 10px
|
||||||
|
height: 36px
|
||||||
|
button-destructive:
|
||||||
|
backgroundColor: "{colors.destructive}"
|
||||||
|
textColor: "{colors.on-destructive}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 10px
|
||||||
|
height: 36px
|
||||||
|
card:
|
||||||
|
backgroundColor: "{colors.card}"
|
||||||
|
textColor: "{colors.card-foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 16px
|
||||||
|
input:
|
||||||
|
backgroundColor: "{colors.background}"
|
||||||
|
textColor: "{colors.foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
height: 36px
|
||||||
|
padding: 10px
|
||||||
|
input-focus:
|
||||||
|
backgroundColor: "{colors.background}"
|
||||||
|
textColor: "{colors.foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
height: 36px
|
||||||
|
padding: 10px
|
||||||
|
badge:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.primary-foreground}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: 4px
|
||||||
|
popover:
|
||||||
|
backgroundColor: "{colors.card}"
|
||||||
|
textColor: "{colors.card-foreground}"
|
||||||
|
rounded: "{rounded.xl}"
|
||||||
|
padding: 4px
|
||||||
|
sidebar:
|
||||||
|
backgroundColor: "{colors.muted}"
|
||||||
|
textColor: "{colors.foreground}"
|
||||||
|
padding: 8px
|
||||||
|
sidebar-item:
|
||||||
|
backgroundColor: "{colors.muted}"
|
||||||
|
textColor: "{colors.muted-foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 8px
|
||||||
|
sidebar-item-active:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.primary-foreground}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 8px
|
||||||
|
tooltip:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.primary-foreground}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: 6px
|
||||||
|
separator:
|
||||||
|
backgroundColor: "{colors.border}"
|
||||||
|
height: 1px
|
||||||
|
dialog:
|
||||||
|
backgroundColor: "{colors.card}"
|
||||||
|
textColor: "{colors.card-foreground}"
|
||||||
|
rounded: "{rounded.xl}"
|
||||||
|
padding: 24px
|
||||||
|
input-invalid:
|
||||||
|
backgroundColor: "{colors.background}"
|
||||||
|
textColor: "{colors.destructive}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
height: 36px
|
||||||
|
padding: 10px
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it.
|
||||||
|
|
||||||
|
The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page.
|
||||||
|
|
||||||
|
The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site.
|
||||||
|
|
||||||
|
One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic").
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable.
|
||||||
|
|
||||||
|
- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent.
|
||||||
|
- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes.
|
||||||
|
- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode.
|
||||||
|
- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth.
|
||||||
|
- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs.
|
||||||
|
- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually.
|
||||||
|
- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color.
|
||||||
|
- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable.
|
||||||
|
- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning.
|
||||||
|
- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements.
|
||||||
|
- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization.
|
||||||
|
|
||||||
|
Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100–900) and excellent readability at small sizes, both on screen and in PDFs.
|
||||||
|
|
||||||
|
- **Hero heading (responsive: 2.25rem mobile / 3rem tablet / 3.75rem desktop, weight 700, tracking-tight):** Landing page headline only. Large, bold, and commanding. Scales across three breakpoints.
|
||||||
|
- **Section heading (1rem / 16px, weight 500):** Used for section titles in the builder sidebar, settings panels, and dashboard cards. Medium weight provides hierarchy without shouting.
|
||||||
|
- **Body (0.875rem / 14px, weight 400):** The workhorse. All form labels, descriptions, card content, and general UI text.
|
||||||
|
- **Small body (0.75rem / 12px, weight 400):** Captions, helper text, timestamps, and metadata.
|
||||||
|
- **Label (0.8rem / ~13px, weight 500):** Button text, badge labels, and form field labels. Slightly heavier than body to denote interactivity.
|
||||||
|
|
||||||
|
The resume content itself uses a separate font system — users choose from 1,000+ Google Fonts for their resume headings and body text, with category-aware fallback stacks including CJK support (Noto Sans SC, PingFang SC, Hiragino Sans GB for sans-serif; Noto Serif SC, Songti SC for serif). Standard PDF fonts (Helvetica, Courier, Times-Roman) are available as offline fallbacks.
|
||||||
|
|
||||||
|
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Builder (Three-Panel Workspace)
|
||||||
|
|
||||||
|
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
|
||||||
|
|
||||||
|
- **Left sidebar (default 22%):** Resume section forms — personal info, experience, education, skills, and custom sections. Scrollable with collapsible section groups.
|
||||||
|
- **Center artboard (default 56%):** Live resume preview rendered via PDF.js canvas. Supports zoom, pan, and pinch gestures via `react-zoom-pan-pinch`. The preview maintains A4 aspect ratio (210:297) with a subtle shadow to simulate a physical page.
|
||||||
|
- **Right sidebar (default 22%):** Design controls — template picker, font selection, color picker, layout manager (page assignments, section ordering via drag-and-drop).
|
||||||
|
|
||||||
|
Panel sizes persist in cookies. On mobile (< 768px), sidebars collapse to 0% width and become toggleable overlays (max 95% width when open). The desktop minimum collapsed width is 48px (icon rail).
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
Standard sidebar navigation layout using the `Sidebar` component system. The sidebar contains: logo, resume list link, agent link, settings subnavigation (profile, preferences, authentication, API keys, integrations, danger zone), and a footer with user avatar. Content area shows a responsive grid of resume cards.
|
||||||
|
|
||||||
|
### Landing Page
|
||||||
|
|
||||||
|
Full-width single-column marketing layout:
|
||||||
|
1. **Floating builder preview** — A non-interactive screenshot of the builder as a hero visual, creating an immediate "this is what you get" impression.
|
||||||
|
2. **Hero** — Centered headline, subheadline, and two CTAs (primary "Get Started" with arrow, ghost "Learn More" with icon).
|
||||||
|
3. **Features grid** — 4-column responsive grid with icon + title + description cards, separated by thin border lines.
|
||||||
|
4. **Template carousel** — Horizontally scrolling row of template preview thumbnails with Pokemon-themed names.
|
||||||
|
5. **Testimonials** — Tiled user quotes in a masonry-style grid.
|
||||||
|
6. **Support / FAQ / Footer** — Accordion FAQ, community section, and a 4-column footer with logo, resource links, community links, and license info.
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
|
||||||
|
Mobile detection uses a 768px threshold via `MediaQueryList`. The layout is optimized for workspace productivity on larger screens, with responsive mobile support that adapts the multi-panel builder into a streamlined single-panel experience. Both desktop and mobile are supported experiences — the builder's three-panel layout leverages desktop space, while mobile surfaces the same editing capabilities through collapsible overlays.
|
||||||
|
|
||||||
|
### Page Aspect Ratio
|
||||||
|
|
||||||
|
A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions wherever resume pages are rendered (builder preview, public view, PDF export).
|
||||||
|
|
||||||
|
## Animation
|
||||||
|
|
||||||
|
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
|
||||||
|
|
||||||
|
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
|
||||||
|
|
||||||
|
**Timing principles:**
|
||||||
|
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
|
||||||
|
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
|
||||||
|
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
|
||||||
|
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
|
||||||
|
|
||||||
|
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
|
||||||
|
|
||||||
|
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
|
||||||
|
|
||||||
|
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
|
||||||
|
|
||||||
|
## Elevation & Depth
|
||||||
|
|
||||||
|
Elevation is handled through background color layering rather than drop shadows:
|
||||||
|
|
||||||
|
- **Level 0 — Background:** The base canvas (`--background`).
|
||||||
|
- **Level 1 — Card:** One step lighter in dark mode (`--card`), used for sidebars, panels, and cards.
|
||||||
|
- **Level 2 — Popover:** Same as card, but appears above the content layer in popovers, dropdowns, and command palette.
|
||||||
|
- **Level 3 — Overlay:** Backdrop blur (`backdrop-blur-xs` at 0.5px or `backdrop-blur-2xl` at 40px) with `backdrop-saturate-150` for modal overlays, creating a frosted-glass effect over the workspace.
|
||||||
|
|
||||||
|
The resume preview page uses a subtle drop shadow to simulate a physical sheet of paper floating above the dark artboard — one of the few places actual shadows appear.
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
Border radius follows a multiplicative scale from a single `--radius` base of `0.3rem`:
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| `sm` | 0.18rem (≈3px) | Small badges, inline chips |
|
||||||
|
| `md` | 0.24rem (≈4px) | XS/SM buttons, compact elements |
|
||||||
|
| `lg` | 0.3rem (≈5px) | Default buttons, cards, inputs |
|
||||||
|
| `xl` | 0.42rem (≈7px) | Larger cards, modal corners |
|
||||||
|
| `2xl` | 0.54rem (≈9px) | Dialog containers |
|
||||||
|
| `3xl` | 0.66rem (≈11px) | Large panels |
|
||||||
|
| `4xl` | 0.78rem (≈12px) | Full-page modals |
|
||||||
|
|
||||||
|
The radius scale is deliberately tight — the largest value (0.78rem) is still quite subtle. This avoids the "rounded everything" aesthetic and keeps the UI feeling precise and tool-like. Interactive elements consistently use `rounded-lg` as the default.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
Six variants, all sharing `rounded-lg` corners, `font-medium`, `text-sm`, and a 1px `translate-y` on active press (except when the button opens a popup):
|
||||||
|
|
||||||
|
- **Default:** Solid primary background. The highest-emphasis action on any screen.
|
||||||
|
- **Outline:** Transparent with a border. For secondary actions that need clear boundaries.
|
||||||
|
- **Secondary:** Muted background. For paired actions alongside a primary button.
|
||||||
|
- **Ghost:** No background or border. For toolbar actions and inline controls where chrome would be noise.
|
||||||
|
- **Destructive:** Red at 10% opacity background with red text. Visually alarming without being garish.
|
||||||
|
- **Link:** Underline-on-hover text. For inline navigation within prose.
|
||||||
|
|
||||||
|
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
White/dark surface with foreground text. Composed of `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, and `CardAction` slots. Default vertical padding is `py-4` (compact: `py-3`).
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
|
||||||
|
Built on TanStack Form with Zod validation. Composed of `FormItem`, `FormLabel`, `FormControl`, `FormMessage`, and `FormDescription`. Validation errors only appear after field touch. Invalid fields get a red destructive border with a ring.
|
||||||
|
|
||||||
|
### Dialogs
|
||||||
|
|
||||||
|
Centralized dialog manager with 40+ dialog types, all rendered via pattern matching (`ts-pattern`). Dialogs support before-close validation, form blocking for unsaved changes, and confirmation prompts. Used for all CRUD operations on resume sections, settings changes, and import/export flows.
|
||||||
|
|
||||||
|
### Command Palette
|
||||||
|
|
||||||
|
Triggered by `Cmd+K` / `Ctrl+K`. Built on `cmdk` with fuzzy search via `Fuse.js`. Multi-page navigation (resumes, settings, preferences) with back navigation via Backspace. Screen-reader accessible with `sr-only` headings.
|
||||||
|
|
||||||
|
### Toast Notifications
|
||||||
|
|
||||||
|
Powered by Sonner, positioned bottom-right with rich colors. Used for auto-save feedback, form submission status, error reporting, and donation prompts. Loading toasts are used during async operations (PDF generation, resume creation) with dismiss-on-complete.
|
||||||
|
|
||||||
|
### Drag and Drop
|
||||||
|
|
||||||
|
Powered by `@dnd-kit` with `PointerSensor` and `KeyboardSensor`. Used in chip inputs (skill tags, URL lists) and page layout management (section ordering across resume pages). Smooth animations via Motion library.
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
|
||||||
|
|
||||||
|
**Direction:** The `<html>` element receives `dir="rtl"` or `dir="ltr"` based on the active locale, detected via `isRTL()` which checks the language prefix against a known RTL set. All layout mirroring flows from this single attribute.
|
||||||
|
|
||||||
|
**Logical properties:** Use CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `inline-start`, `inline-end`, `inset-s-`, `inset-e-`) instead of physical (`pl-`, `pr-`, `ml-`, `mr-`, `left`, `right`). Button components already use `has-data-[icon=inline-start]:ps-2` and `has-data-[icon=inline-end]:pe-2` patterns. This ensures correct spacing in both LTR and RTL layouts without separate stylesheets.
|
||||||
|
|
||||||
|
**Variable-length text:** Translations can be 30–50% longer than English (German, Finnish) or significantly shorter (CJK). UI elements should accommodate variable text length — avoid fixed widths on buttons and labels. Use `whitespace-nowrap` only where truncation is acceptable, and prefer `min-w-0` with `truncate` over fixed-width containers.
|
||||||
|
|
||||||
|
**Icons:** Directional icons (arrows, chevrons, progress indicators) should mirror in RTL contexts. Phosphor Icons provides mirrored variants for directional icons. Non-directional icons (settings gear, checkmark, delete) do not mirror.
|
||||||
|
|
||||||
|
**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, `<Trans>`) — never hardcode English text in components. Translation files are `.po` format under `/locale/`.
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
|
||||||
|
- **Use the grayscale palette for all app chrome.** The absence of color is the brand. The resume content is the only thing that should be colorful.
|
||||||
|
- **Default to dark mode.** The dark workspace makes resume previews pop and reduces eye strain during extended editing sessions.
|
||||||
|
- **Use `text-sm` (14px) as the base text size.** The UI is information-dense — form fields, section labels, metadata — and needs to be scannable without feeling cramped.
|
||||||
|
- **Keep border radius tight.** Use `rounded-lg` (0.3rem) as the default. The tool should feel precise, not playful.
|
||||||
|
- **Respect reduced motion preferences.** All animations collapse to 0.01ms when `prefers-reduced-motion: reduce` is active.
|
||||||
|
- **Use Phosphor Icons consistently.** Regular weight, `size-4` (16px) default. Icons should be functional labels, not decorative.
|
||||||
|
- **Maintain the three-panel builder proportions.** The center artboard should always dominate. Sidebars are support panels, not equal peers.
|
||||||
|
- **Use transparent-white borders in dark mode.** `oklch(1 0 0 / 10%)` blends naturally with any surface rather than introducing a distinct gray band.
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
|
||||||
|
- **Don't introduce accent colors into the app shell.** No blues, greens, or purples for primary actions. The only chromatic color is destructive red. The inherited indigo sidebar-primary token exists in CSS custom properties but is not actively used.
|
||||||
|
- **Don't use drop shadows for elevation.** Rely on background color layering and border separation. The one exception is the resume page preview shadow.
|
||||||
|
- **Don't make the UI compete with the resume content.** If a new feature draws more visual attention than the resume preview, it needs to be toned down.
|
||||||
|
- **Don't use large border radii.** Nothing above `rounded-xl` on standard components. Large pills and full-round shapes conflict with the precision-tool aesthetic.
|
||||||
|
- **Don't hardcode colors outside the token system.** All colors flow through CSS custom properties so that dark/light mode switching works automatically.
|
||||||
|
- **Don't use multiple typefaces in the app shell.** IBM Plex Sans Variable is the only UI font. Resume templates have their own font system, but the chrome stays single-family.
|
||||||
|
- **Don't skip the `data-slot` attribute on components.** It's used for styling hooks and accessibility selectors throughout the component library.
|
||||||
|
- **Don't forget RTL.** The app supports 40+ locales including Arabic, Hebrew, Persian, and Urdu. Use logical properties (`ps`, `pe`, `ms`, `me`) instead of physical (`pl`, `pr`, `ml`, `mr`).
|
||||||
+16
-11
@@ -16,7 +16,7 @@ RUN corepack enable
|
|||||||
FROM base AS pruner
|
FROM base AS pruner
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||||
pnpm dlx turbo@2.9.9 prune web --docker
|
pnpm dlx turbo@2.9.12 prune web server --docker
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=pruner /app/out/json/ ./
|
COPY --from=pruner /app/out/json/ ./
|
||||||
@@ -25,18 +25,18 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY --from=pruner /app/out/full/ ./
|
COPY --from=pruner /app/out/full/ ./
|
||||||
RUN pnpm turbo run build --filter=web
|
RUN rm -rf apps/web/dist apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
|
||||||
|
|
||||||
FROM base AS runtime-pruner
|
FROM base AS runtime-pruner
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||||
pnpm dlx turbo@2.9.9 prune @reactive-resume/runtime-externals --docker
|
pnpm dlx turbo@2.9.12 prune server --docker
|
||||||
|
|
||||||
FROM base AS runtime-deps
|
FROM base AS runtime-deps
|
||||||
COPY --from=runtime-pruner /app/out/json/ ./
|
COPY --from=runtime-pruner /app/out/json/ ./
|
||||||
COPY --from=runtime-pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=runtime-pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||||
pnpm --filter=@reactive-resume/runtime-externals deploy --prod --legacy /runtime-deps
|
pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-slim AS runtime
|
FROM node:${NODE_VERSION}-slim AS runtime
|
||||||
|
|
||||||
@@ -50,22 +50,27 @@ LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
|
|||||||
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
|
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
|
||||||
|
|
||||||
ENV NODE_ENV="production" \
|
ENV NODE_ENV="production" \
|
||||||
PORT=3000
|
PORT=3000 \
|
||||||
|
LOCAL_STORAGE_PATH=/app/data
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN mkdir -p /app/apps/web /app/data && chown node:node /app/data
|
RUN mkdir -p /app/apps/server /app/apps/web /app/data && chown node:node /app/data
|
||||||
|
|
||||||
COPY --from=runtime-deps --chown=node:node /runtime-deps/node_modules ./node_modules
|
COPY --from=runtime-deps --chown=node:node /app/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=node:node /app/apps/web/.output ./apps/web/.output
|
COPY --from=pruner --chown=node:node /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
|
||||||
|
COPY --from=runtime-deps --chown=node:node /app/apps/server/package.json ./apps/server/package.json
|
||||||
|
COPY --from=runtime-deps --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
|
||||||
|
COPY --from=builder --chown=node:node /app/apps/web/dist ./apps/web/dist
|
||||||
|
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
|
||||||
COPY --from=pruner --chown=node:node /app/migrations ./migrations
|
COPY --from=pruner --chown=node:node /app/migrations ./migrations
|
||||||
|
|
||||||
WORKDIR /app/apps/web
|
WORKDIR /app
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000/tcp
|
EXPOSE 3000/tcp
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
|
CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT ?? 3000}/api/health`).then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
|
||||||
|
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
CMD ["node", "apps/server/dist/index.mjs"]
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
ARG NODE_VERSION=24
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-slim AS dev
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
|
PNPM_HOME="/pnpm" \
|
||||||
|
PATH="/pnpm:$PATH" \
|
||||||
|
NODE_ENV=development \
|
||||||
|
TURBO_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY patches ./patches
|
||||||
|
COPY apps/server/package.json ./apps/server/package.json
|
||||||
|
COPY apps/web/package.json ./apps/web/package.json
|
||||||
|
COPY packages/ai/package.json ./packages/ai/package.json
|
||||||
|
COPY packages/api/package.json ./packages/api/package.json
|
||||||
|
COPY packages/auth/package.json ./packages/auth/package.json
|
||||||
|
COPY packages/config/package.json ./packages/config/package.json
|
||||||
|
COPY packages/db/package.json ./packages/db/package.json
|
||||||
|
COPY packages/email/package.json ./packages/email/package.json
|
||||||
|
COPY packages/env/package.json ./packages/env/package.json
|
||||||
|
COPY packages/fonts/package.json ./packages/fonts/package.json
|
||||||
|
COPY packages/import/package.json ./packages/import/package.json
|
||||||
|
COPY packages/pdf/package.json ./packages/pdf/package.json
|
||||||
|
COPY packages/schema/package.json ./packages/schema/package.json
|
||||||
|
COPY packages/ui/package.json ./packages/ui/package.json
|
||||||
|
COPY packages/utils/package.json ./packages/utils/package.json
|
||||||
|
COPY tooling/package.json ./tooling/package.json
|
||||||
|
|
||||||
|
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000/tcp 3001/tcp
|
||||||
|
|
||||||
|
CMD ["pnpm", "run", "dev"]
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<a href="https://discord.gg/aSyA5ZSxpb"><img src="https://img.shields.io/discord/1173518977851473940?style=flat-square&label=discord" alt="Discord" /></a>
|
<a href="https://discord.gg/aSyA5ZSxpb"><img src="https://img.shields.io/discord/1173518977851473940?style=flat-square&label=discord" alt="Discord" /></a>
|
||||||
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
|
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
|
||||||
<a href="https://github.com/sponsors/AmruthPillai"><img src="https://img.shields.io/github/sponsors/AmruthPillai?style=flat-square&label=sponsors" alt="Sponsors" /></a>
|
<a href="https://github.com/sponsors/AmruthPillai"><img src="https://img.shields.io/github/sponsors/AmruthPillai?style=flat-square&label=sponsors" alt="Sponsors" /></a>
|
||||||
<a href="https://opencollective.com/reactive-resume"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
|
<a href="https://opencollective.com/reactive-resume/donate"><img src="https://img.shields.io/opencollective/backers/reactive-resume?style=flat-square&label=donations" alt="Donations" /></a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
|
|||||||
**Resume Building**
|
**Resume Building**
|
||||||
|
|
||||||
- Real-time preview as you type
|
- Real-time preview as you type
|
||||||
- Multiple export formats (PDF, JSON)
|
- Multiple export formats (PDF, JSON, DOCX)
|
||||||
- Drag-and-drop section ordering
|
- Drag-and-drop section ordering
|
||||||
- Custom sections for any content type
|
- Custom sections for any content type
|
||||||
- Rich text editor with formatting support
|
- Rich text editor with formatting support
|
||||||
@@ -130,6 +130,10 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
|
|||||||
<img src="apps/web/public/templates/jpg/meowth.jpg" alt="Meowth" width="150" />
|
<img src="apps/web/public/templates/jpg/meowth.jpg" alt="Meowth" width="150" />
|
||||||
<br /><sub><b>Meowth</b></sub>
|
<br /><sub><b>Meowth</b></sub>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="apps/web/public/templates/jpg/scizor.jpg" alt="Scizor" width="150" />
|
||||||
|
<br /><sub><b>Scizor</b></sub>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -139,7 +143,7 @@ The quickest way to run Reactive Resume locally:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone https://github.com/amruthpillai/reactive-resume.git
|
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
|
||||||
cd reactive-resume
|
cd reactive-resume
|
||||||
|
|
||||||
# Start all services
|
# Start all services
|
||||||
@@ -164,7 +168,7 @@ For detailed setup instructions, environment configuration, and self-hosting gui
|
|||||||
| API | ORPC (Type-safe RPC) |
|
| API | ORPC (Type-safe RPC) |
|
||||||
| Auth | Better Auth |
|
| Auth | Better Auth |
|
||||||
| Styling | Tailwind CSS |
|
| Styling | Tailwind CSS |
|
||||||
| UI Components | Radix UI |
|
| UI Components | Base UI + shadcn-style package |
|
||||||
| State Management | Zustand + TanStack Query |
|
| State Management | Zustand + TanStack Query |
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -208,7 +212,7 @@ Reactive Resume is and always will be free and open-source. If it has helped you
|
|||||||
<a href="https://github.com/sponsors/AmruthPillai">
|
<a href="https://github.com/sponsors/AmruthPillai">
|
||||||
<img src="https://img.shields.io/badge/GitHub%20Sponsors-Support-ea4aaa?style=flat-square&logo=github-sponsors" alt="GitHub Sponsors" />
|
<img src="https://img.shields.io/badge/GitHub%20Sponsors-Support-ea4aaa?style=flat-square&logo=github-sponsors" alt="GitHub Sponsors" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://opencollective.com/reactive-resume">
|
<a href="https://opencollective.com/reactive-resume/donate">
|
||||||
<img src="https://img.shields.io/badge/Open%20Collective-Contribute-7FADF2?style=flat-square&logo=open-collective" alt="Open Collective" />
|
<img src="https://img.shields.io/badge/Open%20Collective-Contribute-7FADF2?style=flat-square&logo=open-collective" alt="Open Collective" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsdown",
|
||||||
|
"start": "node dist/index.mjs",
|
||||||
|
"typecheck": "tsgo --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
|
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
||||||
|
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^3.0.79",
|
||||||
|
"@ai-sdk/google": "^3.0.79",
|
||||||
|
"@ai-sdk/openai": "^3.0.65",
|
||||||
|
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||||
|
"@aws-sdk/client-s3": "^3.1053.0",
|
||||||
|
"@better-auth/api-key": "^1.6.11",
|
||||||
|
"@better-auth/drizzle-adapter": "^1.6.11",
|
||||||
|
"@better-auth/infra": "^0.2.8",
|
||||||
|
"@better-auth/oauth-provider": "^1.6.11",
|
||||||
|
"@better-auth/passkey": "^1.6.11",
|
||||||
|
"@hono/node-server": "^2.0.4",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@orpc/client": "^1.14.3",
|
||||||
|
"@orpc/experimental-ratelimit": "^1.14.3",
|
||||||
|
"@orpc/json-schema": "^1.14.3",
|
||||||
|
"@orpc/openapi": "^1.14.3",
|
||||||
|
"@orpc/server": "^1.14.3",
|
||||||
|
"@orpc/zod": "^1.14.3",
|
||||||
|
"@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/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": "^6.0.191",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-auth": "1.6.11",
|
||||||
|
"cjk-regex": "^3.4.0",
|
||||||
|
"deepmerge-ts": "^7.1.5",
|
||||||
|
"dompurify": "^3.4.5",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-orm": "1.0.0-rc.3",
|
||||||
|
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||||
|
"es-toolkit": "^1.47.0",
|
||||||
|
"fast-json-patch": "^3.1.1",
|
||||||
|
"hono": "^4.12.23",
|
||||||
|
"jsonrepair": "^3.14.0",
|
||||||
|
"node-html-parser": "^7.1.0",
|
||||||
|
"nodemailer": "^8.0.8",
|
||||||
|
"ollama-ai-provider-v2": "^3.5.1",
|
||||||
|
"pg": "^8.21.0",
|
||||||
|
"phosphor-icons-react-pdf": "^0.1.3",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-email": "^6.3.3",
|
||||||
|
"react-pdf-html": "^2.1.5",
|
||||||
|
"resumable-stream": "^2.2.12",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"ts-pattern": "^5.9.0",
|
||||||
|
"unique-names-generator": "^4.7.1",
|
||||||
|
"uuid": "^14.0.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@reactive-resume/config": "workspace:*",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"@types/react": "^19.2.15",
|
||||||
|
"@typescript/native-preview": "7.0.0-dev.20260526.1",
|
||||||
|
"tsdown": "^0.22.0",
|
||||||
|
"tsx": "^4.22.3",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
handleAuth: vi.fn(),
|
||||||
|
handleOAuth: vi.fn(),
|
||||||
|
handleRpc: vi.fn(),
|
||||||
|
handleOpenApi: vi.fn(),
|
||||||
|
handleHealth: vi.fn(),
|
||||||
|
handleUpload: vi.fn(),
|
||||||
|
handleMcp: vi.fn(),
|
||||||
|
handleMcpServerCard: vi.fn(),
|
||||||
|
handleOAuthAuthorizationServer: vi.fn(),
|
||||||
|
handleOAuthProtectedResource: vi.fn(),
|
||||||
|
handleOpenIdConfiguration: vi.fn(),
|
||||||
|
handleWellKnownFallback: vi.fn(),
|
||||||
|
handleRobots: vi.fn(),
|
||||||
|
handleSitemap: vi.fn(),
|
||||||
|
handleLlms: vi.fn(),
|
||||||
|
serveWebDistStatic: vi.fn(),
|
||||||
|
handleWebApp: vi.fn(),
|
||||||
|
handleWebAppHead: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./auth", () => ({
|
||||||
|
handleAuth: mocks.handleAuth,
|
||||||
|
handleOAuth: mocks.handleOAuth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./health", () => ({
|
||||||
|
handleHealth: mocks.handleHealth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../rpc/handler", () => ({
|
||||||
|
handleRpc: mocks.handleRpc,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../openapi/handler", () => ({
|
||||||
|
handleOpenApi: mocks.handleOpenApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../openapi/metadata", () => ({
|
||||||
|
handleMcpServerCard: mocks.handleMcpServerCard,
|
||||||
|
handleOAuthAuthorizationServer: mocks.handleOAuthAuthorizationServer,
|
||||||
|
handleOAuthProtectedResource: mocks.handleOAuthProtectedResource,
|
||||||
|
handleOpenIdConfiguration: mocks.handleOpenIdConfiguration,
|
||||||
|
handleWellKnownFallback: mocks.handleWellKnownFallback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../static/uploads", () => ({
|
||||||
|
handleUpload: mocks.handleUpload,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../static/seo", () => ({
|
||||||
|
handleRobots: mocks.handleRobots,
|
||||||
|
handleSitemap: mocks.handleSitemap,
|
||||||
|
handleLlms: mocks.handleLlms,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../static/web", () => ({
|
||||||
|
serveWebDistStatic: mocks.serveWebDistStatic,
|
||||||
|
handleWebApp: mocks.handleWebApp,
|
||||||
|
handleWebAppHead: mocks.handleWebAppHead,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../mcp/handler", () => ({
|
||||||
|
handleMcp: mocks.handleMcp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||||
|
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
|
||||||
|
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
|
||||||
|
mocks.handleOpenApi.mockResolvedValue(new Response("openapi"));
|
||||||
|
mocks.handleHealth.mockReturnValue(new Response("health"));
|
||||||
|
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||||
|
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||||
|
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||||
|
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||||
|
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||||
|
mocks.handleOpenIdConfiguration.mockReturnValue(new Response("openid-configuration"));
|
||||||
|
mocks.handleWellKnownFallback.mockReturnValue(new Response("well-known"));
|
||||||
|
mocks.handleRobots.mockReturnValue(new Response("robots"));
|
||||||
|
mocks.handleSitemap.mockReturnValue(new Response("sitemap"));
|
||||||
|
mocks.handleLlms.mockReturnValue(new Response("llms"));
|
||||||
|
mocks.serveWebDistStatic.mockResolvedValue(undefined);
|
||||||
|
mocks.handleWebApp.mockResolvedValue(new Response("web"));
|
||||||
|
mocks.handleWebAppHead.mockReturnValue(new Response(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createApp", () => {
|
||||||
|
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
|
||||||
|
const { createApp } = await import("./app");
|
||||||
|
const app = createApp();
|
||||||
|
const request = new Request("http://localhost:3001/api/auth/oauth?client_id=test-client");
|
||||||
|
|
||||||
|
const response = await app.fetch(request);
|
||||||
|
|
||||||
|
await expect(response.text()).resolves.toBe("oauth");
|
||||||
|
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
|
||||||
|
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||||
|
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||||
|
["GET", "/sitemap.xml", "sitemap", mocks.handleSitemap],
|
||||||
|
["HEAD", "/sitemap.xml", "", mocks.handleSitemap],
|
||||||
|
["GET", "/llms.txt", "llms", mocks.handleLlms],
|
||||||
|
["HEAD", "/llms.txt", "", mocks.handleLlms],
|
||||||
|
])("routes %s %s before the static fallback", async (method, pathname, expectedBody, handler) => {
|
||||||
|
const { createApp } = await import("./app");
|
||||||
|
const app = createApp();
|
||||||
|
const request = new Request(`http://localhost:3001${pathname}`, { method });
|
||||||
|
|
||||||
|
const response = await app.fetch(request);
|
||||||
|
|
||||||
|
await expect(response.text()).resolves.toBe(expectedBody);
|
||||||
|
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
|
||||||
|
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.handleWebAppHead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { handleMcp } from "../mcp/handler";
|
||||||
|
import { handleOpenApi } from "../openapi/handler";
|
||||||
|
import {
|
||||||
|
handleMcpServerCard,
|
||||||
|
handleOAuthAuthorizationServer,
|
||||||
|
handleOAuthProtectedResource,
|
||||||
|
handleOpenIdConfiguration,
|
||||||
|
handleWellKnownFallback,
|
||||||
|
} from "../openapi/metadata";
|
||||||
|
import { handleRpc } from "../rpc/handler";
|
||||||
|
import { handleSchemaJson } from "../static/schema";
|
||||||
|
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
|
||||||
|
import { handleUpload } from "../static/uploads";
|
||||||
|
import { handleWebApp, handleWebAppHead, serveWebDistStatic } from "../static/web";
|
||||||
|
import { handleAuth, handleOAuth } from "./auth";
|
||||||
|
import { handleHealth } from "./health";
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
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));
|
||||||
|
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/uploads/*", (c) => handleUpload(c.req.raw));
|
||||||
|
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||||
|
app.get("/schema.json", () => handleSchemaJson());
|
||||||
|
app.all("/mcp", (c) => handleMcp(c.req.raw));
|
||||||
|
app.all("/mcp/*", (c) => handleMcp(c.req.raw));
|
||||||
|
|
||||||
|
app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard());
|
||||||
|
app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||||
|
app.get("/.well-known/oauth-authorization-server/*", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||||
|
app.get("/.well-known/openid-configuration", (c) => handleOpenIdConfiguration(c.req.raw));
|
||||||
|
app.get("/.well-known/oauth-protected-resource", () => handleOAuthProtectedResource());
|
||||||
|
app.get("/.well-known/oauth-protected-resource/*", () => handleOAuthProtectedResource());
|
||||||
|
app.all("/.well-known/*", () => handleWellKnownFallback());
|
||||||
|
|
||||||
|
app.on(["GET", "HEAD"], "/robots.txt", (c) => handleRobots({ head: c.req.method === "HEAD" }));
|
||||||
|
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
|
||||||
|
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||||
|
|
||||||
|
app.use("/*", serveWebDistStatic);
|
||||||
|
app.on(["GET"], "/*", (c) => handleWebApp(c.req.raw));
|
||||||
|
app.on(["HEAD"], "/*", (c) => handleWebAppHead(c.req.raw));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getSession: vi.fn(),
|
||||||
|
handler: vi.fn(),
|
||||||
|
env: {
|
||||||
|
SERVER_PORT: 3001,
|
||||||
|
APP_URL: "http://localhost:3000",
|
||||||
|
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/auth/config", () => ({
|
||||||
|
auth: {
|
||||||
|
api: {
|
||||||
|
getSession: mocks.getSession,
|
||||||
|
},
|
||||||
|
handler: mocks.handler,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/db/client", () => ({ db: {} }));
|
||||||
|
vi.mock("@reactive-resume/db/schema", () => ({ oauthClient: {}, verification: {} }));
|
||||||
|
vi.mock("@reactive-resume/env/server", () => ({
|
||||||
|
env: mocks.env,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = false;
|
||||||
|
mocks.handler.mockResolvedValue(new Response("ok"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleAuth", () => {
|
||||||
|
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
|
||||||
|
const { handleAuth } = await import("./auth");
|
||||||
|
|
||||||
|
const response = await handleAuth(
|
||||||
|
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/callback"] }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toEqual({
|
||||||
|
error: "invalid_redirect_uri",
|
||||||
|
error_description: "redirect_uri is not allowed",
|
||||||
|
});
|
||||||
|
expect(mocks.handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
|
||||||
|
const { handleAuth } = await import("./auth");
|
||||||
|
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
|
||||||
|
|
||||||
|
const response = await handleAuth(
|
||||||
|
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mocks.handler).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleOAuth", () => {
|
||||||
|
it("redirects unauthenticated users to the same-origin login route", async () => {
|
||||||
|
const { handleOAuth } = await import("./auth");
|
||||||
|
mocks.getSession.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const response = await handleOAuth(
|
||||||
|
new Request(
|
||||||
|
"http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const location = response.headers.get("Location");
|
||||||
|
expect(location).toMatch(/^\/auth\/login\?/);
|
||||||
|
|
||||||
|
const loginUrl = new URL(location ?? "", "http://localhost:3000");
|
||||||
|
const callbackUrl = new URL(loginUrl.searchParams.get("callbackURL") ?? "", "http://localhost:3000");
|
||||||
|
|
||||||
|
expect(loginUrl.origin).toBe("http://localhost:3000");
|
||||||
|
expect(callbackUrl.pathname).toBe("/api/auth/oauth");
|
||||||
|
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
|
||||||
|
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
||||||
|
expect(callbackUrl.searchParams.get("state")).toBe("abc");
|
||||||
|
expect(callbackUrl.searchParams.has("exp")).toBe(false);
|
||||||
|
expect(callbackUrl.searchParams.has("sig")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@reactive-resume/auth/config";
|
||||||
|
import { db } from "@reactive-resume/db/client";
|
||||||
|
import { oauthClient, verification } from "@reactive-resume/db/schema";
|
||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { generateId } from "@reactive-resume/utils/string";
|
||||||
|
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
||||||
|
|
||||||
|
const oauthAuthorizeSanitizedParams = [
|
||||||
|
"prompt",
|
||||||
|
"redirect_uri",
|
||||||
|
"client_id",
|
||||||
|
"code_challenge",
|
||||||
|
"code_challenge_method",
|
||||||
|
"response_type",
|
||||||
|
"scope",
|
||||||
|
"state",
|
||||||
|
"resource",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
|
||||||
|
|
||||||
|
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||||
|
if (request.method !== "GET") return request;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
|
||||||
|
|
||||||
|
const sanitizeValue = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/[\r\n\t]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
const sanitizeParam = (key: string) => {
|
||||||
|
const value = url.searchParams.get(key);
|
||||||
|
if (!value) return;
|
||||||
|
url.searchParams.set(key, sanitizeValue(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
|
||||||
|
|
||||||
|
const redirectUri = url.searchParams.get("redirect_uri");
|
||||||
|
if (redirectUri && !URL.canParse(redirectUri)) {
|
||||||
|
try {
|
||||||
|
const decodedRedirectUri = decodeURIComponent(redirectUri);
|
||||||
|
if (URL.canParse(decodedRedirectUri)) {
|
||||||
|
url.searchParams.set("redirect_uri", decodedRedirectUri);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed encoded values and let Better Auth validation handle them.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.toString() === request.url) return request;
|
||||||
|
return new Request(url.toString(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
|
||||||
|
if (request.method !== "POST") return request;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (!url.pathname.endsWith("/oauth2/register")) return request;
|
||||||
|
|
||||||
|
const cloned = request.clone();
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await cloned.json();
|
||||||
|
} catch {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.headers.get("authorization")) {
|
||||||
|
body.token_endpoint_auth_method = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Request(url.toString(), {
|
||||||
|
method: request.method,
|
||||||
|
headers: request.headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
|
||||||
|
if (request.method !== "POST") return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (!url.pathname.endsWith("/oauth2/register")) return;
|
||||||
|
|
||||||
|
const cloned = request.clone();
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await cloned.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
|
||||||
|
|
||||||
|
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
||||||
|
for (const redirectUri of redirectUris) {
|
||||||
|
if (
|
||||||
|
typeof redirectUri !== "string" ||
|
||||||
|
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, {
|
||||||
|
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAuth(request: Request) {
|
||||||
|
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
|
||||||
|
if (registrationValidationError) return registrationValidationError;
|
||||||
|
|
||||||
|
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
||||||
|
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
|
||||||
|
|
||||||
|
return auth.handler(finalRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCode() {
|
||||||
|
return crypto.randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashCode(code: string) {
|
||||||
|
return crypto.createHash("sha256").update(code).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleOAuth(request: Request) {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (session?.user) {
|
||||||
|
const clientId = url.searchParams.get("client_id");
|
||||||
|
const redirectUri = url.searchParams.get("redirect_uri");
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
const scope = url.searchParams.get("scope");
|
||||||
|
const codeChallenge = url.searchParams.get("code_challenge");
|
||||||
|
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
||||||
|
|
||||||
|
if (!clientId || !redirectUri) {
|
||||||
|
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return Response.json({ error: "invalid client" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.redirectUris.includes(redirectUri)) {
|
||||||
|
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = generateCode();
|
||||||
|
const hashedCode = hashCode(code);
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + 600_000);
|
||||||
|
|
||||||
|
await db.insert(verification).values({
|
||||||
|
id: generateId(),
|
||||||
|
identifier: hashedCode,
|
||||||
|
value: JSON.stringify({
|
||||||
|
type: "authorization_code",
|
||||||
|
query: {
|
||||||
|
response_type: "code",
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: codeChallengeMethod,
|
||||||
|
},
|
||||||
|
userId: session.user.id,
|
||||||
|
sessionId: session.session.id,
|
||||||
|
authTime: new Date(session.session.createdAt).getTime(),
|
||||||
|
}),
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const callbackUrl = new URL(redirectUri);
|
||||||
|
callbackUrl.searchParams.set("code", code);
|
||||||
|
if (state) callbackUrl.searchParams.set("state", state);
|
||||||
|
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: callbackUrl.toString() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginUrl = new URL("/auth/login", env.APP_URL);
|
||||||
|
const oauthParams = new URLSearchParams();
|
||||||
|
for (const [key, value] of url.searchParams) {
|
||||||
|
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
|
||||||
|
oauthParams.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function getCookie(request: Request, name: string): string | undefined {
|
||||||
|
const cookieHeader = request.headers.get("cookie");
|
||||||
|
if (!cookieHeader) return;
|
||||||
|
|
||||||
|
for (const part of cookieHeader.split(";")) {
|
||||||
|
const [rawName, ...rawValue] = part.trim().split("=");
|
||||||
|
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
|
||||||
|
if ([...headers].length === 0) return response;
|
||||||
|
|
||||||
|
const nextHeaders = new Headers(response.headers);
|
||||||
|
for (const [key, value] of headers) nextHeaders.append(key, value);
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: nextHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
// Server-only API route. Lazy-imports keep db/storage/drizzle out of the client bundle.
|
|
||||||
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { getStorageService } from "@reactive-resume/api/services/storage";
|
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||||
import { db } from "@reactive-resume/db/client";
|
import { db } from "@reactive-resume/db/client";
|
||||||
|
|
||||||
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
||||||
@@ -51,7 +48,31 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function healthHandler() {
|
async function checkDatabase() {
|
||||||
|
try {
|
||||||
|
await db.execute(sql`SELECT 1`);
|
||||||
|
return { status: "healthy" };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: "unhealthy",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStorage() {
|
||||||
|
try {
|
||||||
|
const storageService = getStorageService();
|
||||||
|
return await storageService.healthcheck();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: "unhealthy",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleHealth() {
|
||||||
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
|
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
|
||||||
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
|
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||||
|
|
||||||
@@ -79,35 +100,3 @@ async function healthHandler() {
|
|||||||
status: checks.status === "unhealthy" ? 503 : 200,
|
status: checks.status === "unhealthy" ? 503 : 200,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkDatabase() {
|
|
||||||
try {
|
|
||||||
await db.execute(sql`SELECT 1`);
|
|
||||||
return { status: "healthy" };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
status: "unhealthy",
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkStorage() {
|
|
||||||
try {
|
|
||||||
const storageService = getStorageService();
|
|
||||||
return await storageService.healthcheck();
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
status: "unhealthy",
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/health")({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: healthHandler,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { createApp } from "./http/app";
|
||||||
|
import { runStartupChecks } from "./startup/checks";
|
||||||
|
|
||||||
|
export { createApp } from "./http/app";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await runStartupChecks();
|
||||||
|
|
||||||
|
const port =
|
||||||
|
process.env.NODE_ENV === "production" ? Number.parseInt(process.env.PORT ?? "3000", 10) : env.SERVER_PORT;
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
serve(
|
||||||
|
{
|
||||||
|
fetch: app.fetch,
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
(info) => {
|
||||||
|
console.info(`🚀 Up and running on http://localhost:${info.port}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
|
||||||
|
|
||||||
|
export class AuthError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Unauthorized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateRequest(request: Request): Promise<void> {
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
try {
|
||||||
|
const payload = await verifyOAuthToken(authHeader.slice(7));
|
||||||
|
if (payload?.sub) return;
|
||||||
|
} catch {
|
||||||
|
// Invalid or expired token; fall through to API key auth.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = request.headers.get("x-api-key");
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
try {
|
||||||
|
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
|
||||||
|
if (result.valid) return;
|
||||||
|
} catch {
|
||||||
|
// Invalid or malformed key; fall through to AuthError.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { AuthError, authenticateRequest } from "./auth";
|
||||||
|
import { createMcpServer } from "./server";
|
||||||
|
|
||||||
|
export async function handleMcp(request: Request) {
|
||||||
|
try {
|
||||||
|
await authenticateRequest(request);
|
||||||
|
|
||||||
|
const server = await createMcpServer(request);
|
||||||
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||||
|
enableJsonResponse: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
return await transport.handleRequest(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return Response.json(
|
||||||
|
{ id: null, jsonrpc: "2.0", error: { code: -32603, message: "Unauthorized" } },
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
"WWW-Authenticate": `Bearer resource_metadata="${env.APP_URL}/.well-known/oauth-protected-resource"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[MCP]", error);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
id: null,
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: {
|
||||||
|
code: -32603,
|
||||||
|
message: `Error handling request: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { RouterClient } from "@orpc/server";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { onError } from "@orpc/client";
|
||||||
|
import { createRouterClient } from "@orpc/server";
|
||||||
|
import router from "@reactive-resume/api/routers";
|
||||||
|
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
|
||||||
|
import { getRequestLocale } from "../rpc/locale";
|
||||||
|
|
||||||
|
function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||||
|
return createRouterClient(router, {
|
||||||
|
interceptors: [
|
||||||
|
onError((error) => {
|
||||||
|
console.error("[MCP oRPC]", error);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
context: () => ({
|
||||||
|
locale: getRequestLocale(request),
|
||||||
|
reqHeaders: request.headers,
|
||||||
|
resHeaders: new Headers(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMcpServer(request: Request) {
|
||||||
|
const server = new McpServer(
|
||||||
|
{
|
||||||
|
name: "reactive-resume",
|
||||||
|
version: __APP_VERSION__,
|
||||||
|
title: "Reactive Resume",
|
||||||
|
websiteUrl: "https://rxresu.me",
|
||||||
|
description:
|
||||||
|
"Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "https://rxresu.me/icon/light.svg",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
theme: "light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://rxresu.me/icon/dark.svg",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
theme: "dark",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
instructions: [
|
||||||
|
"You are connected to Reactive Resume over MCP.",
|
||||||
|
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
|
||||||
|
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
|
||||||
|
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
|
||||||
|
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
||||||
|
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
||||||
|
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
|
||||||
|
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = createRequestClient(request);
|
||||||
|
registerResources(server, client);
|
||||||
|
registerTools(server, client, request.headers);
|
||||||
|
registerPrompts(server);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
|||||||
import { onError } from "@orpc/server";
|
import { onError } from "@orpc/server";
|
||||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||||
import router from "@reactive-resume/api/routers";
|
import router from "@reactive-resume/api/routers";
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||||
import { getLocale } from "@/libs/locale";
|
import { mergeResponseHeaders } from "../http/headers";
|
||||||
import { downloadResumePdfProcedure } from "./-helpers/resume-pdf";
|
import { getRequestLocale } from "../rpc/locale";
|
||||||
|
|
||||||
const openAPIRouter = {
|
const openAPIRouter = {
|
||||||
...router,
|
...router,
|
||||||
@@ -19,29 +19,27 @@ const openAPIRouter = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handler({ request }: { request: Request }) {
|
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
plugins: [
|
||||||
plugins: [
|
new BatchHandlerPlugin(),
|
||||||
new BatchHandlerPlugin(),
|
new RequestHeadersPlugin(),
|
||||||
new RequestHeadersPlugin(),
|
new StrictGetMethodPlugin(),
|
||||||
new StrictGetMethodPlugin(),
|
new SmartCoercionPlugin({
|
||||||
new SmartCoercionPlugin({
|
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
}),
|
||||||
}),
|
],
|
||||||
],
|
interceptors: [
|
||||||
interceptors: [
|
onError((error) => {
|
||||||
onError((error) => {
|
console.error("[OpenAPI]", error);
|
||||||
console.error("[OpenAPI]", error);
|
}),
|
||||||
}),
|
],
|
||||||
],
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const openAPIGenerator = new OpenAPIGenerator({
|
const openAPIGenerator = new OpenAPIGenerator({
|
||||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||||
});
|
});
|
||||||
|
|
||||||
const locale = await getLocale();
|
|
||||||
|
|
||||||
|
export async function handleOpenApi(request: Request) {
|
||||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||||
info: {
|
info: {
|
||||||
@@ -73,22 +71,12 @@ async function handler({ request }: { request: Request }) {
|
|||||||
return Response.json(spec);
|
return Response.json(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resHeaders = new Headers();
|
||||||
const { response } = await openAPIHandler.handle(request, {
|
const { response } = await openAPIHandler.handle(request, {
|
||||||
prefix: "/api/openapi",
|
prefix: "/api/openapi",
|
||||||
context: { locale, reqHeaders: request.headers },
|
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response) {
|
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||||
return new Response("NOT_FOUND", { status: 404 });
|
return mergeResponseHeaders(response, resHeaders);
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/openapi/$")({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
ANY: handler,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||||
|
import { auth } from "@reactive-resume/auth/config";
|
||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
||||||
|
|
||||||
|
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
|
||||||
|
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
|
||||||
|
|
||||||
|
export function handleWellKnownFallback() {
|
||||||
|
return new Response("OK", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleMcpServerCard() {
|
||||||
|
return Response.json(buildMcpServerCard(__APP_VERSION__), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleOAuthAuthorizationServer(request: Request) {
|
||||||
|
return oauthAuthorizationServerHandler(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleOpenIdConfiguration(request: Request) {
|
||||||
|
return openIdConfigurationHandler(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleOAuthProtectedResource() {
|
||||||
|
const metadata = {
|
||||||
|
resource: env.APP_URL,
|
||||||
|
bearer_methods_supported: ["header"],
|
||||||
|
authorization_servers: [env.APP_URL, `${env.APP_URL}/api/auth`],
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(metadata, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { onError } from "@orpc/server";
|
||||||
|
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 { getRequestLocale } from "./locale";
|
||||||
|
|
||||||
|
const rpcHandler = new RPCHandler(router, {
|
||||||
|
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin(), new StrictGetMethodPlugin()],
|
||||||
|
interceptors: [
|
||||||
|
onError((error) => {
|
||||||
|
console.error("[oRPC Server]", error);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function handleRpc(request: Request) {
|
||||||
|
const resHeaders = new Headers();
|
||||||
|
const { response } = await rpcHandler.handle(request, {
|
||||||
|
prefix: "/api/rpc",
|
||||||
|
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||||
|
return mergeResponseHeaders(response, resHeaders);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Locale } from "@reactive-resume/utils/locale";
|
||||||
|
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
||||||
|
import { getCookie } from "../http/headers";
|
||||||
|
|
||||||
|
export function getRequestLocale(request: Request): Locale {
|
||||||
|
const locale = getCookie(request, "locale");
|
||||||
|
return isLocale(locale) ? locale : defaultLocale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { constants, existsSync } from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
|
||||||
|
|
||||||
|
function resolveFromCurrentModule(relativePath: string) {
|
||||||
|
return fileURLToPath(new URL(relativePath, import.meta.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkspaceFolder(folderName: string): string {
|
||||||
|
let dir = resolveFromCurrentModule(".");
|
||||||
|
|
||||||
|
while (dir !== path.dirname(dir)) {
|
||||||
|
const candidate = path.join(dir, folderName);
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
dir = path.dirname(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDatabaseMigrations() {
|
||||||
|
console.info("Running database migrations...");
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
||||||
|
const db = drizzle({ client: pool });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
|
||||||
|
console.info("Database migrations completed");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Database migrations failed", { error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateLocalStoragePath() {
|
||||||
|
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) return;
|
||||||
|
|
||||||
|
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
|
||||||
|
console.info(`Validating local storage path: ${dataDirectory}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dataDirectory, { recursive: true });
|
||||||
|
await fs.access(dataDirectory, constants.R_OK | constants.W_OK);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error(
|
||||||
|
`Local storage path is not writable: ${dataDirectory}\n` +
|
||||||
|
` ${message}\n` +
|
||||||
|
"Set LOCAL_STORAGE_PATH to a writable directory or fix permissions on the existing path.",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStartupChecks() {
|
||||||
|
await runDatabaseMigrations();
|
||||||
|
await validateLocalStoragePath();
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||||
|
|
||||||
function handler() {
|
export function handleSchemaJson() {
|
||||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
||||||
|
|
||||||
return Response.json(resumeDataJSONSchema, {
|
return Response.json(resumeDataJSONSchema, {
|
||||||
@@ -18,11 +17,3 @@ function handler() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/schema.json")({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: handler,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/env/server", () => ({
|
||||||
|
env: {
|
||||||
|
APP_URL: "https://app.example.com/",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { handleLlms, handleRobots, handleSitemap } = await import("./seo");
|
||||||
|
|
||||||
|
describe("SEO static endpoints", () => {
|
||||||
|
it("generates robots.txt from the normalized app URL", async () => {
|
||||||
|
const response = handleRobots();
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||||
|
expect(text).toContain("User-agent: *");
|
||||||
|
expect(text).toContain("Allow: /");
|
||||||
|
expect(text).toContain("Disallow: /api/rpc");
|
||||||
|
expect(text).toContain("Disallow: /api/auth");
|
||||||
|
expect(text).toContain("Disallow: /mcp");
|
||||||
|
expect(text).toContain("Disallow: /.well-known");
|
||||||
|
expect(text).toContain("Sitemap: https://app.example.com/sitemap.xml");
|
||||||
|
expect(text).toContain("Sitemap: https://docs.rxresu.me/sitemap.xml");
|
||||||
|
expect(text).not.toMatch(/GPTBot|ClaudeBot|PerplexityBot|CCBot|ChatGPT-User/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates an app-domain-only sitemap", async () => {
|
||||||
|
const response = handleSitemap();
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
|
||||||
|
expect(text).toContain("<loc>https://app.example.com/</loc>");
|
||||||
|
expect(text).not.toContain("docs.rxresu.me");
|
||||||
|
expect(text).not.toContain("/auth");
|
||||||
|
expect(text).not.toContain("/dashboard");
|
||||||
|
expect(text).not.toContain("/builder");
|
||||||
|
expect(text).not.toContain("/templates");
|
||||||
|
expect(text).not.toContain("/schema.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a lightweight llms.txt product index", async () => {
|
||||||
|
const response = handleLlms();
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||||
|
expect(text).toContain("# Reactive Resume");
|
||||||
|
expect(text).toContain("- Product: https://app.example.com");
|
||||||
|
expect(text).toContain("- Documentation: https://docs.rxresu.me");
|
||||||
|
expect(text).toContain("- Documentation sitemap: https://docs.rxresu.me/sitemap.xml");
|
||||||
|
expect(text).toContain("- Documentation llms.txt: https://docs.rxresu.me/llms.txt");
|
||||||
|
expect(text).toContain("- API documentation: https://docs.rxresu.me/api-reference");
|
||||||
|
expect(text).toContain("- Resume schema: https://app.example.com/schema.json");
|
||||||
|
expect(text).toContain("- MCP documentation: https://docs.rxresu.me/guides/using-the-mcp-server");
|
||||||
|
expect(text).toContain("- OpenAPI specification: https://app.example.com/api/openapi/spec.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns headers without a body for HEAD responses", async () => {
|
||||||
|
const response = handleLlms({ head: true });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
|
||||||
|
const DOCS_URL = "https://docs.rxresu.me";
|
||||||
|
|
||||||
|
type StaticSeoOptions = {
|
||||||
|
head?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function appUrl() {
|
||||||
|
return env.APP_URL.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResponse(body: string, options: StaticSeoOptions = {}) {
|
||||||
|
return new Response(options.head ? null : body, {
|
||||||
|
headers: { "Content-Type": "text/plain; charset=UTF-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleRobots(options?: StaticSeoOptions) {
|
||||||
|
const baseUrl = appUrl();
|
||||||
|
const body = [
|
||||||
|
"User-agent: *",
|
||||||
|
"Allow: /",
|
||||||
|
"Disallow: /api/rpc",
|
||||||
|
"Disallow: /api/auth",
|
||||||
|
"Disallow: /mcp",
|
||||||
|
"Disallow: /.well-known",
|
||||||
|
"",
|
||||||
|
`Sitemap: ${baseUrl}/sitemap.xml`,
|
||||||
|
`Sitemap: ${DOCS_URL}/sitemap.xml`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return textResponse(body, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSitemap(options?: StaticSeoOptions) {
|
||||||
|
const baseUrl = appUrl();
|
||||||
|
const body = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
" <url>",
|
||||||
|
` <loc>${baseUrl}/</loc>`,
|
||||||
|
" </url>",
|
||||||
|
"</urlset>",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return new Response(options?.head ? null : body, {
|
||||||
|
headers: { "Content-Type": "application/xml; charset=UTF-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleLlms(options?: StaticSeoOptions) {
|
||||||
|
const baseUrl = appUrl();
|
||||||
|
const body = [
|
||||||
|
"# Reactive Resume",
|
||||||
|
"",
|
||||||
|
"Reactive Resume is an open-source resume builder for creating, managing, and exporting resumes.",
|
||||||
|
"",
|
||||||
|
"## Links",
|
||||||
|
"",
|
||||||
|
`- Product: ${baseUrl}`,
|
||||||
|
`- Documentation: ${DOCS_URL}`,
|
||||||
|
`- Documentation sitemap: ${DOCS_URL}/sitemap.xml`,
|
||||||
|
`- Documentation llms.txt: ${DOCS_URL}/llms.txt`,
|
||||||
|
`- API documentation: ${DOCS_URL}/api-reference`,
|
||||||
|
`- Resume schema: ${baseUrl}/schema.json`,
|
||||||
|
`- MCP documentation: ${DOCS_URL}/guides/using-the-mcp-server`,
|
||||||
|
`- OpenAPI specification: ${baseUrl}/api/openapi/spec.json`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return textResponse(body, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/api/features/storage", () => ({
|
||||||
|
getStorageService: () => ({
|
||||||
|
read: readMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/env/server", () => ({
|
||||||
|
env: {
|
||||||
|
APP_URL: "https://example.com",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { handleUpload } = await import("./uploads");
|
||||||
|
|
||||||
|
describe("handleUpload", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
readMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves public upload keys", async () => {
|
||||||
|
readMock.mockResolvedValueOnce({
|
||||||
|
data: new TextEncoder().encode("image"),
|
||||||
|
size: 5,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await handleUpload(new Request("https://example.com/api/uploads/user-1/pictures/photo.jpeg"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not serve private agent attachment keys through the public uploads route", async () => {
|
||||||
|
readMock.mockResolvedValueOnce({
|
||||||
|
data: new TextEncoder().encode("secret"),
|
||||||
|
size: 6,
|
||||||
|
contentType: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await handleUpload(
|
||||||
|
new Request("https://example.com/api/uploads/user-1/agent/thread-1/attachment.txt"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(readMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,17 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { basename, extname, normalize } from "node:path";
|
import { basename, extname, normalize } from "node:path";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||||
import { getStorageService } from "@reactive-resume/api/services/storage";
|
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
|
||||||
export const Route = createFileRoute("/uploads/$userId/$")({
|
export async function handleUpload(request: Request) {
|
||||||
server: { handlers: { GET: handler } },
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler for GET requests to serve uploaded files, supporting ETags, content security, and path validation.
|
|
||||||
* Handles nested paths like:
|
|
||||||
* - /uploads/{userId}/pictures/{timestamp}.jpeg
|
|
||||||
* - /uploads/{userId}/screenshots/{resumeId}/{timestamp}.jpeg
|
|
||||||
* - /uploads/{userId}/pdfs/{resumeId}/{timestamp}.pdf
|
|
||||||
*/
|
|
||||||
export async function handler({ request }: { request: Request }) {
|
|
||||||
const { userId, filePath } = parseRouteParams(request.url);
|
const { userId, filePath } = parseRouteParams(request.url);
|
||||||
|
|
||||||
if (!userId || !filePath) return new Response("Bad Request", { status: 400 });
|
if (!userId || !filePath) return new Response("Bad Request", { status: 400 });
|
||||||
|
|
||||||
if (!isValidPath(userId) || !isValidPathSegments(filePath)) return new Response("Forbidden", { status: 403 });
|
if (!isValidPath(userId) || !isValidPathSegments(filePath)) return new Response("Forbidden", { status: 403 });
|
||||||
|
if (isPrivateUploadPath(filePath)) return new Response("Not Found", { status: 404 });
|
||||||
|
|
||||||
const storageService = getStorageService();
|
const storageService = getStorageService();
|
||||||
|
|
||||||
// Build the full storage key: uploads/{userId}/{filePath}
|
|
||||||
const key = `uploads/${userId}/${filePath}`;
|
const key = `uploads/${userId}/${filePath}`;
|
||||||
const storedFile = await storageService.read(key);
|
const storedFile = await storageService.read(key);
|
||||||
if (!storedFile) return new Response("Not Found", { status: 404 });
|
if (!storedFile) return new Response("Not Found", { status: 404 });
|
||||||
@@ -37,7 +24,7 @@ export async function handler({ request }: { request: Request }) {
|
|||||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||||
|
|
||||||
const shouldForceDownload = [".pdf"].includes(ext);
|
const shouldForceDownload = [".pdf"].includes(ext);
|
||||||
const headers = await buildResponseHeaders({
|
const headers = buildResponseHeaders({
|
||||||
filename,
|
filename,
|
||||||
storedFile,
|
storedFile,
|
||||||
contentType,
|
contentType,
|
||||||
@@ -68,9 +55,6 @@ function inferContentTypeFromExtension(ext: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts userId and the remaining file path from the request URL.
|
|
||||||
*/
|
|
||||||
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
||||||
const pathname = new URL(url).pathname;
|
const pathname = new URL(url).pathname;
|
||||||
const [, pathAfterUploads] = pathname.split("/uploads/");
|
const [, pathAfterUploads] = pathname.split("/uploads/");
|
||||||
@@ -87,27 +71,22 @@ function parseRouteParams(url: string): { userId: string | undefined; filePath:
|
|||||||
return { userId, filePath: filePath || undefined };
|
return { userId, filePath: filePath || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a path segment does not contain directory traversal attempts.
|
|
||||||
*/
|
|
||||||
function isValidPath(segment: string): boolean {
|
function isValidPath(segment: string): boolean {
|
||||||
const normalized = normalize(segment).replace(/^(\.\.(\/|\\|$))+/, "");
|
const normalized = normalize(segment).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||||
|
|
||||||
return normalized === segment;
|
return normalized === segment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates all segments in a path for directory traversal attempts.
|
|
||||||
*/
|
|
||||||
function isValidPathSegments(path: string): boolean {
|
function isValidPathSegments(path: string): boolean {
|
||||||
const segments = path.split("/");
|
const segments = path.split("/");
|
||||||
|
|
||||||
return segments.every((segment) => isValidPath(segment));
|
return segments.every((segment) => isValidPath(segment));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function isPrivateUploadPath(path: string): boolean {
|
||||||
* Checks for ETag match for conditional GET requests.
|
return path.split("/")[0] === "agent";
|
||||||
*/
|
}
|
||||||
|
|
||||||
function isNotModified(headers: Headers, etag: string): boolean {
|
function isNotModified(headers: Headers, etag: string): boolean {
|
||||||
const ifNoneMatch = headers.get("If-None-Match");
|
const ifNoneMatch = headers.get("If-None-Match");
|
||||||
const candidates = ifNoneMatch?.split(",").map((s) => s.trim()) ?? [];
|
const candidates = ifNoneMatch?.split(",").map((s) => s.trim()) ?? [];
|
||||||
@@ -115,9 +94,6 @@ function isNotModified(headers: Headers, etag: string): boolean {
|
|||||||
return candidates.includes(etag);
|
return candidates.includes(etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a 304 Not Modified response with caching headers.
|
|
||||||
*/
|
|
||||||
function makeNotModifiedResponse(etag: string): Response {
|
function makeNotModifiedResponse(etag: string): Response {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 304,
|
status: 304,
|
||||||
@@ -133,16 +109,13 @@ type BuildResponseHeaderArgs = {
|
|||||||
shouldForceDownload: boolean;
|
shouldForceDownload: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
function buildResponseHeaders({
|
||||||
* Builds all headers for serving the file, including caching, security, and download headers.
|
|
||||||
*/
|
|
||||||
async function buildResponseHeaders({
|
|
||||||
filename,
|
filename,
|
||||||
storedFile,
|
storedFile,
|
||||||
contentType,
|
contentType,
|
||||||
etag,
|
etag,
|
||||||
shouldForceDownload,
|
shouldForceDownload,
|
||||||
}: BuildResponseHeaderArgs): Promise<Headers> {
|
}: BuildResponseHeaderArgs): Headers {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
|
|
||||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||||
@@ -154,8 +127,6 @@ async function buildResponseHeaders({
|
|||||||
|
|
||||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
headers.set("ETag", etag);
|
headers.set("ETag", etag);
|
||||||
|
|
||||||
// Security Headers
|
|
||||||
headers.set("X-Content-Type-Options", "nosniff");
|
headers.set("X-Content-Type-Options", "nosniff");
|
||||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||||
@@ -167,18 +138,12 @@ async function buildResponseHeaders({
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Uint8Array to ArrayBuffer efficiently.
|
|
||||||
*/
|
|
||||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||||
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
||||||
? (data.buffer as ArrayBuffer)
|
? (data.buffer as ArrayBuffer)
|
||||||
: (data.slice().buffer as ArrayBuffer);
|
: (data.slice().buffer as ArrayBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates or returns the ETag for a stored file.
|
|
||||||
*/
|
|
||||||
function createEtag(storedFile: { data: Uint8Array; size: number; etag?: string }): string {
|
function createEtag(storedFile: { data: Uint8Array; size: number; etag?: string }): string {
|
||||||
if (storedFile.etag) {
|
if (storedFile.etag) {
|
||||||
const tag = storedFile.etag.trim();
|
const tag = storedFile.etag.trim();
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("node:fs", () => ({
|
||||||
|
existsSync: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({
|
||||||
|
default: {
|
||||||
|
readFile: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@hono/node-server/serve-static", () => ({
|
||||||
|
serveStatic: vi.fn(() => vi.fn()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { handleWebApp, handleWebAppHead } = await import("./web");
|
||||||
|
|
||||||
|
describe("web app fallback classification", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the shell for the root app route without noindex", async () => {
|
||||||
|
const response = await handleWebApp(new Request("https://example.com/"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||||
|
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
||||||
|
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) => {
|
||||||
|
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"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||||
|
expect(await response.text()).toBe("<html>app</html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns noindex 404 for unknown non-asset routes", async () => {
|
||||||
|
const response = await handleWebApp(new Request("https://example.com/unknown/extra/path"));
|
||||||
|
|
||||||
|
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.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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns plain 404 for missing asset-looking paths", async () => {
|
||||||
|
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
||||||
|
expect(await response.text()).toBe("Not Found");
|
||||||
|
expect(fs.readFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors fallback status and headers for HEAD without a body", async () => {
|
||||||
|
const knownResponse = handleWebAppHead(new Request("https://example.com/dashboard"));
|
||||||
|
const unknownResponse = handleWebAppHead(new Request("https://example.com/unknown/extra/path"));
|
||||||
|
|
||||||
|
expect(knownResponse.status).toBe(200);
|
||||||
|
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||||
|
expect(knownResponse.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||||
|
expect(await knownResponse.text()).toBe("");
|
||||||
|
|
||||||
|
expect(unknownResponse.status).toBe(404);
|
||||||
|
expect(unknownResponse.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||||
|
expect(unknownResponse.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||||
|
expect(await unknownResponse.text()).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
|
|
||||||
|
function resolveWebDistPath() {
|
||||||
|
const candidates = [
|
||||||
|
// Source layout: apps/server/src/static/web.ts -> apps/web/dist
|
||||||
|
fileURLToPath(new URL("../../../web/dist", import.meta.url)),
|
||||||
|
// Bundled layout: apps/server/dist/index.mjs -> apps/web/dist
|
||||||
|
fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||||
|
];
|
||||||
|
const [fallback] = candidates;
|
||||||
|
if (!fallback) throw new Error("Could not resolve web dist path");
|
||||||
|
|
||||||
|
return candidates.find((candidate) => existsSync(candidate)) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticRoot = resolveWebDistPath();
|
||||||
|
const indexHtmlPath = `${staticRoot}/index.html`;
|
||||||
|
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
|
||||||
|
const reservedPublicResumeSegments = new Set([
|
||||||
|
"api",
|
||||||
|
"mcp",
|
||||||
|
".well-known",
|
||||||
|
"uploads",
|
||||||
|
"auth",
|
||||||
|
"dashboard",
|
||||||
|
"builder",
|
||||||
|
"agent",
|
||||||
|
"templates",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const serveWebDistStatic = serveStatic({ root: staticRoot });
|
||||||
|
|
||||||
|
function isAssetPath(pathname: string): boolean {
|
||||||
|
return pathname.split("/").pop()?.includes(".") ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathSegments(pathname: string) {
|
||||||
|
return pathname.split("/").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNoindexShellPath(pathname: string): boolean {
|
||||||
|
return noindexShellPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicResumePath(pathname: string): boolean {
|
||||||
|
const segments = getPathSegments(pathname);
|
||||||
|
const [firstSegment] = segments;
|
||||||
|
|
||||||
|
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackResponseHeaders(pathname: string) {
|
||||||
|
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8" };
|
||||||
|
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
|
||||||
|
return {
|
||||||
|
"Content-Type": "text/html; charset=UTF-8",
|
||||||
|
"X-Robots-Tag": "noindex, follow",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
||||||
|
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
|
||||||
|
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||||
|
|
||||||
|
return new Response(options.head ? null : "Not Found", {
|
||||||
|
status: 404,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWebApp(request: Request) {
|
||||||
|
const pathname = new URL(request.url).pathname;
|
||||||
|
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response("Not Found", { status: 404 });
|
||||||
|
|
||||||
|
const headers = getFallbackResponseHeaders(pathname);
|
||||||
|
if (!headers) return notFoundResponse({ noindex: true });
|
||||||
|
|
||||||
|
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||||
|
return new Response(html, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleWebAppHead(request: Request) {
|
||||||
|
const pathname = new URL(request.url).pathname;
|
||||||
|
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response(null, { status: 404 });
|
||||||
|
|
||||||
|
const headers = getFallbackResponseHeaders(pathname);
|
||||||
|
if (!headers) return notFoundResponse({ head: true, noindex: true });
|
||||||
|
|
||||||
|
return new Response(null, { status: 200, headers });
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare const __APP_VERSION__: string;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@reactive-resume/config/tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "tsdown.config.ts", "vitest.config.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"types": ["node"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { TsdownPlugin } from "tsdown";
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "tsdown";
|
||||||
|
|
||||||
|
const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as {
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldExternalizeThirdParty = (id: string) => {
|
||||||
|
if (id.startsWith("@reactive-resume/")) return false;
|
||||||
|
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiPromptsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../packages/ai/src/prompts");
|
||||||
|
|
||||||
|
const promptAssetsPlugin: TsdownPlugin = {
|
||||||
|
name: "prompt-assets",
|
||||||
|
buildStart() {
|
||||||
|
for (const filename of readdirSync(aiPromptsDir)) {
|
||||||
|
if (!filename.endsWith(".md")) continue;
|
||||||
|
|
||||||
|
this.emitFile({
|
||||||
|
type: "asset",
|
||||||
|
fileName: `prompts/${filename}`,
|
||||||
|
source: readFileSync(resolve(aiPromptsDir, filename), "utf-8"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: { index: "src/index.ts" },
|
||||||
|
format: "esm",
|
||||||
|
platform: "node",
|
||||||
|
target: "node24",
|
||||||
|
outDir: "dist",
|
||||||
|
clean: true,
|
||||||
|
shims: true,
|
||||||
|
dts: false,
|
||||||
|
define: { __APP_VERSION__: JSON.stringify(rootPackageJson.version ?? "0.0.0") },
|
||||||
|
outExtensions: () => ({ js: ".mjs" }),
|
||||||
|
deps: {
|
||||||
|
alwaysBundle: [/^@reactive-resume\//],
|
||||||
|
neverBundle: shouldExternalizeThirdParty,
|
||||||
|
},
|
||||||
|
plugins: [promptAssetsPlugin],
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tags": ["app:server", "runtime:server", "role:adapter"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
// @boundaries-ignore root shared Vitest config
|
||||||
|
import { createVitestProjectConfig } from "../../vitest.shared";
|
||||||
|
|
||||||
|
export default createVitestProjectConfig({
|
||||||
|
name: "server",
|
||||||
|
dirname: fileURLToPath(new URL(".", import.meta.url)),
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#09090B" />
|
||||||
|
<meta name="application-name" content="Reactive Resume" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<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.">
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
|
||||||
|
<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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+751
-287
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+755
-291
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+756
-292
File diff suppressed because it is too large
Load Diff
+752
-288
File diff suppressed because it is too large
Load Diff
+752
-288
File diff suppressed because it is too large
Load Diff
+739
-275
File diff suppressed because it is too large
Load Diff
+51
-53
@@ -4,10 +4,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "rm -rf dist && vite build",
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"start": "node .output/server/index.mjs",
|
"start": "vite preview",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
@@ -16,93 +16,91 @@
|
|||||||
"lingui:extract": "lingui extract --clean --overwrite"
|
"lingui:extract": "lingui extract --clean --overwrite"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@ai-sdk/react": "^3.0.193",
|
||||||
"@better-auth/api-key": "^1.6.9",
|
"@base-ui/react": "^1.5.0",
|
||||||
"@better-auth/infra": "^0.2.6",
|
"@better-auth/api-key": "^1.6.11",
|
||||||
"@better-auth/oauth-provider": "^1.6.9",
|
"@better-auth/infra": "^0.2.8",
|
||||||
"@better-auth/passkey": "^1.6.9",
|
"@better-auth/oauth-provider": "^1.6.11",
|
||||||
|
"@better-auth/passkey": "^1.6.11",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lingui/core": "^6.0.1",
|
"@lingui/core": "^6.1.0",
|
||||||
"@lingui/react": "^6.0.1",
|
"@lingui/react": "^6.1.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@orpc/client": "^1.14.3",
|
||||||
"@orpc/client": "^1.14.2",
|
"@orpc/server": "^1.14.3",
|
||||||
"@orpc/json-schema": "^1.14.2",
|
"@orpc/tanstack-query": "^1.14.3",
|
||||||
"@orpc/openapi": "^1.14.2",
|
|
||||||
"@orpc/server": "^1.14.2",
|
|
||||||
"@orpc/tanstack-query": "^1.14.2",
|
|
||||||
"@orpc/zod": "^1.14.2",
|
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@reactive-resume/ai": "workspace:*",
|
"@reactive-resume/ai": "workspace:*",
|
||||||
"@reactive-resume/api": "workspace:*",
|
"@reactive-resume/api": "workspace:*",
|
||||||
"@reactive-resume/auth": "workspace:*",
|
"@reactive-resume/auth": "workspace:*",
|
||||||
"@reactive-resume/db": "workspace:*",
|
"@reactive-resume/docx": "workspace:*",
|
||||||
"@reactive-resume/env": "workspace:*",
|
|
||||||
"@reactive-resume/fonts": "workspace:*",
|
"@reactive-resume/fonts": "workspace:*",
|
||||||
"@reactive-resume/import": "workspace:*",
|
"@reactive-resume/import": "workspace:*",
|
||||||
"@reactive-resume/pdf": "workspace:*",
|
"@reactive-resume/pdf": "workspace:*",
|
||||||
|
"@reactive-resume/resume": "workspace:*",
|
||||||
"@reactive-resume/schema": "workspace:*",
|
"@reactive-resume/schema": "workspace:*",
|
||||||
"@reactive-resume/ui": "workspace:*",
|
"@reactive-resume/ui": "workspace:*",
|
||||||
"@reactive-resume/utils": "workspace:*",
|
"@reactive-resume/utils": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-form": "^1.29.1",
|
"@tanstack/react-form": "^1.32.0",
|
||||||
"@tanstack/react-hotkeys": "^0.10.0",
|
"@tanstack/react-hotkeys": "^0.10.0",
|
||||||
"@tanstack/react-query": "^5.100.9",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"@tanstack/react-router": "^1.169.2",
|
"@tanstack/react-router": "^1.170.8",
|
||||||
"@tanstack/react-router-ssr-query": "^1.166.12",
|
"@tiptap/extension-color": "^3.23.6",
|
||||||
"@tanstack/react-start": "^1.167.65",
|
"@tiptap/extension-highlight": "^3.23.6",
|
||||||
"@tiptap/extension-color": "^3.22.5",
|
"@tiptap/extension-table": "^3.23.6",
|
||||||
"@tiptap/extension-highlight": "^3.22.5",
|
"@tiptap/extension-text-align": "^3.23.6",
|
||||||
"@tiptap/extension-table": "^3.22.5",
|
"@tiptap/extension-text-style": "^3.23.6",
|
||||||
"@tiptap/extension-text-align": "^3.22.5",
|
"@tiptap/pm": "^3.23.6",
|
||||||
"@tiptap/extension-text-style": "^3.22.5",
|
"@tiptap/react": "^3.23.6",
|
||||||
"@tiptap/pm": "^3.22.5",
|
"@tiptap/starter-kit": "^3.23.6",
|
||||||
"@tiptap/react": "^3.22.5",
|
|
||||||
"@tiptap/starter-kit": "^3.22.5",
|
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@uiw/color-convert": "^2.10.1",
|
"@uiw/color-convert": "^2.10.3",
|
||||||
"@uiw/react-color-colorful": "^2.10.1",
|
"@uiw/react-color-colorful": "^2.10.3",
|
||||||
"better-auth": "1.6.9",
|
"ai": "^6.0.191",
|
||||||
|
"better-auth": "1.6.11",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"drizzle-orm": "1.0.0-beta.22",
|
"drizzle-orm": "1.0.0-rc.3",
|
||||||
"es-toolkit": "^1.46.1",
|
"es-toolkit": "^1.47.0",
|
||||||
"fuse.js": "^7.3.0",
|
"fuse.js": "^7.3.0",
|
||||||
"immer": "^11.1.7",
|
"immer": "^11.1.8",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.7",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.40.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.21.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-resizable-panels": "^4.11.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-resizable-panels": "^4.11.2",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
"react-zoom-pan-pinch": "^4.0.3",
|
"react-zoom-pan-pinch": "^4.0.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"srvx": "^0.11.15",
|
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "^5.9.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lingui/babel-plugin-lingui-macro": "^6.0.1",
|
"@babel/core": "^7.29.7",
|
||||||
"@lingui/cli": "^6.0.1",
|
"@lingui/babel-plugin-lingui-macro": "^6.1.0",
|
||||||
"@lingui/format-po": "^6.0.1",
|
"@lingui/cli": "^6.1.0",
|
||||||
"@lingui/vite-plugin": "^6.0.1",
|
"@lingui/format-po": "^6.1.0",
|
||||||
|
"@lingui/vite-plugin": "^6.1.0",
|
||||||
"@reactive-resume/config": "workspace:*",
|
"@reactive-resume/config": "workspace:*",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
|
"@tanstack/router-plugin": "^1.168.11",
|
||||||
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260508.1",
|
"@typescript/native-preview": "7.0.0-dev.20260526.1",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-macros": "^3.1.0",
|
||||||
"nitro": "3.0.260429-beta",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.11",
|
"vite": "^8.0.14"
|
||||||
"vite-plugin-pwa": "^1.3.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user