init
This commit is contained in:
commit
519b43e9f8
29 changed files with 1043 additions and 0 deletions
47
src/db/index.ts
Normal file
47
src/db/index.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { Database } from 'bun:sqlite';
|
||||
import { env } from 'bun';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
||||
import { createWrappedTimer } from '../wrapped-timer';
|
||||
|
||||
function initDb() {
|
||||
global.db.exec('PRAGMA journal_mode = delete');
|
||||
global.db.exec('PRAGMA journal_mode = WAL');
|
||||
global.db.exec('PRAGMA synchronous = NORMAL');
|
||||
global.db.exec('PRAGMA auto_vacuum = INCREMENTAL');
|
||||
global.db.exec('PRAGMA wal_autocheckpoint = 1000');
|
||||
}
|
||||
|
||||
function incrementalVacuumDb() {
|
||||
global.db.exec('PRAGMA incremental_vacuum');
|
||||
}
|
||||
|
||||
function optimizeDb() {
|
||||
global.db.exec('PRAGMA optimize');
|
||||
}
|
||||
|
||||
function vacuumDb() {
|
||||
global.db.exec('vacuum');
|
||||
}
|
||||
|
||||
if (global.db === undefined || global.drizzleDB === undefined) {
|
||||
global.db = new Database(`${env.SQLITE_DB_PATH}/${env.SQLITE_DB_NAME}`, { create: true });
|
||||
initDb();
|
||||
global.drizzleDB = drizzle(global.db);
|
||||
migrate(global.drizzleDB, { migrationsFolder: './drizzle' });
|
||||
}
|
||||
|
||||
const incrementalVacuumInterval = 1000 * 30; // 30 seconds
|
||||
const incrementalVacuumRunnable = createWrappedTimer('databaseIncrementalVacuum', incrementalVacuumDb, incrementalVacuumInterval);
|
||||
|
||||
setTimeout(incrementalVacuumRunnable.callback, 0);
|
||||
const optimizeInterval = 1000 * 60 * 30; // 30 minutes
|
||||
const optimizeRunnable = createWrappedTimer('databaseOptimize', optimizeDb, optimizeInterval);
|
||||
setTimeout(optimizeRunnable.callback, 0);
|
||||
|
||||
const vacuumInterval = 1000 * 60 * 60 * 24; // 24 hours
|
||||
const vacuumRunnable = createWrappedTimer('databaseVacuum', vacuumDb, vacuumInterval);
|
||||
setTimeout(vacuumRunnable.callback, 0);
|
||||
|
||||
export const db = global.db;
|
||||
export const drizzleDB = global.drizzleDB;
|
10
src/db/migrate.ts
Normal file
10
src/db/migrate.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Database } from 'bun:sqlite';
|
||||
import { env } from 'bun';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
||||
|
||||
const sqlite = new Database(`${env.SQLITE_DB_PATH}/${env.SQLITE_DB_NAME}`, { create: true });
|
||||
|
||||
const db = drizzle(sqlite);
|
||||
|
||||
migrate(db, { migrationsFolder: './drizzle' });
|
25
src/db/schema.ts
Normal file
25
src/db/schema.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { sql, type InferSelectModel } from 'drizzle-orm';
|
||||
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
const createdAt = integer('created_at', { mode: 'timestamp_ms' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch('subsec')`)
|
||||
.$defaultFn(() => new Date());
|
||||
const updatedAt = integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch('subsec')`)
|
||||
.$onUpdateFn(() => new Date());
|
||||
|
||||
export const entry = sqliteTable(
|
||||
'entry',
|
||||
{
|
||||
id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull().unique(),
|
||||
href: text('href').notNull(),
|
||||
finished: integer('finished', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}
|
||||
);
|
||||
|
||||
type SelectEntry = InferSelectModel<typeof entry>;
|
23
src/index.css
Normal file
23
src/index.css
Normal file
|
@ -0,0 +1,23 @@
|
|||
@import 'tailwindcss' source('.');
|
||||
|
||||
@theme {
|
||||
--breakout-size: calc((var(--breakpoint-xl) - var(--breakpoint-lg)) / 2);
|
||||
--ultrawide-val: minmax(calc(var(--spacing) * 4), 1fr);
|
||||
--breakout-val: minmax(0, var(--breakout-size));
|
||||
--content-val: min(100% - calc(var(--spacing) * 8), var(--breakpoint-lg));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-template-columns:
|
||||
[ultrawide-start] var(--ultrawide-val) [breakout-start] var(--breakout-val) [content-start] var(--content-val) [content-end] var(--breakout-val) [breakout-end] var(--ultrawide-val) [ultrawide-end];
|
||||
}
|
||||
|
||||
.content>* {
|
||||
grid-column: content;
|
||||
}
|
||||
}
|
9
src/index.d.ts
vendored
Normal file
9
src/index.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
import type { Database } from 'bun:sqlite';
|
||||
import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
|
||||
|
||||
declare global {
|
||||
var db: Database;
|
||||
var drizzleDB: BunSQLiteDatabase;
|
||||
var performanceObserver: PerformanceObserver;
|
||||
var wrappedTimers: Map<string, import('../wrapped-timer').WrappedTimer>;
|
||||
}
|
21
src/index.html
Normal file
21
src/index.html
Normal file
File diff suppressed because one or more lines are too long
153
src/server.ts
Normal file
153
src/server.ts
Normal file
|
@ -0,0 +1,153 @@
|
|||
import { env } from "bun";
|
||||
import homepage from './index.html';
|
||||
import robotsTxt from './static/robots.txt';
|
||||
import sitemapTxt from './static/sitemap.txt';
|
||||
// @ts-ignore ts2307
|
||||
import icon from './static/favicon.png' with { type: 'file' };
|
||||
import { entry } from "./db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
const favicon = await Bun.file(icon).bytes();
|
||||
|
||||
const development = env.NODE_ENV !== 'production';
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
'/': homepage,
|
||||
'/robots.txt': new Response(robotsTxt, {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
}),
|
||||
'/sitemap.txt': new Response(sitemapTxt, {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
}),
|
||||
'/favicon.ico': new Response(favicon, {
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
}),
|
||||
'/health': new Response('OK'),
|
||||
'/api/entries': {
|
||||
async GET(req) {
|
||||
if (!isAuthenticated(req)) return unauthorizedResp();
|
||||
|
||||
const entries = await drizzleDB.select().from(entry).orderBy(desc(entry.updatedAt));
|
||||
|
||||
return new Response(JSON.stringify(entries), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
},
|
||||
async POST(req) {
|
||||
if (!isAuthenticated(req)) return unauthorizedResp();
|
||||
|
||||
const body = await getEntryFromReq(req);
|
||||
if (!body) return new Response('Invalid data', { status: 400 });
|
||||
|
||||
const result = await drizzleDB.select({ id: entry.id }).from(entry).where(eq(entry.name, body.name));
|
||||
|
||||
let created = true;
|
||||
|
||||
if (result.length === 0) {
|
||||
await drizzleDB.insert(entry).values(body).execute();
|
||||
} else if (result.length === 1) {
|
||||
const row = result[0];
|
||||
created = false;
|
||||
await drizzleDB.update(entry).set(body).where(eq(entry.id, row.id)).execute();
|
||||
} else {
|
||||
return new Response('Invalid data, multiple matches?', { status: 400 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ created }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
},
|
||||
},
|
||||
'/api/entries/:id': {
|
||||
async GET(req) {
|
||||
if (!isAuthenticated(req)) return unauthorizedResp();
|
||||
|
||||
const id = Number.parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) return new Response('Invalid id', { status: 400 });
|
||||
|
||||
const result = await drizzleDB.select().from(entry).where(eq(entry.id, id));
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
},
|
||||
async PUT(req) {
|
||||
if (!isAuthenticated(req)) return unauthorizedResp();
|
||||
|
||||
const id = Number.parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) return new Response('Invalid id', { status: 400 });
|
||||
|
||||
const body = await getEntryFromReq(req);
|
||||
if (!body) return new Response('Invalid data', { status: 400 });
|
||||
|
||||
await drizzleDB.update(entry).set(body).where(eq(entry.id, id)).execute();
|
||||
|
||||
let created = false;
|
||||
return new Response(JSON.stringify({ created: false }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
},
|
||||
async DELETE(req) {
|
||||
if (!isAuthenticated(req)) return unauthorizedResp();
|
||||
|
||||
const id = Number.parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) return new Response('Invalid id', { status: 400 });
|
||||
|
||||
await drizzleDB.delete(entry).where(eq(entry.id, id)).execute();
|
||||
|
||||
return new Response('OK');
|
||||
},
|
||||
},
|
||||
'/api/entries/:id/check/:finished': {
|
||||
async PUT(req) {
|
||||
if (!isAuthenticated(req)) return unauthorizedResp();
|
||||
|
||||
const id = Number.parseInt(req.params.id, 10);
|
||||
if (Number.isNaN(id)) return new Response('Invalid id', { status: 400 });
|
||||
const finished = req.params.finished === 'true';
|
||||
|
||||
await drizzleDB
|
||||
.update(entry)
|
||||
.set({ finished: finished })
|
||||
.where(eq(entry.id, id))
|
||||
.execute();
|
||||
|
||||
return new Response('OK');
|
||||
},
|
||||
},
|
||||
},
|
||||
development,
|
||||
reusePort: true,
|
||||
port: env.PORT || 3000,
|
||||
// async fetch(req, server) {
|
||||
// return new Response("Not found", { status: 404 });
|
||||
// },
|
||||
});
|
||||
|
||||
async function getEntryFromReq(req: Request) {
|
||||
const json = await req.json();
|
||||
const body = json as { name: string, href: string }
|
||||
if (!body.name || !body.href || typeof body.name !== 'string' || typeof body.href !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trim and lowercase the name to ensure consistency and uniqueness
|
||||
body.name = body.name.trim().toLocaleLowerCase();
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function isAuthenticated(req: Request) {
|
||||
const bearer = req.headers.get('Bearer');
|
||||
if (!bearer) return false;
|
||||
|
||||
return bearer === env.BEARER_TOKEN;
|
||||
}
|
||||
|
||||
const unauthorizedHeaders = new Headers({
|
||||
'WWW-Authenticate': `Bearer realm='sign', error="invalid_request"`
|
||||
});
|
||||
|
||||
function unauthorizedResp() {
|
||||
return new Response('Unauthorized', { status: 401, headers: unauthorizedHeaders });
|
||||
}
|
BIN
src/static/favicon.png
Normal file
BIN
src/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
10
src/static/manifest.json
Normal file
10
src/static/manifest.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "Progress tracker api by skaarup.dev",
|
||||
"short_name": "progress-tracker-api",
|
||||
"display": "browser",
|
||||
"background_color": "#141141",
|
||||
"theme_color": "#371D85",
|
||||
"description": "Progress tracker api by skaarup.dev",
|
||||
"icons": [],
|
||||
"related_applications": []
|
||||
}
|
4
src/static/robots.txt
Normal file
4
src/static/robots.txt
Normal file
|
@ -0,0 +1,4 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://pt-api.skaarup.dev/sitemap.txt
|
1
src/static/sitemap.txt
Normal file
1
src/static/sitemap.txt
Normal file
|
@ -0,0 +1 @@
|
|||
https://pt-api.skaarup.dev/
|
78
src/wrapped-timer.ts
Normal file
78
src/wrapped-timer.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
if (global.wrappedTimers === undefined) {
|
||||
global.wrappedTimers = new Map();
|
||||
}
|
||||
|
||||
type WrappedTimer = { timer: Timer | undefined; running: boolean; };
|
||||
type WrappedTimerResult = { callback: ReturnType<typeof getCallbackHandler>; wrappedTimer: WrappedTimer; };
|
||||
|
||||
/**
|
||||
* Create a callback handler for a wrapped timer that prevents the callback from running if it is already running
|
||||
* and prevents the callback from running if it is already running.
|
||||
* @template TArgs
|
||||
* @param {string} key unique identifier for the timer
|
||||
* @param {(...args: Array<TArgs>) => (Promise<void> | void)} callback function to run
|
||||
* @param {Array<TArgs>} args arguments to pass to the callback
|
||||
* @returns {() => Promise<void>}
|
||||
*/
|
||||
function getCallbackHandler<TArgs>(key: string, callback: (...args: Array<TArgs>) => (Promise<void> | void), ...args: Array<TArgs>): () => Promise<void> {
|
||||
return async () => {
|
||||
const thisTimer = global.wrappedTimers.get(key);
|
||||
if (thisTimer === undefined) {
|
||||
console.debug(`Wrapped timer ${key} does not exist`);
|
||||
return;
|
||||
}
|
||||
if (thisTimer.running) {
|
||||
console.debug(`Wrapped timer ${key} is already running`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
thisTimer.running = true;
|
||||
await callback(...args);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
thisTimer.running = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wrapped timer aka interval
|
||||
* @template TArgs
|
||||
* @param {string} key unique identifier for the timer
|
||||
* @param {number} interval in milliseconds
|
||||
* @param {(...args: Array<TArgs>) => (Promise<void> | void)} callback function to run
|
||||
* @param {Array<TArgs>} args arguments to pass to the callback
|
||||
* @returns {WrappedTimerResult}
|
||||
*/
|
||||
export function createWrappedTimer<TArgs>(key: string, callback: (...args: Array<TArgs>) => (Promise<void> | void), interval: number, ...args: Array<TArgs>): WrappedTimerResult {
|
||||
const thisTimer = global.wrappedTimers.get(key);
|
||||
const handler = getCallbackHandler(key, callback, ...args);
|
||||
|
||||
if (thisTimer !== undefined) {
|
||||
console.debug(`Wrapped timer ${key} already exists, clearing timer`);
|
||||
clearInterval(thisTimer.timer);
|
||||
console.debug(`Wrapped timer ${key} set with interval ${interval}ms`);
|
||||
thisTimer.timer = setInterval(handler, interval);
|
||||
|
||||
return {
|
||||
callback: handler,
|
||||
wrappedTimer: thisTimer,
|
||||
};
|
||||
}
|
||||
|
||||
console.debug(`Wrapped timer ${key} created with interval ${interval}ms`);
|
||||
|
||||
const wrappedTimer: WrappedTimer = {
|
||||
timer: setInterval(handler, interval),
|
||||
running: false,
|
||||
};
|
||||
|
||||
global.wrappedTimers.set(key, wrappedTimer);
|
||||
|
||||
return {
|
||||
callback: handler,
|
||||
wrappedTimer,
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue