Port to elysia, tailwind (no effect)
This commit is contained in:
@@ -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
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"editorconfig.editorconfig",
|
||||
],
|
||||
}
|
||||
@@ -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:"
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 sessionId = cookie.sessionId.value;
|
||||
if (sessionId === undefined) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
const res = await db
|
||||
.updateTable("Session")
|
||||
.set({ expiresAt: sql`datetime('now', '+7 days') `})
|
||||
.where("sessionId", "=", SessionId(sessionId))
|
||||
.returning(["userId"])
|
||||
.executeTakeFirst();
|
||||
|
||||
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();
|
||||
if (res === undefined) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
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];
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.selectAll()
|
||||
.where("userId", "=", res.userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
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" });
|
||||
if (user === undefined) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
const sessionData = SessionData.make({ userId: user.userId });
|
||||
const sessionId = yield* db.createSession(sessionData);
|
||||
return { user };
|
||||
})
|
||||
.onTransform(async ({ db, request, server }) => {
|
||||
|
||||
const responseData = Login.props.response[200].schema.make({
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin,
|
||||
});
|
||||
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;
|
||||
|
||||
const responseJson = yield* pipe(
|
||||
responseData,
|
||||
S.encode(Login.props.response[200].schema),
|
||||
Effect.map(JSON.stringify),
|
||||
);
|
||||
const responseArray = new TextEncoder().encode(responseJson);
|
||||
await db
|
||||
.insertInto("AccessLog")
|
||||
.values({ requestId, timestamp, method, pathname, query, ip })
|
||||
.execute();
|
||||
|
||||
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),
|
||||
);
|
||||
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
|
||||
})
|
||||
.get("/me", async ({ user }) => {
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const responseData = Me.props.response[200].schema.make({
|
||||
return {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin,
|
||||
});
|
||||
admin: user.admin !== 0,
|
||||
};
|
||||
})
|
||||
.post("/login", async ({ db, body: { username, password }, cookie }) => {
|
||||
|
||||
const responseJson = yield* pipe(
|
||||
responseData,
|
||||
S.encode(Me.props.response[200].schema),
|
||||
Effect.map(JSON.stringify),
|
||||
);
|
||||
const responseArray = new TextEncoder().encode(responseJson);
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.selectAll()
|
||||
.where("username", "=", username)
|
||||
.executeTakeFirst();
|
||||
|
||||
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,
|
||||
}
|
||||
});
|
||||
}
|
||||
if (user === undefined) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
|
||||
return new Response(null, { status: 404 });
|
||||
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 }),
|
||||
}),
|
||||
Effect.catchAllDefect(() => new RequestError({ status: 500 })),
|
||||
Effect.catchTag("RequestError", (error) => Effect.succeed(error.response)),
|
||||
);
|
||||
})
|
||||
.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;
|
||||
|
||||
162
packages/backend/src/database.ts
Normal file
162
packages/backend/src/database.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
}),
|
||||
));
|
||||
@@ -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>() { }
|
||||
@@ -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;
|
||||
}),
|
||||
}));
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
|
||||
35
packages/common/src/Brand.ts
Normal file
35
packages/common/src/Brand.ts
Normal 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>;
|
||||
};
|
||||
3
packages/common/src/Function.ts
Normal file
3
packages/common/src/Function.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const identity = <A>(a: A): A => a;
|
||||
|
||||
export const unsafeCoerce: <A, B>(a: A) => B = identity as any;
|
||||
1
packages/common/src/Types.ts
Normal file
1
packages/common/src/Types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never;
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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>();
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "../make-api" },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/frontend/postcss.config.js
Normal file
6
packages/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
26
packages/frontend/src/app.tsx
Normal file
26
packages/frontend/src/app.tsx
Normal 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>
|
||||
);
|
||||
4
packages/frontend/src/client.ts
Normal file
4
packages/frontend/src/client.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "backend/app";
|
||||
|
||||
export const client = treaty<App>("localhost:3000");
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { render } from "preact";
|
||||
import { App } from "./App";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
render(<App />, document.body);
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
if (error !== null) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const responseData = yield* pipe(
|
||||
Effect.promise(() => res.json()),
|
||||
Effect.flatMap(S.decodeUnknown(Me.props.response[200].schema)),
|
||||
Effect.orDie,
|
||||
);
|
||||
setUser(data);
|
||||
};
|
||||
|
||||
setUser(O.some(responseData));
|
||||
});
|
||||
|
||||
const fiber = Effect.runFork(effect);
|
||||
return () => Effect.runFork(Fiber.interrupt(fiber), { immediate: true });
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const logoutEffect = useMemo(() => Effect.gen(function* () {
|
||||
const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/logout", {
|
||||
method: "POST",
|
||||
signal,
|
||||
credentials: "include",
|
||||
}));
|
||||
const onLogoutClick = async () => {
|
||||
const { data, error } = await client.logout.post();
|
||||
|
||||
if (!res.ok) {
|
||||
yield* Effect.die(new Error("Response was not ok"));
|
||||
if (error !== null) {
|
||||
console.error("Response was not ok");
|
||||
}
|
||||
|
||||
setUser(O.none());
|
||||
|
||||
route("/login");
|
||||
}), []);
|
||||
|
||||
const onLogoutClick = () => {
|
||||
Effect.runFork(logoutEffect);
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
3
packages/frontend/src/style.css
Normal file
3
packages/frontend/src/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
1
packages/frontend/src/vite.d.ts
vendored
Normal file
1
packages/frontend/src/vite.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
11
packages/frontend/tailwind.config.js
Normal file
11
packages/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["react", "react-dom"],
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../common" },
|
||||
{ "path": "../backend" },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
};
|
||||
12
packages/frontend/vite.config.ts
Normal file
12
packages/frontend/vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
export type Method =
|
||||
| "GET"
|
||||
| "POST"
|
||||
| "PUT"
|
||||
| "DELETE"
|
||||
| "PATCH"
|
||||
;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Predicate } from "effect"
|
||||
|
||||
export type MimeTypePredicate<T extends string> = T | readonly T[] | Predicate.Refinement<string, T>;
|
||||
@@ -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];
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { Field } from "./Field";
|
||||
|
||||
export namespace Query {
|
||||
export type Any = { readonly [_: string]: Field.AnyUrlEncoded };
|
||||
}
|
||||
|
||||
export const QueryEmpty: {} = Object.freeze<{}>({});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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<{}>({});
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
}
|
||||
1923
pnpm-lock.yaml
generated
1923
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user