feat: migrate from clientside javascript to elysiajs eden

# Conflicts:
#	public/script.js
#	src/index.tsx
This commit is contained in:
Jörg Krzeslak authored and Laertes87 committed 2026-11-03 12:58:16 +01:00
1 parent 57a884d090
commit 09fa7b74fa
14 files changed
+505 -297

No files matched your search

+2
View File
@@ -50,3 +50,5 @@ package-lock.json
/Bruno
/tsconfig.tsbuildinfo
/public/generated.css
/public/results.js
/public/script.js
+3
View File
@@ -5,6 +5,7 @@
"": {
"name": "convertx-frontend",
"dependencies": {
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.2",
"@elysiajs/jwt": "^1.4.2",
"@elysiajs/static": "^1.4.10",
@@ -48,6 +49,8 @@
"@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="],
"@elysiajs/eden": ["@elysiajs/eden@1.4.9", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="],
"@elysiajs/html": ["@elysiajs/html@1.4.2", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Db7dmbkN7gptckMpU0/Fq9Qi3QuhQr/CH60A+8rs+RT+74NUC8sONs5nkfMm5oL+6kCUWCv19uUwOjBP7zsjYQ=="],
"@elysiajs/jwt": ["@elysiajs/jwt@1.4.2", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.27" } }, "sha512-gSPjF8KoL7cQIxL3kb+Ui9g5jpBdO4iUTe2+F52w66JVFct2K8cTLum8PEArxRZNg4PBrg100h6i1bJ2zfcqMg=="],
+2
View File
@@ -2,6 +2,7 @@
preload = ["./tests/preload.ts"]
coverage = true
coveragePathIgnorePatterns = ["tests/converters/helpers/commonTests.ts"]
pathIgnorePatterns = ["**/integration/**"]
[fmt]
arrowParens = "always"
@@ -13,3 +14,4 @@ sortImports = true
sortTailwindcss = false
sortPackageJson = true
jsdoc = true
ignorePatterns = ["public/*.js"]
+1 -1
View File
@@ -6,7 +6,7 @@ import tseslint, { parser as eslintParserTypeScript } from "typescript-eslint";
export default defineConfig(
{
ignores: ["**/node_modules/**", "dist/**"],
ignores: ["**/node_modules/**", "dist/**", "**/public/**/*.js"],
},
js.configs.recommended,
tseslint.configs.recommended,
+5 -3
View File
@@ -10,20 +10,22 @@
"format:eslint": "eslint --fix .",
"format:prettier": "bun fmt",
"build:css": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css",
"build:client": "bun build public/script.ts public/results.ts --outdir public/ --target browser --minify",
"build:js": "tsc",
"build:bin": "bun run build:css && bun build --compile --minify --sourcemap --bytecode --format=esm --define 'process.env.NODE_ENV=\"production\"' src/index.tsx --outfile dist/convertx",
"build:bin": "bun run build:css && bun run build:client && bun build --compile --minify --sourcemap --bytecode --format=esm --define 'process.env.NODE_ENV=\"production\"' src/index.tsx --outfile dist/convertx",
"build:docker": "docker compose build",
"build": "bun run build:css && bun run build:js",
"build": "bun run build:css && bun run build:client && bun run build:js",
"lint": "npm-run-all 'lint:*'",
"lint:tsc": "bun check",
"lint:knip": "knip",
"lint:eslint": "eslint .",
"lint:prettier": "bun fmt --check",
"lint:xss": "xss-scan",
"test": "bun test --path-ignore-patterns=tests/integration",
"test": "bun test",
"test:integration": "docker build --target test -t convertx:test . && docker run --rm -t -v ./tests:/app/tests -v ./src:/app/src -v ./package.json:/app/package.json convertx:test bun test tests/integration"
},
"dependencies": {
"@elysiajs/eden": "^1.4.9",
"@elysiajs/html": "^1.4.2",
"@elysiajs/jwt": "^1.4.2",
"@elysiajs/static": "^1.4.10",
-38
View File
@@ -1,38 +0,0 @@
const webroot = document.querySelector("meta[name='webroot']").content;
const jobId = window.location.pathname.split("/").pop();
const main = document.querySelector("main");
let progressElem = document.querySelector("progress");
const refreshData = () => {
// console.log("Refreshing data...", progressElem.value, progressElem.max);
if (progressElem.value !== progressElem.max) {
fetch(`${webroot}/progress/${jobId}`, {
method: "POST",
})
.then((res) => res.text())
.then((html) => {
main.innerHTML = html;
})
.catch((err) => console.log(err));
setTimeout(refreshData, 1000);
}
progressElem = document.querySelector("progress");
};
refreshData();
window.downloadAll = function () {
// Get all download links
const downloadLinks = document.querySelectorAll("tbody a[download]");
// Trigger download for each link
downloadLinks.forEach((link, index) => {
// We add a delay for each download to prevent them from starting at the same time
setTimeout(() => {
const event = new MouseEvent("click");
link.dispatchEvent(event);
}, index * 300);
});
};
+46
View File
@@ -0,0 +1,46 @@
// Results page logic — monitors conversion progress via polling.
// Loaded as a <script> tag on the results page; uses no imports.
const webroot = document.querySelector<HTMLMetaElement>("meta[name='webroot']")!.content;
const jobId = window.location.pathname.split("/").pop();
const main = document.querySelector("main")!;
let progressElem = document.querySelector<HTMLProgressElement>("progress");
// ── Progress polling ───────────────────────────────────────────────────────────
function refreshData(): void {
// console.log("Refreshing data...", progressElem?.value, progressElem?.max);
if (progressElem && progressElem.value !== progressElem.max) {
fetch(`${webroot}/progress/${jobId}`, { method: "POST" })
.then((res) => res.text())
.then((html) => {
main.innerHTML = html;
})
.catch((err) => console.log(err));
setTimeout(refreshData, 1000);
}
progressElem = document.querySelector<HTMLProgressElement>("progress");
}
refreshData();
// ── Global download-all handler ────────────────────────────────────────────────
declare global {
interface Window {
downloadAll: () => void;
}
}
window.downloadAll = function (): void {
// Get all download links
const downloadLinks = document.querySelectorAll<HTMLAnchorElement>("tbody a[download]");
// Trigger download for each link with staggered delays to prevent browser throttling
downloadLinks.forEach((link, index) => {
setTimeout(() => {
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
link.dispatchEvent(event);
}, index * 300);
});
};
-252
View File
@@ -1,252 +0,0 @@
const webroot = document.querySelector("meta[name='webroot']").content;
const fileInput = document.querySelector('input[type="file"]');
const dropZone = document.getElementById("dropzone");
const convertButton = document.querySelector("input[type='submit']");
const fileNames = [];
let fileType;
let pendingFiles = 0;
let formatSelected = false;
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("dragover");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("dragover");
const files = e.dataTransfer.files;
if (files.length === 0) {
console.warn("No files dropped — likely a URL or unsupported source.");
return;
}
for (const file of files) {
console.log("Handling dropped file:", file.name);
handleFile(file);
}
});
// Extracted handleFile function for reusability in drag-and-drop and file input
function handleFile(file) {
const fileList = document.querySelector("#file-list");
const row = document.createElement("tr");
row.innerHTML = `
<td></td>
<td><progress max="100" class="inline-block h-2 appearance-none overflow-hidden rounded-full border-0 bg-neutral-700 bg-none text-accent-500 accent-accent-500 [&::-moz-progress-bar]:bg-accent-500 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:[background:none] [&[value]::-webkit-progress-value]:bg-accent-500 [&[value]::-webkit-progress-value]:transition-[inline-size]"></progress></td>
<td>${(file.size / 1024).toFixed(2)} kB</td>
<td><button type="button" class="text-accent-500 hover:underline" onclick="deleteRow(this)">Remove</button></td>
`;
row.firstElementChild.textContent = file.name;
if (!fileType) {
fileType = file.name.split(".").pop();
fileInput.setAttribute("accept", `.${fileType}`);
setTitle();
fetch(`${webroot}/conversions`, {
method: "POST",
body: JSON.stringify({ fileType }),
headers: { "Content-Type": "application/json" },
})
.then((res) => res.text())
.then((html) => {
selectContainer.innerHTML = html;
updateSearchBar();
})
.catch(console.error);
}
fileList.appendChild(row);
file.htmlRow = row;
fileNames.push(file.name);
uploadFile(file);
}
const selectContainer = document.querySelector("form .select_container");
const updateSearchBar = () => {
const convertToInput = document.querySelector("input[name='convert_to_search']");
const convertToPopup = document.querySelector(".convert_to_popup");
const convertToGroupElements = document.querySelectorAll(".convert_to_group");
const convertToGroups = {};
const convertToElement = document.querySelector("select[name='convert_to']");
const showMatching = (search) => {
for (const [targets, groupElement] of Object.values(convertToGroups)) {
let matchingTargetsFound = 0;
for (const target of targets) {
if (target.dataset.target.includes(search)) {
matchingTargetsFound++;
target.classList.remove("hidden");
target.classList.add("flex");
} else {
target.classList.add("hidden");
target.classList.remove("flex");
}
}
if (matchingTargetsFound === 0) {
groupElement.classList.add("hidden");
groupElement.classList.remove("flex");
} else {
groupElement.classList.remove("hidden");
groupElement.classList.add("flex");
}
}
};
for (const groupElement of convertToGroupElements) {
const groupName = groupElement.dataset.converter;
const targetElements = groupElement.querySelectorAll(".target");
const targets = Array.from(targetElements);
for (const target of targets) {
target.onmousedown = () => {
convertToElement.value = target.dataset.value;
convertToInput.value = `${target.dataset.target} using ${target.dataset.converter}`;
formatSelected = true;
if (pendingFiles === 0 && fileNames.length > 0) {
convertButton.disabled = false;
}
showMatching("");
};
}
convertToGroups[groupName] = [targets, groupElement];
}
convertToInput.addEventListener("input", (e) => {
showMatching(e.target.value.toLowerCase());
});
convertToInput.addEventListener("search", () => {
// when the user clears the search bar using the 'x' button
convertButton.disabled = true;
formatSelected = false;
});
convertToInput.addEventListener("blur", (e) => {
// Keep the popup open even when clicking on a target button
// for a split second to allow the click to go through
if (e?.relatedTarget?.classList?.contains("target")) {
convertToPopup.classList.add("hidden");
convertToPopup.classList.remove("flex");
return;
}
convertToPopup.classList.add("hidden");
convertToPopup.classList.remove("flex");
});
convertToInput.addEventListener("focus", () => {
convertToPopup.classList.remove("hidden");
convertToPopup.classList.add("flex");
});
};
// Add a 'change' event listener to the file input element
fileInput.addEventListener("change", (e) => {
const files = e.target.files;
for (const file of files) {
handleFile(file);
}
});
const setTitle = () => {
const title = document.querySelector("h1");
title.textContent = `Convert ${fileType ? `.${fileType}` : ""}`;
};
// Add a onclick for the delete button
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const deleteRow = (target) => {
const filename = target.parentElement.parentElement.children[0].textContent;
const row = target.parentElement.parentElement;
row.remove();
// remove from fileNames
const index = fileNames.indexOf(filename);
fileNames.splice(index, 1);
// reset fileInput
fileInput.value = "";
// if fileNames is empty, reset fileType
if (fileNames.length === 0) {
fileType = null;
fileInput.removeAttribute("accept");
convertButton.disabled = true;
setTitle();
}
fetch(`${webroot}/delete`, {
method: "POST",
body: JSON.stringify({ filename: filename }),
headers: {
"Content-Type": "application/json",
},
}).catch((err) => console.log(err));
};
const uploadFile = (file) => {
convertButton.disabled = true;
convertButton.textContent = "Uploading...";
pendingFiles += 1;
const formData = new FormData();
formData.append("file", file, file.name);
let xhr = new XMLHttpRequest();
xhr.open("POST", `${webroot}/upload`, true);
xhr.onload = () => {
let data = JSON.parse(xhr.responseText);
pendingFiles -= 1;
if (pendingFiles === 0) {
if (formatSelected) {
convertButton.disabled = false;
}
convertButton.textContent = "Convert";
}
//Remove the progress bar when upload is done
let progressbar = file.htmlRow.getElementsByTagName("progress");
progressbar[0].parentElement.remove();
console.log(data);
};
xhr.upload.onprogress = (e) => {
let sent = e.loaded;
let total = e.total;
console.log(`upload progress (${file.name}):`, (100 * sent) / total);
let progressbar = file.htmlRow.getElementsByTagName("progress");
progressbar[0].value = (100 * sent) / total;
};
xhr.onerror = (e) => {
console.log(e);
};
xhr.send(formData);
};
const formConvert = document.querySelector(`form[action='${webroot}/convert']`);
formConvert.addEventListener("submit", () => {
const hiddenInput = document.querySelector("input[name='file_names']");
hiddenInput.value = JSON.stringify(fileNames);
});
updateSearchBar();
+331
View File
@@ -0,0 +1,331 @@
// Clientside application logic — migrated from script.js with full TypeScript types.
// Uses @elysiajs/eden for type-safe API calls instead of raw fetch().
import { edenFetch } from "@elysiajs/eden";
import type { App } from "../src";
// ── State ──────────────────────────────────────────────────────────────────────
let fileType: string | null = null;
const fileNames: string[] = [];
let pendingFiles = 0;
let formatSelected = false;
// ── DOM refs (guarded by type assertions) ──────────────────────────────────────
const webroot = document.querySelector<HTMLMetaElement>("meta[name='webroot']")!.content;
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"]')!;
const dropZone = document.getElementById("dropzone") as HTMLDivElement;
const convertButton = document.querySelector<HTMLInputElement>("input[type='submit']")!;
const selectContainer = document.querySelector<HTMLDivElement>("form .select_container")!;
const formConvert = document.querySelector<HTMLFormElement>(`form[action='${webroot}/convert']`)!;
// ── Typed Eden client ──────────────────────────────────────────────────────────
// Created from the app type, not at runtime — elysia is stripped by `bun build -e elysia`.
const api = edenFetch<App>(webroot);
// ── Route helpers (typed via @elysiajs/eden inference from App) ────────────────
async function postConversions(body: { fileType: string | null }): Promise<string> {
const res = await api("/conversions", { method: "POST", body: body as never, headers: {} });
if (res.error) throw new Error(`POST /conversions failed: ${JSON.stringify(res.error)}`);
return res.data as unknown as string;
}
async function postDelete(body: { filename: string }): Promise<unknown> {
const res = await api("/delete", { method: "POST", body: body as never, headers: {} });
if (res.error) throw new Error(`POST /delete failed: ${JSON.stringify(res.error)}`);
return res.data;
}
// ── Drag-and-drop ──────────────────────────────────────────────────────────────
dropZone.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
dropZone.classList.add("dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("dragover");
});
dropZone.addEventListener("drop", async (e: DragEvent) => {
e.preventDefault();
dropZone.classList.remove("dragover");
const files = e.dataTransfer?.files ?? new DataTransfer().files;
if (files.length === 0) {
console.warn("No files dropped — likely a URL or unsupported source.");
return;
}
for (const file of Array.from(files)) {
console.log("Handling dropped file:", file.name);
handleFile(file);
}
});
// ── File handling ──────────────────────────────────────────────────────────────
interface ExtendedFile extends File {
htmlRow: HTMLTableRowElement;
}
function createFileRow(file: File): HTMLTableRowElement {
const row = document.createElement("tr");
// Filename cell (text only, no HTML injection)
const nameCell = document.createElement("td");
nameCell.textContent = file.name;
// Progress cell
const progressCell = document.createElement("td");
const progress = document.createElement("progress");
progress.setAttribute("max", "100");
progress.className =
"inline-block h-2 appearance-none overflow-hidden rounded-full border-0 " +
"bg-neutral-700 bg-none text-accent-500 accent-accent-500";
progressCell.appendChild(progress);
// Size cell
const sizeCell = document.createElement("td");
sizeCell.textContent = `${(file.size / 1024).toFixed(2)} kB`;
// Remove button cell
const removeCell = document.createElement("td");
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "text-accent-500 hover:underline";
removeBtn.textContent = "Remove";
removeBtn.setAttribute("onclick", `deleteRow(this)`);
removeCell.appendChild(removeBtn);
row.appendChild(nameCell);
row.appendChild(progressCell);
row.appendChild(sizeCell);
row.appendChild(removeCell);
return row;
}
function handleFile(file: File): void {
const fileList = document.querySelector<HTMLTableElement>("#file-list")!;
const row = createFileRow(file);
if (!fileType) {
fileType = file.name.split(".").pop() ?? null;
fileInput.setAttribute("accept", `.${fileType}`);
setTitle();
postConversions({ fileType: fileType ?? null })
.then((html) => {
selectContainer.innerHTML = html;
updateSearchBar();
})
.catch(console.error);
}
fileList.appendChild(row);
(file as ExtendedFile).htmlRow = row;
fileNames.push(file.name);
uploadFile(file);
}
// ── Search / format selection ──────────────────────────────────────────────────
const updateSearchBar = (): void => {
const convertToInput = document.querySelector<HTMLInputElement>(
"input[name='convert_to_search']",
)!;
const convertToPopup = document.querySelector<HTMLDivElement>(".convert_to_popup")!;
const convertToGroupElements = document.querySelectorAll<HTMLElement>(".convert_to_group");
const convertToGroups: Record<string, [HTMLElement[], HTMLElement]> = {};
const convertToElement = document.querySelector<HTMLSelectElement>("select[name='convert_to']")!;
const showMatching = (search: string): void => {
for (const [, groupElement] of Object.values(convertToGroups)) {
let matchingTargetsFound = 0;
for (const target of convertToGroups[groupElement.dataset.converter ?? ""]?.[0] ?? []) {
if (target.dataset.target?.includes(search)) {
matchingTargetsFound++;
target.classList.remove("hidden");
target.classList.add("flex");
} else {
target.classList.add("hidden");
target.classList.remove("flex");
}
}
if (matchingTargetsFound === 0) {
groupElement.classList.add("hidden");
groupElement.classList.remove("flex");
} else {
groupElement.classList.remove("hidden");
groupElement.classList.add("flex");
}
}
};
for (const groupElement of Array.from(convertToGroupElements)) {
const groupName = groupElement.dataset.converter ?? "";
const targets: HTMLElement[] = Array.from(
groupElement.querySelectorAll<HTMLElement>(".target"),
);
for (const target of targets) {
target.onmousedown = () => {
if (target.dataset.value && convertToElement) {
convertToElement.value = target.dataset.value;
}
convertToInput.value = `${target.dataset.target ?? ""} using ${target.dataset.converter ?? ""}`;
formatSelected = true;
if (pendingFiles === 0 && fileNames.length > 0) {
convertButton.disabled = false;
}
showMatching("");
};
}
convertToGroups[groupName] = [targets, groupElement];
}
convertToInput.addEventListener("input", (e: Event) => {
showMatching((e.target as HTMLInputElement).value.toLowerCase());
});
convertToInput.addEventListener("search", () => {
// when the user clears the search bar using the 'x' button
convertButton.disabled = true;
formatSelected = false;
});
convertToInput.addEventListener("blur", (e: FocusEvent) => {
// Keep the popup open even when clicking on a target button
// for a split second to allow the click to go through
if ((e.relatedTarget as HTMLElement)?.classList?.contains("target")) {
return;
}
convertToPopup.classList.add("hidden");
convertToPopup.classList.remove("flex");
});
convertToInput.addEventListener("focus", () => {
convertToPopup.classList.remove("hidden");
convertToPopup.classList.add("flex");
});
};
// ── File input change ──────────────────────────────────────────────────────────
fileInput.addEventListener("change", (e: Event) => {
const files = (e.target as HTMLInputElement).files;
if (!files) return;
for (const file of Array.from(files)) {
handleFile(file);
}
});
// ── Page title ─────────────────────────────────────────────────────────────────
const setTitle = (): void => {
const title = document.querySelector("h1");
if (!title) return;
title.textContent = `Convert ${fileType ? `.${fileType}` : ""}`;
};
// ── Row deletion ───────────────────────────────────────────────────────────────
export const deleteRow = (target: HTMLElement): void => {
const filename = target.parentElement?.parentElement?.children?.[0]?.textContent ?? "";
const row = target.parentElement?.parentElement;
row?.remove();
// remove from fileNames
const index = fileNames.indexOf(filename);
if (index !== -1) {
fileNames.splice(index, 1);
}
// reset fileInput
fileInput.value = "";
// if fileNames is empty, reset fileType
if (fileNames.length === 0) {
fileType = null;
fileInput.removeAttribute("accept");
convertButton.disabled = true;
setTitle();
}
postDelete({ filename }).catch(console.log);
};
// Required by the inline `onclick="deleteRow(this)"` handler.
(window as unknown as { deleteRow: typeof deleteRow }).deleteRow = deleteRow;
// ── File upload via XHR ───────────────────────────────────────────────────────
const uploadFile = (file: File): void => {
convertButton.disabled = true;
convertButton.textContent = "Uploading...";
pendingFiles += 1;
const formData = new FormData();
formData.append("file", file, file.name);
const xhr = new XMLHttpRequest();
xhr.open("POST", `${webroot}/upload`, true);
xhr.onload = (): void => {
let data: { message?: string };
try {
data = JSON.parse(xhr.responseText);
} catch {
console.warn("Failed to parse upload response:", xhr.responseText);
return;
}
pendingFiles -= 1;
if (pendingFiles === 0) {
if (formatSelected) {
convertButton.disabled = false;
}
convertButton.textContent = "Convert";
}
// Remove the progress bar when upload is done
const progressbar = (file as ExtendedFile).htmlRow?.getElementsByTagName("progress");
progressbar?.[0]?.parentElement?.remove();
console.log(data);
};
xhr.upload.onprogress = (e: ProgressEvent): void => {
const sent = e.loaded;
const total = e.total;
if (total > 0) {
console.log(`upload progress (${file.name}):`, (100 * sent) / total);
}
const progressbar = (file as ExtendedFile).htmlRow?.getElementsByTagName("progress");
if (progressbar?.[0]) {
progressbar[0].value = total > 0 ? (100 * sent) / total : 0;
}
};
xhr.onerror = (): void => {
console.log("Upload failed");
};
pendingFiles -= 1;
if (pendingFiles === 0 && formatSelected) {
convertButton.disabled = false;
}
convertButton.textContent = "Convert";
xhr.send(formData);
};
// ── Form submission ────────────────────────────────────────────────────────────
formConvert.addEventListener("submit", () => {
const hiddenInput = document.querySelector<HTMLInputElement>("input[name='file_names']")!;
hiddenInput.value = JSON.stringify(fileNames);
});
// ── Init ───────────────────────────────────────────────────────────────────────
updateSearchBar();
+40
View File
@@ -0,0 +1,40 @@
// Typed Elysia app + Eden client for type-safe clientside API access.
// This module composes all routes into a single Elysia instance so that
// @elysiajs/eden can generate fully typed client methods from them.
import { edenFetch } from "@elysiajs/eden";
import { html } from "@elysiajs/html";
import { Elysia } from "elysia";
import { chooseConverter } from "./pages/chooseConverter";
import { convert } from "./pages/convert";
import { deleteFile } from "./pages/deleteFile";
import { download } from "./pages/download";
import { healthcheck } from "./pages/healthcheck";
import { history } from "./pages/history";
import { listConverters } from "./pages/listConverters";
import { results } from "./pages/results";
import { root } from "./pages/root";
import { upload } from "./pages/upload";
import { user } from "./pages/user";
export const app = new Elysia()
.use(html())
.use(user)
.use(root)
.use(upload)
.use(history)
.use(convert)
.use(download)
.use(results)
.use(deleteFile)
.use(listConverters)
.use(chooseConverter)
.use(healthcheck);
// Generate typed client from the Elysia app definition.
export type App = typeof app;
// Re-export the Eden client creator so consumers can build their own client instance.
export type EdenAppClient = ReturnType<typeof edenFetch<App>>;
export const createEdenClient: (baseUrl: string) => EdenAppClient = edenFetch;
+9
View File
@@ -6,6 +6,7 @@ import { Elysia } from "elysia";
import db from "./db/db";
import { Jobs } from "./db/types";
import { createEdenClient, type EdenAppClient } from "./eden";
import { AUTO_DELETE_EVERY_N_HOURS, WEBROOT } from "./helpers/env";
import "./helpers/printVersions";
import { scheduleDelayed, ScheduledTask } from "./helpers/scheduleDelayed";
@@ -25,6 +26,9 @@ import { user } from "./pages/user";
export const uploadsDir = "./data/uploads/";
export const outputDir = "./data/output/";
// Re-export the composed app type so consumers can use @elysiajs/eden for type-safe clients.
export type { App } from "./eden";
// Fix for Elysia issue with Bun, (see https://github.com/oven-sh/bun/issues/12161)
process.getBuiltinModule = require;
@@ -76,6 +80,11 @@ app.listen(process.env.PORT || 3000);
console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`);
// Create an Eden client for type-safe server-side API access and testing.
export const eden = createEdenClient(
`http://${app.server?.hostname ?? "localhost"}:${app.server?.port ?? (process.env.PORT || 3000)}`,
) as EdenAppClient;
const MILLISECONDS_PER_HOUR = 60 * 60 * 1000;
const autoDeleteIntervalMs = AUTO_DELETE_EVERY_N_HOURS * MILLISECONDS_PER_HOUR;
let clearJobsTimer: ScheduledTask;
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { app } from "../../src/eden";
describe("eden app composition", () => {
test("has all route endpoints mounted", async () => {
// GET / — home page (root)
const root = await app.handle(new Request("http://localhost/"));
expect(root.status).toBeLessThan(500);
// POST /conversions — chooseConverter (public endpoint, lists converters)
const conversions = await app.handle(
new Request("http://localhost/conversions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fileType: "txt" }),
}),
);
expect(conversions.status).toBe(200); // public endpoint, returns HTML fragment
});
test("POST /healthcheck returns ok", async () => {
const res = await app.handle(new Request("http://localhost/healthcheck"));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("ok");
});
test("POST /conversions normalizes file types", async () => {
const txt = await app.handle(
new Request("http://localhost/conversions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fileType: "jpg" }),
}),
);
const jpeg = await app.handle(
new Request("http://localhost/conversions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fileType: "jpeg" }),
}),
);
expect(await txt.text()).toBe(await jpeg.text());
});
test("POST /delete requires session (auth macro)", async () => {
const res = await app.handle(
new Request("http://localhost/delete", {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ filename: "test.txt" }),
}),
);
expect(res.status).toBe(401);
});
test("GET /results/:jobId requires session (auth macro)", async () => {
const res = await app.handle(new Request("http://localhost/results/1"));
expect(res.status).toBe(401);
});
});
+1 -1
View File
@@ -3,5 +3,5 @@
"noEmit": true
},
"extends": "./tsconfig.json",
"include": ["src", "tests", "eslint.config.ts", "package.json"]
"include": ["src", "public", "tests", "eslint.config.ts", "package.json"]
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"target": "ES2021",
"moduleResolution": "bundler",
@@ -28,6 +28,6 @@
"esModuleInterop": true
// "noImplicitReturns": true
},
"include": ["src", "package.json"],
"include": ["src", "public", "package.json"],
"exclude": ["dist", "node_modules"]
}