Astro
Use a server endpoint (API Route or Astro Action) so that the API key never reaches the browser.Never put
FLASH_MD_API_KEY in an Astro PUBLIC_* variable or reference it from a .astro component. Any variable prefixed with PUBLIC_ is included in the client bundle. Save it as a normal server variable and only read it from code that runs in src/pages/api/* or Actions.Basic endpoint
// src/pages/api/convert.ts
import type { APIRoute } from "astro";
const API_URL = import.meta.env.FLASH_MD_API_URL;
const API_KEY = import.meta.env.FLASH_MD_API_KEY;
async function pollJob(jobId: string): Promise<string> {
while (true) {
await new Promise((r) => setTimeout(r, 5000));
const res = await fetch(`${API_URL}/jobs/${jobId}`, {
headers: { "x-api-key": API_KEY },
});
const data = await res.json();
if (data.status === "completed") return data.body;
if (data.status === "failed") throw new Error(data.error);
}
}
export const POST: APIRoute = async ({ request }) => {
const form = await request.formData();
const file = form.get("file") as File | null;
if (!file) {
return new Response(JSON.stringify({ error: "Missing file" }), { status: 400 });
}
const res = await fetch(
`${API_URL}/convert/raw?filename=${encodeURIComponent(file.name)}&mode=fast`,
{
method: "POST",
headers: {
"x-api-key": API_KEY,
"content-type": file.type || "application/octet-stream",
},
body: file,
}
);
if (res.status === 200) {
return new Response(await res.text(), {
headers: { "content-type": "text/markdown; charset=utf-8" },
});
}
if (res.status === 202) {
const job = await res.json();
const markdown = await pollJob(job.job_id);
return new Response(markdown, {
headers: { "content-type": "text/markdown; charset=utf-8" },
});
}
return new Response(await res.text(), { status: res.status });
};
Form on the client
---
// src/pages/upload.astro
---
<form id="form">
<input type="file" name="file" accept=".pdf,.docx,.csv,.xlsx,.pptx,.txt" required />
<button type="submit">Convertir</button>
</form>
<pre id="output"></pre>
<script>
const form = document.querySelector("form")!;
const output = document.getElementById("output")!;
form.addEventListener("submit", async (e) => {
e.preventDefault();
output.textContent = "Convirtiendo...";
const data = new FormData(form);
const res = await fetch("/api/convert", { method: "POST", body: data });
output.textContent = await res.text();
});
</script>
You need
output: "server" or output: "hybrid" on astro.config.mjs for server endpoints to work.Astro Action
Astro Actions are a typed alternative to API Routes: they are called from the client as normal functions (without manualfetch) and Astro takes care of serialization. They are a good option when you already have progressive forms (<form> with vavalidation that works without JS) or want vavalidation with Zod before touching the API.
// src/actions/index.ts
import { defineAction } from "astro:actions";
import { ActionError } from "astro:actions";
import { z } from "astro:schema";
const API_URL = import.meta.env.FLASH_MD_API_URL;
const API_KEY = import.meta.env.FLASH_MD_API_KEY;
async function pollJob(jobId: string): Promise<string> {
const deadline = Date.now() + 5 * 60_000; // 5 minutos de presupuesto total
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000));
const res = await fetch(`${API_URL}/jobs/${jobId}`, {
headers: { "x-api-key": API_KEY },
});
const data = await res.json();
if (data.status === "completed") return data.body;
if (data.status === "failed") {
throw new ActionError({ code: "BAD_REQUEST", message: data.error ?? "Conversión falvalida" });
}
}
throw new ActionError({ code: "TIMEOUT", message: "El job tardó demasiado en completarse" });
}
export const server = {
convertDocument: defineAction({
accept: "form",
input: z.object({
file: z.instanceof(File),
mode: z.enum(["fast", "ultra_fast", "balanced", "quality", "auto"]).default("fast"),
}),
handler: async ({ file, mode }) => {
if (file.size === 0) {
throw new ActionError({ code: "BAD_REQUEST", message: "Empty file" });
}
const res = await fetch(
`${API_URL}/convert/raw?filename=${encodeURIComponent(file.name)}&mode=${mode}`,
{
method: "POST",
headers: {
"x-api-key": API_KEY,
"content-type": file.type || "application/octet-stream",
},
body: file,
}
);
if (res.status === 200) {
return { markdown: await res.text() };
}
if (res.status === 202) {
const job = await res.json();
return { markdown: await pollJob(job.job_id) };
}
const detail = await res.json().catch(() => ({ detail: res.statusText }));
if (res.status === 401) {
throw new ActionError({ code: "UNAUTHORIZED", message: "API key invávalida" });
}
if (res.status === 403) {
throw new ActionError({ code: "FORBIDDEN", message: detail.detail });
}
if (res.status === 413) {
throw new ActionError({ code: "PAYLOAD_TOO_LARGE", message: detail.detail });
}
if (res.status === 415) {
throw new ActionError({ code: "UNSUPPORTED_MEDIA_TYPE", message: detail.detail });
}
if (res.status === 422) {
throw new ActionError({ code: "BAD_REQUEST", message: detail.detail });
}
if (res.status === 429) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: detail.detail });
}
throw new ActionError({ code: "INTERNAL_SERVER_ERROR", message: detail.detail });
},
}),
};
---
// src/pages/convert.astro
import { actions } from "astro:actions";
---
<form method="POST" action={actions.convertDocument}>
<input type="file" name="file" accept=".pdf,.docx,.csv,.xlsx,.pptx,.txt" required />
<select name="mode">
<option value="fast">Rápido</option>
<option value="balanced">Balanceado</option>
<option value="quality">Cavalidad</option>
</select>
<button type="submit">Convertir</button>
</form>
<script>
import { actions, isInputError } from "astro:actions";
const form = document.querySelector("form")!;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const { data, error } = await actions.convertDocument(new FormData(form));
if (error) {
if (isInputError(error)) {
console.error("Vavalidación:", error.fields);
} else {
console.error(`[${error.code}]`, error.message);
}
return;
}
console.log(data.markdown);
});
</script>
Ingest into a content collection
A common use case in Astro is converting a batch of PDFs (whitepapers, manuals, minutes) to Markdown during the build to feed a Content Collection. This script runs in Node before the build, not in a request:// scripts/ingest-pdfs.ts
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { extname, basename, join } from "node:path";
const API_URL = process.env.FLASH_MD_API_URL!;
const API_KEY = process.env.FLASH_MD_API_KEY!;
const SOURCE_DIR = "raw-pdfs";
const OUTPUT_DIR = "src/content/docs";
async function convertOne(path: string, filename: string): Promise<string> {
const buffer = await readFile(path);
const res = await fetch(
`${API_URL}/convert/raw?filename=${encodeURIComponent(filename)}&mode=quality&clean=true`,
{
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": "application/pdf" },
body: buffer,
}
);
if (res.status === 200) return res.text();
if (res.status === 202) {
const { job_id } = await res.json();
while (true) {
await new Promise((r) => setTimeout(r, 5000));
const jobRes = await fetch(`${API_URL}/jobs/${job_id}`, {
headers: { "x-api-key": API_KEY },
});
const job = await jobRes.json();
if (job.status === "completed") return job.body;
if (job.status === "failed") throw new Error(`Job ${job_id} falló: ${job.error}`);
}
}
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(`${filename}: HTTP ${res.status} - ${detail.detail}`);
}
async function main() {
await mkdir(OUTPUT_DIR, { recursive: true });
const files = (await readdir(SOURCE_DIR)).filter((f) => extname(f) === ".pdf");
for (const file of files) {
const slug = basename(file, ".pdf");
console.log(`Convirtiendo ${file}...`);
try {
const markdown = await convertOne(join(SOURCE_DIR, file), file);
const frontmatter = `---\ntitle: "${slug}"\n---\n\n`;
await writeFile(join(OUTPUT_DIR, `${slug}.md`), frontmatter + markdown);
} catch (err) {
console.error(`Error en ${file}:`, err);
}
}
}
main();
// package.json
{
"scripts": {
"prebuild": "tsx scripts/ingest-pdfs.ts",
"build": "astro build"
}
}
Language error handling
In either approach (API Route or Action), map each status code to a clear response instead of propagating a generic 500:// src/pages/api/convert.ts (fragment)
function mapUpstreamError(status: number, detail: string): Response {
switch (status) {
case 400:
case 422:
return new Response(JSON.stringify({ error: detail }), { status });
case 401:
return new Response(JSON.stringify({ error: "API key invávalida en el servidor" }), { status: 401 });
case 403:
return new Response(JSON.stringify({ error: "Origen no permitido" }), { status: 403 });
case 413:
return new Response(JSON.stringify({ error: "Document too large" }), { status: 413 });
case 415:
return new Response(JSON.stringify({ error: "Formato no soportado" }), { status: 415 });
case 429:
return new Response(JSON.stringify({ error: "Too many requests, retry in a few seconds" }), { status: 429 });
default:
return new Response(JSON.stringify({ error: "Could not convert the document" }), { status: 502 });
}
}
Production
In serverless deployments (Vercel, Netlify, Cloudflare adapters), check the lifetime limit of your function before polling long jobs: if the
job_id can take several minutes, consider returning the job_id to the client and having it poll directly (with its own read-only key or through a proxy endpoint), instead of blocking the server function.Timeout with fetch
async function convertWithTimeout(file: File, timeoutMs = 30_000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(
`${API_URL}/convert/raw?filename=${encodeURIComponent(file.name)}&mode=fast`,
{
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": file.type },
body: file,
signal: controller.signal,
}
);
} finally {
clearTimeout(timer);
}
}
Retries with exponential backoff
Only retry on429 and 5xx; other 4xx errors are not fixed by retrying.
async function fetchWithRetry(url: string, init: RequestInit, maxAttempts = 4): Promise<Response> {
let attempt = 0;
while (true) {
attempt++;
const res = await fetch(url, init);
if (res.status !== 429 && res.status < 500) return res;
if (attempt >= maxAttempts) return res;
const backoffMs = Math.min(1000 * 2 ** attempt, 15_000);
const jitter = Math.random() * 250;
await new Promise((r) => setTimeout(r, backoffMs + jitter));
}
}
Large files with BYOS (output_url)
For large documents, avoid Markdown traveling through the response body: upload the result directly to your own storage with a pre-signed URL.
// src/pages/api/convert-large.ts
export const POST: APIRoute = async ({ request }) => {
const { url, putUrl, headUrl } = await request.json();
const res = await fetch(
`${API_URL}/convert/from-url`,
{
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": "application/json" },
body: JSON.stringify({
url,
mode: "balanced",
output_url: putUrl, // pre-signed PUT to your bucket
output_head_url: headUrl, // pre-signed HEAD, avoids reprocessing if it already exists
}),
}
);
// output_url auto-enable response_format=json, output_encoding=zstd, slim=true
const body = await res.json();
return new Response(JSON.stringify(body), { status: res.status });
};
With
output_url, the API responds with a small JSON (metadata + confirmation) instead of the full Markdown. This is ideal for documents of hundreds of pages where the normal body would be huge.