diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f8144f0..683fd18 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -2,7 +2,7 @@ name: Deploy to Cloudflare Workers
on:
push:
- branches: [test]
+ branches: [test, main]
jobs:
deploy:
@@ -23,7 +23,15 @@ jobs:
pnpm install
cd frontend && pnpm install
- - name: Build frontend and deploy
+ - name: Build frontend and deploy (test)
+ if: github.ref == 'refs/heads/test'
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: pnpm run deploy:test
+
+ - name: Build frontend and deploy (production)
+ if: github.ref == 'refs/heads/main'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
diff --git a/README.md b/README.md
index 262cd1b..05ca117 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,13 @@
- [核心特性](#features)
- [架构说明](#architecture)
- [快速部署](#quick-start)
+ - [GitHub 绑定自动部署](#方式一通过-github-绑定自动部署推荐)
+ - [本地命令行部署](#方式二本地命令行部署)
+ - [配置 Turnstile](#可选配置-turnstile-人机验证)
+ - [配置 GitHub OAuth](#可选配置-github-oauth-登录与服务器管理)
- [开发说明](#development)
+ - [本地开发](#本地开发)
+ - [技术栈](#技术栈)
- [开源协议](#license)
@@ -152,10 +158,12 @@ flowchart TB
#### 方式一:通过 GitHub 绑定自动部署(推荐)
1. **Fork 本仓库** 到你的 GitHub 账号。
-2. **一键部署**:登录 Cloudflare,进入 Workers & Pages 绑定你的 GitHub 账号,选择刚才 Fork 的仓库进行应用创建。
-3. **填写构建命令**:在部署设置中,请务必将"构建命令"(Build command)填写为 `pnpm run build:frontend`,然后点击保存并部署(无需填写构建输出目录)。
+2. **创建 Worker 应用**:登录 Cloudflare,进入 Workers & Pages,点击创建应用,绑定你的 GitHub 账号,选择 Fork 的仓库。
+3. **填写构建命令**:在部署设置中,将"构建命令"(Build command)填写为 `pnpm run build:frontend`,点击保存并部署。
4. **访问应用**:部署成功后,可通过默认域名 `https://cloudssh.<你的子域>.workers.dev` 访问。
-5. **绑定自定义域名**(可选):在 Cloudflare Dashboard 的 Workers 设置中,进入 "Triggers" → "Custom Domains" 添加你的域名。
+5. **绑定自定义域名**(可选):进入 Worker 的 Settings → Domains & Routes → Add,输入你的域名并确认。
+
+> **说明**:如需部署 test 环境,可 Fork 后在 `test` 分支上重复上述步骤,创建独立的 Worker(如 `cloudssh-test`),两个 Worker 共享相同的 DO 类名即可实现数据互通。
#### 方式二:本地命令行部署
@@ -169,6 +177,7 @@ flowchart TB
```bash
npm install -g pnpm
pnpm install
+ cd frontend && pnpm install
```
3. **登录 Cloudflare**
@@ -176,14 +185,22 @@ flowchart TB
npx wrangler login
```
-4. **一键部署**
+4. **部署生产环境**
```bash
pnpm run deploy
```
-部署完成后,Wrangler 会输出你的 Worker URL。打开浏览器访问该 URL,即可开始使用你的 Web SSH 终端。
+5. **部署测试环境**(可选)
+ ```bash
+ pnpm run deploy:test
+ ```
-5. **绑定自定义域名**(可选):在 Cloudflare Dashboard 的 Workers 设置中,进入 "Triggers" → "Custom Domains" 添加你的域名。
+| 环境 | 命令 | 默认域名 | 说明 |
+|------|------|---------|------|
+| Production | `pnpm run deploy` | `cloudssh.<子域>.workers.dev` | main 分支代码 |
+| Test | `pnpm run deploy:test` | `cloudssh-test.<子域>.workers.dev` | test 分支代码,与生产环境共享 DO 数据 |
+
+> **说明**:两个环境的 Durable Objects 绑定相同的 class_name,数据完全共享。部署完成后可在 Cloudflare Dashboard 中分别绑定不同的自定义域名(Settings → Domains & Routes)。
#### 可选:配置 Turnstile 人机验证
@@ -242,21 +259,54 @@ CloudSSH/
### 本地开发
-1. **安装依赖**
+#### 环境准备
+
+1. **Fork 并克隆仓库**
+ ```bash
+ git clone https://github.com/<你的用户名>/CloudSSH.git
+ cd CloudSSH
+ ```
+
+2. **安装依赖**(需分别安装根目录和前端依赖)
```bash
pnpm install
+ cd frontend && pnpm install
```
-2. **启动开发服务器**
+3. **登录 Cloudflare**(首次需要,后续会缓存凭据)
```bash
- pnpm run dev
+ npx wrangler login
```
- 此命令将构建前端并启动 Wrangler 本地开发环境。
+ > **说明**:本地开发使用 Wrangler Dev 时,会连接到你的 Cloudflare 账号以使用 Durable Objects 和 TCP Sockets。SSH 连接的真实 TCP 流量会通过 Cloudflare 的基础设施转发。
-3. **仅构建前端**
- ```bash
- pnpm run build:frontend
- ```
+#### 启动开发服务器
+
+```bash
+pnpm run dev
+```
+
+此命令将构建前端并启动 Wrangler 本地开发环境,支持:
+- 前端代码变更自动重新构建
+- Worker 代码变更自动重新加载
+- 完整的 Durable Objects 和 TCP Sockets 功能
+
+开发服务器启动后,访问终端输出的本地地址(通常为 `http://localhost:8787`)即可进行调试。
+
+#### 常用开发命令
+
+| 命令 | 说明 |
+|------|------|
+| `pnpm run dev` | 构建前端 + 启动 Wrangler 开发服务器 |
+| `pnpm run build:frontend` | 仅构建前端(输出到 `frontend/dist/`) |
+| `pnpm test` | 运行测试 |
+
+#### 提交 PR 的流程
+
+1. 基于 `main` 分支创建你的特性分支:`git checkout -b feat/your-feature`
+2. 进行开发并本地测试
+3. 提交 PR 到 `main` 分支
+
+> **说明**:`test` 分支为预发布分支,用于部署测试环境,普通开发请基于 `main` 分支工作。
### 技术栈
diff --git a/README_en.md b/README_en.md
index d8558d1..1c4b7e7 100644
--- a/README_en.md
+++ b/README_en.md
@@ -39,7 +39,13 @@
- [Features](#features)
- [Architecture](#architecture)
- [Quick Deployment](#quick-start)
+ - [GitHub Integration](#method-1-deploy-via-github-integration-recommended)
+ - [Local CLI Deployment](#method-2-local-cli-deployment)
+ - [Configure Turnstile](#optional-configure-turnstile-human-verification)
+ - [Configure GitHub OAuth](#optional-configure-github-oauth-login--server-management)
- [Development](#development)
+ - [Local Development](#local-development)
+ - [Tech Stack](#tech-stack)
- [License](#license)
@@ -152,10 +158,12 @@ This project implements a complete SSH-2.0 protocol stack:
#### Method 1: Deploy via GitHub Integration (Recommended)
1. **Fork this repository** to your GitHub account.
-2. **Setup Deployment**: Log in to the Cloudflare dashboard, go to Workers & Pages to connect your GitHub account, and select the forked repository.
-3. **Build Command**: During the deployment configuration, make sure to enter `pnpm run build:frontend` as the Build command, then deploy with one click (the build output directory can be left blank).
-4. **Access the App**: After successful deployment, you can access it via the default domain `https://cloudssh..workers.dev`.
-5. **Bind Custom Domain** (Optional): In the Cloudflare Dashboard Workers settings, go to "Triggers" → "Custom Domains" to add your domain.
+2. **Create Worker App**: Log in to Cloudflare, go to Workers & Pages, click Create Application, connect your GitHub account, and select the forked repository.
+3. **Build Command**: During deployment settings, enter `pnpm run build:frontend` as the Build command, then save and deploy.
+4. **Access the App**: After successful deployment, access via the default domain `https://cloudssh..workers.dev`.
+5. **Bind Custom Domain** (Optional): Go to Worker Settings → Domains & Routes → Add, enter your domain and confirm.
+
+> **Note**: To deploy a test environment, repeat the above steps on the `test` branch to create a separate Worker (e.g., `cloudssh-test`). Both Workers share the same DO class names for data synchronization.
#### Method 2: Local CLI Deployment
@@ -169,6 +177,7 @@ This project implements a complete SSH-2.0 protocol stack:
```bash
npm install -g pnpm
pnpm install
+ cd frontend && pnpm install
```
3. **Login to Cloudflare**
@@ -176,14 +185,22 @@ This project implements a complete SSH-2.0 protocol stack:
npx wrangler login
```
-4. **One-Click Deploy**
+4. **Deploy Production**
```bash
pnpm run deploy
```
-Once deployed, Wrangler will output your Worker URL. Open that URL in your browser to start using your Web SSH terminal.
+5. **Deploy Test Environment** (Optional)
+ ```bash
+ pnpm run deploy:test
+ ```
-5. **Bind Custom Domain** (Optional): In the Cloudflare Dashboard Workers settings, go to "Triggers" → "Custom Domains" to add your domain.
+| Environment | Command | Default Domain | Description |
+|-------------|---------|---------------|-------------|
+| Production | `pnpm run deploy` | `cloudssh..workers.dev` | main branch code |
+| Test | `pnpm run deploy:test` | `cloudssh-test..workers.dev` | test branch code, shares DO data with production |
+
+> **Note**: Both environments bind to Durable Objects with the same `class_name`, sharing data completely. After deployment, you can bind different custom domains for each environment in the Cloudflare Dashboard (Settings → Domains & Routes).
#### Optional: Configure Turnstile Human Verification
@@ -242,21 +259,54 @@ CloudSSH/
### Local Development
-1. **Install Dependencies**
+#### Environment Setup
+
+1. **Fork and Clone the Repository**
+ ```bash
+ git clone https://github.com//CloudSSH.git
+ cd CloudSSH
+ ```
+
+2. **Install Dependencies** (root and frontend separately)
```bash
pnpm install
+ cd frontend && pnpm install
```
-2. **Start Development Server**
+3. **Login to Cloudflare** (required on first run, credentials are cached afterward)
```bash
- pnpm run dev
+ npx wrangler login
```
- This command builds the frontend and starts the Wrangler local development environment.
+ > **Note**: When using Wrangler Dev for local development, it connects to your Cloudflare account to access Durable Objects and TCP Sockets. Real SSH TCP traffic is forwarded through Cloudflare's infrastructure.
-3. **Build Frontend Only**
- ```bash
- pnpm run build:frontend
- ```
+#### Start Development Server
+
+```bash
+pnpm run dev
+```
+
+This command builds the frontend and starts the Wrangler local development environment, supporting:
+- Automatic rebuild on frontend code changes
+- Automatic reload on Worker code changes
+- Full Durable Objects and TCP Sockets functionality
+
+After the dev server starts, visit the local address shown in the terminal (usually `http://localhost:8787`) to start debugging.
+
+#### Common Development Commands
+
+| Command | Description |
+|---------|-------------|
+| `pnpm run dev` | Build frontend + start Wrangler dev server |
+| `pnpm run build:frontend` | Build frontend only (output to `frontend/dist/`) |
+| `pnpm test` | Run tests |
+
+#### Submitting a PR
+
+1. Create your feature branch from `main`: `git checkout -b feat/your-feature`
+2. Develop and test locally
+3. Submit a PR to the `main` branch
+
+> **Note**: The `test` branch is a pre-release branch for deploying the test environment. For regular development, work based on the `main` branch.
### Tech Stack
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index fd3f628..ec81a74 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -9,6 +9,10 @@ let connectionForm: ConnectionForm | null = null;
let serverList: ServerList | null = null;
let isLoggedIn = false;
+terminal.setSessionClosedHandler(() => {
+ showOfflineUI();
+});
+
// ==================== 独立终端标签页模式 ====================
function isTerminalTab(): boolean {
@@ -38,12 +42,6 @@ function initTerminalTab(): void {
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
terminal.connectWithWebSocket(ws);
-
- // 断开连接时关闭标签页
- document.getElementById('disconnect-btn')?.addEventListener('click', () => {
- terminal.disconnect();
- window.close();
- });
}
// ==================== 页面切换 ====================
@@ -81,6 +79,28 @@ function showUserSpace(user: { id: number; github_id: number; username: string;
);
}
+function showOfflineUI(): void {
+ if (isTerminalTab()) {
+ window.close();
+ return;
+ }
+
+ const termSection = document.getElementById('terminal-section');
+ if (termSection) {
+ termSection.classList.add('hidden');
+ termSection.classList.remove('flex');
+ }
+
+ if (isLoggedIn) {
+ document.getElementById('user-space-section')?.classList.remove('hidden');
+ document.getElementById('user-space-section')?.classList.add('flex');
+ } else {
+ showAuthSection();
+ }
+
+ document.getElementById('status-text')!.innerHTML = ' STATUS: OFFLINE';
+}
+
function showTerminalFromServer(wsUrl: string, serverName: string): void {
document.getElementById('auth-section')!.classList.add('hidden');
document.getElementById('user-space-section')!.classList.add('hidden');
@@ -105,20 +125,7 @@ function showTerminalFromServer(wsUrl: string, serverName: string): void {
document.getElementById('disconnect-btn')?.addEventListener('click', () => {
terminal.disconnect();
- const termSection = document.getElementById('terminal-section')!;
- termSection.classList.add('hidden');
- termSection.classList.remove('flex');
-
- if (isLoggedIn) {
- // 已登录用户回到用户空间
- document.getElementById('user-space-section')!.classList.remove('hidden');
- document.getElementById('user-space-section')!.classList.add('flex');
- } else {
- // 匿名用户回到连接表单
- document.getElementById('auth-section')!.classList.remove('hidden');
- }
-
- document.getElementById('status-text')!.innerHTML = ' STATUS: OFFLINE';
+ showOfflineUI();
});
// ==================== 主题切换 ====================
diff --git a/frontend/src/terminal.ts b/frontend/src/terminal.ts
index 949cd4b..dd97fa2 100644
--- a/frontend/src/terminal.ts
+++ b/frontend/src/terminal.ts
@@ -51,6 +51,7 @@ export class SSHTerminal {
private maxReconnectAttempts: number = 5;
private reconnectTimeout: ReturnType | null = null;
private lastConfig: SSHConnectionConfig | null = null;
+ private onSessionClosed?: (event: CloseEvent) => void;
constructor(containerId: string) {
this.container = document.getElementById(containerId)!;
@@ -104,6 +105,10 @@ export class SSHTerminal {
this.terminal.options.theme = THEMES[themeName];
}
+ setSessionClosedHandler(handler: (event: CloseEvent) => void): void {
+ this.onSessionClosed = handler;
+ }
+
mount(): void {
this.terminal.open(this.container);
@@ -152,6 +157,7 @@ export class SSHTerminal {
password: config.password,
authMethod: config.authMethod,
privateKey: config.privateKey,
+ ...this.getTerminalSize(),
}));
this.startHeartbeat();
@@ -188,14 +194,20 @@ export class SSHTerminal {
ws.onopen = () => {
this.terminal.writeln('\x1b[32m[+] WebSocket connected, authenticating...\x1b[0m');
+ this.sendResize();
this.startHeartbeat();
};
+ if (ws.readyState === WebSocket.OPEN) {
+ this.sendResize();
+ }
+
this.setupWebSocketHandlers();
}
private setupWebSocketHandlers(rejectFn?: (reason?: any) => void): void {
if (!this.ws) return;
+ const socket = this.ws;
// Trzsz file transfer support
this.trzszFilter = new TrzszFilter({
@@ -234,6 +246,9 @@ export class SSHTerminal {
case 'error':
this.terminal.writeln(`\x1b[31m[!] ${msg.message}\x1b[0m`);
break;
+ case 'debug':
+ this.terminal.writeln(`\x1b[90m[DEBUG] ${msg.message}\x1b[0m`);
+ break;
case 'pong':
break;
}
@@ -248,6 +263,8 @@ export class SSHTerminal {
};
this.ws.onclose = (event) => {
+ if (socket !== this.ws) return;
+
this.stopHeartbeat();
this.terminal.writeln(
`\x1b[33m[*] Connection closed (code=${event.code})\x1b[0m`
@@ -257,7 +274,12 @@ export class SSHTerminal {
const statusText = document.getElementById('status-text');
if (statusText) statusText.innerHTML = ' STATUS: OFFLINE';
- if (event.code !== 1000 && this.lastConfig && this.reconnectAttempts < this.maxReconnectAttempts) {
+ if (event.code === 1000) {
+ this.onSessionClosed?.(event);
+ return;
+ }
+
+ if (this.lastConfig && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
}
};
@@ -284,13 +306,7 @@ export class SSHTerminal {
// Terminal resize: send to server + update trzsz column count
this.disposables.push(
this.terminal.onResize(({ cols, rows }) => {
- if (this.ws?.readyState === WebSocket.OPEN) {
- this.ws.send(JSON.stringify({
- type: 'resize',
- cols,
- rows,
- }));
- }
+ this.sendResize({ cols, rows });
this.trzszFilter?.setTerminalColumns(cols);
})
);
@@ -309,6 +325,22 @@ export class SSHTerminal {
}, 30000);
}
+ private getTerminalSize(): { cols: number; rows: number } {
+ return {
+ cols: this.terminal.cols,
+ rows: this.terminal.rows,
+ };
+ }
+
+ private sendResize(size = this.getTerminalSize()): void {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify({
+ type: 'resize',
+ ...size,
+ }));
+ }
+ }
+
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
@@ -333,11 +365,13 @@ export class SSHTerminal {
this.clearReconnectTimeout();
this.disposeConnectionDisposables();
- if (this.ws && this.ws.readyState !== WebSocket.CLOSED && this.ws.readyState !== WebSocket.CLOSING) {
- this.ws.close(1000);
- }
+ const socket = this.ws;
this.ws = null;
this.trzszFilter = null;
+
+ if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
+ socket.close(1000);
+ }
}
private scheduleReconnect(): void {
diff --git a/package.json b/package.json
index 4f3c407..e4dc2e6 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "node scripts/build-html.js && wrangler dev",
"deploy": "node scripts/build-html.js && wrangler deploy",
+ "deploy:test": "node scripts/build-html.js && wrangler deploy --env test",
"build:frontend": "node scripts/build-html.js",
"test": "vitest run"
},
diff --git a/src/types.ts b/src/types.ts
index 2d4ac6f..a1a7ecc 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -46,6 +46,13 @@ export interface SSHConnectionConfig {
password: string;
authMethod?: 'password' | 'publickey';
privateKey?: string;
+ cols?: number;
+ rows?: number;
+}
+
+export interface TerminalSize {
+ cols: number;
+ rows: number;
}
export interface Env {
@@ -62,6 +69,8 @@ export interface Env {
BASE_URL?: string;
// 主机密钥验证严格模式(默认 true,设为 false 可跳过签名验证失败)
STRICT_HOST_KEY_VERIFY?: string;
+ // 调试模式(设为 true 启用调试日志输出到前端)
+ DEBUG_MODE?: string;
}
export interface UserInfo {
diff --git a/src/worker/durable-object.ts b/src/worker/durable-object.ts
index dcecb9b..fe262a0 100644
--- a/src/worker/durable-object.ts
+++ b/src/worker/durable-object.ts
@@ -1,4 +1,4 @@
-import { Env, SSHConnectionConfig } from '../types';
+import { Env, SSHConnectionConfig, TerminalSize } from '../types';
import { SSHSession } from './ssh-session';
/**
@@ -38,6 +38,7 @@ export class SSHSessionDO {
private env: Env;
private sessions: Map = new Map();
private _pendingTimeouts: Map> = new Map();
+ private pendingTerminalSizes: Map = new Map();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
@@ -105,26 +106,43 @@ export class SSHSessionDO {
return;
}
+ if (typeof message !== 'string') {
+ return;
+ }
+
+ let msg: any;
+ try {
+ msg = JSON.parse(message);
+ } catch {
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid credentials format' }));
+ ws.close(1011, 'Invalid format');
+ return;
+ }
+
+ if (msg.type === 'resize') {
+ this.rememberTerminalSize(ws, msg.cols, msg.rows);
+ return;
+ }
+ if (msg.type === 'ping') {
+ ws.send(JSON.stringify({ type: 'pong' }));
+ return;
+ }
+
const timeout = this._pendingTimeouts.get(ws);
if (timeout) {
clearTimeout(timeout);
this._pendingTimeouts.delete(ws);
}
- try {
- const config = JSON.parse(message as string) as SSHConnectionConfig;
+ const config = msg as SSHConnectionConfig;
- if (!config.host || !config.username || (!config.password && !config.privateKey)) {
- ws.send(JSON.stringify({ type: 'error', message: 'Missing credentials' }));
- ws.close(1011, 'Invalid credentials');
- return;
- }
-
- await this.initSSHSession(ws, config);
- } catch (e) {
- ws.send(JSON.stringify({ type: 'error', message: 'Invalid credentials format' }));
- ws.close(1011, 'Invalid format');
+ if (!config.host || !config.username || (!config.password && !config.privateKey)) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Missing credentials' }));
+ ws.close(1011, 'Invalid credentials');
+ return;
}
+
+ await this.initSSHSession(ws, config);
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise {
@@ -138,6 +156,7 @@ export class SSHSessionDO {
clearTimeout(timeout);
this._pendingTimeouts.delete(ws);
}
+ this.pendingTerminalSizes.delete(ws);
}
async webSocketError(ws: WebSocket, error: unknown): Promise {
@@ -165,18 +184,51 @@ export class SSHSessionDO {
await socket.opened;
const strictVerify = this.env.STRICT_HOST_KEY_VERIFY !== 'false';
- const session = new SSHSession(ws, socket, config, strictVerify);
+ const debugMode = this.env.DEBUG_MODE === 'true';
+ const pendingSize = this.pendingTerminalSizes.get(ws);
+ if (pendingSize) {
+ config.cols = pendingSize.cols;
+ config.rows = pendingSize.rows;
+ }
+ const session = new SSHSession(ws, socket, config, strictVerify, debugMode);
this.sessions.set(ws, session);
+ this.pendingTerminalSizes.delete(ws);
await session.startHandshake();
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
- console.error('[SSH] Session error:', errMsg);
try {
ws.send(JSON.stringify({ type: 'error', message: `连接失败: ${errMsg}` }));
ws.close(1011, 'SSH connection failed');
} catch {}
}
}
+
+ private rememberTerminalSize(ws: WebSocket, cols: unknown, rows: unknown): void {
+ const size = this.normalizeTerminalSize(cols, rows);
+ if (size) this.pendingTerminalSizes.set(ws, size);
+ }
+
+ private normalizeTerminalSize(cols: unknown, rows: unknown): TerminalSize | null {
+ if (
+ typeof cols !== 'number' ||
+ typeof rows !== 'number' ||
+ !Number.isFinite(cols) ||
+ !Number.isFinite(rows)
+ ) {
+ return null;
+ }
+
+ const size = {
+ cols: Math.floor(cols),
+ rows: Math.floor(rows),
+ };
+
+ if (size.cols < 10 || size.cols > 1000 || size.rows < 5 || size.rows > 1000) {
+ return null;
+ }
+
+ return size;
+ }
}
diff --git a/src/worker/html.ts b/src/worker/html.ts
index 8a15870..54cc15f 100644
--- a/src/worker/html.ts
+++ b/src/worker/html.ts
@@ -41,15 +41,15 @@ export const HTML = `
}
-