Port to elysia, tailwind (no effect)

This commit is contained in:
2024-11-17 23:08:23 +01:00
parent e8aaa328e6
commit 63de1a3b02
53 changed files with 1942 additions and 2363 deletions

View File

@@ -6,10 +6,11 @@ end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
tab_width = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
indent_style = space
tab_width = 2

6
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"editorconfig.editorconfig",
],
}

View File

@@ -1,11 +1,11 @@
{
"name": "music-repo",
"license": "UNLICENSED",
"private": true,
"type": "module",
"license": "UNLICENSED",
"scripts": {
"start:backend": "bun run packages/backend/src/index.ts",
"start:frontend": "pnpm --filter frontend exec vite --open"
"backend:dev": "bun run --watch packages/backend/src/app.ts",
"frontend:dev": "pnpm --filter frontend exec vite --open"
},
"devDependencies": {
"typescript": "catalog:"

View File

@@ -1,18 +1,17 @@
{
"name": "backend",
"license": "UNLICENSED",
"private": true,
"type": "module",
"license": "UNLICENSED",
"devDependencies": {
"typescript": "catalog:",
"@types/bun": "latest"
"@types/bun": "catalog:",
"typescript": "catalog:"
},
"dependencies": {
"@effect/schema": "catalog:",
"@elysiajs/cors": "catalog:",
"common": "workspace:^",
"effect": "catalog:",
"fast-check": "catalog:",
"make-api": "workspace:^",
"ulid": "catalog:"
"elysia": "catalog:",
"kysely": "catalog:",
"kysely-bun-sqlite": "catalog:"
}
}

View File

@@ -1,21 +0,0 @@
import { Data } from "effect";
export class RequestError extends Data.TaggedError("RequestError")<{
readonly status: number,
readonly body?: string,
}> {
get response(): Response {
if (this.body) {
const body = new TextEncoder().encode(this.body);
return new Response(body, {
headers: {
"Content-Length": body.byteLength.toString(),
"Content-Type": "text/plain; charset=utf-8",
},
status: this.status,
});
} else {
return new Response(null, { status: this.status });
}
}
}

View File

@@ -1,292 +1,228 @@
import { Schema as S } from "@effect/schema";
import { SessionId } from "common";
import { CreatePiece, GetPieces, UpdatePiece, DeletePiece, Login, Logout, Me } from "common/api";
import { AccessLog, SessionData } from "common/db";
import { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect";
import { constant } from "effect/Function";
import { Api } from "make-api";
import { brotliCompress } from "node:zlib";
import { RequestError } from "./RequestError";
import { Database } from "./services/database";
import { Request } from "./services/request";
import cors from "@elysiajs/cors";
import { PieceId, RequestId, SessionId } from "common";
import * as Function from "common/Function";
import { Elysia, error, t } from "elysia";
import { sql } from "kysely";
import { generateSessionId, initDatabase } from "./database";
const match = (api: Api.Api.Any) => Effect.gen(function* () {
const tbranded = <T>() => t.Transform(t.String())
.Decode(Function.unsafeCoerce<string, T>)
.Encode(Function.unsafeCoerce<T, string>);
const req = yield* Request;
const app = new Elysia()
.use(cors({ origin: "localhost:5173" }))
.decorate("db", await initDatabase())
.resolve(async ({ db, cookie }) => {
await db
.deleteFrom("Session")
.where(sql`datetime()`, ">=", "expiresAt")
.execute();
return req.method === api.method
&& req.path.length === api.props.route.length
&& api.props.route.every((token, i) => pipe(
Match.value(token),
Match.tags({
Literal: ({ literal }) => req.path[i] === literal,
Param: ({ schema }) => E.isRight(S.decodeUnknownEither(schema)(req.path[i])),
}),
Match.exhaustive,
));
});
const COOKIE_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*=\s*([!#$%&'()*+./:<=>?@[\]^_`{|}~a-z0-9-]+)/i;
const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i;
const acceptsBrotli = Effect.gen(function* () {
const req = yield* Request;
const acceptEncoding = req.headers["accept-encoding"];
/* NOTE According to RFC9110, section 12.5.3:
*
* If no Accept-Encoding header field is in the request, any content coding
* is considered acceptable by the user agent.
*
* This means that we could encode the response as brotli in the absence of
* Accept-Encoding header, but we'd rather be nice and assume the user agent
* does not support any encoding.
*/
if (!acceptEncoding) {
return false;
}
const codings = acceptEncoding
.split(/\s*,\s*/)
.map((token) => token.match(WEIGHTED_TOKEN_REGEX))
.filter((match) => match !== null)
.map((match) => ({
coding: match[1],
...(match[2] ? { quality: Number.parseFloat(match[2]) } : undefined),
}));
return codings.some(({ coding, quality = 1 }) => coding === "br" && quality > 0);
});
const getCookie = (name: string): Effect.Effect<O.Option<string>, never, Request> => Effect.gen(function* () {
const req = yield* Request;
const cookieString = req.headers["cookie"];
if (!cookieString) {
return O.none();
}
const cookieValue = cookieString
.split(/\s*;\s*/)
.map((cookie) => cookie.match(COOKIE_REGEX))
.filter((match): match is RegExpMatchArray => match !== null && match[1] === name)
.map((match) => match[2])[0];
return O.fromNullable(cookieValue);
});
const requestJson = <A, I, R>(schema: S.Schema<A, I, R>) => {
const decoder = S.decodeUnknown(schema);
return pipe(
Request,
Effect.tap((req) => req.headers["content-type"] === "application/json"
? Effect.void
: new RequestError({ status: 415 })
),
Effect.flatMap((req) => req.json),
Effect.flatMap(decoder),
Effect.mapError((error) => new RequestError({ status: 400, body: error.message })),
);
};
export const app = pipe(
Effect.gen(function* () {
const db = yield* Database;
const req = yield* Request;
const timestamp = yield* DateTime.now;
const accessLog = AccessLog.make({
timestamp,
requestId: req.requestId,
method: req.method,
pathname: req.pathname,
query: req.query,
ip: O.getOrNull(req.ip),
});
yield* db.createAccessLog(accessLog);
console.log(`${DateTime.toDateUtc(accessLog.timestamp).toISOString()} ${accessLog.requestId} ${accessLog.method} ${accessLog.pathname}?${new URLSearchParams(accessLog.query).toString()} ${accessLog.ip}`);
if (yield* match(CreatePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(GetPieces)) {
return new Response(null, { status: 501 });
} else if (yield* match(UpdatePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(DeletePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(Login)) {
const body = yield* requestJson(Login.props.request.schema);
const user = yield* pipe(
db.getUserByUsername(body.username),
Effect.catchTag("NoSuchElementException", () => new RequestError({ status: 401, body: "Invalid username or password" })),
);
const valid = yield* Effect.promise(() => Bun.password.verify(body.password, user.password));
if (!valid) {
return yield* new RequestError({ status: 401, body: "Invalid username or password" });
}
const sessionData = SessionData.make({ userId: user.userId });
const sessionId = yield* db.createSession(sessionData);
const responseData = Login.props.response[200].schema.make({
userId: user.userId,
username: user.username,
admin: user.admin,
});
const responseJson = yield* pipe(
responseData,
S.encode(Login.props.response[200].schema),
Effect.map(JSON.stringify),
);
const responseArray = new TextEncoder().encode(responseJson);
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`;
if (yield* acceptsBrotli) {
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
brotliCompress(responseArray, (error, result) => {
if (error !== null) {
callback(Effect.die(error));
} else {
callback(Effect.succeed(result));
}
});
});
return new Response(responseBrotli, {
headers: {
"Content-Encoding": "br",
"Content-Length": responseArray.byteLength.toString(),
"Content-Type": "application/json",
"Set-Cookie": setCookie,
},
});
} else {
return new Response(responseArray, {
headers: {
"Content-Length": responseArray.byteLength.toString(),
"Content-Type": "application/json",
"Set-Cookie": setCookie,
}
});
}
} else if (yield* match(Logout)) {
const sessionId = yield* pipe(
getCookie("sessionId"),
Effect.flatten,
Effect.flatMap(S.decodeUnknown(SessionId)),
Effect.catchAll(constant(Effect.succeed(null))),
);
if (sessionId !== null) {
db.deleteSession(sessionId);
}
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
return new Response(null, {
status: 204,
headers: {
"Set-Cookie": setCookie,
},
});
} else if (yield* match(Me)) {
const sessionId = yield* pipe(
getCookie("sessionId"),
Effect.flatten,
Effect.flatMap(S.decodeUnknown(SessionId)),
Effect.mapError(() => new RequestError({ status: 401, body: "Session invalid or expired" })),
);
const sessionData = yield* pipe(
db.getAndRefreshSessionData(sessionId),
Effect.optionFromOptional,
Effect.map(O.getOrNull),
);
if (sessionData === null) {
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
return new Response(null, {
status: 401,
headers: {
"Set-Cookie": setCookie,
},
});
}
const user = yield* pipe(
db.getUserById(sessionData.userId),
Effect.optionFromOptional,
Effect.map(O.getOrNull),
);
if (user === null) {
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
return new Response(null, {
status: 401,
headers: {
"Set-Cookie": setCookie,
},
});
}
const responseData = Me.props.response[200].schema.make({
userId: user.userId,
username: user.username,
admin: user.admin,
});
const responseJson = yield* pipe(
responseData,
S.encode(Me.props.response[200].schema),
Effect.map(JSON.stringify),
);
const responseArray = new TextEncoder().encode(responseJson);
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`;
if (yield* acceptsBrotli) {
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
brotliCompress(responseArray, (error, result) => {
if (error !== null) {
callback(Effect.die(error));
} else {
callback(Effect.succeed(result));
}
});
});
return new Response(responseBrotli, {
headers: {
"Content-Encoding": "br",
"Content-Length": responseArray.byteLength.toString(),
"Content-Type": "application/json",
"Set-Cookie": setCookie,
},
});
} else {
return new Response(responseArray, {
headers: {
"Content-Length": responseArray.byteLength.toString(),
"Content-Type": "application/json",
"Set-Cookie": setCookie,
}
});
}
const sessionId = cookie.sessionId.value;
if (sessionId === undefined) {
return { user: null };
}
return new Response(null, { status: 404 });
}),
Effect.catchAllDefect(() => new RequestError({ status: 500 })),
Effect.catchTag("RequestError", (error) => Effect.succeed(error.response)),
);
const res = await db
.updateTable("Session")
.set({ expiresAt: sql`datetime('now', '+7 days') `})
.where("sessionId", "=", SessionId(sessionId))
.returning(["userId"])
.executeTakeFirst();
if (res === undefined) {
return { user: null };
}
const user = await db
.selectFrom("User")
.selectAll()
.where("userId", "=", res.userId)
.executeTakeFirst();
if (user === undefined) {
return { user: null };
}
return { user };
})
.onTransform(async ({ db, request, server }) => {
const requestId = RequestId(Bun.randomUUIDv7("hex"));
const timestamp = new Date().toISOString();
const { method } = request;
const url = new URL(request.url);
const { pathname } = url;
const query = JSON.stringify(Object.fromEntries(url.searchParams.entries()));
const ip = server?.requestIP(request)?.address ?? null;
await db
.insertInto("AccessLog")
.values({ requestId, timestamp, method, pathname, query, ip })
.execute();
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
})
.get("/me", async ({ user }) => {
if (user === null) {
return error("Unauthorized");
}
return {
userId: user.userId,
username: user.username,
admin: user.admin !== 0,
};
})
.post("/login", async ({ db, body: { username, password }, cookie }) => {
const user = await db
.selectFrom("User")
.selectAll()
.where("username", "=", username)
.executeTakeFirst();
if (user === undefined) {
return error("Unauthorized", "Invalid username or password");
}
const valid = await Bun.password.verify(password, user.password);
if (!valid) {
return error("Unauthorized", "Invalid username or password");
}
const sessionId = generateSessionId();
await db
.insertInto("Session")
.values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` })
.execute();
const expiresAt = new Date().getTime() + 604800000;
cookie.sessionId.set({
value: sessionId,
expires: new Date(expiresAt),
httpOnly: true,
sameSite: "none",
secure: true,
});
return {
userId: user.userId,
username: user.username,
admin: user.admin !== 0,
};
}, {
body: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 1 }),
}),
})
.post("/logout", async ({ db, cookie, set }) => {
set.status = "No Content";
const sessionCookie = cookie.sessionId;
sessionCookie.remove();
const sessionId = sessionCookie.value;
if (sessionId === undefined) {
return;
}
await db
.deleteFrom("Session")
.where("sessionId", "=", SessionId(sessionId))
.execute();
})
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
if (user === null) {
return error(401);
}
const pieceId = PieceId(Bun.randomUUIDv7());
const res = await db
.insertInto("Piece")
.values({ pieceId, name, composer, lyricist, arranger, createdBy: user.userId, createdAt: sql`datetime()` })
.returningAll()
.executeTakeFirstOrThrow();
return res;
}, {
body: t.Object({
name: t.String({ minLength: 1 }),
composer: t.Nullable(t.String({ minLength: 1 })),
lyricist: t.Nullable(t.String({ minLength: 1 })),
arranger: t.Nullable(t.String({ minLength: 1 })),
}),
})
.get("/piece", async ({ db, user }) => {
if (user === null) {
return error(401);
}
const res = await db
.selectFrom("Piece")
.selectAll()
.orderBy(["name", "composer", "arranger"])
.execute();
return res;
})
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
if (user === null) {
return error(401);
}
const res = await db
.updateTable("Piece")
.set({ name, composer, lyricist, arranger, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
.where("pieceId", "=", pieceId)
.returningAll()
.execute();
if (res.length === 0) {
return error("Not Found");
}
return res;
}, {
body: t.Object({
name: t.String({ minLength: 1 }),
composer: t.Nullable(t.String({ minLength: 1 })),
lyricist: t.Nullable(t.String({ minLength: 1 })),
arranger: t.Nullable(t.String({ minLength: 1 })),
}),
params: t.Object({
pieceId: tbranded<PieceId>(),
})
})
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
if (user === null) {
return error(401);
}
const res = await db
.deleteFrom("Piece")
.where("pieceId", "=", pieceId)
.returningAll()
.execute();
if (res.length === 0) {
return error("Not Found");
}
set.status = "No Content";
}, {
params: t.Object({
pieceId: tbranded<PieceId>(),
}),
})
.listen(process.env.PORT || 3000);
export type App = typeof app;

View File

@@ -0,0 +1,162 @@
import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common";
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, sql, Updateable } from "kysely";
import { BunSqliteDialect } from "kysely-bun-sqlite";
import { Database as BunSqliteDatabase } from "bun:sqlite";
export function generateSessionId(byteLength: number = 32): SessionId {
const array = new Uint8Array(byteLength);
crypto.getRandomValues(array);
const string = Buffer.from(array).toString("base64url");
return SessionId(string);
};
export interface Database {
AccessLog: AccessLogTable;
Attachment: AttachmentTable;
File: FileTable;
Piece: PieceTable;
Session: SessionTable;
User: UserTable;
}
export interface SystemInformation {
createdBy: ColumnType<UserId | null, UserId, never>;
createdAt: ColumnType<string, string, never>;
modifiedBy: ColumnType<UserId | null, null, UserId>;
modifiedAt: ColumnType<string | null, null, string>;
}
export interface AccessLogTable {
requestId: ColumnType<RequestId, RequestId, never>;
timestamp: ColumnType<string, string, never>;
method: ColumnType<string, string, never>;
pathname: ColumnType<string, string, never>;
query: ColumnType<string, string, never>;
ip: ColumnType<string | null, string | null, never>;
}
export interface Attachment {
pieceId: ColumnType<PieceId, PieceId, never>;
sha256: Sha256;
filename: string;
mediaType: string;
}
export interface AttachmentTable extends Attachment, SystemInformation {
attachmentId: ColumnType<AttachmentId, AttachmentId, never>;
}
export interface FileTable {
sha256: ColumnType<Sha256, Sha256, never>;
data: ColumnType<Uint8Array, Uint8Array, never>;
}
export interface Piece {
name: string;
composer: string | null;
lyricist: string | null;
arranger: string | null;
}
export interface PieceTable extends Piece, SystemInformation {
pieceId: ColumnType<PieceId, PieceId, never>;
}
export interface Session {
userId: UserId;
}
export interface SessionTable extends Session {
sessionId: ColumnType<SessionId, SessionId, never>;
expiresAt: string;
}
export interface UserTable {
userId: ColumnType<UserId, UserId, never>;
username: string;
password: string;
admin: number;
}
function systemInformation<TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) {
return schema
.addColumn("createdBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade"))
.addColumn("createdAt", "text", (c) => c.notNull())
.addColumn("modifiedBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade"))
.addColumn("modifiedAt", "text");
}
export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kysely<Database>> {
const database = new BunSqliteDatabase(filename, { create: true, readwrite: true });
const dialect = new BunSqliteDialect({ database });
const db = new Kysely<Database>({ dialect });
await db.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON"));
await db.schema
.createTable("AccessLog")
.ifNotExists()
.addColumn("requestId", "text", (c) => c.notNull().primaryKey())
.addColumn("timestamp", "text", (c) => c.notNull())
.addColumn("method", "text", (c) => c.notNull())
.addColumn("pathname", "text", (c) => c.notNull())
.addColumn("query", "text", (c) => c.notNull())
.addColumn("ip", "text")
.execute();
await db.schema
.createIndex("AccessLog_timestamp")
.ifNotExists()
.on("AccessLog")
.column("timestamp")
.execute();
await db.schema
.createTable("User")
.ifNotExists()
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
.addColumn("username", "text", (c) => c.notNull().unique())
.addColumn("password", "text", (c) => c.notNull())
.addColumn("admin", "boolean", (c) => c.notNull())
.execute();
await db.schema
.createTable("Piece")
.ifNotExists()
.addColumn("pieceId", "text", (c) => c.notNull().primaryKey())
.addColumn("name", "text", (c) => c.notNull())
.addColumn("composer", "text")
.addColumn("lyricist", "text")
.addColumn("arranger", "text")
.$call(systemInformation)
.execute();
await db.schema
.createIndex("Piece_name_composer_arranger")
.ifNotExists()
.on("Piece")
.columns(["name", "composer", "arranger"])
.execute();
await db.schema
.createTable("Session")
.ifNotExists()
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
.addColumn("userId", "text", (c) => c.references("User.userId").onDelete("cascade").onUpdate("cascade"))
.addColumn("expiresAt", "text", (c) => c.notNull())
.execute();
await db.schema
.createTable("Attachment")
.ifNotExists()
.addColumn("attachmentId", "text", (c) => c.notNull().primaryKey())
.addColumn("pieceId", "text", (c) => c.notNull().references("Piece.pieceId").onDelete("cascade").onUpdate("cascade"))
.addColumn("sha256", "blob", (c) => c.notNull())
.addColumn("filename", "text", (c) => c.notNull())
.addColumn("mediaType", "text", (c) => c.notNull())
.$call(systemInformation)
.execute();
return db;
}

View File

@@ -1,71 +0,0 @@
import { RequestId } from "common";
import { Effect, Exit, Layer, ManagedRuntime, Option as O, pipe, Scope, unsafeCoerce } from "effect";
import { ulid } from "ulid";
import { app } from "./app";
import { DatabaseLive } from "./services/database";
import { Request, RequestInterface } from "./services/request";
import { StorageFilesystem } from "./services/storage";
const scope = Effect.runSync(Scope.make());
const runtime = pipe(
StorageFilesystem("storage"),
Layer.merge(DatabaseLive()),
Layer.provide(Layer.succeed(Scope.Scope, scope)),
ManagedRuntime.make,
);
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 requestInterface = Object.freeze<RequestInterface>({
requestId,
method: request.method,
pathname: url.pathname,
path: Object.freeze(url.pathname.slice(1).split("/")),
query,
headers: Object.freeze(Object.fromEntries(request.headers.entries())),
ip,
json: Effect.tryPromise({
try: () => request.json(),
catch: unsafeCoerce<unknown, SyntaxError>,
}),
});
const fiber = pipe(
app,
Effect.provideService(Request, requestInterface),
runtime.runFork,
);
return new Promise((resolve, reject) => {
fiber.addObserver(Exit.match({
onSuccess: resolve,
onFailure: reject,
}));
});
},
websocket: {
message: () => { },
},
});
process.on("SIGINT", () => {
Effect.runFork(Effect.gen(function* () {
yield* runtime.disposeEffect;
yield* Scope.close(scope, Exit.void);
}));
server.stop();
});

View File

@@ -1,197 +0,0 @@
import { Schema as S } from "@effect/schema";
import { Database as SqliteDatabase } from "bun:sqlite";
import { PieceId, SessionId, UserId } from "common";
import { AccessLog, Piece, SessionData, User } from "common/db";
import { Context, Effect, Layer, pipe, Option as O } from "effect";
import { NoSuchElementException } from "effect/Cause";
import { ulid } from "ulid";
export function generateSessionId(byteLength: number = 12): SessionId {
const array = new Uint8Array(byteLength);
crypto.getRandomValues(array);
const string = Buffer.from(array).toString("base64url");
return SessionId.make(string);
}
export interface DatabaseInterface {
readonly createAccessLog: (accessLog: AccessLog) => Effect.Effect<void>;
readonly createPiece: (name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.Effect<PieceId>;
readonly getPieces: Effect.Effect<readonly Piece[]>;
readonly updatePiece: (pieceId: PieceId, name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.Effect<void, NoSuchElementException>;
readonly deletePiece: (pieceId: PieceId) => Effect.Effect<void, NoSuchElementException>;
readonly getUserByUsername: (username: string) => Effect.Effect<User, NoSuchElementException>;
readonly getUserById: (userId: UserId) => Effect.Effect<User, NoSuchElementException>;
readonly createSession: (sessionData: SessionData) => Effect.Effect<SessionId>;
readonly getAndRefreshSessionData: (sessionId: SessionId) => Effect.Effect<SessionData, NoSuchElementException>;
readonly deleteSession: (sessionId: SessionId) => Effect.Effect<void>;
}
export class Database extends Context.Tag("Database")<Database, DatabaseInterface>() { }
export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Database, pipe(
Effect.acquireRelease(
Effect.sync(() => new SqliteDatabase(filePath, { create: true, readwrite: true, strict: true })),
(db) => Effect.sync(() => db.close()),
),
Effect.tap((db) => Effect.sync(() => {
const systemInformation = `
createdBy TEXT REFERENCES User (userId) ON DELETE SET NULL ON UPDATE CASCADE,
createdAt TEXT NOT NULL,
modifiedBy TEXT REFERENCES User (userId) ON DELETE SET NULL ON UPDATE CASCADE,
modifiedAt TEXT
`;
db.exec("PRAGMA foreign_keys = ON");
db.exec(`CREATE TABLE IF NOT EXISTS AccessLog (
timestamp TEXT NOT NULL,
requestId TEXT NOT NULL,
method TEXT NOT NULL,
pathname TEXT NOT NULL,
query TEXT NOT NULL,
ip TEXT
)`);
db.exec(`CREATE TABLE IF NOT EXISTS User (
userId TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
admin BOOLEAN NOT NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS Piece (
pieceId TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
composer TEXT,
lyricist TEXT,
arranger TEXT,
${systemInformation}
)`);
db.exec(`CREATE TABLE IF NOT EXISTS Session (
sessionId TEXT NOT NULL PRIMARY KEY,
userId TEXT NOT NULL REFERENCES User (userId) ON DELETE CASCADE ON UPDATE CASCADE,
expiresAt TEXT NOT NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS Attachment (
attachmentId TEXT NOT NULL PRIMARY KEY,
pieceId TEXT NOT NULL REFERENCES Piece (pieceId) ON DELETE CASCADE ON UPDATE CASCADE,
sha256 BLOB NOT NULL,
filename TEXT NOT NULL,
mediaType TEXT NOT NULL,
${systemInformation}
)`);
const userCount = db.query("SELECT COUNT(*) FROM User").values()[0][0] as number;
if (userCount <= 0) {
const userId = UserId.make(ulid());
const username = "admin";
const password = Bun.password.hashSync("admin");
const admin = true;
db.exec("INSERT INTO User (userId, username, password, admin) VALUES (?, ?, ?, ?)", userId, username, password, admin);
}
})),
Effect.map((db) => {
const createAccessLog = db.prepare<
never,
[timestamp: string, requestId: string, method: string, pathname: string, query: string, ip: string | null]
>("INSERT INTO AccessLog (timestamp, requestId, method, pathname, query, ip) VALUES (?, ?, ?, ?, ?, ?)");
const createPiece = db.prepare<
never,
[pieceId: PieceId, name: string, composer: string | null, lyricist: string | null, arranger: string | null, user: UserId]
>("INSERT INTO Piece (pieceId, name, composer, lyricist, arranger, createdBy, createdAt, modifiedBy, modifiedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime(), ?6, NULL)");
const getPieces = db.prepare<
typeof Piece.Encoded,
[]
>("SELECT pieceId, name, composer, lyricist, arranger, createdBy, createdAt, modifiedBy, modifiedAt FROM Piece ORDER BY name, composer, arranger");
const updatePiece = db.prepare<
never,
[pieceId: PieceId, name: string, composer: string | null, lyricist: string | null, arranger: string | null, user: UserId]
>("UPDATE Piece SET name = ?2, composer = ?3, lyricist = ?4, arranger = ?5, modifiedBy = ?6, modifiedAt = datetime() WHERE pieceId = ?1");
const pieceExists = db.prepare<object, [pieceId: PieceId]>("SELECT 1 FROM Piece WHERE PieceId = ?");
const deletePiece = db.prepare<never, [pieceId: PieceId]>("DELETE FROM Piece WHERE pieceId = ?");
const getUserByUsername = db.prepare<
{ userId: string, username: string, password: string, admin: number },
[username: string]
>("SELECT userId, username, password, admin FROM User WHERE username = ?");
const getUserById = db.prepare<
{ userId: string, username: string, password: string, admin: number },
[userId: UserId]
>("SELECT userId, username, password, admin FROM User WHERE userId = ?");
const cleanupSessions = db.prepare<never, []>("DELETE FROM Session WHERE datetime() >= expiresAt");
const createSession = db.prepare<
never,
[sessionId: SessionId, userId: UserId]
>("INSERT INTO Session (sessionId, userId, expiresAt) VALUES (?, ?, datetime('now', '+7 days'))");
const updateSession = db.prepare<typeof SessionData.Encoded, [sessionId: SessionId]>("UPDATE Session SET expiresAt = datetime('now', '+7 days') WHERE sessionId = ? RETURNING userId");
const deleteSession = db.prepare<never, [sessionId: SessionId]>("DELETE FROM Session WHERE sessionId = ?");
return Object.freeze<DatabaseInterface>({
createAccessLog: (accessLog) => Effect.sync(() => {
const { timestamp, requestId, method, pathname, query, ip } = S.encodeSync(AccessLog)(accessLog);
createAccessLog.run(timestamp, requestId, method, pathname, query, ip);
}),
createPiece: (name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.sync(() => {
const pieceId = PieceId.make(ulid());
createPiece.run(pieceId, name, O.getOrNull(composer), O.getOrNull(lyricist), O.getOrNull(arranger), user);
return pieceId;
}),
getPieces: Effect.sync(() => {
const res = getPieces.all();
const decoder = S.decodeSync(Piece);
return Object.freeze(res.map((encoded) => decoder(encoded)));
}),
updatePiece: (pieceId: PieceId, name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.suspend(() => {
updatePiece.run(pieceId, name, O.getOrNull(composer), O.getOrNull(lyricist), O.getOrNull(arranger), user);
const matched = pieceExists.get(pieceId) !== null;
return matched ? Effect.void : Effect.fail(new NoSuchElementException());
}),
deletePiece: (pieceId: PieceId) => Effect.suspend(() => {
const matched = deletePiece.run(pieceId).changes > 0;
return matched ? Effect.void : Effect.fail(new NoSuchElementException());
}),
getUserByUsername: (username) => Effect.suspend(() => {
const res = getUserByUsername.get(username);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeSync(User)(res));
}),
getUserById: (userId) => Effect.suspend(() => {
const res = getUserById.get(userId);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeSync(User)(res));
}),
createSession: ({ userId }) => Effect.sync(() => {
cleanupSessions.run();
const sessionId = generateSessionId();
createSession.run(sessionId, userId);
return sessionId;
}),
getAndRefreshSessionData: (sessionId) => Effect.suspend(() => {
cleanupSessions.run();
const res = updateSession.get(sessionId);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeUnknownSync(SessionData)(res));
}),
deleteSession: (sessionId) => Effect.sync(() => {
cleanupSessions.run();
deleteSession.run(sessionId);
}),
});
}),
));

View File

@@ -1,16 +0,0 @@
import { RequestId } from "common";
import { Context, Effect, Option as O } from "effect";
export interface RequestInterface {
readonly requestId: RequestId;
readonly method: string;
readonly pathname: string;
readonly path: readonly string[];
readonly query: { readonly [_: string]: string };
readonly headers: { readonly [_: string]: string };
readonly ip: O.Option<string>;
readonly json: Effect.Effect<unknown, SyntaxError>;
}
export class Request extends Context.Tag("Request")<Request, RequestInterface>() { }

View File

@@ -1,107 +0,0 @@
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;
}),
}));

View File

@@ -1,29 +1,13 @@
{
"name": "common",
"license": "UNLICENSED",
"private": true,
"type": "module",
"license": "UNLICENSED",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./api": {
"types": "./dist/api.d.ts",
"import": "./dist/api.js"
},
"./db": {
"types": "./dist/db.d.ts",
"import": "./dist/db.js"
}
".": { "import": "./src/index.ts" },
"./*": { "import": "./src/*.ts" }
},
"devDependencies": {
"typescript": "catalog:"
},
"dependencies": {
"@effect/schema": "catalog:",
"effect": "catalog:",
"fast-check": "catalog:",
"make-api": "workspace:^"
}
}

View File

@@ -0,0 +1,35 @@
import * as Function from "./Function";
import * as Types from "./Types";
declare const BrandTypeId: unique symbol;
export type BrandTypeId = typeof BrandTypeId;
declare const ConstructorTypeId: unique symbol;
export type ConstructorTypeId = typeof ConstructorTypeId;
export interface Brand<in out K extends string | symbol> {
readonly [BrandTypeId]: {
readonly [k in K]: K;
}
}
export declare namespace Brand {
export interface Constructor<in out A extends Brand<any>> {
readonly [ConstructorTypeId]: ConstructorTypeId;
(args: Brand.Unbranded<A>): A;
}
export type Unbranded<P> = P extends infer Q & Brands<P> ? Q : P;
export type Brands<P> = P extends Brand<any>
? Types.UnionToIntersection<{
[k in keyof P[BrandTypeId]]: k extends string | symbol ? Brand<k> : never
}[keyof P[BrandTypeId]]>
: never;
}
export type Branded<A, K extends string | symbol> = A & Brand<K>;
export const nominal = <A extends Brand<any>>(): Brand.Constructor<A> => {
return Function.identity as Brand.Constructor<A>;
};

View File

@@ -0,0 +1,3 @@
export const identity = <A>(a: A): A => a;
export const unsafeCoerce: <A, B>(a: A) => B = identity as any;

View File

@@ -0,0 +1 @@
export type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never;

View File

@@ -1,75 +0,0 @@
import { Api } from "make-api";
import { Schema as S } from "@effect/schema";
import { PieceId, UserId } from "common";
import { pipe } from "effect";
import { Piece } from "./db";
// --- PIECES ------------------------------------------------------------------
export const CreatePiece = pipe(
Api.make("POST", "piece"),
Api.requestBodyJson(S.Struct({
name: S.NonEmptyString,
composer: S.Union(S.NonEmptyString, S.Null),
lyricist: S.Union(S.NonEmptyString, S.Null),
arranger: S.Union(S.NonEmptyString, S.Null),
})),
Api.responseBodyJson(201, Piece),
Api.responseBodyText(400, S.String),
);
export const GetPieces = pipe(
Api.make("GET", "piece"),
Api.responseBodyJson(200, S.Array(Piece)),
);
export const UpdatePiece = pipe(
Api.make("PUT", "piece", ["pieceId", PieceId]),
Api.requestBodyJson(S.Struct({
name: S.NonEmptyString,
composer: S.Union(S.NonEmptyString, S.Null),
lyricist: S.Union(S.NonEmptyString, S.Null),
arranger: S.Union(S.NonEmptyString, S.Null),
})),
Api.responseBodyJson(200, Piece),
Api.responseBodyText(400, S.String),
);
export const DeletePiece = pipe(
Api.make("DELETE", "piece", ["pieceId", PieceId]),
Api.responseBodyNone(200),
Api.responseBodyText(400, S.String),
Api.responseBodyText(404, S.String),
);
// --- AUTHENTICATION ----------------------------------------------------------
export const Me = pipe(
Api.make("GET", "me"),
Api.responseBodyJson(200, S.Struct({
userId: UserId,
username: S.NonEmptyString,
admin: S.Boolean,
})),
Api.responseBodyText(401, S.String),
);
export const Logout = pipe(
Api.make("POST", "logout"),
Api.responseBodyNone(204),
);
export const Login = pipe(
Api.make("POST", "login"),
Api.requestBodyJson(S.Struct({
username: S.NonEmptyString,
password: S.NonEmptyString,
})),
Api.responseBodyJson(200, S.Struct({
userId: UserId,
username: S.NonEmptyString,
admin: S.Boolean,
})),
Api.responseBodyText(400, S.String),
Api.responseBodyText(401, S.String),
);

View File

@@ -1,75 +0,0 @@
import { Schema as S } from "@effect/schema";
import { AttachmentId, BooleanFromNumber, PieceId, RequestId, SessionId, Sha256, UserId } from "common";
import { Brand as B, pipe } from "effect";
export const SessionData = S.Struct({
userId: UserId,
});
export type SessionData = typeof SessionData.Type;
export const SystemInformation = S.Struct({
createdBy: S.Union(UserId, S.Null),
createdAt: S.DateTimeUtc,
modifiedBy: S.Union(UserId, S.Null),
modifiedAt: S.DateTimeUtc,
});
export type SystemInformation = typeof SystemInformation.Type;
// --- TABLES ------------------------------------------------------------------
export const AccessLog = S.Struct({
timestamp: S.DateTimeUtc,
requestId: RequestId,
method: S.NonEmptyString,
pathname: S.NonEmptyString,
query: S.parseJson(S.Record({
key: S.String,
value: S.String,
})),
ip: S.Union(S.NonEmptyString, S.Null),
});
export const Attachment = pipe(
S.Struct({
attachmentId: AttachmentId,
pieceId: PieceId,
sha256: Sha256,
filename: S.NonEmptyString,
mediaType: S.NonEmptyString,
}),
S.extend(SystemInformation),
);
export const Piece = pipe(
S.Struct({
pieceId: PieceId,
name: S.NonEmptyString,
composer: S.Union(S.NonEmptyString, S.Null),
lyricist: S.Union(S.NonEmptyString, S.Null),
arranger: S.Union(S.NonEmptyString, S.Null),
}),
S.extend(SystemInformation),
);
export const Session = pipe(
S.Struct({
sessionId: SessionId,
expiresAt: S.DateTimeUtc,
}),
S.extend(SessionData),
);
export const User = S.Struct({
userId: UserId,
username: S.NonEmptyString,
password: S.NonEmptyString,
admin: BooleanFromNumber,
});
export type AccessLog = typeof AccessLog.Type;
export type Attachment = typeof Attachment.Type;
export type Piece = typeof Piece.Type;
export type Session = typeof Session.Type;
export type User = typeof User.Type;

View File

@@ -1,33 +1,22 @@
import { Schema as S } from "@effect/schema";
import { Brand as B, pipe } from "effect";
import * as Brand from "./Brand";
export const AttachmentId = pipe(S.ULID, S.brand("AttachmentId"));
export const PieceId = pipe(S.ULID, S.brand("PieceId"));
export const RequestId = pipe(S.ULID, S.brand("RequestId"));
export const SessionId = pipe(S.NonEmptyString, S.brand("SessionId"));
export const UserId = pipe(S.ULID, S.brand("UserId"));
export type UUID = Brand.Branded<string, "UUID">;
export const UUID = Brand.nominal<UUID>();
export type AttachmentId = typeof AttachmentId.Type;
export type PieceId = typeof PieceId.Type;
export type RequestId = typeof RequestId.Type;
export type SessionId = typeof SessionId.Type;
export type UserId = typeof UserId.Type;
export type Sha256 = Brand.Branded<Uint8Array, "Sha256">;
export const Sha256 = Brand.nominal<Sha256>();
export type Sha256 = B.Branded<Uint8Array, "Sha256">;
export const Sha256 = pipe(
S.Uint8ArrayFromSelf,
S.fromBrand(B.refined<Sha256>(
(array) => array.byteLength === 32,
() => B.error(`Expected Uint8Array to be 32 bytes long`),
)),
).annotations({ identifier: "SHA-256" });
export type AttachmentId = Brand.Branded<UUID, "AttachmentId">;
export const AttachmentId = Brand.nominal<AttachmentId>();
export class BooleanFromNumber extends S.transform(
S.Number,
S.Boolean,
{
strict: true,
decode: (a) => a !== 0,
encode: (i) => i ? 1 : 0,
},
).annotations({ identifier: "BooleanFromNumber" }) { }
export type PieceId = Brand.Branded<UUID, "PieceId">;
export const PieceId = Brand.nominal<PieceId>();
export type RequestId = Brand.Branded<UUID, "RequestId">;
export const RequestId = Brand.nominal<RequestId>();
export type SessionId = Brand.Branded<string, "SessionId">;
export const SessionId = Brand.nominal<SessionId>();
export type UserId = Brand.Branded<UUID, "UserId">;
export const UserId = Brand.nominal<UserId>();

View File

@@ -1,6 +1,3 @@
{
"extends": "../../tsconfig.base.json",
"references": [
{ "path": "../make-api" },
],
}

View File

@@ -1,10 +1,12 @@
<!doctype html>
<html lang="pl">
<html lang="pl" class="w-full h-full overflow-hidden dark:bg-stone-900 dark:text-white">
<head>
<meta charset="utf-8">
<title>Repozytorium muzyczne</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="module" src="src/index.tsx"></script>
<script type="module" src="/src/app.tsx"></script>
</head>
<body></body>
<body class="w-full h-full overflow-hidden">
<div id="root" class="w-full h-full overflow-hidden"></div>
</body>
</html>

View File

@@ -1,21 +1,25 @@
{
"name": "frontend",
"license": "UNLICENSED",
"private": true,
"type": "module",
"license": "UNLICENSED",
"devDependencies": {
"@vanilla-extract/css": "catalog:",
"@vanilla-extract/vite-plugin": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"autoprefixer": "catalog:",
"backend": "workspace:^",
"elysia": "catalog:",
"postcss": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
},
"dependencies": {
"@effect/schema": "catalog:",
"@elysiajs/eden": "catalog:",
"common": "workspace:^",
"effect": "catalog:",
"fast-check": "catalog:",
"make-api": "workspace:^",
"preact": "catalog:",
"preact-iso": "catalog:"
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "catalog:"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,14 +0,0 @@
import { ErrorBoundary, LocationProvider, Route, Router } from "preact-iso";
import { Home } from "./routes/Home";
import { Login } from "./routes/Login";
export const App = () => (
<LocationProvider>
<ErrorBoundary>
<Router>
<Route path="/" component={Home} />
<Route path="/login" component={Login} />
</Router>
</ErrorBoundary>
</LocationProvider>
);

View File

@@ -0,0 +1,26 @@
import { Home } from "./routes/Home";
import { Login } from "./routes/Login";
import { createRoot } from "react-dom/client";
import { StrictMode } from "react";
import "./style.css";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <Home />,
},
{
path: "/login",
element: <Login />,
}
]);
const rootElement = document.getElementById("root") as HTMLDivElement;
const root = createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);

View File

@@ -0,0 +1,4 @@
import { treaty } from "@elysiajs/eden";
import type { App } from "backend/app";
export const client = treaty<App>("localhost:3000");

View File

@@ -1,43 +0,0 @@
import { globalStyle } from "@vanilla-extract/css";
globalStyle("html, body", {
margin: 0,
padding: 0,
width: "100%",
height: "100%",
overflow: "hidden",
backgroundColor: "white",
color: "black",
fontFamily: "system-ui, sans-serif",
fontSize: 16,
fontWeight: "normal",
"@media": {
"(prefers-color-scheme: dark)": {
backgroundColor: "#1E1E1E",
color: "white",
},
},
});
globalStyle("input, button, select, textarea", {
border: "none",
outline: "none",
backgroundImage: "none",
backgroundColor: "transparent",
fontFamily: "system-ui, sans-serif",
fontSize: 16,
fontWeight: "normal",
color: "black",
"@media": {
"(prefers-color-scheme: dark)": {
color: "white",
},
},
});

View File

@@ -1,6 +0,0 @@
import { render } from "preact";
import { App } from "./App";
import "./index.css";
render(<App />, document.body);

View File

@@ -1,31 +1,4 @@
import { style } from "@vanilla-extract/css";
export const container = style({
width: 1000,
maxWidth: "100%",
margin: "0 auto",
display: "flex",
flexDirection: "column",
alignItems: "stretch",
});
export const loading = style({
padding: 8,
textAlign: "center",
});
export const header = style({
padding: 8,
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
});
export const logout = style({
export const logout = {
padding: 8,
backgroundColor: "#C0C0C0",
@@ -83,4 +56,4 @@ export const logout = style({
borderBottomColor: "#202020",
},
},
});
};

View File

@@ -1,79 +1,66 @@
import { Schema as S } from "@effect/schema";
import { Me } from "common/api";
import { Effect, Fiber, Option as O, Option, pipe } from "effect";
import { useLocation } from "preact-iso";
import { useEffect, useMemo } from "preact/hooks";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { client } from "../client";
import { useStore } from "../store";
import * as style from "./Home.css";
export const Home = () => {
const { route } = useLocation();
const navigate = useNavigate();
const user = Option.getOrNull(useStore(state => state.user));
const user = useStore(state => state.user);
const setUser = useStore(state => state.setUser);
useEffect(() => {
const init = async () => {
if (user !== null) return;
const effect = Effect.gen(function* () {
const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/me", {
method: "GET",
signal,
credentials: "include",
}));
const { data, error } = await client.me.get();
if (!res.ok) {
route("/login");
return;
}
const responseData = yield* pipe(
Effect.promise(() => res.json()),
Effect.flatMap(S.decodeUnknown(Me.props.response[200].schema)),
Effect.orDie,
);
setUser(O.some(responseData));
});
const fiber = Effect.runFork(effect);
return () => Effect.runFork(Fiber.interrupt(fiber), { immediate: true });
}, []);
const logoutEffect = useMemo(() => Effect.gen(function* () {
const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/logout", {
method: "POST",
signal,
credentials: "include",
}));
if (!res.ok) {
yield* Effect.die(new Error("Response was not ok"));
if (error !== null) {
navigate("/login");
return;
}
setUser(O.none());
setUser(data);
};
route("/login");
}), []);
useEffect(() => {
init();
}, []);
const onLogoutClick = () => {
Effect.runFork(logoutEffect);
const onLogoutClick = async () => {
const { data, error } = await client.logout.post();
if (error !== null) {
console.error("Response was not ok");
}
setUser(null);
navigate("/login");
};
if (user === null) {
return (
<div class={style.container}>
<div class={style.loading}>Ładowanie</div>
<div className="w-[1000px] max-w-full mx-auto flex flex-col items-stretch">
<div className="p-2 text-center">Ładowanie</div>
</div>
);
}
return (
<div class={style.container}>
<div class={style.header}>
<div>Użytkownik: {user.username}</div>
<div><button class={style.logout} type="button" onClick={onLogoutClick}>Wyloguj się</button></div>
<div className="w-[1000px] max-w-full mx-auto flex flex-col items-stretch">
<div className="p-2 flex justify-between items-baseline">
<div>
Użytkownik: {user.username}
</div>
<div>
<button
className="p-2 bg-stone-300 border-2 border-t-stone-200 border-l-stone-200 border-r-stone-600 border-b-stone-600 rounded"
type="button"
onClick={onLogoutClick}
>
Wyloguj się
</button>
</div>
</div>
</div>
);

View File

@@ -1,83 +1,4 @@
import { style } from "@vanilla-extract/css";
export const container = style({
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
});
export const box = style({
padding: 8,
display: "flex",
flexDirection: "column",
gap: 8,
borderWidth: 1,
borderStyle: "solid",
borderColor: "black",
borderRadius: 4,
"@media": {
"(prefers-color-scheme: dark)": {
borderColor: "white",
},
},
});
export const header = style({
paddingBottom: 8,
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: "black",
textAlign: "center",
fontWeight: "bold",
"@media": {
"(prefers-color-scheme: dark)": {
borderBottomColor: "white",
},
},
});
export const input = style({
width: "32ch",
padding: 8,
borderWidth: 1,
borderStyle: "solid",
borderColor: "black",
borderRadius: 4,
"selectors": {
"&:focus": {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: "#8080FF",
"@media": {
"(prefers-color-scheme: dark)": {
outlineColor: "#C0C0FF",
},
},
},
},
"@media": {
"(prefers-color-scheme: dark)": {
borderColor: "white",
},
},
});
export const submit = style({
export const submit = {
padding: 8,
backgroundColor: "#C0C0C0",

View File

@@ -1,14 +1,11 @@
import { Schema as S } from "@effect/schema";
import { Login as LoginApi } from "common/api";
import { Effect, Fiber, Option as O, pipe } from "effect";
import { useLocation } from "preact-iso";
import { useId, useMemo, useRef } from "preact/hooks";
import { FormEventHandler, useId } from "react";
import { useNavigate } from "react-router-dom";
import { client } from "../client";
import { useStore } from "../store";
import * as style from "./Login.css";
export const Login = () => {
const { route } = useLocation();
const navigate = useNavigate();
const loginUsername = useStore(state => state.loginUsername);
const loginPassword = useStore(state => state.loginPassword);
@@ -21,76 +18,55 @@ export const Login = () => {
const usernameId = useId();
const passwordId = useId();
const requestFiber = useRef<Fiber.RuntimeFiber<void> | null>(null);
const requestEffect = useMemo(() => Effect.gen(function* () {
const requestData = LoginApi.props.request.schema.make({ username: loginUsername, password: loginPassword });
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
const requestJson = yield* pipe(
requestData,
S.encode(LoginApi.props.request.schema),
Effect.map(JSON.stringify),
Effect.orDie,
);
const { data, error } = await client.login.post({
username: loginUsername,
password: loginPassword,
});
const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/login", {
method: "POST",
body: requestJson,
headers: { "Content-Type": "application/json" },
signal,
credentials: "include",
}));
if (!res.ok) {
yield* Effect.die(new Error("Response was not ok"));
if (error) {
console.error(error.value);
return;
}
const responseData = yield* pipe(
Effect.promise(() => res.json()),
Effect.flatMap(S.decodeUnknown(LoginApi.props.response[200].schema)),
Effect.orDie,
);
setLoginUsername("");
setLoginPassword("");
setUser(O.some(responseData));
setUser(data);
route("/");
}), [loginUsername, loginPassword]);
const onSubmit = (e: SubmitEvent) => {
e.preventDefault();
if (requestFiber.current !== null) {
Effect.runFork(Fiber.interrupt(requestFiber.current), { immediate: true });
}
requestFiber.current = Effect.runFork(requestEffect);
navigate("/");
};
return (
<div class={style.container}>
<form class={style.box} onSubmit={onSubmit}>
<header class={style.header}>Repozytorium muzyczne</header>
<label for={usernameId}>Nazwa użytkownika</label>
<div className="w-full h-full flex items-center justify-center">
<form className="p-2 flex flex-col gap-2 border border-black rounded dark:border-white" onSubmit={onSubmit}>
<header className="pb-2 border-b border-black text-center font-bold dark:border-white">Repozytorium muzyczne</header>
<label htmlFor={usernameId}>Nazwa użytkownika</label>
<input
id={usernameId}
class={style.input}
className="w-[32ch] p-2 bg-transparent border border-black rounded focus:outline focus:outline-2 focus:outline-sky-500 focus:dark:outline-sky-200 dark:border-white"
type="text"
value={loginUsername}
autofocus
autoFocus
required
onInput={(e) => setLoginUsername(e.currentTarget.value)}
/>
<label for={passwordId}>Hasło</label>
<label htmlFor={passwordId}>Hasło</label>
<input
id={passwordId}
class={style.input}
className="w-[32ch] p-2 bg-transparent border border-black rounded focus:outline focus:outline-2 focus:outline-sky-500 focus:dark:outline-sky-200 dark:border-white"
type="password"
value={loginPassword}
required
onInput={(e) => setLoginPassword(e.currentTarget.value)}
/>
<button class={style.submit} type="submit">Zaloguj się</button>
<button
className="p-2 bg-stone-300 border-2 border-t-stone-200 border-l-stone-200 border-r-stone-600 border-b-stone-600 rounded dark:bg-stone-700 dark:border-t-stone-600 dark:border-l-stone-600 dark:border-r-stone-900 dark:border-b-stone-900"
type="submit"
>
Zaloguj się
</button>
</form>
</div>
);

View File

@@ -1,6 +1,6 @@
import { UserId } from "common";
import { identity, Option as O } from "effect";
import { useLayoutEffect, useState } from "preact/hooks";
import * as Function from "common/Function";
import { useLayoutEffect, useState } from "react";
export type Update<T> = T | ((prev: T) => T);
export type Updater<T> = (action: Update<T>) => void;
@@ -21,19 +21,19 @@ export interface Store {
readonly loginUsername: string;
readonly loginPassword: string;
readonly user: O.Option<Store.User>;
readonly user: Store.User | null;
readonly setLoginUsername: Updater<string>;
readonly setLoginPassword: Updater<string>;
readonly setUser: Updater<O.Option<Store.User>>;
readonly setUser: Updater<Store.User | null>;
}
let store: Store = Object.freeze<Store>({
loginUsername: "",
loginPassword: "",
user: O.none(),
user: null,
setLoginUsername: (action) => set(mapProp("loginUsername", action)),
setLoginPassword: (action) => set(mapProp("loginPassword", action)),
@@ -78,14 +78,16 @@ function set(action: Partial<Store> | ((store: Store) => Partial<Store>), replac
}
}
export function useStore<T = Store>(selector: Selector<T> = identity as Selector<T>): T {
export function useStore<T = Store>(selector: Selector<T> = Function.identity as Selector<T>): T {
const [state, setState] = useState(() => selector(store));
useLayoutEffect(() => {
const listener = new Listener(selector, setState);
listeners.add(listener);
return () => listeners.delete(listener);
return () => {
listeners.delete(listener);
};
}, []);
return state;

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

1
packages/frontend/src/vite.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,11 @@
/** @type {import("tailwindcss").Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -1,6 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["react", "react-dom"],
},
"references": [
{ "path": "../common" },
{ "path": "../backend" },
],
}

View File

@@ -1,13 +0,0 @@
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import path from "node:path";
/** @type {import("vite").UserConfig} */
export default {
plugins: [vanillaExtractPlugin()],
resolve: {
alias: {
"common": path.resolve(__dirname, "../common/src"),
"make-api": path.resolve(__dirname, "../make-api/src"),
},
},
};

View File

@@ -0,0 +1,12 @@
import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"common": path.resolve(__dirname, "../common/src"),
},
},
});

View File

@@ -1,20 +0,0 @@
{
"name": "make-api",
"license": "UNLICENSED",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"devDependencies": {
"typescript": "catalog:"
},
"dependencies": {
"@effect/schema": "catalog:",
"effect": "catalog:",
"fast-check": "catalog:"
}
}

View File

@@ -1,150 +0,0 @@
import { Schema as S } from "@effect/schema";
import type { Field } from "./Field";
import type { Method } from "./Method";
import type { MimeTypePredicate } from "./MimeType";
import { QueryEmpty, type Query } from "./Query";
import { RequestBodyFile, RequestBodyJson, RequestBodyMultipart, RequestBodyNone, RequestBodyText, RequestBodyUrlEncoded, type RequestBody } from "./RequestBody";
import { ResponseBodyFile, ResponseBodyJson, ResponseBodyNone, ResponseBodyText, ResponseEmpty, type ResponseBody } from "./ResponseBody";
import { Route, RouteLiteral, RouteParam } from "./Route";
export interface Api<Props extends Api.PropsAny> {
readonly method: Method;
readonly props: Props;
}
export namespace Api {
export type Any = Api<PropsAny>;
export type Props<T extends Api.Any> = T extends Api<infer Props> ? Props : never;
export type PropsAny = {
readonly route: Route.Any;
readonly request: RequestBody.Any;
readonly response: { readonly [_: number]: ResponseBody.Any };
readonly query: Query.Any;
};
}
export const Api = <const Props extends Api.PropsAny>(method: Method, props: Props): Api<Props> => Object.freeze<Api<Props>>({
method,
props,
});
// --- BUILDERS ----------------------------------------------------------------
export type RouteInputLiteral<Literal extends string> = Literal;
export type RouteInputParam<Name extends string, Schema extends S.Schema<any, string>> = readonly [Name, Schema];
export namespace RouteInputLiteral {
export type Any = RouteInputLiteral<string>;
}
export namespace RouteInputParam {
export type Any = RouteInputParam<string, S.Schema<any, string>>;
}
export namespace RouteInput {
export type Any = readonly (
| RouteInputLiteral.Any
| RouteInputParam.Any
)[];
export type Route<R extends RouteInput.Any> = { readonly [K in keyof R]:
R[K] extends RouteInputLiteral<infer Literal> ? RouteLiteral<Literal>
: R[K] extends RouteInputParam<infer Name, infer Schema> ? RouteParam<Name, Schema>
: never;
};
}
export const make = <const R extends RouteInput.Any>(method: Method, ...routeInput: R): Api<{
readonly route: RouteInput.Route<R>,
readonly request: RequestBodyNone,
readonly response: {},
readonly query: {},
}> => Api(method, {
route: Route(...routeInput.map((r) => typeof r === "string" ? RouteLiteral(r) : RouteParam(r[0], r[1])) as RouteInput.Route<R>),
request: RequestBodyNone,
response: ResponseEmpty,
query: QueryEmpty,
});
export const requestBodyText = <const Schema extends S.Schema<any, string>>(schema: Schema) =>
<const T extends Api.Any>({ method, props }: T): Api<{
readonly route: T["props"]["route"],
readonly request: RequestBodyText<Schema>,
readonly response: T["props"]["response"],
readonly query: T["props"]["query"],
}> => Api(method, { ...props, request: RequestBodyText(schema) });
export const requestBodyJson = <const Schema extends S.Schema.Any>(schema: Schema) =>
<const T extends Api.Any>({ method, props }: T): Api<{
readonly route: T["props"]["route"],
readonly request: RequestBodyJson<Schema>,
readonly response: T["props"]["response"],
readonly query: T["props"]["query"],
}> => Api(method, { ...props, request: RequestBodyJson(schema) });
export const requestBodyUrlEncoded = <const Fields extends { readonly [_: string]: Field.AnyUrlEncoded }>(fields: Fields) =>
<const T extends Api.Any>({ method, props }: T): Api<{
readonly route: T["props"]["route"],
readonly request: RequestBodyUrlEncoded<Fields>,
readonly response: T["props"]["response"],
readonly query: T["props"]["query"],
}> => Api(method, { ...props, request: RequestBodyUrlEncoded(fields) });
export const requestBodyMultipart = <const Fields extends { readonly [_: string]: Field.Any }>(fields: Fields) =>
<const T extends Api.Any>({ method, props }: T): Api<{
readonly route: T["props"]["route"],
readonly request: RequestBodyMultipart<Fields>,
readonly response: T["props"]["response"],
readonly query: T["props"]["query"],
}> => Api(method, { ...props, request: RequestBodyMultipart(fields) });
export const requestBodyFile = <const MimeType extends string>(mimeType: MimeTypePredicate<MimeType>) =>
<const T extends Api.Any>({ method, props }: T): Api<{
readonly route: T["props"]["route"],
readonly request: RequestBodyFile<MimeType>,
readonly response: T["props"]["response"],
readonly query: T["props"]["query"],
}> => Api(method, { ...props, request: RequestBodyFile(mimeType) });
export const responseBodyNone = <const Status extends number>(status: Status) =>
<const T extends Api.Any>({ method, props: { response, ...props } }: T): Api<{
readonly route: T["props"]["route"],
readonly request: T["props"]["request"],
readonly response: { readonly [K in keyof T["props"]["response"] | Status]: K extends Status ? ResponseBodyNone : T["props"]["response"][K] },
readonly query: T["props"]["query"],
}> => Api(method, { ...props, response: Object.freeze<any>({ ...response, [status]: ResponseBodyNone }) });
export const responseBodyText = <const Status extends number, const Schema extends S.Schema<any, string>>(status: Status, schema: Schema) =>
<const T extends Api.Any>({ method, props: { response, ...props } }: T): Api<{
readonly route: T["props"]["route"],
readonly request: T["props"]["request"],
readonly response: { readonly [K in keyof T["props"]["response"] | Status]: K extends Status ? ResponseBodyText<Schema> : T["props"]["response"][K] },
readonly query: T["props"]["query"],
}> => Api(method, { ...props, response: Object.freeze<any>({ ...response, [status]: ResponseBodyText(schema) }) });
export const responseBodyJson = <const Status extends number, const Schema extends S.Schema.Any>(status: Status, schema: Schema) =>
<const T extends Api.Any>({ method, props: { response, ...props } }: T): Api<{
readonly route: T["props"]["route"],
readonly request: T["props"]["request"],
readonly response: { readonly [K in keyof T["props"]["response"] | Status]: K extends Status ? ResponseBodyJson<Schema> : T["props"]["response"][K] },
readonly query: T["props"]["query"],
}> => Api(method, { ...props, response: Object.freeze<any>({ ...response, [status]: ResponseBodyJson(schema) }) });
export const responseBodyFile = <const Status extends number, const MimeType extends string>(status: Status, mimeType: MimeTypePredicate<MimeType>) =>
<const T extends Api.Any>({ method, props: { response, ...props } }: T): Api<{
readonly route: T["props"]["route"],
readonly request: T["props"]["request"],
readonly response: { readonly [K in keyof T["props"]["response"] | Status]: K extends Status ? ResponseBodyFile<MimeType> : T["props"]["response"][K] },
readonly query: T["props"]["query"],
}> => Api(method, { ...props, response: Object.freeze<any>({ ...response, [status]: ResponseBodyFile(mimeType) }) });
export const query = <const Query extends Query.Any>(query: Query) =>
<const T extends Api.Any>({ method, props: { query: _, ...props } }: T): Api<{
readonly route: T["props"]["route"],
readonly request: T["props"]["request"],
readonly response: T["props"]["response"],
readonly query: Query,
}> => Api(method, { ...props, query });

View File

@@ -1,68 +0,0 @@
import { Schema as S } from "@effect/schema";
import type { MimeTypePredicate } from "./MimeType";
import type { Multiplicity } from "./Multiplicity";
// --- INTERFACES --------------------------------------------------------------
export interface FieldText<M extends Multiplicity, Schema extends S.Schema<any, string>> {
readonly _tag: "Text";
readonly multiplicity: M;
readonly schema: Schema;
}
export interface FieldJson<M extends Multiplicity, Schema extends S.Schema.Any> {
readonly _tag: "Json";
readonly multiplicity: M;
readonly schema: Schema;
}
export interface FieldFile<M extends Multiplicity, MimeType extends string> {
readonly _tag: "File";
readonly multiplicity: M;
readonly mimeType: MimeTypePredicate<MimeType>;
}
export namespace FieldText {
export type Any = FieldText<Multiplicity, S.Schema<any, string>>;
}
export namespace FieldJson {
export type Any = FieldJson<Multiplicity, S.Schema.Any>;
}
export namespace FieldFile {
export type Any = FieldFile<Multiplicity, string>;
}
export namespace Field {
export type Any =
| FieldText.Any
| FieldJson.Any
| FieldFile.Any
;
export type AnyUrlEncoded =
| FieldText.Any
| FieldJson.Any
;
}
// --- CONSTRUCTORS ------------------------------------------------------------
export const FieldText = <const M extends Multiplicity, const Schema extends S.Schema<any, string>>(multiplicity: M, schema: Schema): FieldText<M, Schema> => Object.freeze<FieldText<M, Schema>>({
_tag: "Text",
multiplicity,
schema,
});
export const FieldJson = <const M extends Multiplicity, const Schema extends S.Schema.Any>(multiplicity: M, schema: Schema): FieldJson<M, Schema> => Object.freeze<FieldJson<M, Schema>>({
_tag: "Json",
multiplicity,
schema,
});
export const FieldFile = <const M extends Multiplicity, const MimeType extends string>(multiplicity: M, mimeType: MimeTypePredicate<MimeType>): FieldFile<M, MimeType> => Object.freeze<FieldFile<M, MimeType>>({
_tag: "File",
multiplicity,
mimeType,
});

View File

@@ -1,7 +0,0 @@
export type Method =
| "GET"
| "POST"
| "PUT"
| "DELETE"
| "PATCH"
;

View File

@@ -1,3 +0,0 @@
import { Predicate } from "effect"
export type MimeTypePredicate<T extends string> = T | readonly T[] | Predicate.Refinement<string, T>;

View File

@@ -1,17 +0,0 @@
import type { Option as O } from "effect";
export type Multiplicity =
| "single"
| "optional"
| "multiple"
| "any"
;
export namespace Multiplicity {
export type With<M extends Multiplicity, T> = {
single: T,
optional: O.Option<T>,
multiple: readonly [T, ...T[]],
any: readonly T[],
}[M];
}

View File

@@ -1,7 +0,0 @@
import type { Field } from "./Field";
export namespace Query {
export type Any = { readonly [_: string]: Field.AnyUrlEncoded };
}
export const QueryEmpty: {} = Object.freeze<{}>({});

View File

@@ -1,96 +0,0 @@
import { Schema as S } from "@effect/schema";
import type { Field } from "./Field";
import type { MimeTypePredicate } from "./MimeType";
// --- INTERFACES --------------------------------------------------------------
export interface RequestBodyNone {
readonly _tag: "None";
}
export interface RequestBodyText<Schema extends S.Schema<any, string>> {
readonly _tag: "Text";
readonly schema: Schema;
}
export interface RequestBodyJson<Schema extends S.Schema.Any> {
readonly _tag: "Json";
readonly schema: Schema;
}
export interface RequestBodyUrlEncoded<Fields extends { readonly [_: string]: Field.AnyUrlEncoded }> {
readonly _tag: "UrlEncoded";
readonly fields: Readonly<Fields>;
}
export interface RequestBodyMultipart<Fields extends { readonly [_: string]: Field.Any }> {
readonly _tag: "Multipart";
readonly fields: Readonly<Fields>;
}
export interface RequestBodyFile<MimeType extends string> {
readonly _tag: "File";
readonly mimeType: MimeTypePredicate<MimeType>;
}
export namespace RequestBodyText {
export type Any = RequestBodyText<S.Schema<any, string>>;
}
export namespace RequestBodyJson {
export type Any = RequestBodyJson<S.Schema.Any>;
}
export namespace RequestBodyUrlEncoded {
export type Any = RequestBodyUrlEncoded<{}>;
}
export namespace RequestBodyMultipart {
export type Any = RequestBodyMultipart<{}>;
}
export namespace RequestBodyFile {
export type Any = RequestBodyFile<string>;
}
export namespace RequestBody {
export type Any =
| RequestBodyNone
| RequestBodyText.Any
| RequestBodyJson.Any
| RequestBodyUrlEncoded.Any
| RequestBodyMultipart.Any
| RequestBodyFile.Any
;
}
// --- CONSTRUCTORS ------------------------------------------------------------
export const RequestBodyNone: RequestBodyNone = Object.freeze<RequestBodyNone>({
_tag: "None",
});
export const RequestBodyText = <const Schema extends S.Schema<any, string>>(schema: Schema): RequestBodyText<Schema> => Object.freeze<RequestBodyText<Schema>>({
_tag: "Text",
schema,
});
export const RequestBodyJson = <const Schema extends S.Schema.Any>(schema: Schema): RequestBodyJson<Schema> => Object.freeze<RequestBodyJson<Schema>>({
_tag: "Json",
schema,
});
export const RequestBodyUrlEncoded = <const Fields extends { readonly [_: string]: Field.AnyUrlEncoded }>(fields: Fields): RequestBodyUrlEncoded<Fields> => Object.freeze<RequestBodyUrlEncoded<Fields>>({
_tag: "UrlEncoded",
fields,
});
export const RequestBodyMultipart = <const Fields extends { readonly [_: string]: Field.Any }>(fields: Fields): RequestBodyMultipart<Fields> => Object.freeze<RequestBodyMultipart<Fields>>({
_tag: "Multipart",
fields,
});
export const RequestBodyFile = <const MimeType extends string>(mimeType: MimeTypePredicate<MimeType>): RequestBodyFile<MimeType> => Object.freeze<RequestBodyFile<MimeType>>({
_tag: "File",
mimeType,
});

View File

@@ -1,67 +0,0 @@
import { Schema as S } from "@effect/schema";
import type { MimeTypePredicate } from "./MimeType";
// --- INTERFACES --------------------------------------------------------------
export interface ResponseBodyNone {
readonly _tag: "None";
}
export interface ResponseBodyText<Schema extends S.Schema<any, string>> {
readonly _tag: "Text";
readonly schema: Schema;
}
export interface ResponseBodyJson<Schema extends S.Schema.Any> {
readonly _tag: "Json";
readonly schema: Schema;
}
export interface ResponseBodyFile<MimeType extends string> {
readonly _tag: "File";
readonly mimeType: MimeTypePredicate<MimeType>;
}
export namespace ResponseBodyText {
export type Any = ResponseBodyText<S.Schema<any, string>>;
}
export namespace ResponseBodyJson {
export type Any = ResponseBodyJson<S.Schema.Any>;
}
export namespace ResponseBodyFile {
export type Any = ResponseBodyFile<string>;
}
export namespace ResponseBody {
export type Any =
| ResponseBodyNone
| ResponseBodyText.Any
| ResponseBodyJson.Any
| ResponseBodyFile.Any
;
}
// --- CONSTRUCTORS ------------------------------------------------------------
export const ResponseBodyNone: ResponseBodyNone = Object.freeze<ResponseBodyNone>({
_tag: "None",
});
export const ResponseBodyText = <const Schema extends S.Schema<any, string>>(schema: Schema): ResponseBodyText<Schema> => Object.freeze<ResponseBodyText<Schema>>({
_tag: "Text",
schema,
});
export const ResponseBodyJson = <const Schema extends S.Schema.Any>(schema: Schema): ResponseBodyJson<Schema> => Object.freeze<ResponseBodyJson<Schema>>({
_tag: "Json",
schema,
});
export const ResponseBodyFile = <const MimeType extends string>(mimeType: MimeTypePredicate<MimeType>): ResponseBodyFile<MimeType> => Object.freeze<ResponseBodyFile<MimeType>>({
_tag: "File",
mimeType,
});
export const ResponseEmpty: {} = Object.freeze<{}>({});

View File

@@ -1,44 +0,0 @@
import { Schema as S } from "@effect/schema";
// --- INTERFACES --------------------------------------------------------------
export interface RouteLiteral<Literal extends string> {
readonly _tag: "Literal";
readonly literal: Literal;
}
export interface RouteParam<Name extends string, Schema extends S.Schema<any, string>> {
readonly _tag: "Param";
readonly name: Name;
readonly schema: Schema;
}
export namespace RouteLiteral {
export type Any = RouteLiteral<string>;
}
export namespace RouteParam {
export type Any = RouteParam<string, S.Schema<any, string>>;
}
export namespace Route {
export type Any = readonly (
| RouteLiteral.Any
| RouteParam.Any
)[];
}
// --- CONSTRUCTORS ------------------------------------------------------------
export const RouteLiteral = <const Literal extends string>(literal: Literal): RouteLiteral<Literal> => Object.freeze<RouteLiteral<Literal>>({
_tag: "Literal",
literal,
});
export const RouteParam = <const Name extends string, const Schema extends S.Schema<any, string>>(name: Name, schema: Schema): RouteParam<Name, Schema> => Object.freeze<RouteParam<Name, Schema>>({
_tag: "Param",
name,
schema,
});
export const Route = <const Route extends Route.Any>(...route: Route): Route => Object.freeze<Route>(route);

View File

@@ -1,9 +0,0 @@
export * as Api from "./Api";
export * from "./Field";
export * from "./Method";
export * from "./MimeType";
export * from "./Multiplicity";
export * from "./Query";
export * from "./RequestBody";
export * from "./ResponseBody";
export * from "./Route";

View File

@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.base.json",
}

1923
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,20 @@ packages:
- 'packages/*'
catalog:
'@effect/schema': '^0.70.1'
'@vanilla-extract/css': '^1.15.3'
'@vanilla-extract/vite-plugin': '^4.0.13'
effect: '^3.6.0'
fast-check: '^3.20.0'
preact: '^10.23.1'
preact-iso: '^2.6.3'
typescript: '^5.6.0-beta'
ulid: '^2.3.0'
vite: '^5.3.5'
'@elysiajs/cors': '^1.1.1'
'@elysiajs/eden': '^1.1.3'
'@types/bun': '^1.1.13'
'@types/react': '^18.3.12'
'@types/react-dom': '^18.3.1'
'@vitejs/plugin-react': '^4.3.3'
autoprefixer: '^10.4.20'
elysia: '^1.1.25'
kysely: '^0.27.4'
kysely-bun-sqlite: '^0.3.2'
postcss: '^8.4.49'
react: '^18.3.1'
react-dom: '^18.3.1'
react-router-dom: '^6.28.0'
tailwindcss: '^3.4.15'
typescript: '^5.6.3'
vite: '^5.4.11'

View File

@@ -7,8 +7,8 @@
"rootDir": "${configDir}/src",
"outDir": "${configDir}/dist",
"module": "ES2020",
"moduleResolution": "Bundler",
"module": "ES2022",
"moduleResolution": "bundler",
"moduleDetection": "force",
"types": [],
@@ -16,7 +16,6 @@
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"exactOptionalPropertyTypes": true,
@@ -28,9 +27,9 @@
"paths": {
"common": ["./packages/common/src/index.ts"],
"common/api": ["./packages/common/src/api.ts"],
"common/db": ["./packages/common/src/db.ts"],
"make-api": ["./packages/make-api/src/index.ts"],
"common/*": ["./packages/common/src/*.ts"],
"backend": ["./packages/backend/src/index.ts"],
"backend/*": ["./packages/backend/src/*.ts"],
},
},
"include": ["${configDir}/src"],