Last active
March 20, 2026 03:33
-
-
Save jeapostrophe/b14377a18ba33ae06c5604b4d5cd1089 to your computer and use it in GitHub Desktop.
Chiron storage server — self-hosted JSON read/write server for the Chiron iOS app
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // chiron-storage.ts — Self-hosted Chiron storage server | |
| // | |
| // A single-file Deno server that reads/writes JSON files from a data/ directory. | |
| // No logic, no SPA serving, no dependencies beyond Deno std. | |
| // | |
| // Usage: | |
| // deno run --allow-net --allow-read=./data --allow-write=./data chiron-storage.ts | |
| // | |
| // Environment variables: | |
| // CHIRON_DATA — path to the data directory (default: ./data) | |
| // PORT — port to listen on (default: 4114) | |
| // | |
| // Endpoints: | |
| // GET /data — list all stored keys (for sync) | |
| // GET /data/:key — read a single JSON file (e.g. /data/profile) | |
| // PUT /data/:key — write a single JSON file (body = JSON) | |
| // GET /data/:category/:ym — read a monthly JSON file (e.g. /data/daily/2026-03) | |
| // PUT /data/:category/:ym — write a monthly JSON file (body = JSON array) | |
| // | |
| // Categories: daily, workouts, weekly | |
| // | |
| // Pair with the Chiron app: Settings -> Use Remote Server -> enter this server's URL | |
| import { ensureDir } from "jsr:@std/fs"; | |
| import { join } from "jsr:@std/path"; | |
| const DATA_DIR = Deno.env.get("CHIRON_DATA") ?? "./data"; | |
| const PORT = Number(Deno.env.get("PORT") ?? 4114); | |
| await ensureDir(DATA_DIR); | |
| for (const sub of ["daily", "workouts", "weekly"]) { | |
| await ensureDir(join(DATA_DIR, sub)); | |
| } | |
| const CORS: Record<string, string> = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "GET, PUT, OPTIONS", | |
| "Access-Control-Allow-Headers": "Content-Type", | |
| }; | |
| function json(data: unknown, status = 200): Response { | |
| return new Response(JSON.stringify(data), { | |
| status, | |
| headers: { "Content-Type": "application/json", ...CORS }, | |
| }); | |
| } | |
| async function readJSON(path: string): Promise<unknown> { | |
| try { | |
| return JSON.parse(await Deno.readTextFile(path)); | |
| } catch (e) { | |
| if (e instanceof Deno.errors.NotFound) return null; | |
| throw e; | |
| } | |
| } | |
| const MONTHLY_CATEGORIES = ["daily", "workouts", "weekly"]; | |
| /** List all stored keys in the data directory. */ | |
| async function listKeys(): Promise<string[]> { | |
| const keys: string[] = []; | |
| // Single files in data/ | |
| for await (const entry of Deno.readDir(DATA_DIR)) { | |
| if (entry.isFile && entry.name.endsWith(".json")) { | |
| keys.push(entry.name.replace(/\.json$/, "")); | |
| } | |
| } | |
| // Monthly files in data/{category}/ | |
| for (const cat of MONTHLY_CATEGORIES) { | |
| const dir = join(DATA_DIR, cat); | |
| try { | |
| for await (const entry of Deno.readDir(dir)) { | |
| if (entry.isFile && entry.name.endsWith(".json")) { | |
| keys.push(`${cat}/${entry.name.replace(/\.json$/, "")}`); | |
| } | |
| } | |
| } catch (e) { | |
| if (!(e instanceof Deno.errors.NotFound)) throw e; | |
| } | |
| } | |
| return keys.sort(); | |
| } | |
| Deno.serve({ port: PORT }, async (req) => { | |
| if (req.method === "OPTIONS") return new Response(null, { headers: CORS }); | |
| const url = new URL(req.url); | |
| const path = url.pathname.replace(/\/+$/, ""); | |
| // List all keys: GET /data | |
| if (path === "/data" && req.method === "GET") { | |
| return json({ keys: await listKeys() }); | |
| } | |
| const parts = path.replace(/^\/data\/?/, "").split("/").filter(Boolean); | |
| if (parts.length === 0) return json({ error: "Not found" }, 404); | |
| // Single file: GET/PUT /data/:key | |
| if (parts.length === 1) { | |
| const file = join(DATA_DIR, `${parts[0]}.json`); | |
| if (req.method === "GET") return json(await readJSON(file)); | |
| if (req.method === "PUT") { | |
| const body = await req.text(); | |
| JSON.parse(body); // validate | |
| await Deno.writeTextFile(file, body); | |
| return json(JSON.parse(body)); | |
| } | |
| } | |
| // Month file: GET/PUT /data/:category/:yearMonth | |
| if (parts.length === 2) { | |
| const [category, ym] = parts; | |
| if (!MONTHLY_CATEGORIES.includes(category)) { | |
| return json({ error: "Invalid category" }, 400); | |
| } | |
| const file = join(DATA_DIR, category, `${ym}.json`); | |
| if (req.method === "GET") return json((await readJSON(file)) ?? []); | |
| if (req.method === "PUT") { | |
| const body = await req.text(); | |
| const arr = JSON.parse(body); | |
| if (!Array.isArray(arr)) return json({ error: "Expected array" }, 400); | |
| await Deno.writeTextFile(file, JSON.stringify(arr, null, 2)); | |
| return json(arr); | |
| } | |
| } | |
| return json({ error: "Not found" }, 404); | |
| }); | |
| console.log(`Chiron storage server listening on :${PORT}`); | |
| console.log(`Data directory: ${DATA_DIR}`); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment