initial commit
All checks were successful
Build & Push Docker (latest) / verify (push) Successful in 9m26s
Build & Push Docker (latest) / build (push) Successful in 14s

This commit is contained in:
Aiden Wilson
2026-05-29 22:24:09 +10:00
parent b667f143ab
commit 4595e782c8
13 changed files with 823 additions and 1 deletions

75
src/oembed.js Normal file
View File

@@ -0,0 +1,75 @@
import { findProvider, loadProviders } from "./providers.js";
const DEFAULT_TIMEOUT_MS = 8000;
export function buildOembedUrl(endpoint, targetUrl, maxWidth) {
const requestUrl = new URL(endpoint);
requestUrl.searchParams.set("url", targetUrl);
requestUrl.searchParams.set("format", "json");
if (maxWidth) {
requestUrl.searchParams.set("maxwidth", String(maxWidth));
}
return requestUrl;
}
export async function fetchOembed(targetUrl, {
fetchImpl = fetch,
providers,
timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
maxWidth,
} = {}) {
let parsed;
try {
parsed = new URL(targetUrl);
} catch {
const error = new Error("The url parameter must be an absolute URL.");
error.status = 400;
throw error;
}
if (!["http:", "https:"].includes(parsed.protocol)) {
const error = new Error("Only http and https URLs are supported.");
error.status = 400;
throw error;
}
const availableProviders = providers || await loadProviders({ fetchImpl });
const provider = findProvider(targetUrl, availableProviders);
if (!provider) {
const error = new Error("No oEmbed provider matched this URL.");
error.status = 404;
throw error;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(buildOembedUrl(provider.endpoint, targetUrl, maxWidth), {
headers: {
accept: "application/json",
"user-agent": "oembed-graphics/0.1",
},
signal: controller.signal,
});
if (!response.ok) {
const error = new Error(`oEmbed provider returned ${response.status}.`);
error.status = response.status;
throw error;
}
const data = await response.json();
return {
provider,
data,
};
} finally {
clearTimeout(timeout);
}
}

83
src/providers.js Normal file
View File

@@ -0,0 +1,83 @@
const DEFAULT_PROVIDERS_URL = "https://oembed.com/providers.json";
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
let providerCache = {
expiresAt: 0,
providers: [],
};
export function wildcardToRegExp(pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`, "i");
}
export function normalizeEndpoint(endpoint) {
return endpoint.replace("{format}", "json");
}
export function flattenProviders(rawProviders) {
const providers = [];
for (const provider of rawProviders) {
for (const endpoint of provider.endpoints || []) {
const schemes = endpoint.schemes || [];
for (const scheme of schemes) {
providers.push({
providerName: provider.provider_name,
providerUrl: provider.provider_url,
endpoint: normalizeEndpoint(endpoint.url),
scheme,
regex: wildcardToRegExp(scheme),
});
}
}
}
return providers;
}
export function findProvider(url, providers) {
return providers.find((provider) => provider.regex.test(url));
}
export async function loadProviders({
providersUrl = process.env.PROVIDERS_URL || DEFAULT_PROVIDERS_URL,
ttlMs = Number(process.env.PROVIDERS_TTL_MS || DEFAULT_TTL_MS),
fetchImpl = fetch,
force = false,
} = {}) {
const now = Date.now();
if (!force && providerCache.providers.length && providerCache.expiresAt > now) {
return providerCache.providers;
}
const response = await fetchImpl(providersUrl, {
headers: {
accept: "application/json",
"user-agent": "oembed-graphics/0.1",
},
});
if (!response.ok) {
throw new Error(`Could not load providers: ${response.status} ${response.statusText}`);
}
const rawProviders = await response.json();
const providers = flattenProviders(rawProviders);
providerCache = {
expiresAt: now + ttlMs,
providers,
};
return providers;
}
export function clearProviderCache() {
providerCache = {
expiresAt: 0,
providers: [],
};
}

160
src/server.js Normal file
View File

@@ -0,0 +1,160 @@
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { fetchOembed } from "./oembed.js";
import { loadProviders } from "./providers.js";
import { errorPage, graphicPage, homePage } from "./templates.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const rootDir = join(__dirname, "..");
const publicDir = join(rootDir, "public");
const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || "0.0.0.0";
const contentTypes = {
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
};
function send(response, status, body, contentType = "text/html; charset=utf-8") {
response.writeHead(status, {
"content-type": contentType,
"cache-control": status === 200 ? "public, max-age=60" : "no-store",
"x-content-type-options": "nosniff",
});
response.end(body);
}
function sendJson(response, status, body) {
send(response, status, JSON.stringify(body, null, 2), "application/json; charset=utf-8");
}
function getBoolean(searchParams, key, defaultValue = false) {
if (!searchParams.has(key)) {
return defaultValue;
}
return ["1", "true", "yes", "on"].includes(searchParams.get(key).toLowerCase());
}
function getNumber(searchParams, key, defaultValue, min, max) {
const value = Number(searchParams.get(key) || defaultValue);
if (!Number.isFinite(value)) {
return defaultValue;
}
return Math.min(Math.max(value, min), max);
}
async function serveStatic(requestUrl, response) {
const path = requestUrl.pathname === "/styles.css" ? "styles.css" : "";
if (!path) {
return false;
}
const body = await readFile(join(publicDir, path), "utf8");
send(response, 200, body, contentTypes[extname(path)] || "text/plain; charset=utf-8");
return true;
}
async function handleGraphic(requestUrl, response) {
const url = requestUrl.searchParams.get("url") || decodeURIComponent(requestUrl.pathname.replace(/^\/+/, ""));
if (!url) {
send(response, 400, errorPage("Add a url query parameter, for example /graphic?url=https://...", 400), "text/html; charset=utf-8");
return;
}
const width = getNumber(requestUrl.searchParams, "width", 1920, 320, 3840);
const height = getNumber(requestUrl.searchParams, "height", 1080, 240, 2160);
const scale = getNumber(requestUrl.searchParams, "scale", 1, 0.25, 3);
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", Math.round(width * 0.72), 240, 3840);
const fit = requestUrl.searchParams.get("fit") === "cover" ? "cover" : "contain";
const transparent = getBoolean(requestUrl.searchParams, "transparent", true);
const chroma = requestUrl.searchParams.get("chroma") || "#00ff00";
const { data } = await fetchOembed(url, { maxWidth });
send(response, 200, graphicPage({
targetUrl: url,
embed: data,
width,
height,
fit,
transparent,
scale,
chroma,
}));
}
async function handleRequest(request, response) {
const requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`);
try {
if (await serveStatic(requestUrl, response)) {
return;
}
if (requestUrl.pathname === "/healthz") {
sendJson(response, 200, { ok: true });
return;
}
if (requestUrl.pathname === "/providers") {
const providers = await loadProviders();
sendJson(response, 200, {
count: providers.length,
providers: providers.map(({ providerName, providerUrl, endpoint, scheme }) => ({
providerName,
providerUrl,
endpoint,
scheme,
})),
});
return;
}
if (requestUrl.pathname === "/api/oembed") {
const url = requestUrl.searchParams.get("url");
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 1280, 240, 3840);
const result = await fetchOembed(url, { maxWidth });
sendJson(response, 200, result);
return;
}
if (requestUrl.pathname === "/" && !requestUrl.searchParams.has("url")) {
const providers = await loadProviders();
send(response, 200, homePage({ providersCount: providers.length }));
return;
}
if (requestUrl.pathname === "/" || requestUrl.pathname === "/graphic" || requestUrl.pathname.length > 1) {
await handleGraphic(requestUrl, response);
return;
}
send(response, 404, errorPage("Not found.", 404));
} catch (error) {
const status = error.status || 500;
const acceptsHtml = request.headers.accept?.includes("text/html");
const message = error.name === "AbortError" ? "The oEmbed provider timed out." : error.message;
if (acceptsHtml) {
send(response, status, errorPage(message, status));
} else {
sendJson(response, status, { error: message });
}
}
}
const server = createServer(handleRequest);
server.listen(port, host, () => {
console.log(`oembed-graphics listening on http://${host}:${port}`);
});

90
src/templates.js Normal file
View File

@@ -0,0 +1,90 @@
function escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function commonHead({ title = "oEmbed Graphics" } = {}) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/styles.css">
</head>`;
}
export function homePage({ providersCount = 0 } = {}) {
return `${commonHead()}
<body class="app">
<main class="shell">
<section class="panel">
<h1>oEmbed Graphics</h1>
<form action="/graphic" method="get">
<label for="url">Source URL</label>
<div class="row">
<input id="url" name="url" type="url" required placeholder="https://www.youtube.com/watch?v=..." autocomplete="off">
<button type="submit">Load</button>
</div>
<div class="controls">
<label>Width <input name="width" type="number" value="1920" min="320" max="3840"></label>
<label>Height <input name="height" type="number" value="1080" min="240" max="2160"></label>
<label>Fit
<select name="fit">
<option value="contain">contain</option>
<option value="cover">cover</option>
</select>
</label>
<label><input name="transparent" value="1" type="checkbox" checked> transparent</label>
</div>
</form>
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
</section>
</main>
</body>
</html>`;
}
export function graphicPage({
targetUrl,
embed,
width,
height,
fit,
transparent,
scale,
chroma,
}) {
const title = embed.title || embed.provider_name || "oEmbed Graphic";
const html = embed.html || "";
const media = embed.type === "photo" && embed.url
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(title)}">`
: html;
return `${commonHead({ title })}
<body class="graphic ${transparent ? "transparent" : ""}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
<main class="stage fit-${escapeHtml(fit)}">
<section class="embed" data-source="${escapeHtml(targetUrl)}">
${media}
</section>
</main>
</body>
</html>`;
}
export function errorPage(message, status = 500) {
return `${commonHead({ title: `Error ${status}` })}
<body class="app">
<main class="shell">
<section class="panel error">
<h1>Error ${status}</h1>
<p>${escapeHtml(message)}</p>
</section>
</main>
</body>
</html>`;
}