diff --git a/src/addons/attach/Interfaces.ts b/src/addons/attach/Interfaces.ts new file mode 100644 index 000000000..4b2690993 --- /dev/null +++ b/src/addons/attach/Interfaces.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + * + * Implements the attach method, that attaches the terminal to a WebSocket stream. + */ + +import { Terminal, IDisposable } from 'xterm'; + +export interface IAttachAddonTerminal extends Terminal { + _core: { + register(d: T): void; + }; + + __socket?: WebSocket; + __attachSocketBuffer?: string; + __dataListener?: IDisposable; + + __getMessage?(ev: MessageEvent): void; + __flushBuffer?(): void; + __pushToBuffer?(data: string): void; + __sendData?(data: string): void; +} diff --git a/src/addons/attach/attach.test.ts b/src/addons/attach/attach.test.ts new file mode 100644 index 000000000..e280b6568 --- /dev/null +++ b/src/addons/attach/attach.test.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; + +import * as attach from './attach'; + +class MockTerminal {} + +describe('attach addon', () => { + describe('apply', () => { + it('should do register the `attach` and `detach` methods', () => { + attach.apply(MockTerminal); + assert.equal(typeof (MockTerminal).prototype.attach, 'function'); + assert.equal(typeof (MockTerminal).prototype.detach, 'function'); + }); + }); +}); diff --git a/src/addons/attach/attach.ts b/src/addons/attach/attach.ts new file mode 100644 index 000000000..2c8a5d4d2 --- /dev/null +++ b/src/addons/attach/attach.ts @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * @license MIT + * + * Implements the attach method, that attaches the terminal to a WebSocket stream. + */ + +import { Terminal, IDisposable } from 'xterm'; +import { IAttachAddonTerminal } from './Interfaces'; + +/** + * Attaches the given terminal to the given socket. + * + * @param term The terminal to be attached to the given socket. + * @param socket The socket to attach the current terminal. + * @param bidirectional Whether the terminal should send data to the socket as well. + * @param buffered Whether the rendering of incoming data should happen instantly or at a maximum + * frequency of 1 rendering per 10ms. + */ +export function attach(term: Terminal, socket: WebSocket, bidirectional: boolean, buffered: boolean): void { + const addonTerminal = term; + bidirectional = (typeof bidirectional === 'undefined') ? true : bidirectional; + addonTerminal.__socket = socket; + + addonTerminal.__flushBuffer = () => { + addonTerminal.write(addonTerminal.__attachSocketBuffer); + addonTerminal.__attachSocketBuffer = null; + }; + + addonTerminal.__pushToBuffer = (data: string) => { + if (addonTerminal.__attachSocketBuffer) { + addonTerminal.__attachSocketBuffer += data; + } else { + addonTerminal.__attachSocketBuffer = data; + setTimeout(addonTerminal.__flushBuffer, 10); + } + }; + + // TODO: This should be typed but there seem to be issues importing the type + let myTextDecoder: any; + + addonTerminal.__getMessage = function(ev: MessageEvent): void { + let str: string; + + if (typeof ev.data === 'object') { + if (!myTextDecoder) { + myTextDecoder = new TextDecoder(); + } + if (ev.data instanceof ArrayBuffer) { + str = myTextDecoder.decode(ev.data); + displayData(str); + } else { + const fileReader = new FileReader(); + + fileReader.addEventListener('load', () => { + str = myTextDecoder.decode(fileReader.result); + displayData(str); + }); + fileReader.readAsArrayBuffer(ev.data); + } + } else if (typeof ev.data === 'string') { + displayData(ev.data); + } else { + throw Error(`Cannot handle "${typeof ev.data}" websocket message.`); + } + }; + + /** + * Push data to buffer or write it in the terminal. + * This is used as a callback for FileReader.onload. + * + * @param str String decoded by FileReader. + * @param data The data of the EventMessage. + */ + function displayData(str?: string, data?: string): void { + if (buffered) { + addonTerminal.__pushToBuffer(str || data); + } else { + addonTerminal.write(str || data); + } + } + + addonTerminal.__sendData = (data: string) => { + if (socket.readyState !== 1) { + return; + } + socket.send(data); + }; + + addonTerminal._core.register(addSocketListener(socket, 'message', addonTerminal.__getMessage)); + + if (bidirectional) { + addonTerminal.__dataListener = addonTerminal.onData(addonTerminal.__sendData); + addonTerminal._core.register(addonTerminal.__dataListener); + } + + addonTerminal._core.register(addSocketListener(socket, 'close', () => detach(addonTerminal, socket))); + addonTerminal._core.register(addSocketListener(socket, 'error', () => detach(addonTerminal, socket))); +} + +function addSocketListener(socket: WebSocket, type: string, handler: (this: WebSocket, ev: Event) => any): IDisposable { + socket.addEventListener(type, handler); + return { + dispose: () => { + if (!handler) { + // Already disposed + return; + } + socket.removeEventListener(type, handler); + handler = null; + } + }; +} + +/** + * Detaches the given terminal from the given socket + * + * @param term The terminal to be detached from the given socket. + * @param socket The socket from which to detach the current terminal. + */ +export function detach(term: Terminal, socket: WebSocket): void { + const addonTerminal = term; + addonTerminal.__dataListener.dispose(); + addonTerminal.__dataListener = undefined; + + socket = (typeof socket === 'undefined') ? addonTerminal.__socket : socket; + + if (socket) { + socket.removeEventListener('message', addonTerminal.__getMessage); + } + + delete addonTerminal.__socket; +} + + +export function apply(terminalConstructor: typeof Terminal): void { + /** + * Attaches the current terminal to the given socket + * + * @param socket The socket to attach the current terminal. + * @param bidirectional Whether the terminal should send data to the socket as well. + * @param buffered Whether the rendering of incoming data should happen instantly or at a maximum + * frequency of 1 rendering per 10ms. + */ + (terminalConstructor.prototype).attach = function (socket: WebSocket, bidirectional: boolean, buffered: boolean): void { + attach(this, socket, bidirectional, buffered); + }; + + /** + * Detaches the current terminal from the given socket. + * + * @param socket The socket from which to detach the current terminal. + */ + (terminalConstructor.prototype).detach = function (socket: WebSocket): void { + detach(this, socket); + }; +} diff --git a/src/addons/attach/index.html b/src/addons/attach/index.html new file mode 100644 index 000000000..b6f853be8 --- /dev/null +++ b/src/addons/attach/index.html @@ -0,0 +1,93 @@ + + + + + + + + + + +
+ +

+ xterm.js: socket attach +

+

+ Attach the terminal to a WebSocket terminal stream with ease. Perfect for attaching to your + Docker containers. +

+

+ Socket information +

+
+ + +
+
+ +
+ + + \ No newline at end of file diff --git a/src/addons/attach/package.json b/src/addons/attach/package.json new file mode 100644 index 000000000..9e45068b5 --- /dev/null +++ b/src/addons/attach/package.json @@ -0,0 +1,5 @@ +{ + "name": "xterm.attach", + "main": "attach.js", + "private": true +} diff --git a/src/addons/attach/tsconfig.json b/src/addons/attach/tsconfig.json new file mode 100644 index 000000000..2f39102ca --- /dev/null +++ b/src/addons/attach/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "lib": [ + "dom", + "es6", + ], + "rootDir": ".", + "outDir": "../../../lib/addons/attach/", + "sourceMap": true, + "removeComments": true, + "declaration": true + }, + "include": [ + "**/*.ts", + "../../../typings/xterm.d.ts" + ] +} diff --git a/src/addons/webLinks/package.json b/src/addons/webLinks/package.json new file mode 100644 index 000000000..f200cab4a --- /dev/null +++ b/src/addons/webLinks/package.json @@ -0,0 +1,5 @@ +{ + "name": "xterm.weblinks", + "main": "weblinks.js", + "private": true +} diff --git a/src/addons/webLinks/tsconfig.json b/src/addons/webLinks/tsconfig.json new file mode 100644 index 000000000..9c4f11760 --- /dev/null +++ b/src/addons/webLinks/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "lib": [ + "dom", + "es5", + ], + "rootDir": ".", + "outDir": "../../../lib/addons/webLinks/", + "sourceMap": true, + "removeComments": true, + "declaration": true, + "types": [ + "../../node_modules/@types/mocha" + ] + }, + "include": [ + "**/*.ts", + "../../../typings/xterm.d.ts" + ] +} diff --git a/src/addons/webLinks/webLinks.test.ts b/src/addons/webLinks/webLinks.test.ts new file mode 100644 index 000000000..da5569ab8 --- /dev/null +++ b/src/addons/webLinks/webLinks.test.ts @@ -0,0 +1,212 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; + +import * as webLinks from './webLinks'; + +class MockTerminal { + public regex: RegExp; + public handler: (event: MouseEvent, uri: string) => void; + public options?: any; + + public registerLinkMatcher(regex: RegExp, handler: (event: MouseEvent, uri: string) => void, options?: any): number { + this.regex = regex; + this.handler = handler; + this.options = options; + return 0; + } +} + +describe('webLinks addon', () => { + describe('apply', () => { + it('should do register the `webLinksInit` method', () => { + webLinks.apply(MockTerminal); + assert.equal(typeof (MockTerminal).prototype.webLinksInit, 'function'); + }); + }); + + describe('should allow simple URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://foo.com '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://bar.io '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io'); + }); + }); + + describe('should allow ~ character in URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://foo.com/a~b#c~d?e~f '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/a~b#c~d?e~f'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://bar.io/a~b#c~d?e~f '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/a~b#c~d?e~f'); + }); + }); + + describe('should allow : character in URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://foo.com/colon:test '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/colon:test'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://bar.io/colon:test '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/colon:test'); + }); + }); + + describe('should not allow : character at the end of a URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://foo.com/colon:test: '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/colon:test'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://bar.io/colon:test: '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/colon:test'); + }); + }); + + describe('should not allow " character at the end of a URI enclosed with ""', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '"http://foo.com/"'; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '"http://bar.io/"'; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/'); + }); + }); + + describe('should not allow \' character at the end of a URI enclosed with \'\'', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '\'http://foo.com/\''; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '\'http://bar.io/\''; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/'); + }); + }); + + describe('should allow + character in URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = 'http://foo.com/subpath/+/id'; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/subpath/+/id'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = 'http://bar.io/subpath/+/id'; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/subpath/+/id'); + }); + }); +}); diff --git a/src/addons/webLinks/webLinks.ts b/src/addons/webLinks/webLinks.ts new file mode 100644 index 000000000..8a0fec097 --- /dev/null +++ b/src/addons/webLinks/webLinks.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, ILinkMatcherOptions } from 'xterm'; + +const protocolClause = '(https?:\\/\\/)'; +const domainCharacterSet = '[\\da-z\\.-]+'; +const negatedDomainCharacterSet = '[^\\da-z\\.-]+'; +const domainBodyClause = '(' + domainCharacterSet + ')'; +const tldClause = '([a-z\\.]{2,6})'; +const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})'; +const localHostClause = '(localhost)'; +const portClause = '(:\\d{1,5})'; +const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?'; +const pathCharacterSet = '(\\/[\\/\\w\\.\\-%~:+]*)*([^:"\'\\s])'; +const pathClause = '(' + pathCharacterSet + ')?'; +const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*'; +const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?'; +const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?'; +const negatedPathCharacterSet = '[^\\/\\w\\.\\-%]+'; +const bodyClause = hostClause + pathClause + queryStringClause + hashFragmentClause; +const start = '(?:^|' + negatedDomainCharacterSet + ')('; +const end = ')($|' + negatedPathCharacterSet + ')'; +const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end); + +function handleLink(event: MouseEvent, uri: string): void { + window.open(uri, '_blank'); +} + +/** + * Initialize the web links addon, registering the link matcher. + * @param term The terminal to use web links within. + * @param handler A custom handler to use. + * @param options Custom options to use, matchIndex will always be ignored. + */ +export function webLinksInit(term: Terminal, handler: (event: MouseEvent, uri: string) => void = handleLink, options: ILinkMatcherOptions = {}): void { + options.matchIndex = 1; + term.registerLinkMatcher(strictUrlRegex, handler, options); +} + +export function apply(terminalConstructor: typeof Terminal): void { + (terminalConstructor.prototype).webLinksInit = function (handler?: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions): void { + webLinksInit(this, handler, options); + }; +} diff --git a/src/public/Terminal.test.ts b/src/public/Terminal.test.ts new file mode 100644 index 000000000..6bad5b044 --- /dev/null +++ b/src/public/Terminal.test.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { Terminal } from './Terminal'; +import * as attach from '../addons/attach/attach'; + + describe('Terminal', () => { + it('should apply addons with Terminal.applyAddon', () => { + Terminal.applyAddon(attach); + // Test that addon was applied successfully, adding attach to Terminal's + // prototype. + assert.equal(typeof (Terminal).prototype.attach, 'function'); + }); +}); diff --git a/src/tsconfig.all.json b/src/tsconfig.all.json index 55689ad86..2a53ab899 100644 --- a/src/tsconfig.all.json +++ b/src/tsconfig.all.json @@ -3,10 +3,12 @@ "include": [], "references": [ { "path": "." }, + { "path": "./addons/attach" }, { "path": "./addons/fit" }, { "path": "./addons/fullscreen" }, { "path": "./addons/search" }, { "path": "./addons/terminado" }, + { "path": "./addons/webLinks" }, { "path": "./addons/zmodem" } ] }