diff --git a/README.md b/README.md index 110e639..4dffb13 100644 --- a/README.md +++ b/README.md @@ -102,24 +102,24 @@ The library runs in the browser via esm.sh — no build step required: ```html ``` diff --git a/cli/main.tsx b/cli/main.tsx index 4ebdd2c..0d5ecb5 100644 --- a/cli/main.tsx +++ b/cli/main.tsx @@ -20,23 +20,38 @@ type Shortlinks = Record; class GoService { public constructor( - private readonly kv: Deno.Kv, + private readonly kv: Deno.Kv | undefined, + private readonly staticShortlinks: Shortlinks = {}, private readonly kvNamespace: Deno.KvKey = ["go"], ) {} + public get writable(): boolean { + return this.kv !== undefined; + } + + private requireKv(): Deno.Kv { + if (!this.kv) { + throw new Error( + "Shortlinks are read-only: no Deno KV database is bound to this deployment.", + ); + } + return this.kv; + } + public async add( alias: string, destination: string, force = false, ): Promise { - const shortlinksResult = await this.kv.get(this.kvNamespace); + const kv = this.requireKv(); + const shortlinksResult = await kv.get(this.kvNamespace); const shortlinks = shortlinksResult.value ?? {}; if (!force && shortlinks[alias]) { throw new Error("Shortlink already exists."); } shortlinks[alias] = destination; - const result = await this.kv.atomic() + const result = await kv.atomic() .check(shortlinksResult) .set(this.kvNamespace, shortlinks) .commit(); @@ -46,14 +61,15 @@ class GoService { } public async delete(alias: string): Promise { - const shortlinksResult = await this.kv.get(this.kvNamespace); + const kv = this.requireKv(); + const shortlinksResult = await kv.get(this.kvNamespace); const shortlinks = shortlinksResult.value ?? {}; if (!shortlinks[alias]) { throw new Error("Shortlink does not exist."); } delete shortlinks[alias]; - const result = await this.kv.atomic() + const result = await kv.atomic() .check(shortlinksResult) .set(this.kvNamespace, shortlinks) .commit(); @@ -63,8 +79,11 @@ class GoService { } public async shortlinks(): Promise { + if (!this.kv) { + return this.staticShortlinks; + } const shortlinksResult = await this.kv.get(this.kvNamespace); - return shortlinksResult.value ?? {}; + return { ...this.staticShortlinks, ...(shortlinksResult.value ?? {}) }; } } @@ -85,6 +104,15 @@ function GoRouter(props: { service: GoService }) { if (!isAuthorized(ctx.request.headers)) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } + if (!props.service.writable) { + return Response.json( + { + error: + "Shortlinks are read-only: no Deno KV database is bound.", + }, + { status: 503 }, + ); + } const body = await ctx.request.json(); await props.service.add(body.alias, body.destination, body.force); @@ -100,6 +128,15 @@ function GoRouter(props: { service: GoService }) { if (!isAuthorized(ctx.request.headers)) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } + if (!props.service.writable) { + return Response.json( + { + error: + "Shortlinks are read-only: no Deno KV database is bound.", + }, + { status: 503 }, + ); + } const body = await ctx.request.json(); await props.service.delete(body.alias); @@ -183,6 +220,12 @@ function GoRouter(props: { service: GoService }) { const shortlinks = await props.service.shortlinks(); const url = new URL(ctx.request.url); const destination = go(url, shortlinks); + if (destination.href === url.href) { + return Response.json( + { error: `Shortlink not found: ${url.pathname}` }, + { status: 404 }, + ); + } return new Response( null, { @@ -196,9 +239,39 @@ function GoRouter(props: { service: GoService }) { ); } +const staticShortlinks: Shortlinks = JSON.parse( + Deno.readTextFileSync(new URL("../links.json", import.meta.url)), +); + +async function openKvIfAvailable(): Promise { + try { + return await Deno.openKv(); + } catch (error) { + console.warn( + "Deno KV is unavailable, serving links.json read-only:", + String(error), + ); + return undefined; + } +} + +const kv = await openKvIfAvailable(); + +let serviceRouter: ReturnType | undefined; + +function getServiceRouter(): ReturnType { + if (!serviceRouter) { + serviceRouter = ; + } + return serviceRouter; +} + +export default { + fetch: (request: Request) => getServiceRouter().fetch(request), +} satisfies Deno.ServeDefaultExport; + if (import.meta.main) { - const kv = await Deno.openKv(); - const goService = new GoService(kv); - const router = ; - Deno.serve((request) => router.fetch(request)); + const port = parseInt(Deno.env.get("PORT") ?? "8000"); + console.info(`go.fart.tools development server: http://localhost:${port}/`); + Deno.serve({ port }, (request) => getServiceRouter().fetch(request)); } diff --git a/demo/browser.html b/demo/browser.html index 6e0b7f2..e10af27 100644 --- a/demo/browser.html +++ b/demo/browser.html @@ -4,54 +4,53 @@ go — browser demo diff --git a/links.json b/links.json index 86c2cdb..0317f44 100644 --- a/links.json +++ b/links.json @@ -1,6 +1,10 @@ { "api": "https://api.example.com", + "chat": "https://discord.gg/ZwHaZ63yq3", "docs": "/documentation", "github": "https://github.com", + "in": "https://linkedin.com/company/fartlabs", + "instagram": "https://instagram.com/fartlabs", + "twitter": "https://x.com/fartlabs_org", "yt": "https://youtube.com" }