initial commit
This commit is contained in:
75
src/oembed.js
Normal file
75
src/oembed.js
Normal 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
83
src/providers.js
Normal 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
160
src/server.js
Normal 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
90
src/templates.js
Normal file
@@ -0,0 +1,90 @@
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user