Init backend, frontend and DB schema
This commit is contained in:
16
packages/backend/package.json
Normal file
16
packages/backend/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/schema": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"ulid": "catalog:"
|
||||
}
|
||||
}
|
||||
29
packages/backend/src/app.ts
Normal file
29
packages/backend/src/app.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Effect } from "effect";
|
||||
import { Request } from "./services/request";
|
||||
import { Storage } from "./services/storage";
|
||||
|
||||
const match = (method: string, ...pattern: readonly string[]) => Effect.gen(function* () {
|
||||
|
||||
const req = yield* Request;
|
||||
|
||||
return req.method === method
|
||||
&& req.path.length === pattern.length
|
||||
&& pattern.every((x, i) => x === "*" || x === req.path[i]);
|
||||
});
|
||||
|
||||
export const app = Effect.gen(function* () {
|
||||
|
||||
const req = yield* Request;
|
||||
const storage = yield* Storage;
|
||||
|
||||
if (yield* match("GET", "ping")) {
|
||||
return new Response("pong", {
|
||||
headers: {
|
||||
"Content-Length": "4",
|
||||
"Content-Type": "text/plain;charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
67
packages/backend/src/index.ts
Normal file
67
packages/backend/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { AccessLog, RequestId } from "common";
|
||||
import { DateTime, Effect, Exit, Option as O, pipe } from "effect";
|
||||
import { ulid } from "ulid";
|
||||
import { app } from "./app";
|
||||
import { Request, RequestInterface } from "./services/request";
|
||||
import { StorageFilesystem } from "./services/storage";
|
||||
|
||||
const storage = StorageFilesystem("storage");
|
||||
|
||||
const server = Bun.serve({
|
||||
fetch: (request, server) => {
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const ip = pipe(
|
||||
server.requestIP(request),
|
||||
O.fromNullable,
|
||||
O.map((a) => a.address),
|
||||
);
|
||||
|
||||
const query = Object.freeze(Object.fromEntries(url.searchParams.entries()));
|
||||
|
||||
const requestId = RequestId.make(ulid());
|
||||
const timestamp = Effect.runSync(DateTime.now);
|
||||
|
||||
const accessLog = AccessLog.make({
|
||||
timestamp,
|
||||
requestId,
|
||||
method: request.method,
|
||||
pathname: url.pathname,
|
||||
query,
|
||||
ip: O.getOrNull(ip),
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(accessLog));
|
||||
|
||||
const requestInterface = Object.freeze<RequestInterface>({
|
||||
requestId,
|
||||
method: request.method,
|
||||
path: Object.freeze(url.pathname.slice(1).split("/")),
|
||||
query,
|
||||
headers: Object.freeze(Object.fromEntries(request.headers.entries())),
|
||||
ip,
|
||||
});
|
||||
|
||||
const fiber = pipe(
|
||||
app,
|
||||
Effect.provideService(Request, requestInterface),
|
||||
Effect.provide(storage),
|
||||
Effect.runFork,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fiber.addObserver(Exit.match({
|
||||
onSuccess: resolve,
|
||||
onFailure: reject,
|
||||
}));
|
||||
});
|
||||
},
|
||||
websocket: {
|
||||
message: () => { },
|
||||
},
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.stop();
|
||||
});
|
||||
13
packages/backend/src/services/request.ts
Normal file
13
packages/backend/src/services/request.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { RequestId } from "common";
|
||||
import { Context, Option as O } from "effect";
|
||||
|
||||
export interface RequestInterface {
|
||||
readonly requestId: RequestId;
|
||||
readonly method: string;
|
||||
readonly path: readonly string[];
|
||||
readonly query: { readonly [_: string]: string };
|
||||
readonly headers: { readonly [_: string]: string };
|
||||
readonly ip: O.Option<string>;
|
||||
}
|
||||
|
||||
export class Request extends Context.Tag("Request")<Request, RequestInterface>() { }
|
||||
107
packages/backend/src/services/storage.ts
Normal file
107
packages/backend/src/services/storage.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Sha256 } from "common";
|
||||
import { Context, Data, Effect, Layer } from "effect";
|
||||
import { NoSuchElementException } from "effect/Cause";
|
||||
import { constant, flow } from "effect/Function";
|
||||
import path from "node:path";
|
||||
|
||||
export class StorageError extends Data.TaggedError("StorageError")<{ cause: unknown }> { }
|
||||
|
||||
export interface StorageInterface {
|
||||
/**
|
||||
* @param sha256 SHA-256 of blob to read
|
||||
* @returns Blob, if exists
|
||||
*/
|
||||
readonly read: (sha256: Sha256) => Effect.Effect<Uint8Array, NoSuchElementException | StorageError>;
|
||||
/**
|
||||
* @param data Blob to write
|
||||
* @param sha256 Precomputed SHA-256, trusted to be accurate if provided
|
||||
* @returns Precomputed SHA-256, if provided, internally computed SHA-256
|
||||
* otherwise
|
||||
*/
|
||||
readonly write: (data: Uint8Array, sha256?: Sha256) => Effect.Effect<Sha256, StorageError>;
|
||||
}
|
||||
|
||||
export class Storage extends Context.Tag("Storage")<Storage, StorageInterface>() { }
|
||||
|
||||
/**
|
||||
* Storage implementation that uses the filesystem.
|
||||
*/
|
||||
export const StorageFilesystem = (baseDir: string = ".") => {
|
||||
const sha256toFilePath = (sha256: Sha256): string => {
|
||||
const sha256hex = Buffer.from(sha256).toString("hex");
|
||||
return path.join(
|
||||
baseDir,
|
||||
sha256hex.slice(0, 2),
|
||||
sha256hex.slice(2, 4),
|
||||
sha256hex.slice(4),
|
||||
);
|
||||
}
|
||||
|
||||
return Layer.succeed(Storage, Object.freeze<StorageInterface>({
|
||||
read: (sha256) => {
|
||||
const filePath = sha256toFilePath(sha256);
|
||||
return Effect.gen(function* () {
|
||||
const arrayBuffer = yield* Effect.tryPromise({
|
||||
try: () => Bun.file(filePath).arrayBuffer(),
|
||||
catch: (error) => new StorageError({ cause: error }),
|
||||
});
|
||||
const array = new Uint8Array(arrayBuffer);
|
||||
return array;
|
||||
});
|
||||
},
|
||||
write: (data, sha256) => Effect.gen(function* () {
|
||||
if (sha256 === undefined) {
|
||||
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(data, sha256);
|
||||
}
|
||||
const filePath = sha256toFilePath(sha256);
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Bun.write(filePath, data),
|
||||
catch: (error) => new StorageError({ cause: error }),
|
||||
});
|
||||
return sha256;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
const sha256ToBase64 = (sha256: Sha256): string => {
|
||||
return Buffer.from(sha256).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage that keeps all data in memory.
|
||||
*/
|
||||
export const StorageMemory = Layer.sync(Storage, () => {
|
||||
const map = new Map<string, Uint8Array>();
|
||||
|
||||
return Object.freeze<StorageInterface>({
|
||||
read: flow(
|
||||
sha256ToBase64,
|
||||
_ => map.get(_),
|
||||
Effect.fromNullable,
|
||||
),
|
||||
write: (data, sha256) => Effect.sync(() => {
|
||||
if (sha256 === undefined) {
|
||||
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(data, sha256);
|
||||
}
|
||||
map.set(sha256ToBase64(sha256), data);
|
||||
return sha256;
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Storage that discards any writes silently and always fails to read.
|
||||
*/
|
||||
export const StorageEmpty = Layer.succeed(Storage, Object.freeze<StorageInterface>({
|
||||
read: constant(Effect.fail(new NoSuchElementException())),
|
||||
write: (data, sha256) => Effect.sync(() => {
|
||||
if (sha256 === undefined) {
|
||||
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(data, sha256);
|
||||
}
|
||||
return sha256;
|
||||
}),
|
||||
}));
|
||||
9
packages/backend/tsconfig.json
Normal file
9
packages/backend/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../common" },
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user