feat: complete Phase 3 - Domain Modeling & Data Layer
Implements comprehensive type system, runtime validation, API client, and testing infrastructure for CS2.WTF. TypeScript Interfaces (src/lib/types/): - Match.ts: Match, MatchPlayer, MatchListItem types - Player.ts: Player, PlayerMeta, PlayerProfile types - RoundStats.ts: Round economy and performance data - Weapon.ts: Weapon statistics with hit groups (HitGroup, WeaponType enums) - Message.ts: Chat messages with filtering support - api.ts: API responses, errors, and APIException class - Complete type safety with strict null checks Zod Schemas (src/lib/schemas/): - Runtime validation for all data models - Schema parsers with safe/unsafe variants - Special handling for backend typo (looses → losses) - Share code validation regex - CS2-specific validations (rank 0-30000, MR12 rounds) API Client (src/lib/api/): - client.ts: Axios-based HTTP client with error handling - Request cancellation support (AbortController) - Automatic retry logic for transient failures - Timeout handling (10s default) - Typed APIException errors - players.ts: Player endpoints (profile, meta, track/untrack, search) - matches.ts: Match endpoints (parse, details, weapons, rounds, chat, search) - Zod validation on all API responses MSW Mock Handlers (src/mocks/): - fixtures.ts: Comprehensive mock data for testing - handlers/players.ts: Mock player API endpoints - handlers/matches.ts: Mock match API endpoints - browser.ts: Browser MSW worker for development - server.ts: Node MSW server for Vitest tests - Realistic responses with delays and pagination - Safe integer IDs to avoid precision loss Configuration: - .env.example: Complete environment variable documentation - src/vite-env.d.ts: Vite environment type definitions - All strict TypeScript checks passing (0 errors, 0 warnings) Features: - Cancellable requests for search (prevent race conditions) - Data normalization (backend typo handling) - Comprehensive error types (NetworkError, Timeout, etc.) - Share code parsing and validation - Pagination support for players and matches - Mock data for offline development and testing Build Status: ✓ Production build successful Type Check: ✓ 0 errors, 0 warnings Lint: ✓ All checks passed Phase 3 Completion: 100% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
44
src/mocks/browser.ts
Normal file
44
src/mocks/browser.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { setupWorker } from 'msw/browser';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
/**
|
||||
* MSW Browser Worker
|
||||
* Used for mocking API requests in the browser (development mode)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create MSW service worker
|
||||
*/
|
||||
export const worker = setupWorker(...handlers);
|
||||
|
||||
/**
|
||||
* Start MSW worker with console logging
|
||||
*/
|
||||
export const startMocking = async () => {
|
||||
const isDev = import.meta.env?.DEV ?? false;
|
||||
const isMockingEnabled = import.meta.env?.VITE_ENABLE_MSW_MOCKING === 'true';
|
||||
|
||||
if (isDev && isMockingEnabled) {
|
||||
await worker.start({
|
||||
onUnhandledRequest: 'bypass',
|
||||
serviceWorker: {
|
||||
url: '/mockServiceWorker.js'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[MSW] API mocking enabled for development');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop MSW worker
|
||||
*/
|
||||
export const stopMocking = () => {
|
||||
worker.stop();
|
||||
console.log('[MSW] API mocking stopped');
|
||||
};
|
||||
|
||||
/**
|
||||
* Default export
|
||||
*/
|
||||
export default worker;
|
||||
194
src/mocks/fixtures.ts
Normal file
194
src/mocks/fixtures.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { Player, Match, MatchPlayer, MatchListItem, PlayerMeta } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Mock data fixtures for testing and development
|
||||
*/
|
||||
|
||||
/** Mock players */
|
||||
export const mockPlayers: Player[] = [
|
||||
{
|
||||
id: 765611980123456, // Smaller mock Steam ID (safe integer)
|
||||
name: 'TestPlayer1',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
|
||||
vanity_url: 'testplayer1',
|
||||
steam_updated: '2024-11-04T10:30:00Z',
|
||||
profile_created: '2015-03-12T00:00:00Z',
|
||||
wins: 1250,
|
||||
losses: 980,
|
||||
ties: 45,
|
||||
vac_count: 0,
|
||||
game_ban_count: 0
|
||||
},
|
||||
{
|
||||
id: 765611980876543, // Smaller mock Steam ID (safe integer)
|
||||
name: 'TestPlayer2',
|
||||
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
|
||||
steam_updated: '2024-11-04T11:15:00Z',
|
||||
wins: 850,
|
||||
losses: 720,
|
||||
ties: 30,
|
||||
vac_count: 0,
|
||||
game_ban_count: 0
|
||||
}
|
||||
];
|
||||
|
||||
/** Mock player metadata */
|
||||
export const mockPlayerMeta: PlayerMeta = {
|
||||
id: 765611980123456,
|
||||
name: 'TestPlayer1',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
|
||||
recent_matches: 25,
|
||||
last_match_date: '2024-11-01T18:45:00Z',
|
||||
avg_kills: 21.3,
|
||||
avg_deaths: 17.8,
|
||||
avg_kast: 75.2,
|
||||
win_rate: 56.5
|
||||
};
|
||||
|
||||
/** Mock match players */
|
||||
export const mockMatchPlayers: MatchPlayer[] = [
|
||||
{
|
||||
id: 765611980123456,
|
||||
name: 'Player1',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
|
||||
team_id: 2,
|
||||
kills: 24,
|
||||
deaths: 18,
|
||||
assists: 6,
|
||||
headshot: 12,
|
||||
mvp: 3,
|
||||
score: 56,
|
||||
kast: 78,
|
||||
rank_old: 18500,
|
||||
rank_new: 18650,
|
||||
dmg_enemy: 2450,
|
||||
dmg_team: 120,
|
||||
flash_assists: 4,
|
||||
flash_duration_enemy: 15.6,
|
||||
flash_total_enemy: 8,
|
||||
ud_he: 450,
|
||||
ud_flames: 230,
|
||||
ud_flash: 5,
|
||||
ud_smoke: 3,
|
||||
avg_ping: 25.5,
|
||||
color: 'yellow'
|
||||
},
|
||||
{
|
||||
id: 765611980876543,
|
||||
name: 'Player2',
|
||||
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
|
||||
team_id: 2,
|
||||
kills: 19,
|
||||
deaths: 20,
|
||||
assists: 8,
|
||||
headshot: 9,
|
||||
mvp: 2,
|
||||
score: 48,
|
||||
kast: 72,
|
||||
rank_old: 17200,
|
||||
rank_new: 17180,
|
||||
dmg_enemy: 2180,
|
||||
dmg_team: 85,
|
||||
avg_ping: 32.1,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
id: 765611980111111,
|
||||
name: 'Player3',
|
||||
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/cd/cde456.jpg',
|
||||
team_id: 3,
|
||||
kills: 22,
|
||||
deaths: 19,
|
||||
assists: 5,
|
||||
headshot: 14,
|
||||
mvp: 4,
|
||||
score: 60,
|
||||
kast: 80,
|
||||
rank_old: 19800,
|
||||
rank_new: 19920,
|
||||
dmg_enemy: 2680,
|
||||
dmg_team: 45,
|
||||
avg_ping: 18.3,
|
||||
color: 'green'
|
||||
}
|
||||
];
|
||||
|
||||
/** Mock matches */
|
||||
export const mockMatches: Match[] = [
|
||||
{
|
||||
match_id: 358948771684207, // Smaller mock match ID (safe integer)
|
||||
share_code: 'CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX',
|
||||
map: 'de_inferno',
|
||||
date: '2024-11-01T18:45:00Z',
|
||||
score_team_a: 13,
|
||||
score_team_b: 10,
|
||||
duration: 2456,
|
||||
match_result: 1,
|
||||
max_rounds: 24,
|
||||
demo_parsed: true,
|
||||
vac_present: false,
|
||||
gameban_present: false,
|
||||
tick_rate: 64.0,
|
||||
players: mockMatchPlayers
|
||||
},
|
||||
{
|
||||
match_id: 358948771684208,
|
||||
share_code: 'CSGO-YYYYY-YYYYY-YYYYY-YYYYY-YYYYY',
|
||||
map: 'de_mirage',
|
||||
date: '2024-11-02T20:15:00Z',
|
||||
score_team_a: 16,
|
||||
score_team_b: 14,
|
||||
duration: 2845,
|
||||
match_result: 1,
|
||||
max_rounds: 24,
|
||||
demo_parsed: true,
|
||||
vac_present: false,
|
||||
gameban_present: false,
|
||||
tick_rate: 64.0
|
||||
},
|
||||
{
|
||||
match_id: 358948771684209,
|
||||
share_code: 'CSGO-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ',
|
||||
map: 'de_dust2',
|
||||
date: '2024-11-03T15:30:00Z',
|
||||
score_team_a: 9,
|
||||
score_team_b: 13,
|
||||
duration: 1980,
|
||||
match_result: 2,
|
||||
max_rounds: 24,
|
||||
demo_parsed: true,
|
||||
vac_present: false,
|
||||
gameban_present: false,
|
||||
tick_rate: 64.0
|
||||
}
|
||||
];
|
||||
|
||||
/** Mock match list items */
|
||||
export const mockMatchListItems: MatchListItem[] = mockMatches.map((match) => ({
|
||||
match_id: match.match_id,
|
||||
map: match.map,
|
||||
date: match.date,
|
||||
score_team_a: match.score_team_a,
|
||||
score_team_b: match.score_team_b,
|
||||
duration: match.duration,
|
||||
demo_parsed: match.demo_parsed,
|
||||
player_count: 10
|
||||
}));
|
||||
|
||||
/** Helper: Generate random Steam ID (safe integer) */
|
||||
export const generateSteamId = (): number => {
|
||||
return 765611980000000 + Math.floor(Math.random() * 999999);
|
||||
};
|
||||
|
||||
/** Helper: Get mock player by ID */
|
||||
export const getMockPlayer = (id: number): Player | undefined => {
|
||||
return mockPlayers.find((p) => p.id === id);
|
||||
};
|
||||
|
||||
/** Helper: Get mock match by ID */
|
||||
export const getMockMatch = (id: number): Match | undefined => {
|
||||
return mockMatches.find((m) => m.match_id === id);
|
||||
};
|
||||
17
src/mocks/handlers/index.ts
Normal file
17
src/mocks/handlers/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* MSW Request Handlers
|
||||
* Mocks all CS2.WTF API endpoints for testing and development
|
||||
*/
|
||||
|
||||
import { playersHandlers } from './players';
|
||||
import { matchesHandlers } from './matches';
|
||||
|
||||
/**
|
||||
* Combined handlers for all API endpoints
|
||||
*/
|
||||
export const handlers = [...playersHandlers, ...matchesHandlers];
|
||||
|
||||
/**
|
||||
* Default export
|
||||
*/
|
||||
export default handlers;
|
||||
192
src/mocks/handlers/matches.ts
Normal file
192
src/mocks/handlers/matches.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import { mockMatches, mockMatchListItems, getMockMatch } from '../fixtures';
|
||||
import type {
|
||||
MatchParseResponse,
|
||||
MatchesListResponse,
|
||||
MatchRoundsResponse,
|
||||
MatchWeaponsResponse,
|
||||
MatchChatResponse
|
||||
} from '$lib/types';
|
||||
|
||||
/**
|
||||
* MSW handlers for Match API endpoints
|
||||
*/
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
export const matchesHandlers = [
|
||||
// GET /match/parse/:sharecode
|
||||
http.get(`${API_BASE_URL}/match/parse/:sharecode`, async () => {
|
||||
// Simulate parsing delay
|
||||
await delay(500);
|
||||
|
||||
const response: MatchParseResponse = {
|
||||
match_id: 358948771684207,
|
||||
status: 'parsing',
|
||||
message: 'Demo download and parsing initiated',
|
||||
estimated_time: 120
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /match/:id
|
||||
http.get(`${API_BASE_URL}/match/:id`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const matchId = Number(id);
|
||||
|
||||
const match = getMockMatch(matchId) || mockMatches[0];
|
||||
|
||||
return HttpResponse.json(match);
|
||||
}),
|
||||
|
||||
// GET /match/:id/weapons
|
||||
http.get(`${API_BASE_URL}/match/:id/weapons`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const matchId = Number(id);
|
||||
|
||||
const response: MatchWeaponsResponse = {
|
||||
match_id: matchId,
|
||||
weapons: [
|
||||
{
|
||||
player_id: 765611980123456,
|
||||
weapon_stats: [
|
||||
{
|
||||
eq_type: 17,
|
||||
weapon_name: 'AK-47',
|
||||
kills: 12,
|
||||
damage: 1450,
|
||||
hits: 48,
|
||||
hit_groups: {
|
||||
head: 8,
|
||||
chest: 25,
|
||||
stomach: 8,
|
||||
left_arm: 3,
|
||||
right_arm: 2,
|
||||
left_leg: 1,
|
||||
right_leg: 1
|
||||
},
|
||||
headshot_pct: 16.7
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /match/:id/rounds
|
||||
http.get(`${API_BASE_URL}/match/:id/rounds`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const matchId = Number(id);
|
||||
|
||||
const winReasons = ['elimination', 'bomb_defused', 'bomb_exploded'];
|
||||
const response: MatchRoundsResponse = {
|
||||
match_id: matchId,
|
||||
rounds: Array.from({ length: 23 }, (_, i) => ({
|
||||
round: i + 1,
|
||||
winner: i % 2 === 0 ? 2 : 3,
|
||||
win_reason: winReasons[i % 3] || 'elimination',
|
||||
players: [
|
||||
{
|
||||
round: i + 1,
|
||||
player_id: 765611980123456,
|
||||
bank: 800 + i * 1000,
|
||||
equipment: 650 + i * 500,
|
||||
spent: 650 + i * 500,
|
||||
kills_in_round: i % 3,
|
||||
damage_in_round: 100 + i * 20
|
||||
}
|
||||
]
|
||||
}))
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /match/:id/chat
|
||||
http.get(`${API_BASE_URL}/match/:id/chat`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const matchId = Number(id);
|
||||
|
||||
const response: MatchChatResponse = {
|
||||
match_id: matchId,
|
||||
messages: [
|
||||
{
|
||||
player_id: 765611980123456,
|
||||
player_name: 'Player1',
|
||||
message: 'nice shot!',
|
||||
tick: 15840,
|
||||
round: 8,
|
||||
all_chat: true,
|
||||
timestamp: '2024-11-01T19:12:34Z'
|
||||
},
|
||||
{
|
||||
player_id: 765611980876543,
|
||||
player_name: 'Player2',
|
||||
message: 'thanks',
|
||||
tick: 15920,
|
||||
round: 8,
|
||||
all_chat: true,
|
||||
timestamp: '2024-11-01T19:12:38Z'
|
||||
},
|
||||
{
|
||||
player_id: 765611980111111,
|
||||
player_name: 'Player3',
|
||||
message: 'rush b no stop',
|
||||
tick: 18400,
|
||||
round: 9,
|
||||
all_chat: false,
|
||||
timestamp: '2024-11-01T19:14:12Z'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /matches
|
||||
http.get(`${API_BASE_URL}/matches`, ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit')) || 50;
|
||||
const map = url.searchParams.get('map');
|
||||
const playerId = url.searchParams.get('player_id');
|
||||
|
||||
let matches = [...mockMatchListItems];
|
||||
|
||||
// Apply filters
|
||||
if (map) {
|
||||
matches = matches.filter((m) => m.map === map);
|
||||
}
|
||||
|
||||
if (playerId) {
|
||||
// In a real scenario, filter by player participation
|
||||
matches = matches.slice(0, Math.ceil(matches.length / 2));
|
||||
}
|
||||
|
||||
const response: MatchesListResponse = {
|
||||
matches: matches.slice(0, limit),
|
||||
next_page_time: Date.now() / 1000 - 86400,
|
||||
has_more: matches.length > limit,
|
||||
total_count: matches.length
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /matches/next/:time
|
||||
http.get(`${API_BASE_URL}/matches/next/:time`, ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit')) || 50;
|
||||
|
||||
// Return older matches for pagination
|
||||
const response: MatchesListResponse = {
|
||||
matches: mockMatchListItems.slice(limit, limit * 2),
|
||||
next_page_time: Date.now() / 1000 - 172800,
|
||||
has_more: mockMatchListItems.length > limit * 2
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
})
|
||||
];
|
||||
99
src/mocks/handlers/players.ts
Normal file
99
src/mocks/handlers/players.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { mockPlayers, mockPlayerMeta, getMockPlayer } from '../fixtures';
|
||||
import type { TrackPlayerResponse } from '$lib/types';
|
||||
|
||||
/**
|
||||
* MSW handlers for Player API endpoints
|
||||
*/
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
export const playersHandlers = [
|
||||
// GET /player/:id
|
||||
http.get(`${API_BASE_URL}/player/:id`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const playerId = Number(id);
|
||||
|
||||
const player = getMockPlayer(playerId);
|
||||
if (!player) {
|
||||
return HttpResponse.json(mockPlayers[0]);
|
||||
}
|
||||
|
||||
return HttpResponse.json(player);
|
||||
}),
|
||||
|
||||
// GET /player/:id/next/:time
|
||||
http.get(`${API_BASE_URL}/player/:id/next/:time`, ({ params }) => {
|
||||
const { id } = params;
|
||||
const playerId = Number(id);
|
||||
|
||||
const player = getMockPlayer(playerId) ?? mockPlayers[0];
|
||||
|
||||
// Return player with paginated matches (simulate older matches)
|
||||
return HttpResponse.json({
|
||||
...player,
|
||||
matches: player?.matches?.slice(5, 10) || []
|
||||
});
|
||||
}),
|
||||
|
||||
// GET /player/:id/meta
|
||||
http.get(`${API_BASE_URL}/player/:id/meta`, () => {
|
||||
return HttpResponse.json(mockPlayerMeta);
|
||||
}),
|
||||
|
||||
// GET /player/:id/meta/:limit
|
||||
http.get(`${API_BASE_URL}/player/:id/meta/:limit`, ({ params }) => {
|
||||
const { limit } = params;
|
||||
const limitNum = Number(limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
...mockPlayerMeta,
|
||||
recent_matches: limitNum
|
||||
});
|
||||
}),
|
||||
|
||||
// POST /player/:id/track
|
||||
http.post(`${API_BASE_URL}/player/:id/track`, async () => {
|
||||
const response: TrackPlayerResponse = {
|
||||
success: true,
|
||||
message: 'Player added to tracking queue'
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// DELETE /player/:id/track
|
||||
http.delete(`${API_BASE_URL}/player/:id/track`, () => {
|
||||
const response: TrackPlayerResponse = {
|
||||
success: true,
|
||||
message: 'Player removed from tracking'
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// GET /players/search (custom endpoint for search)
|
||||
http.get(`${API_BASE_URL}/players/search`, ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get('q');
|
||||
const limit = Number(url.searchParams.get('limit')) || 10;
|
||||
|
||||
// Filter players by name
|
||||
const filtered = mockPlayers
|
||||
.filter((p) => p.name.toLowerCase().includes(query?.toLowerCase() || ''))
|
||||
.slice(0, limit)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
avatar: p.avatar,
|
||||
recent_matches: 25,
|
||||
last_match_date: '2024-11-01T18:45:00Z',
|
||||
avg_kills: 20.5,
|
||||
avg_deaths: 18.2,
|
||||
avg_kast: 74.0,
|
||||
win_rate: 55.0
|
||||
}));
|
||||
|
||||
return HttpResponse.json(filtered);
|
||||
})
|
||||
];
|
||||
39
src/mocks/server.ts
Normal file
39
src/mocks/server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
import { beforeAll, afterEach, afterAll } from 'vitest';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
/**
|
||||
* MSW Server
|
||||
* Used for mocking API requests in Node.js (tests)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create MSW server for testing
|
||||
*/
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
/**
|
||||
* Setup server for tests
|
||||
* Call this in test setup files (e.g., vitest.setup.ts)
|
||||
*/
|
||||
export const setupMockServer = () => {
|
||||
// Start server before all tests
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: 'bypass' });
|
||||
});
|
||||
|
||||
// Reset handlers after each test
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Close server after all tests
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Default export
|
||||
*/
|
||||
export default server;
|
||||
Reference in New Issue
Block a user