Improve error handling + local setup (#4)

* Add unified Docker self-host stack for OpenSEO and Gateway

* Fix self-host migration flow and env wiring

* Track latest gateway release by default and harden migrations

* Fix gateway static asset serving in self-host runtime

* Run gateway self-host from source with .env-based Vite runtime

* Harden self-host env mode and remove apt dependency from gateway build

* Avoid gateway build ENOSPC by installing deps at runtime

* Revert temporary ENOSPC workaround for gateway Docker setup

* Use Cloudflare selfhost env precedence for Docker runtime

* Add selfhost wrangler environment and tighten Docker docs

* Stabilize selfhost Docker runtime and persistence

Use full Node base images for reliable TLS trust, simplify selfhost startup/env handling, and persist local gateway wrangler state to avoid resets between restarts.

* Simplify Docker selfhost: rename scripts to docker:selfhost:*, remove GATEWAY_RELEASE_REPO, default to latest gateway

* Remove selfhost wrangler env, use .env.local everywhere, simplify docker script names

* Simplify selfhost compose: remove D1 preflight, conditional migration, and healthcheck

* Extract inline Node.js from gateway Dockerfile into standalone scripts

* Save

* Move OpenSEO dependency install from container startup to image build step

* Move self-host docker scripts and compose into dedicated folder

* save

* disable TanStack devtools in Docker self-host

* Make edits

* clarify self-hosting env setup docs

* save

* save

* centralize server-function error middleware and simplify client-safe messaging

* remove local migration-specific error remapping

Keep auth middleware simple by letting DB/schema failures flow through the centralized error handler and remove the unused LOCAL_DB_MIGRATION_REQUIRED client/server code path.

* remove custom error logging in favor of plain console.error

* standardize PSI error codes and sanitize non-Error throws
This commit is contained in:
Ben Senescu 2026-03-04 16:27:41 -05:00 committed by GitHub
parent 4350b12c67
commit 35700ca80a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 158 additions and 429 deletions

View File

@ -170,6 +170,8 @@ cp .env.example .env.local
```sh ```sh
pnpm install pnpm install
# Initialize local DB schema (required on a fresh machine)
pnpm run db:migrate:local
# This runs in BYPASS_GATEWAY mode so that you don't need to set up the Every App gateway. This is fine for local use. # This runs in BYPASS_GATEWAY mode so that you don't need to set up the Every App gateway. This is fine for local use.
pnpm dev:agents pnpm dev:agents
``` ```
@ -184,6 +186,8 @@ Running locally is the fastest way to test core flows. In the future, local mode
# This log file make it easier for your coding agent to debug. # This log file make it easier for your coding agent to debug.
mkdir .logs mkdir .logs
touch .logs/dev-server.log touch .logs/dev-server.log
# Run once per fresh local DB
pnpm run db:migrate:local
# terminal 1: start once and keep running # terminal 1: start once and keep running
pnpm dev:agents pnpm dev:agents
``` ```

View File

@ -20,7 +20,6 @@
"src/server.ts", "src/server.ts",
"src/server/lib/audit/progress-kv.ts", "src/server/lib/audit/progress-kv.ts",
"src/server/lib/audit/types.ts", "src/server/lib/audit/types.ts",
"src/server/lib/errors.ts",
"src/server/services/PsiIssuesService.ts", "src/server/services/PsiIssuesService.ts",
"src/server/workflows/SiteAuditWorkflow.ts", "src/server/workflows/SiteAuditWorkflow.ts",
"src/serverFunctions/keywords.ts", "src/serverFunctions/keywords.ts",
@ -29,7 +28,6 @@
"src/types/schemas/psi.ts", "src/types/schemas/psi.ts",
], ],
"ignoreFiles": [ "ignoreFiles": [
"src/server/lib/serverFnErrorBoundary.ts",
"src/server/services/keyword-research/helpers.ts", "src/server/services/keyword-research/helpers.ts",
"src/server/services/keyword-research/projects.ts", "src/server/services/keyword-research/projects.ts",
"src/server/services/keyword-research/research-data.ts", "src/server/services/keyword-research/research-data.ts",

View File

@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"dev:agents": "BYPASS_GATEWAY_LOCAL_ONLY=true vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents": "mkdir -p .logs && BYPASS_GATEWAY_LOCAL_ONLY=true vite dev 2>&1 | tee .logs/dev-server.log",
"build": "vite build && tsc --noEmit", "build": "vite build && tsc --noEmit",
"lint": "oxlint .", "lint": "oxlint .",
"lint:fix": "oxlint . --fix", "lint:fix": "oxlint . --fix",

View File

@ -8,7 +8,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.", CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
RATE_LIMITED: "Too many requests. Please wait and try again.", RATE_LIMITED: "Too many requests. Please wait and try again.",
CONFLICT: "This request conflicts with existing data.", CONFLICT: "This request conflicts with existing data.",
INTERNAL_ERROR: "Something went wrong. Please try again.", INTERNAL_ERROR:
"An unexpected error occurred. Please check server logs and try again.",
}; };
export function getStandardErrorMessage( export function getStandardErrorMessage(

View File

@ -7,22 +7,16 @@ import {
getAuthConfig, getAuthConfig,
} from "@every-app/sdk/tanstack/server"; } from "@every-app/sdk/tanstack/server";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { logServerError } from "@/server/lib/logger";
export const ensureUserMiddleware = createMiddleware({ export const ensureUserMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async (c) => { }).server(async (c) => {
const { next } = c; const { next } = c;
const authConfig = getAuthConfig(); const authConfig = getAuthConfig();
const session = await authenticateRequest(authConfig); const session = await authenticateRequest(authConfig);
if (!session) { if (!session || !session.email) {
throw new AppError("UNAUTHENTICATED");
}
if (!session.email) {
throw new AppError("UNAUTHENTICATED"); throw new AppError("UNAUTHENTICATED");
} }
@ -34,23 +28,16 @@ export const ensureUserMiddleware = createMiddleware({
}); });
if (!user) { if (!user) {
try { await db.insert(users).values({
await db.insert(users).values({ id: userId,
id: userId, email: session.email,
email: session.email, });
});
} catch (error) {
logServerError("auth.ensure-user.create", error, { userId });
throw new AppError("INTERNAL_ERROR");
}
} }
const userEmail = user?.email || session.email;
return next({ return next({
context: { context: {
userId, userId,
userEmail, userEmail: user?.email || session.email,
session, session,
}, },
}); });

View File

@ -0,0 +1,24 @@
import { createMiddleware } from "@tanstack/react-start";
import { asAppError, toClientError } from "@/server/lib/errors";
export const errorHandlingMiddleware = createMiddleware({
type: "function",
}).server(async (c) => {
const { next } = c;
try {
return await next();
} catch (error) {
if (!(error instanceof Error)) {
throw new Error("INTERNAL_ERROR", { cause: error });
}
const appError = asAppError(error);
if (appError?.code !== "UNAUTHENTICATED") {
console.error("server.function error:", error);
}
throw toClientError(error);
}
});

View File

@ -29,7 +29,10 @@ function IndexRedirect() {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-error"> <p className="text-error">
{getStandardErrorMessage(error, "Failed to load. Please try again.")} {getStandardErrorMessage(
error,
"An unexpected error occurred. Please check server logs.",
)}
</p> </p>
</div> </div>
); );

View File

@ -18,7 +18,7 @@ export function asAppError(error: unknown): AppError | null {
return null; return null;
} }
export function toErrorCode(error: unknown): ErrorCode { function toErrorCode(error: unknown): ErrorCode {
return asAppError(error)?.code ?? "INTERNAL_ERROR"; return asAppError(error)?.code ?? "INTERNAL_ERROR";
} }

View File

@ -1,68 +0,0 @@
import { toErrorCode } from "@/server/lib/errors";
type LogContext = Record<string, unknown>;
const SENSITIVE_KEY_PATTERN = /token|secret|password|key|email|authorization/i;
function sanitizeValue(key: string, value: unknown): unknown {
if (SENSITIVE_KEY_PATTERN.test(key)) return "[REDACTED]";
if (typeof value === "string") {
return value.length > 300 ? `${value.slice(0, 300)}...` : value;
}
if (Array.isArray(value)) {
return value.slice(0, 10).map((item) => sanitizeValue(key, item));
}
if (value && typeof value === "object") {
const output: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
output[k] = sanitizeValue(k, v);
}
return output;
}
return value;
}
function sanitizeContext(context: LogContext): LogContext {
const safe: LogContext = {};
for (const [key, value] of Object.entries(context)) {
safe[key] = sanitizeValue(key, value);
}
return safe;
}
export function logServerError(
operation: string,
error: unknown,
context: LogContext = {},
): void {
const code = toErrorCode(error);
const safeErrorMessage =
error instanceof Error
? sanitizeValue("message", error.message)
: "unknown";
const safeStack =
error instanceof Error && typeof error.stack === "string"
? sanitizeValue("stack", error.stack)
: undefined;
const safeCause =
error instanceof Error && "cause" in error
? sanitizeValue("cause", (error as { cause?: unknown }).cause)
: undefined;
console.error(
JSON.stringify({
level: "error",
operation,
code,
errorName: error instanceof Error ? error.name : "UnknownError",
message: safeErrorMessage,
stack: safeStack,
cause: safeCause,
context: sanitizeContext(context),
}),
);
}

View File

@ -1,51 +0,0 @@
export class PublicServerError extends Error {
constructor(message: string) {
super(message);
this.name = "PublicServerError";
}
}
interface ErrorBoundaryOptions<TArgs> {
fallbackMessage?: string;
passThroughMessages?: string[];
getLogContext?: (args: TArgs) => Record<string, unknown>;
}
export function withServerFnErrorBoundary<TArgs, TResult>(
operation: string,
handler: (args: TArgs) => Promise<TResult>,
options: ErrorBoundaryOptions<TArgs> = {},
) {
const passThroughMessages = new Set(options.passThroughMessages ?? []);
return async (args: TArgs): Promise<TResult> => {
try {
return await handler(args);
} catch (error) {
if (error instanceof PublicServerError) {
throw error;
}
if (error instanceof Error && passThroughMessages.has(error.message)) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
const cause =
error instanceof Error && "cause" in error
? (error as { cause?: unknown }).cause
: undefined;
const logContext = options.getLogContext?.(args);
console.error(`${operation} failed`, {
message,
cause,
stack: error instanceof Error ? error.stack : undefined,
...logContext,
});
throw new PublicServerError(
options.fallbackMessage ?? "Something went wrong. Please try again.",
);
}
};
}

View File

@ -7,7 +7,6 @@ import {
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import { sortBy } from "remeda"; import { sortBy } from "remeda";
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import { logServerError } from "@/server/lib/logger";
/** Domain overview data is refreshed every 12 hours. */ /** Domain overview data is refreshed every 12 hours. */
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60; const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;
@ -111,11 +110,7 @@ async function getOverview(input: {
if (result.hasData) { if (result.hasData) {
void setCached(cacheKey, result, DOMAIN_OVERVIEW_TTL_SECONDS).catch( void setCached(cacheKey, result, DOMAIN_OVERVIEW_TTL_SECONDS).catch(
(error) => { (error) => {
logServerError("domain.overview.cache-write", error, { console.error("domain.overview.cache-write failed:", error);
domain,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
}, },
); );
} }

View File

@ -28,7 +28,6 @@ import {
} from "@/server/lib/kv-cache"; } from "@/server/lib/kv-cache";
import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/repositories/KeywordResearchRepository";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { logServerError } from "@/server/lib/logger";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -64,15 +63,12 @@ type EnrichedKeyword = {
type KeywordSource = "related" | "suggestions" | "ideas"; type KeywordSource = "related" | "suggestions" | "ideas";
function parseMonthlySearches( function parseMonthlySearches(payload: string | null): MonthlySearch[] {
payload: string | null,
context: { keyword: string; projectId: string },
): MonthlySearch[] {
if (!payload) return []; if (!payload) return [];
try { try {
return JSON.parse(payload) as MonthlySearch[]; return JSON.parse(payload) as MonthlySearch[];
} catch (error) { } catch (error) {
logServerError("keywords.saved.parse-monthly-searches", error, context); console.error("keywords.saved.parse-monthly-searches failed:", error);
return []; return [];
} }
} }
@ -302,11 +298,7 @@ async function research(
}), }),
), ),
).catch((error) => { ).catch((error) => {
logServerError("keywords.research.persist-metrics", error, { console.error("keywords.research.persist-metrics failed:", error);
locationCode: input.locationCode,
languageCode: input.languageCode,
rowCount: rows.length,
});
}); });
return { rows, source, usedFallback }; return { rows, source, usedFallback };
@ -428,10 +420,7 @@ async function getSavedKeywords(
competition: metric?.competition ?? null, competition: metric?.competition ?? null,
keywordDifficulty: metric?.keywordDifficulty ?? null, keywordDifficulty: metric?.keywordDifficulty ?? null,
intent: metric?.intent ?? null, intent: metric?.intent ?? null,
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null, { monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null),
keyword: row.keyword,
projectId: row.projectId,
}),
fetchedAt: metric?.fetchedAt ?? null, fetchedAt: metric?.fetchedAt ?? null,
})), })),
}; };

View File

@ -7,17 +7,13 @@ import type {
} from "@/types/schemas/keywords"; } from "@/types/schemas/keywords";
import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords"; import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
import { normalizeKeyword } from "./helpers"; import { normalizeKeyword } from "./helpers";
import { logServerError } from "@/server/lib/logger";
function parseMonthlySearches( function parseMonthlySearches(payload: string | null): MonthlySearch[] {
payload: string | null,
context: { keyword: string; projectId: string },
): MonthlySearch[] {
if (!payload) return []; if (!payload) return [];
try { try {
return JSON.parse(payload) as MonthlySearch[]; return JSON.parse(payload) as MonthlySearch[];
} catch (error) { } catch (error) {
logServerError("keywords.saved.parse-monthly-searches", error, context); console.error("keywords.saved.parse-monthly-searches failed:", error);
return []; return [];
} }
} }
@ -76,10 +72,7 @@ export async function getSavedKeywords(
competition: metric?.competition ?? null, competition: metric?.competition ?? null,
keywordDifficulty: metric?.keywordDifficulty ?? null, keywordDifficulty: metric?.keywordDifficulty ?? null,
intent: metric?.intent ?? null, intent: metric?.intent ?? null,
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null, { monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null),
keyword: row.keyword,
projectId: row.projectId,
}),
fetchedAt: metric?.fetchedAt ?? null, fetchedAt: metric?.fetchedAt ?? null,
})), })),
}; };

View File

@ -1,6 +1,6 @@
import { fetchHistoricalSerpsRaw } from "@/server/lib/dataforseo"; import { fetchHistoricalSerpsRaw } from "@/server/lib/dataforseo";
import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import { logServerError } from "@/server/lib/logger";
import type { SerpResultItem } from "@/types/keywords"; import type { SerpResultItem } from "@/types/keywords";
import { normalizeKeyword } from "./helpers"; import { normalizeKeyword } from "./helpers";
@ -57,11 +57,7 @@ export async function getSerpAnalysis(input: {
if (items.length > 0) { if (items.length > 0) {
void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((error) => { void setCached(cacheKey, result, SERP_CACHE_TTL_SECONDS).catch((error) => {
logServerError("keywords.serp.cache-write", error, { console.error("keywords.serp.cache-write failed:", error);
keyword,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
}); });
} }

View File

@ -1,6 +1,5 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { ensureUserMiddleware } from "@/middleware/ensureUser"; import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import { import {
startAuditSchema, startAuditSchema,
getAuditStatusSchema, getAuditStatusSchema,
@ -10,103 +9,53 @@ import {
getCrawlProgressSchema, getCrawlProgressSchema,
} from "@/types/schemas/audit"; } from "@/types/schemas/audit";
import { AuditService } from "@/server/services/AuditService"; import { AuditService } from "@/server/services/AuditService";
import { logServerError } from "@/server/lib/logger";
import { toClientError } from "@/server/lib/errors";
export const startAudit = createServerFn({ method: "POST" }) export const startAudit = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => startAuditSchema.parse(data)) .inputValidator((data: unknown) => startAuditSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { AuditService.startAudit({
return await AuditService.startAudit({ userId: context.userId,
userId: context.userId, projectId: data.projectId,
projectId: data.projectId, startUrl: data.startUrl,
startUrl: data.startUrl, maxPages: data.maxPages,
maxPages: data.maxPages, psiStrategy: data.psiStrategy,
psiStrategy: data.psiStrategy, psiApiKey: data.psiApiKey,
psiApiKey: data.psiApiKey, }),
}); );
} catch (error) {
logServerError("audit.start", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const getAuditStatus = createServerFn({ method: "POST" }) export const getAuditStatus = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => getAuditStatusSchema.parse(data)) .inputValidator((data: unknown) => getAuditStatusSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { AuditService.getStatus(data.auditId, context.userId),
return await AuditService.getStatus(data.auditId, context.userId); );
} catch (error) {
logServerError("audit.status", error, {
userId: context.userId,
auditId: data.auditId,
});
throw toClientError(error);
}
});
export const getAuditResults = createServerFn({ method: "POST" }) export const getAuditResults = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => getAuditResultsSchema.parse(data)) .inputValidator((data: unknown) => getAuditResultsSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { AuditService.getResults(data.auditId, context.userId),
return await AuditService.getResults(data.auditId, context.userId); );
} catch (error) {
logServerError("audit.results", error, {
userId: context.userId,
auditId: data.auditId,
});
throw toClientError(error);
}
});
export const getAuditHistory = createServerFn({ method: "POST" }) export const getAuditHistory = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data)) .inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { AuditService.getHistory(data.projectId, context.userId),
return await AuditService.getHistory(data.projectId, context.userId); );
} catch (error) {
logServerError("audit.history", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const getCrawlProgress = createServerFn({ method: "POST" }) export const getCrawlProgress = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => getCrawlProgressSchema.parse(data)) .inputValidator((data: unknown) => getCrawlProgressSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { AuditService.getCrawlProgress(data.auditId, context.userId),
return await AuditService.getCrawlProgress(data.auditId, context.userId); );
} catch (error) {
logServerError("audit.crawl-progress", error, {
userId: context.userId,
auditId: data.auditId,
});
throw toClientError(error);
}
});
export const deleteAudit = createServerFn({ method: "POST" }) export const deleteAudit = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => deleteAuditSchema.parse(data)) .inputValidator((data: unknown) => deleteAuditSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
try { await AuditService.remove(data.auditId, context.userId);
await AuditService.remove(data.auditId, context.userId); return { success: true };
return { success: true };
} catch (error) {
logServerError("audit.delete", error, {
userId: context.userId,
auditId: data.auditId,
});
throw toClientError(error);
}
}); });

View File

@ -1,22 +1,9 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { ensureUserMiddleware } from "@/middleware/ensureUser"; import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import { domainOverviewSchema } from "@/types/schemas/domain"; import { domainOverviewSchema } from "@/types/schemas/domain";
import { DomainService } from "@/server/services/DomainService"; import { DomainService } from "@/server/services/DomainService";
import { logServerError } from "@/server/lib/logger";
import { toClientError } from "@/server/lib/errors";
export const getDomainOverview = createServerFn({ method: "POST" }) export const getDomainOverview = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => domainOverviewSchema.parse(data)) .inputValidator((data: unknown) => domainOverviewSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data }) => DomainService.getOverview(data));
try {
return await DomainService.getOverview(data);
} catch (error) {
logServerError("domain.overview", error, {
userId: context.userId,
domain: data.domain,
});
throw toClientError(error);
}
});

View File

@ -1,7 +1,6 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { z } from "zod"; import { z } from "zod";
import { ensureUserMiddleware } from "@/middleware/ensureUser"; import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import { import {
researchKeywordsSchema, researchKeywordsSchema,
createProjectSchema, createProjectSchema,
@ -12,157 +11,73 @@ import {
serpAnalysisSchema, serpAnalysisSchema,
} from "@/types/schemas/keywords"; } from "@/types/schemas/keywords";
import { KeywordResearchService } from "@/server/services/KeywordResearchService"; import { KeywordResearchService } from "@/server/services/KeywordResearchService";
import { logServerError } from "@/server/lib/logger";
import { toClientError } from "@/server/lib/errors";
export const researchKeywords = createServerFn({ method: "POST" }) export const researchKeywords = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => researchKeywordsSchema.parse(data)) .inputValidator((data: unknown) => researchKeywordsSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { KeywordResearchService.research(context.userId, data),
return await KeywordResearchService.research(context.userId, data); );
} catch (error) {
logServerError("keywords.research", error, { userId: context.userId });
throw toClientError(error);
}
});
export const listProjects = createServerFn({ method: "POST" }) export const listProjects = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.handler(async ({ context }) => { .handler(async ({ context }) =>
try { KeywordResearchService.listProjects(context.userId),
return await KeywordResearchService.listProjects(context.userId); );
} catch (error) {
logServerError("projects.list", error, { userId: context.userId });
throw toClientError(error);
}
});
export const createProject = createServerFn({ method: "POST" }) export const createProject = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => createProjectSchema.parse(data)) .inputValidator((data: unknown) => createProjectSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { KeywordResearchService.createProject(context.userId, data),
return await KeywordResearchService.createProject(context.userId, data); );
} catch (error) {
logServerError("projects.create", error, { userId: context.userId });
throw toClientError(error);
}
});
export const deleteProject = createServerFn({ method: "POST" }) export const deleteProject = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => deleteProjectSchema.parse(data)) .inputValidator((data: unknown) => deleteProjectSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { KeywordResearchService.deleteProject(context.userId, data),
return await KeywordResearchService.deleteProject(context.userId, data); );
} catch (error) {
logServerError("projects.delete", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const saveKeywords = createServerFn({ method: "POST" }) export const saveKeywords = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => saveKeywordsSchema.parse(data)) .inputValidator((data: unknown) => saveKeywordsSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { KeywordResearchService.saveKeywords(context.userId, data),
return await KeywordResearchService.saveKeywords(context.userId, data); );
} catch (error) {
logServerError("keywords.save", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const getSavedKeywords = createServerFn({ method: "POST" }) export const getSavedKeywords = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => getSavedKeywordsSchema.parse(data)) .inputValidator((data: unknown) => getSavedKeywordsSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { KeywordResearchService.getSavedKeywords(context.userId, data),
return await KeywordResearchService.getSavedKeywords( );
context.userId,
data,
);
} catch (error) {
logServerError("keywords.saved.list", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});
export const removeSavedKeyword = createServerFn({ method: "POST" }) export const removeSavedKeyword = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data)) .inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { KeywordResearchService.removeSavedKeyword(context.userId, data),
return await KeywordResearchService.removeSavedKeyword( );
context.userId,
data,
);
} catch (error) {
logServerError("keywords.saved.remove", error, {
userId: context.userId,
savedKeywordId: data.savedKeywordId,
});
throw toClientError(error);
}
});
export const getOrCreateDefaultProject = createServerFn({ method: "POST" }) export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.handler(async ({ context }) => { .handler(async ({ context }) =>
try { KeywordResearchService.getOrCreateDefaultProject(context.userId),
return await KeywordResearchService.getOrCreateDefaultProject( );
context.userId,
);
} catch (error) {
logServerError("projects.get-or-create-default", error, {
userId: context.userId,
});
throw toClientError(error);
}
});
export const getSerpAnalysis = createServerFn({ method: "POST" }) export const getSerpAnalysis = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => serpAnalysisSchema.parse(data)) .inputValidator((data: unknown) => serpAnalysisSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data }) => KeywordResearchService.getSerpAnalysis(data));
try {
return await KeywordResearchService.getSerpAnalysis(data);
} catch (error) {
logServerError("keywords.serp-analysis", error, {
userId: context.userId,
keyword: data.keyword,
});
throw toClientError(error);
}
});
const getProjectSchema = z.object({ const getProjectSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
}); });
export const getProject = createServerFn({ method: "POST" }) export const getProject = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => getProjectSchema.parse(data)) .inputValidator((data: unknown) => getProjectSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) =>
try { KeywordResearchService.getProject(context.userId, data.projectId),
return await KeywordResearchService.getProject( );
context.userId,
data.projectId,
);
} catch (error) {
logServerError("projects.get", error, {
userId: context.userId,
projectId: data.projectId,
});
throw toClientError(error);
}
});

View File

@ -0,0 +1,9 @@
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack";
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
import { ensureUserMiddleware } from "@/middleware/ensureUser";
export const authenticatedServerFunctionMiddleware = [
errorHandlingMiddleware,
useSessionTokenClientMiddleware,
ensureUserMiddleware,
] as const;

View File

@ -1,6 +1,6 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { ensureUserMiddleware } from "@/middleware/ensureUser"; import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware";
import { useSessionTokenClientMiddleware } from "@every-app/sdk/tanstack"; import { AppError } from "@/server/lib/errors";
import { import {
psiAuditSchema, psiAuditSchema,
psiAuditListSchema, psiAuditListSchema,
@ -33,7 +33,7 @@ async function resolvePsiSource(input: {
}); });
if (!row) { if (!row) {
throw new Error("Audit not found"); throw new AppError("NOT_FOUND");
} }
return { return {
@ -52,7 +52,7 @@ async function resolvePsiSource(input: {
}); });
if (!site) { if (!site) {
throw new Error("Audit not found"); throw new AppError("NOT_FOUND");
} }
return { return {
@ -65,7 +65,7 @@ async function resolvePsiSource(input: {
} }
export const runPsiAudit = createServerFn({ method: "POST" }) export const runPsiAudit = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiAuditSchema.parse(data)) .inputValidator((data: unknown) => psiAuditSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const apiKey = await KeywordResearchRepository.getProjectPsiApiKey( const apiKey = await KeywordResearchRepository.getProjectPsiApiKey(
@ -74,9 +74,7 @@ export const runPsiAudit = createServerFn({ method: "POST" })
); );
if (!apiKey) { if (!apiKey) {
throw new Error( throw new AppError("VALIDATION_ERROR");
"PSI API key is not set for this project. Save a key first.",
);
} }
const auditId = crypto.randomUUID(); const auditId = crypto.randomUUID();
@ -146,7 +144,7 @@ export const runPsiAudit = createServerFn({ method: "POST" })
}); });
export const getProjectPsiApiKey = createServerFn({ method: "POST" }) export const getProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiProjectSchema.parse(data)) .inputValidator((data: unknown) => psiProjectSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
// This PSI key is intentionally treated as low-sensitivity operational config // This PSI key is intentionally treated as low-sensitivity operational config
@ -159,7 +157,7 @@ export const getProjectPsiApiKey = createServerFn({ method: "POST" })
}); });
export const saveProjectPsiApiKey = createServerFn({ method: "POST" }) export const saveProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiProjectKeySchema.parse(data)) .inputValidator((data: unknown) => psiProjectKeySchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
// Same tradeoff: persisted for convenience across PSI + Site Audit flows. // Same tradeoff: persisted for convenience across PSI + Site Audit flows.
@ -172,7 +170,7 @@ export const saveProjectPsiApiKey = createServerFn({ method: "POST" })
}); });
export const clearProjectPsiApiKey = createServerFn({ method: "POST" }) export const clearProjectPsiApiKey = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiProjectSchema.parse(data)) .inputValidator((data: unknown) => psiProjectSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
await KeywordResearchRepository.clearProjectPsiApiKey( await KeywordResearchRepository.clearProjectPsiApiKey(
@ -183,7 +181,7 @@ export const clearProjectPsiApiKey = createServerFn({ method: "POST" })
}); });
export const listProjectPsiAudits = createServerFn({ method: "POST" }) export const listProjectPsiAudits = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiAuditListSchema.parse(data)) .inputValidator((data: unknown) => psiAuditListSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const rows = await PsiAuditRepository.listAuditResults({ const rows = await PsiAuditRepository.listAuditResults({
@ -219,7 +217,7 @@ export const listProjectPsiAudits = createServerFn({ method: "POST" })
}); });
export const getProjectPsiAuditRaw = createServerFn({ method: "POST" }) export const getProjectPsiAuditRaw = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiAuditDetailsSchema.parse(data)) .inputValidator((data: unknown) => psiAuditDetailsSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const row = await PsiAuditRepository.getAuditResult({ const row = await PsiAuditRepository.getAuditResult({
@ -229,11 +227,11 @@ export const getProjectPsiAuditRaw = createServerFn({ method: "POST" })
}); });
if (!row) { if (!row) {
throw new Error("Audit not found"); throw new AppError("NOT_FOUND");
} }
if (!row.r2Key) { if (!row.r2Key) {
throw new Error("Audit payload not available"); throw new AppError("NOT_FOUND");
} }
const payloadJson = await getJsonFromR2(row.r2Key); const payloadJson = await getJsonFromR2(row.r2Key);
@ -247,7 +245,7 @@ export const getProjectPsiAuditRaw = createServerFn({ method: "POST" })
}); });
export const getProjectPsiAuditIssues = createServerFn({ method: "POST" }) export const getProjectPsiAuditIssues = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiIssueFilterSchema.parse(data)) .inputValidator((data: unknown) => psiIssueFilterSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const row = await PsiAuditRepository.getAuditResult({ const row = await PsiAuditRepository.getAuditResult({
@ -257,11 +255,11 @@ export const getProjectPsiAuditIssues = createServerFn({ method: "POST" })
}); });
if (!row) { if (!row) {
throw new Error("Audit not found"); throw new AppError("NOT_FOUND");
} }
if (!row.r2Key) { if (!row.r2Key) {
throw new Error("Audit payload not available"); throw new AppError("NOT_FOUND");
} }
const payloadJson = await getJsonFromR2(row.r2Key); const payloadJson = await getJsonFromR2(row.r2Key);
@ -277,7 +275,7 @@ export const getProjectPsiAuditIssues = createServerFn({ method: "POST" })
}); });
export const exportProjectPsiAudit = createServerFn({ method: "POST" }) export const exportProjectPsiAudit = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiExportSchema.parse(data)) .inputValidator((data: unknown) => psiExportSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const row = await PsiAuditRepository.getAuditResult({ const row = await PsiAuditRepository.getAuditResult({
@ -287,11 +285,11 @@ export const exportProjectPsiAudit = createServerFn({ method: "POST" })
}); });
if (!row) { if (!row) {
throw new Error("Audit not found"); throw new AppError("NOT_FOUND");
} }
if (!row.r2Key) { if (!row.r2Key) {
throw new Error("Audit payload not available"); throw new AppError("NOT_FOUND");
} }
const payloadJson = await getJsonFromR2(row.r2Key); const payloadJson = await getJsonFromR2(row.r2Key);
@ -329,7 +327,7 @@ export const exportProjectPsiAudit = createServerFn({ method: "POST" })
}); });
export const getPsiIssuesBySource = createServerFn({ method: "POST" }) export const getPsiIssuesBySource = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiUnifiedIssueSchema.parse(data)) .inputValidator((data: unknown) => psiUnifiedIssueSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const target = await resolvePsiSource({ const target = await resolvePsiSource({
@ -340,7 +338,7 @@ export const getPsiIssuesBySource = createServerFn({ method: "POST" })
}); });
if (!target.r2Key) { if (!target.r2Key) {
throw new Error("Audit payload not available"); throw new AppError("NOT_FOUND");
} }
const payloadJson = await getJsonFromR2(target.r2Key); const payloadJson = await getJsonFromR2(target.r2Key);
@ -356,7 +354,7 @@ export const getPsiIssuesBySource = createServerFn({ method: "POST" })
}); });
export const exportPsiBySource = createServerFn({ method: "POST" }) export const exportPsiBySource = createServerFn({ method: "POST" })
.middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .middleware(authenticatedServerFunctionMiddleware)
.inputValidator((data: unknown) => psiUnifiedExportSchema.parse(data)) .inputValidator((data: unknown) => psiUnifiedExportSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const target = await resolvePsiSource({ const target = await resolvePsiSource({
@ -367,7 +365,7 @@ export const exportPsiBySource = createServerFn({ method: "POST" })
}); });
if (!target.r2Key) { if (!target.r2Key) {
throw new Error("Audit payload not available"); throw new AppError("NOT_FOUND");
} }
const payloadJson = await getJsonFromR2(target.r2Key); const payloadJson = await getJsonFromR2(target.r2Key);