├── Dockerfile
├── README.md
├── api
├── .dockerignore
├── .gitignore
├── Dockerfile.dev
├── requirements.txt
└── src
│ └── main.py
├── client
└── web
│ ├── .dockerignore
│ ├── .gitignore
│ ├── .prettierrc.json
│ ├── Dockerfile.dev
│ ├── babel.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ ├── components
│ │ ├── App.tsx
│ │ ├── Button.tsx
│ │ ├── Chat.tsx
│ │ ├── Header.tsx
│ │ ├── Main.tsx
│ │ ├── Provider.tsx
│ │ └── editor
│ │ │ ├── Base.tsx
│ │ │ ├── CodeEditor.tsx
│ │ │ └── TextEditor.tsx
│ ├── index.tsx
│ ├── pages
│ │ ├── chat.tsx
│ │ └── index.tsx
│ ├── scripts
│ │ ├── base62.ts
│ │ ├── code-run.ts
│ │ ├── promise.ts
│ │ ├── room-api.ts
│ │ ├── room-mates.ts
│ │ ├── utils.ts
│ │ ├── wandbox.ts
│ │ └── websocket
│ │ │ ├── connection.ts
│ │ │ ├── hooks.ts
│ │ │ ├── transformer.ts
│ │ │ ├── types.ts
│ │ │ └── validator.ts
│ ├── styles
│ │ └── global.ts
│ └── template.html
│ ├── tsconfig.json
│ └── webpack.config.js
├── docker-compose.dev.yml
└── docker-compose.prod.yml
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts-bookworm AS builder
2 |
3 | WORKDIR /workspace
4 |
5 | COPY ./client/web/package.json .
6 |
7 | RUN npm i
8 |
9 | COPY ./client/web .
10 |
11 | RUN npm run build
12 |
13 |
14 | FROM node:lts-bookworm AS modules
15 |
16 | WORKDIR /workspace
17 |
18 | COPY ./client/web/package.json ./
19 | COPY ./client/web/package-lock.json ./
20 |
21 | RUN npm i --production
22 |
23 | FROM python:3.12 AS runner
24 |
25 | WORKDIR /app
26 |
27 | COPY --from=builder /workspace/build ./build
28 | COPY --from=modules /workspace/node_modules ./node_modules
29 |
30 | COPY ./api/requirements.txt .
31 | RUN pip3 install -r requirements.txt
32 |
33 | COPY api/src ./src
34 |
35 | ENV ENV=production
36 |
37 | RUN apt update && apt upgrade -y
38 |
39 | CMD uvicorn src.main:app --host 0.0.0.0 --port $PORT
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 起動する
2 | ```
3 | docker-compose -f docker-compose.[dev/prod].yml up
4 | ```
5 |
6 | # デプロイする
7 | ```
8 | heroku container:push web
9 | heroku container:release web
10 | ```
--------------------------------------------------------------------------------
/api/.dockerignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 |
--------------------------------------------------------------------------------
/api/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 |
--------------------------------------------------------------------------------
/api/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | FROM python:3.12
2 |
3 | WORKDIR /workspace
4 | COPY requirements.txt .
5 | RUN pip install -r requirements.txt
6 |
7 | CMD ["uvicorn", "src.main:app", "--reload", "--host", "0.0.0.0", "--port", "3000"]
8 |
--------------------------------------------------------------------------------
/api/requirements.txt:
--------------------------------------------------------------------------------
1 | uvicorn[standard]
2 | fastapi
3 | jinja2
--------------------------------------------------------------------------------
/api/src/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI, Depends, WebSocket, WebSocketDisconnect, HTTPException, Request
2 | from starlette.middleware.cors import CORSMiddleware
3 | from fastapi.staticfiles import StaticFiles
4 | from fastapi.templating import Jinja2Templates
5 | from typing import List
6 | import json
7 | import binascii
8 | import uuid
9 | from pydantic import BaseModel
10 | from pathlib import Path
11 | from typing import Union
12 | import os
13 |
14 | app = FastAPI()
15 |
16 | # CORSを回避するための設定
17 | app.add_middleware(
18 | CORSMiddleware,
19 | allow_origins=["*"],
20 | allow_credentials=True,
21 | allow_methods=["*"],
22 | allow_headers=["*"]
23 | )
24 |
25 | class ConnectionManager:
26 | def __init__(self):
27 | self.active_connections: List[WebSocket] = []
28 | self.full_text = ""
29 |
30 | def __len__(self):
31 | return len(self.active_connections)
32 |
33 | def set_full_text(self, text: str):
34 | self.full_text = text
35 |
36 | async def connect(self, websocket: WebSocket, name):
37 | await websocket.accept()
38 | self.active_connections.append(websocket)
39 | await self.broadcast(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": self.get_key(websocket), "type": "connect", "data": self.full_text}))
40 |
41 | def disconnect(self, websocket: WebSocket):
42 | self.active_connections.remove(websocket)
43 |
44 | async def send_personal_message(self, message: str, websocket: WebSocket):
45 | await websocket.send_text(message)
46 |
47 | async def broadcast(self, message: str):
48 | for connection in self.active_connections:
49 | await connection.send_text(message)
50 |
51 | async def broadcast_except_me(self, message: str, websocket: WebSocket):
52 | for connection in self.active_connections:
53 | if connection != websocket:
54 | await connection.send_text(message)
55 |
56 | def get_key(self, websocket: WebSocket):
57 | return websocket.headers.get('sec-websocket-key')
58 |
59 | managers = {}
60 | is_public = {}
61 | name = {}
62 |
63 | class CreateRoom(BaseModel):
64 | name: str
65 | is_public: bool
66 |
67 | class RoomInfo(BaseModel):
68 | name: str
69 | is_open: bool
70 | connections: int
71 | room_id: str
72 |
73 | @app.post("/api/create_room", response_model=str)
74 | async def create_room(room: CreateRoom):
75 | clean_keys = []
76 | for room_id in managers:
77 | if len(managers[room_id]) == 0:
78 | clean_keys.append(room_id)
79 | for key in clean_keys:
80 | managers.pop(key)
81 | is_public.pop(key)
82 | name.pop(key)
83 | room_id = str(uuid.uuid4())
84 | managers[room_id] = ConnectionManager()
85 | is_public[room_id] = room.is_public
86 | name[room_id] = room.name
87 | return room_id
88 |
89 | @app.get("/api/room_info/{room_id}", response_model=RoomInfo)
90 | async def room_info(room_id: str):
91 | try:
92 | return RoomInfo(name=name[room_id], is_open=is_public[room_id], room_id=room_id, connections=len(managers[room_id]))
93 | except KeyError:
94 | raise HTTPException(404, "Room not found")
95 |
96 | @app.get("/api/room_list", response_model=List[RoomInfo])
97 | async def room_list():
98 | return list(filter(lambda x: x.is_open is True, [RoomInfo(name=name[room_id], is_open=is_public[room_id], room_id=room_id, connections=len(managers[room_id])) for room_id in managers]))
99 |
100 | @app.websocket("/api/ws/{room_id}")
101 | async def websocket_endpoint(websocket: WebSocket, room_id: str, name: str = "Anonymous"):
102 | print(room_id)
103 | manager = None
104 | try:
105 | manager = managers[room_id]
106 | except KeyError:
107 | raise HTTPException(404, "Room not found")
108 |
109 | await manager.connect(websocket, name)
110 | try:
111 | while True:
112 | data = await websocket.receive_text()
113 | data_json = json.loads(data)
114 |
115 | if data_json["type"] == "chat":
116 | await manager.broadcast(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "chat", "data": data_json["data"]}))
117 |
118 | elif data_json["type"] == "cursormove":
119 | await manager.broadcast_except_me(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "cursormove", "data": data_json["data"]}), websocket)
120 |
121 | elif data_json["type"] == "selection":
122 | await manager.broadcast_except_me(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "selection", "data": data_json["data"]}), websocket)
123 |
124 | elif data_json["type"] == "edit":
125 | manager.set_full_text(data_json["data"]["full_text"])
126 | await manager.broadcast_except_me(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "edit", "data": data_json["data"]}), websocket)
127 |
128 | except WebSocketDisconnect:
129 | manager.disconnect(websocket)
130 | await manager.broadcast(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "disconnect", "data": ""}))
131 |
132 | def serve_react_app(app: FastAPI, build_dir: Union[Path, str]) -> FastAPI:
133 |
134 | if isinstance(build_dir, str):
135 | build_dir = Path(build_dir)
136 |
137 | app.mount(
138 | "/static/",
139 | StaticFiles(directory=build_dir / "static/"),
140 | name="React App static files",
141 | )
142 | templates = Jinja2Templates(directory=build_dir.as_posix())
143 |
144 | @app.get("/{full_path:path}")
145 | async def serve_react_app(request: Request, full_path: str):
146 | return templates.TemplateResponse("index.html", {"request": request})
147 |
148 | return app
149 |
150 | if os.environ.get("ENV") == "production":
151 | serve_react_app(app, "./build")
--------------------------------------------------------------------------------
/client/web/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | Dockerfile*
3 |
4 |
--------------------------------------------------------------------------------
/client/web/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /build
3 |
--------------------------------------------------------------------------------
/client/web/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "singleQuote": true,
4 | "jsxSingleQuote": true,
5 | "arrowParens": "avoid"
6 | }
7 |
--------------------------------------------------------------------------------
/client/web/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | FROM node:lts-bookworm AS modules
2 |
3 | WORKDIR /workspace
4 |
5 | COPY ./package.json .
6 |
7 | RUN npm i
8 |
9 | FROM node:lts-bookworm
10 |
11 | WORKDIR /workspace
12 |
13 | COPY "*.config.js" "tsconfig.json" "package.json" ./
14 | COPY --from=modules /workspace/node_modules ./node_modules
15 |
16 | ENTRYPOINT ["npm", "run"]
17 | CMD ["dev"]
18 |
19 |
--------------------------------------------------------------------------------
/client/web/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | '@babel/preset-env',
5 | {
6 | targets: 'last 2 chrome versions and last 2 firefox versions',
7 | modules: false,
8 | useBuiltIns: 'usage',
9 | corejs: 3,
10 | },
11 | ],
12 | '@babel/preset-typescript',
13 | ],
14 | plugins: [['babel-plugin-styled-components', { pure: true }]],
15 | };
16 |
--------------------------------------------------------------------------------
/client/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.1.0",
4 | "scripts": {
5 | "dev": "webpack-dev-server --mode development",
6 | "build": "webpack --mode production",
7 | "lint": "prettier --write ."
8 | },
9 | "devDependencies": {
10 | "@babel/core": "^7.17.5",
11 | "@babel/preset-env": "^7.16.11",
12 | "@babel/preset-typescript": "^7.16.7",
13 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
14 | "@tanstack/react-location": "^3.6.1",
15 | "@types/react": "^17.0.39",
16 | "@types/react-dom": "^17.0.11",
17 | "@types/styled-components": "^5.1.23",
18 | "babel-loader": "^8.2.3",
19 | "babel-plugin-styled-components": "^2.0.6",
20 | "copy-webpack-plugin": "^10.2.4",
21 | "core-js": "^3.21.1",
22 | "css-loader": "^6.6.0",
23 | "flexlayout-react": "^0.6.8",
24 | "html-webpack-plugin": "^5.5.0",
25 | "monaco-editor": "^0.32.1",
26 | "monaco-editor-webpack-plugin": "^7.0.1",
27 | "prettier": "^2.5.1",
28 | "react": "^17.0.2",
29 | "react-dom": "^17.0.2",
30 | "react-refresh-typescript": "2.0.3",
31 | "style-loader": "^3.3.1",
32 | "styled-components": "^5.3.3",
33 | "ts-loader": "^9.2.6",
34 | "typescript": "4.5.5",
35 | "webpack": "^5.69.1",
36 | "webpack-cli": "^4.9.2",
37 | "webpack-dev-server": "^4.7.4"
38 | },
39 | "dependencies": {
40 | "cors": "^2.8.5",
41 | "express": "^4.17.3",
42 | "http-proxy-middleware": "^2.0.3"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/client/web/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import { Route, MakeGenerics, Router, ReactLocation } from '@tanstack/react-location';
2 | import { Welcome } from '~/pages/index';
3 | import { ChatPage } from '~/pages/chat';
4 | import { getRoomInfo, RoomInfo } from '~/scripts/room-api';
5 | import { GlobalStyle } from '~/styles/global';
6 | import { Providers } from './Provider';
7 |
8 | export type LocationGenerics = MakeGenerics<{
9 | LoaderData: {
10 | roomInfo: RoomInfo;
11 | };
12 | }>;
13 |
14 | const location = new ReactLocation();
15 |
16 | const routes: Route[] = [
17 | {
18 | path: '/',
19 | element: ,
20 | },
21 | {
22 | path: 'chat',
23 | children: [
24 | {
25 | path: ':roomId',
26 | element: ,
27 | loader: async ({ params: { roomId } }) => ({ roomInfo: await getRoomInfo(roomId!) }),
28 | },
29 | ],
30 | },
31 | ];
32 |
33 | const App = () => {
34 | return (
35 |
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | export default App;
43 |
--------------------------------------------------------------------------------
/client/web/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components';
2 |
3 | export const blueButtonCss = css`
4 | border: none;
5 | border-radius: 0.25em;
6 | padding-inline: 0.5em;
7 | font-size: 1em;
8 | line-height: 2;
9 | background-color: #0d6efd;
10 | color: white;
11 | `;
12 |
13 | export const BlueButton = styled.button`
14 | ${blueButtonCss}
15 | `;
16 |
17 | export const planeButtonCss = css`
18 | border: none;
19 | padding-inline: 0.5em;
20 | font-size: 1em;
21 | line-height: 2;
22 | color: inherit;
23 | background-color: inherit;
24 | `;
25 |
26 | export const PlaneButton = styled.button`
27 | ${planeButtonCss}
28 | `;
29 |
--------------------------------------------------------------------------------
/client/web/src/components/Chat.tsx:
--------------------------------------------------------------------------------
1 | import { MouseEvent, useCallback, useEffect, useRef, useState, VFC } from 'react';
2 | import styled from 'styled-components';
3 | import { suffix } from '~/scripts/room-mates';
4 | import { WebsocketConnectionManager } from '~/scripts/websocket/connection';
5 | import { useChat } from '~/scripts/websocket/hooks';
6 | import { BlueButton } from './Button';
7 |
8 | const Chat = styled>(({ connection, ...rest }) => {
9 | const ulRef = useRef(null);
10 | const messages = useChat(connection);
11 |
12 | useEffect(() => {
13 | ulRef.current?.scroll({ top: 0, behavior: 'smooth' });
14 | }, [messages]);
15 |
16 | const send = useCallback(
17 | (content: string) => {
18 | connection.sendMessage({ type: 'chat', data: content });
19 | },
20 | [connection]
21 | );
22 |
23 | return (
24 |
25 |
26 | {messages.length === 0 ? (
27 |
no messages
28 | ) : (
29 |
30 | {messages.map(m => (
31 | -
32 |
{m.content}
33 | {m.type !== 'chat' ? info
: {m.name}
}
34 |
35 | ))}
36 |
37 | )}
38 |
39 | );
40 | })`
41 | display: flex;
42 | flex-direction: column;
43 | height: 100%;
44 | > p {
45 | font-size: 0.9em;
46 | text-align: center;
47 | color: #777;
48 | }
49 | > ul {
50 | list-style: none;
51 | padding: 0;
52 | > li {
53 | border-block-start: 1px solid #ccc;
54 | :last-child {
55 | border-block-end: 1px solid #ccc;
56 | }
57 | padding-inline: 0.5em;
58 | > p {
59 | font-size: 1.25em;
60 | }
61 | }
62 | }
63 | > *:last-child {
64 | flex-grow: 1;
65 | overflow-y: auto;
66 | }
67 | `;
68 |
69 | const ChatInput = styled void }>>(({ send, ...rest }) => {
70 | const [message, setMessage] = useState('');
71 | const onclick = (ev: MouseEvent) => {
72 | ev.preventDefault();
73 | send(message);
74 | setMessage('');
75 | };
76 | return (
77 |
84 | );
85 | })`
86 | display: flex;
87 | padding: 0.5em;
88 |
89 | > input {
90 | flex-grow: 1;
91 | font-size: 1em;
92 | border: 1px solid #ccc;
93 | padding-inline: 0.5em;
94 | }
95 | > span {
96 | padding-inline-start: 0.5em;
97 | }
98 | > button {
99 | flex-shrink: 0;
100 | }
101 | `;
102 |
103 | export default Chat;
104 |
--------------------------------------------------------------------------------
/client/web/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import styled from 'styled-components';
3 | import { Link } from '@tanstack/react-location';
4 |
5 | export const Header = styled(({ children, ...rest }) => {
6 | return (
7 |
8 |
9 | CodingChat
10 |
11 | {children}
12 |
13 | );
14 | })`
15 | display: flex;
16 | padding: 0.5em;
17 | color: white;
18 | background-color: #212529;
19 | > h1 {
20 | font-size: 1.25em;
21 | line-height: 2;
22 | flex-grow: 1;
23 | > a {
24 | color: inherit;
25 | text-decoration: none;
26 | }
27 | }
28 | > div {
29 | display: flex;
30 | align-items: center;
31 | flex-shrink: 0;
32 | }
33 | `;
34 |
--------------------------------------------------------------------------------
/client/web/src/components/Main.tsx:
--------------------------------------------------------------------------------
1 | import { Layout, Model, TabNode } from 'flexlayout-react';
2 | import 'flexlayout-react/style/light.css';
3 | import { FC, useCallback, useLayoutEffect, useState, VFC } from 'react';
4 | import { useTextEditor, useReadonlyTextEditor } from '~/components/editor/TextEditor';
5 | import { Loadable } from '~/scripts/promise';
6 | import { useCodeEditor } from '~/components/editor/CodeEditor';
7 | import { Header } from '~/components/Header';
8 | import { run } from '~/scripts/code-run';
9 | import { Link } from '@tanstack/react-location';
10 | import ChatView from './Chat';
11 | import styled from 'styled-components';
12 | import { PlaneButton, planeButtonCss } from './Button';
13 | import { WebsocketConnectionManager } from '~/scripts/websocket/connection';
14 |
15 | const flexLayoutModel = Model.fromJson({
16 | global: {},
17 | borders: [],
18 | layout: {
19 | type: 'row',
20 | children: [
21 | {
22 | type: 'row',
23 | weight: 80,
24 | children: [
25 | {
26 | type: 'row',
27 | weight: 80,
28 | children: [
29 | {
30 | type: 'tabset',
31 | children: [
32 | {
33 | type: 'tab',
34 | enableClose: false,
35 | name: 'CodeEditor',
36 | component: 'code-editor',
37 | },
38 | ],
39 | },
40 | ],
41 | },
42 | {
43 | type: 'row',
44 | weight: 20,
45 | children: [
46 | {
47 | type: 'tabset',
48 | children: [
49 | {
50 | type: 'tab',
51 | name: 'Log',
52 | component: 'log-editor',
53 | enableClose: false,
54 | },
55 | ],
56 | },
57 | {
58 | type: 'tabset',
59 | children: [
60 | {
61 | type: 'tab',
62 | name: 'Input',
63 | component: 'input-editor',
64 | enableClose: false,
65 | },
66 | ],
67 | },
68 | {
69 | type: 'tabset',
70 | children: [
71 | {
72 | type: 'tab',
73 | name: 'Output',
74 | component: 'output-editor',
75 | enableClose: false,
76 | },
77 | ],
78 | },
79 | ],
80 | },
81 | ],
82 | },
83 | {
84 | type: 'row',
85 | weight: 20,
86 | children: [
87 | {
88 | type: 'tabset',
89 | children: [
90 | {
91 | type: 'tab',
92 | name: 'Chat',
93 | component: 'chat',
94 | enableClose: false,
95 | },
96 | ],
97 | },
98 | ],
99 | },
100 | ],
101 | },
102 | });
103 | type FlexLayoutComponentName = 'chat' | 'log-editor' | 'input-editor' | 'output-editor' | 'code-editor';
104 |
105 | export const Main = styled }>>(({ wsConnection, ...rest }) => {
106 | const conn = wsConnection.get();
107 | useLayoutEffect(() => {
108 | return () => conn.close();
109 | }, [])
110 |
111 | const [language, languageSelect] = useLanguageSelect();
112 | const [CodeEditor, getCode] = useCodeEditor(conn, language);
113 | const [LogView, setLog] = useReadonlyTextEditor();
114 | const [StdinEditor, , getStdin] = useTextEditor();
115 | const [StdoutView, setStdout] = useReadonlyTextEditor();
116 |
117 | const execute = useCallback(async () => {
118 | const code = getCode();
119 | const stdin = getStdin();
120 | if (code == null) throw Error('no code editor instance');
121 | if (stdin == null) throw Error('no input editor instance');
122 | const [stdout, log] = await run(code, stdin, language === 'cpp' ? 'C++' : 'Python');
123 | setLog(log);
124 | setStdout(stdout ?? '');
125 | }, [language]);
126 |
127 | const factory = useCallback(
128 | (node: TabNode) => {
129 | const component = node.getComponent() as FlexLayoutComponentName;
130 | switch (component) {
131 | case 'code-editor':
132 | return ;
133 | case 'log-editor':
134 | return ;
135 | case 'input-editor':
136 | return ;
137 | case 'output-editor':
138 | return ;
139 | case 'chat':
140 | return ;
141 | default:
142 | return unresolved component;
143 | }
144 | },
145 | [language]
146 | );
147 |
148 | return (
149 |
150 |
151 | {languageSelect}
152 |
153 |
154 |
155 |
156 |
157 | );
158 | })`
159 | display: flex;
160 | flex-direction: column;
161 | width: 100vw;
162 | height: 100vh;
163 | overflow: hide;
164 |
165 | > *:last-child {
166 | position: relative;
167 | flex-grow: 1;
168 | }
169 | `;
170 |
171 | const useLanguageSelect = () => {
172 | const [language, setLanguage] = useState<'cpp' | 'python'>('cpp');
173 |
174 | const selectElement = (
175 | setLanguage(ev.target.value as any)}>
176 |
177 |
178 |
179 | );
180 | return [language, selectElement] as const;
181 | };
182 |
183 | const LanguageSelect = styled.select`
184 | border-radius: 0.25em;
185 | padding: 0.5em;
186 | margin-inline-end: 0.5em;
187 | font-size: 1em;
188 | `;
189 |
190 | const ExecuteButton: FC<{ execute: () => Promise }> = ({ execute }) => {
191 | const [executing, setExecuting] = useState(false);
192 | const onClick = () => {
193 | setExecuting(true);
194 | execute().finally(() => {
195 | setExecuting(false);
196 | });
197 | };
198 |
199 | return (
200 |
201 | {executing ? '実行中' : '▶実行'}
202 |
203 | );
204 | };
205 |
206 | const ExitButton = styled(props => (
207 |
208 | 退室
209 |
210 | ))`
211 | text-decoration: none;
212 | ${planeButtonCss}
213 | `;
214 |
--------------------------------------------------------------------------------
/client/web/src/components/Provider.tsx:
--------------------------------------------------------------------------------
1 | import { FC, StrictMode } from 'react';
2 |
3 | export const Providers: FC = ({ children }) => {children};
4 |
--------------------------------------------------------------------------------
/client/web/src/components/editor/Base.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from 'react';
2 | import { editor } from 'monaco-editor';
3 | import styled from 'styled-components';
4 |
5 | export const useMonacoEditor = (
6 | options: editor.IStandaloneEditorConstructionOptions,
7 | effects: () => void = () => {}
8 | ) => {
9 | const containerRef = useRef(null);
10 | const instanceRef = useRef();
11 |
12 | const Editor = useCallback(() => {
13 | useEffect(() => {
14 | if (containerRef.current == null) return;
15 |
16 | const container = containerRef.current;
17 | const instance = editor.create(container, options);
18 | instanceRef.current = instance;
19 | // on_mount(editor);
20 |
21 | const observer = new ResizeObserver(entries => {
22 | instance.layout(); // checking if this works
23 | // for (const entry of entries) {
24 | // editor.layout(entry.contentRect);
25 | // }
26 | });
27 |
28 | observer.observe(container);
29 | return () => {
30 | instance.dispose();
31 | observer.unobserve(container);
32 | };
33 | }, []);
34 | effects();
35 | return ;
36 | }, []);
37 |
38 | return [Editor, instanceRef] as const;
39 | };
40 |
41 | const EditorContainer = styled.div`
42 | height: 100%;
43 | `;
44 |
--------------------------------------------------------------------------------
/client/web/src/components/editor/CodeEditor.tsx:
--------------------------------------------------------------------------------
1 | import { useMonacoEditor } from './Base';
2 | import { Range, editor, IPosition, IRange } from 'monaco-editor';
3 | import { useCallback, useEffect, useRef } from 'react';
4 | import { cursorSuffix, selectionSuffix, suffix } from '~/scripts/room-mates';
5 | import { WebsocketConnectionManager } from '~/scripts/websocket/connection';
6 | import { readStream } from '~/scripts/utils';
7 | import { pickAndTransformEditorCommand } from '~/scripts/websocket/transformer';
8 | import { ConnectionKey } from '~/scripts/websocket/types';
9 |
10 | type Cursor = {
11 | uid: ConnectionKey;
12 | name: string;
13 | position: IPosition;
14 | };
15 | type Selection = {
16 | uid: ConnectionKey;
17 | name: string;
18 | range: IRange;
19 | };
20 |
21 | const defaultValue = ['function x() {', '\tconsole.log("Hello world!");', '}'].join('\n');
22 |
23 | export const useCodeEditor = (conn: WebsocketConnectionManager, language: string) => {
24 | const compositioning = useRef(false);
25 |
26 | const effects = useCallback(() => {
27 | useEffect(() => {
28 | const instance = instanceRef.current;
29 | if (instance == null) {
30 | console.warn('the instance of the code editor has not yet been created');
31 | return;
32 | }
33 |
34 | const cleaners = [
35 | instance.onDidChangeModelContent(({ changes }) => {
36 | if (!compositioning.current)
37 | conn.sendMessage({
38 | type: 'edit',
39 | data: {
40 | changes,
41 | timestamp: Date.now(),
42 | full_text: instance.getValue(),
43 | },
44 | });
45 | }).dispose,
46 | instance.onDidChangeCursorPosition(({ position }) => {
47 | conn.sendMessage({ type: 'cursormove', data: position });
48 | }).dispose,
49 | instance.onDidChangeCursorSelection(({ selection }) => {
50 | conn.sendMessage({ type: 'selection', data: selection });
51 | }).dispose,
52 | ];
53 |
54 | return () => cleaners.forEach(f => f());
55 | }, []);
56 |
57 | useEffect(() => {
58 | const instance = instanceRef.current;
59 | if (instance == null) {
60 | console.warn('the instance of the code editor has not yet been created');
61 | return;
62 | }
63 |
64 | let cursors: Cursor[] = [];
65 | let selections: Selection[] = [];
66 | let first = true;
67 |
68 | readStream(conn.getMessageStream().getReader(), message => {
69 | const command = pickAndTransformEditorCommand(message);
70 | if (command == null) return;
71 |
72 | switch (command.type) {
73 | case 'cursormove': {
74 | const { uid, name, position } = command;
75 | cursors = cursors.filter(v => v.uid !== uid).concat({ uid, name, position });
76 | setCursorDecorations(instance, cursors);
77 | break;
78 | }
79 | case 'selection': {
80 | const { uid, name, range } = command;
81 | selections = selections.filter(v => v.uid !== uid).concat({ uid, name, range });
82 | setSelectionDecrations(instance, selections);
83 | break;
84 | }
85 | case 'edit': {
86 | const { changes } = command;
87 | compositioning.current = true;
88 | instance.executeEdits('coding-chat', changes);
89 | compositioning.current = false;
90 | break;
91 | }
92 | case 'onconnect': {
93 | if (first) {
94 | const { fullText } = command;
95 | compositioning.current = true;
96 | instance.setValue(fullText);
97 | compositioning.current = false;
98 | first = false;
99 | } else {
100 | const position = instance.getPosition();
101 | const selection = instance.getSelection();
102 | if (position != null) conn.sendMessage({ type: 'cursormove', data: position });
103 | if (selection != null) conn.sendMessage({ type: 'selection', data: selection });
104 | }
105 | break;
106 | }
107 | case 'clean': {
108 | const { uid } = command;
109 | cursors = cursors.filter(v => v.uid !== uid);
110 | selections = selections.filter(v => v.uid !== uid);
111 | setCursorDecorations(instance, cursors);
112 | setSelectionDecrations(instance, selections);
113 | break;
114 | }
115 | }
116 | });
117 | }, []);
118 | }, []);
119 |
120 | const [element, instanceRef] = useMonacoEditor(
121 | {
122 | value: defaultValue,
123 | language: language,
124 | },
125 | effects
126 | );
127 |
128 | useEffect(() => {
129 | const instance = instanceRef.current;
130 | if (instance != null) editor.setModelLanguage(instance.getModel()!, language);
131 | }, [language]);
132 |
133 | const getCode = useCallback(() => instanceRef.current?.getValue(), []);
134 |
135 | return [element, getCode] as const;
136 | };
137 |
138 | const setCursorDecorations = (instance: editor.IStandaloneCodeEditor, cursors: Cursor[]) => {
139 | const range = new Range(0, 0, (instance.getModel()?.getLineCount() ?? 0) + 1, 0);
140 | const oldCursorDecorations = (instance.getDecorationsInRange(range) ?? [])
141 | .filter(decoration => decoration.options.className?.startsWith(cursorSuffix))
142 | .map(decoration => decoration.id);
143 | const newCursorDecorations = cursors.map(({ uid, name, position: { lineNumber, column } }) => ({
144 | range: new Range(lineNumber, column, lineNumber, column),
145 | options: {
146 | className: [cursorSuffix, suffix + '-' + uid, suffix + '-' + uid + '-bg'].join(' '),
147 | stickiness: 1,
148 | hoverMessage: { value: name },
149 | },
150 | }));
151 | instance.deltaDecorations(oldCursorDecorations, newCursorDecorations);
152 | };
153 |
154 | const setSelectionDecrations = (instance: editor.IStandaloneCodeEditor, selections: Selection[]) => {
155 | const range = new Range(0, 0, (instance.getModel()?.getLineCount() ?? 0) + 1, 0);
156 | const oldSelectionDecorations = (instance.getDecorationsInRange(range) ?? [])
157 | .filter(decoration => decoration.options.className?.startsWith(selectionSuffix))
158 | .map(v => v.id);
159 | const newSelectionDecorations = selections.map(({ uid, name, range }) => ({
160 | range,
161 | options: {
162 | className: [selectionSuffix, suffix + '-' + uid, suffix + '-' + uid + '-bg'].join(' '),
163 | hoverMessage: { value: name },
164 | },
165 | }));
166 | instance.deltaDecorations(oldSelectionDecorations, newSelectionDecorations);
167 | };
168 |
--------------------------------------------------------------------------------
/client/web/src/components/editor/TextEditor.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useMonacoEditor } from './Base';
3 |
4 | export const useTextEditor = () => {
5 | const [element, instanceRef] = useMonacoEditor({
6 | value: '',
7 | language: 'plaintext',
8 | minimap: { enabled: false },
9 | lineNumbers: 'off',
10 | });
11 |
12 | const setValue = useCallback((value: string) => {
13 | instanceRef.current?.setValue(value);
14 | }, []);
15 | const getValue = useCallback(() => instanceRef.current?.getValue(), []);
16 |
17 | return [element, setValue, getValue] as const;
18 | };
19 |
20 | export const useReadonlyTextEditor = () => {
21 | const [element, instanceRef] = useMonacoEditor({
22 | value: '',
23 | language: 'plaintext',
24 | minimap: { enabled: false },
25 | lineNumbers: 'off',
26 | readOnly: true,
27 | });
28 |
29 | const setValue = useCallback((value: string) => {
30 | instanceRef.current?.setValue(value);
31 | }, []);
32 |
33 | return [element, setValue] as const;
34 | };
35 |
--------------------------------------------------------------------------------
/client/web/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { render } from 'react-dom';
2 | import App from './components/App';
3 |
4 | const root = document.getElementById('root')!;
5 | root.innerHTML = '';
6 |
7 | render(, root);
8 |
--------------------------------------------------------------------------------
/client/web/src/pages/chat.tsx:
--------------------------------------------------------------------------------
1 | import { VFC, useState, useMemo, Suspense, MouseEvent } from 'react';
2 | import { Main } from '~/components/Main';
3 | import { useLoad } from '~/scripts/promise';
4 | import { open } from '~/scripts/websocket/connection';
5 | import { Link, useMatch, useLocation } from '@tanstack/react-location';
6 | import { LocationGenerics } from '~/components/App';
7 | import { RoomInfo } from '~/scripts/room-api';
8 | import styled from 'styled-components';
9 | import { BlueButton } from '~/components/Button';
10 |
11 | const tickets = new Map();
12 | const getTickets = (pageKey: string | undefined) => {
13 | if (!tickets.has(pageKey)) tickets.set(pageKey, Symbol());
14 | return tickets.get(pageKey) as symbol;
15 | };
16 |
17 | export const ChatPage: VFC = () => {
18 | const location = useLocation();
19 | const {
20 | data: { roomInfo },
21 | } = useMatch();
22 |
23 | const [userName, setUserName] = useState();
24 | const chatConnection = useMemo(() => {
25 | if (userName != null && roomInfo != null)
26 | return useLoad(getTickets(location.current.key), () => open(roomInfo.id, userName));
27 | }, [userName]);
28 |
29 | return chatConnection == null ? (
30 |
31 | ) : (
32 | connecting
}>
33 |
34 |
35 | );
36 | };
37 |
38 | const Entrance = styled void }>>(
39 | ({ roomInfo, set, ...rest }) => {
40 | const [userName, setUserName] = useState('');
41 | const onClick = (ev: MouseEvent) => {
42 | ev.preventDefault();
43 | set(userName);
44 | };
45 |
46 | return roomInfo == null ? (
47 |
48 |
部屋が存在しません
49 |
52 |
53 | ) : (
54 |
55 |
部屋名: {roomInfo.name}
56 |
63 |
64 | );
65 | }
66 | )`
67 | width: 100vw;
68 | height: 100vh;
69 | display: flex;
70 | align-items: center;
71 | justify-content: center;
72 | flex-direction: column;
73 |
74 | > h2 {
75 | font-size: 1.25em;
76 | }
77 | > form {
78 | display: flex;
79 | align-items: center;
80 | margin-inline: auto;
81 | padding: 0.5em;
82 | max-width: 30em;
83 | > input {
84 | border: 1px solid #ccc;
85 | padding-inline: 0.5rem;
86 | font-size: 1em;
87 | line-height: 2;
88 | flex-grow: 1;
89 | }
90 | > span {
91 | padding: 0.25em;
92 | }
93 | }
94 | `;
95 |
--------------------------------------------------------------------------------
/client/web/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { VFC, Suspense, useState, MouseEvent } from 'react';
2 | import { Link, useLocation, useNavigate } from '@tanstack/react-location';
3 | import { Header } from '~/components/Header';
4 | import { Loadable, useLoad } from '~/scripts/promise';
5 | import { RoomList, getRoomList, createRoom } from '~/scripts/room-api';
6 | import styled from 'styled-components';
7 | import { BlueButton, blueButtonCss } from '~/components/Button';
8 |
9 | const tickets = new Map();
10 | const getTickets = (pageKey: string | undefined) => {
11 | if (!tickets.has(pageKey)) tickets.set(pageKey, Symbol());
12 | return tickets.get(pageKey) as symbol;
13 | };
14 |
15 | export const Welcome = styled(props => {
16 | const location = useLocation();
17 | const roomList = useLoad(getTickets(location.current.key), getRoomList);
18 | return (
19 |
20 |
21 |
22 |
23 | CodingChatはオンラインでのペアプログラミングを支援するサービスです。
24 |
25 | 誰でも無料でオンラインでコードを共同編集し実行することができます。
26 |
27 |
28 |
29 | 公開部屋一覧
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | })`
40 | > div:last-child {
41 | padding-block: 2em;
42 | text-align: center;
43 | > * {
44 | padding: 2em 0.5em;
45 | }
46 | > section:nth-child(2) > h2 {
47 | font-size: 1.25em;
48 | + div {
49 | margin-inline: auto;
50 | max-width: 30em;
51 | }
52 | }
53 | }
54 | `;
55 |
56 | const Rooms = styled }>>(({ roomList, ...rest }) => {
57 | const list = roomList.get();
58 | return list.length === 0 ? (
59 | no rooms
60 | ) : (
61 |
72 | );
73 | })`
74 | padding: 0;
75 | list-style: none;
76 | > li {
77 | display: flex;
78 | align-items: center;
79 | border: 1px solid #ccc;
80 | border-bottom: 0;
81 | :last-child {
82 | border-bottom: 1px solid #ccc;
83 | }
84 | padding: 0 1rem;
85 | > div {
86 | padding: 0.5em 0;
87 | text-align: start;
88 | flex-grow: 1;
89 | > h3 {
90 | font-weight: bold;
91 | }
92 | }
93 | > a {
94 | display: block;
95 | ${blueButtonCss}
96 | text-decoration: none;
97 | }
98 | }
99 | `;
100 |
101 | const NewRoom = styled(props => {
102 | const [name, setName] = useState('');
103 | const [isPublic, setIsPublic] = useState(true);
104 | const navigate = useNavigate();
105 |
106 | const create = (ev: MouseEvent) => {
107 | createRoom(name, isPublic).then(id => navigate({ to: '/chat/' + id }));
108 | ev.preventDefault();
109 | };
110 |
111 | return (
112 |
113 |
部屋を新規作成
114 |
122 | ※新しい部屋の作成時に、誰も入っていない部屋は自動的に削除されます。
123 |
124 | );
125 | })`
126 | > h2 {
127 | font-size: 1.25em;
128 | }
129 | > form {
130 | display: flex;
131 | align-items: center;
132 | margin-inline: auto;
133 | max-width: 30em;
134 | > input[type='text'] {
135 | border: 1px solid #ccc;
136 | padding-inline: 0.5rem;
137 | font-size: 1em;
138 | line-height: 2;
139 | flex-grow: 1;
140 | }
141 | > label {
142 | margin-inline: 0.5em;
143 | }
144 | }
145 | > span {
146 | font-size: 0.9em;
147 | line-height: 2;
148 | }
149 | `;
150 |
--------------------------------------------------------------------------------
/client/web/src/scripts/base62.ts:
--------------------------------------------------------------------------------
1 | const RADIX = 62;
2 |
3 | export const encode = (data: Uint8Array): string => {
4 | const n = Math.ceil(data.length * 1.3435902316563355); // 1.3435902316633555 = log 256 / log 62
5 | const res: string[] = [];
6 |
7 | for (let i = 0; i < n; ++i) {
8 | const sur = data.reduce((p, c) => ((p << 8) + c) % RADIX, 0);
9 | res.push('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'[sur]!);
10 | minus(data, sur);
11 | div(data, RADIX);
12 | }
13 |
14 | return res.reverse().join('');
15 | };
16 |
17 | const minus = (arr: Uint8Array, b: number) => {
18 | for (let i = arr.length - 1; b > 0 && i >= 0; --i) {
19 | const n = arr[i]! - b;
20 | if (n < 0) {
21 | const a = (n % 256) + 256;
22 | arr[i] = a;
23 | b = (a - n) / 256;
24 | } else {
25 | arr[i] = n % 256;
26 | b = 0;
27 | }
28 | }
29 | };
30 |
31 | const div = (arr: Uint8Array, b: number) => {
32 | let sur = 0;
33 | arr.forEach((v, i) => {
34 | sur = (sur << 8) + v;
35 | arr[i] = (sur / b) | 0;
36 | sur %= b;
37 | });
38 | };
39 |
--------------------------------------------------------------------------------
/client/web/src/scripts/code-run.ts:
--------------------------------------------------------------------------------
1 | import { request } from './wandbox';
2 |
3 | type Language = 'C++' | 'Python';
4 |
5 | export const run = (code: string, stdin: string, language: Language) =>
6 | request({
7 | compiler: language === 'C++' ? 'gcc-head' : 'cpython-3.9.3',
8 | code,
9 | stdin,
10 | })
11 | .then(({ compiler_output, compiler_error, program_output, program_error }) => {
12 | let log = [compiler_output, compiler_error, program_error].filter(v => v != null).join('\n');
13 | return [program_output ?? '', log] as const;
14 | })
15 | .catch(err => {
16 | return ['', 'could not run the code:\n' + err.message] as const;
17 | });
18 |
--------------------------------------------------------------------------------
/client/web/src/scripts/promise.ts:
--------------------------------------------------------------------------------
1 | export interface Loadable {
2 | get(): T;
3 | }
4 |
5 | type SyncStatus =
6 | | {
7 | type: 'pending';
8 | promise: Promise;
9 | }
10 | | {
11 | type: 'fulfilled';
12 | value: T;
13 | }
14 | | {
15 | type: 'rejected';
16 | error: unknown;
17 | };
18 | export class Sync implements Loadable {
19 | protected status: SyncStatus;
20 |
21 | constructor(promise: Promise) {
22 | this.status = { type: 'pending', promise };
23 | promise.then(
24 | value => {
25 | this.status = { type: 'fulfilled', value };
26 | return value;
27 | },
28 | error => {
29 | this.status = { type: 'rejected', error };
30 | throw error;
31 | }
32 | );
33 | }
34 |
35 | get(): T {
36 | switch (this.status.type) {
37 | case 'fulfilled':
38 | return this.status.value;
39 | case 'pending':
40 | throw this.status.promise;
41 | case 'rejected':
42 | throw this.status.error;
43 | }
44 | }
45 | }
46 | export const sync = (promise: Promise) => new Sync(promise);
47 |
48 | const syncs = new Map>();
49 | export const useLoad = (key: symbol, loader: () => Promise) => {
50 | if (!syncs.has(key)) syncs.set(key, sync(loader()));
51 |
52 | return syncs.get(key) as Loadable;
53 | };
54 |
--------------------------------------------------------------------------------
/client/web/src/scripts/room-api.ts:
--------------------------------------------------------------------------------
1 | import { ResponseError } from './utils';
2 |
3 | const endpoint = 'http://' + window.location.host + '/api';
4 |
5 | export type RoomInfo = {
6 | id: string;
7 | name: string;
8 | isPublic: boolean;
9 | participantsNumber: number;
10 | };
11 | type RoomInfoResponse = {
12 | name: string;
13 | is_open: boolean;
14 | connections: number;
15 | room_id: string;
16 | };
17 | export const getRoomInfo = (roomId: string) =>
18 | fetch(endpoint + '/room_info/' + roomId).then(async res => {
19 | if (!res.ok) throw new ResponseError(res);
20 |
21 | const { name, is_open, connections, room_id }: RoomInfoResponse = await res.json();
22 | return {
23 | id: room_id,
24 | name,
25 | isPublic: is_open,
26 | participantsNumber: connections,
27 | };
28 | });
29 |
30 | export type RoomList = {
31 | name: string;
32 | id: string;
33 | participantsNumber: number;
34 | }[];
35 | type RoomListResponse = {
36 | name: string;
37 | room_id: string;
38 | connections: number;
39 | }[];
40 | export const getRoomList = () =>
41 | fetch(endpoint + '/room_list').then(async res => {
42 | if (!res.ok) throw new ResponseError(res);
43 |
44 | const info = (await res.json()) as RoomListResponse;
45 | return info.map(({ name, room_id, connections }) => ({
46 | name,
47 | id: room_id,
48 | participantsNumber: connections,
49 | }));
50 | });
51 |
52 | export const createRoom = (name: string, isPublic: boolean) =>
53 | fetch(endpoint + '/create_room', {
54 | method: 'POST',
55 | body: JSON.stringify({ name, is_public: isPublic }),
56 | headers: { 'Content-Type': 'application/json' },
57 | }).then(async res => {
58 | if (!res.ok) throw new ResponseError(res);
59 | return (await res.json()) as string;
60 | });
61 |
--------------------------------------------------------------------------------
/client/web/src/scripts/room-mates.ts:
--------------------------------------------------------------------------------
1 | import { getRandomColor } from './utils';
2 |
3 | export class Room {
4 | private roomMates: Map;
5 | private styleElement: HTMLStyleElement;
6 |
7 | constructor() {
8 | this.roomMates = new Map();
9 | this.styleElement = document.createElement('style');
10 | document.head.append(this.styleElement);
11 | }
12 |
13 | dispose() {
14 | document.head.removeChild(this.styleElement);
15 | }
16 |
17 | joined(uid: string, name: string) {
18 | if (!this.roomMates.has(uid)) {
19 | this.roomMates.set(uid, name);
20 | this.reflect();
21 | }
22 | }
23 | left(uid: string) {
24 | return this.roomMates.delete(uid);
25 | }
26 |
27 | private getStyles() {
28 | const csss: string[] = [
29 | `
30 | .${selectionSuffix} {
31 | opacity: .5;
32 | position: relative;
33 | margin-inline: 2px;
34 | pointer-events: none;
35 | }
36 | .${cursorSuffix} {
37 | width: 2px !important;
38 | position: relative;
39 | z-index: 1;
40 | animation-name: ${cursorSuffix}-blink-animation;
41 | animation-direction: alternate;
42 | animation-duration: .5s;
43 | animation-delay: 2s;
44 | animation-iteration-count: infinite;
45 | animation-timing-function: steps(2, jump-end);
46 | }
47 | @keyframes ${cursorSuffix}-blink-animation {
48 | from { opacity: 1; }
49 | to { opacity: 0; }
50 | }
51 | .${cursorSuffix}::after {
52 | position: relative;
53 | top: -5px;
54 | right: -5px;
55 |
56 | font-size: calc(1em - 4px);
57 | pointer-events: none;
58 | white-space: nowrap;
59 | padding: 1px;
60 | z-index: 3;
61 | color: white;
62 | animation: 1s ease-in 1s 1 normal forwards running ${cursorSuffix}-fade-animation;
63 | }
64 | @keyframes ${cursorSuffix}-fade-animation {
65 | from { opacity: 1; }
66 | to { opacity: 0; }
67 | }
68 | `,
69 | ];
70 | for (const uid of this.roomMates.keys()) {
71 | const color = getRandomColor(uid);
72 | csss.push(`
73 | .${cursorSuffix}.${suffix}-${uid}:hover {
74 | animation: unset;
75 | }
76 | .${cursorSuffix}.${suffix}-${uid}:hover::after {
77 | animation: unset;
78 | }
79 | .${cursorSuffix}.${suffix}-${uid}::after {
80 | content: "${this.roomMates.get(uid)!}";
81 | background-color: ${color};
82 | }
83 | .${suffix}-${uid}-bg {
84 | background-color: ${color};
85 | }
86 | .${suffix}-${uid}-color {
87 | color: ${color};
88 | }
89 | `);
90 | }
91 | return csss.join('');
92 | }
93 |
94 | reflect() {
95 | requestIdleCallback(() => {
96 | this.styleElement.innerHTML = this.getStyles();
97 | });
98 | }
99 | }
100 |
101 | export const suffix = '__codingchat';
102 | export const cursorSuffix = suffix + '-cursor';
103 | export const selectionSuffix = suffix + '-selection';
104 |
--------------------------------------------------------------------------------
/client/web/src/scripts/utils.ts:
--------------------------------------------------------------------------------
1 | import { encode as base62Encode } from './base62';
2 |
3 | export const cyrb53 = (str: string, seed = 0) => {
4 | let h1 = 0xdeadbeef ^ seed,
5 | h2 = 0x41c6ce57 ^ seed;
6 | for (let i = 0, ch; i < str.length; i++) {
7 | ch = str.charCodeAt(i);
8 | h1 = Math.imul(h1 ^ ch, 2654435761);
9 | h2 = Math.imul(h2 ^ ch, 1597334677);
10 | }
11 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
12 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
13 | return 4294967296 * (2097151 & h2) + (h1 >>> 0);
14 | };
15 |
16 | export const getRandomColor = (input: string) => {
17 | const h = cyrb53(input) % 360;
18 | return `hsl(${h}, 80%, 60%)`;
19 | };
20 |
21 | const encoder = new TextEncoder();
22 | export const str2hex = (str: string) =>
23 | encoder
24 | .encode(str)
25 | .reduce((p, c) => (p.push(c.toString(16).padStart(2, '0')), p), [])
26 | .join('');
27 | export const base64ToBase62 = (str: string) => base62Encode(encoder.encode(atob(str)));
28 |
29 | export class ResponseError extends Error {
30 | constructor(readonly response: Response) {
31 | super('status code: ' + response.status.toString().padStart(3, '0') + '\nresponse: ' + response.text());
32 | }
33 | }
34 |
35 | export const readStream = (
36 | reader: ReadableStreamDefaultReader,
37 | processor: (chunk: T) => void | Promise
38 | ) => {
39 | const handler = async (result: ReadableStreamDefaultReadResult): Promise => {
40 | if (result.done) return;
41 |
42 | await processor(result.value);
43 | return reader.read().then(handler);
44 | };
45 | return reader.read().then(handler);
46 | };
47 |
--------------------------------------------------------------------------------
/client/web/src/scripts/wandbox.ts:
--------------------------------------------------------------------------------
1 | import { ResponseError } from './utils';
2 |
3 | const compilerInfo = [
4 | { lang: 'C', name: 'gcc-head-c' },
5 | { lang: 'C', name: 'gcc-11.1.0-c' },
6 | { lang: 'C', name: 'gcc-10.2.0-c' },
7 | { lang: 'C', name: 'gcc-9.3.0-c' },
8 | { lang: 'C', name: 'gcc-8.4.0-c' },
9 | { lang: 'C', name: 'gcc-7.5.0-c' },
10 | { lang: 'C', name: 'gcc-6.5.0-c' },
11 | { lang: 'C', name: 'gcc-5.5.0-c' },
12 | { lang: 'C', name: 'gcc-4.9.4-c' },
13 | { lang: 'CPP', name: 'gcc-head-pp' },
14 | { lang: 'C++', name: 'gcc-head' },
15 | { lang: 'C++', name: 'gcc-11.1.0' },
16 | { lang: 'C++', name: 'gcc-10.2.0' },
17 | { lang: 'C++', name: 'gcc-9.3.0' },
18 | { lang: 'C++', name: 'gcc-8.4.0' },
19 | { lang: 'C++', name: 'gcc-7.5.0' },
20 | { lang: 'C++', name: 'gcc-6.5.0' },
21 | { lang: 'C++', name: 'gcc-5.5.0' },
22 | { lang: 'C++', name: 'gcc-4.9.4' },
23 | { lang: 'C', name: 'clang-head-c' },
24 | { lang: 'C', name: 'clang-13.0.0-c' },
25 | { lang: 'C', name: 'clang-12.0.1-c' },
26 | { lang: 'C', name: 'clang-11.1.0-c' },
27 | { lang: 'C', name: 'clang-10.0.1-c' },
28 | { lang: 'C', name: 'clang-9.0.1-c' },
29 | { lang: 'C', name: 'clang-8.0.1-c' },
30 | { lang: 'C', name: 'clang-7.1.0-c' },
31 | { lang: 'CPP', name: 'clang-head-pp' },
32 | { lang: 'C++', name: 'clang-head' },
33 | { lang: 'C++', name: 'clang-13.0.0' },
34 | { lang: 'C++', name: 'clang-12.0.1' },
35 | { lang: 'C++', name: 'clang-11.1.0' },
36 | { lang: 'C++', name: 'clang-10.0.1' },
37 | { lang: 'C++', name: 'clang-9.0.1' },
38 | { lang: 'C++', name: 'clang-8.0.1' },
39 | { lang: 'C++', name: 'clang-7.1.0' },
40 | { lang: 'C#', name: 'mono-6.12.0.122' },
41 | { lang: 'C#', name: 'mono-5.20.1.34' },
42 | { lang: 'Erlang', name: 'erlang-23.3.1' },
43 | { lang: 'Erlang', name: 'erlang-22.3.4.16' },
44 | { lang: 'Erlang', name: 'erlang-21.3.8.22' },
45 | { lang: 'Elixir', name: 'elixir-1.11.4' },
46 | { lang: 'Elixir', name: 'elixir-1.10.4' },
47 | { lang: 'Haskell', name: 'ghc-9.0.1' },
48 | { lang: 'Haskell', name: 'ghc-8.10.4' },
49 | { lang: 'Haskell', name: 'ghc-8.8.4' },
50 | { lang: 'D', name: 'dmd-2.096.0' },
51 | { lang: 'D', name: 'ldc-1.25.1' },
52 | { lang: 'D', name: 'ldc-1.24.0' },
53 | { lang: 'D', name: 'ldc-1.23.0' },
54 | { lang: 'Java', name: 'openjdk-jdk-15.0.3+2' },
55 | { lang: 'Java', name: 'openjdk-jdk-14.0.2+12' },
56 | { lang: 'Rust', name: 'rust-1.51.0' },
57 | { lang: 'Rust', name: 'rust-1.50.0' },
58 | { lang: 'Python', name: 'cpython-3.9.3' },
59 | { lang: 'Python', name: 'cpython-3.8.9' },
60 | { lang: 'Python', name: 'cpython-3.7.10' },
61 | { lang: 'Python', name: 'cpython-3.6.12' },
62 | { lang: 'Python', name: 'cpython-2.7.18' },
63 | { lang: 'Ruby', name: 'ruby-3.1.0' },
64 | { lang: 'Ruby', name: 'ruby-3.0.1' },
65 | { lang: 'Ruby', name: 'ruby-2.7.3' },
66 | { lang: 'Ruby', name: 'mruby-3.0.0' },
67 | { lang: 'Ruby', name: 'mruby-2.1.2' },
68 | { lang: 'Ruby', name: 'mruby-1.4.1' },
69 | { lang: 'Scala', name: 'scala-2.13.5' },
70 | { lang: 'Scala', name: 'scala-2.12.13' },
71 | { lang: 'Groovy', name: 'groovy-3.0.8' },
72 | { lang: 'Groovy', name: 'groovy-2.5.14' },
73 | { lang: 'JavaScript', name: 'nodejs-14.16.1' },
74 | { lang: 'JavaScript', name: 'nodejs-12.22.1' },
75 | { lang: 'JavaScript', name: 'nodejs-10.24.1' },
76 | { lang: 'JavaScript', name: 'spidermonkey-88.0.0' },
77 | { lang: 'Swift', name: 'swift-5.3.3' },
78 | { lang: 'Perl', name: 'perl-5.34.0' },
79 | { lang: 'Perl', name: 'perl-5.33.8' },
80 | { lang: 'Perl', name: 'perl-5.32.1' },
81 | { lang: 'Perl', name: 'perl-5.30.3' },
82 | { lang: 'PHP', name: 'php-8.0.3' },
83 | { lang: 'PHP', name: 'php-7.4.16' },
84 | { lang: 'PHP', name: 'php-5.6.40' },
85 | { lang: 'Lua', name: 'lua-5.4.3' },
86 | { lang: 'Lua', name: 'lua-5.3.6' },
87 | { lang: 'Lua', name: 'lua-5.2.4' },
88 | { lang: 'Lua', name: 'luajit-2.0.5' },
89 | { lang: 'Lua', name: 'luajit-2.0.4' },
90 | { lang: 'Lua', name: 'luajit-2.0.3' },
91 | { lang: 'SQL', name: 'sqlite-3.35.5' },
92 | { lang: 'Pascal', name: 'fpc-3.2.0' },
93 | { lang: 'Pascal', name: 'fpc-3.0.4' },
94 | { lang: 'Pascal', name: 'fpc-2.6.4' },
95 | { lang: 'Lisp', name: 'clisp-2.49' },
96 | { lang: 'Lazy K', name: 'lazyk' },
97 | { lang: 'Vim script', name: 'vim-8.2.2811' },
98 | { lang: 'Vim script', name: 'vim-8.1.2424' },
99 | { lang: 'Python', name: 'pypy-3.7-v7.3.4' },
100 | { lang: 'Python', name: 'pypy-2.7-v7.3.4' },
101 | { lang: 'OCaml', name: 'ocaml-4.12.0' },
102 | { lang: 'OCaml', name: 'ocaml-4.11.2' },
103 | { lang: 'OCaml', name: 'ocaml-4.10.2' },
104 | { lang: 'Go', name: 'go-1.16.3' },
105 | { lang: 'Go', name: 'go-1.15.11' },
106 | { lang: 'Go', name: 'go-1.14.15' },
107 | { lang: 'Lisp', name: 'sbcl-2.1.3' },
108 | { lang: 'Lisp', name: 'sbcl-1.5.9' },
109 | { lang: 'Bash script', name: 'bash' },
110 | { lang: 'Pony', name: 'pony-0.39.1' },
111 | { lang: 'Pony', name: 'pony-0.38.3' },
112 | { lang: 'Crystal', name: 'crystal-1.0.0' },
113 | { lang: 'Crystal', name: 'crystal-0.36.1' },
114 | { lang: 'Nim', name: 'nim-1.6.0' },
115 | { lang: 'Nim', name: 'nim-1.4.8' },
116 | { lang: 'Nim', name: 'nim-1.4.6' },
117 | { lang: 'Nim', name: 'nim-1.2.8' },
118 | { lang: 'Nim', name: 'nim-1.0.10' },
119 | { lang: 'OpenSSL', name: 'openssl-1.1.1k' },
120 | { lang: 'OpenSSL', name: 'openssl-1.0.2u' },
121 | { lang: 'OpenSSL', name: 'openssl-0.9.8zh' },
122 | { lang: 'C#', name: 'dotnetcore-5.0.201' },
123 | { lang: 'C#', name: 'dotnetcore-3.1.407' },
124 | { lang: 'C#', name: 'dotnetcore-2.1.814' },
125 | { lang: 'R', name: 'r-4.0.5' },
126 | { lang: 'R', name: 'r-3.6.3' },
127 | { lang: 'TypeScript', name: 'typescript-4.2.4 nodejs 14.16.1' },
128 | { lang: 'TypeScript', name: 'typescript-3.9.9 nodejs 14.16.1' },
129 | { lang: 'Julia', name: 'julia-1.6.1' },
130 | { lang: 'Julia', name: 'julia-1.0.5' },
131 | ] as const;
132 | type CompilerInfo = typeof compilerInfo;
133 |
134 | type Language = CompilerInfo[number]['lang'];
135 | type CompilerName = CompilerInfo[number] extends infer Info
136 | ? Info extends { lang: L; name: string }
137 | ? Info['name']
138 | : never
139 | : never;
140 |
141 | export const getCompilerNameListOf = (language: L) =>
142 | compilerInfo.filter(({ lang }) => lang === language).map(({ name }) => name) as CompilerName[];
143 |
144 | type CompileRequest = {
145 | compiler: CompilerName;
146 | code: string;
147 | stdin: string;
148 | };
149 |
150 | type CompileResponse = {
151 | status: string;
152 | signal?: string;
153 | compiler_output?: string;
154 | compiler_error?: string;
155 | compiler_message?: string;
156 | program_output?: string;
157 | program_error?: string;
158 | program_message?: string;
159 | };
160 |
161 | const endpoint = 'https://wandbox.org/api/compile.json';
162 | export const request = (request: CompileRequest) =>
163 | fetch(endpoint, { method: 'post', body: JSON.stringify(request) }).then(response => {
164 | if (response.ok) {
165 | return response.json() as Promise;
166 | }
167 | throw new ResponseError(response);
168 | });
169 |
--------------------------------------------------------------------------------
/client/web/src/scripts/websocket/connection.ts:
--------------------------------------------------------------------------------
1 | import { Room } from '../room-mates';
2 | import { str2hex } from '../utils';
3 | import { strToInboundMessage } from './transformer';
4 | import { InboundMessage, OutboundMessage } from './types';
5 |
6 | export class WebsocketConnectionManager {
7 | private room: Room;
8 | private destructors: Set<() => void>;
9 | private messageStream: ReadableStream;
10 |
11 | constructor(private socket: WebSocket) {
12 | this.room = new Room();
13 | this.destructors = new Set();
14 | socket.addEventListener('close', () => {
15 | this.destructors.forEach(f => f());
16 | });
17 |
18 | this.messageStream = new ReadableStream({
19 | start: controller => {
20 | socket.addEventListener('message', ({ data, timeStamp }: MessageEvent) => {
21 | if ('string' === typeof data) {
22 | const message = strToInboundMessage(data);
23 | if (message != null) {
24 | if (message.type === 'disconnect') this.room.left(message.key);
25 | else this.room.joined(message.key, message.name);
26 |
27 | controller.enqueue(Object.assign(message, { timestamp: timeStamp }));
28 | }
29 | }
30 | });
31 | socket.addEventListener('close', () => {
32 | controller.close();
33 | });
34 | },
35 | });
36 | }
37 |
38 | sendMessage(message: OutboundMessage) {
39 | this.socket.send(JSON.stringify(message));
40 | }
41 | close() {
42 | this.socket.close();
43 | }
44 | getMessageStream() {
45 | const [s1, s2] = this.messageStream.tee();
46 | this.messageStream = s1;
47 | return s2;
48 | }
49 | }
50 |
51 | const endpoint = 'ws://' + window.location.host + '/api/ws/';
52 |
53 | export const open = (roomId: string, name: string) =>
54 | new Promise((resolve, reject) => {
55 | const sock = new WebSocket(endpoint + roomId + '?name=' + str2hex(name));
56 | const conn = new WebsocketConnectionManager(sock);
57 | sock.addEventListener('open', () => resolve(conn));
58 | sock.addEventListener('error', () => reject('cannot open websocket connection'));
59 | });
60 |
--------------------------------------------------------------------------------
/client/web/src/scripts/websocket/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { readStream } from '../utils';
3 | import { WebsocketConnectionManager } from './connection';
4 | import { pickAndTransformInboundChat } from './transformer';
5 | import { ChatMessage } from './types';
6 |
7 | export const useChat = (conn: WebsocketConnectionManager) => {
8 | const [messages, setMessages] = useState([]);
9 |
10 | useEffect(() => {
11 | readStream(conn.getMessageStream().getReader(), message => {
12 | const chat = pickAndTransformInboundChat(message);
13 |
14 | if (chat != null) setMessages(messages => [chat, ...messages]);
15 | });
16 | }, []);
17 |
18 | return messages;
19 | };
20 |
--------------------------------------------------------------------------------
/client/web/src/scripts/websocket/transformer.ts:
--------------------------------------------------------------------------------
1 | import { base64ToBase62 } from '../utils';
2 | import { ChatMessage, EditorCommand, InboundMessage } from './types';
3 | import { inboundMessageValidator } from './validator';
4 |
5 | export const strToInboundMessage = (str: string) => {
6 | const data = JSON.parse(str) as unknown;
7 |
8 | if (!inboundMessageValidator(data)) return undefined;
9 | data.key = base64ToBase62(data.key);
10 |
11 | return data;
12 | };
13 |
14 | export const pickAndTransformInboundChat = (
15 | message: InboundMessage & { timestamp: number }
16 | ): ChatMessage | undefined => {
17 | switch (message.type) {
18 | case 'chat': {
19 | const { data, timestamp, name, key } = message;
20 | return {
21 | type: 'chat',
22 | content: data,
23 | date: new Date(timestamp),
24 | uid: key,
25 | name,
26 | };
27 | }
28 | case 'connect': {
29 | const { timestamp, name } = message;
30 | return {
31 | type: 'info',
32 | content: name + 'が接続しました',
33 | date: new Date(timestamp),
34 | };
35 | }
36 | case 'disconnect': {
37 | const { timestamp, name } = message;
38 | return {
39 | type: 'info',
40 | content: name + 'が切断しました',
41 | date: new Date(timestamp),
42 | };
43 | }
44 | }
45 | };
46 |
47 | export const pickAndTransformEditorCommand = (message: InboundMessage): EditorCommand | undefined => {
48 | switch (message.type) {
49 | case 'connect': {
50 | return {
51 | type: 'onconnect',
52 | fullText: message.data,
53 | };
54 | }
55 | case 'disconnect': {
56 | return {
57 | type: 'clean',
58 | uid: message.key,
59 | }
60 | }
61 | case 'edit': {
62 | return {
63 | type: 'edit',
64 | changes: message.data.changes,
65 | };
66 | }
67 | case 'cursormove': {
68 | const { data, key, name } = message;
69 | return {
70 | type: 'cursormove',
71 | position: data,
72 | uid: key,
73 | name,
74 | };
75 | }
76 | case 'selection': {
77 | const { data, key, name } = message;
78 | return {
79 | type: 'selection',
80 | range: data,
81 | uid: key,
82 | name,
83 | };
84 | }
85 | }
86 | };
87 |
--------------------------------------------------------------------------------
/client/web/src/scripts/websocket/types.ts:
--------------------------------------------------------------------------------
1 | import type { IPosition, IRange, editor } from 'monaco-editor';
2 |
3 | export type FullText = string;
4 | export type ConnectionKey = string;
5 |
6 | export type OutboundEditorCommand =
7 | | {
8 | type: 'cursormove';
9 | data: IPosition;
10 | }
11 | | {
12 | type: 'selection';
13 | data: IRange;
14 | }
15 | | {
16 | type: 'edit';
17 | data: {
18 | changes: editor.IModelContentChange[];
19 | timestamp: number;
20 | full_text: FullText;
21 | };
22 | };
23 | export type OutboundChat = {
24 | type: 'chat';
25 | data: string;
26 | };
27 | export type OutboundMessage = OutboundEditorCommand | OutboundChat;
28 |
29 | export type InboundEditorCommand = OutboundEditorCommand & { key: ConnectionKey; name: string };
30 | export type InboundChat = OutboundChat & { key: ConnectionKey; name: string };
31 | export type InboundConnectionInfo = { key: ConnectionKey; name: string } & (
32 | | {
33 | type: 'connect';
34 | data: FullText;
35 | }
36 | | {
37 | type: 'disconnect';
38 | data: '';
39 | }
40 | );
41 | export type InboundMessage = InboundEditorCommand | InboundChat | InboundConnectionInfo;
42 |
43 | export type EditorCommand =
44 | | {
45 | type: 'onconnect';
46 | fullText: string;
47 | }
48 | | {
49 | type: 'edit';
50 | changes: editor.IModelContentChange[];
51 | }
52 | | {
53 | type: 'cursormove';
54 | position: IPosition;
55 | uid: ConnectionKey;
56 | name: string;
57 | }
58 | | {
59 | type: 'selection';
60 | range: IRange;
61 | uid: ConnectionKey;
62 | name: string;
63 | }
64 | | {
65 | type: 'clean';
66 | uid: ConnectionKey;
67 | };
68 |
69 | export type ChatMessage =
70 | | {
71 | type: 'chat';
72 | content: string;
73 | date: Date;
74 | uid: ConnectionKey;
75 | name: string;
76 | }
77 | | {
78 | type: 'info';
79 | content: string;
80 | date: Date;
81 | };
82 |
--------------------------------------------------------------------------------
/client/web/src/scripts/websocket/validator.ts:
--------------------------------------------------------------------------------
1 | import { editor, IPosition, IRange } from 'monaco-editor';
2 | import { FullText, InboundMessage } from './types';
3 |
4 | export const inboundMessageValidator = (data: unknown): data is InboundMessage => {
5 | if (data == null) return false;
6 | const obj = data as Record;
7 | return (
8 | false ||
9 | obj.type === 'disconnect' ||
10 | (obj.type === 'connect' && isString(obj.data)) ||
11 | (obj.type === 'chat' && isString(obj.data)) ||
12 | (obj.type === 'edit' && editChangesValidator(obj.data)) ||
13 | (obj.type === 'cursormove' && positionValidator(obj.data)) ||
14 | (obj.type === 'selection' && rangeValidator(obj.data))
15 | );
16 | };
17 |
18 | const isNumber = (v: unknown): v is number => 'number' === typeof v;
19 | const isString = (v: unknown): v is string => 'string' === typeof v;
20 |
21 | const positionValidator = (data: unknown): data is IPosition => {
22 | if (data == null) return false;
23 | const obj = data as Record;
24 | return isNumber(obj.lineNumber) && isNumber(obj.column);
25 | };
26 |
27 | const rangeValidator = (data: unknown): data is IRange => {
28 | if (data == null) return false;
29 | const obj = data as Record;
30 | return (
31 | true &&
32 | isNumber(obj.startLineNumber) &&
33 | isNumber(obj.endLineNumber) &&
34 | isNumber(obj.startColumn) &&
35 | isNumber(obj.endColumn)
36 | );
37 | };
38 |
39 | const editChangesValidator = (
40 | data: unknown
41 | ): data is {
42 | changes: editor.IModelContentChange[];
43 | timestamp: number;
44 | full_text: FullText;
45 | } => {
46 | if (data == null) return false;
47 | const obj = data as Record;
48 | return (
49 | true &&
50 | isNumber(obj.timestamp) &&
51 | isString(obj.full_text) &&
52 | obj.changes instanceof Array &&
53 | obj.changes.every(modelContentChangeValidator)
54 | );
55 | };
56 |
57 | const modelContentChangeValidator = (data: unknown): data is editor.IModelContentChange => {
58 | if (data == null) return false;
59 | const obj = data as Record;
60 | return (
61 | true && rangeValidator(obj.range) && isNumber(obj.rangeOffset) && isNumber(obj.rangeLength) && isString(obj.text)
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/client/web/src/styles/global.ts:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 |
3 | export const GlobalStyle = createGlobalStyle`
4 | * {
5 | margin: 0;
6 | }
7 | h1,h2,h3,h4,h5,h6 {
8 | font-weight: normal;
9 | font-size: 1em;
10 | }
11 | input {
12 | min-width: 0;
13 | }
14 | `;
15 |
--------------------------------------------------------------------------------
/client/web/src/template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | CodingChat
7 |
8 |
9 |
10 |
11 |
Only latest Firefox or Chrome supported
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/client/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "strict": true,
6 | "jsx": "react-jsx",
7 | "lib": ["dom", "esnext"],
8 | "noUncheckedIndexedAccess": true,
9 | "moduleResolution": "node",
10 | "baseUrl": ".",
11 | "paths": {
12 | "~/*": ["./src/*"]
13 | },
14 | "esModuleInterop": true,
15 | "skipLibCheck": true,
16 | "forceConsistentCasingInFileNames": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/client/web/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 | const MonacoEditorWebpackPlugin = require('monaco-editor-webpack-plugin');
4 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
5 | const ReactRefreshTypeScript = require('react-refresh-typescript');
6 |
7 | module.exports = (env, argv) => {
8 | const isDev = argv.mode !== 'production';
9 |
10 | return {
11 | mode: argv.mode,
12 | devtool: isDev && 'eval',
13 | entry: {
14 | app: path.resolve(__dirname, './src/index.tsx'),
15 | },
16 | resolve: {
17 | extensions: ['.ts', '.tsx', '.js'],
18 | alias: {
19 | '~': path.resolve(__dirname, './src'),
20 | },
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.tsx?$/,
26 | use: [
27 | { loader: 'babel-loader' },
28 | {
29 | loader: 'ts-loader',
30 | options: {
31 | getCustomTransformers: () => (isDev ? { before: [ReactRefreshTypeScript()] } : void 0),
32 | compilerOptions: { jsx: 'react-jsx' + (isDev ? 'dev' : '') },
33 | },
34 | },
35 | ],
36 | },
37 | {
38 | test: /\.css$/,
39 | use: ['style-loader', 'css-loader'],
40 | },
41 | ],
42 | },
43 | optimization: {
44 | splitChunks: {
45 | cacheGroups: {
46 | vendor: {
47 | test: /node_modules/,
48 | name: 'vendor',
49 | chunks: 'initial',
50 | enforce: true,
51 | },
52 | },
53 | },
54 | },
55 | output: {
56 | path: path.resolve(__dirname, './build/'),
57 | filename: isDev ? 'static/[id].js' : 'static/[contenthash].js',
58 | publicPath: '/',
59 | clean: true,
60 | },
61 | plugins: [
62 | new HtmlWebpackPlugin({
63 | template: path.resolve(__dirname, './src/template.html'),
64 | }),
65 | new MonacoEditorWebpackPlugin({
66 | filename: 'static/[name].worker.js',
67 | publicPath: '/',
68 | languages: ['cpp', 'python'],
69 | }),
70 | isDev && new ReactRefreshWebpackPlugin(),
71 | ].filter(v => !!v),
72 | devServer: {
73 | proxy: {
74 | '/api': {
75 | target: 'http://' + (process.env.API_HOST ?? 'localhost:3000'),
76 | ws: true,
77 | },
78 | },
79 | static: {
80 | directory: path.resolve(__dirname, './build/static'),
81 | },
82 | historyApiFallback: true,
83 | port: process.env.PORT ?? 8080,
84 | },
85 | };
86 | };
87 |
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | web-client:
5 | build:
6 | context: ./client/web
7 | dockerfile: Dockerfile.dev
8 | ports:
9 | - 8080:8080
10 | volumes:
11 | - ./client/web/src:/workspace/src
12 | environment:
13 | - PORT=8080
14 | - API_HOST=api:3000
15 |
16 | api:
17 | build:
18 | context: ./api
19 | dockerfile: Dockerfile.dev
20 | volumes:
21 | - ./api/src:/workspace/src
22 | environment:
23 | - PORT=3000
24 |
--------------------------------------------------------------------------------
/docker-compose.prod.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | web:
5 | build:
6 | context: .
7 | dockerfile: Dockerfile
8 | ports:
9 | - 8080:8080
10 | environment:
11 | - PORT=8080
--------------------------------------------------------------------------------