fix: remove @sofa/core and @sofa/api deps from public-api to fix Vercel runtime compatibility

- Drop `@sofa/core` and `@sofa/api` from `apps/public-api` — importing workspace packages with DB/Node dependencies broke the Vercel edge runtime
- `fetchUserData` on Trakt and Simkl importers now returns raw `unknown` API responses; parsing is deferred to the self-hosted server
- Add `source` + `rawPayload` fields to `ParsePayloadInput`; `parsePayload` procedure now dispatches to `parseTraktPayload`/`parseSimklPayload` and returns full warnings + diagnostics
- Export `ProviderEnum` from `apps/public-api/src/importers/index.ts` instead of defining it inline in `app.ts`
This commit is contained in:
2026-03-23 12:48:04 -04:00
parent 9df1075040
commit cc8909d64e
19 changed files with 250 additions and 396 deletions
+3 -3
View File
@@ -36,10 +36,10 @@
"@sofa/api": "workspace:*",
"@sofa/i18n": "workspace:*",
"@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.95.0",
"@tanstack/query-async-storage-persister": "5.95.2",
"@tanstack/react-form": "1.28.5",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.95.0",
"@tanstack/react-query-persist-client": "5.95.2",
"better-auth": "catalog:",
"burnt": "0.13.0",
"expo": "55.0.8",
@@ -66,7 +66,7 @@
"expo-updates": "55.0.15",
"expo-web-browser": "55.0.10",
"expo-widgets": "55.0.7",
"posthog-react-native": "4.37.5",
"posthog-react-native": "4.37.6",
"react": "catalog:",
"react-dom": "catalog:",
"react-native": "0.83.2",
+3 -5
View File
@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"dev": "PORT=3002 bun --watch src/app.ts",
"start": "PORT=3002 bun src/app.ts",
"dev": "PORT=3002 bun --hot src/app.ts",
"start": "bun src/app.ts",
"lint": "oxlint",
"format": "oxfmt --config ../../.oxfmtrc.json",
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
@@ -13,10 +13,8 @@
},
"dependencies": {
"@hono/zod-validator": "0.7.6",
"@sofa/api": "workspace:*",
"@sofa/core": "workspace:*",
"@vercel/firewall": "1.1.2",
"hono": "4.12.8",
"hono": "4.12.9",
"zod": "catalog:"
},
"devDependencies": {
+3 -3
View File
@@ -2,15 +2,17 @@ import { zValidator } from "@hono/zod-validator";
import { checkRateLimit } from "@vercel/firewall";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { z } from "zod";
import { getImporter, getImporterConfig } from "./importers";
import { getImporter, getImporterConfig, ProviderEnum } from "./importers";
const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest";
const VERSION_PREFIX_RE = /^v/;
const app = new Hono();
app.use("*", logger());
app.use("*", cors());
// ─── Version Check ──────────────────────────────────────────
@@ -101,8 +103,6 @@ app.post(
// The self-hosted server also prevents concurrent imports per user
// via the importJobs table.
const ProviderEnum = z.enum(["trakt", "simkl"]);
app.post(
"/v1/import/:provider/device-code",
zValidator(
+7 -3
View File
@@ -1,17 +1,21 @@
import * as z from "zod";
import { simkl } from "./simkl";
import { trakt } from "./trakt";
import type { ImportProvider } from "./types";
const importers: Record<string, ImportProvider> = {
export const ProviderEnum = z.enum(["trakt", "simkl"]);
const importers: Record<z.infer<typeof ProviderEnum>, ImportProvider> = {
trakt,
simkl,
};
export function getImporter(name: string): ImportProvider | undefined {
export function getImporter(name: z.infer<typeof ProviderEnum>): ImportProvider | undefined {
return importers[name];
}
export function getImporterConfig(name: string): {
export function getImporterConfig(name: z.infer<typeof ProviderEnum>): {
clientId: string;
clientSecret: string;
} {
+5 -10
View File
@@ -1,6 +1,4 @@
import { parseSimklPayload } from "@sofa/core/imports/parsers";
import type { DeviceCodeResponse, ImportProvider, NormalizedImport, PollResult } from "./types";
import type { DeviceCodeResponse, ImportProvider, PollResult } from "./types";
const API_BASE = "https://api.simkl.com";
const AUTH_BASE = "https://simkl.com";
@@ -108,7 +106,7 @@ export const simkl: ImportProvider = {
return { status: "pending" };
},
async fetchUserData(accessToken, clientId): Promise<NormalizedImport> {
async fetchUserData(accessToken, clientId): Promise<unknown> {
const headers = simklHeaders(clientId, accessToken);
// Fetch movies, shows, and anime in parallel
@@ -131,14 +129,11 @@ export const simkl: ImportProvider = {
animeRes.ok ? (animeRes.json() as Promise<SimklApiItem[]>) : ([] as SimklApiItem[]),
]);
// Flatten API's nested movie/show objects into the flat format
// the core parser expects, then delegate normalization
const result = parseSimklPayload({
// Return flattened API response — parsing happens on the self-hosted server
return {
movies: flattenSimklItems(moviesData, "movie"),
shows: flattenSimklItems(showsData, "show"),
anime: flattenSimklItems(animeData, "show"),
});
return result.data;
};
},
};
+5 -11
View File
@@ -1,6 +1,4 @@
import { parseTraktPayload } from "@sofa/core/imports/parsers";
import type { DeviceCodeResponse, ImportProvider, NormalizedImport, PollResult } from "./types";
import type { DeviceCodeResponse, ImportProvider, PollResult } from "./types";
const API_BASE = "https://api.trakt.tv";
@@ -55,7 +53,7 @@ export const trakt: ImportProvider = {
return { status: "expired" };
},
async fetchUserData(accessToken, clientId): Promise<NormalizedImport> {
async fetchUserData(accessToken, clientId): Promise<unknown> {
const headers = traktHeaders(clientId, accessToken);
// Fetch all data in parallel
@@ -82,15 +80,11 @@ export const trakt: ImportProvider = {
ratingsRes.ok ? ratingsRes.json() : [],
]);
// Restructure API response into the format parseTraktPayload expects.
// The Trakt API returns the same item shapes as the JSON export format.
type TraktPayload = Parameters<typeof parseTraktPayload>[0];
const result = parseTraktPayload({
// Return raw aggregated API response — parsing happens on the self-hosted server
return {
history: { movies: moviesData, shows: showsData },
watchlist: watchlistData,
ratings: ratingsData,
} as TraktPayload);
return result.data;
};
},
};
+1 -5
View File
@@ -1,7 +1,3 @@
import type { NormalizedImport } from "@sofa/api/schemas";
export type { NormalizedImport };
export interface DeviceCodeResponse {
device_code: string;
user_code: string;
@@ -19,5 +15,5 @@ export type PollResult =
export interface ImportProvider {
getDeviceCode(clientId: string, clientSecret: string): Promise<DeviceCodeResponse>;
pollForToken(clientId: string, clientSecret: string, deviceCode: string): Promise<PollResult>;
fetchUserData(accessToken: string, clientId: string): Promise<NormalizedImport>;
fetchUserData(accessToken: string, clientId: string): Promise<unknown>;
}
+1 -1
View File
@@ -25,7 +25,7 @@
"@sofa/logger": "workspace:*",
"@sofa/tmdb": "workspace:*",
"croner": "10.0.2-dev.2",
"hono": "4.12.8",
"hono": "4.12.9",
"zod": "catalog:"
},
"devDependencies": {
+18 -9
View File
@@ -3,7 +3,6 @@ import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import type { ParseResult } from "@sofa/core/imports";
import {
countUnresolved,
getActiveImportJobForUser,
insertImportJob,
parseLetterboxdExport,
@@ -87,17 +86,27 @@ export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input
});
export const parsePayload = os.imports.parsePayload.use(authed).handler(({ input }) => {
const { data } = input;
const { source, rawPayload } = input;
let result: ParseResult;
switch (source) {
case "trakt":
result = parseTraktPayload(rawPayload as Parameters<typeof parseTraktPayload>[0]);
break;
case "simkl":
result = parseSimklPayload(rawPayload as Parameters<typeof parseSimklPayload>[0]);
break;
}
return {
data,
warnings: [],
diagnostics: { unresolved: countUnresolved(data), unsupported: 0 },
data: result.data,
warnings: result.warnings,
diagnostics: result.diagnostics,
stats: {
movies: data.movies.length,
episodes: data.episodes.length,
watchlist: data.watchlist.length,
ratings: data.ratings.length,
movies: result.data.movies.length,
episodes: result.data.episodes.length,
watchlist: result.data.watchlist.length,
ratings: result.data.ratings.length,
},
};
});
+4 -4
View File
@@ -30,7 +30,7 @@
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.5.1",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.168.2",
"@tanstack/react-router": "1.168.3",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -53,9 +53,9 @@
"@tailwindcss/vite": "4.2.2",
"@tanstack/devtools-vite": "0.6.0",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-query-devtools": "5.95.0",
"@tanstack/react-query-devtools": "5.95.2",
"@tanstack/react-router-devtools": "1.166.11",
"@tanstack/router-plugin": "1.167.3",
"@tanstack/router-plugin": "1.167.4",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
@@ -66,7 +66,7 @@
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "8.0.1",
"vite": "8.0.2",
"vitest": "catalog:"
}
}
@@ -416,7 +416,10 @@ function ImportSourceCard({ config }: { config: SourceConfig }) {
if (data.status === "authorized" && data.data) {
stopPolling();
setStep("fetching");
parsePayloadMutation.mutate({ data: data.data as NormalizedImport });
parsePayloadMutation.mutate({
source: config.source as "trakt" | "simkl",
rawPayload: data.data,
});
} else if (data.status === "denied") {
stopPolling();
setOauthError(t`Authorization was denied. Please try again.`);