Skip to content
Closed
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
25 changes: 25 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Auth model: the Epic access_token from §1 is the Fab bearer; no separate Fab token exchange.

import type { AuthTokens } from "./auth.ts";
import { loadLibraryCache, loadLibraryCacheAllowStale, saveLibraryCache } from "./cache.ts";
import { parseManifest, type ChunkInfo, type ChunkPart } from "./manifestParser.ts";

// docs/api-surface.md §3.1 — use /e/ and /p/ URL families exclusively; Bearer-only, no cookies.
Expand Down Expand Up @@ -231,6 +232,30 @@ export async function listLibrary(tokens: AuthTokens): Promise<FabAssetSummary[]
return items;
}

/**
* Cache the library per account for UI refreshes, while retaining the network result as the
* source of truth. A stale entry is used only as an offline fallback.
*/
export async function listLibraryCached(
tokens: AuthTokens,
opts?: { forceRefresh?: boolean; maxAgeMs?: number },
): Promise<FabAssetSummary[]> {
if (!opts?.forceRefresh) {
const cached = await loadLibraryCache(tokens.accountId, opts?.maxAgeMs);
if (cached) return cached.assets;
}

try {
const assets = await listLibrary(tokens);
await saveLibraryCache(tokens.accountId, assets);
return assets;
} catch (err) {
const stale = await loadLibraryCacheAllowStale(tokens.accountId);
if (stale) return stale.assets;
throw err;
}
}

export interface ResolvedAsset {
summary: FabAssetSummary;
namespace: string;
Expand Down
29 changes: 21 additions & 8 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,19 @@ async function promptAuthCode(): Promise<string> {
}
}

export async function startBrowserAuth(): Promise<AuthTokens> {
console.log("Open this URL in your browser, sign in, and copy the `authorizationCode` value:");
console.log("");
console.log(` ${BROWSER_AUTH_URL}`);
console.log("");
export function getBrowserAuthUrl(): string {
return BROWSER_AUTH_URL;
}

const code = await promptAuthCode();
if (code.length === 0) {
export async function exchangeAuthCode(code: string): Promise<AuthTokens> {
const trimmed = code.trim();
if (trimmed.length === 0) {
throw new Error("No authorization code provided");
}

const body = new URLSearchParams({
grant_type: "authorization_code",
code,
code: trimmed,
token_type: "eg1",
});

Expand All @@ -132,6 +131,20 @@ export async function startBrowserAuth(): Promise<AuthTokens> {
return tokens;
}

export async function startBrowserAuth(): Promise<AuthTokens> {
console.log("Open this URL in your browser, sign in, and copy the `authorizationCode` value:");
console.log("");
console.log(` ${getBrowserAuthUrl()}`);
console.log("");

const code = await promptAuthCode();
if (code.length === 0) {
throw new Error("No authorization code provided");
}

return exchangeAuthCode(code);
}

export async function loadTokens(): Promise<AuthTokens | null> {
const path = authConfigPath();
let raw: string;
Expand Down
95 changes: 95 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Local library cache. The Fab /ue/library walk is the hot path — getAsset() re-walks it
// for every resolution, so a sync over N assets used to cost N+1 full fetches. This module
// persists the summarized library per-account under the XDG cache dir; callers decide
// freshness vs. stale-fallback policy.

import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";

import type { FabAssetSummary } from "./api.ts";

export const LIBRARY_CACHE_TTL_MS = 60 * 60 * 1000; // 1h

export interface LibraryCache {
/** ISO timestamp of the successful network fetch. */
fetchedAt: string;
accountId: string;
assets: FabAssetSummary[];
}

export function libraryCachePath(accountId: string): string {
const xdg = process.env["XDG_CACHE_HOME"];
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".cache");
return join(base, "epic-fab", `library-${accountId}.json`);
}

function parseCache(raw: string, accountId: string): LibraryCache | null {
try {
const parsed = JSON.parse(raw) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
"fetchedAt" in parsed &&
"accountId" in parsed &&
"assets" in parsed &&
typeof (parsed as LibraryCache).fetchedAt === "string" &&
Array.isArray((parsed as LibraryCache).assets)
) {
const cache = parsed as LibraryCache;
// Cache is per-account — never serve another user's library.
if (cache.accountId !== accountId) return null;
return cache;
}
return null;
} catch {
return null;
}
}

export async function loadLibraryCache(
accountId: string,
maxAgeMs: number = LIBRARY_CACHE_TTL_MS,
): Promise<LibraryCache | null> {
let raw: string;
try {
raw = await readFile(libraryCachePath(accountId), "utf8");
} catch {
return null;
}
const cache = parseCache(raw, accountId);
if (!cache) return null;
const fetchedMs = Date.parse(cache.fetchedAt);
if (!Number.isFinite(fetchedMs) || Date.now() - fetchedMs > maxAgeMs) {
// Stale — return null and let the caller decide whether to refetch or fall back.
return null;
}
return cache;
}

export async function loadLibraryCacheAllowStale(accountId: string): Promise<LibraryCache | null> {
let raw: string;
try {
raw = await readFile(libraryCachePath(accountId), "utf8");
} catch {
return null;
}
return parseCache(raw, accountId);
}

export async function saveLibraryCache(
accountId: string,
assets: FabAssetSummary[],
): Promise<void> {
const path = libraryCachePath(accountId);
await mkdir(dirname(path), { recursive: true });
const payload: LibraryCache = {
fetchedAt: new Date().toISOString(),
accountId,
assets,
};
const tmp = `${path}.tmp`;
// Write sibling .tmp then rename — a killed process never leaves a truncated cache.
await writeFile(tmp, JSON.stringify(payload), { encoding: "utf8", mode: 0o644 });
await rename(tmp, path);
}
29 changes: 29 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type ResolvedAsset,
} from "./api.ts";
import { downloadAsset } from "./download.ts";
import { startUiServer } from "./serve.ts";
import { createInterface } from "node:readline/promises";

const USAGE = `epic-fab — Epic Games / Fab.com asset library on Linux
Expand All @@ -30,6 +31,7 @@ Commands:
list [--json] List owned Fab assets
download <asset-id> [...] Download asset(s) to disk
sync --project <path> Bulk-download library into a UE project's Content/
ui [--port <n>] [--no-open] Serve local web UI (default port 8471)
whoami Show current authenticated Epic account
logout Delete persisted auth tokens

Expand All @@ -41,9 +43,12 @@ Options:
--project <path> sync: UE project root
--concurrency <n> CDN chunk fetch concurrency (default 8)
--no-skip Redownload even when on-disk SHA1 matches
--port <n> ui: listen port (default 8471)
--no-open ui: do not auto-open the browser
`;

const VERSION = "0.1.0";
const DEFAULT_UI_PORT = 8471;

const EXIT_OK = 0;
const EXIT_USER_ERROR = 1;
Expand Down Expand Up @@ -350,6 +355,28 @@ async function cmdSync(argv: ReadonlyArray<string>, engineVersion: string): Prom
}
}

function parsePort(argv: ReadonlyArray<string>): number {
const raw = findFlagValue(argv, "--port");
if (raw === undefined) return DEFAULT_UI_PORT;
const port = Number.parseInt(raw, 10);
if (!Number.isFinite(port) || String(port) !== raw.trim() || port < 1 || port > 65535) {
throw new Error(`--port must be an integer 1–65535, got ${raw}`);
}
return port;
}

async function cmdUi(argv: ReadonlyArray<string>): Promise<number> {
try {
const port = parsePort(argv);
startUiServer({ port, openBrowser: !hasFlag(argv, "--no-open") });
await new Promise<void>(() => undefined);
return EXIT_OK;
} catch (err) {
console.error(`ui failed: ${(err as Error).message}`);
return EXIT_NETWORK_ERROR;
}
}

async function cmdWhoami(): Promise<number> {
const tokens = await loadTokens();
if (!tokens) {
Expand Down Expand Up @@ -396,6 +423,8 @@ async function main(argv: string[]): Promise<number> {
return cmdDownload(rest, engineVersion);
case "sync":
return cmdSync(rest, engineVersion);
case "ui":
return cmdUi(rest);
case "whoami":
return cmdWhoami();
case "logout":
Expand Down
Loading