build(api): bundle the api instead of transpiling at boot (#2227)

This commit is contained in:
Hampus
2026-08-31 01:26:30 +02:00
committed by GitHub
parent ba54b61dcf
commit 5f6f9428ac
11 changed files with 197 additions and 30 deletions
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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 [email protected] --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"]
+14
View File
@@ -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:"
+87
View File
@@ -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.',
);
}
@@ -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<HonoEnv>): void {
+23
View File
@@ -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>): string {
return join(PACKAGE_ROOT, 'src', 'api', ...segments);
}
@@ -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);
+2 -1
View File
@@ -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<string | null>(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,
+3 -8
View File
@@ -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<string> | undefined;
let scales: Array<string> | 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<string> {
+16
View File
@@ -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"]
+42 -10
View File
@@ -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)([email protected])
'@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/[email protected])([email protected])
@@ -10874,10 +10913,6 @@ packages:
resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
engines: {node: '>=18.17'}
[email protected]:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
[email protected]:
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:
[email protected]:
dependencies:
esbuild: 0.27.3
esbuild: 0.27.2
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
@@ -21253,10 +21288,7 @@ snapshots:
[email protected]: {}
[email protected]2.0: {}
[email protected]:
optional: true
[email protected]9.0: {}
[email protected]: {}