Skip to content

Commit

Permalink
Merge pull request #56 from warjiang/feature/turborepo
Browse files Browse the repository at this point in the history
Feature/turborepo
karmada-bot authored Aug 26, 2024
2 parents b6d614b + eb5e07e commit e26300e
Showing 39 changed files with 3,039 additions and 65 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -26,3 +26,4 @@ _output
# sub chart tgz
charts/*/charts
cmd/ops
.turbo
8 changes: 8 additions & 0 deletions ui/.changeset/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Changesets

Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)

We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
11 changes: 11 additions & 0 deletions ui/.changeset/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
4 changes: 2 additions & 2 deletions ui/.gitignore
Original file line number Diff line number Diff line change
@@ -3,12 +3,12 @@ node_modules/

# Dist
.dist/

**/*/dist
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

apps/demos
15 changes: 12 additions & 3 deletions ui/package.json
Original file line number Diff line number Diff line change
@@ -4,8 +4,9 @@
"description": "",
"main": "index.js",
"scripts": {
"dashboard:dev": "cd apps/dashboard && pnpm run dev",
"dashboard:build": "cd apps/dashboard && pnpm run build",
"dashboard:dev": "turbo dev --filter @karmada/dashboard",
"demos:dev": "turbo dev --filter @karmada/demos",
"dashboard:build": "turbo build --filter @karmada/dashboard",
"prepare": "cd .. && husky install ui/.husky"
},
"engines": {
@@ -20,13 +21,21 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@changesets/cli": "^2.27.6",
"@types/node": "^20.12.11",
"husky": "^8.0.0",
"lint-staged": "^15.2.7",
"tsup": "^8.1.0",
"tsx": "^4.15.6"
"tsx": "^4.15.6",
"turbo": "^2.0.6"
},
"dependencies": {
"dayjs": "^1.11.11"
},
"packageManager": "pnpm@9.1.2",
"pnpm": {
"patchedDependencies": {
"zmodem.js@0.1.10": "patches/zmodem.js@0.1.10.patch"
}
}
}
5 changes: 5 additions & 0 deletions ui/packages/terminal/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
build
coverage
dist
pnpm-*
7 changes: 7 additions & 0 deletions ui/packages/terminal/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"trailingComma": "all",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"endOfLine": "auto"
}
28 changes: 28 additions & 0 deletions ui/packages/terminal/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# @karmada/terminal

## 1.0.4

### Patch Changes

- update declare file for terminal

## 1.0.3

### Major Changes

- export typings of terminal

## 1.0.2

### Patch Changes

- add container terminal for k8s pod

## 1.0.1

### Patch Changes

- add ttyd terminal
- Updated dependencies
- @karmada/xterm-addon-overlay@1.0.1
- @karmada/xterm-addon-zmodem@1.0.1
58 changes: 58 additions & 0 deletions ui/packages/terminal/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@karmada/terminal",
"version": "1.0.4",
"publishConfig": {
"access": "public"
},
"description": "",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
},
"types": "dist/index.d.ts",
"scripts": {
"prepublish": "tsup --config ./tsup.config.ts",
"build": "tsup --config ./tsup.config.ts"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write"
]
},
"keywords": [
"terminal",
"xterm",
"xterm.js"
],
"author": "",
"license": "ISC",
"dependencies": {
"@karmada/xterm-addon-overlay": "workspace:*",
"@karmada/xterm-addon-zmodem": "workspace:*",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-ligatures": "^0.9.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.0.0",
"debug": "^4.3.5",
"lodash.isempty": "^4.4.0",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/lodash.isempty": "^4.4.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/sockjs-client": "^1.5.4"
},
"peerDependencies": {
"react": "^18",
"react-dom": "^18"
}
}
208 changes: 208 additions & 0 deletions ui/packages/terminal/src/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { SearchAddon } from '@xterm/addon-search';
import { CanvasAddon } from '@xterm/addon-canvas';
import { WebglAddon } from '@xterm/addon-webgl';
import isEmpty from 'lodash.isempty';
import { addEventListener, getDebugger } from './utils.ts';
import {
AddonInfo,
AddonType,
innerTerminal,
RendererType,
BaseTerminalOptions,
} from './typing';

const log = getDebugger('BaseTerminal');

class BaseTerminal implements IDisposable {
// terminal is a xterm instance, use inherited type for add method on xterm instance
protected terminal!: innerTerminal;
// use `isXtermOpened` flag avoid create xterm instance multiple times
protected isXtermOpened: boolean = false;
/*
* baseTerminalOptions consists of clientOptions and xtermOptions
* clientOptions: custom options for terminal
* xtermOptions: options for xterm
*/
protected baseTerminalOptions: BaseTerminalOptions;
// disposables used for store clean function for resources
private disposables: IDisposable[] = [];
// addons store the addons of xterm
protected addons = {} as Record<AddonType, AddonInfo>;

constructor(options: BaseTerminalOptions) {
this.baseTerminalOptions = options;
this.addons = {
...this.addons,
fit: {
name: 'fit',
ctor: new FitAddon(),
},
webLinks: {
name: 'webLinks',
ctor: new WebLinksAddon(),
},
search: {
name: 'search',
ctor: new SearchAddon(),
},
};
}

public register = <T extends IDisposable>(...disposables: T[]): T[] => {
for (const disposable of disposables) {
this.disposables.push(disposable);
}
return disposables;
};

private initListeners() {
this.register(
addEventListener(window, 'resize', () =>
this.mustGetAddon<FitAddon>('fit').fit(),
),
);
}

public dispose = () => {
for (const d of this.disposables) {
d.dispose();
}
this.disposables.length = 0;
};

public setRendererType(value: RendererType) {
log('setRendererType', value);
switch (value) {
case 'canvas':
this.deleteAddon('webgl');
this.setAddon('canvas', new CanvasAddon());
log('[ttyd] canvas renderer loaded');
break;
case 'webgl':
this.deleteAddon('canvas');
this.setAddon('webgl', new WebglAddon());
log('[ttyd] WebGL renderer loaded');
break;
case 'dom':
this.deleteAddon('webgl');
this.deleteAddon('canvas');
log('[ttyd] dom renderer loaded');
break;
default:
break;
}
}

/**
* return the addon accoring to addon name, but the function doesn't ensure
* you can get the addon as expected, if the addon exist, it will return the addon,
* otherwise you'll get null
* @param name name of the addon
* */
public getAddon = <T extends ITerminalAddon>(name: AddonType): T | null => {
if (name in this.addons) {
return this.addons[name].ctor as unknown as T;
}
return null;
};

/**
* mustGetAddon will ensure you get the addon as expected, if the addon not exist, it
* will throw an expection.
* @param name name of the addon
*/
public mustGetAddon = <T extends ITerminalAddon>(name: AddonType): T => {
if (name in this.addons) {
return this.addons[name].ctor as unknown as T;
}
throw Error(`Cannot find addon[${name}]`);
};

/**
* setAddon will store the addon into addons, and make the terminal load addon.
* If you setAddon multiple times, it will first invoke the dipose method of addon
* @param name name of the addon
* @param addon addon for xterm
*/
protected setAddon = <T extends ITerminalAddon>(
name: AddonType,
addon: T,
) => {
this.getAddon(name)?.dispose();
this.addons[name] = {
name: name,
ctor: addon,
};
this.terminal.loadAddon(addon);
};

/**
* deleteAddon will remove the addon from addons, and inovke dipose method of addon.
* @param name name of the addon
*/
protected deleteAddon = (name: AddonType) => {
const addon = this.getAddon(name);
if (addon) {
addon.dispose();
delete this.addons[name];
}
};

/**
* write support write a bunch of data into terminal, it will split input data
* by '\n' and iterate the return lines by writing line to the terminal
* @param data
*/
public write = (data: string) => {
if (isEmpty(data)) return;
data.split('\n').map((line) => {
this.terminal.writeln(line);
});
};

/**
* open method is used to initialize the BaseTerminal instance, inside the open
* method, we will pass the containerElement to initialize the xterm, loading build-in
* addons and init esssential listeners for the xterm
* @param containerElement dom element which is used to initialize Terminal
*/
public open = (containerElement: HTMLElement) => {
if (this.isXtermOpened) return;
log('execute open method');
log('termOptions', this.baseTerminalOptions.xtermOptions);
if (this.terminal) {
log('terminal already exists, dispose it first');
this.terminal.dispose();
// this.dispose();
}

const terminal = new Terminal(
this.baseTerminalOptions.xtermOptions,
) as innerTerminal;
this.terminal = terminal;
terminal.fit = () => {
this.mustGetAddon<FitAddon>('fit').fit();
};
for (const name in this.addons) {
log('load addon =>', name);
terminal.loadAddon(this.addons[name as AddonType].ctor);
}
terminal.open(containerElement);
this.setRendererType(this.baseTerminalOptions.clientOptions.rendererType);
this.initListeners();
terminal.fit();
this.isXtermOpened = true;
};

/**
* return the terminal object for some special use-cases
*/
public getTerminal = () => {
return this.terminal;
};
}

export default BaseTerminal;
159 changes: 159 additions & 0 deletions ui/packages/terminal/src/container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import SockJS from 'sockjs-client/dist/sockjs';
import BaseTerminal from './base.ts';
import { BaseTerminalOptions } from './typing';
import { getDebugger } from './utils.ts';

interface ContainerTerminalOptions {
namespace: string;
pod: string;
container: string;
sessionIdUrl: string;
}

export interface SockJSSimpleEvent {
type: string;

toString(): string;
}

export interface SJSCloseEvent extends SockJSSimpleEvent {
code: number;
reason: string;
wasClean: boolean;
}

export interface SJSMessageEvent extends SockJSSimpleEvent {
data: string;
}

export interface ShellFrame {
Op: string;
Data?: string;
SessionID?: string;
Rows?: number;
Cols?: number;
}

const log = getDebugger('ContainerTerminal');

class ContainerTerminal extends BaseTerminal {
private containerOptions: ContainerTerminalOptions;
private sessionId!: string;
private socket!: WebSocket;
private connected: boolean = false;
private connecting: boolean = false;
// @ts-ignore
private connectionClosed: boolean = true;

constructor(
options: BaseTerminalOptions,
containerOptions: ContainerTerminalOptions,
) {
super(options);
this.containerOptions = containerOptions;
}

public getSessionId = async () => {
try {
const url = this.containerOptions.sessionIdUrl;
const replacedUrl = url
.replace('{{namespace}}', this.containerOptions.namespace)
.replace('{{pod}}', this.containerOptions.pod)
.replace('{{container}}', this.containerOptions.container);
log(`request url: ${replacedUrl}`);
const resp = await fetch(replacedUrl);
if (resp.ok) {
const json = await resp.json();
this.sessionId = json.data.id;
}
} catch (e) {
log(`[ttyd] fetch ${this.containerOptions.sessionIdUrl}: `, e);
}
};

private initContainerListeners = () => {
const { terminal } = this;
this.register(
terminal.onData(this.onTerminalSendString.bind(this)),
terminal.onResize(this.onTerminalResize.bind(this)),
);
};

public connect = () => {
if (this.connected || this.connecting) return;
this.connecting = true;
this.connectionClosed = false;

this.socket = new SockJS(`/api/sockjs?${this.sessionId}`);
const { socket } = this;
socket.onopen = this.onConnectionOpen.bind(this, this.sessionId);
socket.onmessage = this.onConnectionMessage.bind(this);
socket.onclose = this.onConnectionClose.bind(this);
};

private onConnectionOpen = (sessionId: string) => {
const startData = { Op: 'bind', SessionID: sessionId };
this.socket.send(JSON.stringify(startData));
this.connected = true;
this.connecting = false;
this.connectionClosed = false;

// Make sure the terminal is with correct display size.
this.onTerminalResize();

this.initContainerListeners();

// Focus on connection
this.terminal.focus();
};

private onConnectionMessage = (evt: SJSMessageEvent) => {
const msg = JSON.parse(evt.data);
this.handleConnectionMessage(msg);
};

private handleConnectionMessage = (frame: ShellFrame) => {
if (frame.Op === 'stdout') {
this.terminal.write(frame.Data || '');
}
if (frame.Op === 'toast') {
}
};

private onConnectionClose = (_evt?: SJSCloseEvent) => {
if (!this.connected) {
return;
}
this.socket.close();
this.connected = false;
this.connecting = false;
this.connectionClosed = true;
};

private onTerminalResize(): void {
if (this.connected) {
this.socket.send(
JSON.stringify({
Op: 'resize',
Cols: this.terminal.cols,
Rows: this.terminal.rows,
}),
);
}
}

private onTerminalSendString = (str: string) => {
if (this.connected) {
this.socket.send(
JSON.stringify({
Op: 'stdin',
Data: str,
Cols: this.terminal.cols,
Rows: this.terminal.rows,
}),
);
}
};
}

export default ContainerTerminal;
21 changes: 21 additions & 0 deletions ui/packages/terminal/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext } from 'react';
import BaseTerminal from './base.ts';
import '@xterm/xterm/css/xterm.css';

export const TerminalContext = createContext<{
terminal: BaseTerminal | null;
}>({
terminal: null,
});
export { default as ContainerTerminal } from './container';
export { default as TtydTerminal } from './ttyd';
export type { FlowControl, Preferences, Command } from './ttyd';
export { default as BaseTerminal } from './base';
export type { ITerminalOptions, ITheme } from '@xterm/xterm';
export type {
AddonType,
AddonInfo,
ClientOptions,
BaseTerminalOptions,
RendererType,
} from './typing.d.ts';
390 changes: 390 additions & 0 deletions ui/packages/terminal/src/ttyd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
import type { ITerminalOptions } from '@xterm/xterm';
import { addEventListener, getDebugger } from './utils';
import BaseTerminal from './base.ts';
import { BaseTerminalOptions, ClientOptions } from './typing';
import OverlayAddon from '@karmada/xterm-addon-overlay';
import ZmodemAddon from '@karmada/xterm-addon-zmodem';

export type Preferences = ITerminalOptions & ClientOptions;

const log = getDebugger('TtydTerminal');

interface TtydTerminalOptions {
wsUrl: string;
tokenUrl: string;
flowControl: FlowControl;
}

export interface FlowControl {
limit: number;
highWater: number;
lowWater: number;
}

export enum Command {
// server side
OUTPUT = '0',
SET_WINDOW_TITLE = '1',
SET_PREFERENCES = '2',

/* eslint-disable @typescript-eslint/no-duplicate-enum-values */
// client side
INPUT = '0',
RESIZE_TERMINAL = '1',
PAUSE = '2',
RESUME = '3',
/* eslint-enable @typescript-eslint/no-duplicate-enum-values */
}

class TtydTerminal extends BaseTerminal {
private ttydTerminalOptions: TtydTerminalOptions;
private socket!: WebSocket;
private opened = false;
private reconnect = true;
private doReconnect = true;
private token: string = '';
private textEncoder = new TextEncoder();
private textDecoder = new TextDecoder();

private written = 0;
private pending = 0;

private title?: string;
private titleFixed?: string;
private resizeOverlay = true;
private sendCb = () => {};

constructor(options: BaseTerminalOptions, ttydOptions: TtydTerminalOptions) {
super(options);
this.ttydTerminalOptions = ttydOptions;
this.addons = {
...this.addons,
overlay: {
name: 'overlay',
ctor: new OverlayAddon(),
},
};
}

private writeFunc = (data: ArrayBuffer) => {
return this.writeData(new Uint8Array(data));
};

public writeData = (data: string | Uint8Array) => {
const { terminal, textEncoder } = this;
if (!terminal) return;
const { limit, highWater, lowWater } = this.ttydTerminalOptions.flowControl;

this.written += data.length;
if (this.written > limit) {
terminal.write(data, () => {
this.pending = Math.max(this.pending - 1, 0);
if (this.pending < lowWater) {
this.socket.send(textEncoder.encode(Command.RESUME));
}
});
this.pending++;
this.written = 0;
if (this.pending > highWater) {
this.socket.send(textEncoder.encode(Command.PAUSE));
}
} else {
terminal.write(data);
}
};

public sendData = (data: string | Uint8Array) => {
const { socket, textEncoder } = this;
if (socket.readyState !== WebSocket.OPEN) return;

if (typeof data === 'string') {
const payload = new Uint8Array(data.length * 3 + 1);
payload[0] = Command.INPUT.charCodeAt(0);
const stats = textEncoder.encodeInto(data, payload.subarray(1));
socket.send(payload.subarray(0, (stats.written as number) + 1));
} else {
const payload = new Uint8Array(data.length + 1);
payload[0] = Command.INPUT.charCodeAt(0);
payload.set(data, 1);
socket.send(payload);
}
};

public refreshToken = async () => {
try {
const resp = await fetch(this.ttydTerminalOptions.tokenUrl);
if (resp.ok) {
const json = await resp.json();
this.token = json.token;
}
} catch (e) {
log(`[ttyd] fetch ${this.ttydTerminalOptions.tokenUrl}: `, e);
}
};

private parseOptsFromUrlQuery = (query: string): Preferences => {
const { terminal } = this;
const clientOptions = this.baseTerminalOptions
.clientOptions as unknown as Record<string, string>;
const prefs = {} as Record<string, boolean | number | string>;
const queryObj = Array.from(
new URLSearchParams(query) as unknown as Iterable<[string, string]>,
);

for (const [k, queryVal] of queryObj) {
let v = clientOptions[k];
if (v === undefined) v = (terminal?.options as Record<string, string>)[k];
switch (typeof v) {
case 'boolean':
prefs[k] = queryVal === 'true' || queryVal === '1';
break;
case 'number':
case 'bigint':
prefs[k] = Number.parseInt(queryVal, 10);
break;
case 'string':
prefs[k] = queryVal;
break;
case 'object':
prefs[k] = JSON.parse(queryVal);
break;
default:
console.warn(
`[ttyd] maybe unknown option: ${k}=${queryVal}, treating as string`,
);
prefs[k] = queryVal;
break;
}
}

return prefs as unknown as Preferences;
};

private initTtydListeners = () => {
const { terminal } = this;
this.register(
terminal.onTitleChange((data) => {
if (data && data !== '' && !this.titleFixed) {
document.title = data + ' | ' + this.title;
}
}),
terminal.onData((data) => this.sendData(data)),
terminal.onResize(({ cols, rows }) => {
const msg = JSON.stringify({ columns: cols, rows: rows });
this.socket.send(
this.textEncoder.encode(Command.RESIZE_TERMINAL + msg),
);
if (this.resizeOverlay) {
this.getAddon<OverlayAddon>('overlay')?.showOverlay(
`${cols}x${rows}`,
300,
);
}
}),
terminal.onSelectionChange(() => {
if (terminal.getSelection() === '') return;
try {
document.execCommand('copy');
} catch (e) {
return;
}
this.getAddon<OverlayAddon>('overlay')?.showOverlay('\u2702', 200);
}),
addEventListener(window, 'beforeunload', this.onWindowUnload),
);
};

public connect = () => {
this.socket = new WebSocket(this.ttydTerminalOptions.wsUrl, ['tty']);
const { socket, register } = this;
socket.binaryType = 'arraybuffer';
register(
addEventListener(socket, 'open', this.onSocketOpen),
addEventListener(socket, 'message', this.onSocketData as EventListener),
addEventListener(socket, 'close', this.onSocketClose as EventListener),
addEventListener(socket, 'error', () => (this.doReconnect = false)),
);
};

private onSocketOpen = () => {
const { terminal, socket, textEncoder } = this;
if (socket.readyState != 1) {
return;
}
log('[ttyd] websocket connection opened', socket.readyState);

const msg = JSON.stringify({
AuthToken: this.token,
columns: terminal.cols,
rows: terminal.rows,
});
socket.send(textEncoder.encode(msg));

if (this.opened && terminal) {
terminal.reset();
terminal.options.disableStdin = false;
} else {
this.opened = true;
}

this.doReconnect = this.reconnect;
this.initTtydListeners();
terminal.focus();
};
private onSocketClose = (event: CloseEvent) => {
log(`[ttyd] websocket connection closed with code: ${event.code}`);
const { refreshToken, connect, doReconnect, terminal } = this;
this.dispose();

// 1000: CLOSE_NORMAL
if (event.code !== 1000 && doReconnect) {
refreshToken().then(connect);
} else {
const keyDispose = terminal.onKey((e) => {
const event = e.domEvent;
if (event.key === 'Enter') {
keyDispose.dispose();
refreshToken().then(connect);
}
});
}
};
private onSocketData = (event: MessageEvent) => {
const { textDecoder } = this;
const rawData = event.data as ArrayBuffer;
const cmd = String.fromCharCode(new Uint8Array(rawData)[0]);
const data = rawData.slice(1);

switch (cmd) {
case Command.OUTPUT:
this.writeFunc(data);
break;
case Command.SET_WINDOW_TITLE:
this.title = textDecoder.decode(data);
document.title = this.title;
break;
case Command.SET_PREFERENCES:
this.applyPreferences({
...this.baseTerminalOptions.clientOptions,
...JSON.parse(textDecoder.decode(data)),
...this.parseOptsFromUrlQuery(window.location.search),
} as Preferences);
break;
default:
log(`[ttyd] unknown command: ${cmd}`);
break;
}
};
private onWindowUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
if (this.socket.readyState === WebSocket.OPEN) {
return 'Close terminal? this will also terminate the command.';
}
return undefined;
};

public applyPreferences = (prefs: Preferences) => {
log('preferences', prefs);
const { terminal } = this;
if (prefs.enableZmodem || prefs.enableTrzsz) {
const zmodemAddon = new ZmodemAddon({
zmodem: prefs.enableZmodem,
trzsz: prefs.enableTrzsz,
windows: prefs.isWindows,
trzszDragInitTimeout: prefs.trzszDragInitTimeout,
onSend: this.sendCb,
sender: this.sendData,
writer: this.writeData,
});
this.writeFunc = (data) => {
return this.getAddon<ZmodemAddon>('zmodem')?.consume(data);
};
this.setAddon('zmodem', zmodemAddon);
}
const opts = {
...(terminal?.options as Record<string, unknown>),
};
delete opts['cols'];
delete opts['rows'];
for (const [key, value] of Object.entries(prefs)) {
switch (key) {
case 'rendererType':
this.setRendererType(value);
break;
// case 'disableLeaveAlert':
// if (value) {
// window.removeEventListener('beforeunload', this.onWindowUnload);
// log('[ttyd] Leave site alert disabled');
// }
// break;
// case 'disableResizeOverlay':
// if (value) {
// log('[ttyd] Resize overlay disabled');
// this.resizeOverlay = false;
// }
// break;
// case 'disableReconnect':
// if (value) {
// log('[ttyd] Reconnect disabled');
// this.reconnect = false;
// this.doReconnect = false;
// }
// break;
case 'enableZmodem':
if (value) log('[ttyd] Zmodem enabled');
break;
case 'enableTrzsz':
if (value) log('[ttyd] trzsz enabled');
break;
case 'trzszDragInitTimeout':
if (value) log(`[ttyd] trzsz drag init timeout: ${value}`);
break;
// case 'enableSixel':
// if (value) {
// terminal?.loadAddon(register(new ImageAddon()));
// log('[ttyd] Sixel enabled');
// }
// break;
// case 'titleFixed':
// if (!value || value === '') return;
// log(`[ttyd] setting fixed title: ${value}`);
// this.titleFixed = value;
// document.title = value;
// break;
case 'isWindows':
if (value) log('[ttyd] is windows');
break;
// case 'unicodeVersion':
// switch (value) {
// case 6:
// case '6':
// log('[ttyd] setting Unicode version: 6');
// break;
// case 11:
// case '11':
// default:
// log('[ttyd] setting Unicode version: 11');
// terminal?.loadAddon(new Unicode11Addon());
// terminal && (terminal.unicode.activeVersion = '11');
// break;
// }
// break;
default:
log(`[ttyd] option: ${key}=${JSON.stringify(value)}`);
if (opts[key] instanceof Object) {
opts[key] = Object.assign({}, opts[key], value);
} else {
opts[key] = value;
}
terminal && (terminal.options = opts);

if (key.indexOf('font') === 0) {
terminal.fit();
}
break;
}
}
};
}

export default TtydTerminal;
42 changes: 42 additions & 0 deletions ui/packages/terminal/src/typing.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ITerminalAddon, type ITerminalOptions, Terminal } from '@xterm/xterm';

export interface innerTerminal extends Terminal {
fit(): void;
}

// AddonType should sync with `cat package.json|grep @xterm/addon-`
export type AddonType =
| 'webgl'
| 'canvas'
| 'fit'
| 'clipboard'
| 'search'
| 'webLinks'
| 'unicode11'
| 'ligatures'
| 'overlay'
| 'zmodem';

export interface AddonInfo {
name: string;
ctor: ITerminalAddon;
}

export interface ClientOptions {
rendererType: RendererType;
disableLeaveAlert: boolean;
disableResizeOverlay: boolean;
enableZmodem: boolean;
enableSixel: boolean;
enableTrzsz: boolean;
trzszDragInitTimeout: number;
isWindows: boolean;
unicodeVersion: string;
}

export interface BaseTerminalOptions {
clientOptions: ClientOptions;
xtermOptions: ITerminalOptions;
}

export type RendererType = 'dom' | 'canvas' | 'webgl';
25 changes: 25 additions & 0 deletions ui/packages/terminal/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { IDisposable } from '@xterm/xterm';
import debug from 'debug';

export function addEventListener(
target: EventTarget,
type: string,
listener: EventListener,
): IDisposable {
target.addEventListener(type, listener);
return toDisposable(() => target.removeEventListener(type, listener));
}

export function toDisposable(f: () => void): IDisposable {
return { dispose: f };
}

const debuggerStore: Record<string, debug.Debugger> = {};

export function getDebugger(name: string) {
if (name in debuggerStore) {
return debuggerStore[name];
}
debuggerStore[name] = debug(name);
return debuggerStore[name];
}
25 changes: 25 additions & 0 deletions ui/packages/terminal/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",

/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "typings.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
11 changes: 11 additions & 0 deletions ui/packages/terminal/tsconfig.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
11 changes: 11 additions & 0 deletions ui/packages/terminal/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.tsx'],
splitting: false,
sourcemap: true,
clean: true,
format: ['cjs', 'esm'],
// external: ['react'],
dts: true,
});
5 changes: 5 additions & 0 deletions ui/packages/terminal/typings.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module 'sockjs-client/dist/sockjs' {
import SockJS from '@types/sockjs-client';
export = SockJS;
export as namespace SockJS;
}
5 changes: 5 additions & 0 deletions ui/packages/xterm-addon-overlay/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
build
coverage
dist
pnpm-*
7 changes: 7 additions & 0 deletions ui/packages/xterm-addon-overlay/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"trailingComma": "all",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"endOfLine": "auto"
}
8 changes: 8 additions & 0 deletions ui/packages/xterm-addon-overlay/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# @karmada/xterm-addon-overlay

## 1.0.1

### Patch Changes

- update cjs&esm export for xterm-addon-overlay
- add ttyd terminal
34 changes: 34 additions & 0 deletions ui/packages/xterm-addon-overlay/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@karmada/xterm-addon-overlay",
"version": "1.0.1",
"publishConfig": {
"access": "public"
},
"description": "",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
},
"types": "dist/index.d.ts",
"scripts": {
"prepublish": "tsup --config ./tsup.config.ts",
"build": "tsup --config ./tsup.config.ts"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write"
]
},
"keywords": [
"terminal",
"xterm",
"xterm.js"
],
"author": "",
"license": "ISC",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
}
73 changes: 73 additions & 0 deletions ui/packages/xterm-addon-overlay/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// ported from hterm.Terminal.prototype.showOverlay
// https://chromium.googlesource.com/apps/libapps/+/master/hterm/js/hterm_terminal.js
import { ITerminalAddon, Terminal } from '@xterm/xterm';

class OverlayAddon implements ITerminalAddon {
private terminal: Terminal | null = null;
private overlayNode: HTMLElement;
private overlayTimeout?: number;

constructor() {
this.overlayNode = document.createElement('div');
this.overlayNode.style.cssText = `border-radius: 15px;
font-size: xx-large;
opacity: 0.75;
padding: 0.2em 0.5em 0.2em 0.5em;
position: absolute;
-webkit-user-select: none;
-webkit-transition: opacity 180ms ease-in;
-moz-user-select: none;
-moz-transition: opacity 180ms ease-in;`;

this.overlayNode.addEventListener(
'mousedown',
(e) => {
e.preventDefault();
e.stopPropagation();
},
true,
);
}

activate(terminal: Terminal): void {
this.terminal = terminal;
}

dispose(): void {}

showOverlay(msg: string, timeout?: number): void {
const { terminal, overlayNode } = this;
if (!terminal || !terminal.element) return;

overlayNode.style.color = '#101010';
overlayNode.style.backgroundColor = '#f0f0f0';
overlayNode.textContent = msg;
overlayNode.style.opacity = '0.75';

if (!overlayNode.parentNode) {
terminal.element.appendChild(overlayNode);
}

const divSize = terminal.element.getBoundingClientRect();
const overlaySize = overlayNode.getBoundingClientRect();

overlayNode.style.top = (divSize.height - overlaySize.height) / 2 + 'px';
overlayNode.style.left = (divSize.width - overlaySize.width) / 2 + 'px';

if (this.overlayTimeout) clearTimeout(this.overlayTimeout);
if (!timeout) return;

this.overlayTimeout = window.setTimeout(() => {
overlayNode.style.opacity = '0';
this.overlayTimeout = window.setTimeout(() => {
if (overlayNode.parentNode) {
overlayNode.parentNode.removeChild(overlayNode);
}
this.overlayTimeout = undefined;
overlayNode.style.opacity = '0.75';
}, 200);
}, timeout || 1500);
}
}

export default OverlayAddon;
25 changes: 25 additions & 0 deletions ui/packages/xterm-addon-overlay/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",

/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
11 changes: 11 additions & 0 deletions ui/packages/xterm-addon-overlay/tsconfig.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
10 changes: 10 additions & 0 deletions ui/packages/xterm-addon-overlay/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.ts'],
splitting: false,
sourcemap: true,
clean: true,
format: ['cjs', 'esm'],
dts: true,
});
5 changes: 5 additions & 0 deletions ui/packages/xterm-addon-zmodem/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
build
coverage
dist
pnpm-*
7 changes: 7 additions & 0 deletions ui/packages/xterm-addon-zmodem/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"trailingComma": "all",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"endOfLine": "auto"
}
7 changes: 7 additions & 0 deletions ui/packages/xterm-addon-zmodem/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @karmada/xterm-addon-zmodem

## 1.0.1

### Patch Changes

- update cjs&esm export for xterm-addon-zmodem
43 changes: 43 additions & 0 deletions ui/packages/xterm-addon-zmodem/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@karmada/xterm-addon-zmodem",
"version": "1.0.1",
"publishConfig": {
"access": "public"
},
"description": "",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
},
"types": "dist/index.d.ts",
"scripts": {
"prepublish": "tsup --config ./tsup.config.ts",
"build": "tsup --config ./tsup.config.ts"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write"
]
},
"keywords": [
"terminal",
"xterm",
"xterm.js"
],
"author": "",
"license": "ISC",
"dependencies": {
"@karmada/xterm-addon-zmodem": "link:",
"file-saver": "^2.0.5",
"trzsz": "^1.1.5",
"zmodem.js": "^0.1.10"
},
"devDependencies": {
"@types/file-saver": "^2.0.7"
},
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
}
205 changes: 205 additions & 0 deletions ui/packages/xterm-addon-zmodem/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { saveAs } from 'file-saver';
import { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
// we've made some patch for the zmodem.js package, but pnpm doesn't support
// sub package patchedDependencies field :https://github.com/pnpm/pnpm/issues/6048
// so we hoist the patchedDependencies to the root dir of pnpm monorepo: `ui` dir
import * as Zmodem from 'zmodem.js/src/zmodem_browser';
import { TrzszFilter } from 'trzsz';

export interface ZmodemOptions {
zmodem: boolean;
trzsz: boolean;
windows: boolean;
trzszDragInitTimeout: number;
onSend: () => void;
sender: (data: string | Uint8Array) => void;
writer: (data: string | Uint8Array) => void;
}

class ZmodemAddon implements ITerminalAddon {
private disposables: IDisposable[] = [];
private terminal: Terminal | null = null;
private sentry!: Zmodem.Sentry;
private session: Zmodem.Session;
private denier: () => void = () => {};
private trzszFilter: TrzszFilter | null = null;

constructor(private options: ZmodemOptions) {}

activate(terminal: Terminal) {
this.terminal = terminal;
if (this.options.zmodem) this.zmodemInit();
if (this.options.trzsz) this.trzszInit();
}

dispose() {
for (const d of this.disposables) {
d.dispose();
}
this.disposables.length = 0;
}

consume(data: ArrayBuffer) {
try {
if (this.options.trzsz) {
this.trzszFilter?.processServerOutput(data);
} else {
this.sentry.consume(data);
}
} catch (e) {
console.error('[ttyd] zmodem consume: ', e);
this.reset();
}
}

private reset() {
if (!this.terminal) return;
this.terminal.options.disableStdin = false;
this.terminal.focus();
}

private addDisposableListener(
target: EventTarget,
type: string,
listener: EventListener,
) {
target.addEventListener(type, listener);
this.disposables.push({
dispose: () => target.removeEventListener(type, listener),
});
}

private trzszInit() {
const { terminal } = this;
if (!terminal) return;
const { sender, writer, zmodem } = this.options;
this.trzszFilter = new TrzszFilter({
writeToTerminal: (data) => {
if (!this.trzszFilter) return;
if (!this.trzszFilter.isTransferringFiles() && zmodem) {
this.sentry.consume(data);
} else {
writer(
typeof data === 'string'
? data
: new Uint8Array(data as ArrayBuffer),
);
}
},
sendToServer: (data) => sender(data),
terminalColumns: terminal.cols,
isWindowsShell: this.options.windows,
dragInitTimeout: this.options.trzszDragInitTimeout,
});
const element = terminal.element as EventTarget;
this.addDisposableListener(element, 'dragover', (event) =>
event.preventDefault(),
);
this.addDisposableListener(element, 'drop', (event) => {
event.preventDefault();
if (!this.trzszFilter) return;
this.trzszFilter
.uploadFiles(
(event as DragEvent).dataTransfer?.items as DataTransferItemList,
)
.then(() => console.log('[ttyd] upload success'))
.catch((err) => console.log('[ttyd] upload failed: ' + err));
});
this.disposables.push(
terminal.onResize((size) =>
this.trzszFilter?.setTerminalColumns(size.cols),
),
);
}

private zmodemInit() {
const { sender, writer } = this.options;
const { terminal, reset, zmodemDetect } = this;
if (!terminal) return;
this.session = null;
this.sentry = new Zmodem.Sentry({
to_terminal: (octets: Iterable<number>) => writer(new Uint8Array(octets)),
sender: (octets: Iterable<number>) => sender(new Uint8Array(octets)),
on_retract: () => reset(),
on_detect: (detection: Zmodem.Detection) => zmodemDetect(detection),
});
this.disposables.push(
terminal.onKey((e) => {
const event = e.domEvent;
if (event.ctrlKey && event.key === 'c') {
if (this.denier) this.denier();
}
}),
);
}

private zmodemDetect(detection: Zmodem.Detection): void {
if (!this.terminal) return;
const { terminal, receiveFile } = this;
terminal.options.disableStdin = true;

this.denier = () => detection.deny();
this.session = detection.confirm();
this.session.on('session_end', () => this.reset());

if (this.session.type === 'send') {
this.options.onSend();
} else {
receiveFile();
}
}

public sendFile(files: FileList) {
const { session, writeProgress } = this;
Zmodem.Browser.send_files(session, files, {
on_progress: (_: any, offer: Zmodem.Offer) => writeProgress(offer),
})
.then(() => session.close())
.catch(() => this.reset());
}

private receiveFile() {
const { session, writeProgress } = this;

session.on('offer', (offer: Zmodem.Offer) => {
offer.on('input', () => writeProgress(offer));
offer
.accept()
.then((payloads: any) => {
const blob = new Blob(payloads, { type: 'application/octet-stream' });
saveAs(blob, offer.get_details().name);
})
.catch(() => this.reset());
});

session.start();
}

private writeProgress(offer: Zmodem.Offer) {
const { bytesHuman } = this;
const file = offer.get_details();
const name = file.name;
const size = file.size;
const offset = offer.get_offset();
const percent = ((100 * offset) / size).toFixed(2);

this.options.writer(
`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`,
);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private bytesHuman(bytes: any, precision: number): string {
if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(bytes)) {
return '-';
}
if (bytes === 0) return '0';
if (typeof precision === 'undefined') precision = 1;
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const num = Math.floor(Math.log(bytes) / Math.log(1024));
const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision);
return `${value} ${units[num]}`;
}
}

export default ZmodemAddon;
25 changes: 25 additions & 0 deletions ui/packages/xterm-addon-zmodem/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",

/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "typing.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
11 changes: 11 additions & 0 deletions ui/packages/xterm-addon-zmodem/tsconfig.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
10 changes: 10 additions & 0 deletions ui/packages/xterm-addon-zmodem/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.ts'],
splitting: false,
sourcemap: true,
clean: true,
format: ['cjs', 'esm'],
dts: true,
});
35 changes: 35 additions & 0 deletions ui/packages/xterm-addon-zmodem/typing.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
declare module 'zmodem.js/src/zmodem_browser' {
type to_terminal = (octets: Iterable<number>) => void;

export function sender(octets: Iterable<number>): void;

export function on_retract(): void;

export function on_detect(detection: Detection): void;

export function on_progress(_: any, offer: Zmodem.Offer): void;

class Sentry {
constructor({
to_terminal: to_terminal,
sender: sender,
on_retract: on_retract,
on_detect: on_detect,
});

consume(data: string | ArrayBuffer | Uint8Array | Blob): void;
}

type Session = any;
type Detection = any;
type Offer = any;

type send_files = (
session: Session,
files: FileList,
{ on_progress: on_progress },
) => Promise<void>;
const Browser = {
send_files: send_files,
};
}
34 changes: 34 additions & 0 deletions ui/patches/zmodem.js@0.1.10.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
diff --git a/src/zsession.js b/src/zsession.js
index 5f0b8f9d8afa6fba0acd6dd0477afa186f7aad9a..c7ea98e0f08c97d63d321f784a5dd8bf66888743 100644
--- a/src/zsession.js
+++ b/src/zsession.js
@@ -548,20 +548,17 @@ Zmodem.Session.Receive = class ZmodemReceiveSession extends Zmodem.Session {
if (this._got_ZFIN) {
if (this._input_buffer.length < 2) return;

- //if it’s OO, then set this._bytes_after_OO
- if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) === 0) {
+ if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) !== 0) {
+ console.warn( "PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " + this._input_buffer.join() );
+ }

- //This doubles as an indication that the session has ended.
- //We need to set this right away so that handlers like
- //"session_end" will have access to it.
- this._bytes_after_OO = _trim_OO(this._bytes_being_consumed.slice(0));
- this._on_session_end();
+ //This doubles as an indication that the session has ended.
+ //We need to set this right away so that handlers like
+ //"session_end" will have access to it.
+ this._bytes_after_OO = _trim_OO(this._bytes_being_consumed.slice(0));
+ this._on_session_end();

- return;
- }
- else {
- throw( "PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " + this._input_buffer.join() );
- }
+ return;
}

var parsed;
1,501 changes: 1,441 additions & 60 deletions ui/pnpm-lock.yaml

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions ui/turbo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"persistent": true,
"cache": false,
"dependsOn": ["^build"]
}
}
}

0 comments on commit e26300e

Please sign in to comment.