1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-13 17:35:40 +02:00

enhance(backend): bundle backend using Rolldown (#17068)

* enhance(backend): bundle backend using rolldown

* fix

* fix [ci skip]

* remove unused build script

* fix

* enhance: 起動からlistenまでかかる時間を減らす (MisskeyIO#1410)

* ✌️

* fix

* update rolldown

* fix(backend): extract static error classes to avoid rolldown design:paramtypes omission

* update rolldown

* Revert "fix(backend): extract static error classes to avoid rolldown design:paramtypes omission"

This reverts commit e2243c9dc3.

* fix

* perf: avoid generating sourcemap in production

* fix

* fix

* fix

* fix paths

* fix

* fix

* fix

* fix

* fix

* enhance: バックエンドの開発サーバー制御をrolldown側で行うように

* remove nodemon

* Update Changelog

* tweak config

* fix

* fix

* fix

* clean up

---------

Co-authored-by: あわわわとーにゅ <17376330+u1-liquid@users.noreply.github.com>
Co-authored-by: bab <mashirohira@gmail.com>
This commit is contained in:
かっこかり
2026-04-16 12:44:50 +09:00
committed by GitHub
parent 024f8bb102
commit 37bfcb604f
26 changed files with 504 additions and 349 deletions

View File

@@ -1,121 +0,0 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { build } from 'esbuild';
import { swcPlugin } from 'esbuild-plugin-swc';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const resolveTsPathsPlugin = {
name: 'resolve-ts-paths',
setup(build) {
build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, (args) => {
if (args.importer) {
const absPath = join(args.resolveDir, args.path);
const tsPath = absPath.slice(0, -3) + '.ts';
if (fs.existsSync(tsPath)) return { path: tsPath };
const tsxPath = absPath.slice(0, -3) + '.tsx';
if (fs.existsSync(tsxPath)) return { path: tsxPath };
}
});
},
};
const externalIpaddrPlugin = {
name: 'external-ipaddr',
setup(build) {
build.onResolve({ filter: /^ipaddr\.js$/ }, (args) => {
return { path: args.path, external: true };
});
},
};
/** @type {import('esbuild').BuildOptions} */
const options = {
entryPoints: ['./src/boot/entry.ts'],
minify: true,
keepNames: true,
bundle: true,
outdir: './built/boot',
target: 'node22',
platform: 'node',
format: 'esm',
sourcemap: 'linked',
packages: 'external',
banner: {
js: 'import { createRequire as topLevelCreateRequire } from "module";' +
'import ___url___ from "url";' +
'const require = topLevelCreateRequire(import.meta.url);' +
'const __filename = ___url___.fileURLToPath(import.meta.url);' +
'const __dirname = ___url___.fileURLToPath(new URL(".", import.meta.url));',
},
plugins: [
externalIpaddrPlugin,
resolveTsPathsPlugin,
swcPlugin({
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
dynamicImport: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
},
experimental: {
keepImportAssertions: true,
},
baseUrl: join(_dirname, 'src'),
paths: {
'@/*': ['*'],
},
target: 'esnext',
keepClassNames: true,
},
}),
externalIpaddrPlugin,
],
// external: [
// 'slacc-*',
// 'class-transformer',
// 'class-validator',
// '@sentry/*',
// '@nestjs/websockets/socket-module',
// '@nestjs/microservices/microservices-module',
// '@nestjs/microservices',
// '@napi-rs/canvas-win32-x64-msvc',
// 'mock-aws-s3',
// 'aws-sdk',
// 'nock',
// 'sharp',
// 'jsdom',
// 're2',
// '@napi-rs/canvas',
// ],
};
const args = process.argv.slice(2).map(arg => arg.toLowerCase());
if (!args.includes('--no-clean')) {
fs.rmSync('./built', { recursive: true, force: true });
}
await buildSrc();
async function buildSrc() {
console.log(`[${_package.name}] start building...`);
await build(options)
.then(() => {
console.log(`[${_package.name}] build succeeded.`);
})
.catch((err) => {
process.stderr.write(err.stderr || err.message || err);
process.exit(1);
});
console.log(`[${_package.name}] finish building.`);
}

View File

@@ -1,6 +1,6 @@
import { DataSource } from 'typeorm';
import { loadConfig } from './src-js/config.js';
import { entities } from './src-js/postgres.js';
import { loadConfig } from './built/config.js';
import { entities } from './built/postgres.js';
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';

View File

@@ -7,21 +7,22 @@
"node": "^22.15.0 || ^24.10.0"
},
"scripts": {
"start": "pnpm compile-config && node ./built/boot/entry.js",
"start:inspect": "pnpm compile-config && node --inspect ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"start": "pnpm compile-config && node ./built/entry.js",
"start:inspect": "pnpm compile-config && node --inspect ./built/entry.js",
"start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/entry.js",
"migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js",
"cli": "pnpm compile-config && node ./src-js/boot/cli.js",
"cli": "pnpm compile-config && node ./built/cli.js",
"check:connect": "pnpm compile-config && node ./scripts/check_connect.js",
"compile-config": "node ./scripts/compile_config.js",
"build": "swc src -d src-js -D --strip-leading-paths && node ./build.js",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"build": "rolldown -c",
"build:unit": "rolldown -c --sourcemap",
"build:e2e": "swc src -d src-js -D --strip-leading-paths && swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
"build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "pnpm compile-config && node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "pnpm compile-config && node ./scripts/dev.mjs",
"dev": "pnpm compile-config && rolldown -c --watch",
"typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
@@ -31,11 +32,11 @@
"jest-and-coverage": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --clearCache",
"test": "pnpm jest",
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test": "pnpm build:unit && pnpm jest",
"test:e2e": "pnpm build:e2e && pnpm jest:e2e",
"test:fed": "pnpm jest:fed",
"test-and-coverage": "pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
"test-and-coverage": "pnpm build:unit && pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build:e2e && pnpm jest-and-coverage:e2e",
"check-migrations": "node scripts/check_migrations_clean.js",
"generate-api-json": "pnpm compile-config && node ./scripts/generate_api_json.js"
},
@@ -179,6 +180,7 @@
"@jest/globals": "29.7.0",
"@kitajs/ts-html-plugin": "4.1.4",
"@nestjs/platform-express": "11.1.18",
"@rollup/plugin-esm-shim": "0.1.8",
"@sentry/vue": "10.47.0",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.39",
@@ -217,15 +219,14 @@
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.12",
"cross-env": "10.1.0",
"esbuild-plugin-swc": "1.0.1",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.3",
"jest": "29.7.0",
"jest-mock": "29.7.0",
"js-yaml": "4.1.1",
"nodemon": "3.1.14",
"pid-port": "2.1.1",
"rolldown": "1.0.0-rc.15",
"simple-oauth2": "5.1.0",
"supertest": "7.2.2",
"vite": "8.0.7"

View File

@@ -0,0 +1,107 @@
import { defineConfig } from 'rolldown';
import type { Plugin, ExternalOption } from 'rolldown';
import { execa, execaNode } from 'execa';
import type { ResultPromise } from 'execa';
import esmShim from '@rollup/plugin-esm-shim';
/**
* Watchモード時にバックエンドの起動・停止制御を行うプラグイン
*/
function backendDevServerPlugin(): Plugin {
let backendProcess: ResultPromise | null = null;
async function runBuildAssets() {
await execa('pnpm', ['run', 'build-assets'], {
cwd: '../../',
stdout: process.stdout,
stderr: process.stderr,
});
}
async function killBackendProcess() {
if (backendProcess) {
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
backendProcess.kill();
await new Promise(resolve => backendProcess!.on('exit', resolve));
backendProcess = null;
}
}
return {
name: 'backend-dev-server',
async closeBundle() {
await runBuildAssets();
if (backendProcess) {
await killBackendProcess();
}
backendProcess = execaNode('./built/entry.js', {
stdout: process.stdout,
stderr: process.stderr,
env: {
NODE_ENV: 'development',
},
});
},
async watchChange() {
if (backendProcess) {
await killBackendProcess();
await runBuildAssets();
}
},
};
}
export default defineConfig((args) => {
const isWatchMode = args.watch != null && args.watch !== 'false';
// 通常のビルド時にexternalとするモジュール
const externalModules: ExternalOption = [
/^slacc-.*/,
'class-transformer',
'class-validator',
/^@sentry\/.*/,
/^@sentry-internal\/.*/,
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'@nestjs/microservices',
/^@napi-rs\/.*/,
'mock-aws-s3',
'aws-sdk',
'nock',
'sharp',
'jsdom',
're2',
'ipaddr.js',
'oauth2orize',
];
return {
input: [
'./src/boot/entry.ts',
'./src/boot/cli.ts',
'./src/config.ts',
'./src/postgres.ts',
'./src/server/api/openapi/gen-spec.ts',
],
platform: 'node',
tsconfig: true,
plugins: [
esmShim(),
(isWatchMode ? backendDevServerPlugin() : undefined),
],
output: {
keepNames: true,
minify: !isWatchMode,
sourcemap: isWatchMode,
dir: './built',
cleanDir: !isWatchMode,
format: 'esm',
},
watch: {
include: ['src/**/*.{ts,js,mjs,cjs,tsx,json}'],
clearScreen: false,
},
// ビルドの高速化のために、watchモードのときは外部モジュールは全てバンドルしないようにする
external: isWatchMode ? [/node_modules/] : externalModules,
};
});

View File

@@ -4,8 +4,8 @@
*/
import Redis from 'ioredis';
import { loadConfig } from '../src-js/config.js';
import { createPostgresDataSource } from '../src-js/postgres.js';
import { loadConfig } from '../built/config.js';
import { createPostgresDataSource } from '../built/postgres.js';
const config = loadConfig();

View File

@@ -1,63 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { execa, execaNode } from 'execa';
/** @type {import('execa').ExecaChildProcess | undefined} */
let backendProcess;
async function execBuildAssets() {
await execa('pnpm', ['run', 'build-assets'], {
cwd: '../../',
stdout: process.stdout,
stderr: process.stderr,
})
}
function execStart() {
// pnpm run start を呼び出したいが、windowsだとプロセスグループ単位でのkillが出来ずゾンビプロセス化するので
// 上記と同等の動きをするコマンドで子・孫プロセスを作らないようにしたい
backendProcess = execaNode('./built/boot/entry.js', [], {
stdout: process.stdout,
stderr: process.stderr,
env: {
'NODE_ENV': 'development',
},
});
}
async function killProc() {
if (backendProcess) {
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
backendProcess.kill();
await new Promise(resolve => backendProcess.on('exit', resolve));
backendProcess = undefined;
}
}
(async () => {
execaNode(
'./node_modules/nodemon/bin/nodemon.js',
[
'-w', 'src',
'-e', 'ts,js,mjs,cjs,tsx,json,pug',
'--exec', 'pnpm', 'run', 'build',
],
{
stdio: [process.stdin, process.stdout, process.stderr, 'ipc'],
serialization: "json",
})
.on('message', async (message) => {
if (message.type === 'exit') {
// かならずbuild->build-assetsの順番で呼び出したいので、
// 少々トリッキーだがnodemonからのexitイベントを利用してbuild-assets->startを行う。
// pnpm restartをbuildが終わる前にbuild-assetsが動いてしまうので、バラバラに呼び出す必要がある
await killProc();
await execBuildAssets();
execStart();
}
})
})();

View File

@@ -19,10 +19,10 @@ async function main() {
}
/** @type {import('../src/config.js')} */
const { loadConfig } = await import('../src-js/config.js');
const { loadConfig } = await import('../built/config.js');
/** @type {import('../src/server/api/openapi/gen-spec.js')} */
const { genOpenapiSpec } = await import('../src-js/server/api/openapi/gen-spec.js');
const { genOpenapiSpec } = await import('../built/gen-spec.js');
const config = loadConfig();
const spec = genOpenapiSpec(config, true);

View File

@@ -55,7 +55,7 @@ async function getMemoryUsage(pid) {
async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), ['expose-gc'], {
const serverProcess = fork(join(__dirname, '../built/entry.js'), ['expose-gc'], {
cwd: join(__dirname, '..'),
env: {
...process.env,

View File

@@ -4,16 +4,12 @@
*/
import { NestFactory } from '@nestjs/core';
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
import { NestLogger } from '@/NestLogger.js';
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
import { ServerService } from '@/server/ServerService.js';
import { MainModule } from '@/MainModule.js';
export async function server() {
const { MainModule } = await import('../MainModule.js');
const { ServerService } = await import('../server/ServerService.js');
const app = await NestFactory.createApplicationContext(MainModule, {
logger: new NestLogger(),
});
@@ -22,6 +18,10 @@ export async function server() {
await serverService.launch();
if (process.env.NODE_ENV !== 'test') {
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const { QueueStatsService } = await import('../daemons/QueueStatsService.js');
const { ServerStatsService } = await import('../daemons/ServerStatsService.js');
app.get(ChartManagementService).start();
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
@@ -31,6 +31,10 @@ export async function server() {
}
export async function jobQueue() {
const { QueueProcessorModule } = await import('../queue/QueueProcessorModule.js');
const { QueueProcessorService } = await import('../queue/QueueProcessorService.js');
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
logger: new NestLogger(),
});

View File

@@ -13,8 +13,6 @@ import chalk from 'chalk';
import Xev from 'xev';
import Logger from '@/logger.js';
import { envOption } from '../env.js';
import { masterMain } from './master.js';
import { workerMain } from './worker.js';
import { readyRef } from './ready.js';
import 'reflect-metadata';
@@ -71,10 +69,12 @@ process.on('exit', code => {
if (!envOption.disableClustering) {
if (cluster.isPrimary) {
logger.info(`Start main process... pid: ${process.pid}`);
const { masterMain } = await import('./master.js');
await masterMain();
ev.mount();
} else if (cluster.isWorker) {
logger.info(`Start worker process... pid: ${process.pid}`);
const { workerMain } = await import('./worker.js');
await workerMain();
} else {
throw new Error('Unknown process type');
@@ -82,6 +82,7 @@ if (!envOption.disableClustering) {
} else {
// 非clusterの場合はMasterのみが起動するため、Workerの処理は行わない(cluster.isWorker === trueの状態でこのブロックに来ることはない)
logger.info(`Start main process... pid: ${process.pid}`);
const { masterMain } = await import('./master.js');
await masterMain();
ev.mount();
}

View File

@@ -190,6 +190,7 @@ export type Config = {
userAgent: string;
frontendManifestExists: boolean;
frontendEmbedManifestExists: boolean;
rootDir: string;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
@@ -330,6 +331,7 @@ export function loadConfig(): Config {
userAgent: `Misskey/${version} (${config.url})`,
frontendManifestExists: frontendManifestExists,
frontendEmbedManifestExists: frontendEmbedManifestExists,
rootDir,
perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000,
perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500,
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),

View File

@@ -4,27 +4,31 @@
*/
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Injectable } from '@nestjs/common';
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { Injectable, Inject } from '@nestjs/common';
import { Mutex } from 'async-mutex';
import fetch from 'node-fetch';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js';
import type { NSFWJS, PredictionType } from 'nsfwjs/core';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const REQUIRED_CPU_FLAGS_X64 = ['avx2', 'fma'];
let isSupportedCpu: undefined | boolean = undefined;
@Injectable()
export class AiService {
private readonly modelDir: string;
private model: NSFWJS;
private modelLoadMutex: Mutex = new Mutex();
constructor(
@Inject(DI.config)
private config: Config,
) {
const md = resolve(this.config.rootDir, 'packages/backend/nsfw-model');
this.modelDir = md.endsWith('/') ? md : md + '/';
}
@bindThis
@@ -46,7 +50,7 @@ export class AiService {
const nsfw = await import('nsfwjs/core');
await this.modelLoadMutex.runExclusive(async () => {
if (this.model == null) {
this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
this.model = await nsfw.load(pathToFileURL(this.modelDir).toString(), { size: 299 });
}
});
}

View File

@@ -5,29 +5,25 @@
import * as fs from 'node:fs';
import * as Path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const path = Path.resolve(_dirname, '../../../../files');
@Injectable()
export class InternalStorageService {
private readonly path: string;
constructor(
@Inject(DI.config)
private config: Config,
) {
this.path = Path.resolve(this.config.rootDir, 'files');
}
@bindThis
public resolvePath(key: string) {
return Path.resolve(path, key);
return Path.resolve(this.path, key);
}
@bindThis
@@ -37,14 +33,14 @@ export class InternalStorageService {
@bindThis
public saveFromPath(key: string, srcPath: string) {
fs.mkdirSync(path, { recursive: true });
fs.mkdirSync(this.path, { recursive: true });
fs.copyFileSync(srcPath, this.resolvePath(key));
return `${this.config.url}/files/${key}`;
}
@bindThis
public saveFromBuffer(key: string, data: Buffer) {
fs.mkdirSync(path, { recursive: true });
fs.mkdirSync(this.path, { recursive: true });
fs.writeFileSync(this.resolvePath(key), data);
return `${this.config.url}/files/${key}`;
}

View File

@@ -4,8 +4,7 @@
*/
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { resolve } from 'node:path';
import { Inject, Injectable } from '@nestjs/common';
import type { Config } from '@/config.js';
import type { DriveFilesRepository } from '@/models/_.js';
@@ -25,11 +24,6 @@ import { FileServerFileResolver } from './file/FileServerFileResolver.js';
import { FileServerProxyHandler } from './file/FileServerProxyHandler.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const assets = `${_dirname}/../../server/file/assets/`;
@Injectable()
export class FileServerService {
private logger: Logger;
@@ -37,6 +31,8 @@ export class FileServerService {
private proxyHandler: FileServerProxyHandler;
private fileResolver: FileServerFileResolver;
private readonly assets: string;
constructor(
@Inject(DI.config)
private config: Config,
@@ -52,6 +48,7 @@ export class FileServerService {
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('server', 'gray');
this.assets = resolve(this.config.rootDir, 'packages/backend/src/server/file/assets');
this.fileResolver = new FileServerFileResolver(
this.driveFilesRepository,
this.fileInfoService,
@@ -61,13 +58,13 @@ export class FileServerService {
this.driveHandler = new FileServerDriveHandler(
this.config,
this.fileResolver,
assets,
this.assets,
this.videoProcessingService,
);
this.proxyHandler = new FileServerProxyHandler(
this.config,
this.fileResolver,
assets,
this.assets,
this.imageProcessingService,
);
@@ -87,7 +84,7 @@ export class FileServerService {
fastify.register((fastify, options, done) => {
fastify.addHook('onRequest', handleRequestRedirectToOmitSearch);
fastify.get('/files/app-default.jpg', (request, reply) => {
const file = fs.createReadStream(`${_dirname}/assets/dummy.png`);
const file = fs.createReadStream(`${this.assets}/dummy.png`);
reply.header('Content-Type', 'image/jpeg');
reply.header('Cache-Control', 'max-age=31536000, immutable');
return reply.send(file);
@@ -121,7 +118,7 @@ export class FileServerService {
reply.header('Cache-Control', 'max-age=300');
if (request.query && 'fallback' in request.query) {
return reply.sendFile('/dummy.png', assets);
return reply.sendFile('/dummy.png', this.assets);
}
if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) {

View File

@@ -4,9 +4,7 @@
*/
import { randomUUID } from 'node:crypto';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as fs from 'node:fs';
import { resolve } from 'node:path';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import sharp from 'sharp';
@@ -67,35 +65,17 @@ import { ErrorPage } from './views/error.js';
import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
let rootDir = _dirname;
// 見つかるまで上に遡る
while (!fs.existsSync(resolve(rootDir, 'packages'))) {
const parentDir = dirname(rootDir);
if (parentDir === rootDir) {
throw new Error('Cannot find root directory');
}
rootDir = parentDir;
}
const backendRootDir = resolve(rootDir, 'packages/backend');
const frontendRootDir = resolve(rootDir, 'packages/frontend');
const staticAssets = resolve(backendRootDir, 'assets');
const clientAssets = resolve(frontendRootDir, 'assets');
const assets = resolve(rootDir, 'built/_frontend_dist_');
const swAssets = resolve(rootDir, 'built/_sw_dist_');
const fluentEmojisDir = resolve(rootDir, 'fluent-emojis/dist');
const twemojiDir = resolve(backendRootDir, 'node_modules/@discordapp/twemoji/dist/svg');
const frontendViteOut = resolve(rootDir, 'built/_frontend_vite_');
const frontendEmbedViteOut = resolve(rootDir, 'built/_frontend_embed_vite_');
const tarball = resolve(rootDir, 'built/tarball');
@Injectable()
export class ClientServerService {
private logger: Logger;
private readonly staticAssets: string;
private readonly clientAssets: string;
private readonly assets: string;
private readonly swAssets: string;
private readonly fluentEmojisDir: string;
private readonly twemojiDir: string;
private readonly frontendViteOut: string;
private readonly frontendEmbedViteOut: string;
private readonly tarball: string;
constructor(
@Inject(DI.config)
@@ -149,6 +129,17 @@ export class ClientServerService {
private clientLoggerService: ClientLoggerService,
) {
//this.createServer = this.createServer.bind(this);
const backendRootdir = resolve(this.config.rootDir, 'packages/backend');
const frontendRootdir = resolve(this.config.rootDir, 'packages/frontend');
this.staticAssets = resolve(backendRootdir, 'assets');
this.clientAssets = resolve(frontendRootdir, 'assets');
this.assets = resolve(this.config.rootDir, 'built/_frontend_dist_');
this.swAssets = resolve(this.config.rootDir, 'built/_sw_dist_');
this.fluentEmojisDir = resolve(this.config.rootDir, 'fluent-emojis/dist');
this.twemojiDir = resolve(backendRootdir, 'node_modules/@discordapp/twemoji/dist/svg');
this.frontendViteOut = resolve(this.config.rootDir, 'built/_frontend_vite_');
this.frontendEmbedViteOut = resolve(this.config.rootDir, 'built/_frontend_embed_vite_');
this.tarball = resolve(this.config.rootDir, 'built/tarball');
}
@bindThis
@@ -223,17 +214,17 @@ export class ClientServerService {
//#region vite assets
if (this.config.frontendEmbedManifestExists) {
console.log(`[ClientServerService] Using built frontend vite assets. ${frontendViteOut}`);
console.log(`[ClientServerService] Using built frontend vite assets. ${this.frontendViteOut}`);
fastify.register((fastify, options, done) => {
fastify.register(fastifyStatic, {
root: frontendViteOut,
root: this.frontendViteOut,
prefix: '/vite/',
maxAge: ms('30 days'),
immutable: true,
decorateReply: false,
});
fastify.register(fastifyStatic, {
root: frontendEmbedViteOut,
root: this.frontendEmbedViteOut,
prefix: '/embed_vite/',
maxAge: ms('30 days'),
immutable: true,
@@ -265,21 +256,21 @@ export class ClientServerService {
//#region static assets
fastify.register(fastifyStatic, {
root: staticAssets,
root: this.staticAssets,
prefix: '/static-assets/',
maxAge: ms('7 days'),
decorateReply: false,
});
fastify.register(fastifyStatic, {
root: clientAssets,
root: this.clientAssets,
prefix: '/client-assets/',
maxAge: ms('7 days'),
decorateReply: false,
});
fastify.register(fastifyStatic, {
root: assets,
root: this.assets,
prefix: '/assets/',
maxAge: ms('7 days'),
decorateReply: false,
@@ -287,7 +278,7 @@ export class ClientServerService {
fastify.register((fastify, options, done) => {
fastify.register(fastifyStatic, {
root: tarball,
root: this.tarball,
prefix: '/tarball/',
maxAge: ms('30 days'),
immutable: true,
@@ -298,11 +289,11 @@ export class ClientServerService {
});
fastify.get('/favicon.ico', async (request, reply) => {
return reply.sendFile('/favicon.ico', staticAssets);
return reply.sendFile('/favicon.ico', this.staticAssets);
});
fastify.get('/apple-touch-icon.png', async (request, reply) => {
return reply.sendFile('/apple-touch-icon.png', staticAssets);
return reply.sendFile('/apple-touch-icon.png', this.staticAssets);
});
fastify.get<{ Params: { path: string } }>('/fluent-emoji/:path(.*)', async (request, reply) => {
@@ -315,7 +306,7 @@ export class ClientServerService {
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
return reply.sendFile(path, fluentEmojisDir, {
return reply.sendFile(path, this.fluentEmojisDir, {
maxAge: ms('30 days'),
});
});
@@ -330,7 +321,7 @@ export class ClientServerService {
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
return reply.sendFile(path, twemojiDir, {
return reply.sendFile(path, this.twemojiDir, {
maxAge: ms('30 days'),
});
});
@@ -344,7 +335,7 @@ export class ClientServerService {
}
const mask = await sharp(
`${twemojiDir}/${path.replace('.png', '')}.svg`,
`${this.twemojiDir}/${path.replace('.png', '')}.svg`,
{ density: 1000 },
)
.resize(488, 488)
@@ -380,7 +371,7 @@ export class ClientServerService {
// ServiceWorker
fastify.get('/sw.js', async (request, reply) => {
return await reply.sendFile('/sw.js', swAssets, {
return await reply.sendFile('/sw.js', this.swAssets, {
maxAge: ms('10 minutes'),
});
});
@@ -390,7 +381,7 @@ export class ClientServerService {
// Embed Javascript
fastify.get('/embed.js', async (request, reply) => {
return await reply.sendFile('/embed.js', staticAssets, {
return await reply.sendFile('/embed.js', this.staticAssets, {
maxAge: ms('1 day'),
});
});

View File

@@ -3,9 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promises as fsp, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { promises as fsp } from 'node:fs';
import { languages } from 'i18n/const';
import { Injectable, Inject } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
@@ -18,25 +17,11 @@ import type { Config } from '@/config.js';
import type { MiMeta } from '@/models/Meta.js';
import type { CommonData, ViteFiles } from './views/_.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
let rootDir = _dirname;
// 見つかるまで上に遡る
while (!existsSync(resolve(rootDir, 'packages'))) {
const parentDir = dirname(rootDir);
if (parentDir === rootDir) {
throw new Error('Cannot find root directory');
}
rootDir = parentDir;
}
const frontendViteBuilt = resolve(rootDir, 'built/_frontend_vite_');
const frontendEmbedViteBuilt = resolve(rootDir, 'built/_frontend_embed_vite_');
@Injectable()
export class HtmlTemplateService {
private frontendAssetsFetched = false;
private readonly frontendViteBuilt: string;
private readonly frontendEmbedViteBuilt: string;
public frontendViteFiles: ViteFiles | null = null;
public frontendBootloaderJs: string | null = null;
public frontendBootloaderCss: string | null = null;
@@ -53,6 +38,8 @@ export class HtmlTemplateService {
private metaEntityService: MetaEntityService,
) {
this.frontendViteBuilt = resolve(this.config.rootDir, 'built/_frontend_vite_');
this.frontendEmbedViteBuilt = resolve(this.config.rootDir, 'built/_frontend_embed_vite_');
}
// 初期ロードで読み込むべきファイルのパスを収集する。
@@ -118,22 +105,22 @@ export class HtmlTemplateService {
embedBootJs,
embedBootCss,
] = await Promise.all([
fsp.readFile(resolve(frontendViteBuilt, 'loader/boot.js'), 'utf-8').catch(() => null),
fsp.readFile(resolve(frontendViteBuilt, 'loader/style.css'), 'utf-8').catch(() => null),
fsp.readFile(resolve(frontendEmbedViteBuilt, 'loader/boot.js'), 'utf-8').catch(() => null),
fsp.readFile(resolve(frontendEmbedViteBuilt, 'loader/style.css'), 'utf-8').catch(() => null),
fsp.readFile(resolve(this.frontendViteBuilt, 'loader/boot.js'), 'utf-8').catch(() => null),
fsp.readFile(resolve(this.frontendViteBuilt, 'loader/style.css'), 'utf-8').catch(() => null),
fsp.readFile(resolve(this.frontendEmbedViteBuilt, 'loader/boot.js'), 'utf-8').catch(() => null),
fsp.readFile(resolve(this.frontendEmbedViteBuilt, 'loader/style.css'), 'utf-8').catch(() => null),
]);
let feViteManifest: Manifest | null = null;
let embedFeViteManifest: Manifest | null = null;
if (this.config.frontendManifestExists) {
const manifestContent = await fsp.readFile(resolve(frontendViteBuilt, 'manifest.json'), 'utf-8').catch(() => null);
const manifestContent = await fsp.readFile(resolve(this.frontendViteBuilt, 'manifest.json'), 'utf-8').catch(() => null);
feViteManifest = manifestContent ? JSON.parse(manifestContent) : null;
}
if (this.config.frontendEmbedManifestExists) {
const manifestContent = await fsp.readFile(resolve(frontendEmbedViteBuilt, 'manifest.json'), 'utf-8').catch(() => null);
const manifestContent = await fsp.readFile(resolve(this.frontendEmbedViteBuilt, 'manifest.json'), 'utf-8').catch(() => null);
embedFeViteManifest = manifestContent ? JSON.parse(manifestContent) : null;
}

View File

@@ -296,7 +296,7 @@ describe('FileServerService', () => {
});
expect(res.statusCode).toBe(404);
expect(res.headers['cache-control']).toBe('max-age=86400');
expect(res.headers['cache-control']).toBe('public, max-age=0');
});
test('GET /files/:key 画像配信ヘッダを検証する', async () => {