diff --git a/examples/chatroom/backend/.gitignore b/examples/chatroom/backend/.gitignore new file mode 100644 index 0000000..d84f0da --- /dev/null +++ b/examples/chatroom/backend/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.DS_STORE \ No newline at end of file diff --git a/examples/chatroom/backend/.vscode/launch.json b/examples/chatroom/backend/.vscode/launch.json new file mode 100644 index 0000000..9ba4218 --- /dev/null +++ b/examples/chatroom/backend/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "mocha current file", + "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", + "args": [ + "${file}" + ], + "internalConsoleOptions": "openOnSessionStart", + "cwd": "${workspaceFolder}" + }, + { + "type": "node", + "request": "launch", + "name": "ts-node current file", + "protocol": "inspector", + "args": [ + "${relativeFile}" + ], + "cwd": "${workspaceRoot}", + "runtimeArgs": [ + "-r", + "ts-node/register" + ], + "internalConsoleOptions": "openOnSessionStart" + } + ] +} \ No newline at end of file diff --git a/examples/chatroom/backend/.vscode/settings.json b/examples/chatroom/backend/.vscode/settings.json new file mode 100644 index 0000000..00ad71f --- /dev/null +++ b/examples/chatroom/backend/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib" +} \ No newline at end of file diff --git a/examples/chatroom/backend/README.md b/examples/chatroom/backend/README.md new file mode 100644 index 0000000..7492c22 --- /dev/null +++ b/examples/chatroom/backend/README.md @@ -0,0 +1,35 @@ +# TSRPC Server + +## Run +### Local Dev Server +``` +npm run dev +``` + + + +### Build +``` +npm run build +``` + +--- + +## Files +### Generate ServiceProto +``` +npm run proto +``` + +### Generate API templates +``` +npm run api +``` + +### Sync shared code to client + +``` +npm run sync +``` + +> If you chose symlink when using `create-tsrpc-app`, it would re-create the symlink instead of copy files. diff --git a/examples/chatroom/backend/package.json b/examples/chatroom/backend/package.json new file mode 100644 index 0000000..7ce13e7 --- /dev/null +++ b/examples/chatroom/backend/package.json @@ -0,0 +1,23 @@ +{ + "name": "chatroom-backend", + "version": "0.1.0", + "main": "index.js", + "private": true, + "scripts": { + "proto": "tsrpc proto -i src/shared/protocols -o src/shared/protocols/serviceProto.ts", + "sync": "tsrpc sync --from src/shared --to ../frontend/src/shared", + "api": "tsrpc api -i src/shared/protocols/serviceProto.ts -o src/api", + "dev": "onchange \"src/**/*.ts\" -i -k -- ts-node \"src/index.ts\"", + "build": "tsrpc build" + }, + "devDependencies": { + "@types/node": "^15.12.2", + "onchange": "^7.1.0", + "ts-node": "^9.1.1", + "tsrpc-cli": "^2.0.1-dev.11", + "typescript": "^4.3.2" + }, + "dependencies": { + "tsrpc": "^3.0.0-dev.19" + } +} diff --git a/examples/chatroom/backend/src/api/ApiSend.ts b/examples/chatroom/backend/src/api/ApiSend.ts new file mode 100644 index 0000000..0864ac3 --- /dev/null +++ b/examples/chatroom/backend/src/api/ApiSend.ts @@ -0,0 +1,23 @@ +import { ApiCall } from "tsrpc"; +import { server } from ".."; +import { ReqSend, ResSend } from "../shared/protocols/PtlSend"; + +export async function ApiSend(call: ApiCall) { + // Error + if (call.req.content.length === 0) { + call.error('Content is empty') + return; + } + + // Success + let time = new Date(); + call.succ({ + time: time + }); + + // Broadcast + server.broadcastMsg('Chat', { + content: call.req.content, + time: time + }) +} \ No newline at end of file diff --git a/examples/chatroom/backend/src/index.ts b/examples/chatroom/backend/src/index.ts new file mode 100644 index 0000000..eab4171 --- /dev/null +++ b/examples/chatroom/backend/src/index.ts @@ -0,0 +1,24 @@ +import * as path from "path"; +import { WsServer } from "tsrpc"; +import { serviceProto } from './shared/protocols/serviceProto'; + +// Create the Server +export const server = new WsServer(serviceProto, { + port: 3000 +}); + +// Entry function +async function main() { + await server.autoImplementApi(path.resolve(__dirname, 'api')); + + // TODO + // Prepare something... (e.g. connect the db) + + await server.start(); +}; + +main().catch(e => { + // Exit if any error during the startup + server.logger.error(e); + process.exit(-1); +}); \ No newline at end of file diff --git a/examples/chatroom/backend/src/shared/protocols/MsgChat.ts b/examples/chatroom/backend/src/shared/protocols/MsgChat.ts new file mode 100644 index 0000000..4912efe --- /dev/null +++ b/examples/chatroom/backend/src/shared/protocols/MsgChat.ts @@ -0,0 +1,4 @@ +export interface MsgChat { + content: string, + time: Date +} \ No newline at end of file diff --git a/examples/chatroom/backend/src/shared/protocols/PtlSend.ts b/examples/chatroom/backend/src/shared/protocols/PtlSend.ts new file mode 100644 index 0000000..ed2505d --- /dev/null +++ b/examples/chatroom/backend/src/shared/protocols/PtlSend.ts @@ -0,0 +1,7 @@ +export interface ReqSend { + content: string +} + +export interface ResSend { + time: Date +} \ No newline at end of file diff --git a/examples/chatroom/backend/src/shared/protocols/serviceProto.ts b/examples/chatroom/backend/src/shared/protocols/serviceProto.ts new file mode 100644 index 0000000..d007337 --- /dev/null +++ b/examples/chatroom/backend/src/shared/protocols/serviceProto.ts @@ -0,0 +1,75 @@ +import { ServiceProto } from 'tsrpc-proto'; +import { MsgChat } from './MsgChat'; +import { ReqSend, ResSend } from './PtlSend'; + +export interface ServiceType { + api: { + "Send": { + req: ReqSend, + res: ResSend + } + }, + msg: { + "Chat": MsgChat + } +} + +export const serviceProto: ServiceProto = { + "services": [ + { + "id": 0, + "name": "Chat", + "type": "msg" + }, + { + "id": 1, + "name": "Send", + "type": "api" + } + ], + "types": { + "MsgChat/MsgChat": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "content", + "type": { + "type": "String" + } + }, + { + "id": 1, + "name": "time", + "type": { + "type": "Date" + } + } + ] + }, + "PtlSend/ReqSend": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "content", + "type": { + "type": "String" + } + } + ] + }, + "PtlSend/ResSend": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "time", + "type": { + "type": "Date" + } + } + ] + } + } +}; \ No newline at end of file diff --git a/examples/chatroom/backend/tsconfig.json b/examples/chatroom/backend/tsconfig.json new file mode 100644 index 0000000..d18498f --- /dev/null +++ b/examples/chatroom/backend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": [ + "es2018" + ], + "module": "commonjs", + "target": "es2018", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node" + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/examples/chatroom/frontend/.gitignore b/examples/chatroom/frontend/.gitignore new file mode 100644 index 0000000..ae16b5a --- /dev/null +++ b/examples/chatroom/frontend/.gitignore @@ -0,0 +1,3 @@ +.DS_STORE +node_modules +dist \ No newline at end of file diff --git a/examples/chatroom/frontend/package.json b/examples/chatroom/frontend/package.json new file mode 100644 index 0000000..49999e6 --- /dev/null +++ b/examples/chatroom/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "chatroom-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "webpack serve --mode=development --open", + "build": "webpack --mode=production" + }, + "devDependencies": { + "copy-webpack-plugin": "^9.0.0", + "html-webpack-plugin": "^5.3.1", + "ts-loader": "^9.2.3", + "typescript": "^4.3.2", + "webpack": "^5.38.1", + "webpack-cli": "^4.7.2", + "webpack-dev-server": "^3.11.2" + }, + "dependencies": { + "tsrpc-browser": "^3.0.0-dev.15" + } +} diff --git a/examples/chatroom/frontend/public/favicon.ico b/examples/chatroom/frontend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/examples/chatroom/frontend/public/index.css b/examples/chatroom/frontend/public/index.css new file mode 100644 index 0000000..b24f118 --- /dev/null +++ b/examples/chatroom/frontend/public/index.css @@ -0,0 +1,100 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body>h1 { + text-align: center; + margin-top: 20px; +} + +.app { + display: flex; + justify-content: center; +} + +.chat-room { + display: flex; + flex-direction: column; + width: 460px; + height: 480px; + margin: 20px; + background: #f7f7f7; + border: 1px solid #454545; + border-radius: 5px; + overflow: hidden; +} + +.chat-room>header { + background: #454545; + color: white; + text-align: center; + padding: 10px; +} + +.send { + flex: 0 0 40px; + display: flex; + border-top: 1px solid #454545; +} + +.send>* { + border: none; + outline: none; + height: 100%; + font-size: 16px; +} + +.send>input { + flex: 1; + background: #fff; + padding: 0 10px; +} + +.send>button { + flex: 0 0 100px; + background: #215fa4; + color: white; + cursor: pointer; +} + +.send>button:hover { + background: #4b80bb; +} + +.list { + flex: 1; + overflow-y: auto; + list-style: none; + border-radius: 5px; + padding: 10px; + padding-bottom: 20px; + background: #f2f2f2; +} + +.list>li { + margin-bottom: 10px; + padding: 10px; + background: #fff; + line-height: 1.5em; + border-radius: 5px; +} + +.list>li>.content { + font-size: 14px; + text-align: left; + white-space: pre-wrap; + word-wrap: break-word; +} + +.list>li>.time { + font-size: 12px; + color: #4b80bb; + text-align: right; +} + +.list>li:last-child { + border-bottom: none; + margin-bottom: 0; +} \ No newline at end of file diff --git a/examples/chatroom/frontend/public/index.html b/examples/chatroom/frontend/public/index.html new file mode 100644 index 0000000..aa28cb3 --- /dev/null +++ b/examples/chatroom/frontend/public/index.html @@ -0,0 +1,37 @@ + + + + + + + + TSRPC Browser + + + + +

TSRPC Chatroom

+ +
+
+
Client #1
+ +
    +
    + + +
    +
    + +
    +
    Client #2
    +
      +
      + + +
      +
      +
      + + + \ No newline at end of file diff --git a/examples/chatroom/frontend/src/Chatroom.ts b/examples/chatroom/frontend/src/Chatroom.ts new file mode 100644 index 0000000..095be42 --- /dev/null +++ b/examples/chatroom/frontend/src/Chatroom.ts @@ -0,0 +1,101 @@ +import { WsClient } from "tsrpc-browser"; +import { MsgChat } from "./shared/protocols/MsgChat"; +import { serviceProto } from './shared/protocols/serviceProto'; + +export interface ChatroomProps { + title: string; +} + +export class Chatroom { + + elem: HTMLDivElement; + props: ChatroomProps; + + input: HTMLInputElement; + list: HTMLUListElement; + header: HTMLElement; + + client = new WsClient(serviceProto, { + server: 'ws://127.0.0.1:3000', + logger: console + }) + + constructor(elem: HTMLDivElement, props: ChatroomProps) { + this.elem = elem; + this.props = props; + + this.elem.innerHTML = ` +
      + +
      + + +
      `; + this.input = this.elem.querySelector('.send>input')!; + this.list = this.elem.querySelector('ul.list')!; + this.header = this.elem.querySelector('header')!; + + // Connect at startup + this.connect(); + + // Listen Msg + this.client.listenMsg('Chat', v => { this.onChatMsg(v) }) + + // Bind Event + this.elem.querySelector('button')!.onclick = () => { this.send() }; + this.input.onkeypress = e => { + if (e.key === 'Enter') { + this.send(); + } + } + + // When disconnected + this.client.flows.postDisconnectFlow.push(v => { + // Retry after 2 seconds + this.header.innerText = `🔴 Disconnected`; + setTimeout(() => { + this.connect(); + }, 2000) + return v; + }) + } + + async connect(): Promise { + this.header.innerText = `🟡 Connecting...`; + let res = await this.client.connect(); + if (!res.isSucc) { + this.header.innerText = `🔴 Disconnected`; + + // Retry after 2 seconds + await new Promise(rs => { setTimeout(rs, 2000) }); + await this.connect(); + } + + this.header.innerText = '🟢 ' + this.props.title; + } + + async send() { + let ret = await this.client.callApi('Send', { + content: this.input.value + }); + + // Error + if (!ret.isSucc) { + alert(ret.err.message); + return; + } + + // Success + this.input.value = ''; + } + + onChatMsg(msg: MsgChat) { + let li = document.createElement('li'); + li.innerHTML = `
      `; + (li.querySelector('.content') as HTMLDivElement).innerText = msg.content; + (li.querySelector('.time') as HTMLDivElement).innerText = msg.time.toLocaleTimeString(); + + this.list.appendChild(li); + this.list.scrollTo(0, this.list.scrollHeight); + } +} \ No newline at end of file diff --git a/examples/chatroom/frontend/src/index.ts b/examples/chatroom/frontend/src/index.ts new file mode 100644 index 0000000..223e2fe --- /dev/null +++ b/examples/chatroom/frontend/src/index.ts @@ -0,0 +1,9 @@ +import { Chatroom } from "./Chatroom"; + +document.querySelectorAll('.chat-room').forEach((v, i) => { + new Chatroom(v as HTMLDivElement, { + title: `Client #${i + 1}` + }); +}); + +export { }; diff --git a/examples/chatroom/frontend/src/shared/protocols/MsgChat.ts b/examples/chatroom/frontend/src/shared/protocols/MsgChat.ts new file mode 100644 index 0000000..4912efe --- /dev/null +++ b/examples/chatroom/frontend/src/shared/protocols/MsgChat.ts @@ -0,0 +1,4 @@ +export interface MsgChat { + content: string, + time: Date +} \ No newline at end of file diff --git a/examples/chatroom/frontend/src/shared/protocols/PtlSend.ts b/examples/chatroom/frontend/src/shared/protocols/PtlSend.ts new file mode 100644 index 0000000..ed2505d --- /dev/null +++ b/examples/chatroom/frontend/src/shared/protocols/PtlSend.ts @@ -0,0 +1,7 @@ +export interface ReqSend { + content: string +} + +export interface ResSend { + time: Date +} \ No newline at end of file diff --git a/examples/chatroom/frontend/src/shared/protocols/serviceProto.ts b/examples/chatroom/frontend/src/shared/protocols/serviceProto.ts new file mode 100644 index 0000000..d007337 --- /dev/null +++ b/examples/chatroom/frontend/src/shared/protocols/serviceProto.ts @@ -0,0 +1,75 @@ +import { ServiceProto } from 'tsrpc-proto'; +import { MsgChat } from './MsgChat'; +import { ReqSend, ResSend } from './PtlSend'; + +export interface ServiceType { + api: { + "Send": { + req: ReqSend, + res: ResSend + } + }, + msg: { + "Chat": MsgChat + } +} + +export const serviceProto: ServiceProto = { + "services": [ + { + "id": 0, + "name": "Chat", + "type": "msg" + }, + { + "id": 1, + "name": "Send", + "type": "api" + } + ], + "types": { + "MsgChat/MsgChat": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "content", + "type": { + "type": "String" + } + }, + { + "id": 1, + "name": "time", + "type": { + "type": "Date" + } + } + ] + }, + "PtlSend/ReqSend": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "content", + "type": { + "type": "String" + } + } + ] + }, + "PtlSend/ResSend": { + "type": "Interface", + "properties": [ + { + "id": 0, + "name": "time", + "type": { + "type": "Date" + } + } + ] + } + } +}; \ No newline at end of file diff --git a/examples/chatroom/frontend/tsconfig.json b/examples/chatroom/frontend/tsconfig.json new file mode 100644 index 0000000..7517bf5 --- /dev/null +++ b/examples/chatroom/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "es2018" + ], + "module": "esnext", + "target": "es2018", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "outDir": "dist", + "skipLibCheck": true, + "strict": true, + "jsx": "react-jsx", + "sourceMap": true, + "isolatedModules": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/examples/chatroom/frontend/webpack.config.js b/examples/chatroom/frontend/webpack.config.js new file mode 100644 index 0000000..062d1a4 --- /dev/null +++ b/examples/chatroom/frontend/webpack.config.js @@ -0,0 +1,58 @@ +const webpack = require('webpack'); +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); + +const isProduction = process.argv.indexOf('--mode=production') > -1; + +module.exports = { + entry: './src/index.ts', + output: { + filename: 'bundle.[contenthash].js', + path: path.resolve(__dirname, 'dist'), + clean: true + }, + resolve: { + extensions: ['.ts', '.tsx', '.js'] + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: [{ + loader: 'ts-loader', + options: { + compilerOptions: isProduction ? { + "lib": [ + "dom", + "es2015.promise" + ], + "target": "es5", + } : undefined + } + }], + exclude: /node_modules/ + }, + ] + }, + plugins: [ + // Copy "public" to "dist" + new CopyWebpackPlugin({ + patterns: [{ + from: 'public', + to: '.', + toType: 'dir', + globOptions: { + gitignore: true, + ignore: [path.resolve(__dirname, 'public/index.html').replace(/\\/g, '/')] + }, + noErrorOnMissing: true + }] + }), + // Auto add