diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36cc29e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM oven/bun:1 AS builder + +WORKDIR /app + +# Copy package files +COPY package.json . +COPY bun.lockb . + +# Install dependencies +RUN bun install --frozen-lockfile + +# Copy source code +COPY . . + +# Build the application +RUN bun build ./src/index.ts --compile --outfile server + +# Production stage +FROM debian:bookworm-slim + +WORKDIR /app + +# Copy only the compiled binary from builder +COPY --from=builder /app/server . + +# Expose the port your app runs on +EXPOSE 3000 + +# Run the binary +CMD ["./server"] \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..bc19a66 Binary files /dev/null and b/bun.lockb differ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..048a584 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,48 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - OTEL_EXPORTER_OTLP_ENDPOINT=http://tracing:4318 + - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf + networks: + - api-network + depends_on: + - tracing + + tracing: + image: jaegertracing/all-in-one:latest + environment: + - COLLECTOR_ZIPKIN_HOST_PORT=:9411 + - COLLECTOR_OTLP_ENABLED=true + ports: + # UI + - "16686:16686" + # Zipkin compatible endpoint + - "9411:9411" + # OTLP gRPC + - "4317:4317" + # OTLP HTTP + - "4318:4318" + # Jaeger gRPC + - "14250:14250" + # Jaeger HTTP + - "14268:14268" + # Admin HTTP + - "14269:14269" + # Agent configs + - "5778:5778" + # Thrift compact + - "6831:6831/udp" + # Thrift binary + - "6832:6832/udp" + networks: + - api-network + +networks: + api-network: + driver: bridge diff --git a/package.json b/package.json index c427aa5..1dc1017 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,13 @@ "version": "1.0.50", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "bun run --watch src/index.ts" + "dev": "bun run --watch src/index.ts", + "build": "bun build --compile --minify-whitespace --minify-syntax --target bun --outfile server ./src/index.ts" }, "dependencies": { + "@elysiajs/opentelemetry": "^1.2.0", + "@elysiajs/server-timing": "^1.2.0", + "@elysiajs/swagger": "^1.2.0", "elysia": "latest" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index 9c1f7a1..5ad5897 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,19 @@ import { Elysia } from "elysia"; +import { swagger } from "@elysiajs/swagger"; +import { opentelemetry } from "@elysiajs/opentelemetry"; +import { serverTiming } from "@elysiajs/server-timing"; -const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); +import { note } from "./routes/note"; +import { user } from "./routes/user"; -console.log( - `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` -); +const app = new Elysia() + .use(opentelemetry()) + .use(swagger()) + .use(serverTiming()) + .onError(({ error, code }) => { + if (code === "NOT_FOUND") return "Not Found :("; + console.error(error); + }) + .use(user) + .use(note) + .listen(3000); diff --git a/src/routes/note.ts b/src/routes/note.ts new file mode 100644 index 0000000..3ba2538 --- /dev/null +++ b/src/routes/note.ts @@ -0,0 +1,91 @@ +import { Elysia, t } from "elysia"; +import { getUserId, userService } from "./user"; + +const memo = t.Object({ + data: t.String(), + author: t.String(), +}); + +type Memo = typeof memo.static; + +class Note { + constructor( + public data: Memo[] = [ + { + data: "Moonhalo", + author: "saltyaom", + }, + ] + ) {} + + add(note: Memo) { + this.data.push(note); + + return this.data; + } + + remove(index: number) { + return this.data.splice(index, 1); + } + + update(index: number, note: Partial) { + return (this.data[index] = { ...this.data[index], ...note }); + } +} + +export const note = new Elysia({ prefix: "/note" }) + .use(userService) + .decorate("note", new Note()) + .model({ + memo: t.Omit(memo, ["author"]), + }) + .onTransform(function log({ body, params, path, request: { method } }) { + console.log(`${method} ${path}`, { + body, + params, + }); + }) + .get("/", ({ note }) => note.data) + .use(getUserId) + .put( + "/", + ({ note, body: { data }, username }) => + note.add({ data, author: username }), + { + body: "memo", + } + ) + .get( + "/:index", + ({ note, params: { index }, error }) => { + return note.data[index] ?? error(404, "Not Found :("); + }, + { + params: t.Object({ + index: t.Number(), + }), + } + ) + .guard({ + params: t.Object({ + index: t.Number(), + }), + }) + .delete("/:index", ({ note, params: { index }, error }) => { + if (index in note.data) return note.remove(index); + + return error(422); + }) + .patch( + "/:index", + ({ note, params: { index }, body: { data }, error, username }) => { + if (index in note.data) + return note.update(index, { data, author: username }); + + return error(422); + }, + { + isSignIn: true, + body: "memo", + } + ); diff --git a/src/routes/user.ts b/src/routes/user.ts new file mode 100644 index 0000000..b486ac4 --- /dev/null +++ b/src/routes/user.ts @@ -0,0 +1,129 @@ +import { Elysia, t } from "elysia"; + +export const userService = new Elysia({ name: "user/service" }) + .state({ + user: {} as Record, + session: {} as Record, + }) + .model({ + signIn: t.Object({ + username: t.String({ minLength: 1 }), + password: t.String({ minLength: 8 }), + }), + session: t.Cookie( + { + token: t.Number(), + }, + { + secrets: "seia", + } + ), + optionalSession: t.Optional(t.Ref("session")), + }) + .macro({ + isSignIn(enabled: boolean) { + if (!enabled) return; + + return { + beforeHandle({ error, cookie: { token }, store: { session } }) { + if (!token.value) + return error(401, { + success: false, + message: "Unauthorized", + }); + + const username = session[token.value as unknown as number]; + + if (!username) + return error(401, { + success: false, + message: "Unauthorized", + }); + }, + }; + }, + }); + +export const getUserId = new Elysia() + .use(userService) + .guard({ + isSignIn: true, + cookie: "session", + }) + .resolve(({ store: { session }, cookie: { token } }) => ({ + username: session[token.value], + })) + .as("plugin"); + +export const user = new Elysia({ prefix: "/user" }) + .use(userService) + .put( + "/sign-up", + async ({ body: { username, password }, store, error }) => { + if (store.user[username]) + return error(400, { + success: false, + message: "User already exists", + }); + + store.user[username] = await Bun.password.hash(password); + + return { + success: true, + message: "User created", + }; + }, + { + body: "signIn", + } + ) + .post( + "/sign-in", + async ({ + store: { user, session }, + error, + body: { username, password }, + cookie: { token }, + }) => { + if ( + !user[username] || + !(await Bun.password.verify(password, user[username])) + ) + return error(400, { + success: false, + message: "Invalid username or password", + }); + + const key = crypto.getRandomValues(new Uint32Array(1))[0]; + session[key] = username; + token.value = key; + + return { + success: true, + message: `Signed in as ${username}`, + }; + }, + { + body: "signIn", + cookie: "optionalSession", + } + ) + .get( + "/sign-out", + ({ cookie: { token } }) => { + token.remove(); + + return { + success: true, + message: "Signed out", + }; + }, + { + cookie: "optionalSession", + } + ) + .use(getUserId) + .get("/profile", ({ username }) => ({ + success: true, + username, + }));