First commit

This commit is contained in:
2026-03-18 22:42:33 +09:00
commit 50657066a6
64 changed files with 5290 additions and 0 deletions
Executable
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
/postgres-data-nix/
.idea.private/
config/config.yaml
temp
*log*
!.log/.gitignore
*.env*
!.env.example
Executable
+55
View File
@@ -0,0 +1,55 @@
# LynqChat
チャットアプリです。
# サーバー作成
## 本体
**要件**
- Node.js v22.15.0以上v23未満
- pnpm v10.17.0以上
```bash
# configのサンプルをコピー
# config/config.yamlをコメントに従って変更してください。
cp config/example.yaml config/config.yaml
# フロントエンドのビルド
cd packages/frontend
pnpm i
pnpm run build
# バックエンドのビルド
cd ../backend
pnpm i
pnpm run build
# データベースのマイグレーション
pnpm mikro migration:up
# 起動
pnpm run start
```
## PostgreSQL
サーバーがある場合はスキップして構いません。
空のデータベースが必要です。
現時点で、暗号化通信には対応していません。
### Nix
> **WARNING**
> ローカルのNix Storeにパスワードが平文で保存されます。
WSLの`/mnt/*`では、権限の制約によって動作しません。
`/home/*`などのディレクトリで使用してください。
```bash
# Configコピー
cp ./config/example.yaml ./config/config.yaml
# 起動
nix develop
# 停止:
pg_ctl -D postgres-data-nix stop
exit
```
+45
View File
@@ -0,0 +1,45 @@
# サーバー設定
server:
# 配信ポート
# number
# 0から65535が使用できます。
port: 3300
# 配信ホスト
# string
host: 0.0.0.0
# 信用するX-Forwarded-*
# string | Array<string> | boolean | (string, number) => boolean
# Fastifyのドキュメントで定められているFunctionは、
# trustProxy: !!js/function >
# function (addr, hop) {/* Here your code. */}
# である必要があります。
# この例はRFC1918で定めるプライベートIPアドレスです。
# 詳細: https://fastify.dev/docs/latest/Reference/Server/#trustproxy
trustProxy:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
# PostgreSQL設定
database:
# ユーザー名
# string
user: user
# パスワード
# string
password: password
# ホスト
# string
host: localhost
# ポート
# number
# 0から65535が使用できます。
port: 5432
# データベース名
database: chat
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1771848320,
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
Executable
+82
View File
@@ -0,0 +1,82 @@
{
description = "PostgreSQL";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs, ... }@inputs:
let
pkgsFor = system: import nixpkgs { inherit system; };
shellHook = ''
CONFIG_FILE=./config/config.yaml
if [ -f "$CONFIG_FILE" ]; then
DB_USER=$(yq -r ".database.user" "$CONFIG_FILE")
DB_PASSWORD=$(yq -r ".database.password" "$CONFIG_FILE")
DB_PORT=$(yq -r ".database.port" "$CONFIG_FILE")
DB_NAME=$(yq -r ".database.database" "$CONFIG_FILE")
else
echo "config.yaml not found, using defaults."
exit
fi
: "$${DB_USER:=$${USER}}"
: "$${DB_PASSWORD:=password}"
: "$${DB_PORT:=5432}"
: "$${DB_NAME:=postgres}"
export PGDATA="$PWD/postgres-data-nix"
export PGHOST="$PGDATA"
mkdir -p "$PGDATA"
chmod 700 "$PGDATA"
mkdir -p "$PWD/.log"
chmod 700 "$PWD/.log"
if [ ! -f "$PGDATA/PG_VERSION" ]; then
echo "Initializing database..."
initdb -D "$PGDATA" > .log/postgres.log 2>&1
echo "unix_socket_directories = '$PGDATA'" >> "$PGDATA/postgresql.conf"
echo "listen_addresses = '127.0.0.1'" >> "$PGDATA/postgresql.conf"
pg_ctl -D "$PGDATA" -l .log/postgres.log -o "-k $PGDATA -p $DB_PORT" start
sleep 1
psql --dbname postgres -v ON_ERROR_STOP=1 <<EOSQL > .log/postgres.log 2>&1
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '$DB_USER') THEN
EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', '$DB_USER', '$DB_PASSWORD');
END IF;
END
\$\$;
EOSQL
psql --dbname postgres -v ON_ERROR_STOP=1 <<EOSQL > .log/postgres.log 2>&1
CREATE DATABASE "$DB_NAME" OWNER "$DB_USER";
EOSQL
else
pg_ctl -D "$PGDATA" -l .log/postgres.log -o "-k $PGDATA -p $DB_PORT" start
fi
'';
in
{
devShells.x86_64-linux.default = let
pkgs = pkgsFor "x86_64-linux";
in pkgs.mkShell {
packages = [ pkgs.postgresql_18 pkgs.yq ];
shellHook = shellHook;
};
devShells.aarch64-linux.default = let
pkgs = pkgsFor "aarch64-linux";
in pkgs.mkShell {
packages = [ pkgs.postgresql_18 pkgs.yq ];
shellHook = shellHook;
};
};
}
Executable
+12
View File
@@ -0,0 +1,12 @@
{
"name": "lynq-chat",
"version": "1.0.0-alpha.0",
"description": "A flexible chat software.",
"author": {
"name": "Last2014",
"email": "info@last2014.com",
"url": "https://about.last2014.com"
},
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.29.1"
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "backend",
"main": "dist/index.js",
"type": "module",
"scripts": {
"start": "cross-env NODE_ENV=production node .",
"build": "tsc && tsc-alias",
"dev": "tsx watch src/index.ts",
"mikro": "tsx ./node_modules/@mikro-orm/cli/esm.js"
},
"author": {
"name": "Last2014",
"email": "info@last2014.com",
"url": "https://about.last2014.com"
},
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.29.1",
"dependencies": {
"@fastify/static": "^9.0.0",
"@mikro-orm/core": "^6.6.7",
"@mikro-orm/migrations": "^6.6.7",
"@mikro-orm/postgresql": "^6.6.7",
"@mikro-orm/reflection": "^6.6.7",
"@types/node": "^25.2.2",
"argon2": "^0.44.0",
"fastify": "^5.7.4",
"fastify-plugin": "^5.1.0",
"fs": "0.0.1-security",
"os": "^0.1.2",
"tsc-alias": "^1.8.16",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"util": "^0.12.5",
"uuid": "^13.0.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"cross-env": "^10.1.0"
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { ZodIssue } from "zod/v3";
type ErrorType<R = undefined> = {
bad: "client" | "server";
code: string;
message: string;
reason?: R,
}
export const ErrorBase = <R = undefined>(err: ErrorType<R>) => {return {
success: false,
error: err,
}}
export const DatabaseError = () =>
ErrorBase({
bad: "server",
code: "database_error",
message: "サーバーでデータベースの問題が発生しました。",
});
export const InputError = (issues: ZodIssue[]) =>
ErrorBase({
bad: "client",
code: "input_wrong",
message: "入力に問題があります。",
reason: issues.map(issue => ({
code: issue.code,
path: issue.path,
message: issue.message,
})),
});
+165
View File
@@ -0,0 +1,165 @@
import Fastify from "fastify";
import config from "@/lib/config";
import { accessSync, constants as fsConst } from "node:fs";
import staticStream from "@fastify/static";
import { styleText } from "node:util";
import Routes from "@/routes";
import Database from "@/lib/db";
import logger from "@/lib/logger";
import AccessLog from "@/lib/access";
import Authorization from "@/lib/auth";
import { RequestContext } from "@mikro-orm/core";
import { DatabaseError, ErrorBase } from "@/errors";
import { ConfigEntity } from "@/modules/entities/Config";
process.title = "Chat";
logger.info("Process started...");
if (process.env.NODE_ENV !== "production") {
process.title = "Chat backend";
logger.warn(styleText(["red", "bold", "bgYellow"], "Development environment avaiable!!"));
}
const fastify = Fastify({
logger: false,
trustProxy: config.server.trustProxy,
bodyLimit: 1024 * 100,
});
try {
fastify.setErrorHandler((err, req, res) => {
res.header("Content-Type", "application/json");
if (err instanceof SyntaxError && /JSON/.test(err.message)) {
return res.status(400).send(ErrorBase({
bad: "client",
code: "input_none",
message: "入力がありません。",
}));
}
logger.error("Unknown error: ", err);
return res.status(500).send(ErrorBase({
bad: "server",
code: "unknown_error",
message: "不明なエラーが発生しました。",
}));
});
if (process.env.NODE_ENV === "production") {
try {
accessSync(`${import.meta.dirname}/../../frontend/dist/index.html`, fsConst.R_OK);
await fastify.register(staticStream, {
root: `${import.meta.dirname}/../../frontend/dist`,
index: "/",
});
} catch (err) {
logger.error("Error: It's in production but the frontend dist is not found.");
process.exit(1);
}
}
fastify.setNotFoundHandler((req, res) => {
if (req.url.startsWith("/api")) {
res.header("Content-Type", "application/json");
return res.code(404).send(ErrorBase({
bad: "client",
code: "endpoint_not_found",
message: "エンドポイントが見つかりませんでした。",
}));
}
if (process.env.NODE_ENV === "production") {
return res.sendFile("index.html");
}
return res.code(500).send();
});
await fastify.register(Database);
fastify.addHook("onRequest", (req, res, done) => {
RequestContext.create(fastify.orm.em, done);
});
fastify.addHook("onRequest", async (req, res) => {
if (
req.url.startsWith("/api") &&
!req.url.startsWith("/api/setup")
) {
try {
const configCount = await fastify.orm.em.count(ConfigEntity);
if (configCount === 0) {
return res.code(409).send(ErrorBase({
bad: "client",
code: "yet_initialization",
message: "初期設定が行われていません。",
}));
}
} catch (err) {
logger.error("Database Error: Could not check if already initialization:", err);
return res.code(500).send(DatabaseError());
}
}
});
await fastify.register(AccessLog);
await fastify.register(Authorization);
fastify.removeAllContentTypeParsers();
fastify.addContentTypeParser("application/json", { parseAs: "string" }, (req, body, done) => {
try {
const json = JSON.parse(
typeof body === "string"
? body
: body.toString("utf-8")
);
done(null, json);
} catch (err) {
if (err instanceof Error) {
(err as any).statusCode = 400;
done(err, undefined);
} else {
done(new Error("Invalid JSON"), undefined);
}
}
});
await fastify.register(Routes, {
prefix: "/api",
});
const addr = await fastify.listen({
port: config.server.port,
});
if (process.env.NODE_ENV === "production") {
logger.info(`Listening at ${addr}`);
} else {
logger.info(`Backend listening at ${addr}`);
}
} catch (err) {
logger.error(err);
process.exit(1);
}
const shutdown = async () => {
try {
await fastify.close();
logger.log("Server downed.");
process.exit(0);
} catch (err) {
logger.error("Server down failed", err);
process.exit(1);
}
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
+31
View File
@@ -0,0 +1,31 @@
import type { FastifyPluginCallback } from "fastify";
import logger from "@/lib/logger";
import fp from "fastify-plugin";
declare module "fastify" {
interface FastifyRequest {
startTime: bigint;
}
}
const AccessLog: FastifyPluginCallback = (fastify) => {
fastify.addHook("onRequest", (req, res, done) => {
req.startTime = process.hrtime.bigint();
logger.info(`${req.method} ${req.url} from ${req.ip}`);
done();
});
fastify.addHook("onResponse", (req, res, done) => {
const duration = (Number(
process.hrtime.bigint() - req.startTime
) / 1_000_000).toFixed(2);
logger.info(`${req.method} ${req.url} ${res.statusCode} from ${req.ip} - ${duration}ms`);
done();
});
}
export default fp(AccessLog);
+36
View File
@@ -0,0 +1,36 @@
import type { FastifyPluginCallback } from "fastify";
import fp from "fastify-plugin";
import { TokenEntity } from "@/modules/entities/Token";
import logger from "./logger";
import { DatabaseError, ErrorBase } from "@/errors";
declare module "fastify" {
interface FastifyRequest {
token: TokenEntity | ReturnType<typeof ErrorBase>;
}
}
const Authorization: FastifyPluginCallback = (fastify) => {
fastify.addHook("onRequest", async (req, res) => {
const token = req.headers["authorization"];
if (typeof token !== "string") {
return req.token = ErrorBase({
bad: "client",
code: "token_none",
message: "トークンが設定されていません。",
});
}
try {
const result = await fastify.orm.em.getRepository(TokenEntity).authToken(token);
req.token = result;
} catch (err) {
logger.error("Database Error: Token authorization failed:", err);
return res.code(500).send(DatabaseError());
}
});
}
export default fp(Authorization);
+59
View File
@@ -0,0 +1,59 @@
import z from "zod/v3";
import { readFileSync } from "node:fs";
import { parse as yamlParse } from "yaml";
import { EOL } from "node:os";
import logger from "@/lib/logger";
const schema = z.object({
server: z.object({
port: z.number().min(0).max(65535),
host: z.string().ip(),
trustProxy: z.union([
z.string(),
z.array(z.string()),
z.boolean(),
z.function()
.args(z.string(), z.number())
.returns(z.boolean()),
]),
}),
database: z.object({
user: z.string(),
password: z.string(),
host: z.string(),
port: z.number().min(0).max(65535),
database: z.string(),
}),
});
const config = (() => {
try {
const configFile = readFileSync(
`${import.meta.dirname}/../../../../config/config.yaml`,
"utf-8",
);
const configObj = yamlParse(configFile);
const result = schema.safeParse(configObj);
if (!result.success) {
const issues = result.error.issues.map(i => ` ${i.message}`);
logger.error(`Config file:${EOL}${issues.join(EOL)}`);
process.exit(1);
}
return result.data;
} catch (err) {
if (
err instanceof Error &&
err.message.includes("no such file or directory, open")
) {
logger.error("Config file: Config file is not found.");
} else {
logger.error(`Config file: ${err}`);
}
process.exit(1);
}
})();
export default config;
+33
View File
@@ -0,0 +1,33 @@
import type { FastifyPluginAsync } from "fastify";
import fp from "fastify-plugin";
import { MikroORM } from "@mikro-orm/postgresql";
import config from "@/mikro-orm.config";
import logger from "@/lib/logger";
declare module "fastify" {
interface FastifyInstance {
orm: MikroORM;
}
}
const Database: FastifyPluginAsync = async (fastify) => {
const orm = await MikroORM.init(config);
if (process.env.NODE_ENV !== "production") {
try {
await orm.schema.updateSchema();
logger.info("Database migration completed.");
} catch (err) {
logger.error("Migration failed:", err);
process.exit(1);
}
}
fastify.decorate("orm", orm);
fastify.addHook("onClose", async () => {
await orm.close(true);
});
};
export default fp(Database);
@@ -0,0 +1,121 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"name": {
"name": "name",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 255,
"mappedType": "string"
},
"value": {
"name": "value",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 255,
"mappedType": "string"
}
},
"name": "config",
"schema": "public",
"indexes": [
{
"keyName": "config_pkey",
"columnNames": [
"name"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
},
{
"columns": {
"userid": {
"name": "userid",
"type": "varchar(20)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 20,
"mappedType": "string"
},
"username": {
"name": "username",
"type": "varchar(30)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 30,
"mappedType": "string"
},
"email": {
"name": "email",
"type": "varchar(254)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 254,
"mappedType": "string"
},
"password": {
"name": "password",
"type": "varchar(60)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 60,
"mappedType": "string"
}
},
"name": "user",
"schema": "public",
"indexes": [
{
"columnNames": [
"email"
],
"composite": false,
"keyName": "user_email_unique",
"constraint": true,
"primary": false,
"unique": true
},
{
"keyName": "user_pkey",
"columnNames": [
"userid"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
}
],
"nativeEnums": {}
}
@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260302212455 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "user" add column "password" varchar(60) not null;`);
}
override async down(): Promise<void> {
this.addSql(`alter table "user" drop column "password";`);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from "@mikro-orm/postgresql";
import config from "@/lib/config";
import { TsMorphMetadataProvider } from "@mikro-orm/reflection";
import { Migrator } from "@mikro-orm/migrations";
import logger from "@/lib/logger";
export default defineConfig({
entities: ["./dist/modules/entities/**/*.js"],
entitiesTs: ["./src/modules/entities/**/*.ts"],
migrations: {
path: "./dist/migrations",
pathTs: "./src/migrations",
allOrNothing: true,
transactional: true,
disableForeignKeys: false,
},
extensions: [Migrator],
metadataProvider: TsMorphMetadataProvider,
debug: process.env.NODE_ENV !== "production",
logger: (message: string) => {
logger.log(`[MikroORM] ${message}`);
},
dbName: config.database.database,
user: config.database.user,
password: config.database.password,
host: config.database.host,
port: config.database.port,
});
@@ -0,0 +1,10 @@
import { Entity, PrimaryKey, Property } from "@mikro-orm/core";
@Entity({ tableName: "config" })
export class ConfigEntity {
@PrimaryKey({ type: "string" })
name!: string;
@Property({ type: "text" })
value!: string;
}
@@ -0,0 +1,31 @@
import { Entity, EntityRepositoryType, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { UserEntity } from "@/modules/entities/User";
import { TokenRepository } from "@/modules/repositories/Token";
@Entity({
tableName: "token",
repository: () => TokenRepository,
})
export class TokenEntity {
[EntityRepositoryType]?: TokenRepository;
[OptionalProps]?: "createdAt";
@PrimaryKey({ type: "string", length: 64 })
name!: string;
@Property({ type: "text" })
passphrase!: string;
@ManyToOne(() => UserEntity)
@Index()
user!: UserEntity;
@Property()
isNative!: boolean;
@Property({ onCreate: () => new Date() })
createdAt!: Date;
@Property({ nullable: true })
lastUsedAt?: Date;
}
@@ -0,0 +1,36 @@
import { Entity, EntityRepositoryType, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { UserRepository } from "@/modules/repositories/User";
import { v4 as uuid } from "uuid";
@Entity({
tableName: "user",
repository: () => UserRepository,
})
export class UserEntity {
[EntityRepositoryType]?: UserRepository;
[OptionalProps]?: "uuid" | "isSuspended" | "createdAt";
@PrimaryKey({ length: 36 })
uuid: string = uuid();
@Property({ unique: true, length: 20 })
userid!: string;
@Property({ length: 30 })
username!: string;
@Property({ unique: true, length: 256 })
email!: string;
@Property({ type: "text" })
password!: string;
@Property({ default: false })
isAdmin: boolean = false;
@Property({ default: false })
isSuspended: boolean = false;
@Property({ onCreate: () => new Date() })
createdAt!: Date;
}
@@ -0,0 +1,68 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import type { TokenEntity } from "@/modules/entities/Token";
import { hash, argon2id, verify as argon2Verify } from "argon2";
import { randomBytes } from "node:crypto";
import { UserEntity } from "@/modules/entities/User";
import { ErrorBase } from "@/errors";
export class TokenRepository extends EntityRepository<TokenEntity> {
async createToken(user: UserEntity, isNative: boolean) {
const name = randomBytes(32).toString("hex");
const passphrase = randomBytes(32).toString("hex");
const hashed = await hash(passphrase, {
type: argon2id,
memoryCost: 2 ** 16,
timeCost: 3,
parallelism: 1,
});
const token = this.create({
name,
passphrase: hashed,
isNative,
user,
});
await this.em.persist(token).flush();
return `${name}_${passphrase}`;
}
async authToken(
tokenStr: string
): Promise<TokenEntity | ReturnType<typeof ErrorBase>> {
const tokenArr = tokenStr.split("_");
if (
tokenArr[0]?.length !== 64 ||
tokenArr[1]?.length !== 64
)
return ErrorBase({
bad: "client",
code: "token_length_wrong",
message: "トークンの文字数が不正です。",
});
const token = await this.findOne({ name: tokenArr[0] }, { populate: ["user"] });
if (!token)
return ErrorBase({
bad: "client",
code: "token_invalid",
message: "トークンが不正です。",
});
const isOk = await argon2Verify(token.passphrase, tokenArr[1]);
if (!isOk)
return ErrorBase({
bad: "client",
code: "token_invalid",
message: "トークンが不正です。",
});
token.lastUsedAt = new Date()
await this.em.persist(token).flush();
return token;
}
}
@@ -0,0 +1,55 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { UserEntity } from "@/modules/entities/User";
import { hash, argon2id, verify as argon2Verify } from "argon2";
import z from "zod/v3";
import EmailRegex from "@/regexs/email";
export class UserRepository extends EntityRepository<UserEntity> {
public static schema = z.object({
userid: z.string().trim().min(3).max(20),
username: z.string().trim().min(3).max(30),
email: z.string().min(6).trim().max(254).regex(EmailRegex),
password: z.string().trim().min(8),
isAdmin: z.boolean(),
});
async createUser(data: z.infer<typeof UserRepository.schema>) {
const hashed = await hash(data.password, {
type: argon2id,
memoryCost: 2 ** 16,
timeCost: 3,
parallelism: 1,
});
const user = this.create({
...data,
password: hashed,
});
await this.em.persist(user).flush();
return;
}
async findByUserId(userid: string) {
return this.findOne({ userid });
}
async findByEmail(email: string) {
return this.findOne({ email });
}
async authUser(data: {
userid: string;
password: string;
}) {
const user = await this.findByUserId(data.userid);
if (!user)
return "userid_wrong";
const isOk = await argon2Verify(user.password, data.password);
if (!isOk)
return "password_wrong";
return user;
}
}
+3
View File
@@ -0,0 +1,3 @@
const EmailRegex = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:([0-9]{1,3}\.){3}[0-9]{1,3})\])$/i;
export default EmailRegex;
+18
View File
@@ -0,0 +1,18 @@
import type { FastifyInstance } from "fastify";
import Setup from "./setup";
import Primary from "./primary";
import Me from "./me";
export default async function Routes(fastify: FastifyInstance) {
await fastify.register(Setup, {
prefix: "/setup",
});
await fastify.register(Primary, {
prefix: "/primary",
});
await fastify.register(Me, {
prefix: "/me",
});
}
+11
View File
@@ -0,0 +1,11 @@
import type { FastifyInstance } from "fastify";
export default async function Me(fastify: FastifyInstance) {
fastify.post("/", async (req, res) => {
if ("error" in req.token)
return res.code(400).send(req.token);
const { password, email, ...safeUser } = req.token.user;
return res.send(safeUser);
});
}
@@ -0,0 +1,13 @@
import type { FastifyInstance } from "fastify";
import SignUp from "./signup";
import SignIn from "./signin";
export default async function Primary(fastify: FastifyInstance) {
await fastify.register(SignUp, {
prefix: "/signup",
});
await fastify.register(SignIn, {
prefix: "/signin",
});
}
@@ -0,0 +1,46 @@
import { DatabaseError, ErrorBase, InputError } from "@/errors";
import logger from "@/lib/logger";
import { TokenEntity } from "@/modules/entities/Token";
import { UserEntity } from "@/modules/entities/User";
import { UserRepository } from "@/modules/repositories/User";
import type { FastifyInstance } from "fastify";
export default function SignIn(fastify: FastifyInstance) {
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
const schema = UserRepository.schema.pick({
userid: true,
password: true,
});
const result = schema.safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const user = await fastify.orm.em.getRepository(UserEntity).authUser(result.data);
if (typeof user === "string") {
return res.code(400).send(ErrorBase({
bad: "client",
code: "auth_input_wrong",
message: "ユーザー名かパスワードが違います。",
}));
}
const token = await fastify.orm.em.getRepository(TokenEntity).createToken(user, true);
return res.send({
success: true,
token: token,
});
} catch (err) {
logger.error("Database Error: User auth or token create failed.", err);
return res.code(500).send(DatabaseError());
}
});
}
@@ -0,0 +1,30 @@
import { UserEntity } from "@/modules/entities/User";
import logger from "@/lib/logger";
import type { FastifyInstance } from "fastify";
import { UserRepository } from "@/modules/repositories/User";
import { DatabaseError, InputError } from "@/errors";
export default function SignUp(fastify: FastifyInstance) {
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
const result = UserRepository.schema.safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
await fastify.orm.em.getRepository(UserEntity).createUser(result.data);
return res.code(200).send({
success: true,
});
} catch (err) {
logger.error("Database Error: User create failed.", err);
return res.code(500).send(DatabaseError());
}
});
}
+18
View File
@@ -0,0 +1,18 @@
import { ConfigEntity } from "@/modules/entities/Config";
import { UserEntity } from "@/modules/entities/User";
import type { FastifyInstance } from "fastify";
export default async function ServerInfo(fastify: FastifyInstance) {
fastify.post("/", async (req, res) => {
const config = fastify.orm.em.getRepository(ConfigEntity);
const user = fastify.orm.em.getRepository(UserEntity);
const configCount = await config.count();
const userCount = await user.count();
return res.send({
isInitialized: configCount > 0,
isFirstAdminExists: userCount > 0,
userCount,
});
});
}
+67
View File
@@ -0,0 +1,67 @@
import { UserEntity } from "@/modules/entities/User";
import logger from "@/lib/logger";
import type { FastifyInstance } from "fastify";
import { UserRepository } from "@/modules/repositories/User";
import { DatabaseError, ErrorBase, InputError } from "@/errors";
import { ConfigEntity } from "@/modules/entities/Config";
export default function CreateAdmin(fastify: FastifyInstance) {
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
const result = UserRepository.schema.omit({ isAdmin: true }).safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const configCount = await fastify.orm.em.count(ConfigEntity);
if (configCount === 0) {
return res.code(409).send(ErrorBase({
bad: "client",
code: "yet_initialization",
message: "初期設定が行われていません。",
}));
}
} catch (err) {
logger.error("Database Error: Could not check if already initialization:", err);
return res.code(500).send(DatabaseError());
}
try {
const userCount = await fastify.orm.em.getRepository(UserEntity).count();
if (userCount > 0) {
return res.code(409).send(ErrorBase({
bad: "client",
code: "first_admin_already_exists",
message: "最初の管理者ユーザーは既に存在します。",
}));
}
} catch (err) {
logger.error("Database Error: Could not check if administrator exists:", err);
return res.code(500).send(DatabaseError());
}
try {
await fastify.orm.em.getRepository(UserEntity).createUser({
...result.data,
isAdmin: true,
});
logger.warn("First administrator account has been created.")
return res.code(200).send({
success: true,
});
} catch (err) {
logger.error("Database Error: Admin user create failed.", err);
return res.code(500).send(DatabaseError());
}
});
}
+13
View File
@@ -0,0 +1,13 @@
import type { FastifyInstance } from "fastify";
import CreateAdmin from "./create-admin";
import Initialization from "./initialization";
export default async function Setup(fastify: FastifyInstance) {
await fastify.register(Initialization, {
prefix: "/initialization",
});
await fastify.register(CreateAdmin, {
prefix: "/create-admin",
});
}
@@ -0,0 +1,70 @@
import { DatabaseError, ErrorBase, InputError } from "@/errors";
import logger from "@/lib/logger";
import { ConfigEntity } from "@/modules/entities/Config";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
export default function Initialization(fastify: FastifyInstance) {
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
const bodySchema = z.object({
name: z.string().trim().min(1).max(20),
description: z.string().trim().min(1),
requiredInvitationCode: z.boolean(),
force: z.literal("use_force_initialization").refine(() => process.env.NODE_ENV !== "production").optional(),
});
const result = bodySchema.safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
if (result.data.force) {
await fastify.orm.em.nativeDelete(ConfigEntity, {});
fastify.orm.em.clear();
}
const configCount = await fastify.orm.em.count(ConfigEntity);
if (configCount > 0) {
return res.code(409).send(ErrorBase({
bad: "client",
code: "already_initialization",
message: "既に初期設定が行われています。",
}));
}
} catch (err) {
logger.error("Database Error: Could not check if yet initialization:", err);
return res.code(500).send(DatabaseError());
}
try {
const entries = Object.entries(result.data).filter(([key]) => key !== "force");
for (const [key, value] of entries) {
const entity = fastify.orm.em.create(ConfigEntity, {
name: key,
value: typeof value === "string"
? value
: String(value),
});
fastify.orm.em.persist(entity);
}
await fastify.orm.em.flush();
return res.code(200).send({
success: true,
});
} catch (err) {
logger.error("Database Error: Server initialization failed:", err);
return res.code(500).send(DatabaseError());
}
});
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"baseUrl": "./",
"typeRoots": [
"./node_modules/@types",
"./src/types",
],
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
"outDir": "./dist",
"paths": {
"@/*": ["./src/*"],
},
"removeComments": true,
},
"tsc-alias": {
"resolveFullPaths": true,
},
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+2
View File
@@ -0,0 +1,2 @@
# Nocos Frontend
Vue 3.5 + Vue Router 5.0 + Vite 7.2
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LynqChat</title>
<link rel="icon" href="/lynqchat.svg" type="image/svg+xml">
</head>
<body>
<style>
* {
margin: 0;
padding: 0;
}
html, body {
width: 100vw;
height: 100vh;
font-size: 16px;
background-color: #ffffff;
}
@media (prefers-color-scheme: dark) {
html, body {
background-color: #1b1b1b;
}
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: fit-content;
height: fit-content;
position: fixed;
inset: 50%;
transform: translate(-50%, -50%);
}
.logo {
width: 6rem;
height: 6rem;
border-radius: 1rem;
}
.progress {
border: 0.4rem solid #425C97;
border-radius: 100%;
border-top: 0.4rem solid #ffffff;
width: 4rem;
height: 4rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<div class="loading">
<img
class="logo"
src="/lynqchat.svg"
/>
<div class="progress" />
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"type": "module",
"license": "AGPL-3.0-only",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"dexie": "^4.3.0",
"vue": "^3.5.24",
"vue-router": "^5.0.2"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^8.0.0",
"vue-tsc": "^3.1.4"
},
"packageManager": "pnpm@10.29.1"
}
+11
View File
@@ -0,0 +1,11 @@
<svg width="4096" height="4096" viewBox="0 0 4096 4096" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="4096" height="4096" fill="#1E1E1E"/>
<rect width="4096" height="4096" fill="#425C97"/>
<rect x="546" y="1086" width="155.544" height="1011.04" rx="77.7722" fill="white"/>
<rect x="1168.18" y="1941.49" width="155.544" height="622.177" rx="77.7722" transform="rotate(90 1168.18 1941.49)" fill="white"/>
<path d="M1674.03 1913.56C1691.75 1951.65 1679.85 1996.96 1645.71 2021.43C1601.21 2053.32 1538.68 2036.9 1515.59 1987.26L1325.79 1579.23C1298.84 1521.31 1341.13 1455 1405.01 1455C1438.99 1455 1469.9 1474.71 1484.23 1505.52L1674.03 1913.56Z" fill="white"/>
<path d="M1520.23 2377.11C1505.89 2407.93 1474.99 2427.64 1441 2427.64C1377.13 2427.64 1334.84 2361.33 1361.78 2303.41L1732.93 1505.52C1747.26 1474.71 1778.16 1455 1812.15 1455C1876.03 1455 1918.32 1521.31 1891.37 1579.23L1520.23 2377.11Z" fill="white"/>
<path d="M2849 1439C2959.46 1439 3049 1528.54 3049 1639V2021.23C3049 2064.18 3014.18 2099 2971.23 2099C2928.28 2099 2893.46 2064.18 2893.46 2021.23V1639C2893.46 1614.83 2874.17 1595.16 2850.15 1594.55L2849 1594.54H2249L2247.85 1594.55C2223.83 1595.16 2204.54 1614.83 2204.54 1639V2021.23C2204.54 2064.18 2169.72 2099 2126.77 2099C2083.82 2099 2049 2064.18 2049 2021.23V1639C2049 1528.54 2138.54 1439 2249 1439H2849Z" fill="white"/>
<path d="M2749 1766.77H3389C3434.41 1766.77 3471.23 1803.59 3471.23 1849V2149C3471.23 2216.51 3416.51 2271.23 3349 2271.23H2749C2681.49 2271.23 2626.77 2216.51 2626.77 2149V1889C2626.77 1821.49 2681.49 1766.77 2749 1766.77Z" stroke="white" stroke-width="155.54"/>
<path d="M3393.46 1689C3479.62 1689 3549.46 1758.84 3549.46 1845V2931C3549.46 2974.08 3514.54 3009 3471.46 3009C3428.38 3009 3393.46 2974.08 3393.46 2931V1689Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+27
View File
@@ -0,0 +1,27 @@
<template>
<Progress
v-show="routerStatus.isLoad"
class="router-progress"
:size="6"
/>
<main>
<RouterView
:key="$route.fullPath"
/>
</main>
</template>
<style scoped>
.router-progress {
position: fixed;
inset: 0.5rem 0.5rem 0 auto;
z-index: 9999;
}
</style>
<script lang="ts" setup>
import { RouterView } from "vue-router";
import routerStatus from "@/lib/router";
import Progress from "@/components/Progress.vue";
</script>
+32
View File
@@ -0,0 +1,32 @@
<template>
<div class="progress" />
</template>
<style scoped>
.progress {
border: v-bind(borderSize) solid #425C97;
border-radius: 100%;
border-top: v-bind(borderSize) solid #ffffff;
width: v-bind(size);
height: v-bind(size);
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<script lang="ts" setup>
const props = defineProps<{
size: number;
}>();
const size = `${props.size * 0.25}rem`;
const borderSize = `${props.size / 3}px`;
</script>
+24
View File
@@ -0,0 +1,24 @@
:root {
--bg-color: #ffffff;
--text-color: #000000;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1b1b1b;
--text-color: #ffffff;
}
}
* {
margin: 0;
padding: 0;
}
html, body {
width: 100vw;
height: 100vh;
font-size: 16px;
background-color: var(--bg-color);
color: var(--text-color);
}
+56
View File
@@ -0,0 +1,56 @@
import Dexie, { type EntityTable } from "dexie";
export interface Settings {
id: number;
name: string;
value: string;
}
export interface Server {
id: number;
name: string;
value: string;
}
export default class extends Dexie {
server!: EntityTable<Server, "id">;
settings!: EntityTable<Settings, "id">;
constructor() {
super("lynq-chat");
this.version(1).stores({
server: "++id,&name",
settings: "++id,&name",
});
/*(async () => {
await this.open();
await this.transaction("rw", this.settings, async () => {
const defaults = {
"key": "value",
};
for (const [name, value] of Object.entries(defaults)) {
const exists = await this.settings
.where("name")
.equals(name)
.first();
if (!exists) {
await this.settings.add({ name, value });
}
}
});
})();*/
};
}
export async function getByIndex<T>(
table: EntityTable<T, any>,
index: string,
indexValue: any
): Promise<T | undefined> {
return await table.where(index).equals(indexValue).first();
}
+9
View File
@@ -0,0 +1,9 @@
import { reactive } from "vue";
const routerStatus = reactive<{
isLoad: boolean;
}>({
isLoad: false,
});
export default routerStatus;
+29
View File
@@ -0,0 +1,29 @@
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import routerStatus from "@/lib/router";
import "@/global.css";
import Layout from "@/Layout.vue";
const app = createApp(Layout);
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
component: () => import("@/routes/index.vue"),
},
],
});
// @ts-ignore 余分な引数の警告
router.beforeEach((to, from, next) => {
routerStatus.isLoad = true;
next();
});
router.afterEach(() => {
routerStatus.isLoad = false;
});
app.use(router);
app.mount("body");
+6
View File
@@ -0,0 +1,6 @@
<template>
<h1>Hello World</h1>
</template>
<script lang="ts" setup>
</script>
+4
View File
@@ -0,0 +1,4 @@
export {}
declare global {
}
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"typeRoots": ["./node_modules/"],
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"module": "ESNext",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Alias */
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"],
},
},
"include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./src/**/*.vue",
],
}
+13
View File
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
},
},
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "")
const apiPort = Number(env.VITE_API_PORT) || 3300
return {
plugins: [vue()],
server: {
hmr: {
clientPort: 5173,
protocol: "ws",
},
proxy: {
"/api": {
target: `http://localhost:${apiPort}`,
changeOrigin: true,
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
}
})
+11
View File
@@ -0,0 +1,11 @@
{
"name": "lynqchat",
"private": true,
"type": "module",
"scripts": {},
"packageManager": "pnpm@10.29.1",
"dependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
import SetupCreateAdmin from "./setup/create-admin";
import SetupInitilization from "./setup/initialization";
type ApiMap =
SetupInitilization &
SetupCreateAdmin;
export default ApiMap;
@@ -0,0 +1,23 @@
import { InputError, InputNoneError } from "../../modules/error/input";
import ErrorBase from "../../modules/error";
import DatabaseError from "../../modules/error/database";
export default interface SetupCreateAdmin {
"setup/create-admin": {
body: {
userid: string;
username: string;
email: string;
password: string;
};
response: Success | DatabaseError | ErrorBase<{
bad: "client",
code: "yet_initialization",
message: "初期設定が行われていません。",
}> | ErrorBase<{
bad: "client",
code: "first_admin_already_exists",
message: "最初の管理者ユーザーは既に存在します。",
}> | InputError | InputNoneError;
};
}
@@ -0,0 +1,19 @@
import { InputError, InputNoneError } from "../../modules/error/input";
import ErrorBase from "../../modules/error";
import DatabaseError from "../../modules/error/database";
export default interface SetupInitilization {
"setup/initilization": {
body: {
name: string;
description: string;
requiredInvitationCode: boolean;
force?: "use_force_initialization";
};
response: Success | DatabaseError | ErrorBase<{
bad: "client",
code: "already_initialization",
message: "既に初期設定が行われています。",
}> | InputError | InputNoneError;
};
}
@@ -0,0 +1,9 @@
import ErrorBase from ".";
type DatabaseError = ErrorBase<{
bad: "server",
code: "database_error",
message: "サーバーでデータベースの問題が発生しました。",
}>;
export default DatabaseError;
@@ -0,0 +1,13 @@
type ErrorType<R = unknown> = {
bad: "client" | "server";
code: string;
message: string;
reason?: R,
}
type ErrorBase<E extends ErrorType<any>> = {
success: false;
error: E;
}
export default ErrorBase;
@@ -0,0 +1,16 @@
export type InputError = ErrorBase<{
bad: "client";
code: "input_wrong";
message: "入力に問題があります。";
reason: {
code: string,
path: string,
message: string,
}[];
}>;
export type InputNoneError = ErrorBase<{
bad: "client",
code: "input_none",
message: "入力がありません。",
}>;
@@ -0,0 +1,3 @@
export default interface Success {
success: true;
}
+71
View File
@@ -0,0 +1,71 @@
import lynqError from "./lib/error";
import lynqFetch from "./lib/fetch";
interface Options {
origin: string;
retry?: number;
waiting?: number;
}
type BodyArgs<M, E extends keyof M> =
M[E] extends { body: never } ? [] :
M[E] extends { body: infer B } ? [body: B] :
M[E] extends { body?: infer B } ? [body?: B] :
[];
export default class LynqChat<
M extends { [K in keyof M]: { body?: any; response: any } }
> {
readonly origin: string;
readonly retry: number;
readonly waiting: number;
private _token: string | null = null;
constructor(options: Options) {
this.origin = options.origin;
this.retry = options.retry ?? 5;
this.waiting = options.waiting ?? 500;
if (this.retry < 1) throw new lynqError("Invalid retry count.");
if (this.waiting < 1) throw new lynqError("Invalid base waiting time.");
if (options.origin !== new URL(options.origin).origin)
throw new lynqError("Invalid origin.");
}
get token(): string | null {
return this._token;
}
set token(token: string) {
if (token.length !== 64) throw new lynqError("Invalid token.");
this._token = token;
}
public async request<E extends keyof M>(
endpoint: E,
...args: BodyArgs<M, E>
): Promise<M[E]["response"]>;
public async request(
endpoint: string,
body?: any
): Promise<any>;
public async request(
endpoint: string,
...args: any[]
): Promise<any> {
const req = await lynqFetch(
this.origin,
this.retry,
this.waiting,
endpoint,
{
method: "POST",
cache: "no-store",
body: args[0],
}
);
return await req.json();
}
}
+6
View File
@@ -0,0 +1,6 @@
export default class lynqError extends Error {
constructor(message: string) {
super(message);
this.name = "lynqError";
}
}
+56
View File
@@ -0,0 +1,56 @@
import lynqError from "./error";
export default async function lynqFetch(
origin: string,
retryCount: number,
waitingTime: number,
endpoint: string,
init?: RequestInit,
) {
const waiting = (ms: number) =>
new Promise<void>(resolve => setTimeout(resolve, ms));
let lastError;
for (let i = 0; i < retryCount; i++) {
try {
if (init?.signal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
const req = await fetch(new URL(`/api/${endpoint}`, origin), init);
if (
Math.floor(req.status / 200) === 1 ||
Math.floor(req.status / 300) === 1
) {
return req;
}
if (Math.floor(req.status / 400) === 1)
throw new lynqError(`Client error: HTTP${req.status} - ${req.statusText}`);
lastError = new Error(`Request failed: HTTP${req.status} - ${req.statusText}`);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
throw err;
}
if (
err instanceof lynqError &&
err.name === "Client error" &&
err.message.startsWith("HTTP4")
)
throw err;
lastError = err;
}
if (i < retryCount - 1) {
const waitTime = waitingTime * 2 ** i;
await waiting(waitTime);
}
}
throw lastError ?? new Error("Unknown Error");
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./dist/types",
"strict": true,
"skipLibCheck": true,
"baseUrl": "./src/",
"typeRoots": [
"./node_modules/@types",
],
},
"tsc-alias": {
"resolveFullPaths": true,
},
}
+3276
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
packages:
- packages/*
minimumReleaseAge: 10080
minimumReleaseAgeExclude:
- fastify
- pnpm@10.29.1
- esbuild
- vite
- rolldown
- fastify
- pnpm
- "@types/node"
- "@esbuild/*"
- "@rolldown/*"
onlyBuiltDependencies:
- argon2
- esbuild