This commit is contained in:
Dax Raad
2025-05-17 21:31:42 -04:00
parent 96fbc37f01
commit a34d020bc6
26 changed files with 979 additions and 54 deletions

46
js/src/app/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import fs from "fs/promises";
import { AppPath } from "./path";
import { Log } from "../util/log";
import { Context } from "../util/context";
export namespace App {
const log = Log.create({ service: "app" });
export type Info = Awaited<ReturnType<typeof create>>;
const ctx = Context.create<Info>("app");
export async function create(input: { directory: string }) {
log.info("creating");
const dataDir = AppPath.data(input.directory);
await fs.mkdir(dataDir, { recursive: true });
log.info("created", { path: dataDir });
const services = new Map<any, any>();
return {
get root() {
return input.directory;
},
service<T extends () => any>(service: any, init: T) {
if (!services.has(service)) {
log.info("registering service", { name: service });
services.set(service, init());
}
return services.get(service) as ReturnType<T>;
},
};
}
export function service<T extends () => any>(key: any, init: T) {
const app = ctx.use();
return app.service(key, init);
}
export async function use() {
return ctx.use();
}
export const provide = ctx.provide;
}

11
js/src/app/path.ts Normal file
View File

@@ -0,0 +1,11 @@
import path from "path";
export namespace AppPath {
export function data(input: string) {
return path.join(input, ".opencode");
}
export function storage(input: string) {
return path.join(data(input), "storage");
}
}

42
js/src/config/config.ts Normal file
View File

@@ -0,0 +1,42 @@
import path from "node:path";
import { Log } from "../util/log";
import { App } from "../app";
export namespace Config {
const log = Log.create({ service: "config" });
// TODO: this should be zod
export interface Info {
mcp: any; // TODO
lsp: any; // TODO
}
function state() {
return App.service("config", async () => {
const app = await App.use();
let result: Info = {
mcp: {},
lsp: {},
};
for (const file of ["opencode.jsonc", "opencode.json"]) {
const resolved = path.join(app.root, file);
log.info("searching", { path: resolved });
try {
result = await import(path.join(app.root, file)).then(
(mod) => mod.default,
);
log.info("found", { path: resolved });
break;
} catch (e) {
continue;
}
}
log.info("loaded", result);
return result;
});
}
function get() {
return state();
}
}

23
js/src/id/id.ts Normal file
View File

@@ -0,0 +1,23 @@
import { ulid } from "ulid";
import { z } from "zod";
export namespace Identifier {
const prefixes = {
session: "ses",
} as const;
export function create(
prefix: keyof typeof prefixes,
given?: string,
): string {
if (given) {
if (given.startsWith(prefixes[prefix])) return given;
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`);
}
return [prefixes[prefix], ulid()].join("_");
}
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix]);
}
}

13
js/src/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { App } from "./app";
import process from "node:process";
import { RPC } from "./server/server";
import { Session } from "./session/session";
const app = await App.create({
directory: process.cwd(),
});
App.provide(app, async () => {
const session = await Session.create();
const rpc = RPC.listen();
});

34
js/src/server/server.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Log } from "../util/log";
export namespace RPC {
const log = Log.create({ service: "rpc" });
const PORT = 16713;
export function listen(input?: { port?: number }) {
const port = input?.port ?? PORT;
log.info("trying", { port });
try {
const server = Bun.serve({
port,
websocket: {
open() {},
message() {},
},
routes: {
"/ws": (req, server) => {
if (server.upgrade(req)) return;
return new Response("Not a websocket request", { status: 400 });
},
},
});
log.info("listening", { port });
return {
server,
};
} catch (e: any) {
if (e?.code === "EADDRINUSE") {
return listen({ port: port + 1 });
}
throw e;
}
}
}

22
js/src/session/session.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Identifier } from "../id/id";
import { Storage } from "../storage/storage";
import { Log } from "../util/log";
export namespace Session {
const log = Log.create({ service: "session" });
export interface Info {
id: string;
title: string;
}
export async function create() {
const result: Info = {
id: Identifier.create("session"),
title: "New Session - " + new Date().toISOString(),
};
log.info("created", result);
await Storage.write("session/info/" + result.id, JSON.stringify(result));
return result;
}
}

39
js/src/storage/storage.ts Normal file
View File

@@ -0,0 +1,39 @@
import { FileStorage } from "@flystorage/file-storage";
import { LocalStorageAdapter } from "@flystorage/local-fs";
import fs from "fs/promises";
import { Log } from "../util/log";
import { App } from "../app";
import { AppPath } from "../app/path";
export namespace Storage {
const log = Log.create({ service: "storage" });
function state() {
return App.service("storage", async () => {
const app = await App.use();
const storageDir = AppPath.storage(app.root);
await fs.mkdir(storageDir, { recursive: true });
const storage = new FileStorage(new LocalStorageAdapter(storageDir));
await storage.write("test", "test");
log.info("created", { path: storageDir });
return {
storage,
};
});
}
function expose<T extends keyof FileStorage>(key: T) {
const fn = FileStorage.prototype[key];
return async (
...args: Parameters<typeof fn>
): Promise<ReturnType<typeof fn>> => {
const { storage } = await state();
const match = storage[key];
// @ts-ignore
return match.call(storage, ...args);
};
}
export const write = expose("write");
export const read = expose("read");
}

25
js/src/util/context.ts Normal file
View File

@@ -0,0 +1,25 @@
import { AsyncLocalStorage } from "node:async_hooks";
export namespace Context {
export class NotFound extends Error {
constructor(public readonly name: string) {
super(`No context found for ${name}`);
}
}
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new NotFound(name);
}
return result;
},
provide<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
},
};
}
}

27
js/src/util/log.ts Normal file
View File

@@ -0,0 +1,27 @@
export namespace Log {
export function create(tags?: Record<string, any>) {
tags = tags || {};
const result = {
info(message?: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.map(([key, value]) => `${key}=${value}`)
.join(" ");
console.log(prefix, message);
return result;
},
tag(key: string, value: string) {
if (tags) tags[key] = value;
return result;
},
clone() {
return Log.create({ ...tags });
},
};
return result;
}
}