Port to node.js
This commit is contained in:
25
Dockerfile
25
Dockerfile
@@ -1,25 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7-labs
|
||||
FROM node:lts AS build
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install --yes build-essential
|
||||
RUN apt-get install --yes libgl-dev
|
||||
RUN apt-get install --yes libglx-dev
|
||||
RUN apt-get install --yes libxi-dev
|
||||
RUN apt-get install --yes python-is-python3
|
||||
|
||||
COPY --parents package.json pnpm-lock.yaml pnpm-workspace.yaml packages/*/package.json .
|
||||
RUN npm install --global pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm exec tsc --build
|
||||
#RUN pnpm exec eslint .
|
||||
RUN pnpm --filter frontend exec vite build
|
||||
|
||||
FROM oven/bun:1.3.14
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY --from=build /app .
|
||||
ENTRYPOINT ["bun", "run", "packages/backend/src/app.ts"]
|
||||
@@ -4,9 +4,7 @@
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"backend:dev": "bun run --watch packages/backend/src/app.ts",
|
||||
"docker:build": "docker build -t music-repo .",
|
||||
"docker:run": "docker run --env-file .env --init --publish 3000:3000 --rm music-repo",
|
||||
"backend:dev": "node --env-file=.env --watch packages/backend/src/app.ts",
|
||||
"frontend:build": "pnpm --filter frontend exec vite build",
|
||||
"frontend:dev": "pnpm --filter frontend exec vite --open"
|
||||
},
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/better-sqlite3": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "catalog:",
|
||||
"better-sqlite3": "catalog:",
|
||||
"cbor2": "catalog:",
|
||||
"common": "workspace:^",
|
||||
"effect": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"kysely": "catalog:",
|
||||
"kysely-bun-sqlite": "catalog:"
|
||||
"uuid": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { registerEncoder } from "cbor2/encoder";
|
||||
import * as Api from "common/Api";
|
||||
import * as Cbor from "common/Cbor";
|
||||
import * as Client from "common/Client";
|
||||
import { Cause, Effect, Either, HashMap, Inspectable, Option, pipe } from "effect";
|
||||
import type { HonoRequest } from "hono";
|
||||
|
||||
registerEncoder(Buffer, (buffer) => [NaN, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)]);
|
||||
|
||||
/* NOTE We shouldn't need to extract this to a separate type, but if we don't do
|
||||
* it the TypeScript parser in VS Code sort of blows up and the syntax colors
|
||||
@@ -26,12 +30,13 @@ export const implement = <
|
||||
>(
|
||||
bundle: Api.ApiBundle<Record>,
|
||||
impl: Impl,
|
||||
): (key: string, request: Request) => Return<Impl> => {
|
||||
return (key, requestObject) => {
|
||||
): (request: HonoRequest<"/api/:key">) => Return<Impl> => {
|
||||
return (honoRequest) => {
|
||||
/* Force both return types to be `Response`. We can use the error route
|
||||
* for it's short-circuit capabilities.
|
||||
*/
|
||||
const effect: Effect.Effect<Response, Response, any> = Effect.gen(function* () {
|
||||
const key = honoRequest.param("key");
|
||||
const maybeApi = HashMap.get(bundle.map, key);
|
||||
if (Option.isNone(maybeApi)) {
|
||||
return RESPONSE_API_NOT_FOUND;
|
||||
@@ -44,7 +49,7 @@ export const implement = <
|
||||
}
|
||||
|
||||
const request = yield* pipe(
|
||||
requestObject,
|
||||
honoRequest as { bytes(): Promise<Uint8Array<ArrayBuffer>> },
|
||||
Client.decodeBody(api.request),
|
||||
catchToResponse,
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import * as Body from "common/Body";
|
||||
import { fetch } from "common/Fetch";
|
||||
import { Cause, Effect, Layer, Match, Option, pipe, Record, Redacted, Stream } from "effect";
|
||||
import { Effect, Layer, Match, pipe } from "effect";
|
||||
import { Hono } from "hono";
|
||||
import * as path from "node:path";
|
||||
import { config } from "./config";
|
||||
import * as Authentication from "./services/Authentication";
|
||||
import * as Database from "./services/Database";
|
||||
import { handle } from "./the_api";
|
||||
import { config } from "./config.ts";
|
||||
import * as Authentication from "./services/Authentication.ts";
|
||||
import * as Database from "./services/Database.ts";
|
||||
import { handle } from "./the_api.ts";
|
||||
|
||||
const FRONTEND_ROOT = "packages/frontend/build";
|
||||
const FRONTEND_ASSETS_ROOT = path.join(FRONTEND_ROOT, "assets");
|
||||
|
||||
const CORS_HEADERS: [string, string][] = Match.value(config.NODE_ENV).pipe(
|
||||
Match.when("development", (): [string, string][] => [
|
||||
@@ -21,106 +23,86 @@ const CORS_HEADERS: [string, string][] = Match.value(config.NODE_ENV).pipe(
|
||||
Match.exhaustive,
|
||||
);
|
||||
|
||||
const assetRoutes = await pipe(
|
||||
Stream.fromAsyncIterable(
|
||||
new Bun.Glob("**/*").scan(FRONTEND_ASSETS_ROOT),
|
||||
(error) => new Cause.UnknownException(error),
|
||||
),
|
||||
Stream.map((filepath): [string, Response] => [
|
||||
`/assets/${filepath}`,
|
||||
new Response(Bun.file(path.join(FRONTEND_ASSETS_ROOT, filepath))),
|
||||
]),
|
||||
Stream.runCollect,
|
||||
Effect.map(Record.fromEntries),
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
const homepage = new Response(Bun.file(path.join(FRONTEND_ROOT, "index.html")));
|
||||
|
||||
const databaseLayer = Database.FromPath(config.DB_PATH);
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
...assetRoutes,
|
||||
"/login": {
|
||||
GET: (req) => Effect.gen(function* () {
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const app = new Hono()
|
||||
.get("/login", (ctx) => Effect.gen(function* () {
|
||||
const code = ctx.req.query("code");
|
||||
const state = ctx.req.query("state");
|
||||
|
||||
// Callback URL with query response type
|
||||
if (searchParams.has("code") || searchParams.has("state")) {
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
// Callback URL with query response type
|
||||
if (code !== undefined || state !== undefined) {
|
||||
yield* Authentication.getAndProcessIdToken(code ?? null, state ?? null);
|
||||
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
}
|
||||
|
||||
yield* Authentication.getAndProcessIdToken(code, state);
|
||||
// Initial login request; redirect to identity provider
|
||||
const res = yield* pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)),
|
||||
Effect.map((url) => Response.redirect(url)),
|
||||
);
|
||||
|
||||
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
}
|
||||
return res;
|
||||
}).pipe(
|
||||
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
|
||||
Effect.runPromise,
|
||||
))
|
||||
.post("/login", (ctx) => Effect.gen(function* () {
|
||||
// Callback URL with form_post response type
|
||||
|
||||
// Initial login request; redirect to identity provider
|
||||
const res = yield* pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)),
|
||||
Effect.map((url) => Response.redirect(url)),
|
||||
);
|
||||
const data = yield* Body.formData(ctx.req);
|
||||
|
||||
return res;
|
||||
}).pipe(
|
||||
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
|
||||
Effect.runPromise,
|
||||
),
|
||||
POST: (req) => Effect.gen(function* () {
|
||||
// Callback URL with form_post response type
|
||||
const code = data.get("code") as string | null;
|
||||
const state = data.get("state") as string | null;
|
||||
|
||||
const data = yield* Body.formData(req);
|
||||
yield* Authentication.getAndProcessIdToken(code, state);
|
||||
|
||||
const code = data.get("code") as string | null;
|
||||
const state = data.get("state") as string | null;
|
||||
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
}).pipe(
|
||||
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
|
||||
Effect.runPromise,
|
||||
))
|
||||
.all("/api/:key", async (ctx) => {
|
||||
const req = ctx.req;
|
||||
|
||||
yield* Authentication.getAndProcessIdToken(code, state);
|
||||
const timestamp = new Date().toISOString();
|
||||
const connInfo = getConnInfo(ctx);
|
||||
console.log(`${timestamp} ${req.method} ${req.url} ${connInfo.remote.address!}`);
|
||||
|
||||
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
}).pipe(
|
||||
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
|
||||
Effect.runPromise,
|
||||
),
|
||||
},
|
||||
"/api/:key": async (req, server) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`${timestamp} ${req.method} ${req.url} ${server.requestIP(req)?.address}`);
|
||||
if (req.method !== "POST") {
|
||||
return new Response(null, {
|
||||
status: 405,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
const authenticationLayer = Authentication.Live(ctx);
|
||||
const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return new Response(null, {
|
||||
status: 405,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
const response = await pipe(
|
||||
handle(req),
|
||||
Effect.provide(layers),
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
const authenticationLayer = Authentication.Live(req);
|
||||
const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
|
||||
for (const [name, value] of CORS_HEADERS) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
|
||||
const response = await pipe(
|
||||
handle(req.params.key, req),
|
||||
Effect.provide(layers),
|
||||
Effect.runPromise,
|
||||
);
|
||||
return response;
|
||||
})
|
||||
.use("/assets/*", serveStatic({ root: FRONTEND_ROOT }))
|
||||
.use("*", serveStatic({ path: path.join(FRONTEND_ROOT, "index.html") }));
|
||||
|
||||
for (const [name, value] of CORS_HEADERS) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
"/*": homepage,
|
||||
},
|
||||
websocket: {
|
||||
message: () => { },
|
||||
},
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: config.PORT,
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { config } from "backend/config";
|
||||
import { BunRequest } from "bun";
|
||||
import { SessionId, UserId } from "common";
|
||||
import * as Body from "common/Body";
|
||||
import { fetch } from "common/Fetch";
|
||||
import { NotFound, Unauthenticated, User } from "common/the_api";
|
||||
import { Context, DateTime, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect";
|
||||
import { Unauthenticated, User } from "common/the_api";
|
||||
import { Context, DateTime, Effect, HashSet, Layer, Option, pipe, Redacted } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import { Context as HonoContext } from "hono";
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { sql } from "kysely";
|
||||
import * as Database from "./Database";
|
||||
import * as crypto from "node:crypto";
|
||||
import { config } from "../config.ts";
|
||||
import * as Database from "./Database.ts";
|
||||
|
||||
export interface AuthenticationInterface {
|
||||
readonly me: Effect.Effect<User, Unauthenticated>;
|
||||
@@ -21,7 +23,7 @@ export const OAUTH_SCOPE = "email openid profile";
|
||||
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login";
|
||||
export const SESSION_COOKIE_NAME = "sessionId";
|
||||
|
||||
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
|
||||
export const Live = (ctx: HonoContext) => Layer.effect(Authentication, Effect.gen(function* () {
|
||||
const database = yield* Database.Database;
|
||||
|
||||
yield* database
|
||||
@@ -30,13 +32,13 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
|
||||
.$call(Database.execute);
|
||||
|
||||
const sessionId = pipe(
|
||||
request.cookies.get(SESSION_COOKIE_NAME),
|
||||
getCookie(ctx, SESSION_COOKIE_NAME),
|
||||
Option.fromNullable,
|
||||
Option.map(SessionId.make),
|
||||
Option.getOrElse(generateSessionId),
|
||||
);
|
||||
|
||||
request.cookies.set(SESSION_COOKIE_NAME, sessionId, {
|
||||
setCookie(ctx, SESSION_COOKIE_NAME, sessionId, {
|
||||
expires: yield* pipe(
|
||||
DateTime.now,
|
||||
Effect.map(DateTime.addDuration("7 days")),
|
||||
@@ -99,32 +101,23 @@ export const Test = (me: Option.Option<User>) => Layer.sync(Authentication, cons
|
||||
})));
|
||||
|
||||
function generateCodeVerifier(byteLength: number = 32) {
|
||||
const codeVerifierBytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(codeVerifierBytes);
|
||||
const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url");
|
||||
|
||||
const codeVerifierBytes = crypto.randomBytes(byteLength);
|
||||
const codeVerifier = codeVerifierBytes.toString("base64url");
|
||||
const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii");
|
||||
const codeVerifierAsciiArray = new Uint8Array(
|
||||
codeVerifierAsciiBuffer.buffer,
|
||||
codeVerifierAsciiBuffer.byteOffset,
|
||||
codeVerifierAsciiBuffer.length,
|
||||
);
|
||||
const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url");
|
||||
const codeChallenge = crypto.hash("sha256", codeVerifierAsciiBuffer, { outputEncoding: "base64url" });
|
||||
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
function generateSessionId(byteLength: number = 32): SessionId {
|
||||
const array = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(array);
|
||||
const string = Buffer.from(array).toString("base64url");
|
||||
const buffer = crypto.randomBytes(byteLength);
|
||||
const string = buffer.toString("base64url");
|
||||
return SessionId.make(string);
|
||||
};
|
||||
|
||||
function generateRandomState(byteLength: number = 32): string {
|
||||
const array = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(array);
|
||||
const state = Buffer.from(array).toString("base64url");
|
||||
const buffer = crypto.randomBytes(byteLength);
|
||||
const state = buffer.toString("base64url");
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Database as BunSqliteDatabase } from "bun:sqlite";
|
||||
import { default as BetterSqliteDatabase } from "better-sqlite3";
|
||||
import { AttachmentId, PieceId, RepertoireId, SessionId, Sha256, UserId } from "common";
|
||||
import { Role } from "common/the_api";
|
||||
import { Cause, Context, Effect, Either, HashSet, Layer, pipe, Runtime } from "effect";
|
||||
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, Transaction } from "kysely";
|
||||
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
||||
import { type ColumnType, CompiledQuery, CreateTableBuilder, type Insertable, Kysely, type Selectable, SqliteDialect, Transaction } from "kysely";
|
||||
|
||||
// --- MARK: KYSELY SCHEMA -----------------------------------------------------
|
||||
|
||||
@@ -189,8 +188,8 @@ const initDatabase = (filename: string) => Effect.gen(function* () {
|
||||
.addColumn("modifiedBy", "text")
|
||||
.addColumn("modifiedAt", "text");
|
||||
|
||||
const database = new BunSqliteDatabase(filename, { create: true, readwrite: true });
|
||||
const dialect = new BunSqliteDialect({ database });
|
||||
const database = new BetterSqliteDatabase(filename, { fileMustExist: false, readonly: false });
|
||||
const dialect = new SqliteDialect({ database });
|
||||
const db = new Kysely<DatabaseSchema>({ dialect });
|
||||
|
||||
yield* Effect.promise(() => db.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON")));
|
||||
|
||||
@@ -2,9 +2,11 @@ import { AttachmentId, PieceId, RepertoireId, Sha256 } from "common";
|
||||
import api, { NotFound, Role, Unauthorized } from "common/the_api";
|
||||
import { DateTime, Effect, HashSet, Number, Option, pipe } from "effect";
|
||||
import { sql } from "kysely";
|
||||
import { implement } from "./api";
|
||||
import * as Authentication from "./services/Authentication";
|
||||
import * as Database from "./services/Database";
|
||||
import * as crypto from "node:crypto";
|
||||
import * as uuid from "uuid";
|
||||
import { implement } from "./api.ts";
|
||||
import * as Authentication from "./services/Authentication.ts";
|
||||
import * as Database from "./services/Database.ts";
|
||||
|
||||
const READ_ACCESS = HashSet.make(Role.Admin, Role.Editor, Role.Viewer);
|
||||
const WRITE_ACCESS = HashSet.make(Role.Admin, Role.Editor);
|
||||
@@ -132,7 +134,7 @@ export const handle = implement(api, {
|
||||
const res = yield* db
|
||||
.insertInto("Piece")
|
||||
.values({
|
||||
pieceId: PieceId.make(Bun.randomUUIDv7()),
|
||||
pieceId: PieceId.make(uuid.v7()),
|
||||
name: piece.name,
|
||||
composer: Option.getOrNull(piece.composer),
|
||||
lyricist: Option.getOrNull(piece.lyricist),
|
||||
@@ -327,8 +329,8 @@ export const handle = implement(api, {
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
} else {
|
||||
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(attachment.data, sha256);
|
||||
const buffer = crypto.hash("sha256", attachment.data, { outputEncoding: "buffer" }) as Buffer<ArrayBuffer>;
|
||||
sha256 = Sha256.make(buffer);
|
||||
|
||||
yield* db
|
||||
.insertInto("File")
|
||||
@@ -340,7 +342,7 @@ export const handle = implement(api, {
|
||||
const res = yield* db
|
||||
.insertInto("Attachment")
|
||||
.values({
|
||||
attachmentId: AttachmentId.make(Bun.randomUUIDv7()),
|
||||
attachmentId: AttachmentId.make(uuid.v7()),
|
||||
pieceId: attachment.pieceId,
|
||||
filename: attachment.filename,
|
||||
mediaType: attachment.mediaType,
|
||||
@@ -430,7 +432,7 @@ export const handle = implement(api, {
|
||||
const { userId } = yield* requireOneOf(WRITE_ACCESS);
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const repertoireId = RepertoireId.make(Bun.randomUUIDv7());
|
||||
const repertoireId = RepertoireId.make(uuid.v7());
|
||||
|
||||
const res = yield* db
|
||||
.insertInto("Repertoire")
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"],
|
||||
"types": [
|
||||
"better-sqlite3",
|
||||
"node",
|
||||
],
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../common" },
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"./*": { "import": "./src/*.ts" }
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"cbor2": "catalog:",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { HashMap, Schema } from "effect";
|
||||
import * as Api from "./Api";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as Api from "./Api.ts";
|
||||
|
||||
describe("bundle", () => {
|
||||
test("constructs a HashMap", () => {
|
||||
@@ -29,7 +27,7 @@ describe("bundle", () => {
|
||||
bar: Api.make(Schema.Void, Schema.Number, Schema.String),
|
||||
});
|
||||
|
||||
expect(Object.isFrozen(bundle)).toBeTrue();
|
||||
expect(Object.isFrozen(bundle)).toBe(true);
|
||||
});
|
||||
|
||||
test("freezes the record", () => {
|
||||
@@ -38,7 +36,7 @@ describe("bundle", () => {
|
||||
bar: Api.make(Schema.Void, Schema.Number, Schema.String),
|
||||
});
|
||||
|
||||
expect(Object.isFrozen(bundle.record)).toBeTrue();
|
||||
expect(Object.isFrozen(bundle.record)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +60,6 @@ describe("make", () => {
|
||||
|
||||
const api = Api.make(request, response, error);
|
||||
|
||||
expect(Object.isFrozen(api)).toBeTrue();
|
||||
expect(Object.isFrozen(api)).toBe(true);
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { describe, test } from "bun:test";
|
||||
import { Cause, Effect } from "effect";
|
||||
import * as Body from "./Body";
|
||||
import * as Test from "./Test";
|
||||
import { describe, test } from "vitest";
|
||||
import * as Body from "./Body.ts";
|
||||
import * as Test from "./Test.ts";
|
||||
|
||||
describe("arrayBuffer", () => {
|
||||
test("succeeds", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Update } from "common";
|
||||
import { type Update } from "common";
|
||||
import { Data, Deferred, Effect, Exit, Option } from "effect";
|
||||
import { dual, identity } from "effect/Function";
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { describe, test } from "bun:test";
|
||||
import * as cbor from "cbor2";
|
||||
import { Uint8ArrayArrayBufferFromSelf } from "common";
|
||||
import { Effect, Schema } from "effect";
|
||||
import * as Cbor from "./Cbor";
|
||||
import * as Test from "./Test";
|
||||
import { describe, test } from "vitest";
|
||||
import * as Cbor from "./Cbor.ts";
|
||||
import * as Test from "./Test.ts";
|
||||
|
||||
describe("encode", () => {
|
||||
test("succeeds", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as cbor from "cbor2";
|
||||
import { Data, Effect, pipe, Schema } from "effect";
|
||||
import { Console, Data, Effect, ParseResult, pipe, Schema } from "effect";
|
||||
|
||||
export class CborDecodeError extends Data.TaggedError("CborDecodeError")<{ cause: unknown }> { }
|
||||
export class CborEncodeError extends Data.TaggedError("CborEncodeError")<{ cause: unknown }> { }
|
||||
@@ -31,5 +31,10 @@ export const decodeSchema = <A>(schema: Schema.Schema<A, any>) => {
|
||||
return (u: Uint8Array<ArrayBuffer>) => pipe(
|
||||
decode(u),
|
||||
Effect.flatMap((u) => schemaDecoder(u)),
|
||||
Effect.tapErrorTag("ParseError", (error) => pipe(
|
||||
error,
|
||||
ParseResult.TreeFormatter.formatError,
|
||||
Effect.flatMap(Console.error),
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Data, Effect, Option, pipe, Record, Schema } from "effect";
|
||||
import { Console, Data, Effect, Option, ParseResult, pipe, Record, Schema } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import * as Api from "./Api";
|
||||
import * as Body from "./Body";
|
||||
import * as Cbor from "./Cbor";
|
||||
import { fetch, FetchError } from "./Fetch";
|
||||
import * as Api from "./Api.ts";
|
||||
import * as Body from "./Body.ts";
|
||||
import * as Cbor from "./Cbor.ts";
|
||||
import { fetch, FetchError } from "./Fetch.ts";
|
||||
|
||||
export class ServerDie extends Data.TaggedError("ServerDie")<{ cause: unknown }> { }
|
||||
export class UnexpectedStatus extends Data.TaggedError("UnexpectedStatusCode")<{ status: number }> { }
|
||||
@@ -61,7 +61,7 @@ export const client = <const Record extends { readonly [_: string]: Api.ApiAny }
|
||||
})) as { readonly [K in keyof Record]: (request: Record[K]["request"]["Type"]) => Effect.Effect<Record[K]["response"]["Type"], Record[K]["error"]["Type"] | FetchError, never> };
|
||||
};
|
||||
|
||||
export const readBody = (body: Body) => pipe(
|
||||
export const readBody = (body: { bytes(): Promise<Uint8Array<ArrayBuffer>> }) => pipe(
|
||||
body,
|
||||
Body.bytes,
|
||||
Effect.map((bytes) => bytes.byteLength > 0 ? Option.some(bytes) : Option.none()),
|
||||
@@ -74,7 +74,15 @@ export const readBody = (body: Body) => pipe(
|
||||
|
||||
export const decodeBody = <A>(schema: Schema.Schema<A, any>) => {
|
||||
const decoder = Schema.decodeUnknown(schema);
|
||||
return (body: Body) => Effect.flatMap(readBody(body), decoder);
|
||||
return (body: { bytes(): Promise<Uint8Array<ArrayBuffer>> }) => pipe(
|
||||
readBody(body),
|
||||
Effect.flatMap(decoder),
|
||||
Effect.tapErrorTag("ParseError", (error) => pipe(
|
||||
error,
|
||||
ParseResult.TreeFormatter.formatError,
|
||||
Effect.flatMap(Console.error),
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
export interface BodyData {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { expect } from "bun:test";
|
||||
import { Cause, Exit, Predicate } from "effect";
|
||||
import { assert, expect } from "vitest";
|
||||
|
||||
export function expectSuccess<A, E>(
|
||||
actual: Exit.Exit<A, E>,
|
||||
@@ -22,19 +20,15 @@ export function expectFailureTag<A, E, Tag extends string>(
|
||||
tag: Tag,
|
||||
): asserts actual is Exit.Failure<never, E & { readonly _tag: Tag }> {
|
||||
if (!Exit.isFailure(actual)) {
|
||||
expect(actual).fail("Expected Exit to be a Failure");
|
||||
throw new Error("Unreachable");
|
||||
assert.fail("Expected Exit to be a Failure");
|
||||
}
|
||||
|
||||
const { cause } = actual;
|
||||
if (!Cause.isFailType(cause)) {
|
||||
expect(cause).fail("Expected Cause to be a Fail");
|
||||
throw new Error("Unreachable");
|
||||
assert.fail("Expected Cause to be a Fail");
|
||||
}
|
||||
|
||||
const { error } = cause;
|
||||
if (!Predicate.isTagged(tag)) {
|
||||
expect(error).fail(`Expected error to be tagged with ${JSON.stringify(tag)}`);
|
||||
throw new Error("Unreachable");
|
||||
assert.fail(`Expected error to be tagged with ${JSON.stringify(tag)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AttachmentId, PieceId, RepertoireId, Sha256, Uint8ArrayArrayBufferFromSelf, UserId } from "common";
|
||||
import { pipe, Schema } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import * as Api from "./Api";
|
||||
import * as Api from "./Api.ts";
|
||||
|
||||
// --- MARK: COMMON TYPES ------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
"types": ["react", "react-dom"],
|
||||
"baseUrl": ".",
|
||||
|
||||
"verbatimModuleSyntax": false,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"common": ["../common/src/index.ts"],
|
||||
|
||||
2795
pnpm-lock.yaml
generated
2795
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,41 +4,47 @@ packages:
|
||||
|
||||
allowBuilds:
|
||||
'@tailwindcss/oxide': true
|
||||
'esbuild': true
|
||||
'gl': false
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
gl: false
|
||||
|
||||
catalog:
|
||||
'@effect/language-service': '^0.21.8'
|
||||
'@eslint/js': '^9.37.0'
|
||||
'@radix-ui/react-avatar': '^1.1.10'
|
||||
'@radix-ui/react-dialog': '^1.1.15'
|
||||
'@radix-ui/react-dropdown-menu': '^2.1.16'
|
||||
'@radix-ui/react-label': '^2.1.7'
|
||||
'@radix-ui/react-select': '^2.2.6'
|
||||
'@radix-ui/react-slot': '^1.2.3'
|
||||
'@eslint/js': '^9.39.4'
|
||||
'@hono/node-server': '^2.0.12'
|
||||
'@radix-ui/react-avatar': '^1.2.0'
|
||||
'@radix-ui/react-dialog': '^1.1.17'
|
||||
'@radix-ui/react-dropdown-menu': '^2.1.18'
|
||||
'@radix-ui/react-label': '^2.1.10'
|
||||
'@radix-ui/react-select': '^2.3.1'
|
||||
'@radix-ui/react-slot': '^1.3.0'
|
||||
'@stylistic/eslint-plugin': '^4.4.1'
|
||||
'@tailwindcss/vite': '^4.1.14'
|
||||
'@types/bun': '^1.2.23'
|
||||
'@types/react': '^19.2.2'
|
||||
'@types/react-dom': '^19.2.1'
|
||||
'@tailwindcss/vite': '^4.3.1'
|
||||
'@types/better-sqlite3': '^7.6.13'
|
||||
'@types/node': '^24'
|
||||
'@types/react': '^19.2.17'
|
||||
'@types/react-dom': '^19.2.3'
|
||||
'@vitejs/plugin-react': '^4.7.0'
|
||||
babel-plugin-react-compiler: '19.1.0-rc.3'
|
||||
cbor2: '^2.0.1'
|
||||
better-sqlite3: '^12.11.1'
|
||||
cbor2: '^2.3.0'
|
||||
class-variance-authority: '^0.7.1'
|
||||
clsx: '^2.1.1'
|
||||
effect: '^3.18.4'
|
||||
effect: '^3.21.3'
|
||||
eslint-plugin-react-hooks: '6.0.0-rc1'
|
||||
hono: '^4.12.32'
|
||||
jszip: '^3.10.1'
|
||||
kysely: '^0.28.7'
|
||||
kysely-bun-sqlite: '^0.4.0'
|
||||
kysely: '^0.28.17'
|
||||
lucide-react: '^0.518.0'
|
||||
opensheetmusicdisplay: '^1.9.2'
|
||||
react: '^19.2.0'
|
||||
react-dom: '^19.2.0'
|
||||
react-router-dom: '^7.9.3'
|
||||
tailwind-merge: '^3.3.1'
|
||||
tailwindcss: '^4.1.14'
|
||||
opensheetmusicdisplay: '^1.9.9'
|
||||
react: '^19.2.7'
|
||||
react-dom: '^19.2.7'
|
||||
react-router-dom: '^7.18.0'
|
||||
tailwind-merge: '^3.6.0'
|
||||
tailwindcss: '^4.3.1'
|
||||
tw-animate-css: '^1.4.0'
|
||||
typescript: '^5.9.3'
|
||||
typescript-eslint: '^8.46.0'
|
||||
vite: '^6.3.6'
|
||||
typescript-eslint: '^8.61.1'
|
||||
uuid: '^14.0.1'
|
||||
vite: '^6.4.3'
|
||||
vitest: '^4.1.9'
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"rootDir": "${configDir}/src",
|
||||
"outDir": "${configDir}/dist",
|
||||
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"moduleDetection": "force",
|
||||
|
||||
"types": [],
|
||||
@@ -23,6 +23,7 @@
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user