diff --git a/examples/upload-adapters/README.md b/examples/upload-adapters/README.md new file mode 100644 index 0000000..1b9e29f --- /dev/null +++ b/examples/upload-adapters/README.md @@ -0,0 +1,94 @@ +# Upload Adapter Examples + +## `foc-remote-adapter.mjs` + +Standalone Node upload adapter that speaks the same request/response contract used by generated Token Host UIs. + +It supports: +- `local` mode + - stores files on disk + - returns absolute URLs from the adapter service +- `foc-process` mode + - shells out to `foc-cli upload --format json` + - returns normalized Filecoin Onchain Cloud upload metadata + +### Start it + +```bash +HOST=127.0.0.1 \ +PORT=8788 \ +TH_UPLOAD_ADAPTER_MODE=local \ +TH_UPLOAD_ENDPOINT_PATH=/api/upload \ +TH_UPLOAD_STATUS_PATH=/api/upload/status \ +TH_UPLOAD_PUBLIC_BASE_URL=http://127.0.0.1:8788 \ +node examples/upload-adapters/foc-remote-adapter.mjs +``` + +### Point a generated app at it + +```bash +TH_UPLOAD_RUNNER=remote \ +TH_UPLOAD_REMOTE_ENDPOINT_URL=http://127.0.0.1:8788/api/upload \ +TH_UPLOAD_REMOTE_STATUS_URL=http://127.0.0.1:8788/api/upload/status \ +th build apps/example/microblog.schema.json --out out/microblog +``` + +### Local-mode env + +- `TH_UPLOAD_ADAPTER_MODE=local` +- `TH_UPLOAD_LOCAL_DIR` + directory root for stored uploads +- `TH_UPLOAD_ENDPOINT_PATH` + upload POST path +- `TH_UPLOAD_STATUS_PATH` + GET/HEAD status path +- `TH_UPLOAD_PUBLIC_BASE_URL` + absolute base URL used to construct returned file URLs +- `TH_UPLOAD_ACCEPT` + comma-separated MIME allowlist +- `TH_UPLOAD_MAX_BYTES` + request size limit + +### FOC process-mode env + +- `TH_UPLOAD_ADAPTER_MODE=foc-process` +- `TH_UPLOAD_FOC_COMMAND` + default `npx -y foc-cli` +- `TH_UPLOAD_FOC_CHAIN` + default `314159` +- `TH_UPLOAD_FOC_COPIES` +- `TH_UPLOAD_FOC_WITH_CDN` + +### Response contract + +Status response: + +```json +{ + "ok": true, + "enabled": true, + "provider": "filecoin_onchain_cloud", + "runnerMode": "foc-process", + "endpointUrl": "https://uploads.example.com/api/upload", + "statusUrl": "https://uploads.example.com/api/upload/status", + "accept": ["image/png", "image/jpeg"], + "maxBytes": 10485760 +} +``` + +Upload response: + +```json +{ + "ok": true, + "upload": { + "url": "https://... or http://.../uploads/...", + "cid": null, + "size": 12345, + "provider": "local_file", + "runnerMode": "local", + "contentType": "image/png", + "metadata": {} + } +} +``` diff --git a/examples/upload-adapters/foc-remote-adapter.mjs b/examples/upload-adapters/foc-remote-adapter.mjs new file mode 100644 index 0000000..10ae7ef --- /dev/null +++ b/examples/upload-adapters/foc-remote-adapter.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import { spawnSync } from 'node:child_process'; + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseBoolean(value, fallback = false) { + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized) return fallback; + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function trimTrailingSlash(value) { + return String(value ?? '').trim().replace(/\/+$/, ''); +} + +function normalizeUploadFileName(fileName) { + const base = path.basename(fileName || 'upload.bin').replace(/[^A-Za-z0-9._-]+/g, '-'); + return base || 'upload.bin'; +} + +function detectUploadExtension(fileName, contentType) { + const ext = path.extname(fileName).toLowerCase(); + if (ext) return ext; + switch (contentType) { + case 'image/png': + return '.png'; + case 'image/jpeg': + return '.jpg'; + case 'image/gif': + return '.gif'; + case 'image/webp': + return '.webp'; + case 'image/svg+xml': + return '.svg'; + default: + return '.bin'; + } +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; +} + +function normalizeFocUploadResult(parsed) { + const result = parsed?.result; + const copyResults = Array.isArray(result?.copyResults) ? result.copyResults : []; + const firstCopy = copyResults.find((entry) => entry && typeof entry.url === 'string' && entry.url.trim()) ?? null; + const url = firstCopy ? String(firstCopy.url) : ''; + if (!url) { + throw new Error('foc-cli upload did not return a usable copyResults[].url value.'); + } + + return { + url, + cid: result?.pieceCid ? String(result.pieceCid) : null, + size: Number.isFinite(Number(result?.size)) ? Number(result.size) : null, + metadata: { + pieceScannerUrl: result?.pieceScannerUrl ? String(result.pieceScannerUrl) : null, + copyResults, + copyFailures: Array.isArray(result?.copyFailures) ? result.copyFailures : [] + } + }; +} + +function runFocCliUpload(config, filePath) { + const command = + `${config.command} upload ${shellQuote(filePath)} --format json --chain ${config.chainId} --copies ${config.copies}` + + `${config.withCDN ? ' --withCDN true' : ''}`; + const result = spawnSync(command, { + shell: true, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024 + }); + if (result.status !== 0) { + throw new Error(String(result.stderr || result.stdout || `foc-cli failed with status ${result.status}`)); + } + return normalizeFocUploadResult(JSON.parse(String(result.stdout || '{}'))); +} + +function readBinaryBody(req, maxBytes) { + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + req.on('data', (chunk) => { + total += chunk.length; + if (total > maxBytes) { + reject(new Error('Request body too large.')); + req.destroy(); + return; + } + chunks.push(Buffer.from(chunk)); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function contentTypeForPath(filePath) { + switch (path.extname(filePath).toLowerCase()) { + case '.html': + return 'text/html; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.svg': + return 'image/svg+xml'; + default: + return 'application/octet-stream'; + } +} + +function sendJson(res, status, value) { + res.statusCode = status; + res.setHeader('content-type', 'application/json; charset=utf-8'); + res.setHeader('cache-control', 'no-store'); + res.end(JSON.stringify(value)); +} + +function sendText(res, status, value) { + res.statusCode = status; + res.setHeader('content-type', 'text/plain; charset=utf-8'); + res.setHeader('cache-control', 'no-store'); + res.end(value); +} + +function safeResolveWithin(rootDir, pathname) { + const candidate = path.resolve(rootDir, `.${pathname}`); + if (!candidate.startsWith(path.resolve(rootDir))) return null; + return candidate; +} + +const host = String(process.env.HOST ?? '127.0.0.1').trim() || '127.0.0.1'; +const port = parsePositiveInt(process.env.PORT, 8788); +const runnerMode = String(process.env.TH_UPLOAD_ADAPTER_MODE ?? process.env.TH_UPLOAD_RUNNER ?? 'local').trim().toLowerCase() === 'foc-process' + ? 'foc-process' + : 'local'; +const endpointPath = (() => { + const raw = String(process.env.TH_UPLOAD_ENDPOINT_PATH ?? '/__tokenhost/upload').trim() || '/__tokenhost/upload'; + return raw.startsWith('/') ? raw : `/${raw}`; +})(); +const statusPath = (() => { + const raw = String(process.env.TH_UPLOAD_STATUS_PATH ?? endpointPath).trim() || endpointPath; + return raw.startsWith('/') ? raw : `/${raw}`; +})(); +const publicBaseUrl = trimTrailingSlash(process.env.TH_UPLOAD_PUBLIC_BASE_URL || `http://${host}:${port}`); +const storagePath = (() => { + const raw = String(process.env.TH_UPLOAD_LOCAL_DIR ?? path.join(process.cwd(), '.tokenhost-upload-adapter')).trim(); + return path.resolve(raw, 'uploads'); +})(); +const publicUploadsPath = '/uploads'; +const accept = String(process.env.TH_UPLOAD_ACCEPT ?? 'image/png,image/jpeg,image/gif,image/webp,image/svg+xml') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +const maxBytes = parsePositiveInt(process.env.TH_UPLOAD_MAX_BYTES, 10 * 1024 * 1024); +const focConfig = { + chainId: parsePositiveInt(process.env.TH_UPLOAD_FOC_CHAIN, 314159), + copies: parsePositiveInt(process.env.TH_UPLOAD_FOC_COPIES, 2), + withCDN: parseBoolean(process.env.TH_UPLOAD_FOC_WITH_CDN, false), + command: String(process.env.TH_UPLOAD_FOC_COMMAND ?? 'npx -y foc-cli').trim() || 'npx -y foc-cli' +}; + +fs.mkdirSync(storagePath, { recursive: true }); + +const server = http.createServer((req, res) => { + if (!req.url) return sendText(res, 400, 'Bad Request'); + + const pathname = new URL(req.url, `http://${host}:${port}`).pathname || '/'; + + if (pathname === endpointPath || pathname === statusPath) { + (async () => { + if (req.method === 'GET' || req.method === 'HEAD') { + return sendJson(res, 200, { + ok: true, + enabled: true, + provider: runnerMode === 'foc-process' ? 'filecoin_onchain_cloud' : 'local_file', + runnerMode, + endpointUrl: `${publicBaseUrl}${endpointPath}`, + statusUrl: `${publicBaseUrl}${statusPath}`, + accept, + maxBytes + }); + } + + if (req.method !== 'POST') { + res.setHeader('allow', 'GET, HEAD, POST'); + return sendText(res, 405, 'Method Not Allowed'); + } + + try { + const fileName = normalizeUploadFileName(String(req.headers['x-tokenhost-upload-filename'] ?? 'upload.bin')); + const contentType = String(req.headers['content-type'] ?? 'application/octet-stream').split(';')[0].trim().toLowerCase(); + if (accept.length > 0) { + const supported = accept.some((pattern) => pattern === contentType || (pattern.endsWith('/*') && contentType.startsWith(pattern.slice(0, -1)))); + if (!supported) return sendJson(res, 415, { ok: false, error: `Unsupported content type "${contentType}".` }); + } + + const body = await readBinaryBody(req, maxBytes); + if (!body.length) return sendJson(res, 400, { ok: false, error: 'Empty upload body.' }); + + if (runnerMode === 'foc-process') { + const ext = detectUploadExtension(fileName, contentType); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-foc-remote-')); + const tempFile = path.join(tempDir, `upload${ext}`); + fs.writeFileSync(tempFile, body); + try { + const uploaded = runFocCliUpload(focConfig, tempFile); + return sendJson(res, 200, { + ok: true, + upload: { + url: uploaded.url, + cid: uploaded.cid, + size: uploaded.size ?? body.length, + provider: 'filecoin_onchain_cloud', + runnerMode: 'foc-process', + contentType, + metadata: uploaded.metadata + } + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + + const ext = detectUploadExtension(fileName, contentType); + const storedName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`; + const storedPath = path.join(storagePath, storedName); + fs.writeFileSync(storedPath, body); + return sendJson(res, 200, { + ok: true, + upload: { + url: `${publicBaseUrl}${publicUploadsPath}/${storedName}`, + cid: null, + size: body.length, + provider: 'local_file', + runnerMode: 'local', + contentType, + metadata: { + storedName + } + } + }); + } catch (error) { + return sendJson(res, 400, { ok: false, error: String(error?.message ?? error) }); + } + })(); + return; + } + + if (pathname.startsWith(`${publicUploadsPath}/`)) { + const filePath = safeResolveWithin(storagePath, pathname.slice(publicUploadsPath.length)); + if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return sendText(res, 404, 'Not Found'); + } + res.statusCode = 200; + res.setHeader('content-type', contentTypeForPath(filePath)); + fs.createReadStream(filePath).pipe(res); + return; + } + + if (pathname === '/' || pathname === '/healthz') { + return sendJson(res, 200, { + ok: true, + service: 'tokenhost-upload-adapter-example', + runnerMode, + endpointUrl: `${publicBaseUrl}${endpointPath}`, + statusUrl: `${publicBaseUrl}${statusPath}` + }); + } + + return sendText(res, 404, 'Not Found'); +}); + +server.listen(port, host, () => { + console.log(`Token Host upload adapter listening at http://${host}:${port}`); + console.log(`Upload endpoint: ${publicBaseUrl}${endpointPath}`); + console.log(`Status endpoint: ${publicBaseUrl}${statusPath}`); +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 88dad93..34f5d79 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ import os from 'os'; import path from 'path'; import crypto from 'crypto'; import * as nodeHttp from 'node:http'; +import * as nodeNet from 'node:net'; import { createInterface, type Interface as ReadlineInterface } from 'node:readline/promises'; import { spawn, spawnSync } from 'child_process'; import { createRequire } from 'module'; @@ -337,27 +338,27 @@ type SharedThemeTokens = { function defaultSharedThemeTokens(): SharedThemeTokens { return { colors: { - bg: '#06122b', - bgAlt: '#0a1f45', - panel: '#0f2958cc', - panelStrong: '#103062', - border: '#7eb8ff55', - text: '#f3f8ff', - muted: '#b6caea', - primary: '#4cb1f7', - primaryStrong: '#1e8fe0', - accent: '#ffc700', - success: '#12c26d', - danger: '#ff5f63' + bg: '#f2f5f7', + bgAlt: '#fbfcfd', + panel: '#ffffff', + panelStrong: '#ffffff', + border: '#d6dfeb', + text: '#0f1729', + muted: '#66758d', + primary: '#ff80ff', + primaryStrong: '#ff67f5', + accent: '#1b9847', + success: '#1b9847', + danger: '#ef4444' }, - radius: { sm: '10px', md: '14px', lg: '20px' }, - spacing: { xs: '6px', sm: '10px', md: '16px', lg: '24px', xl: '36px' }, + radius: { sm: '0px', md: '0px', lg: '0px' }, + spacing: { xs: '6px', sm: '10px', md: '16px', lg: '24px', xl: '38px' }, typography: { display: '"Montserrat", "Avenir Next", "Segoe UI", sans-serif', - body: '"Inter", "Segoe UI", sans-serif', - mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' + body: '"Montserrat", "Avenir Next", "Segoe UI", sans-serif', + mono: '"JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }, - motion: { fast: '120ms', base: '180ms' } + motion: { fast: '140ms', base: '220ms' } }; } @@ -1205,6 +1206,7 @@ function syncUiOutput(args: { const resolvedOutDir = path.resolve(args.outDir); const templateDir = resolveNextExportUiTemplateDir(); const uiDir = path.join(resolvedOutDir, 'ui'); + const preservedUiState = captureUiPackageManagerState(uiDir); fs.rmSync(uiDir, { recursive: true, force: true }); copyDir(templateDir, uiDir); @@ -1230,9 +1232,58 @@ function syncUiOutput(args: { } applyUiExtensions(uiDir, args.schema, args.schemaPathForHints); + restoreUiPackageManagerState(uiDir, preservedUiState); console.log(`Wrote ui/ (Next.js static export template)`); } +type UiPackageManagerState = { + priorPackageJson: string | null; + lockfiles: Array<{ name: string; contents: Buffer }>; + nodeModulesDir: string | null; +}; + +function captureUiPackageManagerState(uiDir: string): UiPackageManagerState { + const packageJsonPath = path.join(uiDir, 'package.json'); + const lockfileNames = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lockb', 'bun.lock']; + const lockfiles: Array<{ name: string; contents: Buffer }> = []; + for (const name of lockfileNames) { + const filePath = path.join(uiDir, name); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) continue; + lockfiles.push({ name, contents: fs.readFileSync(filePath) }); + } + + let nodeModulesDir: string | null = null; + const nodeModulesPath = path.join(uiDir, 'node_modules'); + if (fs.existsSync(nodeModulesPath) && fs.statSync(nodeModulesPath).isDirectory()) { + nodeModulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-ui-sync-node-modules-')); + fs.renameSync(nodeModulesPath, nodeModulesDir); + } + + return { + priorPackageJson: fs.existsSync(packageJsonPath) ? fs.readFileSync(packageJsonPath, 'utf-8') : null, + lockfiles, + nodeModulesDir + }; +} + +function restoreUiPackageManagerState(uiDir: string, state: UiPackageManagerState) { + for (const lockfile of state.lockfiles) { + fs.writeFileSync(path.join(uiDir, lockfile.name), lockfile.contents); + } + + if (!state.nodeModulesDir) return; + + const currentPackageJsonPath = path.join(uiDir, 'package.json'); + const currentPackageJson = fs.existsSync(currentPackageJsonPath) ? fs.readFileSync(currentPackageJsonPath, 'utf-8') : null; + if (state.priorPackageJson !== null && currentPackageJson === state.priorPackageJson) { + fs.rmSync(path.join(uiDir, 'node_modules'), { recursive: true, force: true }); + fs.renameSync(state.nodeModulesDir, path.join(uiDir, 'node_modules')); + return; + } + + fs.rmSync(state.nodeModulesDir, { recursive: true, force: true }); +} + function resolveUiExtensionsDir(schema: ThsSchema, schemaPathForHints?: string): string | null { const declared = String(schema.app?.ui?.extensions?.directory ?? '').trim(); if (!declared) return null; @@ -1984,16 +2035,75 @@ function pipeWithPrefix(stream: NodeJS.ReadableStream, prefix: string, dest: Nod }); } -async function ensureAnvilRunning(rpcUrl: string, opts?: { start: boolean; expectedChainId?: number }): Promise<{ child: ReturnType | null }> { +async function findAvailableLocalPort(host: string): Promise { + return await new Promise((resolve, reject) => { + const server = nodeNet.createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, host, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate a local port.'))); + return; + } + const port = address.port; + server.close((closeErr) => { + if (closeErr) { + reject(closeErr); + return; + } + resolve(port); + }); + }); + }); +} + +function replaceRpcUrlPort(rpcUrl: string, port: number): string { + const u = new URL(rpcUrl); + u.port = String(port); + return u.toString(); +} + +async function ensureAnvilRunning( + rpcUrl: string, + opts?: { start: boolean; expectedChainId?: number } +): Promise<{ child: ReturnType | null; rpcUrl: string; chainId: number }> { const expectedChainId = opts?.expectedChainId ?? 31337; const start = opts?.start ?? true; const chainId = await tryGetRpcChainId(rpcUrl, 500); if (chainId !== null) { if (chainId !== expectedChainId) { - throw new Error(`RPC at ${rpcUrl} is chainId ${chainId}, expected ${expectedChainId}.`); + if (!start) { + throw new Error(`RPC at ${rpcUrl} is chainId ${chainId}, expected ${expectedChainId}.`); + } + const local = isLocalHttpRpcUrl(rpcUrl); + if (!local) { + throw new Error(`RPC at ${rpcUrl} is chainId ${chainId}, expected ${expectedChainId}.`); + } + const altPort = await findAvailableLocalPort(local.host); + const altRpcUrl = replaceRpcUrlPort(rpcUrl, altPort); + console.log(`RPC at ${rpcUrl} is chainId ${chainId}, expected ${expectedChainId}. Starting dedicated anvil at ${altRpcUrl}.`); + + const child = spawn('anvil', ['--host', local.host, '--port', String(altPort), '--chain-id', String(expectedChainId)], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + if (child.stdout) pipeWithPrefix(child.stdout, '[anvil] ', process.stdout); + if (child.stderr) pipeWithPrefix(child.stderr, '[anvil] ', process.stderr); + + const startedAt = Date.now(); + const timeoutMs = 10_000; + while (Date.now() - startedAt < timeoutMs) { + const nowChainId = await tryGetRpcChainId(altRpcUrl, 500); + if (nowChainId === expectedChainId) return { child, rpcUrl: altRpcUrl, chainId: expectedChainId }; + await new Promise((r) => setTimeout(r, 200)); + } + + child.kill('SIGTERM'); + throw new Error(`Timed out waiting for dedicated anvil at ${altRpcUrl} to become ready.`); } - return { child: null }; + return { child: null, rpcUrl, chainId }; } if (!start) { @@ -2021,7 +2131,7 @@ async function ensureAnvilRunning(rpcUrl: string, opts?: { start: boolean; expec const timeoutMs = 10_000; while (Date.now() - startedAt < timeoutMs) { const nowChainId = await tryGetRpcChainId(rpcUrl, 500); - if (nowChainId === expectedChainId) return { child }; + if (nowChainId === expectedChainId) return { child, rpcUrl, chainId: expectedChainId }; await new Promise((r) => setTimeout(r, 200)); } @@ -2045,6 +2155,236 @@ type RelayConfig = { from: Address; }; +type UploadRunnerMode = 'local' | 'remote' | 'foc-process'; +type UploadProvider = 'local_file' | 'filecoin_onchain_cloud'; + +type UploadManifestConfig = { + enabled: boolean; + baseUrl: string; + endpointUrl: string; + statusUrl: string; + provider: UploadProvider; + runnerMode: UploadRunnerMode; + accept: string[]; + maxBytes: number; +}; + +type UploadServerConfig = UploadManifestConfig & { + localDir: string; + foc?: { + chainId: number; + copies: number; + withCDN: boolean; + command: string; + } | null; +}; + +function parsePositiveIntEnv(value: string | undefined, fallback: number): number { + const n = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function parseBooleanEnv(value: string | undefined, fallback = false): boolean { + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized) return fallback; + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function trimTrailingSlash(value: string): string { + return String(value ?? '').trim().replace(/\/+$/, ''); +} + +function deriveRemoteUploadUrls(input: string): { baseUrl: string; endpointUrl: string; statusUrl: string } { + const trimmed = trimTrailingSlash(input); + if (!trimmed) { + return { + baseUrl: '/__tokenhost/upload', + endpointUrl: '/__tokenhost/upload', + statusUrl: '/__tokenhost/upload' + }; + } + + try { + const url = new URL(trimmed); + const normalizedPath = url.pathname.replace(/\/+$/, '') || '/'; + if (normalizedPath === '/') { + const endpoint = new URL('/__tokenhost/upload', `${url.origin}/`).toString(); + return { + baseUrl: endpoint, + endpointUrl: endpoint, + statusUrl: endpoint + }; + } + + return { + baseUrl: url.toString(), + endpointUrl: url.toString(), + statusUrl: url.toString() + }; + } catch { + if (trimmed.startsWith('/')) { + const normalizedPath = trimmed || '/'; + const endpoint = normalizedPath === '/' ? '/__tokenhost/upload' : normalizedPath; + return { + baseUrl: endpoint, + endpointUrl: endpoint, + statusUrl: endpoint + }; + } + + return { + baseUrl: trimmed, + endpointUrl: trimmed, + statusUrl: trimmed + }; + } +} + +function resolveUploadManifestConfig(featuresUploads: boolean): UploadManifestConfig | null { + if (!featuresUploads) return null; + + const remoteBaseUrl = String(process.env.TH_UPLOAD_REMOTE_BASE_URL ?? '').trim(); + const remoteEndpointUrl = String(process.env.TH_UPLOAD_REMOTE_ENDPOINT_URL ?? '').trim(); + const remoteStatusUrl = String(process.env.TH_UPLOAD_REMOTE_STATUS_URL ?? '').trim(); + const explicitRunner = String(process.env.TH_UPLOAD_RUNNER ?? '').trim().toLowerCase(); + const runnerMode: UploadRunnerMode = + remoteBaseUrl || remoteEndpointUrl + ? 'remote' + : explicitRunner === 'foc-process' || explicitRunner === 'foc_process' + ? 'foc-process' + : explicitRunner === 'remote' + ? 'remote' + : 'local'; + const providerEnv = String(process.env.TH_UPLOAD_PROVIDER ?? '').trim().toLowerCase(); + const provider: UploadProvider = + providerEnv === 'filecoin_onchain_cloud' || providerEnv === 'foc' || runnerMode === 'foc-process' || runnerMode === 'remote' + ? 'filecoin_onchain_cloud' + : 'local_file'; + const localBaseUrl = String(process.env.TH_UPLOAD_BASE_URL ?? '/__tokenhost/upload').trim() || '/__tokenhost/upload'; + const remoteUrls = deriveRemoteUploadUrls(remoteEndpointUrl || remoteBaseUrl); + const baseUrl = runnerMode === 'remote' ? remoteUrls.baseUrl : localBaseUrl; + const endpointUrl = runnerMode === 'remote' ? remoteUrls.endpointUrl : localBaseUrl; + const statusUrl = runnerMode === 'remote' ? trimTrailingSlash(remoteStatusUrl) || remoteUrls.statusUrl : localBaseUrl; + const accept = String(process.env.TH_UPLOAD_ACCEPT ?? 'image/png,image/jpeg,image/gif,image/webp,image/svg+xml') + .split(',') + .map((x) => x.trim()) + .filter(Boolean); + const maxBytes = parsePositiveIntEnv(process.env.TH_UPLOAD_MAX_BYTES, 10 * 1024 * 1024); + + return { + enabled: true, + baseUrl, + endpointUrl, + statusUrl, + provider, + runnerMode, + accept, + maxBytes + }; +} + +function shellQuote(s: string): string { + return `'${String(s).replace(/'/g, `'\"'\"'`)}'`; +} + +function detectUploadExtension(fileName: string, contentType: string): string { + const ext = path.extname(fileName).toLowerCase(); + if (ext) return ext; + switch (contentType) { + case 'image/png': + return '.png'; + case 'image/jpeg': + return '.jpg'; + case 'image/gif': + return '.gif'; + case 'image/webp': + return '.webp'; + case 'image/svg+xml': + return '.svg'; + default: + return '.bin'; + } +} + +function normalizeUploadFileName(fileName: string): string { + const base = path.basename(fileName || 'upload.bin').replace(/[^A-Za-z0-9._-]+/g, '-'); + return base || 'upload.bin'; +} + +function normalizeFocUploadResult(parsed: any): { url: string; cid: string | null; size: number | null; metadata: Record } { + const result = parsed?.result; + const copyResults = Array.isArray(result?.copyResults) ? result.copyResults : []; + const firstCopy = copyResults.find((x: any) => x && typeof x.url === 'string' && x.url.trim()) ?? null; + const url = firstCopy ? String(firstCopy.url) : ''; + if (!url) { + throw new Error('foc-cli upload did not return a usable copyResults[].url value.'); + } + return { + url, + cid: result?.pieceCid ? String(result.pieceCid) : null, + size: Number.isFinite(Number(result?.size)) ? Number(result.size) : null, + metadata: { + pieceScannerUrl: result?.pieceScannerUrl ? String(result.pieceScannerUrl) : null, + copyResults, + copyFailures: Array.isArray(result?.copyFailures) ? result.copyFailures : [] + } + }; +} + +function runFocCliUpload(config: NonNullable, filePath: string): { url: string; cid: string | null; size: number | null; metadata: Record } { + const command = + `${config.command} upload ${shellQuote(filePath)} --format json --chain ${config.chainId} --copies ${config.copies}` + + `${config.withCDN ? ' --withCDN true' : ''}`; + const res = spawnSync(command, { + shell: true, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024 + }); + if (res.status !== 0) { + throw new Error(String(res.stderr || res.stdout || `foc-cli failed with status ${res.status}`)); + } + const parsed = JSON.parse(String(res.stdout || '{}')); + return normalizeFocUploadResult(parsed); +} + +function buildUploadServerConfig(manifest: any, uiSiteDir: string): UploadServerConfig | null { + const ext = manifest?.extensions?.uploads; + if (!ext || ext.enabled !== true) return null; + const baseUrl = String(ext.baseUrl ?? '').trim() || '/__tokenhost/upload'; + const endpointUrl = String(ext.endpointUrl ?? baseUrl).trim() || '/__tokenhost/upload'; + const statusUrl = String(ext.statusUrl ?? endpointUrl).trim() || endpointUrl; + const runnerMode = String(ext.runnerMode ?? 'local').trim() as UploadRunnerMode; + if (runnerMode === 'remote') return null; + if (!endpointUrl.startsWith('/')) return null; + + const provider = String(ext.provider ?? 'local_file').trim() === 'filecoin_onchain_cloud' ? 'filecoin_onchain_cloud' : 'local_file'; + const maxBytes = parsePositiveIntEnv(String(ext.maxBytes ?? ''), 10 * 1024 * 1024); + const accept = Array.isArray(ext.accept) ? ext.accept.map((x: any) => String(x)).filter(Boolean) : ['image/*']; + const localDir = path.join(uiSiteDir, '__tokenhost', 'uploads'); + const foc = + runnerMode === 'foc-process' + ? { + chainId: parsePositiveIntEnv(process.env.TH_UPLOAD_FOC_CHAIN, 314159), + copies: parsePositiveIntEnv(process.env.TH_UPLOAD_FOC_COPIES, 2), + withCDN: parseBooleanEnv(process.env.TH_UPLOAD_FOC_WITH_CDN, false), + command: String(process.env.TH_UPLOAD_FOC_COMMAND ?? 'npx -y foc-cli').trim() || 'npx -y foc-cli' + } + : null; + + return { + enabled: true, + baseUrl, + endpointUrl, + statusUrl, + provider, + runnerMode: runnerMode === 'foc-process' ? 'foc-process' : 'local', + accept, + maxBytes, + localDir, + foc + }; +} + function resolveTxMode(mode: string | undefined, chainId: number): TxMode { const normalized = String(mode ?? 'auto').toLowerCase().trim(); if (normalized === 'sponsored') return 'sponsored'; @@ -2059,6 +2399,7 @@ function startUiSiteServer(args: { port: number; faucet?: FaucetConfig | null; relay?: RelayConfig | null; + upload?: UploadServerConfig | null; }): { server: nodeHttp.Server; url: string } { const resolvedBuildDir = path.resolve(args.buildDir); const uiSiteDir = path.join(resolvedBuildDir, 'ui-site'); @@ -2076,8 +2417,11 @@ function startUiSiteServer(args: { const rootAbs = path.resolve(uiSiteDir); const faucet = args.faucet ?? null; const relay = args.relay ?? null; + const upload = args.upload ?? null; const faucetPath = '/__tokenhost/faucet'; const relayPath = '/__tokenhost/relay'; + const uploadPath = upload?.endpointUrl && upload.endpointUrl.startsWith('/') ? upload.endpointUrl : '/__tokenhost/upload'; + const uploadStatusPath = upload?.statusUrl && upload.statusUrl.startsWith('/') ? upload.statusUrl : uploadPath; const faucetTargetEth = faucet?.targetWei ? Number(faucet.targetWei / 10n ** 18n) : 10; function contentTypeForPath(filePath: string): string { @@ -2187,6 +2531,24 @@ function startUiSiteServer(args: { }); } + function readBinaryBody(req: nodeHttp.IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + reject(new Error('Request body too large.')); + req.destroy(); + return; + } + chunks.push(Buffer.from(chunk)); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); + } + const server = nodeHttp.createServer((req, res) => { if (!req.url) return sendText(res, 400, 'Bad Request'); @@ -2338,6 +2700,98 @@ function startUiSiteServer(args: { return; } + if (pathname === uploadPath || pathname === uploadStatusPath) { + (async () => { + const enabled = Boolean(upload?.enabled); + if (req.method === 'GET' || req.method === 'HEAD') { + return sendJson(res, 200, { + ok: true, + enabled, + provider: upload?.provider ?? null, + runnerMode: upload?.runnerMode ?? null, + maxBytes: upload?.maxBytes ?? null, + accept: upload?.accept ?? [], + endpointUrl: upload?.endpointUrl ?? null, + statusUrl: upload?.statusUrl ?? null, + reason: enabled ? null : upload ? 'disabled' : 'not-configured' + }); + } + + if (req.method !== 'POST') { + res.setHeader('Allow', 'GET, HEAD, POST'); + return sendText(res, 405, 'Method Not Allowed'); + } + + if (!enabled || !upload) { + return sendJson(res, 400, { ok: false, error: 'Upload endpoint is disabled.' }); + } + + try { + const fileName = normalizeUploadFileName(String(req.headers['x-tokenhost-upload-filename'] ?? 'upload.bin')); + const contentType = String(req.headers['content-type'] ?? 'application/octet-stream').split(';')[0]!.trim().toLowerCase(); + const accept = Array.isArray(upload.accept) ? upload.accept : []; + if (accept.length > 0 && !accept.some((pattern) => pattern === contentType || (pattern.endsWith('/*') && contentType.startsWith(pattern.slice(0, -1))))) { + return sendJson(res, 415, { ok: false, error: `Unsupported content type "${contentType}".` }); + } + + const body = await readBinaryBody(req, upload.maxBytes); + if (body.length === 0) return sendJson(res, 400, { ok: false, error: 'Empty upload body.' }); + + if (upload.runnerMode === 'foc-process') { + const ext = detectUploadExtension(fileName, contentType); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-foc-upload-')); + const tmpFile = path.join(tmpDir, `upload${ext}`); + try { + fs.writeFileSync(tmpFile, body); + const uploaded = runFocCliUpload(upload.foc ?? { + chainId: 314159, + copies: 2, + withCDN: false, + command: 'npx -y foc-cli' + }, tmpFile); + return sendJson(res, 200, { + ok: true, + upload: { + url: uploaded.url, + cid: uploaded.cid, + size: uploaded.size ?? body.length, + provider: upload.provider, + runnerMode: upload.runnerMode, + contentType, + metadata: uploaded.metadata + } + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } + + fs.mkdirSync(upload.localDir, { recursive: true }); + const ext = detectUploadExtension(fileName, contentType); + const storedName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${ext}`; + const storedPath = path.join(upload.localDir, storedName); + fs.writeFileSync(storedPath, body); + return sendJson(res, 200, { + ok: true, + upload: { + url: `/__tokenhost/uploads/${storedName}`, + cid: null, + size: body.length, + provider: upload.provider, + runnerMode: upload.runnerMode, + contentType, + metadata: { + originalFileName: fileName + } + } + }); + } catch (e: any) { + return sendJson(res, 400, { ok: false, error: String(e?.message ?? e) }); + } + })(); + return; + } + if (req.method && req.method !== 'GET' && req.method !== 'HEAD') { res.setHeader('Allow', 'GET, HEAD'); return sendText(res, 405, 'Method Not Allowed'); @@ -2563,6 +3017,7 @@ function buildFromSchema( const zeroAddress = '0x0000000000000000000000000000000000000000'; const txMode = resolveTxMode(opts.txMode, Number(opts.targetChainId ?? anvil.id)); const relayBaseUrl = String(opts.relayBaseUrl ?? process.env.TH_RELAY_BASE_URL ?? '/__tokenhost/relay').trim() || '/__tokenhost/relay'; + const uploadConfig = resolveUploadManifestConfig(features.uploads); const manifest = { manifestVersion: '0.1.0', @@ -2618,6 +3073,20 @@ function buildFromSchema( }, features, extensions: { + ...(uploadConfig + ? { + uploads: { + enabled: uploadConfig.enabled, + baseUrl: uploadConfig.baseUrl, + endpointUrl: uploadConfig.endpointUrl, + statusUrl: uploadConfig.statusUrl, + provider: uploadConfig.provider, + runnerMode: uploadConfig.runnerMode, + accept: uploadConfig.accept, + maxBytes: uploadConfig.maxBytes + } + } + : {}), tx: txMode === 'sponsored' ? { @@ -3635,7 +4104,9 @@ program // Start Anvil (if needed) while we build. const anvilPromise = - chainName === 'anvil' ? ensureAnvilRunning(rpcUrl, { start: Boolean(opts.startAnvil), expectedChainId: chain.id }) : Promise.resolve({ child: null }); + chainName === 'anvil' + ? ensureAnvilRunning(rpcUrl, { start: Boolean(opts.startAnvil), expectedChainId: chain.id }) + : Promise.resolve({ child: null, rpcUrl, chainId: chain.id }); console.log('Building…'); buildFromSchema(schema, outDir, { @@ -3652,13 +4123,14 @@ program const ensured = await anvilPromise; anvilChild = ensured.child; + const activeRpcUrl = ensured.rpcUrl; if (opts.deploy) { console.log(''); console.log('Deploying…'); await deployBuildDir(outDir, { chain: opts.chain, - rpc: opts.rpc, + rpc: activeRpcUrl, privateKey: opts.privateKey, admin: opts.admin, treasury: opts.treasury, @@ -3673,6 +4145,13 @@ program const relayEnabled = Boolean(chainName === 'anvil' && resolvedTxMode === 'sponsored'); const faucetTargetWei = 10n * 10n ** 18n; const relayFrom = privateKeyToAccount(resolvePrivateKey('anvil')).address as Address; + let uploadConfig: UploadServerConfig | null = null; + try { + const manifest = readJsonFile(path.join(outDir, 'manifest.json')) as any; + uploadConfig = buildUploadServerConfig(manifest, path.join(outDir, 'ui-site')); + } catch { + uploadConfig = null; + } const { server, url } = startUiSiteServer({ buildDir: outDir, host, @@ -3680,7 +4159,7 @@ program faucet: faucetEnabled ? { enabled: true, - rpcUrl, + rpcUrl: activeRpcUrl, chainId: chain.id, targetWei: faucetTargetWei } @@ -3688,11 +4167,12 @@ program relay: relayEnabled ? { enabled: true, - rpcUrl, + rpcUrl: activeRpcUrl, chainId: chain.id, from: relayFrom } - : null + : null, + upload: uploadConfig }); console.log(''); console.log(`Ready: ${url}`); @@ -3759,53 +4239,25 @@ program const faucetTargetWei = 10n * 10n ** 18n; let faucetConfig: FaucetConfig | null = null; let relayConfig: RelayConfig | null = null; + let uploadConfig: UploadServerConfig | null = null; let txMode: TxMode = 'userPays'; + let manifestChainId: number | null = null; + let activePreviewRpcUrl: string | null = null; if (fs.existsSync(manifestPath)) { - try { - const manifest = readJsonFile(manifestPath) as any; - txMode = resolveTxMode(String(manifest?.extensions?.tx?.mode ?? 'auto'), Number(manifest?.deployments?.[0]?.chainId ?? anvil.id)); - } catch { - // ignore parse issues - } - } - - // Enable faucet when previewing an anvil build (chainId 31337) and the user hasn't disabled it. - if (opts.faucet && txMode !== 'sponsored' && fs.existsSync(manifestPath)) { - try { - const manifest = readJsonFile(manifestPath) as any; - const deployments = Array.isArray(manifest?.deployments) ? manifest.deployments : []; - const d = deployments.find((x: any) => x && x.role === 'primary') ?? deployments[0] ?? null; - const chainId = Number(d?.chainId ?? NaN); - if (chainId === anvil.id) { - const { chainName, chain } = resolveKnownChain('anvil'); - const rpcUrl = resolveRpcUrl(chainName, chain, opts.rpc); - faucetConfig = { enabled: true, rpcUrl, chainId, targetWei: faucetTargetWei }; - } - } catch { - // Ignore manifest parsing issues; serving static UI still works. - } - } - - // Enable local relay in sponsored mode for anvil chains. - if (txMode === 'sponsored' && fs.existsSync(manifestPath)) { try { const manifest = readJsonFile(manifestPath) as any; const deployments = Array.isArray(manifest?.deployments) ? manifest.deployments : []; - const d = deployments.find((x: any) => x && x.role === 'primary') ?? deployments[0] ?? null; - const chainId = Number(d?.chainId ?? NaN); - if (chainId === anvil.id) { + const primaryDeployment = deployments.find((x: any) => x && x.role === 'primary') ?? deployments[0] ?? null; + txMode = resolveTxMode(String(manifest?.extensions?.tx?.mode ?? 'auto'), Number(manifest?.deployments?.[0]?.chainId ?? anvil.id)); + manifestChainId = Number(primaryDeployment?.chainId ?? NaN); + if (Number.isFinite(manifestChainId) && manifestChainId === anvil.id) { const { chainName, chain } = resolveKnownChain('anvil'); - const rpcUrl = resolveRpcUrl(chainName, chain, opts.rpc); - relayConfig = { - enabled: true, - rpcUrl, - chainId, - from: privateKeyToAccount(resolvePrivateKey('anvil')).address as Address - }; + activePreviewRpcUrl = resolveRpcUrl(chainName, chain, opts.rpc); } + uploadConfig = buildUploadServerConfig(manifest, path.join(resolvedBuildDir, 'ui-site')); } catch { - // Ignore manifest parsing issues; serving static UI still works. + // ignore parse issues } } @@ -3821,24 +4273,41 @@ program const chainNameFromId = chainId === anvil.id ? ('anvil' as const) : chainId === sepolia.id ? ('sepolia' as const) : null; if (chainNameFromId === 'anvil') { const { chainName, chain } = resolveKnownChain('anvil'); - const rpcUrl = resolveRpcUrl(chainName, chain, opts.rpc); + const rpcUrl = activePreviewRpcUrl ?? resolveRpcUrl(chainName, chain, opts.rpc); console.log(`Manifest is not deployed (0x0). Deploying automatically to ${chainName}...`); const ensured = await ensureAnvilRunning(rpcUrl, { start: Boolean(opts.startAnvil), expectedChainId: chain.id }); anvilChild = ensured.child; - await deployBuildDir(resolvedBuildDir, { chain: 'anvil', rpc: opts.rpc, role: 'primary' }); + activePreviewRpcUrl = ensured.rpcUrl; + await deployBuildDir(resolvedBuildDir, { chain: 'anvil', rpc: activePreviewRpcUrl, role: 'primary' }); console.log('Auto-deploy complete.'); console.log(''); } } } + // Enable faucet when previewing an anvil build and the user hasn't disabled it. + if (opts.faucet && txMode !== 'sponsored' && manifestChainId === anvil.id && activePreviewRpcUrl) { + faucetConfig = { enabled: true, rpcUrl: activePreviewRpcUrl, chainId: manifestChainId, targetWei: faucetTargetWei }; + } + + // Enable local relay in sponsored mode for anvil chains. + if (txMode === 'sponsored' && manifestChainId === anvil.id && activePreviewRpcUrl) { + relayConfig = { + enabled: true, + rpcUrl: activePreviewRpcUrl, + chainId: manifestChainId, + from: privateKeyToAccount(resolvePrivateKey('anvil')).address as Address + }; + } + const port = Number(opts.port); const { server } = startUiSiteServer({ buildDir: resolvedBuildDir, host: opts.host, port, faucet: faucetConfig, - relay: relayConfig + relay: relayConfig, + upload: uploadConfig }); const cleanup = () => { diff --git a/packages/templates/next-export-ui/src/lib/manifest.ts b/packages/templates/next-export-ui/src/lib/manifest.ts index 880faa2..baa849e 100644 --- a/packages/templates/next-export-ui/src/lib/manifest.ts +++ b/packages/templates/next-export-ui/src/lib/manifest.ts @@ -1,5 +1,6 @@ export const WELL_KNOWN_MANIFEST_PATH = '/.well-known/tokenhost/manifest.json'; export type TxMode = 'userPays' | 'sponsored'; +export type UploadRunnerMode = 'local' | 'remote' | 'foc-process'; let cached: Promise | null = null; @@ -43,3 +44,34 @@ export function getRelayBaseUrl(manifest: any): string { if (configured) return configured; return '/__tokenhost/relay'; } + +export function uploadsEnabled(manifest: any): boolean { + const extEnabled = manifest?.extensions?.uploads?.enabled; + if (typeof extEnabled === 'boolean') return extEnabled; + return Boolean(manifest?.features?.uploads); +} + +export function getUploadBaseUrl(manifest: any): string { + const configured = String(manifest?.extensions?.uploads?.baseUrl ?? '').trim(); + if (configured) return configured; + return '/__tokenhost/upload'; +} + +export function getUploadEndpointUrl(manifest: any): string { + const configured = String(manifest?.extensions?.uploads?.endpointUrl ?? '').trim(); + if (configured) return configured; + return getUploadBaseUrl(manifest); +} + +export function getUploadStatusUrl(manifest: any): string { + const configured = String(manifest?.extensions?.uploads?.statusUrl ?? '').trim(); + if (configured) return configured; + return getUploadEndpointUrl(manifest); +} + +export function getUploadRunnerMode(manifest: any): UploadRunnerMode { + const mode = String(manifest?.extensions?.uploads?.runnerMode ?? '').trim(); + if (mode === 'remote') return 'remote'; + if (mode === 'foc-process') return 'foc-process'; + return 'local'; +} diff --git a/packages/templates/next-export-ui/src/lib/upload.ts b/packages/templates/next-export-ui/src/lib/upload.ts new file mode 100644 index 0000000..3e189d9 --- /dev/null +++ b/packages/templates/next-export-ui/src/lib/upload.ts @@ -0,0 +1,117 @@ +'use client'; + +import { getUploadEndpointUrl, getUploadRunnerMode, getUploadStatusUrl, uploadsEnabled } from './manifest'; + +export type UploadResult = { + url: string; + cid: string | null; + size: number | null; + provider: string | null; + runnerMode: string | null; + contentType: string | null; + metadata: Record; +}; + +export type UploadConfig = { + enabled: boolean; + endpointUrl: string; + statusUrl: string; + runnerMode: string; + provider: string | null; + accept: string[]; + maxBytes: number | null; +}; + +function normalizeUrl(value: string, fallback: string): string { + const trimmed = String(value || '').trim(); + if (!trimmed) return fallback; + return trimmed; +} + +function parseAcceptList(manifest: any): string[] { + const value = manifest?.extensions?.uploads?.accept; + return Array.isArray(value) ? value.map((x: any) => String(x)).filter(Boolean) : []; +} + +function parseMaxBytes(manifest: any): number | null { + const value = manifest?.extensions?.uploads?.maxBytes; + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : null; +} + +export function getUploadConfig(manifest: any): UploadConfig | null { + if (!uploadsEnabled(manifest)) return null; + return { + enabled: true, + endpointUrl: normalizeUrl(getUploadEndpointUrl(manifest), '/__tokenhost/upload'), + statusUrl: normalizeUrl(getUploadStatusUrl(manifest), '/__tokenhost/upload'), + runnerMode: getUploadRunnerMode(manifest), + provider: manifest?.extensions?.uploads?.provider ? String(manifest.extensions.uploads.provider) : null, + accept: parseAcceptList(manifest), + maxBytes: parseMaxBytes(manifest) + }; +} + +export async function uploadFile(args: { + manifest: any; + file: File; + onProgress?: (percent: number) => void; +}): Promise { + const config = getUploadConfig(args.manifest); + if (!config) throw new Error('Uploads are not enabled for this app.'); + + if (config.maxBytes !== null && args.file.size > config.maxBytes) { + throw new Error(`File exceeds upload limit (${config.maxBytes} bytes).`); + } + + if (config.accept.length > 0 && args.file.type) { + const ok = config.accept.some((pattern) => { + if (pattern === '*/*') return true; + if (pattern.endsWith('/*')) return args.file.type.startsWith(pattern.slice(0, -1)); + return args.file.type === pattern; + }); + if (!ok) throw new Error(`Unsupported file type "${args.file.type}".`); + } + + return await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', config.endpointUrl, true); + xhr.responseType = 'text'; + xhr.setRequestHeader('Content-Type', args.file.type || 'application/octet-stream'); + xhr.setRequestHeader('X-TokenHost-Upload-Filename', args.file.name || 'upload.bin'); + xhr.setRequestHeader('X-TokenHost-Upload-Size', String(args.file.size)); + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable || !args.onProgress) return; + args.onProgress(Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))); + }; + + xhr.onerror = () => reject(new Error('Upload request failed.')); + xhr.onabort = () => reject(new Error('Upload request was aborted.')); + xhr.onload = () => { + let body: any = null; + try { + body = xhr.responseText ? JSON.parse(xhr.responseText) : null; + } catch { + body = null; + } + + if (xhr.status < 200 || xhr.status >= 300 || !body?.ok || !body?.upload?.url) { + reject(new Error(String(body?.error ?? `Upload failed (HTTP ${xhr.status}).`))); + return; + } + + resolve({ + url: String(body.upload.url), + cid: body.upload.cid ? String(body.upload.cid) : null, + size: Number.isFinite(Number(body.upload.size)) ? Number(body.upload.size) : null, + provider: body.upload.provider ? String(body.upload.provider) : config.provider, + runnerMode: body.upload.runnerMode ? String(body.upload.runnerMode) : config.runnerMode, + contentType: body.upload.contentType ? String(body.upload.contentType) : null, + metadata: body.upload.metadata && typeof body.upload.metadata === 'object' ? body.upload.metadata : {} + }); + }; + + xhr.send(args.file); + }); +} diff --git a/test/integration/testCliUploadPreviewIntegration.js b/test/integration/testCliUploadPreviewIntegration.js new file mode 100644 index 0000000..eb92118 --- /dev/null +++ b/test/integration/testCliUploadPreviewIntegration.js @@ -0,0 +1,219 @@ +import { expect } from 'chai'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawn, spawnSync } from 'child_process'; + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +function runTh(args, cwd, extraEnv = {}) { + return spawnSync('node', [path.resolve('packages/cli/dist/index.js'), ...args], { + cwd, + encoding: 'utf-8', + env: { ...process.env, ...extraEnv } + }); +} + +function waitForOutput(proc, pattern, timeoutMs) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + let combined = ''; + let done = false; + + function cleanup() { + if (done) return; + done = true; + clearInterval(timer); + proc.stdout?.off('data', onData); + proc.stderr?.off('data', onData); + } + + function onData(chunk) { + combined += String(chunk ?? ''); + if (pattern.test(combined)) { + cleanup(); + resolve(combined); + } + } + + proc.stdout?.on('data', onData); + proc.stderr?.on('data', onData); + + const timer = setInterval(() => { + if (Date.now() - startedAt < timeoutMs) return; + cleanup(); + reject(new Error(`Timed out waiting for output match: ${pattern}\nOutput:\n${combined}`)); + }, 200); + }); +} + +async function request(url, init) { + const res = await fetch(url, init); + const buffer = Buffer.from(await res.arrayBuffer()); + let json = null; + try { + json = JSON.parse(buffer.toString('utf-8')); + } catch { + json = null; + } + return { status: res.status, json, buffer, headers: res.headers }; +} + +function uploadSchema() { + return { + thsVersion: '2025-12', + schemaVersion: '0.0.1', + app: { + name: 'Upload Integration App', + slug: 'upload-integration-app', + features: { uploads: true, onChainIndexing: true } + }, + collections: [ + { + name: 'Post', + fields: [ + { name: 'body', type: 'string', required: true }, + { name: 'image', type: 'image' } + ], + createRules: { required: ['body'], access: 'public' }, + visibilityRules: { gets: ['body', 'image'], access: 'public' }, + updateRules: { mutable: ['body', 'image'], access: 'owner' }, + deleteRules: { softDelete: true, access: 'owner' }, + indexes: { unique: [], index: [] } + } + ] + }; +} + +describe('CLI local preview upload integration', function () { + it('serves a local upload endpoint and stores uploaded bytes for generated apps with uploads enabled', async function () { + this.timeout(180000); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-upload-preview-')); + const schemaPath = path.join(dir, 'schema.json'); + const outDir = path.join(dir, 'out'); + writeJson(schemaPath, uploadSchema()); + + const buildRes = runTh(['build', schemaPath, '--out', outDir], process.cwd()); + expect(buildRes.status, buildRes.stderr || buildRes.stdout).to.equal(0); + + const port = 43000 + Math.floor(Math.random() * 2000); + const host = '127.0.0.1'; + const baseUrl = `http://${host}:${port}`; + + const preview = spawn( + 'node', + [ + path.resolve('packages/cli/dist/index.js'), + 'preview', + outDir, + '--host', + host, + '--port', + String(port), + '--no-deploy', + '--no-start-anvil', + '--no-faucet' + ], + { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] } + ); + + try { + await waitForOutput(preview, new RegExp(`http://${host}:${port}/`), 60000); + + const uploadStatus = await request(`${baseUrl}/__tokenhost/upload`); + expect(uploadStatus.status).to.equal(200); + expect(uploadStatus.json?.ok).to.equal(true); + expect(uploadStatus.json?.enabled).to.equal(true); + expect(String(uploadStatus.json?.runnerMode || '')).to.equal('local'); + + const payload = Buffer.from('not-a-real-png-but-good-enough-for-local-upload-test', 'utf-8'); + const uploadRes = await request(`${baseUrl}/__tokenhost/upload`, { + method: 'POST', + headers: { + 'content-type': 'image/png', + 'x-tokenhost-upload-filename': 'tiny.png', + 'x-tokenhost-upload-size': String(payload.length) + }, + body: payload + }); + + expect(uploadRes.status).to.equal(200); + expect(uploadRes.json?.ok).to.equal(true); + expect(String(uploadRes.json?.upload?.url || '')).to.match(/^\/__tokenhost\/uploads\/.+/); + + const uploadedUrl = `${baseUrl}${uploadRes.json.upload.url}`; + const stored = await request(uploadedUrl); + expect(stored.status).to.equal(200); + expect(Buffer.compare(stored.buffer, payload)).to.equal(0); + expect(String(stored.headers.get('content-type') || '')).to.match(/^image\/png/); + } finally { + preview.kill('SIGINT'); + } + }); + + it('serves uploads from a custom local endpoint path when configured', async function () { + this.timeout(180000); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-upload-preview-custom-')); + const schemaPath = path.join(dir, 'schema.json'); + const outDir = path.join(dir, 'out'); + writeJson(schemaPath, uploadSchema()); + + const buildRes = runTh(['build', schemaPath, '--out', outDir], process.cwd(), { + TH_UPLOAD_BASE_URL: '/api/uploads' + }); + expect(buildRes.status, buildRes.stderr || buildRes.stdout).to.equal(0); + + const port = 45000 + Math.floor(Math.random() * 1000); + const host = '127.0.0.1'; + const baseUrl = `http://${host}:${port}`; + + const preview = spawn( + 'node', + [ + path.resolve('packages/cli/dist/index.js'), + 'preview', + outDir, + '--host', + host, + '--port', + String(port), + '--no-deploy', + '--no-start-anvil', + '--no-faucet' + ], + { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] } + ); + + try { + await waitForOutput(preview, new RegExp(`http://${host}:${port}/`), 60000); + + const uploadStatus = await request(`${baseUrl}/api/uploads`); + expect(uploadStatus.status).to.equal(200); + expect(uploadStatus.json?.ok).to.equal(true); + expect(uploadStatus.json?.endpointUrl).to.equal('/api/uploads'); + expect(uploadStatus.json?.statusUrl).to.equal('/api/uploads'); + + const payload = Buffer.from('custom-path-upload', 'utf-8'); + const uploadRes = await request(`${baseUrl}/api/uploads`, { + method: 'POST', + headers: { + 'content-type': 'image/png', + 'x-tokenhost-upload-filename': 'custom.png', + 'x-tokenhost-upload-size': String(payload.length) + }, + body: payload + }); + + expect(uploadRes.status).to.equal(200); + expect(uploadRes.json?.ok).to.equal(true); + expect(String(uploadRes.json?.upload?.url || '')).to.match(/^\/__tokenhost\/uploads\/.+/); + } finally { + preview.kill('SIGINT'); + } + }); +}); diff --git a/test/integration/testStandaloneUploadAdapter.js b/test/integration/testStandaloneUploadAdapter.js new file mode 100644 index 0000000..b4fd8de --- /dev/null +++ b/test/integration/testStandaloneUploadAdapter.js @@ -0,0 +1,115 @@ +import { expect } from 'chai'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawn } from 'child_process'; + +function waitForOutput(proc, pattern, timeoutMs) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + let combined = ''; + let done = false; + + function cleanup() { + if (done) return; + done = true; + clearInterval(timer); + proc.stdout?.off('data', onData); + proc.stderr?.off('data', onData); + } + + function onData(chunk) { + combined += String(chunk ?? ''); + if (pattern.test(combined)) { + cleanup(); + resolve(combined); + } + } + + proc.stdout?.on('data', onData); + proc.stderr?.on('data', onData); + + const timer = setInterval(() => { + if (Date.now() - startedAt < timeoutMs) return; + cleanup(); + reject(new Error(`Timed out waiting for output match: ${pattern}\nOutput:\n${combined}`)); + }, 200); + }); +} + +async function request(url, init) { + const res = await fetch(url, init); + const buffer = Buffer.from(await res.arrayBuffer()); + let json = null; + try { + json = JSON.parse(buffer.toString('utf-8')); + } catch { + json = null; + } + return { status: res.status, json, buffer, headers: res.headers }; +} + +describe('Standalone remote upload adapter example', function () { + it('serves the generated UI upload contract in local mode', async function () { + this.timeout(120000); + + const host = '127.0.0.1'; + const port = 47000 + Math.floor(Math.random() * 1000); + const baseUrl = `http://${host}:${port}`; + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-remote-upload-adapter-')); + const endpointPath = '/api/upload'; + const statusPath = '/api/upload/status'; + + const proc = spawn('node', [path.resolve('examples/upload-adapters/foc-remote-adapter.mjs')], { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + HOST: host, + PORT: String(port), + TH_UPLOAD_ADAPTER_MODE: 'local', + TH_UPLOAD_ENDPOINT_PATH: endpointPath, + TH_UPLOAD_STATUS_PATH: statusPath, + TH_UPLOAD_PUBLIC_BASE_URL: baseUrl, + TH_UPLOAD_LOCAL_DIR: storageDir + } + }); + + try { + await waitForOutput(proc, new RegExp(`Token Host upload adapter listening at http://${host}:${port}`), 30000); + + const health = await request(`${baseUrl}/healthz`); + expect(health.status).to.equal(200); + expect(health.json?.ok).to.equal(true); + + const status = await request(`${baseUrl}${statusPath}`); + expect(status.status).to.equal(200); + expect(status.json?.ok).to.equal(true); + expect(status.json?.runnerMode).to.equal('local'); + expect(status.json?.endpointUrl).to.equal(`${baseUrl}${endpointPath}`); + expect(status.json?.statusUrl).to.equal(`${baseUrl}${statusPath}`); + + const payload = Buffer.from('standalone-upload-adapter-test', 'utf-8'); + const upload = await request(`${baseUrl}${endpointPath}`, { + method: 'POST', + headers: { + 'content-type': 'image/png', + 'x-tokenhost-upload-filename': 'adapter.png', + 'x-tokenhost-upload-size': String(payload.length) + }, + body: payload + }); + + expect(upload.status).to.equal(200); + expect(upload.json?.ok).to.equal(true); + expect(String(upload.json?.upload?.url || '')).to.match(new RegExp(`^${baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/uploads/.+`)); + + const stored = await request(String(upload.json.upload.url)); + expect(stored.status).to.equal(200); + expect(Buffer.compare(stored.buffer, payload)).to.equal(0); + expect(String(stored.headers.get('content-type') || '')).to.match(/^image\/png/); + } finally { + proc.kill('SIGINT'); + } + }); +}); diff --git a/test/testCliUploads.js b/test/testCliUploads.js new file mode 100644 index 0000000..30b844c --- /dev/null +++ b/test/testCliUploads.js @@ -0,0 +1,109 @@ +import { expect } from 'chai'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +function runTh(args, cwd, extraEnv = {}) { + return spawnSync('node', [path.resolve('packages/cli/dist/index.js'), ...args], { + cwd, + encoding: 'utf-8', + env: { ...process.env, ...extraEnv } + }); +} + +function uploadSchema() { + return { + thsVersion: '2025-12', + schemaVersion: '0.0.1', + app: { + name: 'Upload Test App', + slug: 'upload-test-app', + features: { uploads: true, onChainIndexing: true } + }, + collections: [ + { + name: 'Post', + fields: [ + { name: 'body', type: 'string', required: true }, + { name: 'image', type: 'image' } + ], + createRules: { required: ['body'], access: 'public' }, + visibilityRules: { gets: ['body', 'image'], access: 'public' }, + updateRules: { mutable: ['body', 'image'], access: 'owner' }, + deleteRules: { softDelete: true, access: 'owner' }, + indexes: { unique: [], index: [] } + } + ] + }; +} + +describe('th build (upload config)', function () { + it('emits upload runtime metadata in manifest when app.features.uploads=true', function () { + this.timeout(60000); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-build-uploads-')); + const schemaPath = path.join(dir, 'schema.json'); + const outDir = path.join(dir, 'out'); + writeJson(schemaPath, uploadSchema()); + + const res = runTh(['build', schemaPath, '--out', outDir, '--no-ui'], process.cwd()); + expect(res.status, res.stderr || res.stdout).to.equal(0); + + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'manifest.json'), 'utf-8')); + expect(manifest?.features?.uploads).to.equal(true); + expect(manifest?.extensions?.uploads?.enabled).to.equal(true); + expect(String(manifest?.extensions?.uploads?.baseUrl || '')).to.not.equal(''); + expect(String(manifest?.extensions?.uploads?.provider || '')).to.match(/local_file|filecoin_onchain_cloud/); + expect(String(manifest?.extensions?.uploads?.runnerMode || '')).to.match(/local|remote|foc-process/); + expect(Array.isArray(manifest?.extensions?.uploads?.accept)).to.equal(true); + }); + + it('emits exact remote upload endpoint metadata when remote runner env is configured', function () { + this.timeout(60000); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-build-uploads-remote-')); + const schemaPath = path.join(dir, 'schema.json'); + const outDir = path.join(dir, 'out'); + writeJson(schemaPath, uploadSchema()); + + const res = runTh(['build', schemaPath, '--out', outDir, '--no-ui'], process.cwd(), { + TH_UPLOAD_RUNNER: 'remote', + TH_UPLOAD_PROVIDER: 'foc', + TH_UPLOAD_REMOTE_ENDPOINT_URL: 'https://uploads.example.com/api/upload', + TH_UPLOAD_REMOTE_STATUS_URL: 'https://uploads.example.com/api/health/upload' + }); + expect(res.status, res.stderr || res.stdout).to.equal(0); + + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'manifest.json'), 'utf-8')); + expect(manifest?.extensions?.uploads?.runnerMode).to.equal('remote'); + expect(manifest?.extensions?.uploads?.provider).to.equal('filecoin_onchain_cloud'); + expect(manifest?.extensions?.uploads?.endpointUrl).to.equal('https://uploads.example.com/api/upload'); + expect(manifest?.extensions?.uploads?.statusUrl).to.equal('https://uploads.example.com/api/health/upload'); + expect(manifest?.extensions?.uploads?.baseUrl).to.equal('https://uploads.example.com/api/upload'); + }); + + it('derives a default remote upload endpoint when given only a remote origin', function () { + this.timeout(60000); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-build-uploads-remote-origin-')); + const schemaPath = path.join(dir, 'schema.json'); + const outDir = path.join(dir, 'out'); + writeJson(schemaPath, uploadSchema()); + + const res = runTh(['build', schemaPath, '--out', outDir, '--no-ui'], process.cwd(), { + TH_UPLOAD_REMOTE_BASE_URL: 'https://uploads.example.com' + }); + expect(res.status, res.stderr || res.stdout).to.equal(0); + + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'manifest.json'), 'utf-8')); + expect(manifest?.extensions?.uploads?.runnerMode).to.equal('remote'); + expect(manifest?.extensions?.uploads?.endpointUrl).to.equal('https://uploads.example.com/__tokenhost/upload'); + expect(manifest?.extensions?.uploads?.statusUrl).to.equal('https://uploads.example.com/__tokenhost/upload'); + }); +});