♻️ refactor: Update deps, add Biome/Husky/tests, rework CI

This commit is contained in:
2026-07-29 03:45:35 +07:00
parent 7ac0187989
commit 959c8005ba
18 changed files with 524 additions and 227 deletions
-16
View File
@@ -1,16 +0,0 @@
name: Docker Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Build Docker image
id: build-image
uses: docker/build-push-action@v2
with:
push: false
+39 -13
View File
@@ -5,43 +5,69 @@ on:
branches: branches:
- '*' - '*'
tags: tags:
- '*' - '*.*.*'
pull_request:
env: env:
IMAGE_NAME: ghcr.io/yuzuzensai/kiroku IMAGE_NAME: ghcr.io/yuzuzensai/kiroku
jobs: jobs:
build: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint, format, and typecheck
run: bun run check
- name: Test
run: bun test
docker:
needs: check
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v2 if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ secrets.GHCR_IO_USERNAME }} username: ${{ github.actor }}
password: ${{ secrets.GHCR_IO_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta - name: Docker meta
id: docker-meta id: docker-meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.IMAGE_NAME }} ${{ env.IMAGE_NAME }}
tags: | tags: |
type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest-dev,enable=${{ github.ref_type == 'branch' }} type=raw,value=latest-dev,enable=${{ github.ref_type == 'branch' }}
type=raw,value=${{ github.ref_name }} type=raw,value=${{ github.ref_name }},enable=${{ github.ref_type == 'branch' }}
type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }} type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }}
flavor: | flavor: |
latest=false latest=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }}
- name: Docker Build and Push - name: Docker Build and Push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker-meta.outputs.tags }} tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }} labels: ${{ steps.docker-meta.outputs.labels }}
+2
View File
@@ -0,0 +1,2 @@
bun run check
bun test
-13
View File
@@ -1,13 +0,0 @@
{
"singleQuote": true,
"printWidth": 120,
"editor.formatOnSave": true,
"proseWrap": "always",
"tabWidth": 4,
"requireConfig": false,
"useTabs": false,
"trailingComma": "none",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"semi": true
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4,
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always",
"trailingCommas": "none",
"bracketSpacing": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
Regular → Executable
BIN
View File
Binary file not shown.
+35 -24
View File
@@ -1,25 +1,36 @@
{ {
"name": "kiroku", "name": "kiroku",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"tsc": "tsc", "tsc": "tsc",
"start": "bun --watch run ./src/index.ts", "start": "bun --watch run ./src/index.ts",
"dev": "tsc && bun start" "dev": "tsc && bun start",
}, "lint": "biome lint .",
"devDependencies": { "lint:fix": "biome lint --write .",
"@types/bun": "^1.1.10", "format": "biome format .",
"@types/steamapi": "^2.2.5", "format:fix": "biome format --write .",
"bun-types": "^1.1.29", "typecheck": "tsc --noEmit",
"typescript": "^5.6.2" "check": "biome check . && tsc --noEmit",
}, "check:fix": "biome check --write . && tsc --noEmit",
"peerDependencies": { "test": "bun test",
"typescript": "^5.0.0" "prepare": "husky"
}, },
"dependencies": { "devDependencies": {
"discord.js": "^14.16.3", "@biomejs/biome": "2.5.6",
"dotenv": "^16.4.5", "@sinclair/typebox": "^0.34.52",
"elysia": "^1.1.17", "@types/bun": "^1.3.14",
"steamapi": "^3.0.12" "bun-types": "^1.3.14",
} "husky": "^9.1.7",
} "typescript": "^7.0.2"
},
"peerDependencies": {
"typescript": "^7.0.0"
},
"dependencies": {
"discord.js": "^14.27.0",
"dotenv": "^17.4.2",
"elysia": "^1.4.29",
"steamapi": "^3.1.5"
}
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, mock, test } from 'bun:test';
import { createServer } from '../server';
import type { Config, ConfigSource, PresenceStore } from '../types';
function makeConfig(config: Config): ConfigSource {
return { getConfig: () => config };
}
describe('server', () => {
test('returns all data when no user is requested and lookup_all is enabled', async () => {
const config = makeConfig({ global: { lookup_all: true }, users: {} });
const presenceStore: PresenceStore = { getAll: () => ({ alice: {} }), get: mock() };
const response = await createServer(config, presenceStore).handle(new Request('http://localhost/'));
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toEqual({ success: true, data: { alice: {} } });
});
test('forbids lookup-all when disabled in config', async () => {
const config = makeConfig({ global: { lookup_all: false }, users: {} });
const presenceStore: PresenceStore = { getAll: mock(), get: mock() };
const response = await createServer(config, presenceStore).handle(new Request('http://localhost/'));
const body = await response.json();
expect(response.status).toBe(403);
expect(body).toEqual({ success: false, message: 'Lookup all is disabled' });
});
test('returns data for a specific user, case-insensitively', async () => {
const config = makeConfig({ global: { lookup_all: true }, users: {} });
const get = mock().mockReturnValue({ discord: { user: { id: '1' } } });
const presenceStore: PresenceStore = { getAll: mock(), get };
const response = await createServer(config, presenceStore).handle(new Request('http://localhost/?user=Alice'));
const body = await response.json();
expect(get).toHaveBeenCalledWith('alice');
expect(response.status).toBe(200);
expect(body).toEqual({ success: true, data: { discord: { user: { id: '1' } } } });
});
test('returns 404 when the requested user is not found', async () => {
const config = makeConfig({ global: { lookup_all: true }, users: {} });
const presenceStore: PresenceStore = { getAll: mock(), get: mock().mockReturnValue(undefined) };
const response = await createServer(config, presenceStore).handle(
new Request('http://localhost/?user=unknown')
);
const body = await response.json();
expect(response.status).toBe(404);
expect(body).toEqual({ success: false, message: 'Unable to find user' });
});
});
+6 -96
View File
@@ -1,108 +1,18 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
import ConfigProvider from './providers/ConfigProvider'; import ConfigProvider from './providers/ConfigProvider';
import DiscordProvider from './providers/DiscordProvider'; import { createServer } from './server';
import SteamProvider from './providers/SteamProvider'; import PresenceAggregator from './services/PresenceAggregator';
import { Elysia, t } from 'elysia';
// 1 minute function main() {
const REFRESH_TIME = 60 * 1000;
interface ReturnData {
[key: string]: {
discord?: {
user: any;
presences: any;
updatedAt: Date;
};
steam?: {
user: any;
updatedAt: Date;
};
};
}
let latestReturnData: ReturnData = {};
async function main() {
if (!ConfigProvider.isReady()) return; if (!ConfigProvider.isReady()) return;
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
setInterval(async () => { PresenceAggregator.start();
let newData: ReturnData = {};
for (let [key, value] of Object.entries(ConfigProvider.getConfig().users)) { createServer().listen(port);
key = key.toLowerCase();
newData[key] = {};
// Discord
if (value.discord && DiscordProvider.isReady) {
// Discord data is already cached by the library, no need to limit it
let user = await DiscordProvider.getUser(value.discord);
let presences = await DiscordProvider.getPresence(value.discord);
newData[key].discord = {
user,
presences: presences,
updatedAt: new Date()
};
}
// Steam
if (value.steam && SteamProvider.isReady) {
if (
latestReturnData[key] &&
latestReturnData[key].steam &&
latestReturnData[key].steam!.updatedAt.getTime() > Date.now() - REFRESH_TIME
) {
newData[key].steam = latestReturnData[key].steam;
} else {
let user = await SteamProvider.getProfile(value.steam);
newData[key].steam = {
user,
updatedAt: new Date()
};
}
}
}
// Check for changes
for (let [key, value] of Object.entries(newData)) {
if (JSON.stringify(value) !== JSON.stringify(latestReturnData[key])) {
latestReturnData[key] = value;
}
}
}, 500);
const server = new Elysia()
.get('/', async ({ query, error }) => {
query: t.Object({
user: t.Optional(t.String())
});
let user = query.user;
if (!user) {
if (!ConfigProvider.getConfig().global.lookup_all)
return error('Forbidden', { success: false, message: 'Lookup all is disabled' });
return {
success: true,
data: latestReturnData
};
}
user = user.toLowerCase();
if (!latestReturnData[user]) return error('Not Found', { success: false, message: 'Unable to find user' });
const userData = latestReturnData[user];
return {
success: true,
data: userData
};
})
.listen(port);
console.log(`Listening on port ${port}`); console.log(`Listening on port ${port}`);
} }
+5 -17
View File
@@ -1,19 +1,8 @@
import fs from 'fs'; import fs from 'node:fs';
import { Client, Events, GatewayIntentBits } from 'discord.js';
export interface Config { import type { Config } from '../types';
global: {
lookup_all: boolean;
discord_guild_id?: string;
discord_bot_token?: string;
steam_api_key?: string;
};
users: {
[key: string]: any;
};
}
class ConfigProvider { export class ConfigProvider {
private config: Config = { private config: Config = {
global: { global: {
lookup_all: false lookup_all: false
@@ -22,14 +11,13 @@ class ConfigProvider {
}; };
private ready = false; private ready = false;
constructor() { constructor(path = './config.json') {
try { try {
let data = fs.readFileSync('./config.json', 'utf8'); const data = fs.readFileSync(path, 'utf8');
this.config = JSON.parse(data); this.config = JSON.parse(data);
this.ready = true; this.ready = true;
} catch (err) { } catch (err) {
console.error('[ConfigProvider]', err); console.error('[ConfigProvider]', err);
return;
} }
} }
+14 -16
View File
@@ -1,7 +1,9 @@
import { ActivityType, Client, Events, GatewayIntentBits } from 'discord.js'; import { ActivityType, Client, Events, GatewayIntentBits, type Presence, type User } from 'discord.js';
import ConfigProvider from './ConfigProvider'; import ConfigProvider from './ConfigProvider';
const STATUS_REFRESH_INTERVAL = 5 * 60 * 1000;
class DiscordProvider { class DiscordProvider {
private client: Client; private client: Client;
private guildId: string = ''; private guildId: string = '';
@@ -17,7 +19,7 @@ class DiscordProvider {
this.ready = true; this.ready = true;
const setStatus = () => { const setStatus = () => {
this.client?.user?.setPresence({ client.user.setPresence({
activities: [ activities: [
{ {
name: 'custom', name: 'custom',
@@ -31,34 +33,30 @@ class DiscordProvider {
}; };
setStatus(); setStatus();
setInterval(() => { setInterval(setStatus, STATUS_REFRESH_INTERVAL);
setStatus();
}, 1000 * 60 * 5);
}); });
const token = ConfigProvider.getConfig().global.discord_bot_token; const token = ConfigProvider.getConfig().global.discord_bot_token;
const guild_id = ConfigProvider.getConfig().global.discord_guild_id; const guildId = ConfigProvider.getConfig().global.discord_guild_id;
if (!token || !guild_id) { if (!token || !guildId) {
console.error('[DiscordProvider]', 'Missing token or guild_id in config.json'); console.error('[DiscordProvider]', 'Missing token or guild_id in config.json');
return; return;
} }
this.guildId = guild_id; this.guildId = guildId;
this.client.login(token); this.client.login(token);
} }
public async getUser(id: string) { public async getUser(id: string): Promise<User> {
const user = this.client.users.cache.get(id) || (await this.client.users.fetch(id)); return this.client.users.cache.get(id) ?? (await this.client.users.fetch(id));
return user;
} }
public async getPresence(id: string) { public async getPresence(id: string): Promise<Presence | null> {
const guild = this.client.guilds.cache.get(this.guildId) || (await this.client.guilds.fetch(this.guildId)); const guild = this.client.guilds.cache.get(this.guildId) ?? (await this.client.guilds.fetch(this.guildId));
const member = guild.members.cache.get(id) || (await guild.members.fetch(id)); const member = guild.members.cache.get(id) ?? (await guild.members.fetch(id));
const presence = member.presence; return member.presence;
return presence;
} }
public get isReady(): boolean { public get isReady(): boolean {
+8 -9
View File
@@ -1,27 +1,26 @@
import SteamAPI from 'steamapi'; import SteamAPI, { type UserSummary } from 'steamapi';
import ConfigProvider from './ConfigProvider'; import ConfigProvider from './ConfigProvider';
class SteamProvider { class SteamProvider {
private client: SteamAPI; private client: SteamAPI | undefined;
private ready: boolean = false; private ready: boolean = false;
constructor() { constructor() {
this.client = new SteamAPI('dummy'); const apiKey = ConfigProvider.getConfig().global.steam_api_key;
const token = ConfigProvider.getConfig().global.steam_api_key;
if (!token) { if (!apiKey) {
console.error('[SteamProvider]', 'Missing steam_api_key in config'); console.error('[SteamProvider]', 'Missing steam_api_key in config');
return; return;
} }
this.client = new SteamAPI(token); this.client = new SteamAPI(apiKey);
this.ready = true; this.ready = true;
} }
public async getProfile(id: string) { public async getProfile(id: string): Promise<UserSummary> {
let profile = await this.client.getUserSummary(id); if (!this.client) throw new Error('SteamProvider is not ready');
return profile; return this.client.getUserSummary(id);
} }
public get isReady(): boolean { public get isReady(): boolean {
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, test } from 'bun:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { ConfigProvider } from '../ConfigProvider';
let tmpFile: string | undefined;
function writeTempConfig(contents: string): string {
tmpFile = path.join(os.tmpdir(), `kiroku-config-test-${Date.now()}-${Math.random()}.json`);
fs.writeFileSync(tmpFile, contents);
return tmpFile;
}
describe('ConfigProvider', () => {
afterEach(() => {
if (tmpFile) {
fs.rmSync(tmpFile, { force: true });
tmpFile = undefined;
}
});
test('loads and parses a valid config file', () => {
const configPath = writeTempConfig(
JSON.stringify({
global: { lookup_all: true, steam_api_key: 'abc' },
users: { alice: { discord: '123' } }
})
);
const provider = new ConfigProvider(configPath);
expect(provider.isReady()).toBe(true);
expect(provider.getConfig().global.lookup_all).toBe(true);
expect(provider.getConfig().users.alice?.discord).toBe('123');
});
test('is not ready and falls back to defaults when the file is missing', () => {
const provider = new ConfigProvider('./does-not-exist.json');
expect(provider.isReady()).toBe(false);
expect(provider.getConfig()).toEqual({ global: { lookup_all: false }, users: {} });
});
test('is not ready when the file contains invalid JSON', () => {
const configPath = writeTempConfig('{ not valid json');
const provider = new ConfigProvider(configPath);
expect(provider.isReady()).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { Elysia, t } from 'elysia';
import ConfigProviderSingleton from './providers/ConfigProvider';
import PresenceAggregatorSingleton from './services/PresenceAggregator';
import type { ConfigSource, PresenceStore } from './types';
export function createServer(
config: ConfigSource = ConfigProviderSingleton,
presenceStore: PresenceStore = PresenceAggregatorSingleton
) {
return new Elysia().get(
'/',
({ query, status }) => {
const requestedUser = query.user?.toLowerCase();
if (!requestedUser) {
if (!config.getConfig().global.lookup_all)
return status('Forbidden', { success: false, message: 'Lookup all is disabled' });
return { success: true, data: presenceStore.getAll() };
}
const userData = presenceStore.get(requestedUser);
if (!userData) return status('Not Found', { success: false, message: 'Unable to find user' });
return { success: true, data: userData };
},
{
query: t.Object({
user: t.Optional(t.String())
})
}
);
}
+67
View File
@@ -0,0 +1,67 @@
import ConfigProviderSingleton from '../providers/ConfigProvider';
import DiscordProviderSingleton from '../providers/DiscordProvider';
import SteamProviderSingleton from '../providers/SteamProvider';
import type { ConfigSource, DiscordSource, PresenceData, SteamSource } from '../types';
const POLL_INTERVAL = 500;
const STEAM_CACHE_TTL = 60 * 1000;
export class PresenceAggregator {
private data: PresenceData = {};
private timer: ReturnType<typeof setInterval> | undefined;
constructor(
private readonly config: ConfigSource = ConfigProviderSingleton,
private readonly discord: DiscordSource = DiscordProviderSingleton,
private readonly steam: SteamSource = SteamProviderSingleton
) {}
public start(): void {
if (this.timer) return;
this.timer = setInterval(() => this.refresh(), POLL_INTERVAL);
}
public stop(): void {
clearInterval(this.timer);
this.timer = undefined;
}
public getAll(): PresenceData {
return this.data;
}
public get(user: string): PresenceData[string] | undefined {
return this.data[user];
}
public async refresh(): Promise<void> {
const newData: PresenceData = {};
for (const [rawKey, userConfig] of Object.entries(this.config.getConfig().users)) {
const key = rawKey.toLowerCase();
newData[key] = {};
if (userConfig.discord && this.discord.isReady) {
const [user, presence] = await Promise.all([
this.discord.getUser(userConfig.discord),
this.discord.getPresence(userConfig.discord)
]);
newData[key].discord = { user, presence, updatedAt: new Date() };
}
if (userConfig.steam && this.steam.isReady) {
const cached = this.data[key]?.steam;
if (cached && cached.updatedAt.getTime() > Date.now() - STEAM_CACHE_TTL) {
newData[key].steam = cached;
} else {
const user = await this.steam.getProfile(userConfig.steam);
newData[key].steam = { user, updatedAt: new Date() };
}
}
}
this.data = newData;
}
}
export default new PresenceAggregator();
@@ -0,0 +1,87 @@
import { describe, expect, mock, test } from 'bun:test';
import type { Presence, User } from 'discord.js';
import type { UserSummary } from 'steamapi';
import type { Config, ConfigSource, DiscordSource, SteamSource } from '../../types';
import { PresenceAggregator } from '../PresenceAggregator';
const fakeUser = { id: 'd1', tag: 'alice#0' } as unknown as User;
const fakePresence = { status: 'online' } as unknown as Presence;
const fakeSteamUser = { nickname: 'alice' } as unknown as UserSummary;
function makeConfig(config: Config): ConfigSource {
return { getConfig: () => config };
}
function makeDiscord(overrides: Partial<DiscordSource> = {}): DiscordSource {
return {
isReady: true,
getUser: mock(),
getPresence: mock(),
...overrides
};
}
function makeSteam(overrides: Partial<SteamSource> = {}): SteamSource {
return {
isReady: true,
getProfile: mock(),
...overrides
};
}
describe('PresenceAggregator', () => {
test('aggregates discord and steam data per user, lowercasing keys', async () => {
const discord = makeDiscord({
getUser: mock().mockResolvedValue(fakeUser),
getPresence: mock().mockResolvedValue(fakePresence)
});
const steam = makeSteam({
getProfile: mock().mockResolvedValue(fakeSteamUser)
});
const config = makeConfig({
global: { lookup_all: true },
users: { Alice: { discord: 'd1', steam: 's1' } }
});
const aggregator = new PresenceAggregator(config, discord, steam);
await aggregator.refresh();
const entry = aggregator.get('alice');
expect(entry?.discord?.user).toEqual(fakeUser);
expect(entry?.discord?.presence).toEqual(fakePresence);
expect(entry?.steam?.user).toEqual(fakeSteamUser);
expect(aggregator.get('Alice')).toBeUndefined();
});
test('skips discord/steam sections when providers are not ready', async () => {
const discord = makeDiscord({ isReady: false });
const steam = makeSteam({ isReady: false });
const config = makeConfig({
global: { lookup_all: true },
users: { bob: { discord: 'd2', steam: 's2' } }
});
const aggregator = new PresenceAggregator(config, discord, steam);
await aggregator.refresh();
expect(aggregator.get('bob')).toEqual({});
expect(discord.getUser).not.toHaveBeenCalled();
expect(steam.getProfile).not.toHaveBeenCalled();
});
test('reuses cached steam data within the TTL window instead of refetching', async () => {
const getProfile = mock().mockResolvedValue({ nickname: 'carol' } as unknown as UserSummary);
const steam = makeSteam({ getProfile });
const config = makeConfig({
global: { lookup_all: true },
users: { carol: { steam: 's3' } }
});
const aggregator = new PresenceAggregator(config, makeDiscord(), steam);
await aggregator.refresh();
await aggregator.refresh();
expect(getProfile).toHaveBeenCalledTimes(1);
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { Presence, User } from 'discord.js';
import type { UserSummary } from 'steamapi';
export interface UserConfig {
discord?: string;
steam?: string;
}
export interface Config {
global: {
lookup_all: boolean;
discord_guild_id?: string;
discord_bot_token?: string;
steam_api_key?: string;
};
users: {
[key: string]: UserConfig;
};
}
export interface DiscordEntry {
user: User;
presence: Presence | null;
updatedAt: Date;
}
export interface SteamEntry {
user: UserSummary;
updatedAt: Date;
}
export interface UserData {
discord?: DiscordEntry;
steam?: SteamEntry;
}
export type PresenceData = {
[key: string]: UserData;
};
export interface ConfigSource {
getConfig(): Config;
}
export interface DiscordSource {
readonly isReady: boolean;
getUser(id: string): Promise<User>;
getPresence(id: string): Promise<Presence | null>;
}
export interface SteamSource {
readonly isReady: boolean;
getProfile(id: string): Promise<UserSummary>;
}
export interface PresenceStore {
getAll(): PresenceData;
get(user: string): PresenceData[string] | undefined;
}
+18 -23
View File
@@ -1,24 +1,19 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": ["ESNext"],
"ESNext" "module": "esnext",
], "target": "esnext",
"module": "esnext", "moduleResolution": "bundler",
"target": "esnext", "moduleDetection": "force",
"moduleResolution": "bundler", "allowImportingTsExtensions": true,
"moduleDetection": "force", "noEmit": true,
"allowImportingTsExtensions": true, "composite": true,
"noEmit": true, "strict": true,
"composite": true, "skipLibCheck": true,
"strict": true, "jsx": "react-jsx",
"downlevelIteration": true, "allowSyntheticDefaultImports": true,
"skipLibCheck": true, "forceConsistentCasingInFileNames": true,
"jsx": "react-jsx", "allowJs": false,
"allowSyntheticDefaultImports": true, "types": ["bun-types"]
"forceConsistentCasingInFileNames": true, }
"allowJs": false, }
"types": [
"bun-types" // add Bun global
]
}
}