Docker support, backend SPA mode

This commit is contained in:
2024-12-22 22:46:55 +01:00
parent 555a24dbe5
commit 228713b3cc
13 changed files with 467 additions and 362 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
build
db.sqlite3
dist
node_modules
tsconfig.tsbuildinfo
.dockerignore
Dockerfile

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
build
db.sqlite3
dist
node_modules
storage
tsconfig.tsbuildinfo

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# 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 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 --filter frontend exec vite build
FROM oven/bun:1
WORKDIR /usr/src/app
COPY --from=build /app .
ENTRYPOINT ["bun", "run", "packages/backend/src/app.ts"]

View File

@@ -5,6 +5,9 @@
"license": "UNLICENSED",
"scripts": {
"backend:dev": "bun run --watch packages/backend/src/app.ts",
"docker:build": "docker build -t music-repo .",
"docker:run": "docker run --init --publish 3000:3000 --rm music-repo",
"frontend:build": "pnpm --filter frontend exec vite build",
"frontend:dev": "pnpm --filter frontend exec vite --open"
},
"devDependencies": {

View File

@@ -9,6 +9,7 @@
},
"dependencies": {
"@elysiajs/cors": "catalog:",
"@elysiajs/static": "catalog:",
"@elysiajs/swagger": "catalog:",
"common": "workspace:^",
"elysia": "catalog:",

View File

@@ -1,4 +1,5 @@
import cors from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static";
import { swagger } from "@elysiajs/swagger";
import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common";
import * as Function from "common/Function";
@@ -14,9 +15,9 @@ const app = new Elysia()
.use(swagger())
.use(cors({ origin: "localhost:5173" }))
.use(cors({ origin: process.env.NODE_ENV === "production" ? false : "localhost:5173" }))
.decorate("db", await initDatabase())
.decorate("db", await initDatabase(process.env.DB_PATH))
.resolve(async ({ db, cookie }) => {
await db
@@ -71,7 +72,16 @@ const app = new Elysia()
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
})
// --- MARK: AUTHENTICATION ------------------------------------------------
.use(staticPlugin({
assets: "packages/frontend/build/assets",
prefix: "/assets",
alwaysStatic: true,
indexHTML: false,
}))
.group("/api", (app) => app
// --- MARK: AUTHENTICATION --------------------------------------------
.get("/me", async ({ user }) => {
@@ -148,7 +158,7 @@ const app = new Elysia()
.execute();
})
// --- MARK: USER MANAGEMENT -----------------------------------------------
// --- MARK: USER MANAGEMENT -------------------------------------------
.get("/user/:userId", async ({ db, params: { userId }, user }) => {
@@ -177,7 +187,7 @@ const app = new Elysia()
}),
})
// --- MARK: PIECE CRUD ----------------------------------------------------
// --- MARK: PIECE CRUD ------------------------------------------------
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
@@ -327,9 +337,9 @@ const app = new Elysia()
}),
})
// --- MARK: ATTACHMENT CRUD -----------------------------------------------
// --- MARK: ATTACHMENT CRUD -------------------------------------------
.post("piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => {
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => {
if (user === null) {
return error("Unauthorized");
@@ -368,14 +378,14 @@ const app = new Elysia()
}),
})
/* NOTE The piece ID is reduntant, because attachment IDs are unique for the
* entire DB, not just per piece. However, we consider a piece to be the
* sole owner of an attachment, i.e. attachments are not shared (attachments
* are deduplicated on file storage level by their SHA-256 hash). Thus, we
* reflect the ownership in the URLs.
/* NOTE The piece ID is reduntant, because attachment IDs are unique for
* the entire DB, not just per piece. However, we consider a piece to be
* the sole owner of an attachment, i.e. attachments are not shared
* (attachments are deduplicated on file storage level by their SHA-256
* hash). Thus, we reflect the ownership in the URLs.
*/
.get("piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => {
.get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => {
if (user === null) {
return error("Unauthorized");
@@ -395,8 +405,9 @@ const app = new Elysia()
return error("Not Found");
}
set.headers["content-disposition"] = `attachment; filename="${res.filename}"`;
return Bun.file(res.data, { type: res.mediaType });
set.headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(res.filename)}`;
set.headers["content-type"] = res.mediaType;
return new File([res.data], res.filename, { type: res.mediaType });
}, {
params: t.Object({
pieceId: tbranded<PieceId>(),
@@ -404,7 +415,7 @@ const app = new Elysia()
}),
})
.put("piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => {
.put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => {
if (user === null) {
return error("Unauthorized");
@@ -435,7 +446,7 @@ const app = new Elysia()
}),
})
.delete("piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => {
.delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => {
if (user === null) {
return error("Unauthorized");
@@ -461,6 +472,9 @@ const app = new Elysia()
attachmentId: tbranded<AttachmentId>(),
}),
})
)
.get("*", () => Bun.file("packages/frontend/build/index.html"))
// -------------------------------------------------------------------------

View File

@@ -22,6 +22,12 @@ export const ACCEPTED_EXTENSIONS = (() => {
return ret.join(",");
})();
export const ACCEPTED_MEDIA_TYPES = (() => {
const ret = Array.from(mediaTypeExtension.keys());
ret.sort((a, b) => a.localeCompare(b));
return Object.freeze(ret);
})();
function register(mediaType: string, ...extensions: [string, ...string[]]) {
const [primaryExtension, ...secondaryExtensions] = extensions;

View File

@@ -1,9 +1,24 @@
import { Treaty, treaty } from "@elysiajs/eden";
import type { App } from "backend/app";
import { ACCEPTED_MEDIA_TYPES } from "common/MediaType";
import { Effect } from "effect";
export type ResponseEffect<R extends Record<number, unknown>> = Effect.Effect<R[200], Exclude<keyof R, 200> extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200>]>;
export const client = treaty<App>("localhost:3000", { fetch: { credentials: "include" } });
export const client = treaty<App>(process.env.NODE_ENV === "production" ? "" : "localhost:3000", {
fetch: {
credentials: "include",
},
keepDomain: true,
onResponse: async (res) => {
const contentType = res.headers.get('Content-Type')?.split(';')[0];
if (contentType !== undefined && ACCEPTED_MEDIA_TYPES.includes(contentType)) {
const blob = await res.blob();
// TODO Decode filename from Content-Disposition header
const file = new File([blob], "", { type: contentType });
return file;
}
},
}).api;
export const mapResponse = <R extends Record<number, unknown>>({ error, data }: Treaty.TreatyResponse<R>): ResponseEffect<R> => error !== null ? Effect.fail(error) as any : Effect.succeed(data);

View File

@@ -201,7 +201,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = data.name;
a.download = props.attachment.filename; // TODO Use `data.name` after Content-Disposition parser is implemented
a.click();
URL.revokeObjectURL(url);
}, [props.attachment.attachmentId, props.attachment.pieceId]);

View File

@@ -1,3 +1,5 @@
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import("tailwindcss").Config} */
export default {
darkMode: ["class"],
@@ -19,5 +21,5 @@ export default {
colors: {},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [tailwindcssAnimate],
};

View File

@@ -10,4 +10,7 @@ export default defineConfig({
"common": path.resolve(__dirname, "../common/src"),
},
},
build: {
outDir: "build",
},
});

30
pnpm-lock.yaml generated
View File

@@ -12,6 +12,9 @@ catalogs:
'@elysiajs/eden':
specifier: ^1.1.3
version: 1.1.3
'@elysiajs/static':
specifier: ^1.1.1
version: 1.1.1
'@elysiajs/swagger':
specifier: ^1.1.6
version: 1.1.6
@@ -107,6 +110,9 @@ importers:
'@elysiajs/cors':
specifier: 'catalog:'
version: 1.1.1(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2))
'@elysiajs/static':
specifier: 'catalog:'
version: 1.1.1(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2))
'@elysiajs/swagger':
specifier: 'catalog:'
version: 1.1.6(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2))
@@ -317,6 +323,11 @@ packages:
peerDependencies:
elysia: '>= 1.1.0'
'@elysiajs/static@1.1.1':
resolution: {integrity: sha512-H1KqsuNHhHKYKUkPoies0pPQBgbA4qsfre840FKraeF99jz++2P/igrOagp8cWqwFGrHP1V+nwGlGm9U6rZAEg==}
peerDependencies:
elysia: '>= 1.1.0'
'@elysiajs/swagger@1.1.6':
resolution: {integrity: sha512-B1airTG3eh6eFgFxGS2UtsdZ7Xc2vrn3YKIFLFai9YeZVROSHmi3ZaXZvGAn3DnkXHT6I+qx960xnrqoNiopUw==}
peerDependencies:
@@ -1108,6 +1119,10 @@ packages:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1618,6 +1633,10 @@ packages:
resolution: {integrity: sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==}
engines: {node: '>=10'}
node-cache@5.1.2:
resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==}
engines: {node: '>= 8.0.0'}
node-gyp@9.4.1:
resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
engines: {node: ^12.13 || ^14.13 || >=16}
@@ -2287,6 +2306,11 @@ snapshots:
dependencies:
elysia: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)
'@elysiajs/static@1.1.1(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2))':
dependencies:
elysia: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)
node-cache: 5.1.2
'@elysiajs/swagger@1.1.6(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2))':
dependencies:
'@scalar/types': 0.0.12
@@ -3016,6 +3040,8 @@ snapshots:
clean-stack@2.2.0:
optional: true
clone@2.1.2: {}
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -3564,6 +3590,10 @@ snapshots:
semver: 7.6.3
optional: true
node-cache@5.1.2:
dependencies:
clone: 2.1.2
node-gyp@9.4.1:
dependencies:
env-paths: 2.2.1

View File

@@ -4,6 +4,7 @@ packages:
catalog:
'@elysiajs/cors': '^1.1.1'
'@elysiajs/eden': '^1.1.3'
'@elysiajs/static': '^1.1.1'
'@elysiajs/swagger': '^1.1.6'
'@radix-ui/react-dialog': '^1.1.2'
'@radix-ui/react-dropdown-menu': '^2.1.2'