diff --git a/apps/codetrack/.gitignore b/apps/codetrack/.gitignore new file mode 100644 index 0000000..468f82a --- /dev/null +++ b/apps/codetrack/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/apps/codetrack/README.md b/apps/codetrack/README.md new file mode 100644 index 0000000..445afce --- /dev/null +++ b/apps/codetrack/README.md @@ -0,0 +1,15 @@ +# codetrack + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.0.25. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/apps/codetrack/bun.lockb b/apps/codetrack/bun.lockb new file mode 100755 index 0000000..85d0d20 Binary files /dev/null and b/apps/codetrack/bun.lockb differ diff --git a/apps/codetrack/index.ts b/apps/codetrack/index.ts new file mode 100644 index 0000000..0f5819d --- /dev/null +++ b/apps/codetrack/index.ts @@ -0,0 +1,18 @@ +import type { Context } from "koa"; + +export async function createHeartbeats(ctx: Context) { + const results = []; + for (const heartbeat of ctx.request.body) { + console.log("heartbeat", heartbeat); + const resp = await fetch("http://localhost:3000/node", { + method: "PUT", + }); + const data = await resp.json(); + results.push({ + id: data.id, + }); + } + ctx.status = 400; + console.log("results", results); + ctx.body = {}; +} diff --git a/apps/codetrack/manifest.yml b/apps/codetrack/manifest.yml new file mode 100644 index 0000000..ad68492 --- /dev/null +++ b/apps/codetrack/manifest.yml @@ -0,0 +1,19 @@ +name: panorama/codetrack + +depends: +- name: panorama + +code: dist/index.js + +attributes: +- name: heartbeat + type: interface + requires: + - panorama::time/start + +- name: project + type: option + +endpoints: +- route: /api/v1/users/current/heartbeats.bulk + handler: createHeartbeats \ No newline at end of file diff --git a/apps/codetrack/package.json b/apps/codetrack/package.json new file mode 100644 index 0000000..f82c12f --- /dev/null +++ b/apps/codetrack/package.json @@ -0,0 +1,15 @@ +{ + "name": "codetrack", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest", + "@types/koa": "^2.15.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@koa/bodyparser": "^5.1.1" + } +} \ No newline at end of file diff --git a/apps/codetrack/tsconfig.json b/apps/codetrack/tsconfig.json new file mode 100644 index 0000000..dcd8fc5 --- /dev/null +++ b/apps/codetrack/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/apps/std/manifest.yml b/apps/std/manifest.yml new file mode 100644 index 0000000..23f34ce --- /dev/null +++ b/apps/std/manifest.yml @@ -0,0 +1,7 @@ +name: panorama + +attributes: +- name: time/start + type: datetime +- name: time/end + type: datetime diff --git a/biome.json b/biome.json index 4724444..0d449a7 100644 --- a/biome.json +++ b/biome.json @@ -1,18 +1,18 @@ { - "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json", - "organizeImports": { - "enabled": true - }, - "formatter": { - "enabled": true, - "indentWidth": 2, - "indentStyle": "space", - "lineWidth": 100 - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - } + "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json", + "organizeImports": { + "enabled": true + }, + "formatter": { + "enabled": true, + "indentWidth": 2, + "indentStyle": "space", + "lineWidth": 80 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } } diff --git a/packages/panorama-daemon/bun.lockb b/packages/panorama-daemon/bun.lockb index c5fc541..2ba2afa 100755 Binary files a/packages/panorama-daemon/bun.lockb and b/packages/panorama-daemon/bun.lockb differ diff --git a/packages/panorama-daemon/package.json b/packages/panorama-daemon/package.json index e34343b..62ada9a 100644 --- a/packages/panorama-daemon/package.json +++ b/packages/panorama-daemon/package.json @@ -11,12 +11,15 @@ "typescript": "^5.0.0" }, "dependencies": { + "@koa/bodyparser": "^5.1.1", "@koa/router": "^12.0.1", "koa": "^2.15.3", "koa-json": "^2.0.2", "reflect-metadata": "^0.2.2", "sqlite3": "^5.1.7", "typeorm": "^0.3.20", - "uuidv7": "^1.0.1" + "uuidv7": "^1.0.1", + "yaml": "^2.4.5", + "zod": "^3.23.8" } } \ No newline at end of file diff --git a/packages/panorama-daemon/src/apps/index.ts b/packages/panorama-daemon/src/apps/index.ts new file mode 100644 index 0000000..ea762e2 --- /dev/null +++ b/packages/panorama-daemon/src/apps/index.ts @@ -0,0 +1,78 @@ +import { join } from "node:path"; +import { parse } from "yaml"; +import { readFile } from "node:fs/promises"; +import Router from "@koa/router"; +import { manifestSchema, type Manifest } from "./manifest"; +import { dataSource } from "../db"; +import { App, Attribute } from "../models"; + +export interface CustomApp extends Manifest { + sanitizedName: string; + router: Router; +} + +export function sanitizeName(name: string): string { + return name.replaceAll("/", "__"); +} + +export async function loadApps(): Promise> { + const apps = new Map(); + const paths = ["/Users/michael/Projects/panorama/apps/codetrack"]; + + for (const path of paths) { + const app = await loadApp(path); + apps.set(app.name, app); + } + return apps; +} + +export async function loadApp(path: string): Promise { + const manifestPath = join(path, "manifest.yml"); + const manifestRaw = parse(await readFile(manifestPath, "utf-8")); + const manifest = manifestSchema.parse(manifestRaw); + const sanitizedName = sanitizeName(manifest.name); + + // load code + const codePath = join(path, manifest.code); + const codeModule = await import(codePath); + + // wire up routes + const router = new Router(); + for (const endpoint of manifest.endpoints || []) { + const func = codeModule[endpoint.handler]; + router.all(endpoint.route, func); + } + + await dataSource.transaction(async (em) => { + const app = await em + .createQueryBuilder() + .select("app") + .from(App, "app") + .where("app.id = :id", { id: sanitizedName }) + .getOne(); + let appId = app?.id; + + console.log("app id", appId); + + if (!appId) { + const result = await em.getRepository(App).insert({ + id: sanitizedName, + name: manifest.name, + }); + + appId = result.identifiers[0].id; + } + + // register all the attributes + for (const attribute of manifest.attributes || []) { + await em + .getRepository(Attribute) + .upsert({ appId, name: attribute.name, type: attribute.type }, [ + "appId", + "name", + ]); + } + }); + + return { ...manifest, sanitizedName, router }; +} diff --git a/packages/panorama-daemon/src/apps/manifest.ts b/packages/panorama-daemon/src/apps/manifest.ts new file mode 100644 index 0000000..3befae8 --- /dev/null +++ b/packages/panorama-daemon/src/apps/manifest.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const manifestSchema = z.object({ + name: z.string(), + code: z.string(), + + attributes: z + .array( + z.object({ + name: z.string(), + type: z.string(), + }), + ) + .optional(), + + endpoints: z + .array( + z.object({ + route: z.string(), + handler: z.string(), + }), + ) + .optional(), +}); + +export type Manifest = z.infer; diff --git a/packages/panorama-daemon/src/db.ts b/packages/panorama-daemon/src/db.ts index cce3e65..714b257 100644 --- a/packages/panorama-daemon/src/db.ts +++ b/packages/panorama-daemon/src/db.ts @@ -7,7 +7,7 @@ const AppDataSource = new DataSource({ entities: [PNode, App, Attribute, NodeHasAttribute], synchronize: true, - logging: true, + // logging: true, migrationsTableName: "migrations", migrations: ["migrations/*"], diff --git a/packages/panorama-daemon/src/index.ts b/packages/panorama-daemon/src/index.ts index 274f8b8..a69cbd1 100644 --- a/packages/panorama-daemon/src/index.ts +++ b/packages/panorama-daemon/src/index.ts @@ -3,12 +3,27 @@ import json from "koa-json"; import Router from "@koa/router"; import { dataSource } from "./db"; import { PNode } from "./models"; -import { uuidv7 } from "uuidv7"; +import { nodeRouter } from "./routes/node"; +import { loadApps, sanitizeName } from "./apps"; +import { bodyParser } from "@koa/bodyparser"; const app = new Koa(); const router = new Router(); app.use(json()); +app.use(bodyParser()); + +app.use(async (ctx, next) => { + console.log("Got a request from %s for %s", ctx.request.ip, ctx.method, ctx.path); + return next(); +}); + +const apps = await loadApps(); +for (const [name, customApp] of apps.entries()) { + console.log("name", name); + const sanitizedName = sanitizeName(name); + router.use(`/apps/${sanitizedName}`, customApp.router.routes()); +} router.get("/", async (ctx: Context) => { const nodeRepo = dataSource.getRepository(PNode); @@ -16,27 +31,12 @@ router.get("/", async (ctx: Context) => { ctx.body = { nodes: numNodes }; }); -router.put("/node", async (ctx) => { - const id = uuidv7(); - await dataSource.getRepository(PNode).insert({ id }); - ctx.body = { id }; -}); +router.use("/node", nodeRouter.routes()); -router.get("/node/:id", async (ctx) => { - const query = dataSource - // .getRepository(Node) - .createQueryBuilder() - .select("node") - .from(PNode, "node") - .where("node.id = :id", { id: ctx.params.id }); - const node = await query.getOne(); - if (node === null) { - ctx.status = 404; - ctx.body = { error: "Not found" }; - return; - } - ctx.body = { node }; -}); +console.log( + "routes", + router.stack.map((i) => i.path), +); app.use(router.routes()).use(router.allowedMethods()); diff --git a/packages/panorama-daemon/src/models.ts b/packages/panorama-daemon/src/models.ts index 9eff94e..d14eb59 100644 --- a/packages/panorama-daemon/src/models.ts +++ b/packages/panorama-daemon/src/models.ts @@ -1,6 +1,6 @@ import { Column, Entity, ManyToOne, OneToMany, PrimaryColumn } from "typeorm"; -@Entity() +@Entity({ name: "node" }) export class PNode { @PrimaryColumn() id!: string; @@ -11,6 +11,8 @@ export class App { @PrimaryColumn() id!: string; + name!: string; + @OneToMany( () => Attribute, (attr) => attr.appId, @@ -30,6 +32,7 @@ export class Attribute { @PrimaryColumn() name!: string; + @Column() type!: string; } diff --git a/packages/panorama-daemon/src/routes/node.ts b/packages/panorama-daemon/src/routes/node.ts new file mode 100644 index 0000000..429261e --- /dev/null +++ b/packages/panorama-daemon/src/routes/node.ts @@ -0,0 +1,33 @@ +import Router from "@koa/router"; +import { PNode } from "../models"; +import { uuidv7 } from "uuidv7"; +import { dataSource } from "../db"; + +export const nodeRouter = new Router(); + +nodeRouter.put("/", async (ctx) => { + const id = uuidv7(); + const body = ctx.request.body; + await dataSource.getRepository(PNode).insert({ id }); + ctx.body = { id }; +}); + +nodeRouter.post("/query", async (ctx) => {}); + +nodeRouter.get("/:id", async (ctx) => { + const query = dataSource + .createQueryBuilder() + .select("node") + .from(PNode, "node") + .where("node.id = :id", { id: ctx.params.id }); + + const node = await query.getOne(); + if (node === null) { + ctx.status = 404; + ctx.body = { error: "Not found" }; + return; + } + ctx.body = { node }; +}); + +nodeRouter.post("/:id", async (ctx) => {});