Compare commits

...

16 Commits

Author SHA1 Message Date
e17485ef08 Add bunfig.toml 2025-10-03 20:26:55 -04:00
56ec490159 update look and feel of login 2025-10-03 20:26:40 -04:00
e63615eb46 rename websocket directory to websocket-chat 2025-10-01 07:51:50 -04:00
a762bed15c Delete Web/WebSocket/package.json 2025-10-01 11:50:52 +00:00
0e8b84b462 updates to frontend-react 2025-10-01 07:49:34 -04:00
0345f3d2d0 Add README and initial project structure for WebSocket chat application
* Created README.md for project overview and setup instructions.
* Updated App component to use selector for todo items.
* Enhanced TodoItemComponent styling and structure.
* Introduced new Redux selectors for better state management.
* Added initial configuration files for RequireJS and Bun.
* Established project structure for WebSocket chat application with server and frontend components.
* Included necessary dependencies and configurations for TypeScript and Vite.
2025-09-30 03:19:52 -04:00
0cc0ce13e7 basic chat 2025-09-28 14:55:09 -04:00
9306632cc1 Cleaned up HttpServer. WebSocket connects 2025-09-28 11:19:15 -04:00
8585549ae1 HttpServer working with GET routes 2025-09-28 09:20:37 -04:00
9372777296 Update WebSocket server with favicon assets and additional dependencies
* Added favicon assets including various sizes and a manifest file for improved branding
* Updated package.json to include new type definitions and dependencies for body-parser, cors, lru-cache, moment, multer, on-headers, response-time, and serve-favicon
* Enhanced HttpServer class to utilize the favicon and improved
  middleware configuration for handling requests
2025-09-27 18:41:19 -04:00
e895704785 Enhance WebSocket server with new HTTP server implementation and type assertions
* Updated package.json to specify the entry point for the development script and added morgan as a dependency.
* Introduced HttpServer class for handling HTTP requests with Express and integrated logging.
* Added new assertion methods in the Assert class for better type validation.
* Created IRoutes interface to define route handling structure.
* Added optional and nullable type definitions for improved type safety.
* Implemented initial server setup in src/index.ts.
2025-09-27 14:36:06 -04:00
cd40ed2bca Update WebSocket server configuration and add new dependencies
* Added npm configuration for Techniker registry
* Introduced preinstall and prelint scripts in package.json
* Added new dependencies: @techniker-me/logger and @techniker-me/tools
2025-09-27 13:52:59 -04:00
1fd1fc4090 Add all strict typescript configuration 2025-09-27 13:45:44 -04:00
ff31848460 websocket server: initial commit. Add Asserts static class 2025-09-27 13:43:30 -04:00
804f2d990a bfcache: server - Implement WebSocket server functionality
* Added WebSocketServer class with configurable options
* Introduced WebSocketServerFactory for creating WebSocket relay servers
* Updated index.ts to set up a server with WebSocket support and handle various signals for graceful shutdown
2025-09-18 17:49:07 -04:00
8f9813e735 bfcache: server - Update WebSocketServer configuration
* Adjusted maxPayloadLength to 16 MB
* Set backPressureLimit to 1 MB
* Added new socket event handlers: onSocketError, onSocketOpen, onSocketMessage, onSocketDrain, and onSocketClose
2025-09-17 23:55:28 -04:00
97 changed files with 3928 additions and 99 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# Playground
A repository to play around in

View File

@@ -1,8 +1,10 @@
import {useAppSelector} from './store'; import {selectTodoItems} from "./store/slices";
import {TodoItem} from './entities/TodoItem';
import {TodoItemComponent} from './components/TodoItem/TodoItem'; import {TodoItemComponent} from './components/TodoItem/TodoItem';
import { useAppSelector } from "./store";
export default function App() { export default function App() {
const items = useAppSelector(state => state.todo.items); const items = useAppSelector(selectTodoItems);
return ( return (
<div style={{width: '100vw', height: '100vh'}}> <div style={{width: '100vw', height: '100vh'}}>

View File

@@ -6,7 +6,14 @@ export interface ITodoItemProps {
} }
export const TodoItemComponent = ({todo: {id, title, notes, createdAt, updatedAt, dueAt, isArchived, isDone}}: ITodoItemProps): JSX.Element => { export const TodoItemComponent = ({todo: {id, title, notes, createdAt, updatedAt, dueAt, isArchived, isDone}}: ITodoItemProps): JSX.Element => {
return <div key={id} style={{border: '2px solid black', display: 'flex', width: '800px', height: '400px', flexDirection: 'column', margin: 'auto'}}> return <div key={id} style={{
border: '2px solid black',
display: 'flex',
width: '800px',
height: '250px',
flexDirection: 'column',
margin: 'auto'
}}>
<header >{title}</header> <header >{title}</header>
<div> <div>
<textarea defaultValue={notes ?? ''}></textarea> <textarea defaultValue={notes ?? ''}></textarea>

View File

@@ -1,5 +1,5 @@
import type {Nullable} from '../../types/definedTypes'; import type {Nullable} from '../../types/definedTypes';
import {createSlice} from '@reduxjs/toolkit'; import {createSelector, createSlice} from '@reduxjs/toolkit';
import {TodoItem} from '../../entities/TodoItem'; import {TodoItem} from '../../entities/TodoItem';
interface ITodoSliceState { interface ITodoSliceState {
@@ -20,6 +20,7 @@ const initialTodoState: ITodoSliceState = {
error: null error: null
}; };
export const TodoSlice = createSlice({ export const TodoSlice = createSlice({
name: 'todo', name: 'todo',
initialState: initialTodoState, initialState: initialTodoState,
@@ -61,5 +62,8 @@ export const TodoSlice = createSlice({
//} //}
}); });
const selectTodos = (state: {todo: ITodoSliceState}) => state.todo;
export const selectTodoItems = createSelector([selectTodos], todo => todo.items);
export const {appendTodoItem} = TodoSlice.actions; export const {appendTodoItem} = TodoSlice.actions;
export const todoSliceStateReducer = TodoSlice.reducer; export const todoSliceStateReducer = TodoSlice.reducer;

View File

@@ -0,0 +1 @@
../../CLAUDE.md

34
RequireJs/require-js-node/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@@ -0,0 +1,3 @@
save-exact=true
package-lock=false
@techniker-me:registry=https://registry-node.techniker.me

View File

@@ -0,0 +1,12 @@
{
"arrowParens": "avoid",
"bracketSameLine": true,
"bracketSpacing": false,
"printWidth": 160,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}

View File

@@ -0,0 +1,111 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
// import .css files directly and it works
import './index.css';
import { createRoot } from "react-dom/client";
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.

View File

@@ -0,0 +1,15 @@
# require-js-node
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run src/index.ts
```
This project was created using `bun init` in bun v1.2.21. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,37 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "require-js-node",
"dependencies": {
"requirejs": "2.3.7",
},
"devDependencies": {
"@types/bun": "latest",
"@types/requirejs": "2.1.37",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
"@types/node": ["@types/node@24.3.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g=="],
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
"@types/requirejs": ["@types/requirejs@2.1.37", "", {}, "sha512-jmFgr3mwN2NSmtRP6IpZ2nfRS7ufSXuDYQ6YyPFArN8x5dARQcD/DXzT0J6NYbvquVT4pg9K9HWdi6e6DZR9iQ=="],
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"requirejs": ["requirejs@2.3.7", "", { "bin": { "r.js": "bin/r.js", "r_js": "bin/r.js" } }, "sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "require-js-node",
"module": "src/index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest",
"@types/requirejs": "2.1.37"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"requirejs": "2.3.7"
}
}

View File

@@ -0,0 +1,7 @@
// requireJs
import {requirejs} from 'requirejs';
requirejs.config({
})

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "es6",
"module": "AMD",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"include": ["src"],
"exclude": [],
}

24
Web/WebSocket/websocket-chat/.gitignore vendored Normal file
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?

View File

@@ -0,0 +1,3 @@
save-exact=true
package-lock=false
@techniker-me:registry=https://registry-node.techniker.me

View File

@@ -0,0 +1 @@
20

View File

@@ -0,0 +1,12 @@
{
"arrowParens": "avoid",
"bracketSameLine": true,
"bracketSpacing": false,
"printWidth": 160,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}

View File

@@ -0,0 +1,60 @@
# WebSocket Chat Monorepo
A real-time WebSocket chat application built with TypeScript, Express, and Vite.
## Project Structure
```
├── apps/
│ ├── server/ # Express WebSocket server
│ └── frontend/ # React/Vite frontend
├── packages/
│ └── shared-types/ # Shared TypeScript types
└── package.json # Root workspace configuration
```
## Quick Start
```bash
# Install all dependencies
npm install
# Start both server and frontend in development mode
npm start
# Or run individually:
npm run dev:server # Start server only
npm run dev:frontend # Start frontend only
```
## Available Scripts
- `npm start` - Build shared types and start both server and frontend
- `npm run dev:all` - Start both server and frontend concurrently
- `npm run dev:server` - Start server only
- `npm run dev:frontend` - Start frontend only
- `npm run build` - Build all packages
- `npm run typecheck` - Type check all packages
- `npm run lint` - Lint all packages
- `npm run clean` - Clean all build outputs
## Development
The monorepo uses npm workspaces for dependency management and TypeScript project references for type checking across packages.
### Server
- Runs on `http://localhost:3000`
- WebSocket endpoint: `ws://localhost:3000/ws`
- Health check: `http://localhost:3000/ping`
### Frontend
- Runs on `http://localhost:5173` (Vite default)
- Connects to WebSocket server for real-time messaging
## Features
- Real-time WebSocket communication
- Ping/pong latency measurement
- Shared TypeScript types across packages
- Hot reload for development
- Monorepo workspace management

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?

View File

@@ -0,0 +1,2 @@
save-exact=true
package-lock=false

View File

@@ -0,0 +1 @@
24

View File

@@ -0,0 +1,12 @@
{
"arrowParens": "avoid",
"bracketSameLine": true,
"bracketSpacing": false,
"printWidth": 160,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname
}
// other options...
}
}
]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname
}
// other options...
}
}
]);
```

View File

@@ -0,0 +1,18 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import {defineConfig, globalIgnores} from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactRefresh.configs.vite],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
}
}
]);

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend-react</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
{
"name": "frontend-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "2.9.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "9.2.0",
"styled-components": "6.1.19"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@techniker-me/websocket-shared-types": "*",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react-swc": "^4.1.0",
"babel-plugin-styled-components": "2.1.4",
"babel-plugin-transform-amd-to-commonjs": "1.6.0",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "^7.1.7",
"vite-plugin-babel": "1.3.2"
}
}

View File

@@ -0,0 +1,10 @@
import {LoginView} from './views';
export default function App() {
console.log('APP')
return (
<>
<LoginView />
</>
);
}

View File

@@ -0,0 +1,3 @@
export interface ILoginFormProps {
onSubmit: (username: string, secret: string) => void;
}

View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
import type { ILoginFormProps } from './ILoginFormProps';
import { FlexForm, Input, Button, FormTitle, FormSubtitle, InputContainer, ErrorMessage } from './Styled';
export function LoginForm({ onSubmit }: ILoginFormProps) {
const [username, setUsername] = useState<string>('');
const [secret, setSecret] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const handleFormSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!username.trim() || !secret.trim()) {
setError('Please fill in all fields');
return;
}
setIsLoading(true);
setError('');
try {
await onSubmit(username, secret);
} catch (err) {
setError('Login failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<FlexForm onSubmit={handleFormSubmit}>
<FormTitle>Welcome Back</FormTitle>
<FormSubtitle>Sign in to your account to continue</FormSubtitle>
<InputContainer>
<Input
type="text"
placeholder="Enter your username"
autoComplete="username"
value={username}
onChange={({ target }) => setUsername(target.value)}
disabled={isLoading}
required
/>
</InputContainer>
<InputContainer>
<Input
type="password"
placeholder="Enter your password"
autoComplete="current-password"
value={secret}
onChange={({ target }) => setSecret(target.value)}
disabled={isLoading}
required
/>
</InputContainer>
{error && <ErrorMessage>{error}</ErrorMessage>}
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Signing In...' : 'Sign In'}
</Button>
</FlexForm>
);
}

View File

@@ -0,0 +1,154 @@
import styled, { keyframes } from 'styled-components';
const fadeIn = keyframes`
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const industrialGlow = keyframes`
0% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.1);
}
50% {
box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.05);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.1);
}
`;
export const FlexForm = styled.form`
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 3rem 3rem 3rem 3rem;
background: #2a2a2a;
border: 2px solid #111111;
border-radius: 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
animation: ${fadeIn} 0.6s ease-out;
width: 450px;
box-sizing: border-box;
&:focus {
outline: none;
}
`;
export const Input = styled.input`
width: 100%;
padding: 1rem 1.25rem;
font-size: 0.9rem;
font-weight: 400;
font-family: 'Courier New', monospace;
background: #0a0a0a;
border: 1px solid #404040;
border-radius: 0;
color: #ffffff;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 1px;
box-sizing: border-box;
&::placeholder {
color: #666666;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 1px;
}
&:focus {
outline: none;
border-color: #ffffff;
background: #000000;
}
&:hover {
border-color: #666666;
}
`;
export const Button = styled.button`
width: 100%;
padding: 1.1rem 1.5rem;
font-size: 0.95rem;
font-weight: 600;
font-family: 'Courier New', monospace;
background: #000000;
color: #ffffff;
border: 2px solid #ffffff;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 2px;
margin-top: 0.5rem;
box-sizing: border-box;
&:hover {
background: #ffffff;
color: #000000;
}
&:active {
background: #333333;
color: #ffffff;
border-color: #cccccc;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
border-color: #666666;
color: #666666;
}
`;
export const FormTitle = styled.h1`
font-size: 1.4rem;
font-weight: 700;
font-family: 'Courier New', monospace;
color: #ffffff;
text-align: center;
margin: 0 0 0.5rem 0;
text-transform: uppercase;
letter-spacing: 3px;
`;
export const FormSubtitle = styled.p`
font-size: 0.75rem;
font-family: 'Courier New', monospace;
color: #999999;
text-align: center;
margin: 0 0 1.5rem 0;
font-weight: 400;
line-height: 1.5;
text-transform: uppercase;
letter-spacing: 1px;
`;
export const InputContainer = styled.div`
width: 100%;
`;
export const ErrorMessage = styled.div`
color: #ffffff;
font-size: 0.8rem;
font-weight: 400;
font-family: 'Courier New', monospace;
text-align: center;
padding: 0.75rem 1rem;
background: #000000;
border: 1px solid #333333;
border-radius: 0;
margin: 0.5rem 0;
text-transform: uppercase;
letter-spacing: 1px;
animation: ${fadeIn} 0.3s ease-out;
`;

View File

@@ -0,0 +1,2 @@
export * from './ILoginFormProps';
export * from './LoginForm';

View File

@@ -0,0 +1 @@
export * from './LoginForm';

View File

@@ -0,0 +1,156 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700;800;900&display=swap');
:root {
font-family: 'JetBrains Mono', 'Courier New', monospace;
line-height: 1.4;
font-weight: 400;
color-scheme: dark;
color: #ffffff;
background-color: #000000;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
background-color: #000000;
}
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
background:
radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.01) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.01) 0%, transparent 50%),
linear-gradient(135deg, #000000 0%, #0a0a0a 50%, #000000 100%);
background-attachment: fixed;
}
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
overflow-x: hidden;
}
a {
font-weight: 600;
color: #ffffff;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s ease;
}
a:hover {
color: #cccccc;
text-shadow: 0 0 5px rgba(255, 255, 255, 0.3);
}
h1, h2, h3, h4, h5, h6 {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-weight: 700;
color: #ffffff;
margin: 0;
text-transform: uppercase;
letter-spacing: 1px;
}
h1 {
font-size: 2.5rem;
line-height: 1.1;
font-weight: 900;
letter-spacing: 3px;
}
p {
font-family: 'JetBrains Mono', 'Courier New', monospace;
color: #cccccc;
line-height: 1.5;
}
button {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
input, textarea {
font-family: 'JetBrains Mono', 'Courier New', monospace;
}
/* Scrollbar styling for post-industrial feel */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #000000;
border: 1px solid #333333;
}
::-webkit-scrollbar-thumb {
background: #666666;
border: 1px solid #000000;
}
::-webkit-scrollbar-thumb:hover {
background: #888888;
}
/* Selection styling */
::selection {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
::-moz-selection {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
/* Focus styling */
:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
/* Utility classes */
.text-center {
text-align: center;
}
.text-uppercase {
text-transform: uppercase;
}
.font-mono {
font-family: 'JetBrains Mono', 'Courier New', monospace;
}
.font-weight-bold {
font-weight: 700;
}
.letter-spacing {
letter-spacing: 1px;
}

View File

@@ -0,0 +1,13 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {Provider} from 'react-redux';
import store from './store';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<Provider store={store}>
<StrictMode>
<App />
</StrictMode>
</Provider>
);

View File

@@ -0,0 +1,18 @@
import {configureStore} from '@reduxjs/toolkit';
import {userSlice} from './slices';
// Configure the main store
const store = configureStore({
reducer: {
// Add the reducer from your slice to the store
user: userSlice.reducer
}
});
export default store;
// Type definition for the root state, can be used with useSelector hook
export type RootState = ReturnType<typeof store.getState>;
// Type definition for dispatch, can be used with useDispatch hook
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1,40 @@
import {createSlice, configureStore, PayloadAction} from '@reduxjs/toolkit';
interface IUserState {
id: string | null;
name: string | null;
isLoading: boolean;
error: string | null;
}
const initialState: IUserState = {
id: null,
name: null,
isLoading: false,
error: null
};
export const userSlice = createSlice({
name: 'user', // A name for this slice, used in action types
initialState,
reducers: {
// The key here becomes part of the action type: 'user/setUserLoading'
setUserLoading(state, action: PayloadAction<boolean>) {
// Redux Toolkit uses Immer, so you can "mutate" the state directly.
// Immer handles the immutable update behind the scenes.
state.isLoading = action.payload;
},
userFetchSuccess(state, action: PayloadAction<{id: string; name: string}>) {
state.isLoading = false;
state.id = action.payload.id;
state.name = action.payload.name;
state.error = null;
},
userFetchError(state, action: PayloadAction<string>) {
state.isLoading = false;
state.error = action.payload;
}
}
});
export const {setUserLoading, userFetchSuccess, userFetchError} = userSlice.actions;

View File

@@ -0,0 +1 @@
export * from './User.slice';

View File

@@ -0,0 +1,39 @@
import styled from 'styled-components';
import {LoginForm} from '../../components';
const FlexContainer = styled.div`
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
background-image:
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 50px 50px;
overflow: hidden;
`;
const LoginFormContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
`;
export function LoginView() {
const handleLoginSubmit = (username: string, secret: string) => {
console.log('handling login submit username [%o] secret [%o]', username, secret)
};
return <FlexContainer>
<LoginFormContainer>
<LoginForm onSubmit={handleLoginSubmit} />
</LoginFormContainer>
</FlexContainer>
}

View File

@@ -0,0 +1 @@
export * from './LoginView';

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{"path": "./tsconfig.app.json"}, {"path": "./tsconfig.node.json"}]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": [],
"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"]
}

View File

@@ -0,0 +1,95 @@
import type {UserConfig} from 'vite';
import path from 'node:path';
import {defineConfig} from 'vite';
import VitePluginReact from '@vitejs/plugin-react-swc';
import ViteBabelPlugin from 'vite-plugin-babel';
// https://vite.dev/config/
export default defineConfig(({mode}) => {
const isProductionMode = mode === 'production';
// optional: const modeDependentEnvironmentVariables = loadEnv(mode, process.cwd()); // https://vite.dev/config/#using-environment-variables-in-config
return {
define: {},
root: path.resolve(process.cwd()),
publicDir: 'public',
cacheDir: 'node_modules/.vite', // default is `node_modules/.vite` <-- https://vite.dev/config/shared-options.html#cachedir
base: '/',
mode,
resolve: {
alias: {
assets: path.resolve(process.cwd(), 'src/assets'),
components: path.resolve(process.cwd(), 'src/components'),
services: path.resolve(process.cwd(), 'src/services'),
store: path.resolve(process.cwd(), 'src/store'),
styles: path.resolve(process.cwd(), 'src/styles'),
themes: path.resolve(process.cwd(), 'src/themes'),
utils: path.resolve(process.cwd(), 'src/utils'),
views: path.resolve(process.cwd(), 'src/views')
},
dedupe: [],
conditions: ['module', 'browser', 'development|production'], // default is `['module', 'browser', 'development|production']`
mainFields: ['browser', 'module'], // default is `['browser', 'module', 'jsnext:main', 'jsnext']`
extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'],
preserveSymlinks: false // default is false
},
css: {
modules: undefined, // default is `undefined`
// postcss: {},
devSourcemap: true, // default is `false` **experimental Vite feature**
transformer: 'postcss' // default is `postcss` **experimental Vite feature**
},
json: {
stringify: true // default is 'auto' // https://vite.dev/config/shared-options.html#json-stringify
},
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
jsxInject: `import React from 'react'`
},
logLevel: 'debug', // default is `info`
clearScreen: false, // default is `true`
envDir: '.',
appType: 'spa',
server: {
host: 'localhost',
allowedHosts: ['localhost', '.phenixrts.com'],
port: 5173,
open: true,
hmr: true,
fs: {
strict: true,
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**'] // default is `['.env', '.env.*', '*.{crt,pem}', '**/.git/**']`
}
},
build: {
target: ['es2020', 'chrome80', 'firefox78', 'safari14'],
outDir: 'dist',
assetsDir: 'assets',
assetsInlineLimit: 4096, // default is `4096` ~ 4KiB
cssCodeSplit: true,
minify: isProductionMode,
cssMinify: isProductionMode,
sourcemap: !isProductionMode,
manifest: false, // default is `false`<-- https://vite.dev/config/build-options.html#build-manifest ,
ssrManifest: false,
ssr: false,
emitAssets: false,
terserOptions: {},
emptyOutDir: true,
copyPublicDir: true,
reportCompressedSize: true,
chunkSizeWarningLimit: 500,
watch: null
},
plugins: [
VitePluginReact(),
ViteBabelPlugin({
babelConfig: {
plugins: ['transform-amd-to-commonjs', 'babel-plugin-styled-components']
}
})
]
} satisfies UserConfig;
});

View File

@@ -0,0 +1,135 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "websocket",
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.1.7",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.3", "", { "os": "android", "cpu": "arm" }, "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.3", "", { "os": "android", "cpu": "arm64" }, "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.3", "", { "os": "linux", "cpu": "arm" }, "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.3", "", { "os": "linux", "cpu": "arm" }, "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.3", "", { "os": "linux", "cpu": "none" }, "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.3", "", { "os": "linux", "cpu": "none" }, "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.3", "", { "os": "linux", "cpu": "none" }, "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.3", "", { "os": "none", "cpu": "arm64" }, "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.3", "", { "os": "win32", "cpu": "x64" }, "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.3", "", { "os": "win32", "cpu": "x64" }, "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"rollup": ["rollup@4.52.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.3", "@rollup/rollup-android-arm64": "4.52.3", "@rollup/rollup-darwin-arm64": "4.52.3", "@rollup/rollup-darwin-x64": "4.52.3", "@rollup/rollup-freebsd-arm64": "4.52.3", "@rollup/rollup-freebsd-x64": "4.52.3", "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", "@rollup/rollup-linux-arm-musleabihf": "4.52.3", "@rollup/rollup-linux-arm64-gnu": "4.52.3", "@rollup/rollup-linux-arm64-musl": "4.52.3", "@rollup/rollup-linux-loong64-gnu": "4.52.3", "@rollup/rollup-linux-ppc64-gnu": "4.52.3", "@rollup/rollup-linux-riscv64-gnu": "4.52.3", "@rollup/rollup-linux-riscv64-musl": "4.52.3", "@rollup/rollup-linux-s390x-gnu": "4.52.3", "@rollup/rollup-linux-x64-gnu": "4.52.3", "@rollup/rollup-linux-x64-musl": "4.52.3", "@rollup/rollup-openharmony-arm64": "4.52.3", "@rollup/rollup-win32-arm64-msvc": "4.52.3", "@rollup/rollup-win32-ia32-msvc": "4.52.3", "@rollup/rollup-win32-x64-gnu": "4.52.3", "@rollup/rollup-win32-x64-msvc": "4.52.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"vite": ["vite@7.1.7", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA=="],
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>websocket</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,324 @@
{
"name": "websocket",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "websocket",
"version": "0.0.0",
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.1.7"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.10",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.3",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@types/estree": {
"version": "1.0.8",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.10",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.10",
"@esbuild/android-arm": "0.25.10",
"@esbuild/android-arm64": "0.25.10",
"@esbuild/android-x64": "0.25.10",
"@esbuild/darwin-arm64": "0.25.10",
"@esbuild/darwin-x64": "0.25.10",
"@esbuild/freebsd-arm64": "0.25.10",
"@esbuild/freebsd-x64": "0.25.10",
"@esbuild/linux-arm": "0.25.10",
"@esbuild/linux-arm64": "0.25.10",
"@esbuild/linux-ia32": "0.25.10",
"@esbuild/linux-loong64": "0.25.10",
"@esbuild/linux-mips64el": "0.25.10",
"@esbuild/linux-ppc64": "0.25.10",
"@esbuild/linux-riscv64": "0.25.10",
"@esbuild/linux-s390x": "0.25.10",
"@esbuild/linux-x64": "0.25.10",
"@esbuild/netbsd-arm64": "0.25.10",
"@esbuild/netbsd-x64": "0.25.10",
"@esbuild/openbsd-arm64": "0.25.10",
"@esbuild/openbsd-x64": "0.25.10",
"@esbuild/openharmony-arm64": "0.25.10",
"@esbuild/sunos-x64": "0.25.10",
"@esbuild/win32-arm64": "0.25.10",
"@esbuild/win32-ia32": "0.25.10",
"@esbuild/win32-x64": "0.25.10"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.52.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.52.3",
"@rollup/rollup-android-arm64": "4.52.3",
"@rollup/rollup-darwin-arm64": "4.52.3",
"@rollup/rollup-darwin-x64": "4.52.3",
"@rollup/rollup-freebsd-arm64": "4.52.3",
"@rollup/rollup-freebsd-x64": "4.52.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.52.3",
"@rollup/rollup-linux-arm-musleabihf": "4.52.3",
"@rollup/rollup-linux-arm64-gnu": "4.52.3",
"@rollup/rollup-linux-arm64-musl": "4.52.3",
"@rollup/rollup-linux-loong64-gnu": "4.52.3",
"@rollup/rollup-linux-ppc64-gnu": "4.52.3",
"@rollup/rollup-linux-riscv64-gnu": "4.52.3",
"@rollup/rollup-linux-riscv64-musl": "4.52.3",
"@rollup/rollup-linux-s390x-gnu": "4.52.3",
"@rollup/rollup-linux-x64-gnu": "4.52.3",
"@rollup/rollup-linux-x64-musl": "4.52.3",
"@rollup/rollup-openharmony-arm64": "4.52.3",
"@rollup/rollup-win32-arm64-msvc": "4.52.3",
"@rollup/rollup-win32-ia32-msvc": "4.52.3",
"@rollup/rollup-win32-x64-gnu": "4.52.3",
"@rollup/rollup-win32-x64-msvc": "4.52.3",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "7.1.7",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "@techniker-me/websocket-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc",
"clean": "rm -rf dist",
"lint": "echo 'No linting configured for frontend'"
},
"devDependencies": {
"typescript": "5.9.2",
"vite": "7.1.7"
},
"dependencies": {
"@techniker-me/logger": "0.0.15",
"@techniker-me/tools": "2025.0.16"
}
}

View File

@@ -0,0 +1,96 @@
import WebSocketConnection from './websockets/WebSocketConnection';
import {WebSocketConnectionStatus} from './websockets/WebSocketConnectionStatus';
import './styles.css';
import {DisposableList} from '@techniker-me/tools';
const disposables = new DisposableList();
let websocket = new WebSocketConnection('ws://' + window.location.hostname + ':3000/ws');
let username = '';
const appContainer = document.getElementById('app') as HTMLDivElement;
const chatContainer = document.createElement('div');
chatContainer.id = 'chat-container';
const websocketStatusContainer = document.createElement('div');
websocketStatusContainer.id = 'websocket-status-container';
const rttContainer = document.createElement('div');
rttContainer.id = 'rtt-container';
rttContainer.innerHTML = `RTT: [<span id="rtt-value">0</span>] milliseconds`;
const messagesElement = document.createElement('div');
messagesElement.id = 'messages-container';
const messageInput = document.createElement('input');
messageInput.type = 'text';
messageInput.disabled = true;
messageInput.onkeydown = ({target}) => {
if (target.value === 'enter') {
sendMessage()
}
};
const actionButton = document.createElement('button');
actionButton.innerText = 'Set your username!';
disposables.add(websocket.on('message', (message: any) => {
if (message.pong) {
console.log('[WebSocket] Received message', message);
const rtt = message.pong.rtt || 0;
rttContainer.innerHTML = `RTT: [<span id="rtt-value">${rtt.toFixed(2)}</span>] ms`;
} else {
messagesElement.innerHTML += `<div class="message"> [${new Date(message.sentAt).toLocaleString("en-US", {timeStyle: 'short'})}]${message.message.author}: ${message.message.payload}</div>`;
}
}));
disposables.add(websocket);
chatContainer.append(websocketStatusContainer);
chatContainer.append(rttContainer);
chatContainer.append(messagesElement);
chatContainer.append(messageInput);
chatContainer.append(actionButton);
appContainer.append(chatContainer);
actionButton.onclick = () => {
const usernamePrompt = prompt('Enter your name:');
if (!usernamePrompt) {
return;
}
username = usernamePrompt;
messageInput.disabled = false;
messageInput.placeholder = 'Enter a message...';
actionButton.innerText = 'Send message!';
actionButton.onclick = sendMessage;
}
function sendMessage() {
console.log('[actionButton] Set username');
if (!messageInput.value) {
return;
}
websocket.sendRequest('message', {
author: username,
recipient: 'chat',
payload: messageInput.value
}
);
messageInput.value = ''
}
window.addEventListener('load', () => {
console.log('window load');
const statusSubscription = websocket.status.subscribe(status => {
websocketStatusContainer.innerHTML = `<h4 id="websocket-status">WebSocket Status [${WebSocketConnectionStatus[status]}]</h4>`;
});
disposables.add(statusSubscription);
window.addEventListener('beforeunload', disposables.dispose.bind(disposables));
});

View File

@@ -0,0 +1,44 @@
export class Message<T> {
private readonly _author: string;
private readonly _recipient: string;
private readonly _payload: T
constructor(author, recipient, payload) {
this._author = author;
this._recipient = recipient;
this._payload = payload;
}
get author(): string {
return this._author;
}
get recipient(): string {
return this._recipient;
}
get payload(): T {
return this._payload;
}
}
export type CreateMessageParameters<T> = {
author: string;
recipient: string;
payload: T
}
export default class MessageFactory {
public static createMessage<T>({
author,
recipient,
payload
}: CreateMessageParameters<T>): Message<T> {
return new Message(author, recipient, payload);
}
private constructor() {
throw new Error('MessageFactory is a static class that may not be instantiated');
}
}

View File

@@ -0,0 +1,44 @@
body {
margin: 0;
padding: 0;
}
#app {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
}
#websocket-status-container {
text-align: center;
margin: auto;
}
#rtt-container {
text-align: center;
margin: auto;
}
#chat-container {
width: 500px;
height: 300px;
margin: auto;
display: flex;
flex-direction: column;
}
#messages-container {
width: 80%;
height: 600px;
margin: auto;
border: 2px solid black;
overflow-y: auto;
}
#chat-container input, button {
width: 79%;
margin: auto;
margin-top: 0;
padding: 0;
}

View File

@@ -0,0 +1,101 @@
import {Subject, ReadOnlySubject, Disposable, DisposableList, EventPublisher} from '@techniker-me/tools';
import type {Milliseconds} from '../../types/Units';
import {WebSocketConnectionStatus} from './WebSocketConnectionStatus';
const pingIntervalDuration: Milliseconds = 2000;
export default class WebSocketConnection extends EventPublisher {
private readonly _connectionDisposables = new DisposableList();
private readonly _status: Subject<WebSocketConnectionStatus> = new Subject(WebSocketConnectionStatus.Closed);
private readonly _readOnlyStatus: ReadOnlySubject<WebSocketConnectionStatus> = new ReadOnlySubject(this._status);
private readonly _socket: WebSocket;
constructor(url: string) {
super();
this._socket = new WebSocket(url);
this.initialize(this._socket);
}
get status(): ReadOnlySubject<WebSocketConnectionStatus> {
return this._readOnlyStatus;
}
public on(event: string, handler: (...args: unknown[]) => void): Disposable {
return super.subscribe(event, handler);
}
public sendRequest<T>(name: string, payload?: T) {
try {
const payloadStringified = JSON.stringify({
sentAt: Date.now(),
[name]: payload
});
this._socket.send(payloadStringified);
} catch (error){
console.error('[WebSocket] Error sending request [%o]', error);
}
}
public dispose(): void {
this._connectionDisposables.dispose();
super.dispose();
}
private initialize(socket: WebSocket) {
const websocketStart = performance.now();
let pingIntervalId;
pingIntervalId = window.setInterval(() => {
this.sendRequest('ping', {sentAt: Date.now()});
}, pingIntervalDuration);
this.setStatus(WebSocketConnectionStatus.Connecting);
socket.onerror = error => {
this.setStatus(WebSocketConnectionStatus.Error);
window.clearInterval(pingIntervalId);
console.error('[WebSocket] Error [%o]', error);
};
socket.onopen = () => {
this.setStatus(WebSocketConnectionStatus.Open);
const websocketEnd = performance.now();
console.log(`[WebSocket] Connection time [${(websocketEnd - websocketStart)}] milliseconds`);
};
socket.onclose = () => {
this.setStatus(WebSocketConnectionStatus.Closed);
};
socket.onmessage = (messageEvent) => {
try {
const messageData = JSON.parse(messageEvent.data);
super.publish('message', messageData);
} catch (error) {
console.log('[WebSocket] Received non-JSON message [%s]', messageEvent.data);
console.error('[WebSocket] Error parsing message [%o]', error);
}
};
this._connectionDisposables.add(new Disposable(() => {
window.clearInterval(pingIntervalId);
}));
this._connectionDisposables.add(new Disposable(() => {
socket.onerror = null;
socket.onopen = null;
socket.onclose = null;
socket.onmessage = null;
}));
this._connectionDisposables.add(new Disposable(() => {
// @ts-expect-error Disposing the subject
this._status.value = null;
}));
this._connectionDisposables.add(new Disposable(socket.close.bind(socket)));
}
private setStatus(status: WebSocketConnectionStatus) {
this._status.value = status;
}
}

View File

@@ -0,0 +1,8 @@
export enum WebSocketConnectionStatus {
Connecting = 0,
Open = 1,
Closing = 2,
Closed = 3,
Error = 4
}

View File

@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"references": [
{ "path": "../../packages/shared-types" }
]
}

View File

@@ -0,0 +1,2 @@
export type Milliseconds = number;
export type Seconds = number;

View File

@@ -0,0 +1,6 @@
This favicon was generated using the following graphics from Twitter Twemoji:
- Graphics Title: 1f916.svg
- Graphics Author: Copyright 2020 Twitter, Inc and other contributors (https://github.com/twitter/twemoji)
- Graphics Source: https://github.com/twitter/twemoji/blob/master/assets/svg/1f916.svg
- Graphics License: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,11 @@
{
"name": "",
"short_name": "",
"icons": [
{"src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -0,0 +1,15 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import json from '@eslint/json';
import markdown from '@eslint/markdown';
import css from '@eslint/css';
import {defineConfig} from 'eslint/config';
export default defineConfig([
{files: ['**/*.{js,mjs,cjs,ts,mts,cts}'], plugins: {js}, extends: ['js/recommended'], languageOptions: {globals: globals.node}},
tseslint.configs.recommended,
{files: ['**/*.json'], plugins: {json}, language: 'json/json', extends: ['json/recommended']},
{files: ['**/*.md'], plugins: {markdown}, language: 'markdown/commonmark', extends: ['markdown/recommended']},
{files: ['**/*.css'], plugins: {css}, language: 'css/css', extends: ['css/recommended']}
]);

View File

@@ -0,0 +1,61 @@
{
"name": "@techniker-me/websocket-server",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"format": "prettier --write .",
"prelint": "npm install",
"lint": "eslint --max-warnings 0 .",
"prelint:fix": "npm run format",
"lint:fix": "eslint --fix .",
"dev": "tsx watch --clear-screen=false src/index.ts",
"build": "tsc",
"typecheck": "tsc",
"clean": "rm -rf dist"
},
"author": "Alexander Zinn",
"license": "ISC",
"description": "",
"devDependencies": {
"@eslint/css": "0.11.1",
"@eslint/js": "9.36.0",
"@eslint/json": "0.13.2",
"@eslint/markdown": "7.3.0",
"@types/body-parser": "1.19.6",
"@types/cors": "2.8.19",
"@types/lru-cache": "7.10.9",
"@types/morgan": "1.9.10",
"@types/multer": "2.0.0",
"@types/node": "24.5.2",
"@types/on-headers": "1.0.4",
"@types/response-time": "2.3.9",
"@types/serve-favicon": "2.5.7",
"@types/ws": "8.18.1",
"eslint": "9.36.0",
"globals": "16.4.0",
"jiti": "2.6.0",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"tsx": "4.20.6",
"typescript": "5.9.2",
"typescript-eslint": "8.44.1"
},
"dependencies": {
"@techniker-me/logger": "0.0.15",
"@techniker-me/tools": "2025.0.16",
"body-parser": "2.2.0",
"cors": "2.8.5",
"express": "5.1.0",
"lru-cache": "11.2.2",
"moment": "2.30.1",
"morgan": "1.10.1",
"multer": "2.0.2",
"on-headers": "1.1.0",
"permessage-deflate": "0.1.7",
"response-time": "2.3.4",
"serve-favicon": "2.5.1",
"websocket-extensions": "0.1.4",
"ws": "8.18.3"
}
}

View File

@@ -0,0 +1,13 @@
import type {HealthStatus} from './IHealthCheck';
export default class HealthCheck {
public checkHealth(): HealthStatus {
return {
status: 'ok',
environment: 'development',
app: 'websocket-server',
version: '0.0.1',
zone: 'us-central1'
};
}
}

View File

@@ -0,0 +1,72 @@
import type {Request, Response} from 'express';
import type IRoute from '../net/http/IRoutes';
import IHealthCheck from './IHealthCheck';
export default class HealthCheckRoute implements IRoute {
private readonly _healthCheck: IHealthCheck;
constructor(healthCheck: IHealthCheck) {
this._healthCheck = healthCheck;
}
public getGETRoutes() {
console.log('[HealthCheckRoute] getGETRoutes called');
const routes = {
'/ok.html': (req, res) => this.externalReadiness.call(this, req, res),
'/ping': (req, res) => this.ping.call(this, req, res)
};
console.log('[HealthCheckRoute] returning routes:', Object.keys(routes));
return routes;
}
public getPOSTRoutes() {
return {};
}
public getPUTRoutes() {
return {};
}
public getPATCHRoutes() {
return {};
}
public getDELETERoutes() {
return {};
}
private externalReadiness(_req: Request, res: Response) {
console.log('[HealthCheckRoute] External readiness');
res.setHeader('Cache-Control', 'public, max-age=0, no-cache, no-store');
try {
const result = this._healthCheck.checkHealth();
if (!result || !result.status) {
return res.status(500).end();
}
switch (result.status) {
case 'ok':
case 'draining':
case 'draining2':
case 'disabled':
return res.status(200).json(result);
case 'starting':
case 'drained':
case 'stopped':
return res.status(503).json(result);
default:
return res.status(500).json(result);
}
} catch {
return res.status(500).end();
}
}
private ping(_req: Request, res: Response) {
console.log('[HealthCheckRoute] Ping handler called from [%s]', _req.ip);
res.status(200).send({status: 'pong'}).end();
}
}

View File

@@ -0,0 +1,11 @@
export type HealthStatus = {
status: 'ok' | 'draining' | 'draining2' | 'disabled' | 'starting' | 'drained' | 'stopped';
environment: string;
app: string;
version: string;
zone: string;
};
export default interface IHealthCheck {
checkHealth(): HealthStatus;
}

View File

@@ -0,0 +1,78 @@
import path from 'node:path';
import {IncomingMessage} from 'node:http';
import {LoggerFactory} from '@techniker-me/logger';
import HttpServer from './net/http/HttpServer';
import HealthCheckRoute from './health/HealthCheckRoute';
import HealthCheck from './health/HealthCheck';
import WebSocketServer, {ExtendedWebSocket} from './net/websocket/WebSocketServer';
const logger = LoggerFactory.getLogger('Server');
const healthCheck = new HealthCheck();
const healthCheckRoute = new HealthCheckRoute(healthCheck);
const httpServer = new HttpServer('http', 3000, healthCheckRoute, {}, '', [], path.resolve(process.cwd(), 'assets', 'favicon', 'favicon.ico'), {});
httpServer.on('error', () => logger.error('[HttpServer] Error'));
const sockets = new Map<string, ExtendedWebSocket>();
function connectDelegate(connection: ExtendedWebSocket, req: IncomingMessage) {
console.log('[Server] Connect delegate');
sockets.set(connection.id, connection);
}
function requestDelegate(connection: ExtendedWebSocket, message: Buffer) {
console.log('[Server] Request delegate');
try {
const messageJson = JSON.parse(message.toString());
console.log('messageJson', messageJson);
if (messageJson.ping) {
const serverTime = Date.now();
const rtt = serverTime - messageJson.ping.sentAt;
connection.send(
JSON.stringify({
pong: {
sentAt: serverTime,
rtt: rtt
},
...messageJson
})
);
}
else if (messageJson.message) {
sockets.forEach(socket => {
socket.send(JSON.stringify({...messageJson}));
});
}
} catch (error) {
console.log('error requestingDelegate to handle websocket message', error);
}
}
function disconnectDelegate(connection: ExtendedWebSocket, reasonCode: number, description: string) {
console.log('[Server] Disconnect delegate');
}
function pongDelegate(connection: ExtendedWebSocket, message: Buffer) {
console.log('[Server] Pong delegate');
}
httpServer
.start()
.then(() => {
const server = httpServer.getServer();
if (server) {
const websocketServer = new WebSocketServer(server, {path: '/ws'});
websocketServer.start(connectDelegate, requestDelegate, disconnectDelegate, pongDelegate);
} else {
console.error('[Server] Failed to get HTTP server instance');
}
})
.catch(err => console.log('[Server] Server failed to start:', err));

View File

@@ -0,0 +1,170 @@
export default class Assert {
public static isUndefined(name: string, value: unknown): asserts value is undefined {
if (value !== undefined) {
throw new Error(`[${name}] must be undefined instead received [${typeof value}]`);
}
}
public static isNull(name: string, value: unknown): asserts value is null {
if (value !== null) {
throw new Error(`[${name}] must be null instead received [${typeof value}]`);
}
}
public static isDefined(name: string, value: unknown): asserts value is NonNullable<typeof value> {
if (value === undefined || value === null) {
throw new Error(`[${name}] must be defined instead received [${typeof value}]`);
}
}
public static isBoolean(name: string, value: unknown): asserts value is boolean {
if (!Assert._isBoolean(value)) {
throw new Error(`[${name}] must be a boolean instead received [${typeof value}]`);
}
}
public static isTrue(name: string, value: unknown): asserts value is true {
Assert.isBoolean(name, value);
if (!value) {
throw new Error(`[${name}] must be true`);
}
}
public static isString(name: string, value: unknown): asserts value is string {
if (!Assert._isString(value)) {
throw new Error(`[${name}] must be a string instead received [${typeof value}]`);
}
}
public static isNonEmptyString(name: string, value: unknown): asserts value is string {
if (!Assert._isNonEmptyString(value)) {
throw new Error(`[${name}] must be a non-empty string instead received [${typeof value}]`);
}
}
public static isNumber(name: string, value: unknown): asserts value is number {
if (!Assert._isNumber(value)) {
throw new Error(`[${name}] must be a number instead received [${typeof value}]`);
}
}
public static isInteger(name: string, value: unknown): asserts value is number {
Assert.isNumber(name, value);
if (!Number.isInteger(value)) {
throw new Error(`[${name}] must be an integer, received [${value}]`);
}
}
public static isPositiveNumber(name: string, value: unknown): asserts value is number {
if (!Assert._isNumberPositive(value)) {
throw new Error(`[${name}] must be a positive number instead received [${typeof value}]`);
}
}
public static isNumberInRange(name: string, lowerBound: number, upperBound: number, value: unknown): asserts value is number {
if (upperBound < lowerBound) {
throw new Error(`Invalid Range: [${name}] bounds are invalid, lower bound [${lowerBound}] must be less than upper bound [${upperBound}]`);
}
if (!(Assert._isNumber(value) && Assert._isNumberInRange(value, lowerBound, upperBound))) {
throw new Error(`[${name}] must have a value between [${lowerBound}] and [${upperBound}] instead received [${value}]`);
}
}
public static isFunction(name: string, value: unknown): asserts value is (...args: unknown[]) => unknown {
if (typeof value !== 'function') {
throw new Error(`[${name}] must be a function, instead received [${typeof value}]`);
}
}
public static satisfiesInterface<T>(name: string, obj: unknown, requiredProps: (keyof T)[]): asserts obj is T {
Assert.isObject(name, obj);
for (const prop of requiredProps) {
if (!(prop in (obj as Record<string, unknown>))) {
throw new Error(`[${name}] missing required property: ${String(prop)}`);
}
}
}
public static isArray(name: string, value: unknown): asserts value is unknown[] {
if (!Array.isArray(value)) {
throw new Error(`[${name}] must be an array, instead received [${typeof value}]`);
}
}
public static isArrayOf<T>(name: string, arrayValueType: T, value: unknown): asserts value is T[] {
Assert.isArray(name, value);
for (const item of value) {
const itemTypeof = typeof item;
if (itemTypeof !== arrayValueType) {
throw new Error(`[${name}] must be an array of [${arrayValueType}] received [${itemTypeof}]`);
}
}
}
public static isStringArray(name: string, value: unknown): asserts value is string[] {
if (!Array.isArray(value)) {
throw new Error(`[${name}] must be an array, instead received [${typeof value}]`);
}
for (const item of value) {
Assert.isString(name, item);
}
}
public static isObject<T>(name: string, value: unknown): asserts value is T {
if (value === null || typeof value !== 'object') {
throw new Error(`[${name}] must be an object, instead received [${typeof value}]`);
}
}
public static isEnumMember<T extends object>(name: string, enumObj: T, value: unknown): asserts value is T[keyof T] {
if (!Object.values(enumObj).includes(value as T[keyof T])) {
throw new Error(`[${name}] is not a member of the enum`);
}
}
// eslint-disable-next-line
public static isInstance<T>(name: string, parentClass: new (...args: any[]) => T, object: unknown): asserts object is T {
if (object === null || object === undefined || typeof object !== 'object') {
throw new Error(`[${name}] must be an instance of [${parentClass.constructor.name}], instead received [${typeof object}]`);
}
if (!(object instanceof parentClass)) {
throw new Error(`[${name}] must be an instance of [${parentClass.constructor.name}], instead received [${object.constructor.name}]`);
}
}
private static _isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
private static _isString(value: unknown): value is string {
return typeof value === 'string';
}
private static _isNonEmptyString(value: unknown): value is string {
return Assert._isString(value) && value.length > 0;
}
private static _isNumber(value: unknown): value is number {
return typeof value === 'number';
}
private static _isNumberPositive(value: unknown): value is number {
return Assert._isNumber(value) && value > 0;
}
private static _isNumberInRange(value: unknown, lowerBound: number, upperBound: number): value is number {
return Assert._isNumber(value) && value >= lowerBound && value <= upperBound;
}
private constructor() {
throw new Error('Assert is a static class that may not be instantiated');
}
}

View File

@@ -0,0 +1,9 @@
export default class Strings {
public static randomString(length: number): string {
return (Date.now().toString(36) + Math.random().toString(36).substring(2)).substring(0, length);
}
private constructor() {
throw new Error('Strings is a static class that may not be instantiated');
}
}

View File

@@ -0,0 +1,379 @@
import {Nullable} from '../../types/optional';
import Assert from '../../lang/Assert';
import moment from 'moment';
import type {Server} from 'node:http';
import http from 'node:http';
import {EventEmitter} from 'node:events';
import type {ILogger} from '@techniker-me/logger';
import {LoggerFactory} from '@techniker-me/logger';
import IRoutes from './IRoutes';
import {Subject} from '@techniker-me/tools';
import express, {RequestHandler} from 'express';
import morgan from 'morgan';
import favicon from 'serve-favicon';
import bodyParser from 'body-parser';
import multer from 'multer';
import {Kilobytes} from '../../types/Units';
import responseTime from 'response-time';
import onHeaders from 'on-headers';
import {LRUCache} from 'lru-cache';
const requestSizeLimit: Kilobytes = 10240;
const defaultTcpSocketTimeout = moment.duration(720, 'seconds'); // Google HTTPS load balancer expects at least 600 seconds
const defaultKeepAliveTimeout = moment.duration(660, 'seconds'); // Google HTTPS load balancer expects at least 600 seconds
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age#:~:text=The%20Access%2DControl%2DMax%2D,Headers%20headers)%20can%20be%20cached.
const corsAccessControlMaxAge = moment.duration(24, 'hours');
const shortTermCaching = 'public, max-age=20, s-maxage=20';
const tlsSessionTimeout = moment.duration(5, 'minutes');
const maxCachedTlsSessions = 1000;
export default class HttpServer {
private readonly _logger: ILogger = LoggerFactory.getLogger('HttpServer');
private readonly _eventEmitter: EventEmitter;
private readonly _protocol: 'http' | 'https';
private readonly _port: number;
private readonly _routes: IRoutes;
// @ts-expect-error - unused parameter for future functionality
private readonly _viewsPath: object;
// @ts-expect-error - unused parameter for future functionality
private readonly _viewParameters: string;
private readonly _resourcesPaths: string[];
private readonly _favicon: string;
private readonly _cors: object;
private readonly _app: Subject<Nullable<express.Application>>;
private readonly _server: Subject<Nullable<Server>>;
private readonly _tlsSessionCache = new LRUCache({
ttl: tlsSessionTimeout.asMilliseconds(),
max: maxCachedTlsSessions
});
private _jsonHandler: Nullable<express.RequestHandler>;
constructor(
protocol: 'https' | 'http',
port: number,
routes: IRoutes,
viewsPath: object,
viewParameters: string,
resourcesPaths: string[],
favicon: string,
cors: object
) {
Assert.isString('protocol', protocol);
Assert.isNumber('port', port);
Assert.satisfiesInterface('routes', routes, ['getGETRoutes', 'getPOSTRoutes', 'getPUTRoutes', 'getPATCHRoutes', 'getDELETERoutes']);
// Assert.isObjectOf<string>('viewsPath', viewsPath);
Assert.isString('viewParameters', viewParameters);
Assert.isArrayOf<string>('resourcesPaths', 'string', resourcesPaths);
Assert.isString('favicon', favicon);
// Assert.isObjectOf<string>('cors', cors, 'string');
this._protocol = protocol;
this._port = port;
this._routes = routes;
this._viewsPath = viewsPath;
this._viewParameters = viewParameters;
this._resourcesPaths = resourcesPaths;
this._favicon = favicon;
this._cors = cors;
this._app = new Subject<Nullable<express.Application>>(null);
this._eventEmitter = new EventEmitter();
this._server = new Subject<Nullable<Server>>(null);
this._jsonHandler = null;
}
public start() {
return new Promise((resolve, reject) => {
const app = (this._app.value = express());
this._jsonHandler = bodyParser.json({limit: requestSizeLimit});
this.configureListener();
this.configureMiddleware();
this.configureResources();
this.configureRoutes();
app.set('x-powered-by', false);
const server = (this._server.value = http.createServer(app));
const onListen = () => {
this._logger.info('HTTP Server listening on %s://*:%s', this._protocol, this._port);
server.removeListener('error', onError);
resolve(this);
};
const onError = (err: unknown) => {
server.removeListener('listening', onListen);
reject(err);
};
server.keepAliveTimeout = defaultKeepAliveTimeout.milliseconds();
server.timeout = defaultTcpSocketTimeout.asMilliseconds();
server.setTimeout(defaultTcpSocketTimeout.asMilliseconds());
server.once('error', onError);
server.once('listening', onListen);
server.on('request', (req, res) => {
this._eventEmitter.emit('request', req.method, req.url, res.statusCode, req.headers);
});
server.on('newSession', (sessionId, sessionData, callback) => {
const cacheId = sessionId.toString('hex');
this._tlsSessionCache.set(cacheId, sessionData);
this._logger.debug('Created new TLS session [%s]', cacheId);
callback();
});
server.on('resumeSession', (sessionId, callback) => {
const cacheId = sessionId.toString('hex');
const sessionData = this._tlsSessionCache.get(cacheId);
callback(null, sessionData);
if (sessionData) {
this._logger.debug('Resumed TLS session [%s]', cacheId);
} else {
this._logger.debug('TLS session [%s] not found', cacheId);
}
});
server.listen({
port: this._port,
backlog: 16 * 1024
});
});
}
public on(event: string, handler: (...args: unknown[]) => void): void {
Assert.isNonEmptyString('event', event);
Assert.isFunction('handler', handler);
this._eventEmitter.on(event, handler);
}
public getServer(): Nullable<Server> {
return this._server.value;
}
private configureListener() {
if (!this._app.value) {
throw new Error('Unable to configure listener, no app instance found');
}
const app = this._app.value;
app.use((req, res, next) => {
req.on('finish', () => {
this._eventEmitter.emit('request', req.method, req.url, res.statusCode, req.headers);
});
next();
});
}
private configureMiddleware() {
if (!this._app.value) {
throw new Error('Unable to configure middleware, no app instance found');
}
const app = this._app.value;
const logger = this._logger;
app.use(morgan('common'));
app.enable('trust proxy');
app.set('env', 'development'); // setting env to test prevents logging to the console
app.use(favicon(this._favicon));
app.use(
morgan(
':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent" :response-time',
{
stream: {
write(line) {
logger.info(line.trim());
}
}
}
)
);
app.use(
bodyParser.text({
type: 'application/sdp',
limit: requestSizeLimit
})
);
app.use(
bodyParser.text({
type: 'application/trickle-ice-sdpfrag',
limit: requestSizeLimit
})
);
app.use(
bodyParser.urlencoded({
extended: true,
limit: requestSizeLimit
})
);
app.use(
multer({
limits: {
fields: 1,
fieldNameSize: 100,
fieldSize: requestSizeLimit,
files: 0,
parts: 1,
headerPairs: 1
}
}).none()
);
app.use((req, _res, next) => {
const contentType = req?.headers?.['content-type'] || '';
if (contentType.startsWith('multipart/form-data;')) {
if (req?.body?.jsonBody) {
req.body = JSON.parse(req.body.jsonBody);
}
}
next();
});
app.use(responseTime());
app.use((req, res, next) => {
res.set('x-origination', 'Platform');
if (req.secure) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
}
next();
});
if (this._cors) {
this._logger.info('Enable CORS on %s://*:%s', this._protocol, this._port);
const cachedCorsAllowOrigins: Record<string, string> = {};
const getCorsAllowOrigin = (url: string) => {
let corsAllowOrigin = cachedCorsAllowOrigins[url];
if (!Object.hasOwn(cachedCorsAllowOrigins, url)) {
Object.entries(this._cors).forEach(([key, value]) => {
if (url.startsWith(key)) {
corsAllowOrigin = value;
}
});
cachedCorsAllowOrigins[url] = corsAllowOrigin ?? '';
}
return corsAllowOrigin;
};
app.use((req, res, next) => {
const corsAllowOrigin = getCorsAllowOrigin(req.url);
if (corsAllowOrigin) {
res.header('Access-Control-Allow-Origin', corsAllowOrigin);
res.header(
'Access-Control-Allow-Headers',
'Authorization, Origin, Range, X-Requested-With, If-Modified-Since, Accept, Keep-Alive, Cache-Control, Content-Type, DNT'
);
res.header('Access-Control-Allow-Methods', 'POST, GET, HEAD, OPTIONS, PUT, PATCH, DELETE');
res.header('Access-Control-Expose-Headers', 'Server, Range, Date, Content-Disposition, X-Timer, ETag, Link, Location');
res.header('Access-Control-Max-Age', corsAccessControlMaxAge.asSeconds().toString());
if (req.method === 'OPTIONS') {
res.header('Cache-Control', shortTermCaching);
}
}
next();
});
}
app.use((_req, res, next) => {
const startTimeSeconds = Date.now() / 1000;
const startTimeNanoseconds = process.hrtime.bigint();
onHeaders(res, () => {
const durationNanoseconds = process.hrtime.bigint() - startTimeNanoseconds;
const durationMilliseconds = durationNanoseconds / 1000000n;
// https://developer.fastly.com/reference/http/http-headers/X-Timer/
// S{unixStartTimeSeconds},VS0,VE{durationMilliseconds}
res.setHeader('X-Timer', `S${startTimeSeconds},VS0,VE${durationMilliseconds}`);
});
next();
});
}
private configureResources() {
if (!this._app.value) {
throw new Error('Unable to configure resources, no app instance found');
}
const app = this._app.value;
for (const resourcePath of this._resourcesPaths) {
app.use(express.static(resourcePath));
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private errorHandler(err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) {
this._logger.error(err.message);
res.status(500).send({status: 'error'});
return;
}
private genericNotFoundHandler(_req: express.Request, res: express.Response) {
console.log('Generic not found handler');
res.status(404).send({status: 'not-found'});
return;
}
private configureRoutes() {
if (!this._app.value) {
throw new Error('Unable to configure routes, no app instance found');
}
const app = this._app.value;
let catchAllHandler: Nullable<RequestHandler> = null;
const registerRoutes = (method: string, routes: Record<string, RequestHandler>): void => {
for (const route of Object.entries(routes)) {
if (!route) {
continue;
}
const [name, handler] = route;
if (name === '*') {
if (catchAllHandler) {
throw new Error(`Only one catch-all handler can ber registered per server, ignoring conflicting catch-all`);
}
catchAllHandler = handler;
continue;
}
this._logger.debug(`Registering [${method}] route [${name}] handler`);
app[method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'](name, handler);
}
};
registerRoutes('GET', this._routes.getGETRoutes());
registerRoutes('POST', this._routes.getPOSTRoutes());
registerRoutes('PUT', this._routes.getPUTRoutes());
registerRoutes('PATCH', this._routes.getPATCHRoutes());
registerRoutes('DELETE', this._routes.getDELETERoutes());
}
}

View File

@@ -0,0 +1,9 @@
import type {RequestHandler} from 'express';
export default interface IRoutes {
getGETRoutes(): Record<string, RequestHandler>;
getPOSTRoutes(): Record<string, RequestHandler>;
getPUTRoutes(): Record<string, RequestHandler>;
getPATCHRoutes(): Record<string, RequestHandler>;
getDELETERoutes(): Record<string, RequestHandler>;
}

View File

@@ -0,0 +1,177 @@
import {LoggerFactory} from '@techniker-me/logger';
import {Server as HttpServer} from 'node:http';
import {IncomingMessage} from 'node:http';
import ws, {WebSocketServer as WSServer, WebSocket} from 'ws';
import Assert from '../../lang/Assert';
import Strings from '../../lang/Strings';
import WebsocketExtensions from 'websocket-extensions';
export interface ExtendedWebSocket extends WebSocket {
id: string;
remoteAddress: string;
isOpen(): boolean;
isClosed(): boolean;
}
function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T;
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as T;
}
if (obj instanceof Object) {
const copy = {} as T;
Object.keys(obj).forEach(key => {
copy[key as keyof T] = deepClone(obj[key as keyof T]);
});
return copy;
}
return obj;
}
function getRemoteAddress(connection: ExtendedWebSocket, req: IncomingMessage): string {
// WebSocket doesn't expose socket directly, so we need to use the underlying socket
const socket = (connection as WebSocket & {_socket?: {remoteAddress?: string}})._socket;
const remoteAddress = socket?.remoteAddress || '::';
const xForwardedFor = req.headers['x-forwarded-for'];
let forwardingRemoteAddressChain: string[] = [];
if (typeof xForwardedFor === 'string') {
forwardingRemoteAddressChain = xForwardedFor.split(', ').filter(Boolean);
} else if (Array.isArray(xForwardedFor)) {
forwardingRemoteAddressChain = xForwardedFor.flatMap(addr => addr.split(', ')).filter(Boolean);
}
forwardingRemoteAddressChain.push(remoteAddress);
// For now, just return the first address (most direct)
// TODO: Implement proper proxy trust checking
return forwardingRemoteAddressChain[forwardingRemoteAddressChain.length - 1] || '::';
}
export type WebSovketServerOptions = {
path?: string;
};
const connectionIdLength = 32;
export default class WebSocketServer {
private readonly _logger = LoggerFactory.getLogger('WebSocketServer');
private readonly _httpServer: HttpServer;
private readonly _parameters: Record<string, string>;
private _server?: WSServer;
private _extensions?: WebsocketExtensions;
constructor(httpServer: HttpServer, parameters: WebSovketServerOptions) {
this._httpServer = httpServer;
this._parameters = parameters;
}
public start(
connectDelegate: (connection: ExtendedWebSocket, req: IncomingMessage) => void,
requestDelegate: (connection: ExtendedWebSocket, message: Buffer) => void,
disconnectDelegate: (connection: ExtendedWebSocket, reasonCode: number, description: string) => void,
pongDelegate: (connection: ExtendedWebSocket, message: Buffer) => void
): void {
Assert.isFunction('connectDelegate', connectDelegate);
Assert.isFunction('requestDelegate', requestDelegate);
Assert.isFunction('disconnectDelegate', disconnectDelegate);
Assert.isFunction('pongDelegate', pongDelegate);
const serverOptions = deepClone(this._parameters);
const address = this._httpServer.address();
const port = typeof address === 'string' ? address : address?.port?.toString() || 'unknown';
const path = serverOptions['path'] || '/';
this._logger.info(`Listening on port [${port}] and bound to [${path}]`);
(serverOptions as Record<string, unknown>)['noServer'] = true;
this._server = new WSServer(serverOptions);
this._extensions = new WebsocketExtensions();
// this._extensions.add(deflate());
(this._server as WSServer & {_server?: HttpServer})._server = this._httpServer;
this._httpServer.on('error', this._server.emit.bind(this._server, 'error'));
this._httpServer.on('listening', this._server.emit.bind(this._server, 'listening'));
this._httpServer.on('upgrade', (req, socket, head) => {
this._logger.debug(`[HttpServer] Upgrade to WebSocket: ${req.method} ${req.url}`);
if (!req.url?.startsWith(path)) {
this._logger.debug(`Skipping upgrade of http request due to incorrect path [${req.url}]`);
return;
}
this._server!.handleUpgrade(req, socket, head, (ws: WebSocket) => {
this._server!.emit('connection', ws, req as IncomingMessage);
});
});
this._httpServer.on('request', (req, res) => {
this._logger.debug(`[HttpServer] Request: ${req.method} ${req.url} -> ${res.statusCode}`);
});
this._server.on('error', err => this._logger.error('An error occurred with WebSocket', err));
this._server.on('connection', (connection: WebSocket, req: IncomingMessage) => {
let closed = false;
try {
const extendedConnection = connection as ExtendedWebSocket;
extendedConnection.id = Strings.randomString(connectionIdLength);
extendedConnection.remoteAddress = getRemoteAddress(extendedConnection, req);
extendedConnection.isOpen = () => connection.readyState === ws.OPEN;
extendedConnection.isClosed = () => connection.readyState === ws.CLOSED;
connection.on('error', (e: Error) => {
this._logger.error('An error occurred on websocket', e);
});
connection.on('message', (message: Buffer) => {
try {
requestDelegate(extendedConnection, message);
} catch (e) {
this._logger.error('Request handler failed for message [%s]', message, e);
}
});
connection.on('close', (reasonCode: number, description: string) => {
if (closed) {
this._logger.warn('[%s] Multiple close events [%s] [%s] [%s]', extendedConnection.id, extendedConnection.remoteAddress, reasonCode, description);
return;
}
closed = true;
try {
disconnectDelegate(extendedConnection, reasonCode, description);
} catch (e) {
this._logger.error('Disconnect handler failed', e);
}
});
connection.on('pong', (message: Buffer) => {
try {
pongDelegate(extendedConnection, message);
} catch (e) {
this._logger.error('Pong handler failed', e);
}
});
connectDelegate(extendedConnection, req);
} catch (e) {
this._logger.error('Accept/connect handler failed', e);
}
});
}
public stop() {}
}

View File

@@ -0,0 +1,18 @@
export type Bytes = number;
export type Kilobytes = number;
export type Megabytes = number;
export type Gigabytes = number;
export type Terabytes = number;
export type Petabytes = number;
export type Exabytes = number;
export type Zettabytes = number;
export type Yottabytes = number;
export type Milliseconds = number;
export type Seconds = number;
export type Minutes = number;
export type Hours = number;
export type Days = number;
export type Weeks = number;
export type Months = number;
export type Years = number;

View File

@@ -0,0 +1,32 @@
declare module 'websocket-extensions' {
interface Extension {
name: string;
params?: Record<string, string | number | boolean>;
}
class WebSocketExtensions {
constructor();
add(extension: {name: string; [key: string]: unknown}): void;
negotiate(protocol: string, extensions: string[]): string | false;
parse(extensions: string): Extension[];
generate(extensions: Extension[]): string;
}
export = WebSocketExtensions;
}
declare module 'permessage-deflate' {
interface DeflateOptions {
serverNoContextTakeover?: boolean;
clientNoContextTakeover?: boolean;
serverMaxWindowBits?: number;
clientMaxWindowBits?: number;
level?: number;
memLevel?: number;
strategy?: number;
}
function deflate(options?: DeflateOptions): {name: string; [key: string]: unknown};
export = deflate;
}

View File

@@ -0,0 +1,2 @@
export type Optional<T> = T | undefined | null;
export type Nullable<T> = T | null;

View File

@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
"moduleResolution": "node",
"module": "Preserve",
"resolveJsonModule": true,
"allowJs": false,
"isolatedModules": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"references": [
{ "path": "../../packages/shared-types" }
]
}

View File

@@ -0,0 +1,9 @@
[install]
exact = true
auto = "auto"
[install.scopes]
"@techniker-me"="https://registry-node.techniker.me"
[install.lockfile]
save = false

View File

@@ -0,0 +1,26 @@
{
"name": "@techniker-me/websocket-chat",
"version": "0.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "npm run build --workspaces",
"build:types": "npm run build -w @techniker-me/websocket-shared-types",
"dev": "npm run dev --workspaces --if-present",
"dev:server": "npm run dev -w @techniker-me/websocket-server",
"dev:frontend:vanilla": "npm run dev -w @techniker-me/websocket-frontend",
"clean": "npm run clean --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"start": "npm run build:types && npm run dev:all"
},
"devDependencies": {
"concurrently": "8.2.2",
"prettier": "3.6.2",
"typescript": "5.9.2"
},
"author": "Alexander Zinn"
}

View File

@@ -0,0 +1,17 @@
{
"name": "@techniker-me/websocket-shared-types",
"version": "0.0.0",
"type": "module",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc"
},
"devDependencies": {
"typescript": "5.9.2"
},
"dependencies": {}
}

View File

@@ -0,0 +1,43 @@
// Shared types for WebSocket communication
export type Milliseconds = number;
export type Seconds = number;
export interface PingMessage {
ping: {
sentAt: number;
};
}
export interface PongMessage {
pong: {
sentAt: number;
rtt: number;
};
}
export interface ChatMessage {
message: {
author: string;
recipient: string;
payload: string;
};
}
export type WebSocketMessage = PingMessage | PongMessage | ChatMessage;
export enum WebSocketConnectionStatus {
Connecting = 'Connecting',
Open = 'Open',
Closed = 'Closed',
Error = 'Error'
}
export interface ExtendedWebSocket {
id: string;
remoteAddress: string;
isOpen(): boolean;
isClosed(): boolean;
}
export type Nullable<T> = T | null;

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"composite": true,
"incremental": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
},
"references": [
{ "path": "./packages/shared-types" },
{ "path": "./apps/server" },
{ "path": "./apps/frontend" }
],
"files": []
}

24
Web/bfcache/.gitignore vendored Normal file
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?

View File

@@ -0,0 +1,133 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "bfcache",
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.1.2",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.50.2", "", { "os": "android", "cpu": "arm" }, "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.50.2", "", { "os": "android", "cpu": "arm64" }, "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.50.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.50.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.50.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.50.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.50.2", "", { "os": "linux", "cpu": "arm" }, "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.50.2", "", { "os": "linux", "cpu": "arm" }, "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.50.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.50.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.50.2", "", { "os": "linux", "cpu": "none" }, "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.50.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.50.2", "", { "os": "linux", "cpu": "none" }, "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.50.2", "", { "os": "linux", "cpu": "none" }, "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.50.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.50.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.50.2", "", { "os": "linux", "cpu": "x64" }, "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.50.2", "", { "os": "none", "cpu": "arm64" }, "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.50.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.50.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.50.2", "", { "os": "win32", "cpu": "x64" }, "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"rollup": ["rollup@4.50.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.50.2", "@rollup/rollup-android-arm64": "4.50.2", "@rollup/rollup-darwin-arm64": "4.50.2", "@rollup/rollup-darwin-x64": "4.50.2", "@rollup/rollup-freebsd-arm64": "4.50.2", "@rollup/rollup-freebsd-x64": "4.50.2", "@rollup/rollup-linux-arm-gnueabihf": "4.50.2", "@rollup/rollup-linux-arm-musleabihf": "4.50.2", "@rollup/rollup-linux-arm64-gnu": "4.50.2", "@rollup/rollup-linux-arm64-musl": "4.50.2", "@rollup/rollup-linux-loong64-gnu": "4.50.2", "@rollup/rollup-linux-ppc64-gnu": "4.50.2", "@rollup/rollup-linux-riscv64-gnu": "4.50.2", "@rollup/rollup-linux-riscv64-musl": "4.50.2", "@rollup/rollup-linux-s390x-gnu": "4.50.2", "@rollup/rollup-linux-x64-gnu": "4.50.2", "@rollup/rollup-linux-x64-musl": "4.50.2", "@rollup/rollup-openharmony-arm64": "4.50.2", "@rollup/rollup-win32-arm64-msvc": "4.50.2", "@rollup/rollup-win32-ia32-msvc": "4.50.2", "@rollup/rollup-win32-x64-msvc": "4.50.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"vite": ["vite@7.1.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ=="],
}
}

View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bfcache</title>
</head>
<body>
<div id="app" class="flex-column-container">
<div class="flex-row-container">
<h1>bfcache</h1>
<div>
<br/>
<br/>
<br/>
<br/>
<p>WebSocket status: <span id="websocket-status">Offline</span></p>
</div>
</div>
<div class="flex-row-container">
<div>
<h2>Messages</h2>
<ul id="messages"></ul>
</div>
</div>
<div class="flex-row-container">
<div>
<h2>Send Message</h2>
<input type="text" id="message-input" required/>
<button id="send-button">Send</button>
</div>
</div>
</div>
<dialog id="login-dialog">
<form id="login-form">
<div>
<input type="text" id="username-input" autocomplete="username" placeholder="Enter a username" required/>
</div>
<!-- <input type="password" id="password-input" autocomplete="current-password" placeholder="Password" required/> -->
<div>
<button type="submit" id="login-button">Login</button>
</div>
</form>
</dialog>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
{
"name": "bfcache",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.1.2"
}
}

View File

@@ -0,0 +1,84 @@
const websocketStatus = document.getElementById('websocket-status') as HTMLSpanElement;
const messages = document.getElementById('messages') as HTMLUListElement;
const messageInput = document.getElementById('message-input') as HTMLInputElement;
const sendButton = document.getElementById('send-button') as HTMLButtonElement;
const loginDialog = document.getElementById('login-dialog') as HTMLDialogElement;
const usernameInput = document.getElementById('username-input') as HTMLInputElement;
const loginForm = document.getElementById('login-form') as HTMLFormElement;
let username: string = '';
let websocket: WebSocket;
window.addEventListener('beforeunload', () => {
disconnect(websocket);
});
function connect(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
websocket = new WebSocket(`ws://${window.location.hostname}:4444`)
websocket.onopen = () => {
websocketStatus.textContent = 'Connected';
websocket.send(JSON.stringify({
type: 'login',
username
}));
resolve(websocket);
};
websocket.onerror = (event) => {
reject(event);
};
websocket.onclose = () => {
websocketStatus.textContent = 'Offline';
};
websocket.onclose = () => {
websocketStatus.textContent = 'Offline';
};
websocket.onerror = (event: Event) => {
websocketStatus.textContent = `Error [${event.toString()}]`;
};
websocket.onmessage = (event) => {
console.log('WebSocket message received:', event.data);
const message = document.createElement('li');
message.textContent = event.data;
messages.appendChild(message);
};
sendButton.onclick = () => sendMessage(websocket, messageInput.value);
});
}
function disconnect(websocket: WebSocket) {
websocket.close();
}
function login() {
connect();
loginDialog.close();
}
function sendMessage(websocket: WebSocket, message: string) {
if (!message) {
return;
}
console.log('Sending message:', message);
websocket.send(message);
messageInput.value = '';
}
loginForm.onsubmit = (event) => {
event.preventDefault();
loginDialog.close();
login(usernameInput.value);
};
loginDialog.showModal();

View File

@@ -0,0 +1,49 @@
body {
background-color: #f0f0f0;
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
h3 {
text-align: center;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.flex-column-container {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
}
.flex-row-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.flex-item {
flex: 1;
}
#login-button {
margin: 10px auto;
width: 100%;
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
#login-button:hover {
background-color: #0056b3;
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"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": ["src"]
}

View File

@@ -0,0 +1,3 @@
{
"idf.pythonInstallPath": "/opt/homebrew/bin/python3"
}

View File

@@ -1,93 +0,0 @@
import type {ServerWebSocket, WebSocketCompressor} from 'bun';
import type {Seconds, Bytes} from './types/Units';
export type PerMessageDeflate =
| boolean
| {
compress?: boolean | WebSocketCompressor;
decomporess?: boolean | WebSocketCompressor;
};
export type WebSocketServerOptions = {
maxPayloadLength?: Bytes;
idleTimeout?: Seconds;
backPressureLimit?: Bytes;
closeOnBackPressureLimit?: boolean;
sendPings?: boolean;
publishToSelf?: boolean;
perMessageDeflate?: PerMessageDeflate;
onSocketError?: (client: ServerWebSocket, error: Error) => void;
onSocketOpen?: (client: ServerWebSocket) => void;
onSocketMessage?: (client: ServerWebSocket, message: string | ArrayBuffer | Uint8Array) => void;
onSocketDrain?: (client: ServerWebSocket) => void;
onSocketClose?: (client: ServerWebSocket) => void;
};
const webSocketServerDefaults: WebSocketServerOptions = {
maxPayloadLength: 16777216,
idleTimeout: 120,
backPressureLimit: 1048576,
closeOnBackPressureLimit: false,
sendPings: true,
publishToSelf: true,
perMessageDeflate: true
};
export default class WebSocketServer {
private readonly _maxPayloadLength: Bytes;
private readonly _idleTimeout: Seconds;
private readonly _backPressureLimit: Bytes;
private readonly _closeOnBackPressureLimit: boolean;
private readonly _sendPings: boolean;
private readonly _publishToSelf: boolean;
private readonly _perMessageDeflate: PerMessageDeflate;
public readonly error?: (client: ServerWebSocket, error: Error) => void;
public readonly open?: (client: ServerWebSocket) => void;
public readonly message?: (client: ServerWebSocket, message: string | ArrayBuffer | Uint8Array) => void;
public readonly drain?: (client: ServerWebSocket) => void;
public readonly close?: (client: ServerWebSocket) => void;
constructor(options?: WebSocketServerOptions) {
this._maxPayloadLength = options?.maxPayloadLength ?? webSocketServerDefaults.maxPayloadLength!;
this._idleTimeout = options?.idleTimeout ?? webSocketServerDefaults.idleTimeout!;
this._backPressureLimit = options?.backPressureLimit ?? webSocketServerDefaults.backPressureLimit!;
this._closeOnBackPressureLimit = options?.closeOnBackPressureLimit ?? webSocketServerDefaults.closeOnBackPressureLimit!;
this._sendPings = options?.sendPings ?? webSocketServerDefaults.sendPings!;
this._publishToSelf = options?.publishToSelf ?? webSocketServerDefaults.publishToSelf!;
this._perMessageDeflate = options?.perMessageDeflate ?? webSocketServerDefaults.perMessageDeflate!;
this.error = options?.onSocketError;
this.open = options?.onSocketOpen;
this.message = options?.onSocketMessage;
this.drain = options?.onSocketDrain;
this.close = options?.onSocketClose;
}
get maxPayloadLength(): Bytes {
return this._maxPayloadLength;
}
get idleTimeout(): Seconds {
return this._idleTimeout;
}
get backPressureLimit(): Bytes {
return this._backPressureLimit;
}
get closeOnBackPressureLimit(): boolean {
return this._closeOnBackPressureLimit;
}
get sendPings(): boolean {
return this._sendPings;
}
get publishToSelf(): boolean {
return this._publishToSelf;
}
get perMessageDeflate(): PerMessageDeflate {
return this._perMessageDeflate;
}
}

View File

@@ -1 +1,37 @@
console.log('hello claris'); import {LoggerFactory} from '@techniker-me/logger';
import WebSocketServerFactory from './net/websockets/WebSocketServerFactory';
import type { WebSocketHandler } from 'bun';
LoggerFactory.setLoggingLevel('All');
const webSocketRelayServer = WebSocketServerFactory.createWebSocketRelayServer();
const PORT = parseInt(process.env.PORT || '4444', 10);
const server = Bun.serve({
port: PORT,
fetch: (request, server) => {
if (server.upgrade(request)) {
return;
}
return new Response('hi');
},
websocket: webSocketRelayServer as WebSocketHandler<undefined>
});
process.on('SIGINT', () => {
console.log('Received [SIGINT] stopping server');
server.stop();
});
process.on('SIGKILL', () => {
console.log('Received [SIGKILL] stopping server');
server.stop();
});
process.on('SIGTERM', () => {
console.log('Received [SIGTERM] stopping server');
server.stop();
});
console.log(`Server listening on [:${server.port}]`);

View File

@@ -0,0 +1,196 @@
import type {ServerWebSocket, WebSocketCompressor} from 'bun';
import type {Seconds, Bytes} from '../../types/Units';
export type PerMessageDeflate =
| boolean
| {
compress?: boolean | WebSocketCompressor;
decomporess?: boolean | WebSocketCompressor;
};
export type WebSocketServerOptions = {
maxPayloadLength?: Bytes;
idleTimeout?: Seconds;
backPressureLimit?: Bytes;
closeOnBackPressureLimit?: boolean;
sendPings?: boolean;
publishToSelf?: boolean;
perMessageDeflate?: PerMessageDeflate;
onSocketError?: (client: ServerWebSocket, error: Error) => void;
onSocketOpen?: (client: ServerWebSocket) => void;
onSocketMessage?: (client: ServerWebSocket, message: string | ArrayBuffer | Uint8Array) => void;
onSocketDrain?: (client: ServerWebSocket) => void;
onSocketClose?: (client: ServerWebSocket) => void;
};
const webSocketServerDefaults: WebSocketServerOptions = {
maxPayloadLength: 16 * 1024 * 1024, // 16 MB
idleTimeout: 120, // 2 minutes
backPressureLimit: 1 * 1024 * 1024, // 1 MB
closeOnBackPressureLimit: false,
sendPings: true,
publishToSelf: true,
perMessageDeflate: true,
onSocketError: undefined,
onSocketOpen: undefined,
onSocketMessage: undefined,
onSocketDrain: undefined,
onSocketClose: undefined
};
export default class WebSocketServer {
private readonly _maxPayloadLength: Bytes;
private readonly _idleTimeout: Seconds;
private readonly _backPressureLimit: Bytes;
private readonly _closeOnBackPressureLimit: boolean;
private readonly _sendPings: boolean;
private readonly _publishToSelf: boolean;
private readonly _perMessageDeflate: PerMessageDeflate;
public readonly error?: (client: ServerWebSocket, error: Error) => void;
public readonly open?: (client: ServerWebSocket) => void;
public readonly message?: (client: ServerWebSocket, message: string | ArrayBuffer | Uint8Array) => void;
public readonly drain?: (client: ServerWebSocket) => void;
public readonly close?: (client: ServerWebSocket) => void;
constructor(options?: WebSocketServerOptions) {
this._maxPayloadLength = options?.maxPayloadLength ?? webSocketServerDefaults.maxPayloadLength!;
this._idleTimeout = options?.idleTimeout ?? webSocketServerDefaults.idleTimeout!;
this._backPressureLimit = options?.backPressureLimit ?? webSocketServerDefaults.backPressureLimit!;
this._closeOnBackPressureLimit = options?.closeOnBackPressureLimit ?? webSocketServerDefaults.closeOnBackPressureLimit!;
this._sendPings = options?.sendPings ?? webSocketServerDefaults.sendPings!;
this._publishToSelf = options?.publishToSelf ?? webSocketServerDefaults.publishToSelf!;
this._perMessageDeflate = options?.perMessageDeflate ?? webSocketServerDefaults.perMessageDeflate!;
this.error = options?.onSocketError;
this.open = options?.onSocketOpen;
this.message = options?.onSocketMessage;
this.drain = options?.onSocketDrain;
this.close = options?.onSocketClose;
}
get maxPayloadLength(): Bytes {
return this._maxPayloadLength;
}
get idleTimeout(): Seconds {
return this._idleTimeout;
}
get backPressureLimit(): Bytes {
return this._backPressureLimit;
}
get closeOnBackPressureLimit(): boolean {
return this._closeOnBackPressureLimit;
}
get sendPings(): boolean {
return this._sendPings;
}
get publishToSelf(): boolean {
return this._publishToSelf;
}
get perMessageDeflate(): PerMessageDeflate {
return this._perMessageDeflate;
}
public start(connectDelegate: (req, websocket, headers) => void, requestDelegate: (req, websocket, headers) => void, disconnectDelegate: (req, websocket, headers) => void, pongDelegate: (req, websocket, headers) => void) {
if (this._server) {
throw new Error('Can only start server once');
}
assert.assertFunction('connectDelegate', connectDelegate);
assert.assertFunction('requestDelegate', requestDelegate);
assert.assertFunction('disconnectDelegate', disconnectDelegate);
assert.assertFunction('pongDelegate', pongDelegate);
const serverOptions = _.cloneDeep(this._parameters);
log.info('Websocket server listening on port [%s] bound to [%s]', this._httpServer.address().port, _.get(serverOptions, ['path']));
serverOptions.noServer = true;
this._server = new ws.Server(serverOptions);
this._extensions = new WebsocketExtensions();
this._extensions.add(deflate);
// TODO Prevent upgrade request from being handled by this WS server if it's not within the configured path
this._server._server = this._httpServer;
this._httpServer.on('listening', this._server.emit.bind(this._server, 'listening'));
this._httpServer.on('error', this._server.emit.bind(this._server, 'error'));
this._httpServer.on('upgrade', (req, socket, head) => {
if (!_.startsWith(req.url, serverOptions.path)) {
return;
}
this._server.handleUpgrade(req, socket, head, ws => {
this._server.emit('connection', ws, req);
});
});
this._server.on('error', e => {
log.error('An error occurred on websocket', e);
});
this._server.on('connection', (connection, req) => {
let closed = false;
try {
connection.id = randomstring.generate(connectionIdLength);
connection.remoteAddress = getRemoteAddress.call(this, connection, req);
log.debug('[%s] connected from [%s] with headers [%j]', connection.id, connection.remoteAddress, req.headers);
connection.isOpen = () => connection.readyState === ws.OPEN;
connection.isClosed = () => connection.readyState === ws.CLOSED;
connection.on('error', e => {
log.error('An error occurred on websocket', e);
});
connection.on('message', message => {
try {
requestDelegate(connection, message);
} catch (e) {
log.error('Request handler failed for message [%s]', message, e);
}
});
connection.on('close', (reasonCode, description) => {
if (closed) {
log.warn('[%s] Multiple close events [%s] [%s] [%s]', connection.id, connection.remoteAddress, reasonCode, description);
return;
}
closed = true;
try {
disconnectDelegate(connection, reasonCode, description);
} catch (e) {
log.error('Disconnect handler failed', e);
}
});
connection.on('pong', message => {
try {
pongDelegate(connection, message);
} catch (e) {
log.error('Pong handler failed', e);
}
});
return connectDelegate(connection, _.get(req, ['headers']));
} catch (e) {
log.error('Accept/connect handler failed', e);
}
}
);
return this;
}
}
}

View File

@@ -0,0 +1,42 @@
import {LoggerFactory} from '@techniker-me/logger';
import WebSocketServer, {WebSocketServerOptions} from './WebSocketServer';
import {ServerWebSocket} from 'bun';
export default class WebSocketServerFactory {
public static createWebSocketRelayServer(): WebSocketServer {
const logger = LoggerFactory.getLogger('WebSocketRelayServer');
const clients = new Set<ServerWebSocket>();
const webSocketRelayServerOptions: WebSocketServerOptions = {
onSocketError: (client, error) => logger.error(`Error: [%o] [${error.message}]`, client),
onSocketOpen: client => {
console.log('New WebSocketClient [%o]', client);
logger.debug('New WebSocketClient [%o]', client);
clients.add(client);
},
onSocketMessage: (fromClient, message) => {
console.log('Relaying message [%o]', message);
logger.debug(`Relaying message [%o]`, message);
for (const client of clients) {
if (client === fromClient) {
continue;
}
client.send(message);
}
},
onSocketClose: client => {
console.log('Client closed [%o]', client);
clients.delete(client);
},
onSocketDrain: client => logger.debug('Client drain [%o]', client),
publishToSelf: false
};
return new WebSocketServer(webSocketRelayServerOptions);
}
private constructor() {
throw new Error('WebSocketServerFactory is a static class that may not be instantiated');
}
}