diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index a7bf9513a..f00526aa5 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -325,7 +325,7 @@ services: limits: memory: 1536mb working_dir: /usr/src/app/fluxer_api - command: ["./node_modules/.bin/tsx", "src/WorkerEntrypoint.ts"] + command: ["node", "dist/WorkerEntrypoint.js"] environment: <<: *fluxer-env FLUXER_API_WORKER_MODE: all_lanes diff --git a/fluxer_api/Dockerfile b/fluxer_api/Dockerfile index a1ce657d1..871cba9fa 100644 --- a/fluxer_api/Dockerfile +++ b/fluxer_api/Dockerfile @@ -23,6 +23,7 @@ COPY . . RUN pnpm install --frozen-lockfile RUN pnpm --filter @fluxer/config run --if-present generate +RUN pnpm --filter fluxer_api run build RUN pnpm deploy --legacy --filter=fluxer_api --prod /out FROM node:24-bookworm-slim @@ -48,6 +49,7 @@ RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/s RUN corepack enable && corepack prepare pnpm@10.29.3 --activate COPY --from=deploy /out ./ +COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist COPY --from=deploy /usr/src/app/tsconfigs /usr/src/app/tsconfigs RUN rm -rf pkgs && \ @@ -57,7 +59,7 @@ RUN rm -rf pkgs && \ ENV HOME=/usr/src/app ENV COREPACK_HOME=/usr/src/app/.cache/corepack ENV NODE_ENV=production -ENV NODE_OPTIONS="--max-old-space-size=2048" +ENV NODE_OPTIONS="--max-old-space-size=2048 --enable-source-maps" ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt ENV BUILD_VERSION=${BUILD_VERSION} @@ -65,4 +67,4 @@ USER 65532:65532 EXPOSE 8080 -CMD ["./node_modules/.bin/tsx", "src/AppEntrypoint.ts"] +CMD ["node", "dist/AppEntrypoint.js"] diff --git a/fluxer_api/package.json b/fluxer_api/package.json index ed1a8a92d..f5ff8f31f 100644 --- a/fluxer_api/package.json +++ b/fluxer_api/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "build": "node scripts/build.mjs", "test": "vitest run", "typecheck": "tsgo --noEmit", "dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts", @@ -17,6 +18,7 @@ "@bluesky-social/jwk-jose": "catalog:", "@bluesky-social/oauth-client-node": "catalog:", "@bufbuild/protobuf": "^2.12.0", + "@elastic/elasticsearch": "catalog:", "@fluxer/config": "workspace:*", "@fluxer/constants": "workspace:*", "@fluxer/date_utils": "workspace:*", @@ -29,6 +31,9 @@ "@fluxer/logger": "workspace:*", "@fluxer/schema": "workspace:*", "@fluxer/snowflake": "workspace:*", + "@hono/node-server": "catalog:", + "@messageformat/core": "catalog:", + "@messageformat/parser": "catalog:", "@pkgs/cache": "workspace:*", "@pkgs/captcha": "workspace:*", "@pkgs/cassandra": "workspace:*", @@ -49,28 +54,36 @@ "@pkgs/worker": "workspace:*", "@simplewebauthn/server": "catalog:", "@types/node": "catalog:", + "@vvo/tzdb": "catalog:", "archiver": "catalog:", "argon2": "catalog:", "bowser": "catalog:", "cassandra-driver": "catalog:", + "emoji-regex": "catalog:", "fast-xml-parser": "catalog:", "hi-base32": "catalog:", "hono": "catalog:", "html-entities": "catalog:", "idna-uts46-hx": "catalog:", + "ioredis": "catalog:", "itty-time": "catalog:", "jose": "catalog:", "livekit-server-sdk": "catalog:", "lodash": "catalog:", "luxon": "catalog:", + "maxmind": "catalog:", "mime": "catalog:", "nats": "catalog:", + "nodemailer": "catalog:", + "pg": "catalog:", + "pino": "catalog:", "sharp": "catalog:", "stripe": "catalog:", "tempy": "catalog:", "transliteration": "catalog:", "tsx": "catalog:", "uint8array-extras": "catalog:", + "validator": "catalog:", "zod": "catalog:" }, "devDependencies": { @@ -78,6 +91,7 @@ "@types/lodash": "catalog:", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", + "esbuild": "catalog:", "msw": "catalog:", "vite-tsconfig-paths": "catalog:", "vitest": "catalog:" diff --git a/fluxer_api/scripts/build.mjs b/fluxer_api/scripts/build.mjs new file mode 100644 index 000000000..ad5af6bd8 --- /dev/null +++ b/fluxer_api/scripts/build.mjs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {existsSync, readFileSync, rmSync, statSync} from 'node:fs'; +import {isBuiltin} from 'node:module'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; + +const API_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const REPO_ROOT = resolve(API_ROOT, '..'); +const OUT_DIR = join(API_ROOT, 'dist'); +const CANDIDATE_SUFFIXES = ['', '.ts', '.tsx', '.js', '.mjs', '/index.ts', '/index.tsx', '/index.js', '/src/index.ts']; + +const WORKSPACE_ROOTS = { + '@app/': join(API_ROOT, 'src'), + '@pkgs/': join(API_ROOT, 'pkgs'), + '@fluxer/': join(REPO_ROOT, 'packages'), +}; + +function resolveWorkspacePath(specifier) { + for (const [prefix, root] of Object.entries(WORKSPACE_ROOTS)) { + if (!specifier.startsWith(prefix)) { + continue; + } + const base = join(root, specifier.slice(prefix.length)); + for (const suffix of CANDIDATE_SUFFIXES) { + const candidate = `${base}${suffix}`; + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + throw new Error(`Unable to resolve workspace import ${specifier}`); + } + return null; +} + +function packageNameOf(specifier) { + const segments = specifier.split('/'); + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; +} + +const declaredDependencies = new Set( + Object.keys(JSON.parse(readFileSync(join(API_ROOT, 'package.json'), 'utf-8')).dependencies), +); +const undeclaredDependencies = new Set(); + +const workspacePlugin = { + name: 'fluxer-workspace', + setup(pluginBuild) { + pluginBuild.onResolve({filter: /^[^./]/}, (args) => { + const workspacePath = resolveWorkspacePath(args.path); + if (workspacePath) { + return {path: workspacePath}; + } + const packageName = packageNameOf(args.path); + if (!isBuiltin(args.path) && !declaredDependencies.has(packageName)) { + undeclaredDependencies.add(packageName); + } + return {path: args.path, external: true}; + }); + }, +}; + +rmSync(OUT_DIR, {recursive: true, force: true}); + +await build({ + entryPoints: [join(API_ROOT, 'src/AppEntrypoint.ts'), join(API_ROOT, 'src/WorkerEntrypoint.ts')], + outdir: OUT_DIR, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node24', + charset: 'utf8', + sourcemap: true, + sourcesContent: false, + logLevel: 'info', + banner: {js: '// SPDX-License-Identifier: AGPL-3.0-or-later'}, + plugins: [workspacePlugin], +}); + +if (undeclaredDependencies.size > 0) { + const names = [...undeclaredDependencies].sort().join(', '); + throw new Error( + `The bundle imports packages that fluxer_api does not declare as dependencies: ${names}. ` + + 'Workspace packages keep their own node_modules, so the bundle cannot reach them from fluxer_api.', + ); +} diff --git a/fluxer_api/src/api/openapi/OpenAPIController.ts b/fluxer_api/src/api/openapi/OpenAPIController.ts index 2a700678e..88f576f9f 100644 --- a/fluxer_api/src/api/openapi/OpenAPIController.ts +++ b/fluxer_api/src/api/openapi/OpenAPIController.ts @@ -1,14 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as fs from 'node:fs'; -import * as path from 'node:path'; -import {fileURLToPath} from 'node:url'; import type {Hono} from 'hono'; import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware'; import {RateLimitConfigs} from '../RateLimitConfig'; import type {HonoEnv} from '../types/HonoEnv'; +import {resolveAssetPath} from '../utils/AssetPaths'; -const SPEC_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'openapi.json'); +const SPEC_PATH = resolveAssetPath('openapi', 'openapi.json'); const SPEC_BODY = fs.readFileSync(SPEC_PATH, 'utf-8'); export function OpenAPIController(app: Hono): void { diff --git a/fluxer_api/src/api/utils/AssetPaths.ts b/fluxer_api/src/api/utils/AssetPaths.ts new file mode 100644 index 000000000..7b9b936a7 --- /dev/null +++ b/fluxer_api/src/api/utils/AssetPaths.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {existsSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +function findPackageRoot(startDir: string): string { + let current = startDir; + while (!existsSync(join(current, 'package.json'))) { + const parent = dirname(current); + if (parent === current) { + throw new Error(`Unable to locate the fluxer_api package root from ${startDir}`); + } + current = parent; + } + return current; +} + +const PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url))); + +export function resolveAssetPath(...segments: Array): string { + return join(PACKAGE_ROOT, 'src', 'api', ...segments); +} diff --git a/fluxer_api/src/api/utils/UsernameGenerator.ts b/fluxer_api/src/api/utils/UsernameGenerator.ts index 6c43c29f0..f96900daf 100644 --- a/fluxer_api/src/api/utils/UsernameGenerator.ts +++ b/fluxer_api/src/api/utils/UsernameGenerator.ts @@ -2,13 +2,11 @@ import {randomInt} from 'node:crypto'; import {readFileSync} from 'node:fs'; -import {dirname, resolve} from 'node:path'; -import {fileURLToPath} from 'node:url'; import {UsernameType} from '@fluxer/schema/src/primitives/UserValidators'; +import {resolveAssetPath} from './AssetPaths'; -const WORDS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'words'); -const scales = readFileSync(resolve(WORDS_DIR, 'scales.txt'), 'utf-8').trim().split('\n').filter(Boolean); -const tails = readFileSync(resolve(WORDS_DIR, 'tails.txt'), 'utf-8').trim().split('\n').filter(Boolean); +const scales = readFileSync(resolveAssetPath('words', 'scales.txt'), 'utf-8').trim().split('\n').filter(Boolean); +const tails = readFileSync(resolveAssetPath('words', 'tails.txt'), 'utf-8').trim().split('\n').filter(Boolean); function capitalize(word: string): string { return word.charAt(0).toUpperCase() + word.slice(1); diff --git a/fluxer_api/src/api/webhook/WebhookService.ts b/fluxer_api/src/api/webhook/WebhookService.ts index 651cb44bf..993c35aee 100644 --- a/fluxer_api/src/api/webhook/WebhookService.ts +++ b/fluxer_api/src/api/webhook/WebhookService.ts @@ -42,6 +42,7 @@ import type {RequestCache} from '../middleware/RequestCacheMiddleware'; import type {Channel} from '../models/Channel'; import type {Message} from '../models/Message'; import type {Webhook} from '../models/Webhook'; +import {resolveAssetPath} from '../utils/AssetPaths'; import * as RandomUtils from '../utils/RandomUtils'; import type {IWebhookRepository} from './IWebhookRepository'; import {transform as GitHubTransform} from './transformers/GitHubTransformer'; @@ -620,7 +621,7 @@ export class WebhookService { const cacheKey = `webhook:${webhookId}:avatar:${provider}`; const avatarCache = await this.cacheService.get(cacheKey); if (avatarCache) return avatarCache; - const avatarFile = await fs.readFile(new URL(`../assets/${provider}.webp`, import.meta.url)); + const avatarFile = await fs.readFile(resolveAssetPath('assets', `${provider}.webp`)); const avatar = await this.avatarService.uploadAvatar({ prefix: 'avatars', entityId: webhookId, diff --git a/fluxer_api/src/api/words/Words.ts b/fluxer_api/src/api/words/Words.ts index 5755c9d22..6600655f0 100644 --- a/fluxer_api/src/api/words/Words.ts +++ b/fluxer_api/src/api/words/Words.ts @@ -2,11 +2,7 @@ import {randomInt} from 'node:crypto'; import * as fs from 'node:fs'; -import * as path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import {resolveAssetPath} from '../utils/AssetPaths'; let tails: Array | undefined; let scales: Array | undefined; @@ -34,9 +30,8 @@ export function generateConnectionId(): string { } function initWords(): void { - const wordsDir = path.join(__dirname); - tails = parseWordsFile(path.join(wordsDir, 'tails.txt')); - scales = parseWordsFile(path.join(wordsDir, 'scales.txt')); + tails = parseWordsFile(resolveAssetPath('words', 'tails.txt')); + scales = parseWordsFile(resolveAssetPath('words', 'scales.txt')); } function parseWordsFile(filePath: string): Array { diff --git a/knip.json b/knip.json index ca4a6d2c2..aae3fc3ad 100644 --- a/knip.json +++ b/knip.json @@ -30,6 +30,22 @@ ".": { "project": ["scripts/**/*.mjs"] }, + "fluxer_api": { + "ignoreDependencies": [ + "@elastic/elasticsearch", + "@hono/node-server", + "@messageformat/core", + "@messageformat/parser", + "@vvo/tzdb", + "emoji-regex", + "ioredis", + "maxmind", + "nodemailer", + "pg", + "pino", + "validator" + ] + }, "fluxer_app": { "entry": ["lingui.config.js", "scripts/build-sw.mjs", "scripts/Generate*.ts"], "ignoreDependencies": ["@lingui/swc-plugin"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 343e6ea1c..bf3d59180 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -440,6 +440,9 @@ importers: '@bufbuild/protobuf': specifier: ^2.12.0 version: 2.12.0 + '@elastic/elasticsearch': + specifier: 'catalog:' + version: 9.3.2 '@fluxer/config': specifier: workspace:* version: link:../packages/config @@ -476,6 +479,15 @@ importers: '@fluxer/snowflake': specifier: workspace:* version: link:../packages/snowflake + '@hono/node-server': + specifier: 'catalog:' + version: 1.19.9(patch_hash=8736af0d82dad75a667b7daed93f88c00065adc9a809d526353e290b150827ed)(hono@4.12.2) + '@messageformat/core': + specifier: 'catalog:' + version: 3.4.0 + '@messageformat/parser': + specifier: 'catalog:' + version: 5.1.1 '@pkgs/cache': specifier: workspace:* version: link:pkgs/cache @@ -536,6 +548,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.3.0 + '@vvo/tzdb': + specifier: 'catalog:' + version: 6.198.0 archiver: specifier: 'catalog:' version: 7.0.1 @@ -548,6 +563,9 @@ importers: cassandra-driver: specifier: 'catalog:' version: 4.8.0 + emoji-regex: + specifier: 'catalog:' + version: 10.6.0 fast-xml-parser: specifier: 'catalog:' version: 5.3.7 @@ -563,6 +581,9 @@ importers: idna-uts46-hx: specifier: 'catalog:' version: 6.1.1 + ioredis: + specifier: 'catalog:' + version: 5.9.3 itty-time: specifier: 'catalog:' version: 2.0.2 @@ -578,12 +599,24 @@ importers: luxon: specifier: 'catalog:' version: 3.7.2 + maxmind: + specifier: 'catalog:' + version: 5.0.5 mime: specifier: 'catalog:' version: 4.1.0 nats: specifier: 'catalog:' version: 2.29.3 + nodemailer: + specifier: 'catalog:' + version: 8.0.1 + pg: + specifier: 'catalog:' + version: 8.16.3 + pino: + specifier: 'catalog:' + version: 10.3.1 sharp: specifier: 'catalog:' version: 0.34.5 @@ -602,6 +635,9 @@ importers: uint8array-extras: specifier: 'catalog:' version: 1.5.0 + validator: + specifier: 'catalog:' + version: 13.15.26 zod: specifier: 'catalog:' version: 4.3.6 @@ -618,6 +654,9 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260224.1 + esbuild: + specifier: 'catalog:' + version: 0.27.3 msw: specifier: 'catalog:' version: 2.12.10(@types/node@25.3.0)(typescript@5.9.3) @@ -10874,10 +10913,6 @@ packages: resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} engines: {node: '>=18.17'} - undici@7.22.0: - resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} - engines: {node: '>=20.18.1'} - undici@7.29.0: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} @@ -13478,7 +13513,7 @@ snapshots: ms: 2.1.3 secure-json-parse: 4.1.0 tslib: 2.8.1 - undici: 7.22.0 + undici: 7.29.0 transitivePeerDependencies: - supports-color @@ -21182,7 +21217,7 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.3 + esbuild: 0.27.2 get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -21253,10 +21288,7 @@ snapshots: undici@6.26.0: {} - undici@7.22.0: {} - - undici@7.29.0: - optional: true + undici@7.29.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {}