init: FileDrop phase1 architecture and scaffold
- Rust axum signaling server with WebSocket support - Lit + TypeScript frontend with Vite - Redis session storage with TTL - WebRTC transport and crypto client stubs - Phase1 architecture plan in plans/ - Deploy directory structure prepared
This commit is contained in:
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FileDrop - 安全文件传输</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📦</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<filedrop-app></filedrop-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
web/package.json
Normal file
17
web/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "filedrop-web",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
27
web/src/api/session-api.ts
Normal file
27
web/src/api/session-api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export class SessionApi {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string = '') {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async createSession(fileCount: number, totalSize: number) {
|
||||
const res = await fetch(`${this.baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_count: fileCount, total_size: totalSize }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create session');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async joinSession(roomId: string, joinToken: string) {
|
||||
const res = await fetch(`${this.baseUrl}/api/sessions/${roomId}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ join_token: joinToken }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to join session');
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
33
web/src/api/signaling.ts
Normal file
33
web/src/api/signaling.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export class SignalingClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private messageHandler: ((msg: any) => void) | null = null;
|
||||
|
||||
connect(url: string) {
|
||||
this.ws = new WebSocket(url);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!this.ws) return reject('No WebSocket');
|
||||
this.ws.onopen = () => resolve();
|
||||
this.ws.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
send(msg: any) {
|
||||
this.ws?.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
onMessage(callback: (msg: any) => void) {
|
||||
this.messageHandler = callback;
|
||||
if (this.ws) {
|
||||
this.ws.onmessage = (e) => {
|
||||
try {
|
||||
this.messageHandler?.(JSON.parse(e.data));
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
47
web/src/crypto/crypto-client.ts
Normal file
47
web/src/crypto/crypto-client.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export class CryptoClient {
|
||||
private sessionKey: CryptoKey | null = null;
|
||||
|
||||
async initFromSecret(secret: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
this.sessionKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode('filedrop-v1'),
|
||||
info: encoder.encode('file-transfer-key'),
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
async encrypt(data: ArrayBuffer): Promise<{ ciphertext: ArrayBuffer; nonce: Uint8Array }> {
|
||||
if (!this.sessionKey) throw new Error('Not initialized');
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce },
|
||||
this.sessionKey,
|
||||
data
|
||||
);
|
||||
return { ciphertext, nonce };
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: ArrayBuffer, nonce: Uint8Array): Promise<ArrayBuffer> {
|
||||
if (!this.sessionKey) throw new Error('Not initialized');
|
||||
return crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: nonce },
|
||||
this.sessionKey,
|
||||
ciphertext
|
||||
);
|
||||
}
|
||||
}
|
||||
46
web/src/main.ts
Normal file
46
web/src/main.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import './ui/pages/home-page.js';
|
||||
import './ui/pages/send-page.js';
|
||||
import './ui/pages/join-page.js';
|
||||
import './ui/pages/expired-page.js';
|
||||
|
||||
@customElement('filedrop-app')
|
||||
export class FileDropApp extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
`;
|
||||
|
||||
@state() private route = '/';
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('popstate', () => this.updateRoute());
|
||||
this.updateRoute();
|
||||
}
|
||||
|
||||
private updateRoute() {
|
||||
this.route = window.location.pathname;
|
||||
}
|
||||
|
||||
render() {
|
||||
switch (this.route) {
|
||||
case '/send':
|
||||
return html`<send-page></send-page>`;
|
||||
case '/expired':
|
||||
return html`<expired-page></expired-page>`;
|
||||
default:
|
||||
if (this.route.startsWith('/join/')) {
|
||||
const roomId = this.route.split('/join/')[1];
|
||||
return html`<join-page .roomId="${roomId}"></join-page>`;
|
||||
}
|
||||
return html`<home-page></home-page>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
web/src/transfer/file-transfer.ts
Normal file
26
web/src/transfer/file-transfer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface FileManifest {
|
||||
type: 'file_manifest';
|
||||
transfer_id: string;
|
||||
files: {
|
||||
file_id: string;
|
||||
name_enc: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
chunk_size: number;
|
||||
chunk_count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ChunkAck {
|
||||
type: 'chunk_ack';
|
||||
transfer_id: string;
|
||||
file_id: string;
|
||||
chunk_index: number;
|
||||
}
|
||||
|
||||
export interface TransferComplete {
|
||||
type: 'transfer_complete';
|
||||
transfer_id: string;
|
||||
}
|
||||
|
||||
export const CHUNK_SIZE = 64 * 1024; // 64KB
|
||||
26
web/src/transfer/state.ts
Normal file
26
web/src/transfer/state.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type TransferState =
|
||||
| 'idle'
|
||||
| 'creating'
|
||||
| 'waiting'
|
||||
| 'connecting'
|
||||
| 'transferring'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'expired'
|
||||
| 'error';
|
||||
|
||||
export interface TransferStateContext {
|
||||
state: TransferState;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
roomId: string | null;
|
||||
}
|
||||
|
||||
export function createInitialState(): TransferStateContext {
|
||||
return {
|
||||
state: 'idle',
|
||||
progress: 0,
|
||||
error: null,
|
||||
roomId: null,
|
||||
};
|
||||
}
|
||||
39
web/src/ui/components/progress-bar.ts
Normal file
39
web/src/ui/components/progress-bar.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
@customElement('progress-bar')
|
||||
export class ProgressBar extends LitElement {
|
||||
static styles = css`
|
||||
.bar-container {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e5e5ea;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: #0071e3;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ type: Number }) progress = 0;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div>
|
||||
<div class="bar-container">
|
||||
<div class="bar-fill" style="width: ${this.progress}%"></div>
|
||||
</div>
|
||||
<div class="label">${Math.round(this.progress)}%</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
35
web/src/ui/components/qr-display.ts
Normal file
35
web/src/ui/components/qr-display.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
@customElement('qr-display')
|
||||
export class QrDisplay extends LitElement {
|
||||
static styles = css`
|
||||
.qr-container {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
canvas {
|
||||
border-radius: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
@property() data = '';
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="qr-container">
|
||||
<p>扫码加入</p>
|
||||
<canvas id="qr-canvas"></canvas>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.renderQR();
|
||||
}
|
||||
|
||||
private renderQR() {
|
||||
// QR code rendering placeholder
|
||||
// Will use a QR library in production
|
||||
}
|
||||
}
|
||||
35
web/src/ui/pages/expired-page.ts
Normal file
35
web/src/ui/pages/expired-page.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
@customElement('expired-page')
|
||||
export class ExpiredPage extends LitElement {
|
||||
static styles = css`
|
||||
.container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
p {
|
||||
color: #666;
|
||||
}
|
||||
a {
|
||||
color: #0071e3;
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="container">
|
||||
<h1>会话已过期</h1>
|
||||
<p>此会话已超时或被取消</p>
|
||||
<p><a href="/">返回首页</a></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
79
web/src/ui/pages/home-page.ts
Normal file
79
web/src/ui/pages/home-page.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
@customElement('home-page')
|
||||
export class HomePage extends LitElement {
|
||||
static styles = css`
|
||||
.container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
p {
|
||||
color: #666;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
button {
|
||||
padding: 16px 32px;
|
||||
font-size: 18px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.send-btn {
|
||||
background: #0071e3;
|
||||
color: white;
|
||||
}
|
||||
.send-btn:hover {
|
||||
background: #0077ed;
|
||||
}
|
||||
.receive-btn {
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
border: 1px solid #d2d2d7;
|
||||
}
|
||||
.receive-btn:hover {
|
||||
background: #e8e8ed;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="container">
|
||||
<h1>FileDrop</h1>
|
||||
<p>安全、无痕的文件传输工具</p>
|
||||
<div class="actions">
|
||||
<button class="send-btn" @click=${this.goToSend}>
|
||||
发送文件
|
||||
</button>
|
||||
<button class="receive-btn" @click=${this.showJoinInput}>
|
||||
接收文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private goToSend() {
|
||||
window.location.pathname = '/send';
|
||||
}
|
||||
|
||||
private showJoinInput() {
|
||||
const code = prompt('请输入房间码:');
|
||||
if (code) {
|
||||
window.location.pathname = `/join/${code}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
web/src/ui/pages/join-page.ts
Normal file
67
web/src/ui/pages/join-page.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { SessionApi } from '../../api/session-api.js';
|
||||
|
||||
@customElement('join-page')
|
||||
export class JoinPage extends LitElement {
|
||||
static styles = css`
|
||||
.container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.code {
|
||||
font-size: 20px;
|
||||
font-family: monospace;
|
||||
padding: 16px;
|
||||
background: #f5f5f7;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
button {
|
||||
padding: 16px 32px;
|
||||
font-size: 16px;
|
||||
background: #0071e3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
@property() roomId = '';
|
||||
@state() private joined = false;
|
||||
|
||||
render() {
|
||||
if (this.joined) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<h1>已连接</h1>
|
||||
<p>等待发送端传输文件...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<h1>加入房间</h1>
|
||||
<div class="code">房间码: ${this.roomId}</div>
|
||||
<button @click=${this.join}>确认加入</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async join() {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
const token = params.get('t') || '';
|
||||
|
||||
const api = new SessionApi();
|
||||
await api.joinSession(this.roomId, token);
|
||||
this.joined = true;
|
||||
}
|
||||
}
|
||||
92
web/src/ui/pages/send-page.ts
Normal file
92
web/src/ui/pages/send-page.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { SessionApi } from '../../api/session-api.js';
|
||||
|
||||
@customElement('send-page')
|
||||
export class SendPage extends LitElement {
|
||||
static styles = css`
|
||||
.container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.file-picker {
|
||||
border: 2px dashed #d2d2d7;
|
||||
border-radius: 12px;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.file-picker:hover {
|
||||
border-color: #0071e3;
|
||||
}
|
||||
.code {
|
||||
font-size: 32px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 4px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
background: #f5f5f7;
|
||||
border-radius: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.hint {
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@state() private roomId: string | null = null;
|
||||
@state() private files: File[] = [];
|
||||
|
||||
render() {
|
||||
if (!this.roomId) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<h1>选择文件</h1>
|
||||
<div class="file-picker" @click=${this.pickFiles}>
|
||||
<p>点击选择文件</p>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
@change=${this.onFilesSelected}
|
||||
id="file-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<h1>等待接收</h1>
|
||||
<div class="code">${this.roomId}</div>
|
||||
<p class="hint">让对方输入此房间码或扫码加入</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private pickFiles() {
|
||||
document.getElementById('file-input')?.click();
|
||||
}
|
||||
|
||||
private async onFilesSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
|
||||
this.files = Array.from(input.files);
|
||||
const totalSize = this.files.reduce((sum, f) => sum + f.size, 0);
|
||||
|
||||
const api = new SessionApi();
|
||||
const session = await api.createSession(this.files.length, totalSize);
|
||||
this.roomId = session.room_id;
|
||||
}
|
||||
}
|
||||
70
web/src/webrtc/transport.ts
Normal file
70
web/src/webrtc/transport.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export class WebRTCTransport {
|
||||
private pc: RTCPeerConnection | null = null;
|
||||
private dc: RTCDataChannel | null = null;
|
||||
private iceServers: RTCIceServer[];
|
||||
|
||||
constructor(iceServers: RTCIceServer[]) {
|
||||
this.iceServers = iceServers;
|
||||
}
|
||||
|
||||
async createOffer(): Promise<{ sdp: string; candidates: RTCIceCandidate[] }> {
|
||||
this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
||||
this.dc = this.pc.createDataChannel('filedrop', { ordered: true });
|
||||
|
||||
const offer = await this.pc.createOffer();
|
||||
await this.pc.setLocalDescription(offer);
|
||||
|
||||
const candidates = await this.waitForIceGathering();
|
||||
return {
|
||||
sdp: this.pc.localDescription?.sdp || '',
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
async handleAnswer(answer: { sdp: string }) {
|
||||
if (!this.pc) return;
|
||||
await this.pc.setRemoteDescription(
|
||||
new RTCSessionDescription({ type: 'answer', sdp: answer.sdp })
|
||||
);
|
||||
}
|
||||
|
||||
async handleCandidate(candidate: RTCIceCandidateInit) {
|
||||
await this.pc?.addIceCandidate(candidate);
|
||||
}
|
||||
|
||||
onDataChannel(callback: (dc: RTCDataChannel) => void) {
|
||||
if (this.pc) {
|
||||
this.pc.ondatachannel = (e) => callback(e.channel);
|
||||
}
|
||||
}
|
||||
|
||||
getDataChannel() {
|
||||
return this.dc;
|
||||
}
|
||||
|
||||
private waitForIceGathering(): Promise<RTCIceCandidate[]> {
|
||||
return new Promise((resolve) => {
|
||||
const candidates: RTCIceCandidate[] = [];
|
||||
if (!this.pc) return resolve(candidates);
|
||||
|
||||
this.pc.onicecandidate = (e) => {
|
||||
if (e.candidate) candidates.push(e.candidate);
|
||||
};
|
||||
|
||||
this.pc.onicegatheringstatechange = () => {
|
||||
if (this.pc?.iceGatheringState === 'complete') {
|
||||
resolve(candidates);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => resolve(candidates), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dc?.close();
|
||||
this.pc?.close();
|
||||
this.dc = null;
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
15
web/tsconfig.json
Normal file
15
web/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
17
web/vite.config.ts
Normal file
17
web/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: '../server/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:3000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user