Port to elysia, tailwind (no effect)
This commit is contained in:
@@ -6,10 +6,11 @@ end_of_line = lf
|
|||||||
indent_size = 4
|
indent_size = 4
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
tab_width = 4
|
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
[*.{yml,yaml}]
|
[*.{yml,yaml}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
indent_style = space
|
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",
|
"name": "music-repo",
|
||||||
"license": "UNLICENSED",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start:backend": "bun run packages/backend/src/index.ts",
|
"backend:dev": "bun run --watch packages/backend/src/app.ts",
|
||||||
"start:frontend": "pnpm --filter frontend exec vite --open"
|
"frontend:dev": "pnpm --filter frontend exec vite --open"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"license": "UNLICENSED",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/bun": "latest"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/schema": "catalog:",
|
"@elysiajs/cors": "catalog:",
|
||||||
"common": "workspace:^",
|
"common": "workspace:^",
|
||||||
"effect": "catalog:",
|
"elysia": "catalog:",
|
||||||
"fast-check": "catalog:",
|
"kysely": "catalog:",
|
||||||
"make-api": "workspace:^",
|
"kysely-bun-sqlite": "catalog:"
|
||||||
"ulid": "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 cors from "@elysiajs/cors";
|
||||||
import { SessionId } from "common";
|
import { PieceId, RequestId, SessionId } from "common";
|
||||||
import { CreatePiece, GetPieces, UpdatePiece, DeletePiece, Login, Logout, Me } from "common/api";
|
import * as Function from "common/Function";
|
||||||
import { AccessLog, SessionData } from "common/db";
|
import { Elysia, error, t } from "elysia";
|
||||||
import { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect";
|
import { sql } from "kysely";
|
||||||
import { constant } from "effect/Function";
|
import { generateSessionId, initDatabase } from "./database";
|
||||||
import { Api } from "make-api";
|
|
||||||
import { brotliCompress } from "node:zlib";
|
|
||||||
import { RequestError } from "./RequestError";
|
|
||||||
import { Database } from "./services/database";
|
|
||||||
import { Request } from "./services/request";
|
|
||||||
|
|
||||||
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
|
const sessionId = cookie.sessionId.value;
|
||||||
&& req.path.length === api.props.route.length
|
if (sessionId === undefined) {
|
||||||
&& api.props.route.every((token, i) => pipe(
|
return { user: null };
|
||||||
Match.value(token),
|
|
||||||
Match.tags({
|
|
||||||
Literal: ({ literal }) => req.path[i] === literal,
|
|
||||||
Param: ({ schema }) => E.isRight(S.decodeUnknownEither(schema)(req.path[i])),
|
|
||||||
}),
|
|
||||||
Match.exhaustive,
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
const COOKIE_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*=\s*([!#$%&'()*+./:<=>?@[\]^_`{|}~a-z0-9-]+)/i;
|
|
||||||
const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i;
|
|
||||||
|
|
||||||
const acceptsBrotli = Effect.gen(function* () {
|
|
||||||
const req = yield* Request;
|
|
||||||
|
|
||||||
const acceptEncoding = req.headers["accept-encoding"];
|
|
||||||
|
|
||||||
/* NOTE According to RFC9110, section 12.5.3:
|
|
||||||
*
|
|
||||||
* If no Accept-Encoding header field is in the request, any content coding
|
|
||||||
* is considered acceptable by the user agent.
|
|
||||||
*
|
|
||||||
* This means that we could encode the response as brotli in the absence of
|
|
||||||
* Accept-Encoding header, but we'd rather be nice and assume the user agent
|
|
||||||
* does not support any encoding.
|
|
||||||
*/
|
|
||||||
if (!acceptEncoding) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codings = acceptEncoding
|
|
||||||
.split(/\s*,\s*/)
|
|
||||||
.map((token) => token.match(WEIGHTED_TOKEN_REGEX))
|
|
||||||
.filter((match) => match !== null)
|
|
||||||
.map((match) => ({
|
|
||||||
coding: match[1],
|
|
||||||
...(match[2] ? { quality: Number.parseFloat(match[2]) } : undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return codings.some(({ coding, quality = 1 }) => coding === "br" && quality > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const getCookie = (name: string): Effect.Effect<O.Option<string>, never, Request> => Effect.gen(function* () {
|
|
||||||
const req = yield* Request;
|
|
||||||
|
|
||||||
const cookieString = req.headers["cookie"];
|
|
||||||
|
|
||||||
if (!cookieString) {
|
|
||||||
return O.none();
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieValue = cookieString
|
|
||||||
.split(/\s*;\s*/)
|
|
||||||
.map((cookie) => cookie.match(COOKIE_REGEX))
|
|
||||||
.filter((match): match is RegExpMatchArray => match !== null && match[1] === name)
|
|
||||||
.map((match) => match[2])[0];
|
|
||||||
|
|
||||||
return O.fromNullable(cookieValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestJson = <A, I, R>(schema: S.Schema<A, I, R>) => {
|
|
||||||
const decoder = S.decodeUnknown(schema);
|
|
||||||
return pipe(
|
|
||||||
Request,
|
|
||||||
Effect.tap((req) => req.headers["content-type"] === "application/json"
|
|
||||||
? Effect.void
|
|
||||||
: new RequestError({ status: 415 })
|
|
||||||
),
|
|
||||||
Effect.flatMap((req) => req.json),
|
|
||||||
Effect.flatMap(decoder),
|
|
||||||
Effect.mapError((error) => new RequestError({ status: 400, body: error.message })),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const app = pipe(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
|
|
||||||
const db = yield* Database;
|
|
||||||
const req = yield* Request;
|
|
||||||
|
|
||||||
const timestamp = yield* DateTime.now;
|
|
||||||
|
|
||||||
const accessLog = AccessLog.make({
|
|
||||||
timestamp,
|
|
||||||
requestId: req.requestId,
|
|
||||||
method: req.method,
|
|
||||||
pathname: req.pathname,
|
|
||||||
query: req.query,
|
|
||||||
ip: O.getOrNull(req.ip),
|
|
||||||
});
|
|
||||||
|
|
||||||
yield* db.createAccessLog(accessLog);
|
|
||||||
console.log(`${DateTime.toDateUtc(accessLog.timestamp).toISOString()} ${accessLog.requestId} ${accessLog.method} ${accessLog.pathname}?${new URLSearchParams(accessLog.query).toString()} ${accessLog.ip}`);
|
|
||||||
|
|
||||||
if (yield* match(CreatePiece)) {
|
|
||||||
return new Response(null, { status: 501 });
|
|
||||||
} else if (yield* match(GetPieces)) {
|
|
||||||
return new Response(null, { status: 501 });
|
|
||||||
} else if (yield* match(UpdatePiece)) {
|
|
||||||
return new Response(null, { status: 501 });
|
|
||||||
} else if (yield* match(DeletePiece)) {
|
|
||||||
return new Response(null, { status: 501 });
|
|
||||||
} else if (yield* match(Login)) {
|
|
||||||
|
|
||||||
const body = yield* requestJson(Login.props.request.schema);
|
|
||||||
|
|
||||||
const user = yield* pipe(
|
|
||||||
db.getUserByUsername(body.username),
|
|
||||||
Effect.catchTag("NoSuchElementException", () => new RequestError({ status: 401, body: "Invalid username or password" })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const valid = yield* Effect.promise(() => Bun.password.verify(body.password, user.password));
|
|
||||||
if (!valid) {
|
|
||||||
return yield* new RequestError({ status: 401, body: "Invalid username or password" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionData = SessionData.make({ userId: user.userId });
|
|
||||||
const sessionId = yield* db.createSession(sessionData);
|
|
||||||
|
|
||||||
const responseData = Login.props.response[200].schema.make({
|
|
||||||
userId: user.userId,
|
|
||||||
username: user.username,
|
|
||||||
admin: user.admin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseJson = yield* pipe(
|
|
||||||
responseData,
|
|
||||||
S.encode(Login.props.response[200].schema),
|
|
||||||
Effect.map(JSON.stringify),
|
|
||||||
);
|
|
||||||
const responseArray = new TextEncoder().encode(responseJson);
|
|
||||||
|
|
||||||
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
|
|
||||||
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
|
||||||
|
|
||||||
if (yield* acceptsBrotli) {
|
|
||||||
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
|
|
||||||
brotliCompress(responseArray, (error, result) => {
|
|
||||||
if (error !== null) {
|
|
||||||
callback(Effect.die(error));
|
|
||||||
} else {
|
|
||||||
callback(Effect.succeed(result));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return new Response(responseBrotli, {
|
|
||||||
headers: {
|
|
||||||
"Content-Encoding": "br",
|
|
||||||
"Content-Length": responseArray.byteLength.toString(),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Set-Cookie": setCookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return new Response(responseArray, {
|
|
||||||
headers: {
|
|
||||||
"Content-Length": responseArray.byteLength.toString(),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Set-Cookie": setCookie,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (yield* match(Logout)) {
|
|
||||||
|
|
||||||
const sessionId = yield* pipe(
|
|
||||||
getCookie("sessionId"),
|
|
||||||
Effect.flatten,
|
|
||||||
Effect.flatMap(S.decodeUnknown(SessionId)),
|
|
||||||
Effect.catchAll(constant(Effect.succeed(null))),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sessionId !== null) {
|
|
||||||
db.deleteSession(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
|
||||||
return new Response(null, {
|
|
||||||
status: 204,
|
|
||||||
headers: {
|
|
||||||
"Set-Cookie": setCookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (yield* match(Me)) {
|
|
||||||
|
|
||||||
const sessionId = yield* pipe(
|
|
||||||
getCookie("sessionId"),
|
|
||||||
Effect.flatten,
|
|
||||||
Effect.flatMap(S.decodeUnknown(SessionId)),
|
|
||||||
Effect.mapError(() => new RequestError({ status: 401, body: "Session invalid or expired" })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const sessionData = yield* pipe(
|
|
||||||
db.getAndRefreshSessionData(sessionId),
|
|
||||||
Effect.optionFromOptional,
|
|
||||||
Effect.map(O.getOrNull),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sessionData === null) {
|
|
||||||
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
|
||||||
return new Response(null, {
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
"Set-Cookie": setCookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = yield* pipe(
|
|
||||||
db.getUserById(sessionData.userId),
|
|
||||||
Effect.optionFromOptional,
|
|
||||||
Effect.map(O.getOrNull),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (user === null) {
|
|
||||||
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
|
||||||
return new Response(null, {
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
"Set-Cookie": setCookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = Me.props.response[200].schema.make({
|
|
||||||
userId: user.userId,
|
|
||||||
username: user.username,
|
|
||||||
admin: user.admin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseJson = yield* pipe(
|
|
||||||
responseData,
|
|
||||||
S.encode(Me.props.response[200].schema),
|
|
||||||
Effect.map(JSON.stringify),
|
|
||||||
);
|
|
||||||
const responseArray = new TextEncoder().encode(responseJson);
|
|
||||||
|
|
||||||
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
|
|
||||||
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
|
||||||
|
|
||||||
if (yield* acceptsBrotli) {
|
|
||||||
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
|
|
||||||
brotliCompress(responseArray, (error, result) => {
|
|
||||||
if (error !== null) {
|
|
||||||
callback(Effect.die(error));
|
|
||||||
} else {
|
|
||||||
callback(Effect.succeed(result));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return new Response(responseBrotli, {
|
|
||||||
headers: {
|
|
||||||
"Content-Encoding": "br",
|
|
||||||
"Content-Length": responseArray.byteLength.toString(),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Set-Cookie": setCookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return new Response(responseArray, {
|
|
||||||
headers: {
|
|
||||||
"Content-Length": responseArray.byteLength.toString(),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Set-Cookie": setCookie,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 404 });
|
const res = await db
|
||||||
}),
|
.updateTable("Session")
|
||||||
Effect.catchAllDefect(() => new RequestError({ status: 500 })),
|
.set({ expiresAt: sql`datetime('now', '+7 days') `})
|
||||||
Effect.catchTag("RequestError", (error) => Effect.succeed(error.response)),
|
.where("sessionId", "=", SessionId(sessionId))
|
||||||
);
|
.returning(["userId"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (res === undefined) {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db
|
||||||
|
.selectFrom("User")
|
||||||
|
.selectAll()
|
||||||
|
.where("userId", "=", res.userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (user === undefined) {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user };
|
||||||
|
})
|
||||||
|
.onTransform(async ({ db, request, server }) => {
|
||||||
|
|
||||||
|
const requestId = RequestId(Bun.randomUUIDv7("hex"));
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const { method } = request;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const { pathname } = url;
|
||||||
|
const query = JSON.stringify(Object.fromEntries(url.searchParams.entries()));
|
||||||
|
const ip = server?.requestIP(request)?.address ?? null;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insertInto("AccessLog")
|
||||||
|
.values({ requestId, timestamp, method, pathname, query, ip })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
|
||||||
|
})
|
||||||
|
.get("/me", async ({ user }) => {
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
admin: user.admin !== 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.post("/login", async ({ db, body: { username, password }, cookie }) => {
|
||||||
|
|
||||||
|
const user = await db
|
||||||
|
.selectFrom("User")
|
||||||
|
.selectAll()
|
||||||
|
.where("username", "=", username)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (user === undefined) {
|
||||||
|
return error("Unauthorized", "Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await Bun.password.verify(password, user.password);
|
||||||
|
if (!valid) {
|
||||||
|
return error("Unauthorized", "Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
await db
|
||||||
|
.insertInto("Session")
|
||||||
|
.values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const expiresAt = new Date().getTime() + 604800000;
|
||||||
|
cookie.sessionId.set({
|
||||||
|
value: sessionId,
|
||||||
|
expires: new Date(expiresAt),
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "none",
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
admin: user.admin !== 0,
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
username: t.String({ minLength: 1 }),
|
||||||
|
password: t.String({ minLength: 1 }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.post("/logout", async ({ db, cookie, set }) => {
|
||||||
|
|
||||||
|
set.status = "No Content";
|
||||||
|
|
||||||
|
const sessionCookie = cookie.sessionId;
|
||||||
|
sessionCookie.remove();
|
||||||
|
|
||||||
|
const sessionId = sessionCookie.value;
|
||||||
|
if (sessionId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.deleteFrom("Session")
|
||||||
|
.where("sessionId", "=", SessionId(sessionId))
|
||||||
|
.execute();
|
||||||
|
})
|
||||||
|
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return error(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieceId = PieceId(Bun.randomUUIDv7());
|
||||||
|
|
||||||
|
const res = await db
|
||||||
|
.insertInto("Piece")
|
||||||
|
.values({ pieceId, name, composer, lyricist, arranger, createdBy: user.userId, createdAt: sql`datetime()` })
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String({ minLength: 1 }),
|
||||||
|
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||||
|
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||||
|
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.get("/piece", async ({ db, user }) => {
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return error(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await db
|
||||||
|
.selectFrom("Piece")
|
||||||
|
.selectAll()
|
||||||
|
.orderBy(["name", "composer", "arranger"])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return error(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await db
|
||||||
|
.updateTable("Piece")
|
||||||
|
.set({ name, composer, lyricist, arranger, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
||||||
|
.where("pieceId", "=", pieceId)
|
||||||
|
.returningAll()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (res.length === 0) {
|
||||||
|
return error("Not Found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String({ minLength: 1 }),
|
||||||
|
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||||
|
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||||
|
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||||
|
}),
|
||||||
|
params: t.Object({
|
||||||
|
pieceId: tbranded<PieceId>(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return error(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await db
|
||||||
|
.deleteFrom("Piece")
|
||||||
|
.where("pieceId", "=", pieceId)
|
||||||
|
.returningAll()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (res.length === 0) {
|
||||||
|
return error("Not Found");
|
||||||
|
}
|
||||||
|
|
||||||
|
set.status = "No Content";
|
||||||
|
}, {
|
||||||
|
params: t.Object({
|
||||||
|
pieceId: tbranded<PieceId>(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.listen(process.env.PORT || 3000);
|
||||||
|
|
||||||
|
export type App = typeof app;
|
||||||
|
|||||||
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",
|
"name": "common",
|
||||||
"license": "UNLICENSED",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": { "import": "./src/index.ts" },
|
||||||
"types": "./dist/index.d.ts",
|
"./*": { "import": "./src/*.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"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:"
|
"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 * as Brand from "./Brand";
|
||||||
import { Brand as B, pipe } from "effect";
|
|
||||||
|
|
||||||
export const AttachmentId = pipe(S.ULID, S.brand("AttachmentId"));
|
export type UUID = Brand.Branded<string, "UUID">;
|
||||||
export const PieceId = pipe(S.ULID, S.brand("PieceId"));
|
export const UUID = Brand.nominal<UUID>();
|
||||||
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 AttachmentId = typeof AttachmentId.Type;
|
export type Sha256 = Brand.Branded<Uint8Array, "Sha256">;
|
||||||
export type PieceId = typeof PieceId.Type;
|
export const Sha256 = Brand.nominal<Sha256>();
|
||||||
export type RequestId = typeof RequestId.Type;
|
|
||||||
export type SessionId = typeof SessionId.Type;
|
|
||||||
export type UserId = typeof UserId.Type;
|
|
||||||
|
|
||||||
export type Sha256 = B.Branded<Uint8Array, "Sha256">;
|
export type AttachmentId = Brand.Branded<UUID, "AttachmentId">;
|
||||||
export const Sha256 = pipe(
|
export const AttachmentId = Brand.nominal<AttachmentId>();
|
||||||
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 class BooleanFromNumber extends S.transform(
|
export type PieceId = Brand.Branded<UUID, "PieceId">;
|
||||||
S.Number,
|
export const PieceId = Brand.nominal<PieceId>();
|
||||||
S.Boolean,
|
|
||||||
{
|
export type RequestId = Brand.Branded<UUID, "RequestId">;
|
||||||
strict: true,
|
export const RequestId = Brand.nominal<RequestId>();
|
||||||
decode: (a) => a !== 0,
|
|
||||||
encode: (i) => i ? 1 : 0,
|
export type SessionId = Brand.Branded<string, "SessionId">;
|
||||||
},
|
export const SessionId = Brand.nominal<SessionId>();
|
||||||
).annotations({ identifier: "BooleanFromNumber" }) { }
|
|
||||||
|
export type UserId = Brand.Branded<UUID, "UserId">;
|
||||||
|
export const UserId = Brand.nominal<UserId>();
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"references": [
|
|
||||||
{ "path": "../make-api" },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="pl">
|
<html lang="pl" class="w-full h-full overflow-hidden dark:bg-stone-900 dark:text-white">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Repozytorium muzyczne</title>
|
<title>Repozytorium muzyczne</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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>
|
</head>
|
||||||
<body></body>
|
<body class="w-full h-full overflow-hidden">
|
||||||
|
<div id="root" class="w-full h-full overflow-hidden"></div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"license": "UNLICENSED",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vanilla-extract/css": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@vanilla-extract/vite-plugin": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
|
"@vitejs/plugin-react": "catalog:",
|
||||||
|
"autoprefixer": "catalog:",
|
||||||
|
"backend": "workspace:^",
|
||||||
|
"elysia": "catalog:",
|
||||||
|
"postcss": "catalog:",
|
||||||
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:"
|
"vite": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/schema": "catalog:",
|
"@elysiajs/eden": "catalog:",
|
||||||
"common": "workspace:^",
|
"common": "workspace:^",
|
||||||
"effect": "catalog:",
|
"react": "catalog:",
|
||||||
"fast-check": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"make-api": "workspace:^",
|
"react-router-dom": "catalog:"
|
||||||
"preact": "catalog:",
|
|
||||||
"preact-iso": "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 logout = {
|
||||||
|
|
||||||
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({
|
|
||||||
padding: 8,
|
padding: 8,
|
||||||
|
|
||||||
backgroundColor: "#C0C0C0",
|
backgroundColor: "#C0C0C0",
|
||||||
@@ -83,4 +56,4 @@ export const logout = style({
|
|||||||
borderBottomColor: "#202020",
|
borderBottomColor: "#202020",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -1,79 +1,66 @@
|
|||||||
import { Schema as S } from "@effect/schema";
|
import { useEffect } from "react";
|
||||||
import { Me } from "common/api";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Effect, Fiber, Option as O, Option, pipe } from "effect";
|
import { client } from "../client";
|
||||||
import { useLocation } from "preact-iso";
|
|
||||||
import { useEffect, useMemo } from "preact/hooks";
|
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import * as style from "./Home.css";
|
|
||||||
|
|
||||||
export const Home = () => {
|
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);
|
const setUser = useStore(state => state.setUser);
|
||||||
|
|
||||||
useEffect(() => {
|
const init = async () => {
|
||||||
if (user !== null) return;
|
if (user !== null) return;
|
||||||
|
|
||||||
const effect = Effect.gen(function* () {
|
const { data, error } = await client.me.get();
|
||||||
const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/me", {
|
|
||||||
method: "GET",
|
|
||||||
signal,
|
|
||||||
credentials: "include",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (error !== null) {
|
||||||
route("/login");
|
navigate("/login");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = yield* pipe(
|
|
||||||
Effect.promise(() => res.json()),
|
|
||||||
Effect.flatMap(S.decodeUnknown(Me.props.response[200].schema)),
|
|
||||||
Effect.orDie,
|
|
||||||
);
|
|
||||||
|
|
||||||
setUser(O.some(responseData));
|
|
||||||
});
|
|
||||||
|
|
||||||
const fiber = Effect.runFork(effect);
|
|
||||||
return () => Effect.runFork(Fiber.interrupt(fiber), { immediate: true });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logoutEffect = useMemo(() => Effect.gen(function* () {
|
|
||||||
const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/logout", {
|
|
||||||
method: "POST",
|
|
||||||
signal,
|
|
||||||
credentials: "include",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
yield* Effect.die(new Error("Response was not ok"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(O.none());
|
setUser(data);
|
||||||
|
};
|
||||||
|
|
||||||
route("/login");
|
useEffect(() => {
|
||||||
}), []);
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onLogoutClick = () => {
|
const onLogoutClick = async () => {
|
||||||
Effect.runFork(logoutEffect);
|
const { data, error } = await client.logout.post();
|
||||||
|
|
||||||
|
if (error !== null) {
|
||||||
|
console.error("Response was not ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(null);
|
||||||
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
return (
|
return (
|
||||||
<div class={style.container}>
|
<div className="w-[1000px] max-w-full mx-auto flex flex-col items-stretch">
|
||||||
<div class={style.loading}>Ładowanie…</div>
|
<div className="p-2 text-center">Ładowanie…</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={style.container}>
|
<div className="w-[1000px] max-w-full mx-auto flex flex-col items-stretch">
|
||||||
<div class={style.header}>
|
<div className="p-2 flex justify-between items-baseline">
|
||||||
<div>Użytkownik: {user.username}</div>
|
<div>
|
||||||
<div><button class={style.logout} type="button" onClick={onLogoutClick}>Wyloguj się</button></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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,83 +1,4 @@
|
|||||||
import { style } from "@vanilla-extract/css";
|
export const submit = {
|
||||||
|
|
||||||
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({
|
|
||||||
padding: 8,
|
padding: 8,
|
||||||
|
|
||||||
backgroundColor: "#C0C0C0",
|
backgroundColor: "#C0C0C0",
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { Schema as S } from "@effect/schema";
|
import { FormEventHandler, useId } from "react";
|
||||||
import { Login as LoginApi } from "common/api";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Effect, Fiber, Option as O, pipe } from "effect";
|
import { client } from "../client";
|
||||||
import { useLocation } from "preact-iso";
|
|
||||||
import { useId, useMemo, useRef } from "preact/hooks";
|
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import * as style from "./Login.css";
|
|
||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
|
|
||||||
const { route } = useLocation();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const loginUsername = useStore(state => state.loginUsername);
|
const loginUsername = useStore(state => state.loginUsername);
|
||||||
const loginPassword = useStore(state => state.loginPassword);
|
const loginPassword = useStore(state => state.loginPassword);
|
||||||
@@ -21,76 +18,55 @@ export const Login = () => {
|
|||||||
const usernameId = useId();
|
const usernameId = useId();
|
||||||
const passwordId = useId();
|
const passwordId = useId();
|
||||||
|
|
||||||
const requestFiber = useRef<Fiber.RuntimeFiber<void> | null>(null);
|
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||||
const requestEffect = useMemo(() => Effect.gen(function* () {
|
e.preventDefault();
|
||||||
const requestData = LoginApi.props.request.schema.make({ username: loginUsername, password: loginPassword });
|
|
||||||
|
|
||||||
const requestJson = yield* pipe(
|
const { data, error } = await client.login.post({
|
||||||
requestData,
|
username: loginUsername,
|
||||||
S.encode(LoginApi.props.request.schema),
|
password: loginPassword,
|
||||||
Effect.map(JSON.stringify),
|
});
|
||||||
Effect.orDie,
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/login", {
|
if (error) {
|
||||||
method: "POST",
|
console.error(error.value);
|
||||||
body: requestJson,
|
return;
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
signal,
|
|
||||||
credentials: "include",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
yield* Effect.die(new Error("Response was not ok"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = yield* pipe(
|
|
||||||
Effect.promise(() => res.json()),
|
|
||||||
Effect.flatMap(S.decodeUnknown(LoginApi.props.response[200].schema)),
|
|
||||||
Effect.orDie,
|
|
||||||
);
|
|
||||||
|
|
||||||
setLoginUsername("");
|
setLoginUsername("");
|
||||||
setLoginPassword("");
|
setLoginPassword("");
|
||||||
setUser(O.some(responseData));
|
setUser(data);
|
||||||
|
|
||||||
route("/");
|
navigate("/");
|
||||||
}), [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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={style.container}>
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<form class={style.box} onSubmit={onSubmit}>
|
<form className="p-2 flex flex-col gap-2 border border-black rounded dark:border-white" onSubmit={onSubmit}>
|
||||||
<header class={style.header}>Repozytorium muzyczne</header>
|
<header className="pb-2 border-b border-black text-center font-bold dark:border-white">Repozytorium muzyczne</header>
|
||||||
<label for={usernameId}>Nazwa użytkownika</label>
|
<label htmlFor={usernameId}>Nazwa użytkownika</label>
|
||||||
<input
|
<input
|
||||||
id={usernameId}
|
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"
|
type="text"
|
||||||
value={loginUsername}
|
value={loginUsername}
|
||||||
autofocus
|
autoFocus
|
||||||
required
|
required
|
||||||
onInput={(e) => setLoginUsername(e.currentTarget.value)}
|
onInput={(e) => setLoginUsername(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<label for={passwordId}>Hasło</label>
|
<label htmlFor={passwordId}>Hasło</label>
|
||||||
<input
|
<input
|
||||||
id={passwordId}
|
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"
|
type="password"
|
||||||
value={loginPassword}
|
value={loginPassword}
|
||||||
required
|
required
|
||||||
onInput={(e) => setLoginPassword(e.currentTarget.value)}
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { UserId } from "common";
|
import { UserId } from "common";
|
||||||
import { identity, Option as O } from "effect";
|
import * as Function from "common/Function";
|
||||||
import { useLayoutEffect, useState } from "preact/hooks";
|
import { useLayoutEffect, useState } from "react";
|
||||||
|
|
||||||
export type Update<T> = T | ((prev: T) => T);
|
export type Update<T> = T | ((prev: T) => T);
|
||||||
export type Updater<T> = (action: Update<T>) => void;
|
export type Updater<T> = (action: Update<T>) => void;
|
||||||
@@ -21,19 +21,19 @@ export interface Store {
|
|||||||
readonly loginUsername: string;
|
readonly loginUsername: string;
|
||||||
readonly loginPassword: string;
|
readonly loginPassword: string;
|
||||||
|
|
||||||
readonly user: O.Option<Store.User>;
|
readonly user: Store.User | null;
|
||||||
|
|
||||||
readonly setLoginUsername: Updater<string>;
|
readonly setLoginUsername: Updater<string>;
|
||||||
readonly setLoginPassword: 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>({
|
let store: Store = Object.freeze<Store>({
|
||||||
loginUsername: "",
|
loginUsername: "",
|
||||||
loginPassword: "",
|
loginPassword: "",
|
||||||
|
|
||||||
user: O.none(),
|
user: null,
|
||||||
|
|
||||||
setLoginUsername: (action) => set(mapProp("loginUsername", action)),
|
setLoginUsername: (action) => set(mapProp("loginUsername", action)),
|
||||||
setLoginPassword: (action) => set(mapProp("loginPassword", 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));
|
const [state, setState] = useState(() => selector(store));
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const listener = new Listener(selector, setState);
|
const listener = new Listener(selector, setState);
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => listeners.delete(listener);
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return state;
|
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",
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["react", "react-dom"],
|
||||||
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../common" },
|
{ "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/*'
|
- 'packages/*'
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@effect/schema': '^0.70.1'
|
'@elysiajs/cors': '^1.1.1'
|
||||||
'@vanilla-extract/css': '^1.15.3'
|
'@elysiajs/eden': '^1.1.3'
|
||||||
'@vanilla-extract/vite-plugin': '^4.0.13'
|
'@types/bun': '^1.1.13'
|
||||||
effect: '^3.6.0'
|
'@types/react': '^18.3.12'
|
||||||
fast-check: '^3.20.0'
|
'@types/react-dom': '^18.3.1'
|
||||||
preact: '^10.23.1'
|
'@vitejs/plugin-react': '^4.3.3'
|
||||||
preact-iso: '^2.6.3'
|
autoprefixer: '^10.4.20'
|
||||||
typescript: '^5.6.0-beta'
|
elysia: '^1.1.25'
|
||||||
ulid: '^2.3.0'
|
kysely: '^0.27.4'
|
||||||
vite: '^5.3.5'
|
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",
|
"rootDir": "${configDir}/src",
|
||||||
"outDir": "${configDir}/dist",
|
"outDir": "${configDir}/dist",
|
||||||
|
|
||||||
"module": "ES2020",
|
"module": "ES2022",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "bundler",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|
||||||
"types": [],
|
"types": [],
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "preact",
|
|
||||||
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
@@ -28,9 +27,9 @@
|
|||||||
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common": ["./packages/common/src/index.ts"],
|
"common": ["./packages/common/src/index.ts"],
|
||||||
"common/api": ["./packages/common/src/api.ts"],
|
"common/*": ["./packages/common/src/*.ts"],
|
||||||
"common/db": ["./packages/common/src/db.ts"],
|
"backend": ["./packages/backend/src/index.ts"],
|
||||||
"make-api": ["./packages/make-api/src/index.ts"],
|
"backend/*": ["./packages/backend/src/*.ts"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"include": ["${configDir}/src"],
|
"include": ["${configDir}/src"],
|
||||||
|
|||||||
Reference in New Issue
Block a user