Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ This repo is intended to be publishable as an open-core app.
| --- | --- | --- | --- |
| File-backed local mode | Editor, preview, templates, local publishing flows | Nothing beyond Node and pnpm | Fast local development and simple self-hosting |
| Database-backed mode | Durable workspaces, accounts, account-owned resumes, full developer API | Postgres | Serious self-hosting and production deployments |
| Browser-backed jobs | Chromium-measured publish fit and async PDF jobs | `TINYCV_BROWSER_WS_ENDPOINT` or `TINYCV_CHROME_EXECUTABLE_PATH` | Production PDF/export parity |
| Browser-backed jobs | Chromium-measured publish fit, async PDF jobs, and per-resume social cards | `TINYCV_BROWSER_WS_ENDPOINT` or `TINYCV_CHROME_EXECUTABLE_PATH` | Production PDF/export parity |
| Billing and machine payments | Stripe checkout, premium entitlements, x402/MPP paid endpoints | Extra provider credentials | Optional commercial add-ons |

## Quick Start
Expand Down Expand Up @@ -192,7 +192,7 @@ High-level deployment requirements:

- Postgres for durable production storage
- explicit secrets for editor, platform, auth, and worker flows
- Chromium access for browser-measured publish fit and PDF jobs
- Chromium access for browser-measured publish fit, PDF jobs, and social cards
- a scheduled job or worker invocation for `/api/v1/jobs/process`

## Verification
Expand Down
4 changes: 2 additions & 2 deletions docs/production-launch-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This guide is the public production checklist for self-hosting Tiny CV with dura
Tiny CV works best when you treat the deployment as two layers:

- core app: Next.js server that handles the editor, API, auth, and public resumes
- background/browser layer: a worker or scheduled caller plus Chromium access for publish-fit measurement and PDF jobs
- background/browser layer: a worker or scheduled caller plus Chromium access for publish-fit measurement, PDF jobs, and social cards

You can run both on one machine for small deployments or split them across services later.

Expand Down Expand Up @@ -64,7 +64,7 @@ pnpm check:prod

### 3. Browser-Backed Jobs

API publish-fit measurement and async PDF jobs need Chromium access.
API publish-fit measurement, async PDF jobs, and the per-resume social cards on published pages need Chromium access. Without it, published pages fall back to the generic Tiny CV card.

Set one of:

Expand Down
76 changes: 76 additions & 0 deletions src/app/[slug]/social-card/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextResponse, type NextRequest } from "next/server";
import {
ApiRateLimitError,
ApiRateLimitUnavailableError,
assertApiRateLimit,
} from "@/app/_lib/api-rate-limit";
import { BrowserRendererUnavailableError } from "@/app/_lib/browser-renderer";
import {
getPublishedResumeBySlug,
HostedResumeStoreUnavailableError,
} from "@/app/_lib/hosted-resume-store";
import {
buildResumeSocialCardRenderUrl,
renderResumeSocialCard,
SOCIAL_CARD_CONTENT_TYPE,
} from "@/app/_lib/resume-social-card";
import { TINYCV_SOCIAL_CARD_PATH } from "@/app/_lib/site-metadata";

export const maxDuration = 60;

const CARD_CACHE_CONTROL = "public, max-age=3600, s-maxage=604800, stale-while-revalidate=604800";
const FALLBACK_CACHE_CONTROL = "public, max-age=60, s-maxage=300";

export async function GET(
request: NextRequest,
context: RouteContext<"/[slug]/social-card">,
) {
const { slug } = await context.params;

try {
await assertApiRateLimit({ action: "social:card", request });

const resume = await getPublishedResumeBySlug(slug);

if (!resume) {
return buildFallbackCardResponse(request);
}

const card = await renderResumeSocialCard(
buildResumeSocialCardRenderUrl(request.nextUrl.origin, resume.slug),
);

return new Response(card as BodyInit, {
headers: {
"Cache-Control": CARD_CACHE_CONTROL,
"Content-Type": SOCIAL_CARD_CONTENT_TYPE,
},
});
} catch (error) {
if (isRecoverableCardError(error)) {
return buildFallbackCardResponse(request);
}

throw error;
}
}

/**
* A link preview should never look broken, so anything that stops us rendering
* the resume itself falls back to the generic Tiny CV card.
*/
function buildFallbackCardResponse(request: NextRequest) {
return NextResponse.redirect(new URL(TINYCV_SOCIAL_CARD_PATH, request.nextUrl.origin), {
headers: { "Cache-Control": FALLBACK_CACHE_CONTROL },
status: 307,
});
}

function isRecoverableCardError(error: unknown) {
return (
error instanceof ApiRateLimitError ||
error instanceof ApiRateLimitUnavailableError ||
error instanceof BrowserRendererUnavailableError ||
error instanceof HostedResumeStoreUnavailableError
);
}
14 changes: 12 additions & 2 deletions src/app/_components/public-resume-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { getPageMetrics } from "@/app/_lib/cv-fit";
import { parseCvMarkdown } from "@/app/_lib/cv-markdown";
import { canRemoveBrandingForResume } from "@/app/_lib/entitlements";
import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types";
import { getTinyCvSocialCardImage } from "@/app/_lib/site-metadata";
import {
buildResumeSocialCardPath,
SOCIAL_CARD_CONTENT_TYPE,
SOCIAL_CARD_IMAGE_SIZE,
} from "@/app/_lib/resume-social-card";

export async function PublicResumeRenderer({
autoPrint = false,
Expand Down Expand Up @@ -60,7 +64,13 @@ export async function buildPublicResumeMetadata(
: `${document.name}'s resume, published with Tiny CV.`;
const title = `${document.name} | Resume`;
const canonicalPath = options?.canonicalPath ?? `/${resume.slug}`;
const socialCardImage = getTinyCvSocialCardImage();
const socialCardImage = {
alt: `The top of ${document.name}'s resume`,
height: SOCIAL_CARD_IMAGE_SIZE.height,
type: SOCIAL_CARD_CONTENT_TYPE,
url: buildResumeSocialCardPath(resume),
width: SOCIAL_CARD_IMAGE_SIZE.width,
} as const;

return {
alternates: {
Expand Down
2 changes: 2 additions & 0 deletions src/app/_lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type ApiRateLimitAction =
| "api:resume_publish"
| "api:resume_update"
| "api:validate"
| "social:card"
| "workspace:attach"
| "workspace:bootstrap"
| "workspace:create"
Expand Down Expand Up @@ -44,6 +45,7 @@ const DEFAULT_POLICIES: Record<ApiRateLimitAction, RateLimitPolicy> = {
"api:resume_publish": { max: 30, windowSeconds: 60 },
"api:resume_update": { max: 120, windowSeconds: 60 },
"api:validate": { max: 120, windowSeconds: 60 },
"social:card": { max: 120, windowSeconds: 60 },
"workspace:attach": { max: 30, windowSeconds: 60 },
"workspace:bootstrap": { max: 20, windowSeconds: 60 * 60 },
"workspace:create": { max: 30, windowSeconds: 60 },
Expand Down
57 changes: 57 additions & 0 deletions src/app/_lib/resume-social-card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types";
import {
buildResumeSocialCardPath,
buildResumeSocialCardRenderUrl,
} from "@/app/_lib/resume-social-card";

function buildResume(overrides: Partial<HostedResumePublicRecord> = {}): HostedResumePublicRecord {
return {
createdAt: "2026-05-01T00:00:00.000Z",
fitScale: 1,
id: "resume_123",
isPublished: true,
markdown: "# Steady Blue Heron",
publishedAt: "2026-06-01T00:00:00.000Z",
slug: "SteadyBlueHeron",
templateKey: "engineer",
title: "Steady Blue Heron",
updatedAt: "2026-06-02T00:00:00.000Z",
...overrides,
};
}

describe("resume-social-card", () => {
it("versions the card path by publish time so edits are not served from cache", () => {
const publishedAt = buildResumeSocialCardPath(buildResume());
const republished = buildResumeSocialCardPath(
buildResume({ publishedAt: "2026-06-03T00:00:00.000Z" }),
);

expect(publishedAt).toBe(
`/SteadyBlueHeron/social-card?v=${Date.parse("2026-06-01T00:00:00.000Z").toString(36)}`,
);
expect(republished).not.toBe(publishedAt);
});

it("falls back to the update time when a resume has no publish time", () => {
expect(buildResumeSocialCardPath(buildResume({ publishedAt: null }))).toBe(
`/SteadyBlueHeron/social-card?v=${Date.parse("2026-06-02T00:00:00.000Z").toString(36)}`,
);
});

it("escapes slugs in the card path", () => {
expect(buildResumeSocialCardPath(buildResume({ slug: "steady blue/heron" }))).toContain(
"/steady%20blue%2Fheron/social-card",
);
});

it("builds render URLs against the requesting origin", () => {
expect(buildResumeSocialCardRenderUrl("https://tiny.cv", "SteadyBlueHeron")).toBe(
"https://tiny.cv/internal/social-card/SteadyBlueHeron",
);
expect(buildResumeSocialCardRenderUrl("http://localhost:3000/", "SteadyBlueHeron")).toBe(
"http://localhost:3000/internal/social-card/SteadyBlueHeron",
);
});
});
62 changes: 62 additions & 0 deletions src/app/_lib/resume-social-card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import "server-only";

import { getBrowserTimeoutMs, withBrowserPage } from "@/app/_lib/browser-renderer";
import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types";

export const SOCIAL_CARD_CONTENT_TYPE = "image/png";

/** Open Graph's 1.91:1 landscape card, rendered at 2x for high density displays. */
export const SOCIAL_CARD_LAYOUT = {
deviceScaleFactor: 2,
height: 630,
width: 1200,
} as const;

export const SOCIAL_CARD_IMAGE_SIZE = {
height: SOCIAL_CARD_LAYOUT.height * SOCIAL_CARD_LAYOUT.deviceScaleFactor,
width: SOCIAL_CARD_LAYOUT.width * SOCIAL_CARD_LAYOUT.deviceScaleFactor,
} as const;

/**
* How far the 8.5in page is scaled up on the card. It leaves a ~110px gutter on
* each side, and the page runs off the bottom edge so the card shows the top of
* the resume at a readable size rather than a shrunken whole page.
*/
export const SOCIAL_CARD_PAGE_SCALE = 1.2;

export function buildResumeSocialCardPath(resume: HostedResumePublicRecord) {
return `/${encodeURIComponent(resume.slug)}/social-card?v=${buildSocialCardVersion(resume)}`;
}

export function buildResumeSocialCardRenderUrl(origin: string, slug: string) {
return new URL(
`/internal/social-card/${encodeURIComponent(slug)}`,
origin.replace(/\/+$/, ""),
).toString();
}

export async function renderResumeSocialCard(renderUrl: string) {
return await withBrowserPage(async (page) => {
await page.setViewport({
deviceScaleFactor: SOCIAL_CARD_LAYOUT.deviceScaleFactor,
height: SOCIAL_CARD_LAYOUT.height,
width: SOCIAL_CARD_LAYOUT.width,
});
await page.goto(renderUrl, {
timeout: getBrowserTimeoutMs(),
waitUntil: "networkidle0",
});
await page.evaluate(() => window.document.fonts.ready.then(() => undefined));

return await page.screenshot({
captureBeyondViewport: false,
type: "png",
});
});
}

function buildSocialCardVersion(resume: HostedResumePublicRecord) {
const timestamp = Date.parse(resume.publishedAt ?? resume.updatedAt);

return Number.isFinite(timestamp) ? timestamp.toString(36) : "1";
}
25 changes: 25 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,31 @@ textarea {
}
}

/*
* Screenshot surface for a resume's social card. The sheet is anchored to the
* top and runs off the bottom edge, so the card shows the head of the resume at
* a readable size instead of a shrunken full page.
*/
.social-card-stage {
position: relative;
overflow: hidden;
background:
radial-gradient(circle at 50% -24%, rgba(15, 118, 110, 0.09), transparent 62%),
linear-gradient(180deg, #fbf8f2 0%, #f1ece3 100%);
}

.social-card-paper {
position: absolute;
left: 50%;
top: 44px;
width: calc(816px * var(--cv-stage-scale));
transform: translateX(-50%);
}

.social-card-paper .cv-sheet {
border-radius: 0.55rem 0.55rem 0 0;
}

.cv-document {
height: 100%;
position: relative;
Expand Down
63 changes: 63 additions & 0 deletions src/app/internal/social-card/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { CSSProperties } from "react";
import { notFound } from "next/navigation";
import { ResumeDesktopSheet } from "@/app/_components/resume-live-document";
import { parseCvMarkdown } from "@/app/_lib/cv-markdown";
import {
getPublishedResumeBySlug,
HostedResumeStoreUnavailableError,
} from "@/app/_lib/hosted-resume-store";
import { SOCIAL_CARD_LAYOUT, SOCIAL_CARD_PAGE_SCALE } from "@/app/_lib/resume-social-card";
import { TINYCV_NOINDEX_METADATA } from "@/app/_lib/site-metadata";

export const dynamic = "force-dynamic";
export const metadata = TINYCV_NOINDEX_METADATA;

/**
* The surface the social card screenshot is taken from. It renders the same
* sheet the published page and the PDF use, so a card can never drift from the
* resume it links to.
*/
export default async function ResumeSocialCardPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
let resume = null;

try {
resume = await getPublishedResumeBySlug(slug);
} catch (error) {
if (error instanceof HostedResumeStoreUnavailableError) {
notFound();
}

throw error;
}

if (!resume) {
notFound();
}

const document = parseCvMarkdown(resume.markdown);

return (
<main
className="social-card-stage"
style={{
height: `${SOCIAL_CARD_LAYOUT.height}px`,
width: `${SOCIAL_CARD_LAYOUT.width}px`,
}}
>
<div
className="cv-paper-frame cv-scaled-frame social-card-paper"
style={{ "--cv-stage-scale": SOCIAL_CARD_PAGE_SCALE } as CSSProperties}
>
<ResumeDesktopSheet document={document} fitScale={resume.fitScale} interactive={false} />
</div>

{/* The screenshot must hold the card and nothing else, including the dev overlay. */}
<style>{"nextjs-portal { display: none !important; }"}</style>
</main>
);
}
Loading