Port to node.js

This commit is contained in:
2026-07-30 00:45:46 +02:00
parent 988025bfac
commit 1249781204
22 changed files with 1461 additions and 1768 deletions

View File

@@ -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"]

View File

@@ -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"
},

View File

@@ -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:"
}
}

View File

@@ -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,
);

View File

@@ -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,38 +23,16 @@ 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");
yield* Authentication.getAndProcessIdToken(code, state);
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);
}
@@ -65,13 +45,13 @@ Bun.serve({
return res;
}).pipe(
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
Effect.runPromise,
),
POST: (req) => Effect.gen(function* () {
))
.post("/login", (ctx) => Effect.gen(function* () {
// Callback URL with form_post response type
const data = yield* Body.formData(req);
const data = yield* Body.formData(ctx.req);
const code = data.get("code") as string | null;
const state = data.get("state") as string | null;
@@ -80,14 +60,15 @@ Bun.serve({
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
}).pipe(
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
Effect.runPromise,
),
},
"/api/:key": async (req, server) => {
))
.all("/api/:key", async (ctx) => {
const req = ctx.req;
const timestamp = new Date().toISOString();
console.log(`${timestamp} ${req.method} ${req.url} ${server.requestIP(req)?.address}`);
const connInfo = getConnInfo(ctx);
console.log(`${timestamp} ${req.method} ${req.url} ${connInfo.remote.address!}`);
if (req.method === "OPTIONS") {
return new Response(null, {
@@ -103,11 +84,11 @@ Bun.serve({
});
}
const authenticationLayer = Authentication.Live(req);
const authenticationLayer = Authentication.Live(ctx);
const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
const response = await pipe(
handle(req.params.key, req),
handle(req),
Effect.provide(layers),
Effect.runPromise,
);
@@ -117,10 +98,11 @@ Bun.serve({
}
return response;
},
"/*": homepage,
},
websocket: {
message: () => { },
},
})
.use("/assets/*", serveStatic({ root: FRONTEND_ROOT }))
.use("*", serveStatic({ path: path.join(FRONTEND_ROOT, "index.html") }));
serve({
fetch: app.fetch,
port: config.PORT,
});

View File

@@ -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;
}

View File

@@ -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")));

View File

@@ -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")

View File

@@ -1,7 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun"],
"types": [
"better-sqlite3",
"node",
],
},
"references": [
{ "path": "../common" },

View File

@@ -8,8 +8,8 @@
"./*": { "import": "./src/*.ts" }
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
"cbor2": "catalog:",

View File

@@ -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);
})
});

View File

@@ -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 () => {

View File

@@ -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";

View File

@@ -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 () => {

View File

@@ -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),
)),
);
};

View File

@@ -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 {

View File

@@ -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)}`);
}
}

View File

@@ -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 ------------------------------------------------------

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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'

View File

@@ -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,