├── .eslintrc.yaml ├── .github └── workflows │ └── build.yaml ├── .gitignore ├── .npmignore ├── .prettierrc.yaml ├── LICENSE ├── README.md ├── package.json ├── src ├── client │ ├── child_process.ts │ ├── client.ts │ ├── fs.ts │ ├── net.ts │ ├── os.ts │ └── stream.ts ├── common │ ├── arguments.ts │ ├── clientMessages.ts │ ├── connection.ts │ ├── events.ts │ ├── messages.ts │ ├── proxy.ts │ ├── serverMessages.ts │ └── util.ts ├── index.ts └── server │ ├── child_process.ts │ ├── fs.ts │ ├── net.ts │ ├── server.ts │ └── stream.ts ├── test ├── child_process.test.ts ├── event.test.ts ├── forker.js ├── fs.test.ts ├── helpers.ts ├── net.test.ts └── util.test.ts ├── tsconfig.client.json ├── tsconfig.json ├── tsconfig.server.json └── yarn.lock /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | parser: "@typescript-eslint/parser" 2 | env: 3 | node: true 4 | es6: true # Map, etc. 5 | mocha: true 6 | 7 | parserOptions: 8 | ecmaVersion: 2018 9 | sourceType: module 10 | 11 | extends: 12 | - eslint:recommended 13 | - plugin:@typescript-eslint/recommended 14 | - plugin:import/recommended 15 | - plugin:import/typescript 16 | - plugin:prettier/recommended 17 | - prettier # Removes eslint rules that conflict with prettier. 18 | - prettier/@typescript-eslint # Remove conflicts again. 19 | 20 | rules: 21 | # For overloads. 22 | no-dupe-class-members: off 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout branch 12 | uses: actions/checkout@v1 13 | - name: Set up Node 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '10.x' 17 | - name: Install dependencies 18 | run: yarn 19 | - name: Build node-browser 20 | run: yarn prepublishOnly 21 | 22 | lint: 23 | 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - name: Checkout branch 28 | uses: actions/checkout@v1 29 | - name: Set up Node 30 | uses: actions/setup-node@v1 31 | with: 32 | node-version: '10.x' 33 | - name: Install dependencies 34 | run: yarn 35 | - name: Lint node-browser 36 | run: yarn lint 37 | 38 | test: 39 | 40 | runs-on: ubuntu-latest 41 | 42 | steps: 43 | - name: Checkout branch 44 | uses: actions/checkout@v1 45 | - name: Set up Node 46 | uses: actions/setup-node@v1 47 | with: 48 | node-version: '10.x' 49 | - name: Install dependencies 50 | run: yarn 51 | - name: Test node-browser 52 | run: yarn test 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /out -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .gitignore 4 | .npmignore 5 | *.yaml 6 | node_modules 7 | src 8 | test 9 | tsconfig.json 10 | yarn.lock -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 120 2 | semi: false 3 | tabWidth: 2 4 | singleQuote: false 5 | trailingComma: es5 6 | useTabs: false 7 | arrowParens: always 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019 Coder Technologies Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-browser 2 | This module provides a way for the browser to run Node modules like fs, net, 3 | etc. 4 | 5 | ## Internals 6 | ### Server-side proxies 7 | The server-side proxies are regular classes that call native Node functions. The 8 | only thing special about them is that they must return promises and they must 9 | return serializable values. 10 | 11 | The only exception to the promise rule are event-related methods such as onEvent 12 | and onDone (these are synchronous). The server will simply immediately bind and 13 | push all events it can to the client. It doesn't wait for the client to start 14 | listening. This prevents issues with the server not receiving the client's 15 | request to start listening in time. 16 | 17 | However, there is a way to specify events that should not bind immediately and 18 | should wait for the client to request it, because some events (like data on a 19 | stream) cannot be bound immediately (because doing so changes how the stream 20 | behaves). 21 | 22 | ### Client-side proxies 23 | Client-side proxies are Proxy instances. They simply make remote calls for any 24 | method you call on it using a web socket. The only exception is for events. Each 25 | client proxy has a local emitter which it uses in place of a remote call (this 26 | allows the call to be completed synchronously on the client). Then when an event 27 | is received from the server, it gets emitted on that local emitter. 28 | 29 | When an event is listened to, the proxy also notifies the server so it can start 30 | listening in case it isn't already (see the data example above). This only works 31 | for events that only fire after they are bound. 32 | 33 | ### Client-side fills 34 | The client-side fills implement the actual Node API and make calls to the 35 | server-side proxies using the client-side proxies. 36 | 37 | When a proxy returns a proxy (for example fs.createWriteStream), that proxy is a 38 | promise (since communicating with the server is asynchronous). We have to return 39 | the fill from fs.createWriteStream synchronously, so that means the fill has to 40 | contain a proxy promise. To eliminate the need for calling `then` and to keep 41 | the code looking clean every time you use the proxy, the proxy is itself wrapped 42 | in another proxy which just calls the method after a `then`. This works since 43 | all the methods return promises (aside from the event methods, but those are not 44 | used by the fills directly—they are only used internally to forward events to 45 | the fill if it is an event emitter). 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@coder/node-browser", 3 | "version": "1.0.8", 4 | "license": "MIT", 5 | "author": "Coder", 6 | "description": "Use Node APIs in the browser.", 7 | "main": "out/index", 8 | "scripts": { 9 | "test": "mocha -r ts-node/register -r amd-loader ./test/*.test.ts", 10 | "lint": "eslint {src,test}/*.ts {src,test}/**/*.ts", 11 | "prepare": "sed -i 's/var __/let __/g' ./node_modules/tslib/tslib.js", 12 | "prepublishOnly": "rm -rf ./out && yarn build:client && yarn build:server", 13 | "build:client": "tsc --project ./tsconfig.client.json && browserify ./out/client/client.js -s node-browser -o ./out/client.js && rm ./out/client/*.js && mv ./out/client.js ./out/client/client.js && rm -r ./out/common", 14 | "build:server": "tsc --project ./tsconfig.server.json" 15 | }, 16 | "devDependencies": { 17 | "@coder/logger": "^1.1.11", 18 | "@types/mocha": "2.2.39", 19 | "@types/node": "^10.12.12", 20 | "@types/rimraf": "^2.0.2", 21 | "@typescript-eslint/eslint-plugin": "^2.0.0", 22 | "@typescript-eslint/parser": "^2.0.0", 23 | "amd-loader": "^0.0.8", 24 | "browserify": "^16.5.0", 25 | "eslint": "^6.2.0", 26 | "eslint-config-prettier": "^6.0.0", 27 | "eslint-plugin-import": "^2.18.2", 28 | "eslint-plugin-prettier": "^3.1.0", 29 | "leaked-handles": "^5.2.0", 30 | "mocha": "^6.2.0", 31 | "prettier": "^1.18.2", 32 | "rimraf": "^3.0.0", 33 | "ts-node": "^8.3.0", 34 | "tslib": "^1.10.0", 35 | "typescript": "~3.5.0" 36 | }, 37 | "resolutions": { 38 | "util": "^0.12.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/client/child_process.ts: -------------------------------------------------------------------------------- 1 | import * as cp from "child_process" 2 | import * as net from "net" 3 | import * as stream from "stream" 4 | import { callbackify } from "util" 5 | import { ClientProxy, ClientServerProxy } from "../common/proxy" 6 | import { ChildProcessModuleProxy, ChildProcessProxy } from "../server/child_process" 7 | import { ClientReadableProxy, ClientWritableProxy, Readable, Writable } from "./stream" 8 | 9 | /* eslint-disable @typescript-eslint/no-explicit-any */ 10 | 11 | export interface ClientChildProcessProxy extends ChildProcessProxy, ClientServerProxy {} 12 | 13 | export interface ClientChildProcessProxies { 14 | childProcess: ClientChildProcessProxy 15 | stdin?: ClientWritableProxy | null 16 | stdout?: ClientReadableProxy | null 17 | stderr?: ClientReadableProxy | null 18 | } 19 | 20 | export class ChildProcess extends ClientProxy implements cp.ChildProcess { 21 | public readonly stdin: stream.Writable 22 | public readonly stdout: stream.Readable 23 | public readonly stderr: stream.Readable 24 | public readonly stdio: [stream.Writable, stream.Readable, stream.Readable] 25 | 26 | private _connected = false 27 | private _killed = false 28 | private _pid = -1 29 | 30 | public constructor(proxyPromises: Promise) { 31 | super(proxyPromises.then((p) => p.childProcess)) 32 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 33 | this.stdin = new Writable(proxyPromises.then((p) => p.stdin!)) 34 | this.stdout = new Readable(proxyPromises.then((p) => p.stdout!)) 35 | this.stderr = new Readable(proxyPromises.then((p) => p.stderr!)) 36 | /* eslint-enable @typescript-eslint/no-non-null-assertion */ 37 | this.stdio = [this.stdin, this.stdout, this.stderr] 38 | 39 | this.catch( 40 | this.proxy.getPid().then((pid) => { 41 | this._pid = pid 42 | this._connected = true 43 | }) 44 | ) 45 | this.on("disconnect", () => (this._connected = false)) 46 | this.on("exit", () => { 47 | this._connected = false 48 | this._killed = true 49 | }) 50 | } 51 | 52 | public get pid(): number { 53 | return this._pid 54 | } 55 | 56 | public get connected(): boolean { 57 | return this._connected 58 | } 59 | 60 | public get killed(): boolean { 61 | return this._killed 62 | } 63 | 64 | public kill(): void { 65 | this._killed = true 66 | this.catch(this.proxy.kill()) 67 | } 68 | 69 | public disconnect(): void { 70 | this.catch(this.proxy.disconnect()) 71 | } 72 | 73 | public ref(): void { 74 | this.catch(this.proxy.ref()) 75 | } 76 | 77 | public unref(): void { 78 | this.catch(this.proxy.unref()) 79 | } 80 | 81 | public send( 82 | message: any, 83 | sendHandle?: net.Socket | net.Server | ((error: Error) => void), 84 | options?: cp.MessageOptions | ((error: Error) => void), 85 | callback?: (error: Error) => void 86 | ): boolean { 87 | if (typeof sendHandle === "function") { 88 | callback = sendHandle 89 | sendHandle = undefined 90 | } else if (typeof options === "function") { 91 | callback = options 92 | options = undefined 93 | } 94 | if (sendHandle || options) { 95 | throw new Error("sendHandle and options are not supported") 96 | } 97 | 98 | callbackify(this.proxy.send)(message, (error) => { 99 | if (callback) { 100 | callback(error) 101 | } 102 | }) 103 | 104 | return true // Always true since we can't get this synchronously. 105 | } 106 | 107 | /** 108 | * Exit and close the process when disconnected. 109 | */ 110 | protected handleDisconnect(): void { 111 | this.emit("exit", 1) 112 | this.emit("close") 113 | } 114 | } 115 | 116 | interface ClientChildProcessModuleProxy extends ChildProcessModuleProxy, ClientServerProxy { 117 | exec( 118 | command: string, 119 | options?: { encoding?: string | null } & cp.ExecOptions | null, 120 | callback?: (error: cp.ExecException | null, stdin: string | Buffer, stdout: string | Buffer) => void 121 | ): Promise 122 | fork(modulePath: string, args?: string[], options?: cp.ForkOptions): Promise 123 | spawn(command: string, args?: string[], options?: cp.SpawnOptions): Promise 124 | } 125 | 126 | export class ChildProcessModule { 127 | public constructor(private readonly proxy: ClientChildProcessModuleProxy) {} 128 | 129 | public exec = ( 130 | command: string, 131 | options?: 132 | | { encoding?: string | null } & cp.ExecOptions 133 | | null 134 | | ((error: cp.ExecException | null, stdout: string | Buffer, stderr: string | Buffer) => void), 135 | callback?: (error: cp.ExecException | null, stdout: string | Buffer, stderr: string | Buffer) => void 136 | ): cp.ChildProcess => { 137 | if (typeof options === "function") { 138 | callback = options 139 | options = undefined 140 | } 141 | 142 | const proc = new ChildProcess(this.proxy.exec(command, options)) 143 | // If we pass the callback it'll stick around forever so we'll handle it 144 | // client-side instead. 145 | if (callback) { 146 | const cb = callback 147 | const encoding = options && options.encoding 148 | const stdout: any[] = [] 149 | const stderr: any[] = [] 150 | proc.stdout.on("data", (d) => stdout.push(d)) 151 | proc.stderr.on("data", (d) => stderr.push(d)) 152 | proc.once("exit", (code, signal) => { 153 | cb( 154 | code !== 0 || signal !== null ? new Error(`Command failed: ${command}`) : null, 155 | encoding === "utf8" ? stdout.join("") : Buffer.concat(stdout), 156 | encoding === "utf8" ? stderr.join("") : Buffer.concat(stderr) 157 | ) 158 | }) 159 | } 160 | return proc 161 | } 162 | 163 | public fork = (modulePath: string, args?: string[] | cp.ForkOptions, options?: cp.ForkOptions): cp.ChildProcess => { 164 | if (!Array.isArray(args)) { 165 | options = args 166 | args = undefined 167 | } 168 | 169 | return new ChildProcess(this.proxy.fork(modulePath, args, options)) 170 | } 171 | 172 | public spawn = (command: string, args?: string[] | cp.SpawnOptions, options?: cp.SpawnOptions): cp.ChildProcess => { 173 | if (!Array.isArray(args)) { 174 | options = args 175 | args = undefined 176 | } 177 | 178 | return new ChildProcess(this.proxy.spawn(command, args, options)) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/client/client.ts: -------------------------------------------------------------------------------- 1 | import * as buffer from "buffer" 2 | import { ExecException, ExecOptions } from "child_process" 3 | import * as crypto from "crypto" 4 | import * as events from "events" 5 | import { PathLike } from "fs" 6 | import * as stream from "stream" 7 | import * as timers from "timers" 8 | import * as tty from "tty" 9 | import * as path from "path" 10 | import * as util from "util" 11 | import * as sd from "string_decoder" 12 | import { Argument, decode, encode } from "../common/arguments" 13 | import { ConnectionStatus, DefaultLogger, Logger, ReadWriteConnection } from "../common/connection" 14 | import { Emitter } from "../common/events" 15 | import * as Message from "../common/messages" 16 | import { ClientServerProxy, Module, ServerProxy } from "../common/proxy" 17 | import { ChildProcessModule } from "./child_process" 18 | import { FsModule } from "./fs" 19 | import { NetModule } from "./net" 20 | import { OsModule } from "./os" 21 | 22 | /* eslint-disable @typescript-eslint/no-explicit-any */ 23 | 24 | export interface ClientOptions { 25 | readonly logger?: Logger 26 | } 27 | 28 | interface ProxyData { 29 | promise: Promise 30 | instance: any 31 | callbacks: Map void> 32 | } 33 | 34 | /** 35 | * Client accepts a connection to communicate with the server. 36 | */ 37 | export class Client { 38 | private messageId = 0 39 | private callbackId = 0 40 | private clientId = (Math.random() * 0x100000000) >>> 0 41 | private readonly proxies = new Map() 42 | private readonly successEmitter = new Emitter() 43 | private readonly failEmitter = new Emitter() 44 | private readonly eventEmitter = new Emitter<{ event: string; args: any[] }>() 45 | private readonly initDataEmitter = new Emitter() 46 | 47 | private status = ConnectionStatus.Connected 48 | 49 | // The socket timeout is 60s, so we need to send a ping periodically to 50 | // prevent it from closing. 51 | private pingTimeout: NodeJS.Timer | number | undefined 52 | private readonly pingTimeoutDelay = 30000 53 | 54 | private readonly responseTimeout = 10000 55 | 56 | public readonly modules: { 57 | [Module.Buffer]: typeof buffer 58 | [Module.ChildProcess]: ChildProcessModule 59 | [Module.Crypto]: typeof crypto 60 | [Module.Events]: typeof events 61 | [Module.Fs]: FsModule 62 | [Module.Net]: NetModule 63 | [Module.Os]: OsModule 64 | [Module.Path]: typeof path 65 | [Module.Process]: typeof process 66 | [Module.Stream]: typeof stream 67 | [Module.StringDecoder]: typeof sd 68 | [Module.Timers]: typeof timers 69 | [Module.Tty]: typeof tty 70 | [Module.Util]: typeof util 71 | } 72 | 73 | private readonly logger: Logger 74 | 75 | private readonly _handshake: Promise 76 | 77 | /** 78 | * @param connection Established connection to the server 79 | */ 80 | public constructor(private readonly connection: ReadWriteConnection, private readonly options?: ClientOptions) { 81 | this.logger = (this.options && this.options.logger) || new DefaultLogger("client") 82 | connection.onMessage(async (data) => { 83 | try { 84 | await this.handleMessage(JSON.parse(data)) 85 | } catch (error) { 86 | this.logger.error("failed to handle server message", { error: error.message }) 87 | } 88 | }) 89 | 90 | this._handshake = new Promise((resolve): void => { 91 | const d = this.initDataEmitter.event((data) => { 92 | d.dispose() 93 | resolve(data) 94 | }) 95 | this.send({ 96 | type: Message.Client.Type.Handshake, 97 | clientId: this.clientId, 98 | }) 99 | }) 100 | 101 | this.createProxy(Module.ChildProcess) 102 | this.createProxy(Module.Fs) 103 | this.createProxy(Module.Net) 104 | 105 | this.modules = { 106 | [Module.Buffer]: buffer, 107 | [Module.ChildProcess]: new ChildProcessModule(this.getProxy(Module.ChildProcess).instance), 108 | [Module.Crypto]: crypto, 109 | [Module.Events]: events, 110 | [Module.Fs]: new FsModule(this.getProxy(Module.Fs).instance), 111 | [Module.Net]: new NetModule(this.getProxy(Module.Net).instance), 112 | [Module.Os]: new OsModule(this._handshake), 113 | [Module.Path]: path, 114 | [Module.Process]: process, 115 | [Module.Stream]: stream, 116 | [Module.StringDecoder]: sd, 117 | [Module.Timers]: timers, 118 | [Module.Tty]: tty, 119 | [Module.Util]: util, 120 | } 121 | 122 | // Methods that don't follow the standard callback pattern (an error 123 | // followed by a single result) need to provide a custom promisify function. 124 | Object.defineProperty(this.modules[Module.Fs].exists, util.promisify.custom, { 125 | value: (path: PathLike): Promise => { 126 | return new Promise((resolve): void => this.modules[Module.Fs].exists(path, resolve)) 127 | }, 128 | }) 129 | 130 | Object.defineProperty(this.modules[Module.ChildProcess].exec, util.promisify.custom, { 131 | value: ( 132 | command: string, 133 | options?: { encoding?: string | null } & ExecOptions | null 134 | ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => { 135 | return new Promise((resolve, reject): void => { 136 | this.modules[Module.ChildProcess].exec( 137 | command, 138 | options, 139 | (error: ExecException | null, stdout: string | Buffer, stderr: string | Buffer) => { 140 | if (error) { 141 | reject(error) 142 | } else { 143 | resolve({ stdout, stderr }) 144 | } 145 | } 146 | ) 147 | }) 148 | }, 149 | }) 150 | 151 | /** 152 | * If the connection is interrupted, the calls will neither succeed nor fail 153 | * nor exit so we need to send a failure on all of them as well as trigger 154 | * events so things like child processes can clean up and possibly restart. 155 | */ 156 | const handleDisconnect = (permanent?: boolean): void => { 157 | this.status = permanent ? ConnectionStatus.Closed : ConnectionStatus.Disconnected 158 | this.logger.trace(`disconnected${permanent ? " permanently" : ""} from server`, () => ({ 159 | proxies: this.proxies.size, 160 | callbacks: Array.from(this.proxies.values()).reduce((count, p) => count + p.callbacks.size, 0), 161 | success: this.successEmitter.counts, 162 | fail: this.failEmitter.counts, 163 | event: this.eventEmitter.counts, 164 | })) 165 | 166 | const error = new Error("disconnected") 167 | this.failEmitter.emit({ 168 | type: Message.Server.Type.Fail, 169 | clientId: this.clientId, 170 | messageId: -1, 171 | response: encode(error), 172 | }) 173 | 174 | this.eventEmitter.emit({ event: "disconnected", args: [error] }) 175 | this.eventEmitter.emit({ event: "done", args: [] }) 176 | } 177 | 178 | connection.onDown(() => handleDisconnect()) 179 | connection.onClose(() => { 180 | clearTimeout(this.pingTimeout as any) 181 | this.pingTimeout = undefined 182 | handleDisconnect(true) 183 | this.proxies.clear() 184 | this.successEmitter.dispose() 185 | this.failEmitter.dispose() 186 | this.eventEmitter.dispose() 187 | this.initDataEmitter.dispose() 188 | }) 189 | connection.onUp(() => { 190 | if (this.status === ConnectionStatus.Disconnected) { 191 | this.logger.trace("reconnected to server") 192 | this.status = ConnectionStatus.Connected 193 | } 194 | }) 195 | 196 | this.startPinging() 197 | } 198 | 199 | /** 200 | * Get the handshake promise. This isn't necessary to start communicating but 201 | * is generally a good idea since some fills get their data from it. 202 | */ 203 | public handshake(): Promise { 204 | return this._handshake 205 | } 206 | 207 | /** 208 | * Make a remote call for a proxy's method. 209 | */ 210 | private remoteCall(proxyId: number | Module, method: string, args: any[]): Promise { 211 | // Assume killing and closing works because a disconnected proxy is disposed 212 | // on the server and a non-existent proxy has already been disposed. 213 | if (typeof proxyId === "number" && (this.status !== ConnectionStatus.Connected || !this.proxies.has(proxyId))) { 214 | switch (method) { 215 | case "close": 216 | case "kill": 217 | return Promise.resolve() 218 | } 219 | } 220 | 221 | if (this.status !== ConnectionStatus.Connected) { 222 | return Promise.reject(new Error(`Unable to call "${method}" on proxy ${proxyId}: disconnected`)) 223 | } 224 | 225 | // We won't have garbage collection on callbacks so they should only be used 226 | // if they are long-lived or on proxies that dispose around the same time 227 | // the callback becomes invalid. 228 | const storeCallback = (cb: (...args: any[]) => void): number => { 229 | const callbackId = this.callbackId++ 230 | this.logger.trace("storing callback", { proxyId, callbackId }) 231 | 232 | this.getProxy(proxyId).callbacks.set(callbackId, cb) 233 | 234 | return callbackId 235 | } 236 | 237 | const messageId = this.messageId++ 238 | const message: Message.Client.Proxy = { 239 | type: Message.Client.Type.Proxy, 240 | clientId: this.clientId, 241 | messageId, 242 | proxyId, 243 | method, 244 | args: args.map((a) => encode(a, storeCallback, (p) => p.proxyId)), 245 | } 246 | 247 | this.logger.trace("sending", { messageId, proxyId, method, args }) 248 | 249 | this.send(message) 250 | 251 | // The server will send back a fail or success message when the method 252 | // has completed, so we listen for that based on the message's unique ID. 253 | const promise = new Promise((resolve, reject): void => { 254 | const dispose = (): void => { 255 | /* eslint-disable @typescript-eslint/no-use-before-define */ 256 | d1.dispose() 257 | d2.dispose() 258 | clearTimeout(timeout as any) 259 | /* eslint-enable @typescript-eslint/no-use-before-define */ 260 | } 261 | 262 | const timeout = setTimeout(() => { 263 | dispose() 264 | reject(new Error("timed out")) 265 | }, this.responseTimeout) 266 | 267 | const d1 = this.successEmitter.event(messageId, (message) => { 268 | dispose() 269 | resolve(this.decode(message.response, promise)) 270 | }) 271 | 272 | const d2 = this.failEmitter.event(messageId, (message) => { 273 | dispose() 274 | reject(decode(message.response)) 275 | }) 276 | }) 277 | 278 | return promise 279 | } 280 | 281 | /** 282 | * Handle all messages from the server. 283 | */ 284 | private async handleMessage(message: Message.Server.Message): Promise { 285 | if (this.status !== ConnectionStatus.Connected || message.clientId !== this.clientId) { 286 | if (this.status !== ConnectionStatus.Connected) { 287 | this.logger.trace("discarding message", { message }) 288 | } 289 | return 290 | } 291 | switch (message.type) { 292 | case Message.Server.Type.Callback: 293 | return this.runCallback(message) 294 | case Message.Server.Type.Event: 295 | return this.emitEvent(message) 296 | case Message.Server.Type.Fail: 297 | return this.emitFail(message) 298 | case Message.Server.Type.Init: 299 | return this.initDataEmitter.emit(message) 300 | case Message.Server.Type.Pong: 301 | // Nothing to do since pings are on a timer rather than waiting for the 302 | // next pong in case a message from either the client or server is 303 | // dropped which would break the ping cycle. 304 | return this.logger.trace("received pong") 305 | case Message.Server.Type.Success: 306 | return this.emitSuccess(message) 307 | default: 308 | throw new Error(`unknown message type ${(message as any).type}`) 309 | } 310 | } 311 | 312 | /** 313 | * Convert success message to a success event. 314 | */ 315 | private emitSuccess(message: Message.Server.Success): void { 316 | this.logger.trace("received resolve", message) 317 | this.successEmitter.emit(message.messageId, message) 318 | } 319 | 320 | /** 321 | * Convert fail message to a fail event. 322 | */ 323 | private emitFail(message: Message.Server.Fail): void { 324 | this.logger.trace("received reject", message) 325 | this.failEmitter.emit(message.messageId, message) 326 | } 327 | 328 | /** 329 | * Emit an event received from the server. We could send requests for "on" to 330 | * the server and serialize functions using IDs, but doing it that way makes 331 | * it possible to miss events depending on whether the server receives the 332 | * request before it emits. Instead, emit all events from the server so all 333 | * events are always caught on the client. 334 | */ 335 | private async emitEvent(message: Message.Server.Event): Promise { 336 | await this.ensureResolved(message.proxyId) 337 | const args = message.args.map((a) => this.decode(a)) 338 | this.logger.trace("received event", () => ({ 339 | proxyId: message.proxyId, 340 | event: message.event, 341 | args: args.map((a) => (a instanceof Buffer ? a.toString() : a)), 342 | })) 343 | this.eventEmitter.emit(message.proxyId, { 344 | event: message.event, 345 | args, 346 | }) 347 | } 348 | 349 | /** 350 | * Run a callback as requested by the server. Since we don't know when 351 | * callbacks get garbage collected we dispose them only when the proxy 352 | * disposes. That means they should only be used if they run for the lifetime 353 | * of the proxy (like child_process.exec), otherwise we'll leak. They should 354 | * also only be used when passed together with the method. If they are sent 355 | * afterward, they may never be called due to timing issues. 356 | */ 357 | private async runCallback(message: Message.Server.Callback): Promise { 358 | await this.ensureResolved(message.proxyId) 359 | this.logger.trace("running callback", { proxyId: message.proxyId, callbackId: message.callbackId }) 360 | const cb = this.getProxy(message.proxyId).callbacks.get(message.callbackId) 361 | if (cb) { 362 | cb(...message.args.map((a) => this.decode(a))) 363 | } 364 | } 365 | 366 | /** 367 | * Start the ping loop. Does nothing if already pinging. 368 | */ 369 | private readonly startPinging = (): void => { 370 | if (typeof this.pingTimeout !== "undefined") { 371 | return 372 | } 373 | 374 | const schedulePing = (): void => { 375 | this.pingTimeout = setTimeout(() => { 376 | this.send({ 377 | type: Message.Client.Type.Ping, 378 | clientId: this.clientId, 379 | }) 380 | schedulePing() 381 | }, this.pingTimeoutDelay) 382 | } 383 | 384 | schedulePing() 385 | } 386 | 387 | /** 388 | * Return a proxy that makes remote calls. 389 | */ 390 | private createProxy( 391 | proxyId: number | Module, 392 | promise: Promise = Promise.resolve() 393 | ): T { 394 | this.logger.trace("creating proxy", { proxyId }) 395 | 396 | const instance = new Proxy( 397 | { 398 | proxyId, 399 | onDone: (cb: (...args: any[]) => void): void => { 400 | this.eventEmitter.event(proxyId, (event) => { 401 | if (event.event === "done") { 402 | cb(...event.args) 403 | } 404 | }) 405 | }, 406 | onEvent: (cb: (event: string, ...args: any[]) => void): void => { 407 | this.eventEmitter.event(proxyId, (event) => { 408 | cb(event.event, ...event.args) 409 | }) 410 | }, 411 | } as ClientServerProxy, 412 | { 413 | get: (target: any, name: string): any => { 414 | // When resolving a promise with a proxy, it will check for "then". 415 | if (name === "then") { 416 | return 417 | } 418 | 419 | if (typeof target[name] === "undefined") { 420 | target[name] = (...args: any[]): Promise | ServerProxy => { 421 | return this.remoteCall(proxyId, name, args) 422 | } 423 | } 424 | 425 | return target[name] 426 | }, 427 | } 428 | ) 429 | 430 | this.proxies.set(proxyId, { 431 | promise, 432 | instance, 433 | callbacks: new Map(), 434 | }) 435 | 436 | instance.onDone(() => { 437 | const log = (): void => { 438 | this.logger.trace(typeof proxyId === "number" ? "disposed proxy" : "disposed proxy callbacks", () => ({ 439 | proxyId, 440 | proxies: this.proxies.size, 441 | status: this.status, 442 | callbacks: Array.from(this.proxies.values()).reduce((count, proxy) => count + proxy.callbacks.size, 0), 443 | success: this.successEmitter.counts, 444 | fail: this.failEmitter.counts, 445 | event: this.eventEmitter.counts, 446 | })) 447 | } 448 | 449 | // Uniquely identified items (top-level module proxies) can continue to 450 | // be used so we don't need to delete them. 451 | if (typeof proxyId === "number") { 452 | const dispose = (): void => { 453 | this.proxies.delete(proxyId) 454 | this.eventEmitter.dispose(proxyId) 455 | log() 456 | } 457 | if (this.status === ConnectionStatus.Connected) { 458 | instance 459 | .dispose() 460 | .then(dispose) 461 | .catch(dispose) 462 | } else { 463 | dispose() 464 | } 465 | } else { 466 | // The callbacks will still be unusable though so clear them. 467 | this.getProxy(proxyId).callbacks.clear() 468 | log() 469 | } 470 | }) 471 | 472 | return instance 473 | } 474 | 475 | /** 476 | * We aren't guaranteed the promise will call all the `then` callbacks 477 | * synchronously once it resolves, so the event message can come in and fire 478 | * before a caller has been able to attach an event. Waiting for the promise 479 | * ensures it runs after everything else. 480 | */ 481 | private async ensureResolved(proxyId: number | Module): Promise { 482 | await this.getProxy(proxyId).promise 483 | } 484 | 485 | /** 486 | * Same as decode except provides createProxy. 487 | */ 488 | private decode(value?: Argument, promise?: Promise): any { 489 | return decode(value, undefined, (id) => this.createProxy(id, promise)) 490 | } 491 | 492 | /** 493 | * Get a proxy. Error if it doesn't exist. 494 | */ 495 | private getProxy(proxyId: number | Module): ProxyData { 496 | const proxy = this.proxies.get(proxyId) 497 | if (!proxy) { 498 | throw new Error(`proxy ${proxyId} disposed too early`) 499 | } 500 | return proxy 501 | } 502 | 503 | private send(message: T): void { 504 | if (this.status === ConnectionStatus.Connected) { 505 | this.connection.send(JSON.stringify(message)) 506 | } 507 | } 508 | } 509 | -------------------------------------------------------------------------------- /src/client/fs.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import { callbackify } from "util" 3 | import { Batch, ClientProxy, ClientServerProxy, EncodingOptions, EncodingOptionsCallback } from "../common/proxy" 4 | import { FsModuleProxy, ReadStreamProxy, Stats as IStats, WatcherProxy, WriteStreamProxy } from "../server/fs" 5 | import { Readable, Writable } from "./stream" 6 | 7 | /* eslint-disable @typescript-eslint/no-explicit-any */ 8 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 9 | 10 | class Stats implements fs.Stats { 11 | public constructor(private readonly stats: IStats) {} 12 | 13 | public get dev(): number { 14 | return this.stats.dev 15 | } 16 | public get ino(): number { 17 | return this.stats.ino 18 | } 19 | public get mode(): number { 20 | return this.stats.mode 21 | } 22 | public get nlink(): number { 23 | return this.stats.nlink 24 | } 25 | public get uid(): number { 26 | return this.stats.uid 27 | } 28 | public get gid(): number { 29 | return this.stats.gid 30 | } 31 | public get rdev(): number { 32 | return this.stats.rdev 33 | } 34 | public get size(): number { 35 | return this.stats.size 36 | } 37 | public get blksize(): number { 38 | return this.stats.blksize 39 | } 40 | public get blocks(): number { 41 | return this.stats.blocks 42 | } 43 | public get atime(): Date { 44 | return this.stats.atime 45 | } 46 | public get mtime(): Date { 47 | return this.stats.mtime 48 | } 49 | public get ctime(): Date { 50 | return this.stats.ctime 51 | } 52 | public get birthtime(): Date { 53 | return this.stats.birthtime 54 | } 55 | public get atimeMs(): number { 56 | return this.stats.atimeMs 57 | } 58 | public get mtimeMs(): number { 59 | return this.stats.mtimeMs 60 | } 61 | public get ctimeMs(): number { 62 | return this.stats.ctimeMs 63 | } 64 | public get birthtimeMs(): number { 65 | return this.stats.birthtimeMs 66 | } 67 | public isFile(): boolean { 68 | return this.stats._isFile 69 | } 70 | public isDirectory(): boolean { 71 | return this.stats._isDirectory 72 | } 73 | public isBlockDevice(): boolean { 74 | return this.stats._isBlockDevice 75 | } 76 | public isCharacterDevice(): boolean { 77 | return this.stats._isCharacterDevice 78 | } 79 | public isSymbolicLink(): boolean { 80 | return this.stats._isSymbolicLink 81 | } 82 | public isFIFO(): boolean { 83 | return this.stats._isFIFO 84 | } 85 | public isSocket(): boolean { 86 | return this.stats._isSocket 87 | } 88 | 89 | public toObject(): object { 90 | return JSON.parse(JSON.stringify(this)) 91 | } 92 | } 93 | 94 | class StatBatch extends Batch { 95 | public constructor(private readonly proxy: FsModuleProxy) { 96 | super() 97 | } 98 | 99 | protected remoteCall(batch: { path: fs.PathLike }[]): Promise<(IStats | Error)[]> { 100 | return this.proxy.statBatch(batch) 101 | } 102 | } 103 | 104 | class LstatBatch extends Batch { 105 | public constructor(private readonly proxy: FsModuleProxy) { 106 | super() 107 | } 108 | 109 | protected remoteCall(batch: { path: fs.PathLike }[]): Promise<(IStats | Error)[]> { 110 | return this.proxy.lstatBatch(batch) 111 | } 112 | } 113 | 114 | class ReaddirBatch extends Batch { 115 | public constructor(private readonly proxy: FsModuleProxy) { 116 | super() 117 | } 118 | 119 | protected remoteCall( 120 | queue: { path: fs.PathLike; options: EncodingOptions }[] 121 | ): Promise<(Buffer[] | fs.Dirent[] | string[] | Error)[]> { 122 | return this.proxy.readdirBatch(queue) 123 | } 124 | } 125 | 126 | interface ClientWatcherProxy extends WatcherProxy, ClientServerProxy {} 127 | 128 | class Watcher extends ClientProxy implements fs.FSWatcher { 129 | public close(): void { 130 | this.catch(this.proxy.close()) 131 | } 132 | 133 | protected handleDisconnect(): void { 134 | this.emit("close") 135 | } 136 | } 137 | 138 | interface ClientReadStreamProxy extends ReadStreamProxy, ClientServerProxy {} 139 | 140 | class ReadStream extends Readable implements fs.ReadStream { 141 | public get bytesRead(): number { 142 | throw new Error("not implemented") 143 | } 144 | 145 | public get path(): string | Buffer { 146 | throw new Error("not implemented") 147 | } 148 | 149 | public close(): void { 150 | this.catch(this.proxy.close()) 151 | } 152 | } 153 | 154 | interface ClientWriteStreamProxy extends WriteStreamProxy, ClientServerProxy {} 155 | 156 | class WriteStream extends Writable implements fs.WriteStream { 157 | public get bytesWritten(): number { 158 | throw new Error("not implemented") 159 | } 160 | 161 | public get path(): string | Buffer { 162 | throw new Error("not implemented") 163 | } 164 | 165 | public close(): void { 166 | this.catch(this.proxy.close()) 167 | } 168 | } 169 | 170 | interface ClientFsModuleProxy extends FsModuleProxy, ClientServerProxy { 171 | createReadStream(path: fs.PathLike, options?: any): Promise 172 | createWriteStream(path: fs.PathLike, options?: any): Promise 173 | watch(filename: fs.PathLike, options?: EncodingOptions): Promise 174 | } 175 | 176 | export class FsModule { 177 | private readonly statBatch: StatBatch 178 | private readonly lstatBatch: LstatBatch 179 | private readonly readdirBatch: ReaddirBatch 180 | 181 | public constructor(private readonly proxy: ClientFsModuleProxy) { 182 | this.statBatch = new StatBatch(this.proxy) 183 | this.lstatBatch = new LstatBatch(this.proxy) 184 | this.readdirBatch = new ReaddirBatch(this.proxy) 185 | } 186 | 187 | public access = ( 188 | path: fs.PathLike, 189 | mode: number | undefined | ((err: NodeJS.ErrnoException) => void), 190 | callback?: (err: NodeJS.ErrnoException) => void 191 | ): void => { 192 | if (typeof mode === "function") { 193 | callback = mode 194 | mode = undefined 195 | } 196 | callbackify(this.proxy.access)(path, mode, callback!) 197 | } 198 | 199 | public appendFile = ( 200 | path: fs.PathLike | number, 201 | data: any, 202 | options?: fs.WriteFileOptions | ((err: NodeJS.ErrnoException) => void), 203 | callback?: (err: NodeJS.ErrnoException) => void 204 | ): void => { 205 | if (typeof options === "function") { 206 | callback = options 207 | options = undefined 208 | } 209 | callbackify(this.proxy.appendFile)(path, data, options, callback!) 210 | } 211 | 212 | public chmod = (path: fs.PathLike, mode: string | number, callback: (err: NodeJS.ErrnoException) => void): void => { 213 | callbackify(this.proxy.chmod)(path, mode, callback!) 214 | } 215 | 216 | public chown = ( 217 | path: fs.PathLike, 218 | uid: number, 219 | gid: number, 220 | callback: (err: NodeJS.ErrnoException) => void 221 | ): void => { 222 | callbackify(this.proxy.chown)(path, uid, gid, callback!) 223 | } 224 | 225 | public close = (fd: number, callback: (err: NodeJS.ErrnoException) => void): void => { 226 | callbackify(this.proxy.close)(fd, callback!) 227 | } 228 | 229 | public copyFile = ( 230 | src: fs.PathLike, 231 | dest: fs.PathLike, 232 | flags: number | ((err: NodeJS.ErrnoException) => void), 233 | callback?: (err: NodeJS.ErrnoException) => void 234 | ): void => { 235 | if (typeof flags === "function") { 236 | callback = flags 237 | } 238 | callbackify(this.proxy.copyFile)(src, dest, typeof flags !== "function" ? flags : undefined, callback!) 239 | } 240 | 241 | public createReadStream = (path: fs.PathLike, options?: any): fs.ReadStream => { 242 | return new ReadStream(this.proxy.createReadStream(path, options)) 243 | } 244 | 245 | public createWriteStream = (path: fs.PathLike, options?: any): fs.WriteStream => { 246 | return new WriteStream(this.proxy.createWriteStream(path, options)) 247 | } 248 | 249 | public exists = (path: fs.PathLike, callback: (exists: boolean) => void): void => { 250 | this.proxy 251 | .exists(path) 252 | .then((exists) => callback(exists)) 253 | .catch(() => callback(false)) 254 | } 255 | 256 | public fchmod = (fd: number, mode: string | number, callback: (err: NodeJS.ErrnoException) => void): void => { 257 | callbackify(this.proxy.fchmod)(fd, mode, callback!) 258 | } 259 | 260 | public fchown = (fd: number, uid: number, gid: number, callback: (err: NodeJS.ErrnoException) => void): void => { 261 | callbackify(this.proxy.fchown)(fd, uid, gid, callback!) 262 | } 263 | 264 | public fdatasync = (fd: number, callback: (err: NodeJS.ErrnoException) => void): void => { 265 | callbackify(this.proxy.fdatasync)(fd, callback!) 266 | } 267 | 268 | public fstat = (fd: number, callback: (err: NodeJS.ErrnoException, stats: fs.Stats) => void): void => { 269 | callbackify(this.proxy.fstat)(fd, (error, stats) => { 270 | callback(error!, stats && new Stats(stats)) 271 | }) 272 | } 273 | 274 | public fsync = (fd: number, callback: (err: NodeJS.ErrnoException) => void): void => { 275 | callbackify(this.proxy.fsync)(fd, callback!) 276 | } 277 | 278 | public ftruncate = ( 279 | fd: number, 280 | len: number | undefined | null | ((err: NodeJS.ErrnoException) => void), 281 | callback?: (err: NodeJS.ErrnoException) => void 282 | ): void => { 283 | if (typeof len === "function") { 284 | callback = len 285 | len = undefined 286 | } 287 | callbackify(this.proxy.ftruncate)(fd, len, callback!) 288 | } 289 | 290 | public futimes = ( 291 | fd: number, 292 | atime: string | number | Date, 293 | mtime: string | number | Date, 294 | callback: (err: NodeJS.ErrnoException) => void 295 | ): void => { 296 | callbackify(this.proxy.futimes)(fd, atime, mtime, callback!) 297 | } 298 | 299 | public lchmod = (path: fs.PathLike, mode: string | number, callback: (err: NodeJS.ErrnoException) => void): void => { 300 | callbackify(this.proxy.lchmod)(path, mode, callback!) 301 | } 302 | 303 | public lchown = ( 304 | path: fs.PathLike, 305 | uid: number, 306 | gid: number, 307 | callback: (err: NodeJS.ErrnoException) => void 308 | ): void => { 309 | callbackify(this.proxy.lchown)(path, uid, gid, callback!) 310 | } 311 | 312 | public link = ( 313 | existingPath: fs.PathLike, 314 | newPath: fs.PathLike, 315 | callback: (err: NodeJS.ErrnoException) => void 316 | ): void => { 317 | callbackify(this.proxy.link)(existingPath, newPath, callback!) 318 | } 319 | 320 | public lstat = (path: fs.PathLike, callback: (err: NodeJS.ErrnoException, stats: fs.Stats) => void): void => { 321 | callbackify(this.lstatBatch.add)({ path }, (error, stats) => { 322 | callback(error!, stats && new Stats(stats)) 323 | }) 324 | } 325 | 326 | public mkdir = ( 327 | path: fs.PathLike, 328 | mode: number | string | fs.MakeDirectoryOptions | undefined | null | ((err: NodeJS.ErrnoException) => void), 329 | callback?: (err: NodeJS.ErrnoException) => void 330 | ): void => { 331 | if (typeof mode === "function") { 332 | callback = mode 333 | mode = undefined 334 | } 335 | callbackify(this.proxy.mkdir)(path, mode, callback!) 336 | } 337 | 338 | public mkdtemp = ( 339 | prefix: string, 340 | options: EncodingOptionsCallback, 341 | callback?: (err: NodeJS.ErrnoException | null, folder: string | Buffer) => void 342 | ): void => { 343 | if (typeof options === "function") { 344 | callback = options 345 | options = undefined 346 | } 347 | callbackify(this.proxy.mkdtemp)(prefix, options, callback!) 348 | } 349 | 350 | public open = ( 351 | path: fs.PathLike, 352 | flags: string | number, 353 | mode: string | number | undefined | null | ((err: NodeJS.ErrnoException | null, fd: number) => void), 354 | callback?: (err: NodeJS.ErrnoException | null, fd: number) => void 355 | ): void => { 356 | if (typeof mode === "function") { 357 | callback = mode 358 | mode = undefined 359 | } 360 | callbackify(this.proxy.open)(path, flags, mode, callback!) 361 | } 362 | 363 | public read = ( 364 | fd: number, 365 | buffer: Buffer, 366 | offset: number, 367 | length: number, 368 | position: number | null, 369 | callback: (err: NodeJS.ErrnoException, bytesRead: number, buffer: Buffer) => void 370 | ): void => { 371 | this.proxy 372 | .read(fd, length, position) 373 | .then((response) => { 374 | buffer.set(response.buffer, offset) 375 | callback(undefined!, response.bytesRead, response.buffer) 376 | }) 377 | .catch((error) => { 378 | callback(error, undefined!, undefined!) 379 | }) 380 | } 381 | 382 | public readFile = ( 383 | path: fs.PathLike | number, 384 | options: EncodingOptionsCallback, 385 | callback?: (err: NodeJS.ErrnoException | null, data: string | Buffer) => void 386 | ): void => { 387 | if (typeof options === "function") { 388 | callback = options 389 | options = undefined 390 | } 391 | callbackify(this.proxy.readFile)(path, options, callback!) 392 | } 393 | 394 | public readdir = ( 395 | path: fs.PathLike, 396 | options: EncodingOptionsCallback, 397 | callback?: (err: NodeJS.ErrnoException | null, files: Buffer[] | fs.Dirent[] | string[]) => void 398 | ): void => { 399 | if (typeof options === "function") { 400 | callback = options 401 | options = undefined 402 | } 403 | callbackify(this.readdirBatch.add)({ path, options }, callback!) 404 | } 405 | 406 | public readlink = ( 407 | path: fs.PathLike, 408 | options: EncodingOptionsCallback, 409 | callback?: (err: NodeJS.ErrnoException | null, linkString: string | Buffer) => void 410 | ): void => { 411 | if (typeof options === "function") { 412 | callback = options 413 | options = undefined 414 | } 415 | callbackify(this.proxy.readlink)(path, options, callback!) 416 | } 417 | 418 | public realpath = ( 419 | path: fs.PathLike, 420 | options: EncodingOptionsCallback, 421 | callback?: (err: NodeJS.ErrnoException | null, resolvedPath: string | Buffer) => void 422 | ): void => { 423 | if (typeof options === "function") { 424 | callback = options 425 | options = undefined 426 | } 427 | callbackify(this.proxy.realpath)(path, options, callback!) 428 | } 429 | 430 | public rename = ( 431 | oldPath: fs.PathLike, 432 | newPath: fs.PathLike, 433 | callback: (err: NodeJS.ErrnoException) => void 434 | ): void => { 435 | callbackify(this.proxy.rename)(oldPath, newPath, callback!) 436 | } 437 | 438 | public rmdir = (path: fs.PathLike, callback: (err: NodeJS.ErrnoException) => void): void => { 439 | callbackify(this.proxy.rmdir)(path, callback!) 440 | } 441 | 442 | public stat = (path: fs.PathLike, callback: (err: NodeJS.ErrnoException, stats: fs.Stats) => void): void => { 443 | callbackify(this.statBatch.add)({ path }, (error, stats) => { 444 | callback(error!, stats && new Stats(stats)) 445 | }) 446 | } 447 | 448 | public symlink = ( 449 | target: fs.PathLike, 450 | path: fs.PathLike, 451 | type: fs.symlink.Type | undefined | null | ((err: NodeJS.ErrnoException) => void), 452 | callback?: (err: NodeJS.ErrnoException) => void 453 | ): void => { 454 | if (typeof type === "function") { 455 | callback = type 456 | type = undefined 457 | } 458 | callbackify(this.proxy.symlink)(target, path, type, callback!) 459 | } 460 | 461 | public truncate = ( 462 | path: fs.PathLike, 463 | len: number | undefined | null | ((err: NodeJS.ErrnoException) => void), 464 | callback?: (err: NodeJS.ErrnoException) => void 465 | ): void => { 466 | if (typeof len === "function") { 467 | callback = len 468 | len = undefined 469 | } 470 | callbackify(this.proxy.truncate)(path, len, callback!) 471 | } 472 | 473 | public unlink = (path: fs.PathLike, callback: (err: NodeJS.ErrnoException) => void): void => { 474 | callbackify(this.proxy.unlink)(path, callback!) 475 | } 476 | 477 | public utimes = ( 478 | path: fs.PathLike, 479 | atime: string | number | Date, 480 | mtime: string | number | Date, 481 | callback: (err: NodeJS.ErrnoException) => void 482 | ): void => { 483 | callbackify(this.proxy.utimes)(path, atime, mtime, callback!) 484 | } 485 | 486 | public write = ( 487 | fd: number, 488 | buffer: Buffer, 489 | offset: number | undefined | ((err: NodeJS.ErrnoException, written: number, buffer: Buffer) => void), 490 | length: number | undefined | ((err: NodeJS.ErrnoException, written: number, buffer: Buffer) => void), 491 | position: number | undefined | ((err: NodeJS.ErrnoException, written: number, buffer: Buffer) => void), 492 | callback?: (err: NodeJS.ErrnoException, written: number, buffer: Buffer) => void 493 | ): void => { 494 | if (typeof offset === "function") { 495 | callback = offset 496 | offset = undefined 497 | } 498 | if (typeof length === "function") { 499 | callback = length 500 | length = undefined 501 | } 502 | if (typeof position === "function") { 503 | callback = position 504 | position = undefined 505 | } 506 | this.proxy 507 | .write(fd, buffer, offset, length, position) 508 | .then((r) => { 509 | callback!(undefined!, r.bytesWritten, r.buffer) 510 | }) 511 | .catch((error) => { 512 | callback!(error, undefined!, undefined!) 513 | }) 514 | } 515 | 516 | public writeFile = ( 517 | path: fs.PathLike | number, 518 | data: any, 519 | options: EncodingOptionsCallback, 520 | callback?: (err: NodeJS.ErrnoException) => void 521 | ): void => { 522 | if (typeof options === "function") { 523 | callback = options 524 | options = undefined 525 | } 526 | callbackify(this.proxy.writeFile)(path, data, options, callback!) 527 | } 528 | 529 | public watch = ( 530 | filename: fs.PathLike, 531 | options?: EncodingOptions | ((event: string, filename: string | Buffer) => void), 532 | listener?: (event: string, filename: string | Buffer) => void 533 | ): fs.FSWatcher => { 534 | if (typeof options === "function") { 535 | listener = options 536 | options = undefined 537 | } 538 | 539 | const watcher = new Watcher(this.proxy.watch(filename, options)) 540 | if (listener) { 541 | watcher.on("change", listener) 542 | } 543 | 544 | return watcher 545 | } 546 | } 547 | -------------------------------------------------------------------------------- /src/client/net.ts: -------------------------------------------------------------------------------- 1 | import * as net from "net" 2 | import { callbackify } from "util" 3 | import { ClientProxy, ClientServerProxy } from "../common/proxy" 4 | import { NetModuleProxy, NetServerProxy, NetSocketProxy } from "../server/net" 5 | import { Duplex } from "./stream" 6 | 7 | /* eslint-disable @typescript-eslint/no-explicit-any */ 8 | 9 | interface ClientNetSocketProxy extends NetSocketProxy, ClientServerProxy {} 10 | 11 | export class Socket extends Duplex implements net.Socket { 12 | private _connecting = false 13 | private _destroyed = false 14 | 15 | public constructor(proxyPromise: Promise | ClientNetSocketProxy, connecting?: boolean) { 16 | super(proxyPromise) 17 | if (connecting) { 18 | this._connecting = connecting 19 | } 20 | this.on("close", () => { 21 | this._destroyed = true 22 | this._connecting = false 23 | }) 24 | this.on("connect", () => (this._connecting = false)) 25 | } 26 | 27 | public connect( 28 | options: number | string | net.SocketConnectOpts, 29 | host?: string | Function, 30 | callback?: Function 31 | ): this { 32 | if (typeof host === "function") { 33 | callback = host 34 | host = undefined 35 | } 36 | this._connecting = true 37 | if (callback) { 38 | this.on("connect", callback as () => void) 39 | } 40 | 41 | return this.catch(this.proxy.connect(options, host)) 42 | } 43 | 44 | public end(data?: any, encoding?: string | Function, callback?: Function): void { 45 | if (typeof encoding === "function") { 46 | callback = encoding 47 | encoding = undefined 48 | } 49 | 50 | callbackify(this.proxy.end)(data, encoding, () => { 51 | if (callback) { 52 | callback() 53 | } 54 | }) 55 | } 56 | 57 | public write(data: any, encoding?: string | Function, fd?: string | Function): boolean { 58 | let callback: undefined | Function 59 | if (typeof encoding === "function") { 60 | callback = encoding 61 | encoding = undefined 62 | } 63 | if (typeof fd === "function") { 64 | callback = fd 65 | fd = undefined 66 | } 67 | if (typeof fd !== "undefined") { 68 | throw new Error("fd argument not supported") 69 | } 70 | 71 | callbackify(this.proxy.write)(data, encoding, () => { 72 | if (callback) { 73 | callback() 74 | } 75 | }) 76 | 77 | return true // Always true since we can't get this synchronously. 78 | } 79 | 80 | public get connecting(): boolean { 81 | return this._connecting 82 | } 83 | 84 | public get destroyed(): boolean { 85 | return this._destroyed 86 | } 87 | 88 | public get bufferSize(): number { 89 | throw new Error("not implemented") 90 | } 91 | 92 | public get bytesRead(): number { 93 | throw new Error("not implemented") 94 | } 95 | 96 | public get bytesWritten(): number { 97 | throw new Error("not implemented") 98 | } 99 | 100 | public get localAddress(): string { 101 | throw new Error("not implemented") 102 | } 103 | 104 | public get localPort(): number { 105 | throw new Error("not implemented") 106 | } 107 | 108 | public address(): net.AddressInfo | string { 109 | throw new Error("not implemented") 110 | } 111 | 112 | public setTimeout(): this { 113 | throw new Error("not implemented") 114 | } 115 | 116 | public setNoDelay(): this { 117 | throw new Error("not implemented") 118 | } 119 | 120 | public setKeepAlive(): this { 121 | throw new Error("not implemented") 122 | } 123 | 124 | public unref(): void { 125 | this.catch(this.proxy.unref()) 126 | } 127 | 128 | public ref(): void { 129 | this.catch(this.proxy.ref()) 130 | } 131 | } 132 | 133 | interface ClientNetServerProxy extends NetServerProxy, ClientServerProxy { 134 | onConnection(cb: (proxy: ClientNetSocketProxy) => void): Promise 135 | } 136 | 137 | export class Server extends ClientProxy implements net.Server { 138 | private socketId = 0 139 | private readonly sockets = new Map() 140 | private _listening = false 141 | 142 | public constructor(proxyPromise: Promise | ClientNetServerProxy) { 143 | super(proxyPromise) 144 | 145 | this.catch( 146 | this.proxy.onConnection((socketProxy) => { 147 | const socket = new Socket(socketProxy) 148 | const socketId = this.socketId++ 149 | this.sockets.set(socketId, socket) 150 | socket.onInternalError(() => this.sockets.delete(socketId)) 151 | socket.on("close", () => this.sockets.delete(socketId)) 152 | this.emit("connection", socket) 153 | }) 154 | ) 155 | 156 | this.on("listening", () => (this._listening = true)) 157 | this.onInternalError(() => (this._listening = false)) 158 | this.on("close", () => (this._listening = false)) 159 | } 160 | 161 | public listen( 162 | handle?: net.ListenOptions | number | string, 163 | hostname?: string | number | Function, 164 | backlog?: number | Function, 165 | callback?: Function 166 | ): this { 167 | if (typeof hostname === "function") { 168 | callback = hostname 169 | hostname = undefined 170 | } 171 | if (typeof backlog === "function") { 172 | callback = backlog 173 | backlog = undefined 174 | } 175 | if (callback) { 176 | this.on("listening", callback as () => void) 177 | } 178 | 179 | return this.catch(this.proxy.listen(handle, hostname, backlog)) 180 | } 181 | 182 | public get connections(): number { 183 | return this.sockets.size 184 | } 185 | 186 | public get listening(): boolean { 187 | return this._listening 188 | } 189 | 190 | public get maxConnections(): number { 191 | throw new Error("not implemented") 192 | } 193 | 194 | public address(): net.AddressInfo | string { 195 | throw new Error("not implemented") 196 | } 197 | 198 | public close(callback?: () => void): this { 199 | this._listening = false 200 | if (callback) { 201 | this.on("close", callback) 202 | } 203 | 204 | return this.catch(this.proxy.close()) 205 | } 206 | 207 | public ref(): this { 208 | return this.catch(this.proxy.ref()) 209 | } 210 | 211 | public unref(): this { 212 | return this.catch(this.proxy.unref()) 213 | } 214 | 215 | public getConnections(cb: (error: Error | null, count: number) => void): void { 216 | cb(null, this.sockets.size) 217 | } 218 | 219 | protected handleDisconnect(): void { 220 | this.emit("close") 221 | } 222 | } 223 | 224 | type NodeNet = typeof net 225 | 226 | interface ClientNetModuleProxy extends NetModuleProxy, ClientServerProxy { 227 | createSocket(options?: net.SocketConstructorOpts): Promise 228 | createConnection(target: string | number | net.NetConnectOpts, host?: string): Promise 229 | createServer(options?: { allowHalfOpen?: boolean; pauseOnConnect?: boolean }): Promise 230 | } 231 | 232 | export class NetModule implements NodeNet { 233 | public readonly Socket: typeof net.Socket 234 | public readonly Server: typeof net.Server 235 | 236 | public constructor(private readonly proxy: ClientNetModuleProxy) { 237 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 238 | // @ts-ignore this is because Socket is missing things from the Stream 239 | // namespace but I'm unsure how best to provide them (finished, 240 | // finished.__promisify__, pipeline, and some others) or if it even matters. 241 | this.Socket = class extends Socket { 242 | public constructor(options?: net.SocketConstructorOpts) { 243 | super(proxy.createSocket(options)) 244 | } 245 | } 246 | 247 | this.Server = class extends Server { 248 | public constructor( 249 | options?: { allowHalfOpen?: boolean; pauseOnConnect?: boolean } | ((socket: Socket) => void), 250 | listener?: (socket: Socket) => void 251 | ) { 252 | super(proxy.createServer(typeof options !== "function" ? options : undefined)) 253 | if (typeof options === "function") { 254 | listener = options 255 | } 256 | if (listener) { 257 | this.on("connection", listener) 258 | } 259 | } 260 | } 261 | } 262 | 263 | public createConnection = ( 264 | target: string | number | net.NetConnectOpts, 265 | host?: string | Function, 266 | callback?: Function 267 | ): net.Socket => { 268 | if (typeof host === "function") { 269 | callback = host 270 | host = undefined 271 | } 272 | 273 | const socket = new Socket(this.proxy.createConnection(target, host), true) 274 | if (callback) { 275 | socket.on("connect", callback as () => void) 276 | } 277 | 278 | return socket 279 | } 280 | 281 | public createServer = ( 282 | options?: { allowHalfOpen?: boolean; pauseOnConnect?: boolean } | ((socket: net.Socket) => void), 283 | callback?: (socket: net.Socket) => void 284 | ): net.Server => { 285 | if (typeof options === "function") { 286 | callback = options 287 | options = undefined 288 | } 289 | 290 | const server = new Server(this.proxy.createServer(options)) 291 | if (callback) { 292 | server.on("connection", callback) 293 | } 294 | 295 | return server 296 | } 297 | 298 | public connect = (): net.Socket => { 299 | throw new Error("not implemented") 300 | } 301 | 302 | public isIP = (): number => { 303 | throw new Error("not implemented") 304 | } 305 | 306 | public isIPv4 = (): boolean => { 307 | throw new Error("not implemented") 308 | } 309 | 310 | public isIPv6 = (): boolean => { 311 | throw new Error("not implemented") 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/client/os.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os" 2 | import { NotImplementedProxy } from "../common/proxy" 3 | import * as Message from "../common/messages" 4 | 5 | type NodeOs = typeof os 6 | 7 | export class OsModule extends NotImplementedProxy implements Partial { 8 | private data = { 9 | homedir: "", 10 | eol: "\n", 11 | } 12 | 13 | public constructor(initData: Promise) { 14 | super("os") 15 | initData.then((data) => { 16 | this.data = { ...data.os } 17 | }) 18 | } 19 | 20 | public homedir(): string { 21 | return this.data.homedir 22 | } 23 | 24 | public get EOL(): string { 25 | return this.data.eol 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/client/stream.ts: -------------------------------------------------------------------------------- 1 | import * as stream from "stream" 2 | import { callbackify } from "util" 3 | import { ClientProxy, ClientServerProxy } from "../common/proxy" 4 | import { isPromise } from "../common/util" 5 | import { DuplexProxy, ReadableProxy, WritableProxy } from "../server/stream" 6 | 7 | /* eslint-disable @typescript-eslint/no-explicit-any */ 8 | 9 | export interface ClientWritableProxy extends WritableProxy, ClientServerProxy {} 10 | 11 | export class Writable extends ClientProxy 12 | implements stream.Writable { 13 | public get writable(): boolean { 14 | throw new Error("not implemented") 15 | } 16 | 17 | public get writableFinished(): boolean { 18 | throw new Error("not implemented") 19 | } 20 | 21 | public get writableHighWaterMark(): number { 22 | throw new Error("not implemented") 23 | } 24 | 25 | public get writableLength(): number { 26 | throw new Error("not implemented") 27 | } 28 | 29 | public _write(): void { 30 | throw new Error("not implemented") 31 | } 32 | 33 | public _destroy(): void { 34 | throw new Error("not implemented") 35 | } 36 | 37 | public _final(): void { 38 | throw new Error("not implemented") 39 | } 40 | 41 | public pipe(): T { 42 | throw new Error("not implemented") 43 | } 44 | 45 | public cork(): void { 46 | throw new Error("not implemented") 47 | } 48 | 49 | public uncork(): void { 50 | throw new Error("not implemented") 51 | } 52 | 53 | public destroy(): void { 54 | this.catch(this.proxy.destroy()) 55 | } 56 | 57 | public setDefaultEncoding(encoding: string): this { 58 | return this.catch(this.proxy.setDefaultEncoding(encoding)) 59 | } 60 | 61 | public write( 62 | chunk: any, 63 | encoding?: string | ((error?: Error | null) => void), 64 | callback?: (error?: Error | null) => void 65 | ): boolean { 66 | if (typeof encoding === "function") { 67 | callback = encoding 68 | encoding = undefined 69 | } 70 | callbackify(this.proxy.write)(chunk, encoding, (error) => { 71 | if (callback) { 72 | callback(error) 73 | } 74 | }) 75 | 76 | return true // Always true since we can't get this synchronously. 77 | } 78 | 79 | public end(data?: any | (() => void), encoding?: string | (() => void), callback?: () => void): void { 80 | if (typeof data === "function") { 81 | callback = data 82 | data = undefined 83 | } 84 | if (typeof encoding === "function") { 85 | callback = encoding 86 | encoding = undefined 87 | } 88 | callbackify(this.proxy.end)(data, encoding, () => { 89 | if (callback) { 90 | callback() 91 | } 92 | }) 93 | } 94 | 95 | protected handleDisconnect(): void { 96 | this.emit("close") 97 | this.emit("finish") 98 | } 99 | } 100 | 101 | export interface ClientReadableProxy extends ReadableProxy, ClientServerProxy {} 102 | 103 | export class Readable extends ClientProxy 104 | implements stream.Readable { 105 | public get readable(): boolean { 106 | throw new Error("not implemented") 107 | } 108 | 109 | public get readableHighWaterMark(): number { 110 | throw new Error("not implemented") 111 | } 112 | 113 | public get readableLength(): number { 114 | throw new Error("not implemented") 115 | } 116 | 117 | public _read(): void { 118 | throw new Error("not implemented") 119 | } 120 | 121 | public read(): void { 122 | throw new Error("not implemented") 123 | } 124 | 125 | public _destroy(): void { 126 | throw new Error("not implemented") 127 | } 128 | 129 | public unpipe(): this { 130 | throw new Error("not implemented") 131 | } 132 | 133 | public pause(): this { 134 | throw new Error("not implemented") 135 | } 136 | 137 | public resume(): this { 138 | throw new Error("not implemented") 139 | } 140 | 141 | public isPaused(): boolean { 142 | throw new Error("not implemented") 143 | } 144 | 145 | public wrap(): this { 146 | throw new Error("not implemented") 147 | } 148 | 149 | public push(): boolean { 150 | throw new Error("not implemented") 151 | } 152 | 153 | public unshift(): void { 154 | throw new Error("not implemented") 155 | } 156 | 157 | public pipe

(destination: P, options?: { end?: boolean }): P { 158 | const writableProxy = ((destination as any) as Writable).proxyPromise 159 | if (!writableProxy) { 160 | throw new Error("can only pipe stream proxies") 161 | } 162 | this.catch( 163 | isPromise(writableProxy) 164 | ? writableProxy.then((p) => 165 | this.proxy.pipe( 166 | p, 167 | options 168 | ) 169 | ) 170 | : this.proxy.pipe( 171 | writableProxy, 172 | options 173 | ) 174 | ) 175 | 176 | return destination 177 | } 178 | 179 | public [Symbol.asyncIterator](): AsyncIterableIterator { 180 | throw new Error("not implemented") 181 | } 182 | 183 | public destroy(): void { 184 | this.catch(this.proxy.destroy()) 185 | } 186 | 187 | public setEncoding(encoding: string): this { 188 | return this.catch(this.proxy.setEncoding(encoding)) 189 | } 190 | 191 | protected handleDisconnect(): void { 192 | this.emit("close") 193 | this.emit("end") 194 | } 195 | } 196 | 197 | export interface ClientDuplexProxy extends DuplexProxy, ClientServerProxy {} 198 | 199 | export class Duplex extends Writable 200 | implements stream.Duplex, stream.Readable { 201 | private readonly _readable: Readable 202 | 203 | public constructor(proxyPromise: Promise | T) { 204 | super(proxyPromise) 205 | this._readable = new Readable(proxyPromise, false) 206 | } 207 | 208 | public get readable(): boolean { 209 | return this._readable.readable 210 | } 211 | 212 | public get readableHighWaterMark(): number { 213 | return this._readable.readableHighWaterMark 214 | } 215 | 216 | public get readableLength(): number { 217 | return this._readable.readableLength 218 | } 219 | 220 | public _read(): void { 221 | this._readable._read() 222 | } 223 | 224 | public read(): void { 225 | this._readable.read() 226 | } 227 | 228 | public unpipe(): this { 229 | this._readable.unpipe() 230 | 231 | return this 232 | } 233 | 234 | public pause(): this { 235 | this._readable.unpipe() 236 | 237 | return this 238 | } 239 | 240 | public resume(): this { 241 | this._readable.resume() 242 | 243 | return this 244 | } 245 | 246 | public isPaused(): boolean { 247 | return this._readable.isPaused() 248 | } 249 | 250 | public wrap(): this { 251 | this._readable.wrap() 252 | 253 | return this 254 | } 255 | 256 | public push(): boolean { 257 | return this._readable.push() 258 | } 259 | 260 | public unshift(): void { 261 | this._readable.unshift() 262 | } 263 | 264 | public [Symbol.asyncIterator](): AsyncIterableIterator { 265 | return this._readable[Symbol.asyncIterator]() 266 | } 267 | 268 | public setEncoding(encoding: string): this { 269 | return this.catch(this.proxy.setEncoding(encoding)) 270 | } 271 | 272 | protected handleDisconnect(): void { 273 | super.handleDisconnect() 274 | this.emit("end") 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/common/arguments.ts: -------------------------------------------------------------------------------- 1 | import { ClientServerProxy, Module, ServerProxy } from "./proxy" 2 | import { isNonModuleProxy } from "./util" 3 | 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | 6 | enum Type { 7 | Array = "array", 8 | Buffer = "buffer", 9 | Date = "date", 10 | Error = "error", 11 | Function = "function", 12 | Object = "object", 13 | Proxy = "proxy", 14 | Undefined = "undefined", 15 | } 16 | 17 | interface EncodedArray { 18 | type: Type.Array 19 | values: Argument[] 20 | } 21 | interface EncodedBuffer { 22 | type: Type.Buffer 23 | data: Uint8Array 24 | } 25 | interface EncodedDate { 26 | type: Type.Date 27 | date: string 28 | } 29 | interface EncodedError { 30 | type: Type.Error 31 | code: string | undefined 32 | message: string 33 | stack: string | undefined 34 | } 35 | interface EncodedFunction { 36 | type: Type.Function 37 | id: number 38 | } 39 | interface EncodedObject { 40 | type: Type.Object 41 | values: { [key: string]: Argument } 42 | } 43 | interface EncodedProxy { 44 | type: Type.Proxy 45 | id: number 46 | } 47 | interface EncodedUndefined { 48 | // undefined must be explicitly encoded because converting to JSON will strip 49 | // it or turn it into null in some cases. 50 | type: Type.Undefined 51 | } 52 | 53 | export type Argument = 54 | | EncodedArray 55 | | EncodedBuffer 56 | | EncodedDate 57 | | EncodedError 58 | | EncodedFunction 59 | | EncodedObject 60 | | EncodedProxy 61 | | EncodedUndefined 62 | | string 63 | | number 64 | | boolean 65 | | null 66 | 67 | /** 68 | * Convert an argument for serialization. 69 | * If sending a function is possible, provide `storeFunction`. 70 | * If sending a proxy is possible, provide `storeProxy`. 71 | */ 72 | export const encode =

( 73 | value: any, 74 | storeFunction?: (fn: () => void) => number, 75 | storeProxy?: (proxy: P) => number | Module 76 | ): Argument => { 77 | const convert = (currentValue: any): Argument => { 78 | if (isNonModuleProxy

(currentValue)) { 79 | if (!storeProxy) { 80 | throw new Error("no way to serialize proxy") 81 | } 82 | const id = storeProxy(currentValue) 83 | if (typeof id === "string") { 84 | throw new Error("unable to serialize module proxy") 85 | } 86 | return { type: Type.Proxy, id } as EncodedProxy 87 | } else if ( 88 | currentValue instanceof Error || 89 | (currentValue && typeof currentValue.message !== "undefined" && typeof currentValue.stack !== "undefined") 90 | ) { 91 | return { 92 | type: Type.Error, 93 | code: currentValue.code, 94 | message: currentValue.message, 95 | stack: currentValue.stack, 96 | } as EncodedError 97 | } else if (currentValue instanceof Uint8Array || currentValue instanceof Buffer) { 98 | return { type: Type.Buffer, data: currentValue } as EncodedBuffer 99 | } else if (Array.isArray(currentValue)) { 100 | return { type: Type.Array, values: currentValue.map(convert) } as EncodedArray 101 | } else if (currentValue instanceof Date || (currentValue && typeof currentValue.getTime === "function")) { 102 | return { type: Type.Date, date: currentValue.toString() } 103 | } else if (currentValue !== null && typeof currentValue === "object") { 104 | const values: { [key: string]: Argument } = {} 105 | Object.keys(currentValue).forEach((key) => { 106 | values[key] = convert(currentValue[key]) 107 | }) 108 | return { type: Type.Object, values } as EncodedObject 109 | } else if (currentValue === null) { 110 | return currentValue 111 | } 112 | switch (typeof currentValue) { 113 | case "undefined": 114 | return { type: Type.Undefined } as EncodedUndefined 115 | case "function": 116 | if (!storeFunction) { 117 | throw new Error("no way to serialize function") 118 | } 119 | return { type: Type.Function, id: storeFunction(currentValue) } as EncodedFunction 120 | case "number": 121 | case "string": 122 | case "boolean": 123 | return currentValue 124 | } 125 | throw new Error(`cannot convert ${typeof currentValue} to encoded argument`) 126 | } 127 | 128 | return convert(value) 129 | } 130 | 131 | /** 132 | * Decode arguments into their original values. 133 | * If running a remote callback is supported, provide `runCallback`. 134 | * If using a remote proxy is supported, provide `createProxy`. 135 | */ 136 | export const decode = ( 137 | argument?: Argument, 138 | runCallback?: (id: number, args: any[]) => void, 139 | createProxy?: (id: number) => ServerProxy 140 | ): any => { 141 | const convert = (currentArgument: Argument): any => { 142 | switch (typeof currentArgument) { 143 | case "number": 144 | case "string": 145 | case "boolean": 146 | return currentArgument 147 | } 148 | if (currentArgument === null) { 149 | return currentArgument 150 | } 151 | switch (currentArgument.type) { 152 | case Type.Array: 153 | return currentArgument.values.map(convert) 154 | case Type.Buffer: 155 | return Buffer.from((currentArgument as EncodedBuffer).data) 156 | case Type.Date: 157 | return new Date((currentArgument as EncodedDate).date) 158 | case Type.Error: { 159 | const error = new Error((currentArgument as EncodedError).message) 160 | ;(error as NodeJS.ErrnoException).code = (currentArgument as EncodedError).code 161 | ;(error as any).originalStack = (currentArgument as EncodedError).stack 162 | return error 163 | } 164 | case Type.Function: 165 | if (!runCallback) { 166 | throw new Error("no way to run remote callback") 167 | } 168 | return (...args: any[]): void => { 169 | return runCallback((currentArgument as EncodedFunction).id, args) 170 | } 171 | case Type.Object: { 172 | const obj: { [Key: string]: any } = {} 173 | Object.keys((currentArgument as EncodedObject).values).forEach((key) => { 174 | obj[key] = convert((currentArgument as EncodedObject).values[key]) 175 | }) 176 | return obj 177 | } 178 | case Type.Proxy: 179 | if (!createProxy) { 180 | throw new Error("no way to create proxy") 181 | } 182 | return createProxy((currentArgument as EncodedProxy).id) 183 | case Type.Undefined: 184 | return undefined 185 | } 186 | throw new Error("cannot convert unexpected encoded argument to value") 187 | } 188 | 189 | return argument && convert(argument) 190 | } 191 | -------------------------------------------------------------------------------- /src/common/clientMessages.ts: -------------------------------------------------------------------------------- 1 | import { Argument } from "./arguments" 2 | import { Module } from "./proxy" 3 | 4 | export enum Type { 5 | Proxy = "proxy", 6 | Ping = "ping", 7 | Handshake = "handshake", 8 | } 9 | 10 | export interface Proxy { 11 | type: Type.Proxy 12 | clientId: number 13 | messageId: number 14 | proxyId: Module | number 15 | method: string 16 | args: Argument[] 17 | } 18 | 19 | export interface Ping { 20 | type: Type.Ping 21 | clientId: number 22 | } 23 | 24 | export interface Handshake { 25 | type: Type.Handshake 26 | clientId: number 27 | } 28 | 29 | export type Message = Proxy | Ping | Handshake 30 | -------------------------------------------------------------------------------- /src/common/connection.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | export interface SendableConnection { 4 | send(data: string): void 5 | } 6 | 7 | export interface ReadWriteConnection extends SendableConnection { 8 | /** 9 | * Message received from the connection. 10 | */ 11 | onMessage(cb: (data: string) => void): void 12 | /** 13 | * Indicates permanent closure, meaning we should dispose. 14 | */ 15 | onClose(cb: () => void): void 16 | /** 17 | * Connection is temporarily down. 18 | */ 19 | onDown(cb: () => void): void 20 | /** 21 | * Connection is back up. 22 | */ 23 | onUp(cb: () => void): void 24 | } 25 | 26 | export type LoggerArgument = (() => Array<{ [key: string]: any }>) | { [key: string]: any } 27 | 28 | export interface Logger { 29 | trace(message: string, ...args: LoggerArgument[]): void 30 | error(message: string, ...args: LoggerArgument[]): void 31 | } 32 | 33 | export class DefaultLogger implements Logger { 34 | public constructor(private readonly name: string) {} 35 | 36 | public trace(message: string, ...args: LoggerArgument[]): void { 37 | if (typeof process !== "undefined" && process.env && process.env.LOG_LEVEL === "trace") { 38 | this.log(message, ...args) 39 | } 40 | } 41 | 42 | public error(message: string, ...args: LoggerArgument[]): void { 43 | this.log(message, ...args) 44 | } 45 | 46 | private log(message: string, ...args: LoggerArgument[]): void { 47 | console.log(`[${this.name}]`, message, ...args.map((a) => JSON.stringify(typeof a === "function" ? a() : a))) 48 | } 49 | } 50 | 51 | export enum ConnectionStatus { 52 | Disconnected, 53 | Connected, 54 | Closed, // This status is permanent. 55 | } 56 | -------------------------------------------------------------------------------- /src/common/events.ts: -------------------------------------------------------------------------------- 1 | import { Disposable } from "./util" 2 | 3 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 4 | 5 | export interface Event { 6 | (listener: (value: T) => void): Disposable 7 | (id: number | string, listener: (value: T) => void): Disposable 8 | } 9 | 10 | /** 11 | * Emitter typecasts for a single event type. You can optionally use IDs, but 12 | * using undefined with IDs will not work. If you emit without an ID, *all* 13 | * listeners regardless of their ID (or lack thereof) will receive the event. 14 | * Similarly, if you listen without an ID you will get *all* events for any or 15 | * no ID. 16 | */ 17 | export class Emitter { 18 | private listeners: Array<(value: T) => void> = [] 19 | private readonly idListeners = new Map void>>() 20 | 21 | public get event(): Event { 22 | return (id: number | string | ((value: T) => void), cb?: (value: T) => void): Disposable => { 23 | if (typeof id !== "function") { 24 | if (this.idListeners.has(id)) { 25 | this.idListeners.get(id)!.push(cb!) 26 | } else { 27 | this.idListeners.set(id, [cb!]) 28 | } 29 | 30 | return { 31 | dispose: (): void => { 32 | if (this.idListeners.has(id)) { 33 | const cbs = this.idListeners.get(id)! 34 | const i = cbs.indexOf(cb!) 35 | if (i !== -1) { 36 | cbs.splice(i, 1) 37 | } 38 | } 39 | }, 40 | } 41 | } 42 | 43 | cb = id 44 | this.listeners.push(cb) 45 | 46 | return { 47 | dispose: (): void => { 48 | const i = this.listeners.indexOf(cb!) 49 | if (i !== -1) { 50 | this.listeners.splice(i, 1) 51 | } 52 | }, 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * Emit an event with a value. 59 | */ 60 | public emit(value: T): void 61 | public emit(id: number | string, value: T): void 62 | public emit(id: number | string | T, value?: T): void { 63 | if ((typeof id === "number" || typeof id === "string") && typeof value !== "undefined") { 64 | if (this.idListeners.has(id)) { 65 | this.idListeners.get(id)!.forEach((cb) => cb(value!)) 66 | } 67 | this.listeners.forEach((cb) => cb(value!)) 68 | } else { 69 | this.idListeners.forEach((cbs) => cbs.forEach((cb) => cb((id as T)!))) 70 | this.listeners.forEach((cb) => cb((id as T)!)) 71 | } 72 | } 73 | 74 | /** 75 | * Dispose the current events. 76 | */ 77 | public dispose(): void 78 | public dispose(id: number | string): void 79 | public dispose(id?: number | string): void { 80 | if (typeof id !== "undefined") { 81 | this.idListeners.delete(id) 82 | } else { 83 | this.listeners = [] 84 | this.idListeners.clear() 85 | } 86 | } 87 | 88 | public get counts(): { [key: string]: number } { 89 | const counts: { [key: string]: number } = {} 90 | if (this.listeners.length > 0) { 91 | counts["n/a"] = this.listeners.length 92 | } 93 | this.idListeners.forEach((cbs, id) => { 94 | if (cbs.length > 0) { 95 | counts[`${id}`] = cbs.length 96 | } 97 | }) 98 | 99 | return counts 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/common/messages.ts: -------------------------------------------------------------------------------- 1 | import * as Client from "./clientMessages" 2 | import * as Server from "./serverMessages" 3 | 4 | export { Client, Server } 5 | -------------------------------------------------------------------------------- /src/common/proxy.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events" 2 | import { isPromise } from "./util" 3 | 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | 6 | // This is so we can internally listen to errors for cleaning up without 7 | // removing the ability to throw if nothing external is listening. 8 | const internalErrorEvent = Symbol("error") 9 | 10 | export type EventCallback = (event: string, ...args: any[]) => void 11 | export type EncodingOptions = 12 | | { 13 | encoding?: BufferEncoding | null 14 | flag?: string 15 | mode?: string 16 | persistent?: boolean 17 | recursive?: boolean 18 | } 19 | | BufferEncoding 20 | | undefined 21 | | null 22 | export type EncodingOptionsCallback = EncodingOptions | ((err: NodeJS.ErrnoException | null, ...args: any[]) => void) 23 | 24 | /** 25 | * Allow using a proxy like it's returned synchronously. This only works because 26 | * all proxy methods must return promises. 27 | */ 28 | const unpromisify = (proxyPromise: Promise): T => { 29 | return new Proxy( 30 | {}, 31 | { 32 | get: (target: any, name: string): any => { 33 | if (typeof target[name] === "undefined") { 34 | target[name] = async (...args: any[]): Promise => { 35 | const proxy = await proxyPromise 36 | 37 | return proxy ? (proxy as any)[name](...args) : undefined 38 | } 39 | } 40 | 41 | return target[name] 42 | }, 43 | } 44 | ) 45 | } 46 | 47 | /** 48 | * Client-side emitter that just forwards server proxy events to its own 49 | * emitter. It also turns a promisified server proxy into a non-promisified 50 | * proxy so we don't need a bunch of `then` calls everywhere. 51 | */ 52 | export abstract class ClientProxy extends EventEmitter { 53 | private _proxy: T 54 | 55 | /** 56 | * You can specify not to bind events in order to avoid emitting twice for 57 | * duplex streams. 58 | */ 59 | public constructor(private _proxyPromise: Promise | T, private readonly bindEvents: boolean = true) { 60 | super() 61 | this._proxy = this.initialize(this._proxyPromise) 62 | if (this.bindEvents) { 63 | this.on("disconnected", (error) => { 64 | try { 65 | this.emit("error", error) 66 | } catch (error) { 67 | // If nothing is listening, EventEmitter will throw an error. 68 | } 69 | this.handleDisconnect() 70 | }) 71 | } 72 | } 73 | 74 | /** 75 | * Bind to the error event without counting as a listener for the purpose of 76 | * throwing if nothing is listening. 77 | */ 78 | public onInternalError(listener: (...args: any[]) => void): void { 79 | super.on(internalErrorEvent, listener) 80 | } 81 | 82 | /** 83 | * Bind the event locally and ensure the event is bound on the server. 84 | */ 85 | public addListener(event: string, listener: (...args: any[]) => void): this { 86 | this.catch(this.proxy.bindDelayedEvent(event)) 87 | 88 | return super.on(event, listener) 89 | } 90 | 91 | /** 92 | * Alias for `addListener`. 93 | */ 94 | public on(event: string, listener: (...args: any[]) => void): this { 95 | return this.addListener(event, listener) 96 | } 97 | 98 | /** 99 | * Same as the parent except also emit the internal error event for errors. 100 | */ 101 | public emit(event: string | symbol, ...args: any[]): boolean { 102 | if (event === "error") { 103 | super.emit(internalErrorEvent, ...args) 104 | } 105 | return super.emit(event, ...args) 106 | } 107 | 108 | /** 109 | * Original promise for the server proxy. Can be used to be passed as an 110 | * argument. 111 | */ 112 | public get proxyPromise(): Promise | T { 113 | return this._proxyPromise 114 | } 115 | 116 | /** 117 | * Server proxy. 118 | */ 119 | protected get proxy(): T { 120 | return this._proxy 121 | } 122 | 123 | /** 124 | * Initialize the proxy by unpromisifying if necessary and binding to its 125 | * events. 126 | */ 127 | protected initialize(proxyPromise: Promise | T): T { 128 | this._proxyPromise = proxyPromise 129 | this._proxy = isPromise(this._proxyPromise) ? unpromisify(this._proxyPromise) : this._proxyPromise 130 | if (this.bindEvents) { 131 | this.proxy.onEvent((event, ...args): void => { 132 | this.emit(event, ...args) 133 | }) 134 | } 135 | 136 | return this._proxy 137 | } 138 | 139 | /** 140 | * Perform necessary cleanup on disconnect (or reconnect). 141 | */ 142 | protected abstract handleDisconnect(): void 143 | 144 | /** 145 | * Emit an error event if the promise errors. 146 | */ 147 | protected catch(promise?: Promise): this { 148 | if (promise) { 149 | promise.catch((e) => this.emit("error", e)) 150 | } 151 | 152 | return this 153 | } 154 | } 155 | 156 | export interface ServerProxyOptions { 157 | /** 158 | * The events to bind immediately. 159 | */ 160 | bindEvents: string[] 161 | /** 162 | * Events that signal the proxy is done. 163 | */ 164 | doneEvents: string[] 165 | /** 166 | * Events that should only be bound when asked 167 | */ 168 | delayedEvents?: string[] 169 | /** 170 | * Whatever is emitting events (stream, child process, etc). 171 | */ 172 | instance: T 173 | } 174 | 175 | /** 176 | * The actual proxy instance on the server. Every method must only accept 177 | * serializable arguments and must return promises with serializable values. 178 | * 179 | * If a proxy itself has proxies on creation (like how ChildProcess has stdin), 180 | * then it should return all of those at once, otherwise you will miss events 181 | * from those child proxies and fail to dispose them properly. 182 | * 183 | * Events listeners are added client-side (since all events automatically 184 | * forward to the client), so onDone and onEvent do not need to be asynchronous. 185 | */ 186 | export abstract class ServerProxy { 187 | public readonly instance: T 188 | 189 | private readonly callbacks: EventCallback[] = [] 190 | 191 | public constructor(private readonly options: ServerProxyOptions) { 192 | this.instance = options.instance 193 | } 194 | 195 | /** 196 | * Dispose the proxy. 197 | */ 198 | public async dispose(): Promise { 199 | this.instance.removeAllListeners() 200 | } 201 | 202 | /** 203 | * This is used instead of an event to force it to be implemented since there 204 | * would be no guarantee the implementation would remember to emit the event. 205 | */ 206 | public onDone(cb: () => void): void { 207 | this.options.doneEvents.forEach((event) => this.instance.on(event, cb)) 208 | } 209 | 210 | /** 211 | * Bind an event that will not fire without first binding it and shouldn't be 212 | * bound immediately. 213 | 214 | * For example, binding to `data` switches a stream to flowing mode, so we 215 | * don't want to do it until we're asked. Otherwise something like `pipe` 216 | * won't work because potentially some or all of the data will already have 217 | * been flushed out. 218 | */ 219 | public async bindDelayedEvent(event: string): Promise { 220 | if ( 221 | this.options.delayedEvents && 222 | this.options.delayedEvents.includes(event) && 223 | !this.options.bindEvents.includes(event) 224 | ) { 225 | this.options.bindEvents.push(event) 226 | this.callbacks.forEach((cb) => { 227 | this.instance.on(event, (...args: any[]) => cb(event, ...args)) 228 | }) 229 | } 230 | } 231 | 232 | /** 233 | * Listen to all possible events. On the client, this is to reduce boilerplate 234 | * that would just be a bunch of error-prone forwarding of each individual 235 | * event from the proxy to its own emitter. 236 | * 237 | * It also fixes a timing issue because we just always send all events from 238 | * the server, so we never miss any due to listening too late. 239 | * 240 | * This cannot be async because then we can bind to the events too late. 241 | */ 242 | public onEvent(cb: EventCallback): void { 243 | this.callbacks.push(cb) 244 | this.options.bindEvents.forEach((event) => { 245 | this.instance.on(event, (...args: any[]) => cb(event, ...args)) 246 | }) 247 | } 248 | } 249 | 250 | /** 251 | * A server-side proxy stored on the client. The proxy ID only exists on the 252 | * client-side version of the server proxy. The event listeners are handled by 253 | * the client and the remaining methods are proxied to the server. 254 | */ 255 | export interface ClientServerProxy extends ServerProxy { 256 | proxyId: number | Module 257 | } 258 | 259 | /** 260 | * Supported top-level module proxies. 261 | */ 262 | export enum Module { 263 | Buffer = "buffer", 264 | ChildProcess = "child_process", 265 | Crypto = "crypto", 266 | Events = "events", 267 | Fs = "fs", 268 | Net = "net", 269 | Os = "os", 270 | Path = "path", 271 | Process = "process", 272 | Stream = "stream", 273 | StringDecoder = "string_decoder", 274 | Timers = "timers", 275 | Tty = "tty", 276 | Util = "util", 277 | } 278 | 279 | interface BatchItem { 280 | args: A 281 | resolve: (t: T) => void 282 | reject: (e: Error) => void 283 | } 284 | 285 | /** 286 | * Batch remote calls. 287 | */ 288 | export abstract class Batch { 289 | private idleTimeout: number | NodeJS.Timer | undefined 290 | private maxTimeout: number | NodeJS.Timer | undefined 291 | private batch: BatchItem[] = [] 292 | 293 | public constructor( 294 | /** 295 | * Flush after reaching this amount of time. 296 | */ 297 | private readonly maxTime: number = 1000, 298 | /** 299 | * Flush after reaching this count. 300 | */ 301 | private readonly maxCount: number = 100, 302 | /** 303 | * Flush after not receiving more requests for this amount of time. 304 | * This is pretty low by default so essentially we just end up batching 305 | * requests that are all made at the same time. 306 | */ 307 | private readonly idleTime: number = 1 308 | ) {} 309 | 310 | public add = (args: A): Promise => { 311 | return new Promise((resolve, reject): void => { 312 | this.batch.push({ 313 | args, 314 | resolve, 315 | reject, 316 | }) 317 | if (this.batch.length >= this.maxCount) { 318 | this.flush() 319 | } else { 320 | clearTimeout(this.idleTimeout as any) 321 | this.idleTimeout = setTimeout(this.flush, this.idleTime) 322 | if (typeof this.maxTimeout === "undefined") { 323 | this.maxTimeout = setTimeout(this.flush, this.maxTime) 324 | } 325 | } 326 | }) 327 | } 328 | 329 | /** 330 | * Perform remote call for a batch. 331 | */ 332 | protected abstract remoteCall(batch: A[]): Promise<(T | Error)[]> 333 | 334 | /** 335 | * Flush out the current batch. 336 | */ 337 | private readonly flush = (): void => { 338 | clearTimeout(this.idleTimeout as any) 339 | clearTimeout(this.maxTimeout as any) 340 | this.maxTimeout = undefined 341 | 342 | const batch = this.batch 343 | this.batch = [] 344 | 345 | this.remoteCall(batch.map((q) => q.args)) 346 | .then((results) => { 347 | batch.forEach((item, i) => { 348 | const result = results[i] 349 | if (result && result instanceof Error) { 350 | item.reject(result) 351 | } else { 352 | item.resolve(result) 353 | } 354 | }) 355 | }) 356 | .catch((error) => batch.forEach((item) => item.reject(error))) 357 | } 358 | } 359 | 360 | export class NotImplementedProxy { 361 | public constructor(name: string) { 362 | return new Proxy(this, { 363 | get(target: any, prop: string | number): any { 364 | if (prop in target) { 365 | return target[prop] 366 | } 367 | throw new Error(`not implemented: ${name}->${String(prop)}`) 368 | }, 369 | }) 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /src/common/serverMessages.ts: -------------------------------------------------------------------------------- 1 | import { Argument } from "./arguments" 2 | import { Module } from "./proxy" 3 | 4 | export enum Type { 5 | Callback = "callback", 6 | Event = "event", 7 | Fail = "fail", 8 | Init = "init", 9 | Pong = "pong", 10 | Success = "success", 11 | } 12 | 13 | export interface Callback { 14 | type: Type.Callback 15 | clientId: number 16 | callbackId: number 17 | proxyId: Module | number 18 | args: Argument[] 19 | } 20 | 21 | export interface Event { 22 | type: Type.Event 23 | clientId: number 24 | event: string 25 | proxyId: Module | number 26 | args: Argument[] 27 | } 28 | 29 | export interface Fail { 30 | type: Type.Fail 31 | clientId: number 32 | messageId: number 33 | response: Argument 34 | } 35 | 36 | export interface InitData { 37 | readonly env: NodeJS.ProcessEnv 38 | readonly os: { 39 | platform: NodeJS.Platform 40 | readonly homedir: string 41 | readonly eol: string 42 | } 43 | } 44 | 45 | export interface Init extends InitData { 46 | type: Type.Init 47 | clientId: number 48 | } 49 | 50 | export interface Pong { 51 | type: Type.Pong 52 | clientId: number 53 | } 54 | 55 | export interface Success { 56 | type: Type.Success 57 | clientId: number 58 | messageId: number 59 | response: Argument 60 | } 61 | 62 | export type Message = Callback | Event | Fail | Init | Pong | Success 63 | -------------------------------------------------------------------------------- /src/common/util.ts: -------------------------------------------------------------------------------- 1 | import { ClientServerProxy, ServerProxy } from "./proxy" 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | 5 | export const isNonModuleProxy =

(value: any): value is P => { 6 | return value && typeof value === "object" && typeof value.onEvent === "function" 7 | } 8 | 9 | export const isPromise = (value: any): value is Promise => { 10 | return typeof value.then === "function" && typeof value.catch === "function" 11 | } 12 | 13 | export const withEnv = (options?: T): T => { 14 | return options && options.env 15 | ? { 16 | ...options, 17 | env: { 18 | ...process.env, 19 | ...options.env, 20 | }, 21 | } 22 | : options || ({} as T) 23 | } 24 | 25 | export interface Disposable { 26 | dispose: () => void | Promise 27 | } 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Client } from "./client/client" 2 | export { ReadWriteConnection } from "./common/connection" 3 | export { Server } from "./server/server" 4 | -------------------------------------------------------------------------------- /src/server/child_process.ts: -------------------------------------------------------------------------------- 1 | import * as cp from "child_process" 2 | import { ServerProxy } from "../common/proxy" 3 | import { withEnv } from "../common/util" 4 | import { WritableProxy, ReadableProxy } from "./stream" 5 | 6 | /* eslint-disable @typescript-eslint/no-explicit-any */ 7 | 8 | export type ForkProvider = (modulePath: string, args?: string[], options?: cp.ForkOptions) => cp.ChildProcess 9 | 10 | export class ChildProcessProxy extends ServerProxy { 11 | private exited = false 12 | 13 | public constructor(instance: cp.ChildProcess) { 14 | super({ 15 | bindEvents: ["close", "disconnect", "error", "exit", "message"], 16 | doneEvents: ["exit", "error"], 17 | instance, 18 | }) 19 | this.onDone(() => (this.exited = true)) 20 | } 21 | 22 | public async kill(signal?: string): Promise { 23 | this.instance.kill(signal) 24 | } 25 | 26 | public async disconnect(): Promise { 27 | this.instance.disconnect() 28 | } 29 | 30 | public async ref(): Promise { 31 | this.instance.ref() 32 | } 33 | 34 | public async unref(): Promise { 35 | this.instance.unref() 36 | } 37 | 38 | public async send(message: any): Promise { 39 | return new Promise((resolve, reject): void => { 40 | this.instance.send(message, (error) => { 41 | if (error) { 42 | reject(error) 43 | } else { 44 | resolve() 45 | } 46 | }) 47 | }) 48 | } 49 | 50 | public async getPid(): Promise { 51 | return this.instance.pid 52 | } 53 | 54 | public async dispose(): Promise { 55 | if (!this.exited) { 56 | await new Promise((resolve): void => { 57 | const timeout = setTimeout(() => this.instance.kill("SIGKILL"), 5000) 58 | this.onDone(() => { 59 | clearTimeout(timeout) 60 | resolve() 61 | }) 62 | this.instance.kill() 63 | }) 64 | } 65 | await super.dispose() 66 | } 67 | } 68 | 69 | export interface ChildProcessProxies { 70 | childProcess: ChildProcessProxy 71 | stdin?: WritableProxy | null 72 | stdout?: ReadableProxy | null 73 | stderr?: ReadableProxy | null 74 | } 75 | 76 | export class ChildProcessModuleProxy { 77 | public constructor(private readonly forkProvider?: ForkProvider) {} 78 | 79 | public async exec( 80 | command: string, 81 | options?: { encoding?: string | null } & cp.ExecOptions | null 82 | ): Promise { 83 | return this.returnProxies(cp.exec(command, options && withEnv(options))) 84 | } 85 | 86 | public async fork(modulePath: string, args?: string[], options?: cp.ForkOptions): Promise { 87 | return this.returnProxies((this.forkProvider || cp.fork)(modulePath, args, withEnv(options))) 88 | } 89 | 90 | public async spawn(command: string, args?: string[], options?: cp.SpawnOptions): Promise { 91 | return this.returnProxies(cp.spawn(command, args || [], withEnv(options))) 92 | } 93 | 94 | private returnProxies(process: cp.ChildProcess): ChildProcessProxies { 95 | return { 96 | childProcess: new ChildProcessProxy(process), 97 | stdin: process.stdin && new WritableProxy(process.stdin), 98 | // Child processes streams appear to immediately flow so we need to bind 99 | // to the data event right away. 100 | stdout: process.stdout && new ReadableProxy(process.stdout, ["data"]), 101 | stderr: process.stderr && new ReadableProxy(process.stderr, ["data"]), 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/server/fs.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import { promisify } from "util" 3 | import { EncodingOptions, ServerProxy } from "../common/proxy" 4 | import { ReadableProxy, WritableProxy } from "./stream" 5 | 6 | /* eslint-disable @typescript-eslint/no-explicit-any */ 7 | 8 | /** 9 | * A serializable version of fs.Stats. 10 | */ 11 | export interface Stats { 12 | dev: number 13 | ino: number 14 | mode: number 15 | nlink: number 16 | uid: number 17 | gid: number 18 | rdev: number 19 | size: number 20 | blksize: number 21 | blocks: number 22 | atimeMs: number 23 | mtimeMs: number 24 | ctimeMs: number 25 | birthtimeMs: number 26 | atime: Date 27 | mtime: Date 28 | ctime: Date 29 | birthtime: Date 30 | _isFile: boolean 31 | _isDirectory: boolean 32 | _isBlockDevice: boolean 33 | _isCharacterDevice: boolean 34 | _isSymbolicLink: boolean 35 | _isFIFO: boolean 36 | _isSocket: boolean 37 | } 38 | 39 | export class ReadStreamProxy extends ReadableProxy { 40 | public constructor(stream: fs.ReadStream) { 41 | super(stream, ["open"]) 42 | } 43 | 44 | public async close(): Promise { 45 | this.instance.close() 46 | } 47 | 48 | public async dispose(): Promise { 49 | this.instance.close() 50 | await super.dispose() 51 | } 52 | } 53 | 54 | export class WriteStreamProxy extends WritableProxy { 55 | public constructor(stream: fs.WriteStream) { 56 | super(stream, ["open"]) 57 | } 58 | 59 | public async close(): Promise { 60 | this.instance.close() 61 | } 62 | 63 | public async dispose(): Promise { 64 | this.instance.close() 65 | await super.dispose() 66 | } 67 | } 68 | 69 | export class WatcherProxy extends ServerProxy { 70 | public constructor(watcher: fs.FSWatcher) { 71 | super({ 72 | bindEvents: ["change", "close", "error"], 73 | doneEvents: ["close", "error"], 74 | instance: watcher, 75 | }) 76 | } 77 | 78 | public async close(): Promise { 79 | this.instance.close() 80 | } 81 | 82 | public async dispose(): Promise { 83 | this.instance.close() 84 | await super.dispose() 85 | } 86 | } 87 | 88 | export class FsModuleProxy { 89 | public access(path: fs.PathLike, mode?: number): Promise { 90 | return promisify(fs.access)(path, mode) 91 | } 92 | 93 | public appendFile(file: fs.PathLike | number, data: any, options?: fs.WriteFileOptions): Promise { 94 | return promisify(fs.appendFile)(file, data, options) 95 | } 96 | 97 | public chmod(path: fs.PathLike, mode: string | number): Promise { 98 | return promisify(fs.chmod)(path, mode) 99 | } 100 | 101 | public chown(path: fs.PathLike, uid: number, gid: number): Promise { 102 | return promisify(fs.chown)(path, uid, gid) 103 | } 104 | 105 | public close(fd: number): Promise { 106 | return promisify(fs.close)(fd) 107 | } 108 | 109 | public copyFile(src: fs.PathLike, dest: fs.PathLike, flags?: number): Promise { 110 | return promisify(fs.copyFile)(src, dest, flags) 111 | } 112 | 113 | public async createReadStream(path: fs.PathLike, options?: any): Promise { 114 | return new ReadStreamProxy(fs.createReadStream(path, options)) 115 | } 116 | 117 | public async createWriteStream(path: fs.PathLike, options?: any): Promise { 118 | return new WriteStreamProxy(fs.createWriteStream(path, options)) 119 | } 120 | 121 | public exists(path: fs.PathLike): Promise { 122 | return promisify(fs.exists)(path) 123 | } 124 | 125 | public fchmod(fd: number, mode: string | number): Promise { 126 | return promisify(fs.fchmod)(fd, mode) 127 | } 128 | 129 | public fchown(fd: number, uid: number, gid: number): Promise { 130 | return promisify(fs.fchown)(fd, uid, gid) 131 | } 132 | 133 | public fdatasync(fd: number): Promise { 134 | return promisify(fs.fdatasync)(fd) 135 | } 136 | 137 | public async fstat(fd: number): Promise { 138 | return this.makeStatsSerializable(await promisify(fs.fstat)(fd)) 139 | } 140 | 141 | public fsync(fd: number): Promise { 142 | return promisify(fs.fsync)(fd) 143 | } 144 | 145 | public ftruncate(fd: number, len?: number | null): Promise { 146 | return promisify(fs.ftruncate)(fd, len) 147 | } 148 | 149 | public futimes(fd: number, atime: string | number | Date, mtime: string | number | Date): Promise { 150 | return promisify(fs.futimes)(fd, atime, mtime) 151 | } 152 | 153 | public lchmod(path: fs.PathLike, mode: string | number): Promise { 154 | return promisify(fs.lchmod)(path, mode) 155 | } 156 | 157 | public lchown(path: fs.PathLike, uid: number, gid: number): Promise { 158 | return promisify(fs.lchown)(path, uid, gid) 159 | } 160 | 161 | public link(existingPath: fs.PathLike, newPath: fs.PathLike): Promise { 162 | return promisify(fs.link)(existingPath, newPath) 163 | } 164 | 165 | public async lstat(path: fs.PathLike): Promise { 166 | return this.makeStatsSerializable(await promisify(fs.lstat)(path)) 167 | } 168 | 169 | public async lstatBatch(args: { path: fs.PathLike }[]): Promise<(Stats | Error)[]> { 170 | return Promise.all(args.map((a) => this.lstat(a.path).catch((e) => e))) 171 | } 172 | 173 | public mkdir(path: fs.PathLike, mode: number | string | fs.MakeDirectoryOptions | undefined | null): Promise { 174 | return promisify(fs.mkdir)(path, mode) 175 | } 176 | 177 | public mkdtemp(prefix: string, options: EncodingOptions): Promise { 178 | return promisify(fs.mkdtemp)(prefix, options) 179 | } 180 | 181 | public open(path: fs.PathLike, flags: string | number, mode: string | number | undefined | null): Promise { 182 | return promisify(fs.open)(path, flags, mode) 183 | } 184 | 185 | public read(fd: number, length: number, position: number | null): Promise<{ bytesRead: number; buffer: Buffer }> { 186 | const buffer = Buffer.alloc(length) 187 | 188 | return promisify(fs.read)(fd, buffer, 0, length, position) 189 | } 190 | 191 | public readFile(path: fs.PathLike | number, options: EncodingOptions): Promise { 192 | return promisify(fs.readFile)(path, options) 193 | } 194 | 195 | public readdir(path: fs.PathLike, options: EncodingOptions): Promise { 196 | return promisify(fs.readdir)(path, options) 197 | } 198 | 199 | public readdirBatch( 200 | args: { path: fs.PathLike; options: EncodingOptions }[] 201 | ): Promise<(Buffer[] | fs.Dirent[] | string[] | Error)[]> { 202 | return Promise.all(args.map((a) => this.readdir(a.path, a.options).catch((e) => e))) 203 | } 204 | 205 | public readlink(path: fs.PathLike, options: EncodingOptions): Promise { 206 | return promisify(fs.readlink)(path, options) 207 | } 208 | 209 | public realpath(path: fs.PathLike, options: EncodingOptions): Promise { 210 | return promisify(fs.realpath)(path, options) 211 | } 212 | 213 | public rename(oldPath: fs.PathLike, newPath: fs.PathLike): Promise { 214 | return promisify(fs.rename)(oldPath, newPath) 215 | } 216 | 217 | public rmdir(path: fs.PathLike): Promise { 218 | return promisify(fs.rmdir)(path) 219 | } 220 | 221 | public async stat(path: fs.PathLike): Promise { 222 | return this.makeStatsSerializable(await promisify(fs.stat)(path)) 223 | } 224 | 225 | public async statBatch(args: { path: fs.PathLike }[]): Promise<(Stats | Error)[]> { 226 | return Promise.all(args.map((a) => this.stat(a.path).catch((e) => e))) 227 | } 228 | 229 | public symlink(target: fs.PathLike, path: fs.PathLike, type?: fs.symlink.Type | null): Promise { 230 | return promisify(fs.symlink)(target, path, type) 231 | } 232 | 233 | public truncate(path: fs.PathLike, len?: number | null): Promise { 234 | return promisify(fs.truncate)(path, len) 235 | } 236 | 237 | public unlink(path: fs.PathLike): Promise { 238 | return promisify(fs.unlink)(path) 239 | } 240 | 241 | public utimes(path: fs.PathLike, atime: string | number | Date, mtime: string | number | Date): Promise { 242 | return promisify(fs.utimes)(path, atime, mtime) 243 | } 244 | 245 | public async write( 246 | fd: number, 247 | buffer: Buffer, 248 | offset?: number, 249 | length?: number, 250 | position?: number 251 | ): Promise<{ bytesWritten: number; buffer: Buffer }> { 252 | return promisify(fs.write)(fd, buffer, offset, length, position) 253 | } 254 | 255 | public writeFile(path: fs.PathLike | number, data: any, options: EncodingOptions): Promise { 256 | return promisify(fs.writeFile)(path, data, options) 257 | } 258 | 259 | public async watch(filename: fs.PathLike, options?: EncodingOptions): Promise { 260 | return new WatcherProxy(fs.watch(filename, options)) 261 | } 262 | 263 | private makeStatsSerializable(stats: fs.Stats): Stats { 264 | return { 265 | ...stats, 266 | /** 267 | * We need to check if functions exist because nexe's implemented FS 268 | * lib doesnt implement fs.stats properly. 269 | */ 270 | _isBlockDevice: stats.isBlockDevice ? stats.isBlockDevice() : false, 271 | _isCharacterDevice: stats.isCharacterDevice ? stats.isCharacterDevice() : false, 272 | _isDirectory: stats.isDirectory(), 273 | _isFIFO: stats.isFIFO ? stats.isFIFO() : false, 274 | _isFile: stats.isFile(), 275 | _isSocket: stats.isSocket ? stats.isSocket() : false, 276 | _isSymbolicLink: stats.isSymbolicLink ? stats.isSymbolicLink() : false, 277 | } 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/server/net.ts: -------------------------------------------------------------------------------- 1 | import * as net from "net" 2 | import { ServerProxy } from "../common/proxy" 3 | import { DuplexProxy } from "./stream" 4 | 5 | /* eslint-disable @typescript-eslint/no-explicit-any */ 6 | 7 | export class NetSocketProxy extends DuplexProxy { 8 | public constructor(socket: net.Socket) { 9 | super(socket, ["connect", "lookup", "timeout"]) 10 | } 11 | 12 | public async connect(options: number | string | net.SocketConnectOpts, host?: string): Promise { 13 | this.instance.connect(options as any, host as any) 14 | } 15 | 16 | public async unref(): Promise { 17 | this.instance.unref() 18 | } 19 | 20 | public async ref(): Promise { 21 | this.instance.ref() 22 | } 23 | 24 | public async dispose(): Promise { 25 | this.instance.end() 26 | this.instance.destroy() 27 | this.instance.unref() 28 | await super.dispose() 29 | } 30 | } 31 | 32 | export class NetServerProxy extends ServerProxy { 33 | public constructor(instance: net.Server) { 34 | super({ 35 | bindEvents: ["close", "error", "listening"], 36 | doneEvents: ["close"], 37 | instance, 38 | }) 39 | } 40 | 41 | public async listen( 42 | handle?: net.ListenOptions | number | string, 43 | hostname?: string | number, 44 | backlog?: number 45 | ): Promise { 46 | this.instance.listen(handle, hostname as any, backlog as any) 47 | } 48 | 49 | public async ref(): Promise { 50 | this.instance.ref() 51 | } 52 | 53 | public async unref(): Promise { 54 | this.instance.unref() 55 | } 56 | 57 | public async close(): Promise { 58 | this.instance.close() 59 | } 60 | 61 | public async onConnection(cb: (proxy: NetSocketProxy) => void): Promise { 62 | this.instance.on("connection", (socket) => cb(new NetSocketProxy(socket))) 63 | } 64 | 65 | public async dispose(): Promise { 66 | this.instance.close() 67 | this.instance.removeAllListeners() 68 | } 69 | } 70 | 71 | export class NetModuleProxy { 72 | public async createSocket(options?: net.SocketConstructorOpts): Promise { 73 | return new NetSocketProxy(new net.Socket(options)) 74 | } 75 | 76 | public async createConnection(target: string | number | net.NetConnectOpts, host?: string): Promise { 77 | return new NetSocketProxy(net.createConnection(target as any, host)) 78 | } 79 | 80 | public async createServer(options?: { allowHalfOpen?: boolean; pauseOnConnect?: boolean }): Promise { 81 | return new NetServerProxy(net.createServer(options)) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os" 2 | import { ReadWriteConnection, Logger, DefaultLogger, ConnectionStatus } from "../common/connection" 3 | import * as Message from "../common/messages" 4 | import { Module, ServerProxy } from "../common/proxy" 5 | import { isPromise, isNonModuleProxy } from "../common/util" 6 | import { Argument, encode, decode } from "../common/arguments" 7 | import { ChildProcessModuleProxy, ForkProvider } from "./child_process" 8 | import { FsModuleProxy } from "./fs" 9 | import { NetModuleProxy } from "./net" 10 | 11 | /* eslint-disable @typescript-eslint/no-explicit-any */ 12 | 13 | export interface ServerOptions { 14 | readonly fork?: ForkProvider 15 | readonly logger?: Logger 16 | } 17 | 18 | type ModuleProxy = ChildProcessModuleProxy | FsModuleProxy | NetModuleProxy 19 | 20 | interface ModuleProxyData { 21 | instance: ModuleProxy 22 | } 23 | 24 | interface ProxyData { 25 | clientId: number 26 | disposePromise?: Promise 27 | disposeTimeout?: number | NodeJS.Timer 28 | instance: ServerProxy 29 | disconnected?: boolean 30 | } 31 | 32 | /** 33 | * Handle messages from the client. 34 | */ 35 | export class Server { 36 | private proxyId = 0 37 | private readonly proxies = new Map() 38 | private status = ConnectionStatus.Connected 39 | private readonly responseTimeout = 10000 40 | private readonly logger: Logger 41 | private lastDisconnect: number | undefined 42 | 43 | public constructor(private readonly connection: ReadWriteConnection, private readonly options?: ServerOptions) { 44 | this.logger = (this.options && this.options.logger) || new DefaultLogger("server") 45 | 46 | connection.onMessage(async (data) => { 47 | try { 48 | await this.handleMessage(JSON.parse(data)) 49 | } catch (error) { 50 | this.logger.error("failed to handle client message", { error: error.message }) 51 | } 52 | }) 53 | 54 | const handleDisconnect = (permanent?: boolean): void => { 55 | this.lastDisconnect = Date.now() 56 | this.status = permanent ? ConnectionStatus.Closed : ConnectionStatus.Disconnected 57 | this.logger.trace(`disconnected${permanent ? " permanently" : ""} from client`, { proxies: this.proxies.size }) 58 | this.proxies.forEach((_, proxyId) => { 59 | this.removeProxy(proxyId, permanent, true) 60 | }) 61 | } 62 | 63 | connection.onDown(() => handleDisconnect()) 64 | connection.onClose(() => handleDisconnect(true)) 65 | connection.onUp(() => { 66 | if (this.status === ConnectionStatus.Disconnected) { 67 | this.logger.trace("reconnected to client") 68 | this.status = ConnectionStatus.Connected 69 | } 70 | }) 71 | 72 | this.storeProxy(new ChildProcessModuleProxy(this.options ? this.options.fork : undefined), Module.ChildProcess) 73 | this.storeProxy(new FsModuleProxy(), Module.Fs) 74 | this.storeProxy(new NetModuleProxy(), Module.Net) 75 | } 76 | 77 | /** 78 | * Handle all messages from the client. 79 | */ 80 | private async handleMessage(message: Message.Client.Message): Promise { 81 | if (this.status !== ConnectionStatus.Connected) { 82 | return this.logger.trace("discarding message", { message }) 83 | } 84 | switch (message.type) { 85 | case Message.Client.Type.Proxy: 86 | await this.runMethod(message) 87 | break 88 | case Message.Client.Type.Handshake: 89 | this.send({ 90 | type: Message.Server.Type.Init, 91 | clientId: message.clientId, 92 | env: process.env, 93 | os: { 94 | platform: os.platform(), 95 | homedir: os.homedir(), 96 | eol: os.EOL, 97 | }, 98 | }) 99 | break 100 | case Message.Client.Type.Ping: 101 | this.logger.trace("received ping", { proxies: this.proxies.size }) 102 | this.send({ 103 | type: Message.Server.Type.Pong, 104 | clientId: message.clientId, 105 | }) 106 | break 107 | default: 108 | throw new Error(`unknown message type ${(message as any).type}`) 109 | } 110 | } 111 | 112 | /** 113 | * Run a method on a proxy. 114 | */ 115 | private async runMethod(message: Message.Client.Proxy): Promise { 116 | const clientId = message.clientId 117 | const messageId = message.messageId 118 | const proxyId = message.proxyId 119 | const method = message.method 120 | const args = message.args.map((a) => { 121 | return decode( 122 | a, 123 | (id, args) => this.sendCallback(clientId, proxyId, id, args), 124 | (id) => this.getProxy(id, "Unable to decode").instance 125 | ) 126 | }) 127 | 128 | this.logger.trace("received", { clientId, messageId, proxyId, method, args }) 129 | 130 | let response: any 131 | try { 132 | const proxy = this.getProxy(proxyId, `Unable to call ${method}`) 133 | if (typeof (proxy.instance as any)[method] !== "function") { 134 | throw new Error(`"${method}" is not a function on proxy ${proxyId}`) 135 | } 136 | 137 | // We wait for the client to call "dispose" instead of doing it in onDone 138 | // to ensure all messages get processed before we get rid of it. 139 | if (method === "dispose") { 140 | response = this.removeProxy(proxyId) 141 | } else { 142 | response = (proxy.instance as any)[method](...args) 143 | } 144 | 145 | // Proxies must always return promises. 146 | if (!isPromise(response)) { 147 | throw new Error(`"${method}" must return a promise`) 148 | } 149 | } catch (error) { 150 | this.logger.error(error.message, { type: typeof response, proxyId }) 151 | this.sendException(clientId, messageId, error) 152 | } 153 | 154 | // If we disconnect while waiting for a response we need to just dispose 155 | // and not try sending anything back to the client even if we've 156 | // reconnected because the client will have already moved on and is not 157 | // expecting a response so it will just error pointlessly. 158 | const started = Date.now() 159 | const disconnected = (): boolean => 160 | this.status !== ConnectionStatus.Connected || 161 | (!!started && !!this.lastDisconnect && started <= this.lastDisconnect) 162 | try { 163 | const value = await response 164 | if (!disconnected()) { 165 | this.sendResponse(clientId, messageId, value) 166 | } else if (isNonModuleProxy(value)) { 167 | this.logger.trace("discarding resolve", { clientId, messageId }) 168 | value.dispose().catch((error) => this.logger.error(error.message)) 169 | } 170 | } catch (error) { 171 | if (!disconnected()) { 172 | this.sendException(clientId, messageId, error) 173 | } else { 174 | this.logger.trace("discarding reject", { clientId, messageId, error: error.message }) 175 | } 176 | } 177 | } 178 | 179 | /** 180 | * Send a callback to the client. 181 | */ 182 | private sendCallback(clientId: number, proxyId: number | Module, callbackId: number, args: any[]): void { 183 | this.logger.trace("sending callback", { clientId, proxyId, callbackId }) 184 | this.send({ 185 | type: Message.Server.Type.Callback, 186 | clientId, 187 | callbackId, 188 | proxyId, 189 | args: args.map((a) => this.encode(clientId, a)), 190 | }) 191 | } 192 | 193 | /** 194 | * Store a proxy and bind events to send them back to the client. 195 | */ 196 | private storeProxy(instance: ServerProxy, clientId: number): number 197 | private storeProxy(instance: ModuleProxy, moduleProxyId: Module): Module 198 | private storeProxy(instance: ServerProxy | ModuleProxy, clientOrModuleProxyId: Module | number): number | Module { 199 | this.logger.trace("storing proxy", { proxyId: clientOrModuleProxyId }) 200 | 201 | if (isNonModuleProxy(instance)) { 202 | if (typeof clientOrModuleProxyId !== "number") { 203 | throw new Error("non-module proxies must have numerical IDs") 204 | } 205 | const proxyId = this.proxyId++ 206 | instance.onEvent((event, ...args) => this.sendEvent(clientOrModuleProxyId, proxyId, event, ...args)) 207 | instance.onDone(() => { 208 | const proxy = this.getProxy(proxyId, "Unable to dispose") 209 | this.sendEvent(clientOrModuleProxyId, proxyId, "done") 210 | proxy.disposeTimeout = setTimeout(() => { 211 | this.removeProxy(proxyId) 212 | }, this.responseTimeout) 213 | }) 214 | this.proxies.set(proxyId, { clientId: clientOrModuleProxyId, instance }) 215 | return proxyId 216 | } 217 | 218 | if (typeof clientOrModuleProxyId !== "string") { 219 | throw new Error("module proxies must have string IDs") 220 | } 221 | 222 | this.proxies.set(clientOrModuleProxyId, { instance }) 223 | return clientOrModuleProxyId 224 | } 225 | 226 | /** 227 | * Send an event on a numbered proxy to the client that owns it. 228 | */ 229 | private sendEvent(clientId: number, proxyId: number, event: string, ...args: any[]): void { 230 | this.logger.trace("sending event", () => ({ 231 | clientId, 232 | proxyId, 233 | event, 234 | args: args.map((a) => (a instanceof Buffer ? a.toString() : a)), 235 | })) 236 | this.send({ 237 | type: Message.Server.Type.Event, 238 | clientId, 239 | event, 240 | proxyId, 241 | args: args.map((a) => this.encode(clientId, a)), 242 | }) 243 | } 244 | 245 | /** 246 | * Send a response back to the client. 247 | */ 248 | private sendResponse(clientId: number, messageId: number, response: any): void { 249 | const encoded = this.encode(clientId, response) 250 | this.logger.trace("sending resolve", { clientId, messageId, response: encoded }) 251 | this.send({ 252 | type: Message.Server.Type.Success, 253 | clientId, 254 | messageId, 255 | response: encoded, 256 | }) 257 | } 258 | 259 | /** 260 | * Send an exception back to the client. 261 | */ 262 | private sendException(clientId: number, messageId: number, error: Error): void { 263 | this.logger.trace("sending reject", { clientId, messageId, message: error.message }) 264 | this.send({ 265 | type: Message.Server.Type.Fail, 266 | clientId, 267 | messageId, 268 | response: this.encode(clientId, error), 269 | }) 270 | } 271 | 272 | private isProxyData(p: ProxyData | ModuleProxyData): p is ProxyData { 273 | return isNonModuleProxy(p.instance) 274 | } 275 | 276 | /** 277 | * Dispose then remove a proxy. Module proxies are immune to removal unless 278 | * otherwise specified. 279 | */ 280 | private async removeProxy(proxyId: number | Module, modules?: boolean, disconnect?: boolean): Promise { 281 | const proxy = this.proxies.get(proxyId) 282 | if (!proxy) { 283 | throw new Error(`unable to remove: proxy ${proxyId} already removed`) 284 | } 285 | if (this.isProxyData(proxy)) { 286 | // If disconnected we don't want to send any more events. 287 | if (disconnect && !proxy.disconnected) { 288 | proxy.disconnected = true 289 | proxy.instance.instance.removeAllListeners() 290 | } 291 | if (!proxy.disposePromise) { 292 | clearTimeout(proxy.disposeTimeout as any) 293 | this.logger.trace("disposing proxy", { proxyId }) 294 | proxy.disposePromise = proxy.instance 295 | .dispose() 296 | .then(() => this.logger.trace("disposed proxy", { proxyId })) 297 | .catch((error) => this.logger.error("failed to dispose proxy", { proxyId, error: error.message })) 298 | .finally(() => { 299 | this.proxies.delete(proxyId) 300 | this.logger.trace("removed proxy", { proxyId, proxies: this.proxies.size }) 301 | }) 302 | } 303 | await proxy.disposePromise 304 | } else if (modules) { 305 | this.proxies.delete(proxyId) 306 | this.logger.trace("removed proxy", { proxyId, proxies: this.proxies.size }) 307 | } 308 | } 309 | 310 | /** 311 | * Same as encode but provides storeProxy. 312 | */ 313 | private encode(clientId: number, value: any): Argument { 314 | return encode(value, undefined, (p) => this.storeProxy(p, clientId)) 315 | } 316 | 317 | /** 318 | * Get a proxy. Error if it doesn't exist. 319 | */ 320 | private getProxy(proxyId: number, message: string): ProxyData 321 | private getProxy(proxyId: Module, message: string): ModuleProxyData 322 | private getProxy(proxyId: number | Module, message: string): ProxyData | ModuleProxyData 323 | private getProxy(proxyId: number | Module, message: string): ProxyData | ModuleProxyData { 324 | const proxy = this.proxies.get(proxyId) 325 | if (!proxy || "disposePromise" in proxy) { 326 | throw new Error(`${message}: proxy ${proxyId} disposed too early`) 327 | } 328 | return proxy as any 329 | } 330 | 331 | private send(message: T): void { 332 | if (this.status === ConnectionStatus.Connected) { 333 | this.connection.send(JSON.stringify(message)) 334 | } 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/server/stream.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events" 2 | import * as stream from "stream" 3 | import { ServerProxy } from "../common/proxy" 4 | 5 | /* eslint-disable @typescript-eslint/no-explicit-any */ 6 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 7 | 8 | export class WritableProxy extends ServerProxy { 9 | public constructor(instance: T, bindEvents: string[] = [], delayedEvents?: string[]) { 10 | super({ 11 | bindEvents: ["close", "drain", "error", "finish"].concat(bindEvents), 12 | doneEvents: ["close"], 13 | delayedEvents, 14 | instance, 15 | }) 16 | } 17 | 18 | public async destroy(): Promise { 19 | this.instance.destroy() 20 | } 21 | 22 | public async end(data?: any, encoding?: string): Promise { 23 | return new Promise((resolve): void => { 24 | this.instance.end(data, encoding!, () => { 25 | // HACK: Should avoid ! here. 26 | resolve() 27 | }) 28 | }) 29 | } 30 | 31 | public async setDefaultEncoding(encoding: string): Promise { 32 | this.instance.setDefaultEncoding(encoding) 33 | } 34 | 35 | public async write(data: any, encoding?: string): Promise { 36 | return new Promise((resolve, reject): void => { 37 | this.instance.write(data, encoding!, (error) => { 38 | // HACK: Should avoid ! here. 39 | if (error) { 40 | reject(error) 41 | } else { 42 | resolve() 43 | } 44 | }) 45 | }) 46 | } 47 | 48 | public async dispose(): Promise { 49 | this.instance.end() 50 | await super.dispose() 51 | } 52 | } 53 | 54 | /** 55 | * This noise is because we can't do multiple extends and we also can't seem to 56 | * do `extends WritableProxy implement ReadableProxy` (for `DuplexProxy`). 57 | */ 58 | export interface CommonReadableProxy extends ServerProxy { 59 | pipe

(destination: P, options?: { end?: boolean }): Promise 60 | setEncoding(encoding: string): Promise 61 | } 62 | 63 | export class ReadableProxy extends ServerProxy 64 | implements CommonReadableProxy { 65 | public constructor(instance: T, bindEvents: string[] = []) { 66 | super({ 67 | bindEvents: ["close", "end", "error"].concat(bindEvents), 68 | doneEvents: ["close"], 69 | delayedEvents: ["data"], 70 | instance, 71 | }) 72 | } 73 | 74 | public async pipe

(destination: P, options?: { end?: boolean }): Promise { 75 | this.instance.pipe( 76 | destination.instance, 77 | options 78 | ) 79 | // `pipe` switches the stream to flowing mode and makes data start emitting. 80 | await this.bindDelayedEvent("data") 81 | } 82 | 83 | public async destroy(): Promise { 84 | this.instance.destroy() 85 | } 86 | 87 | public async setEncoding(encoding: string): Promise { 88 | this.instance.setEncoding(encoding) 89 | } 90 | 91 | public async dispose(): Promise { 92 | this.instance.destroy() 93 | await super.dispose() 94 | } 95 | } 96 | 97 | export class DuplexProxy extends WritableProxy 98 | implements CommonReadableProxy { 99 | public constructor(stream: T, bindEvents: string[] = []) { 100 | super(stream, ["end"].concat(bindEvents), ["data"]) 101 | } 102 | 103 | public async pipe

(destination: P, options?: { end?: boolean }): Promise { 104 | this.instance.pipe( 105 | destination.instance, 106 | options 107 | ) 108 | // `pipe` switches the stream to flowing mode and makes data start emitting. 109 | await this.bindDelayedEvent("data") 110 | } 111 | 112 | public async setEncoding(encoding: string): Promise { 113 | this.instance.setEncoding(encoding) 114 | } 115 | 116 | public async dispose(): Promise { 117 | this.instance.destroy() 118 | await super.dispose() 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /test/child_process.test.ts: -------------------------------------------------------------------------------- 1 | import "leaked-handles" 2 | import * as assert from "assert" 3 | import { ChildProcess } from "child_process" 4 | import * as os from "os" 5 | import * as path from "path" 6 | import { Readable } from "stream" 7 | import { Module } from "../src/common/proxy" 8 | import { createClient, testFn } from "./helpers" 9 | 10 | describe("child_process", () => { 11 | const client = createClient() 12 | const cp = (client.modules[Module.ChildProcess] as any) as typeof import("child_process") // eslint-disable-line @typescript-eslint/no-explicit-any 13 | const util = client.modules[Module.Util] 14 | 15 | const getStdout = async (proc: ChildProcess): Promise => { 16 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 17 | return new Promise((r): Readable => proc.stdout!.once("data", r)).then((s) => s.toString()) 18 | } 19 | 20 | describe("handshake", () => { 21 | it("should get init data", async () => { 22 | const data = await client.handshake() 23 | assert.equal(data.os.platform, os.platform()) 24 | assert.deepEqual(data.env, process.env) 25 | }) 26 | }) 27 | 28 | describe("exec", () => { 29 | it("should get exec stdout", async () => { 30 | const result = await util.promisify(cp.exec)("echo test", { encoding: "utf8" }) 31 | assert.deepEqual(result, { 32 | stdout: "test\n", 33 | stderr: "", 34 | }) 35 | }) 36 | }) 37 | 38 | describe("spawn", () => { 39 | it("should get spawn stdout", async () => { 40 | const proc = cp.spawn("echo", ["test"]) 41 | const result = await Promise.all([getStdout(proc), new Promise((r): ChildProcess => proc.on("exit", r))]).then( 42 | (values) => values[0] 43 | ) 44 | assert.equal(result, "test\n") 45 | }) 46 | 47 | it("should cat stdin", async () => { 48 | const proc = cp.spawn("cat", []) 49 | assert.equal(proc.pid, -1) 50 | proc.stdin.write("banana") 51 | const result = await getStdout(proc) 52 | assert.equal(result, "banana") 53 | 54 | proc.stdin.end() 55 | proc.kill() 56 | 57 | assert.equal(proc.pid > -1, true) 58 | await new Promise((r): ChildProcess => proc.on("exit", r)) 59 | }) 60 | 61 | it("should print env", async () => { 62 | const proc = cp.spawn("env", [], { 63 | env: { hi: "donkey" }, 64 | }) 65 | 66 | const stdout = await getStdout(proc) 67 | assert.equal(stdout.includes("hi=donkey\n"), true) 68 | }) 69 | 70 | it("should eval", async () => { 71 | const proc = cp.spawn("node", ["-e", "console.log('foo')"]) 72 | const stdout = await getStdout(proc) 73 | assert.equal(stdout.includes("foo"), true) 74 | }) 75 | }) 76 | 77 | describe("fork", () => { 78 | it("should echo messages", async () => { 79 | const proc = cp.fork(path.join(__dirname, "forker.js")) 80 | 81 | proc.send({ bananas: true }) 82 | const result = await new Promise((r): ChildProcess => proc.on("message", r)) 83 | assert.deepEqual(result, { bananas: true }) 84 | 85 | proc.kill() 86 | 87 | await new Promise((r): ChildProcess => proc.on("exit", r)) 88 | }) 89 | }) 90 | 91 | describe("cleanup", () => { 92 | it("should dispose", (done) => { 93 | setTimeout(() => { 94 | client.dispose() 95 | done() 96 | }, 100) 97 | }) 98 | 99 | it("should disconnect", async () => { 100 | const client = createClient() 101 | const cp = client.modules[Module.ChildProcess] 102 | const proc = cp.fork(path.join(__dirname, "forker.js")) 103 | const fn = testFn() 104 | proc.on("error", fn) 105 | 106 | proc.send({ bananas: true }) 107 | const result = await new Promise((r): ChildProcess => proc.on("message", r)) 108 | assert.deepEqual(result, { bananas: true }) 109 | 110 | client.dispose() 111 | assert.equal(fn.called, 1) 112 | assert.equal(fn.args.length, 1) 113 | assert.equal(fn.args[0].message, "disconnected") 114 | }) 115 | }) 116 | 117 | describe("resiliency", () => { 118 | it("should handle multiple clients", async () => { 119 | const clients = createClient(3) 120 | const procs = clients.map((client, i) => { 121 | return client.modules[Module.ChildProcess].spawn("/bin/bash", ["-c", `echo -n hello: ${i}`]) 122 | }) 123 | 124 | const done = Promise.all(procs.map((p) => new Promise((r): ChildProcess => p.on("exit", r)))) 125 | const stdout = await Promise.all(procs.map(getStdout)) 126 | 127 | for (let i = 0; i < stdout.length; ++i) { 128 | assert.equal(stdout[i], `hello: ${i}`) 129 | } 130 | 131 | await done 132 | clients.forEach((c) => c.dispose()) 133 | }) 134 | 135 | it("should exit if connection drops while in use", async () => { 136 | const client = createClient() 137 | const proc = client.modules[Module.ChildProcess].spawn("/bin/bash", ["-c", "sleep inf"]) 138 | setTimeout(() => { 139 | client.down() 140 | }, 200) 141 | await new Promise((r): ChildProcess => proc.on("exit", r)) 142 | client.dispose() 143 | }) 144 | 145 | it("should exit if connection drops briefly", async () => { 146 | const client = createClient() 147 | const proc = client.modules[Module.ChildProcess].spawn("/bin/bash", ["-c", "sleep inf"]) 148 | setTimeout(() => { 149 | client.down() 150 | client.up() 151 | }, 200) 152 | await new Promise((r): ChildProcess => proc.on("exit", r)) 153 | client.dispose() 154 | }) 155 | }) 156 | }) 157 | -------------------------------------------------------------------------------- /test/event.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert" 2 | import { Emitter } from "../src/common/events" 3 | import { testFn } from "./helpers" 4 | 5 | describe("Event", () => { 6 | const emitter = new Emitter() 7 | 8 | it("should listen to global event", () => { 9 | const fn = testFn() 10 | const d = emitter.event(fn) 11 | emitter.emit(10) 12 | assert.equal(fn.called, 1) 13 | assert.deepEqual(fn.args, [10]) 14 | d.dispose() 15 | }) 16 | 17 | it("should listen to id event", () => { 18 | const fn = testFn() 19 | const d = emitter.event(0, fn) 20 | emitter.emit(0, 5) 21 | assert.equal(fn.called, 1) 22 | assert.deepEqual(fn.args, [5]) 23 | d.dispose() 24 | }) 25 | 26 | it("should listen to string id event", () => { 27 | const fn = testFn() 28 | const d = emitter.event("string", fn) 29 | emitter.emit("string", 55) 30 | assert.equal(fn.called, 1) 31 | assert.deepEqual(fn.args, [55]) 32 | d.dispose() 33 | }) 34 | 35 | it("should not listen wrong id event", () => { 36 | const fn = testFn() 37 | const d = emitter.event(1, fn) 38 | emitter.emit(0, 5) 39 | emitter.emit(1, 6) 40 | assert.equal(fn.called, 1) 41 | assert.deepEqual(fn.args, [6]) 42 | d.dispose() 43 | }) 44 | 45 | it("should listen to id event globally", () => { 46 | const fn = testFn() 47 | const d = emitter.event(fn) 48 | emitter.emit(1, 11) 49 | assert.equal(fn.called, 1) 50 | assert.deepEqual(fn.args, [11]) 51 | d.dispose() 52 | }) 53 | 54 | it("should listen to global event", () => { 55 | const fn = testFn() 56 | const d = emitter.event(3, fn) 57 | emitter.emit(14) 58 | assert.equal(fn.called, 1) 59 | assert.deepEqual(fn.args, [14]) 60 | d.dispose() 61 | }) 62 | 63 | it("should listen to id event multiple times", () => { 64 | const fn = testFn() 65 | const disposers = [emitter.event(934, fn), emitter.event(934, fn), emitter.event(934, fn), emitter.event(934, fn)] 66 | emitter.emit(934, 324) 67 | assert.equal(fn.called, 4) 68 | assert.deepEqual(fn.args, [324, 324, 324, 324]) 69 | disposers.forEach((d) => d.dispose()) 70 | }) 71 | 72 | it("should dispose individually", () => { 73 | const fn = testFn() 74 | const d = emitter.event(fn) 75 | 76 | const fn2 = testFn() 77 | const d2 = emitter.event(1, fn2) 78 | 79 | d.dispose() 80 | 81 | emitter.emit(12) 82 | emitter.emit(1, 12) 83 | 84 | assert.equal(fn.called, 0) 85 | assert.equal(fn2.called, 2) 86 | 87 | d2.dispose() 88 | 89 | emitter.emit(12) 90 | emitter.emit(1, 12) 91 | 92 | assert.equal(fn.called, 0) 93 | assert.equal(fn2.called, 2) 94 | }) 95 | 96 | it("should dispose by id", () => { 97 | const fn = testFn() 98 | emitter.event(fn) 99 | 100 | const fn2 = testFn() 101 | emitter.event(1, fn2) 102 | 103 | emitter.dispose(1) 104 | 105 | emitter.emit(12) 106 | emitter.emit(1, 12) 107 | 108 | assert.equal(fn.called, 2) 109 | assert.equal(fn2.called, 0) 110 | }) 111 | 112 | it("should dispose all", () => { 113 | const fn = testFn() 114 | emitter.event(fn) 115 | emitter.event(1, fn) 116 | 117 | emitter.dispose() 118 | 119 | emitter.emit(12) 120 | emitter.emit(1, 12) 121 | 122 | assert.equal(fn.called, 0) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /test/forker.js: -------------------------------------------------------------------------------- 1 | process.on("message", (data) => { 2 | process.send(data) 3 | }) 4 | -------------------------------------------------------------------------------- /test/fs.test.ts: -------------------------------------------------------------------------------- 1 | import "leaked-handles" 2 | import * as assert from "assert" 3 | import * as nativeFs from "fs" 4 | import * as os from "os" 5 | import * as path from "path" 6 | import * as nativeUtil from "util" 7 | import { Module } from "../src/common/proxy" 8 | import { createClient, Helper } from "./helpers" 9 | 10 | describe("fs", () => { 11 | const client = createClient() 12 | const fs = (client.modules[Module.Fs] as any) as typeof import("fs") // eslint-disable-line @typescript-eslint/no-explicit-any 13 | const util = client.modules[Module.Util] 14 | const helper = new Helper("fs") 15 | 16 | before(async () => { 17 | await helper.prepare() 18 | }) 19 | 20 | describe("access", () => { 21 | it("should access existing file", async () => { 22 | assert.equal(await util.promisify(fs.access)(__filename), undefined) 23 | }) 24 | 25 | it("should fail to access nonexistent file", async () => { 26 | await assert.rejects(util.promisify(fs.access)(helper.tmpFile()), /ENOENT/) 27 | }) 28 | }) 29 | 30 | describe("append", () => { 31 | it("should append to existing file", async () => { 32 | const file = await helper.createTmpFile() 33 | assert.equal(await util.promisify(fs.appendFile)(file, "howdy"), undefined) 34 | assert.equal(await nativeUtil.promisify(nativeFs.readFile)(file, "utf8"), "howdy") 35 | }) 36 | 37 | it("should create then append to nonexistent file", async () => { 38 | const file = helper.tmpFile() 39 | assert.equal(await util.promisify(fs.appendFile)(file, "howdy"), undefined) 40 | assert.equal(await nativeUtil.promisify(nativeFs.readFile)(file, "utf8"), "howdy") 41 | }) 42 | 43 | it("should fail to append to file in nonexistent directory", async () => { 44 | const file = path.join(helper.tmpFile(), "nope") 45 | await assert.rejects(util.promisify(fs.appendFile)(file, "howdy"), /ENOENT/) 46 | assert.equal(await nativeUtil.promisify(nativeFs.exists)(file), false) 47 | }) 48 | }) 49 | 50 | describe("chmod", () => { 51 | it("should chmod existing file", async () => { 52 | const file = await helper.createTmpFile() 53 | assert.equal(await util.promisify(fs.chmod)(file, "755"), undefined) 54 | }) 55 | 56 | it("should fail to chmod nonexistent file", async () => { 57 | await assert.rejects(util.promisify(fs.chmod)(helper.tmpFile(), "755"), /ENOENT/) 58 | }) 59 | }) 60 | 61 | describe("chown", () => { 62 | it("should chown existing file", async () => { 63 | const file = await helper.createTmpFile() 64 | assert.equal(await nativeUtil.promisify(nativeFs.chown)(file, os.userInfo().uid, os.userInfo().gid), undefined) 65 | }) 66 | 67 | it("should fail to chown nonexistent file", async () => { 68 | await assert.rejects(util.promisify(fs.chown)(helper.tmpFile(), os.userInfo().uid, os.userInfo().gid), /ENOENT/) 69 | }) 70 | }) 71 | 72 | describe("close", () => { 73 | it("should close opened file", async () => { 74 | const file = await helper.createTmpFile() 75 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "r") 76 | assert.equal(await util.promisify(fs.close)(fd), undefined) 77 | }) 78 | 79 | it("should fail to close non-opened file", async () => { 80 | await assert.rejects(util.promisify(fs.close)(99999999), /EBADF/) 81 | }) 82 | }) 83 | 84 | describe("copyFile", () => { 85 | it("should copy existing file", async () => { 86 | const source = await helper.createTmpFile() 87 | const destination = helper.tmpFile() 88 | assert.equal(await util.promisify(fs.copyFile)(source, destination), undefined) 89 | assert.equal(await util.promisify(fs.exists)(destination), true) 90 | }) 91 | 92 | it("should fail to copy nonexistent file", async () => { 93 | await assert.rejects(util.promisify(fs.copyFile)(helper.tmpFile(), helper.tmpFile()), /ENOENT/) 94 | }) 95 | }) 96 | 97 | describe("createWriteStream", () => { 98 | it("should write to file", async () => { 99 | const file = helper.tmpFile() 100 | const content = "howdy\nhow\nr\nu" 101 | const stream = fs.createWriteStream(file) 102 | stream.on("open", (fd) => { 103 | assert.notEqual(fd, undefined) 104 | stream.write(content) 105 | stream.close() 106 | stream.end() 107 | }) 108 | 109 | await Promise.all([ 110 | new Promise((resolve): nativeFs.WriteStream => stream.on("close", resolve)), 111 | new Promise((resolve): nativeFs.WriteStream => stream.on("finish", resolve)), 112 | ]) 113 | 114 | assert.equal(await nativeUtil.promisify(nativeFs.readFile)(file, "utf8"), content) 115 | }) 116 | }) 117 | 118 | describe("createReadStream", () => { 119 | it("should read a file", async () => { 120 | const file = helper.tmpFile() 121 | const content = "foobar" 122 | await nativeUtil.promisify(nativeFs.writeFile)(file, content) 123 | 124 | const reader = fs.createReadStream(file) 125 | 126 | assert.equal( 127 | await new Promise((resolve, reject): void => { 128 | let data = "" 129 | reader.once("error", reject) 130 | reader.once("end", () => resolve(data)) 131 | reader.on("data", (d) => (data += d.toString())) 132 | }), 133 | content 134 | ) 135 | }) 136 | 137 | it("should pipe to a writable stream", async () => { 138 | const source = helper.tmpFile() 139 | const content = "foo" 140 | await nativeUtil.promisify(nativeFs.writeFile)(source, content) 141 | 142 | const destination = helper.tmpFile() 143 | const reader = fs.createReadStream(source) 144 | const writer = fs.createWriteStream(destination) 145 | 146 | await new Promise((resolve, reject): void => { 147 | reader.once("error", reject) 148 | writer.once("error", reject) 149 | writer.once("close", resolve) 150 | reader.pipe(writer) 151 | }) 152 | 153 | assert.equal(await nativeUtil.promisify(nativeFs.readFile)(destination, "utf8"), content) 154 | }) 155 | }) 156 | 157 | describe("exists", () => { 158 | it("should output file exists", async () => { 159 | assert.equal(await util.promisify(fs.exists)(__filename), true) 160 | }) 161 | 162 | it("should output file does not exist", async () => { 163 | assert.equal(await util.promisify(fs.exists)(helper.tmpFile()), false) 164 | }) 165 | }) 166 | 167 | describe("fchmod", () => { 168 | it("should fchmod existing file", async () => { 169 | const file = await helper.createTmpFile() 170 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "r") 171 | assert.equal(await util.promisify(fs.fchmod)(fd, "755"), undefined) 172 | await nativeUtil.promisify(nativeFs.close)(fd) 173 | }) 174 | 175 | it("should fail to fchmod nonexistent file", async () => { 176 | await assert.rejects(util.promisify(fs.fchmod)(2242342, "755"), /EBADF/) 177 | }) 178 | }) 179 | 180 | describe("fchown", () => { 181 | it("should fchown existing file", async () => { 182 | const file = await helper.createTmpFile() 183 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "r") 184 | assert.equal(await util.promisify(fs.fchown)(fd, os.userInfo().uid, os.userInfo().gid), undefined) 185 | await nativeUtil.promisify(nativeFs.close)(fd) 186 | }) 187 | 188 | it("should fail to fchown nonexistent file", async () => { 189 | await assert.rejects(util.promisify(fs.fchown)(99999, os.userInfo().uid, os.userInfo().gid), /EBADF/) 190 | }) 191 | }) 192 | 193 | describe("fdatasync", () => { 194 | it("should fdatasync existing file", async () => { 195 | const file = await helper.createTmpFile() 196 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "r") 197 | assert.equal(await util.promisify(fs.fdatasync)(fd), undefined) 198 | await nativeUtil.promisify(nativeFs.close)(fd) 199 | }) 200 | 201 | it("should fail to fdatasync nonexistent file", async () => { 202 | await assert.rejects(util.promisify(fs.fdatasync)(99999), /EBADF/) 203 | }) 204 | }) 205 | 206 | describe("fstat", () => { 207 | it("should fstat existing file", async () => { 208 | const fd = await nativeUtil.promisify(nativeFs.open)(__filename, "r") 209 | const stat = await nativeUtil.promisify(nativeFs.fstat)(fd) 210 | assert.equal((await util.promisify(fs.fstat)(fd)).size, stat.size) 211 | await nativeUtil.promisify(nativeFs.close)(fd) 212 | }) 213 | 214 | it("should fail to fstat", async () => { 215 | await assert.rejects(util.promisify(fs.fstat)(9999), /EBADF/) 216 | }) 217 | }) 218 | 219 | describe("fsync", () => { 220 | it("should fsync existing file", async () => { 221 | const file = await helper.createTmpFile() 222 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "r") 223 | assert.equal(await util.promisify(fs.fsync)(fd), undefined) 224 | await nativeUtil.promisify(nativeFs.close)(fd) 225 | }) 226 | 227 | it("should fail to fsync nonexistent file", async () => { 228 | await assert.rejects(util.promisify(fs.fsync)(99999), /EBADF/) 229 | }) 230 | }) 231 | 232 | describe("ftruncate", () => { 233 | it("should ftruncate existing file", async () => { 234 | const file = await helper.createTmpFile() 235 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "w") 236 | assert.equal(await util.promisify(fs.ftruncate)(fd, 1), undefined) 237 | await nativeUtil.promisify(nativeFs.close)(fd) 238 | }) 239 | 240 | it("should fail to ftruncate nonexistent file", async () => { 241 | await assert.rejects(util.promisify(fs.ftruncate)(99999, 9999), /EBADF/) 242 | }) 243 | }) 244 | 245 | describe("futimes", () => { 246 | it("should futimes existing file", async () => { 247 | const file = await helper.createTmpFile() 248 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "w") 249 | assert.equal(await util.promisify(fs.futimes)(fd, os.userInfo().uid, os.userInfo().gid), undefined) 250 | await nativeUtil.promisify(nativeFs.close)(fd) 251 | }) 252 | 253 | it("should futimes existing file with date", async () => { 254 | const file = await helper.createTmpFile() 255 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "w") 256 | assert.equal(await util.promisify(fs.futimes)(fd, new Date(), new Date()), undefined) 257 | await nativeUtil.promisify(nativeFs.close)(fd) 258 | }) 259 | 260 | it("should fail to futimes nonexistent file", async () => { 261 | await assert.rejects(util.promisify(fs.futimes)(99999, 9999, 9999), /EBADF/) 262 | }) 263 | }) 264 | 265 | if (os.platform() === "darwin") { 266 | describe("lchmod", () => { 267 | it("should lchmod existing file", async () => { 268 | const file = await helper.createTmpFile() 269 | assert.equal(await util.promisify(fs.lchmod)(file, "755"), undefined) 270 | }) 271 | 272 | it("should fail to lchmod nonexistent file", async () => { 273 | await assert.rejects(util.promisify(fs.lchmod)(helper.tmpFile(), "755"), /ENOENT/) 274 | }) 275 | }) 276 | } 277 | 278 | describe("lchown", () => { 279 | it("should lchown existing file", async () => { 280 | const file = await helper.createTmpFile() 281 | assert.equal(await util.promisify(fs.lchown)(file, os.userInfo().uid, os.userInfo().gid), undefined) 282 | }) 283 | 284 | it("should fail to lchown nonexistent file", async () => { 285 | await assert.rejects(util.promisify(fs.lchown)(helper.tmpFile(), os.userInfo().uid, os.userInfo().gid), /ENOENT/) 286 | }) 287 | }) 288 | 289 | describe("link", () => { 290 | it("should link existing file", async () => { 291 | const source = await helper.createTmpFile() 292 | const destination = helper.tmpFile() 293 | assert.equal(await util.promisify(fs.link)(source, destination), undefined) 294 | assert.equal(await util.promisify(fs.exists)(destination), true) 295 | }) 296 | 297 | it("should fail to link nonexistent file", async () => { 298 | await assert.rejects(util.promisify(fs.link)(helper.tmpFile(), helper.tmpFile()), /ENOENT/) 299 | }) 300 | }) 301 | 302 | describe("lstat", () => { 303 | it("should lstat existing file", async () => { 304 | const stat = await nativeUtil.promisify(nativeFs.lstat)(__filename) 305 | assert.equal((await util.promisify(fs.lstat)(__filename)).size, stat.size) 306 | }) 307 | 308 | it("should fail to lstat non-existent file", async () => { 309 | await assert.rejects(util.promisify(fs.lstat)(helper.tmpFile()), /ENOENT/) 310 | }) 311 | }) 312 | 313 | describe("mkdir", () => { 314 | let target: string 315 | it("should create nonexistent directory", async () => { 316 | target = helper.tmpFile() 317 | assert.equal(await util.promisify(fs.mkdir)(target), undefined) 318 | }) 319 | 320 | it("should fail to create existing directory", async () => { 321 | await assert.rejects(util.promisify(fs.mkdir)(target), /EEXIST/) 322 | }) 323 | }) 324 | 325 | describe("mkdtemp", () => { 326 | it("should create temp dir", async () => { 327 | assert.equal( 328 | /^\/tmp\/coder\/fs\/[a-zA-Z0-9]{6}/.test(await util.promisify(fs.mkdtemp)(helper.coderDir + "/")), 329 | true 330 | ) 331 | }) 332 | }) 333 | 334 | describe("open", () => { 335 | it("should open existing file", async () => { 336 | const fd = await util.promisify(fs.open)(__filename, "r") 337 | assert.notEqual(isNaN(fd), true) 338 | assert.equal(await util.promisify(fs.close)(fd), undefined) 339 | }) 340 | 341 | it("should fail to open nonexistent file", async () => { 342 | await assert.rejects(util.promisify(fs.open)(helper.tmpFile(), "r"), /ENOENT/) 343 | }) 344 | }) 345 | 346 | describe("read", () => { 347 | it("should read existing file", async () => { 348 | const fd = await nativeUtil.promisify(nativeFs.open)(__filename, "r") 349 | const stat = await nativeUtil.promisify(nativeFs.fstat)(fd) 350 | const buffer = Buffer.alloc(stat.size) 351 | let bytesRead = 0 352 | let chunkSize = 2048 353 | while (bytesRead < stat.size) { 354 | if (bytesRead + chunkSize > stat.size) { 355 | chunkSize = stat.size - bytesRead 356 | } 357 | 358 | await util.promisify(fs.read)(fd, buffer, bytesRead, chunkSize, bytesRead) 359 | bytesRead += chunkSize 360 | } 361 | 362 | const content = await nativeUtil.promisify(nativeFs.readFile)(__filename, "utf8") 363 | assert.equal(buffer.toString(), content) 364 | await nativeUtil.promisify(nativeFs.close)(fd) 365 | }) 366 | 367 | it("should fail to read nonexistent file", async () => { 368 | await assert.rejects(util.promisify(fs.read)(99999, Buffer.alloc(10), 9999, 999, 999), /EBADF/) 369 | }) 370 | }) 371 | 372 | describe("readFile", () => { 373 | it("should read existing file", async () => { 374 | const content = await nativeUtil.promisify(nativeFs.readFile)(__filename, "utf8") 375 | assert.equal(await util.promisify(fs.readFile)(__filename, "utf8"), content) 376 | }) 377 | 378 | it("should fail to read nonexistent file", async () => { 379 | await assert.rejects(util.promisify(fs.readFile)(helper.tmpFile()), /ENOENT/) 380 | }) 381 | }) 382 | 383 | describe("readdir", () => { 384 | it("should read existing directory", async () => { 385 | const paths = await nativeUtil.promisify(nativeFs.readdir)(helper.coderDir) 386 | assert.deepEqual(await util.promisify(fs.readdir)(helper.coderDir), paths) 387 | }) 388 | 389 | it("should fail to read nonexistent directory", async () => { 390 | await assert.rejects(util.promisify(fs.readdir)(helper.tmpFile()), /ENOENT/) 391 | }) 392 | }) 393 | 394 | describe("readlink", () => { 395 | it("should read existing link", async () => { 396 | const source = await helper.createTmpFile() 397 | const destination = helper.tmpFile() 398 | await nativeUtil.promisify(nativeFs.symlink)(source, destination) 399 | assert.equal(await util.promisify(fs.readlink)(destination), source) 400 | }) 401 | 402 | it("should fail to read nonexistent link", async () => { 403 | await assert.rejects(util.promisify(fs.readlink)(helper.tmpFile()), /ENOENT/) 404 | }) 405 | }) 406 | 407 | describe("realpath", () => { 408 | it("should read real path of existing file", async () => { 409 | const source = await helper.createTmpFile() 410 | const destination = helper.tmpFile() 411 | nativeFs.symlinkSync(source, destination) 412 | assert.equal(await util.promisify(fs.realpath)(destination), source) 413 | }) 414 | 415 | it("should fail to read real path of nonexistent file", async () => { 416 | await assert.rejects(util.promisify(fs.realpath)(helper.tmpFile()), /ENOENT/) 417 | }) 418 | }) 419 | 420 | describe("rename", () => { 421 | it("should rename existing file", async () => { 422 | const source = await helper.createTmpFile() 423 | const destination = helper.tmpFile() 424 | assert.equal(await util.promisify(fs.rename)(source, destination), undefined) 425 | assert.equal(await nativeUtil.promisify(nativeFs.exists)(source), false) 426 | assert.equal(await nativeUtil.promisify(nativeFs.exists)(destination), true) 427 | }) 428 | 429 | it("should fail to rename nonexistent file", async () => { 430 | await assert.rejects(util.promisify(fs.rename)(helper.tmpFile(), helper.tmpFile()), /ENOENT/) 431 | }) 432 | }) 433 | 434 | describe("rmdir", () => { 435 | it("should rmdir existing directory", async () => { 436 | const dir = helper.tmpFile() 437 | await nativeUtil.promisify(nativeFs.mkdir)(dir) 438 | assert.equal(await util.promisify(fs.rmdir)(dir), undefined) 439 | assert.equal(await nativeUtil.promisify(nativeFs.exists)(dir), false) 440 | }) 441 | 442 | it("should fail to rmdir nonexistent directory", async () => { 443 | await assert.rejects(util.promisify(fs.rmdir)(helper.tmpFile()), /ENOENT/) 444 | }) 445 | }) 446 | 447 | describe("stat", () => { 448 | it("should stat existing file", async () => { 449 | const nativeStat = await nativeUtil.promisify(nativeFs.stat)(__filename) 450 | const stat = await util.promisify(fs.stat)(__filename) 451 | assert.equal(stat.size, nativeStat.size) 452 | assert.equal(typeof stat.mtime.getTime(), "number") 453 | assert.equal(stat.isFile(), true) 454 | }) 455 | 456 | it("should stat existing folder", async () => { 457 | const dir = helper.tmpFile() 458 | await nativeUtil.promisify(nativeFs.mkdir)(dir) 459 | const nativeStat = await nativeUtil.promisify(nativeFs.stat)(dir) 460 | const stat = await util.promisify(fs.stat)(dir) 461 | assert.equal(stat.size, nativeStat.size) 462 | assert.equal(stat.isDirectory(), true) 463 | }) 464 | 465 | it("should fail to stat nonexistent file", async () => { 466 | const error = await util 467 | .promisify(fs.stat)(helper.tmpFile()) 468 | .catch((e) => e) 469 | assert.equal(error.message.includes("ENOENT"), true) 470 | assert.equal(error.code.includes("ENOENT"), true) 471 | }) 472 | }) 473 | 474 | describe("symlink", () => { 475 | it("should symlink existing file", async () => { 476 | const source = await helper.createTmpFile() 477 | const destination = helper.tmpFile() 478 | assert.equal(await util.promisify(fs.symlink)(source, destination), undefined) 479 | assert.equal(await nativeUtil.promisify(nativeFs.exists)(source), true) 480 | }) 481 | 482 | // TODO: Seems to be happy to do this on my system? 483 | it("should fail to symlink nonexistent file", async () => { 484 | assert.equal(await util.promisify(fs.symlink)(helper.tmpFile(), helper.tmpFile()), undefined) 485 | }) 486 | }) 487 | 488 | describe("truncate", () => { 489 | it("should truncate existing file", async () => { 490 | const file = helper.tmpFile() 491 | await nativeUtil.promisify(nativeFs.writeFile)(file, "hiiiiii") 492 | assert.equal(await util.promisify(fs.truncate)(file, 2), undefined) 493 | assert.equal(await nativeUtil.promisify(nativeFs.readFile)(file, "utf8"), "hi") 494 | }) 495 | 496 | it("should fail to truncate nonexistent file", async () => { 497 | await assert.rejects(util.promisify(fs.truncate)(helper.tmpFile(), 0), /ENOENT/) 498 | }) 499 | }) 500 | 501 | describe("unlink", () => { 502 | it("should unlink existing file", async () => { 503 | const file = await helper.createTmpFile() 504 | assert.equal(await util.promisify(fs.unlink)(file), undefined) 505 | assert.equal(await nativeUtil.promisify(nativeFs.exists)(file), false) 506 | }) 507 | 508 | it("should fail to unlink nonexistent file", async () => { 509 | await assert.rejects(util.promisify(fs.unlink)(helper.tmpFile()), /ENOENT/) 510 | }) 511 | }) 512 | 513 | describe("utimes", () => { 514 | it("should update times on existing file", async () => { 515 | const file = await helper.createTmpFile() 516 | assert.equal(await util.promisify(fs.utimes)(file, 100, 100), undefined) 517 | }) 518 | 519 | it("should fail to update times on nonexistent file", async () => { 520 | await assert.rejects(util.promisify(fs.utimes)(helper.tmpFile(), 100, 100), /ENOENT/) 521 | }) 522 | }) 523 | 524 | describe("write", () => { 525 | it("should write to existing file", async () => { 526 | const file = await helper.createTmpFile() 527 | const fd = await nativeUtil.promisify(nativeFs.open)(file, "w") 528 | assert.equal(await util.promisify(fs.write)(fd, Buffer.from("hi")), 2) 529 | assert.equal(await nativeUtil.promisify(nativeFs.readFile)(file, "utf8"), "hi") 530 | await nativeUtil.promisify(nativeFs.close)(fd) 531 | }) 532 | 533 | it("should fail to write to nonexistent file", async () => { 534 | await assert.rejects(util.promisify(fs.write)(100000, Buffer.from("wowow")), /EBADF/) 535 | }) 536 | }) 537 | 538 | describe("writeFile", () => { 539 | it("should write file", async () => { 540 | const file = await helper.createTmpFile() 541 | assert.equal(await util.promisify(fs.writeFile)(file, "howdy"), undefined) 542 | assert.equal(await nativeUtil.promisify(nativeFs.readFile)(file, "utf8"), "howdy") 543 | }) 544 | }) 545 | 546 | describe("cleanup", () => { 547 | it("should dispose", (done) => { 548 | setTimeout(() => { 549 | client.dispose() 550 | done() 551 | }, 100) 552 | }) 553 | }) 554 | 555 | describe("reconnect", () => { 556 | it("should reconnect", async () => { 557 | const client = createClient() 558 | const fs = (client.modules[Module.Fs] as any) as typeof import("fs") // eslint-disable-line @typescript-eslint/no-explicit-any 559 | assert.equal(await util.promisify(fs.access)(__filename), undefined) 560 | client.down() 561 | await new Promise((r): NodeJS.Timeout => setTimeout(r, 100)) 562 | client.up() 563 | assert.equal(await util.promisify(fs.access)(__filename), undefined) 564 | client.dispose() 565 | }) 566 | 567 | it("should fail while disconnected", async () => { 568 | const client = createClient() 569 | const fs = (client.modules[Module.Fs] as any) as typeof import("fs") // eslint-disable-line @typescript-eslint/no-explicit-any 570 | assert.equal(await util.promisify(fs.access)(__filename), undefined) 571 | client.down() 572 | await assert.rejects(util.promisify(fs.access)(__filename), /Unable to call/) 573 | client.dispose() 574 | }) 575 | 576 | it("should close permanently", async () => { 577 | const client = createClient() 578 | const fs = (client.modules[Module.Fs] as any) as typeof import("fs") // eslint-disable-line @typescript-eslint/no-explicit-any 579 | assert.equal(await util.promisify(fs.access)(__filename), undefined) 580 | client.dispose() 581 | await assert.rejects(util.promisify(fs.access)(__filename), /Unable to call/) 582 | client.up() 583 | await assert.rejects(util.promisify(fs.access)(__filename), /Unable to call/) 584 | }) 585 | }) 586 | }) 587 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import * as os from "os" 3 | import * as path from "path" 4 | import * as rimraf from "rimraf" 5 | import * as util from "util" 6 | import { Client } from "../src/client/client" 7 | import { Emitter } from "../src/common/events" 8 | import { Disposable } from "../src/common/util" 9 | import { Server, ServerOptions } from "../src/server/server" 10 | import { ReadWriteConnection, Logger } from "../src/common/connection" 11 | 12 | // So we only make the directory once when running multiple tests. 13 | let mkdirPromise: Promise | undefined 14 | 15 | export class Helper { 16 | private i = 0 17 | public coderDir: string 18 | private baseDir = path.join(os.tmpdir(), "coder") 19 | 20 | public constructor(directoryName: string) { 21 | if (!directoryName.trim()) { 22 | throw new Error("no directory name") 23 | } 24 | 25 | this.coderDir = path.join(this.baseDir, directoryName) 26 | } 27 | 28 | public tmpFile(): string { 29 | return path.join(this.coderDir, `${this.i++}`) 30 | } 31 | 32 | public async createTmpFile(): Promise { 33 | const tf = this.tmpFile() 34 | await util.promisify(fs.writeFile)(tf, "") 35 | 36 | return tf 37 | } 38 | 39 | public async prepare(): Promise { 40 | if (!mkdirPromise) { 41 | mkdirPromise = util 42 | .promisify(fs.mkdir)(this.baseDir) 43 | .catch((error) => { 44 | if (error.code !== "EEXIST" && error.code !== "EISDIR") { 45 | throw error 46 | } 47 | }) 48 | } 49 | await mkdirPromise 50 | await util.promisify(rimraf)(this.coderDir) 51 | await util.promisify(fs.mkdir)(this.coderDir) 52 | } 53 | } 54 | 55 | interface TestConnection extends ReadWriteConnection { 56 | close(): void 57 | down(): void 58 | up(): void 59 | } 60 | 61 | export class TestClient extends Client { 62 | public constructor(private readonly _connection: TestConnection, logger?: Logger) { 63 | super(_connection, { logger }) 64 | } 65 | 66 | public dispose(): void { 67 | this._connection.close() 68 | } 69 | 70 | public down(): void { 71 | this._connection.down() 72 | } 73 | 74 | public up(): void { 75 | this._connection.up() 76 | } 77 | } 78 | 79 | export function createClient(count?: 1, serverOptions?: ServerOptions): TestClient 80 | export function createClient(count: number, serverOptions?: ServerOptions): TestClient[] 81 | export function createClient(count = 1, serverOptions?: ServerOptions): TestClient | TestClient[] { 82 | const s2c = new Emitter() 83 | const c2s = new Emitter() 84 | const closeCallbacks = [] as Array<() => void> 85 | const downCallbacks = [] as Array<() => void> 86 | const upCallbacks = [] as Array<() => void> 87 | 88 | new Server( 89 | { 90 | onDown: (cb: () => void): number => downCallbacks.push(cb), 91 | onUp: (cb: () => void): number => upCallbacks.push(cb), 92 | onClose: (cb: () => void): number => closeCallbacks.push(cb), 93 | onMessage: (cb): Disposable => c2s.event((d) => cb(d)), 94 | send: (data): NodeJS.Timer => setTimeout(() => s2c.emit(data), 0), 95 | }, 96 | serverOptions 97 | ) 98 | 99 | const clients = [] 100 | while (--count >= 0) { 101 | clients.push( 102 | new TestClient({ 103 | close: (): void => closeCallbacks.forEach((cb) => cb()), 104 | down: (): void => downCallbacks.forEach((cb) => cb()), 105 | up: (): void => upCallbacks.forEach((cb) => cb()), 106 | onDown: (cb: () => void): number => downCallbacks.push(cb), 107 | onUp: (cb: () => void): number => upCallbacks.push(cb), 108 | onClose: (cb: () => void): number => closeCallbacks.push(cb), 109 | onMessage: (cb): Disposable => s2c.event((d) => cb(d)), 110 | send: (data): NodeJS.Timer => setTimeout(() => c2s.emit(data), 0), 111 | }) 112 | ) 113 | } 114 | 115 | return clients.length === 1 ? clients[0] : clients 116 | } 117 | 118 | type Argument = any // eslint-disable-line @typescript-eslint/no-explicit-any 119 | type Fn = (...args: Argument[]) => void 120 | export interface TestFn extends Fn { 121 | called: number 122 | args: Argument[] 123 | } 124 | 125 | export const testFn = (): TestFn => { 126 | const test = (...args: Argument[]): void => { 127 | ++test.called 128 | test.args.push(args.length === 1 ? args[0] : args) 129 | } 130 | 131 | test.called = 0 132 | test.args = [] as Argument[] 133 | 134 | return test 135 | } 136 | -------------------------------------------------------------------------------- /test/net.test.ts: -------------------------------------------------------------------------------- 1 | import "leaked-handles" 2 | import * as assert from "assert" 3 | import * as nativeNet from "net" 4 | import { Module } from "../src/common/proxy" 5 | import { createClient, Helper, testFn } from "./helpers" 6 | 7 | describe("net", () => { 8 | const client = createClient() 9 | const net = client.modules[Module.Net] 10 | const helper = new Helper("net") 11 | 12 | before(async () => { 13 | await helper.prepare() 14 | }) 15 | 16 | describe("Socket", () => { 17 | const socketPath = helper.tmpFile() 18 | let server: nativeNet.Server 19 | 20 | before(async () => { 21 | await new Promise((r): void => { 22 | server = nativeNet.createServer().listen(socketPath, r) 23 | }) 24 | }) 25 | 26 | after(() => { 27 | server.close() 28 | }) 29 | 30 | it("should fail to connect", async () => { 31 | const socket = new net.Socket() 32 | 33 | const fn = testFn() 34 | socket.on("error", fn) 35 | 36 | socket.connect("/tmp/t/e/s/t/d/o/e/s/n/o/t/e/x/i/s/t") 37 | 38 | await new Promise((r): nativeNet.Socket => socket.on("close", r)) 39 | 40 | assert.equal(fn.called, 1) 41 | }) 42 | 43 | it("should remove event listener", async () => { 44 | const socket = new net.Socket() 45 | 46 | const fn = testFn() 47 | const fn2 = testFn() 48 | 49 | const on = fn 50 | socket.on("error", on) 51 | socket.on("error", fn2) 52 | socket.off("error", on) 53 | 54 | socket.connect("/tmp/t/e/s/t/d/o/e/s/n/o/t/e/x/i/s/t") 55 | 56 | await new Promise((r): nativeNet.Socket => socket.on("close", r)) 57 | assert.equal(fn.called, 0) 58 | assert.equal(fn2.called, 1) 59 | }) 60 | 61 | it("should connect", async () => { 62 | await new Promise((resolve): void => { 63 | const socket = net.createConnection(socketPath, () => { 64 | socket.end() 65 | socket.addListener("close", () => { 66 | resolve() 67 | }) 68 | }) 69 | }) 70 | 71 | await new Promise((resolve): void => { 72 | const socket = new net.Socket() 73 | socket.connect(socketPath, () => { 74 | socket.end() 75 | socket.addListener("close", () => { 76 | resolve() 77 | }) 78 | }) 79 | }) 80 | }) 81 | 82 | it("should get data", (done) => { 83 | server.once("connection", (socket: nativeNet.Socket) => { 84 | socket.write("hi how r u") 85 | }) 86 | 87 | const socket = net.createConnection(socketPath) 88 | 89 | socket.addListener("data", (data) => { 90 | assert.equal(data.toString(), "hi how r u") 91 | socket.end() 92 | socket.addListener("close", () => { 93 | done() 94 | }) 95 | }) 96 | }) 97 | 98 | it("should send data", (done) => { 99 | const clientSocket = net.createConnection(socketPath) 100 | clientSocket.write(Buffer.from("bananas")) 101 | server.once("connection", (socket: nativeNet.Socket) => { 102 | socket.addListener("data", (data) => { 103 | assert.equal(data.toString(), "bananas") 104 | socket.end() 105 | clientSocket.addListener("end", () => { 106 | done() 107 | }) 108 | }) 109 | }) 110 | }) 111 | }) 112 | 113 | describe("Server", () => { 114 | it("should listen", (done) => { 115 | const s = net.createServer() 116 | s.on("listening", () => s.close()) 117 | s.on("close", () => done()) 118 | s.listen(helper.tmpFile()) 119 | }) 120 | 121 | it("should get connection", async () => { 122 | let constructorListener: (() => void) | undefined 123 | const s = net.createServer(() => { 124 | if (constructorListener) { 125 | constructorListener() 126 | } 127 | }) 128 | 129 | const socketPath = helper.tmpFile() 130 | s.listen(socketPath) 131 | 132 | await new Promise((resolve): void => { 133 | s.on("listening", resolve) 134 | }) 135 | 136 | const makeConnection = async (): Promise => { 137 | net.createConnection(socketPath) 138 | await Promise.all([ 139 | new Promise((resolve): void => { 140 | constructorListener = resolve 141 | }), 142 | new Promise((resolve): void => { 143 | s.once("connection", (socket) => { 144 | socket.destroy() 145 | resolve() 146 | }) 147 | }), 148 | ]) 149 | } 150 | 151 | await makeConnection() 152 | await makeConnection() 153 | 154 | s.close() 155 | await new Promise((r): nativeNet.Server => s.on("close", r)) 156 | }) 157 | }) 158 | 159 | describe("cleanup", () => { 160 | it("should dispose", (done) => { 161 | setTimeout(() => { 162 | client.dispose() 163 | done() 164 | }, 100) 165 | }) 166 | }) 167 | }) 168 | -------------------------------------------------------------------------------- /test/util.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert" 2 | import { EventEmitter } from "events" 3 | import * as fs from "fs" 4 | import * as util from "util" 5 | import { encode, decode } from "../src/common/arguments" 6 | import { ServerProxy } from "../src/common/proxy" 7 | import { testFn } from "./helpers" 8 | 9 | class TestProxy extends ServerProxy { 10 | public constructor(public readonly id: string) { 11 | super({ 12 | bindEvents: [], 13 | doneEvents: [], 14 | instance: new EventEmitter(), 15 | }) 16 | } 17 | } 18 | 19 | describe("Convert", () => { 20 | it("should convert nothing", () => { 21 | assert.equal(decode(), undefined) 22 | }) 23 | 24 | it("should convert null", () => { 25 | assert.equal(decode(encode(null)), null) 26 | }) 27 | 28 | it("should convert undefined", () => { 29 | assert.equal(decode(encode(undefined)), undefined) 30 | }) 31 | 32 | it("should convert string", () => { 33 | assert.equal(decode(encode("test")), "test") 34 | }) 35 | 36 | it("should convert number", () => { 37 | assert.equal(decode(encode(10)), 10) 38 | }) 39 | 40 | it("should convert boolean", () => { 41 | assert.equal(decode(encode(true)), true) 42 | assert.equal(decode(encode(false)), false) 43 | }) 44 | 45 | it("should convert error", () => { 46 | const error = new Error("message") 47 | const convertedError = decode(encode(error)) 48 | 49 | assert.equal(convertedError instanceof Error, true) 50 | assert.equal(convertedError.message, "message") 51 | }) 52 | 53 | it("should convert buffer", async () => { 54 | const buffer = await util.promisify(fs.readFile)(__filename) 55 | assert.equal(buffer instanceof Buffer, true) 56 | 57 | const convertedBuffer = decode(encode(buffer)) 58 | assert.equal(convertedBuffer instanceof Buffer, true) 59 | assert.equal(convertedBuffer.toString(), buffer.toString()) 60 | }) 61 | 62 | it("should convert proxy", () => { 63 | let i = 0 64 | const proto = encode({ onEvent: (): void => undefined }, undefined, () => i++) 65 | const proxy = decode(proto, undefined, (id) => new TestProxy(`created: ${id}`)) 66 | assert.equal(proxy.id, "created: 0") 67 | }) 68 | 69 | it("should convert function", () => { 70 | const fn = testFn() 71 | const map = new Map) => void>() 72 | let i = 0 73 | const encoded = encode(fn, (f) => { 74 | map.set(i++, f) 75 | return i - 1 76 | }) 77 | 78 | const remoteFn = decode(encoded, (id, args) => { 79 | const f = map.get(id) 80 | if (f) { 81 | f(...args) 82 | } 83 | }) 84 | 85 | remoteFn("a", "b", 1) 86 | 87 | assert.equal(fn.called, 1) 88 | assert.deepEqual(fn.args, [["a", "b", 1]]) 89 | }) 90 | 91 | it("should convert array", () => { 92 | const array = ["a", "b", 1, [1, "a"], null, undefined] 93 | assert.deepEqual(decode(encode(array)), array) 94 | }) 95 | 96 | it("should convert object", () => { 97 | const obj = { a: "test" } 98 | // const obj = { "a": 1, "b": [1, "a"], test: null, test2: undefined }; 99 | assert.deepEqual(decode(encode(obj)), obj) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noImplicitReturns": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "outDir": "out", 10 | "declaration": true, 11 | "importHelpers": true 12 | }, 13 | "include": [ 14 | "./src/client", 15 | "./src/common", 16 | "./src/index.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noImplicitReturns": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "outDir": "out", 10 | "declaration": true 11 | }, 12 | "include": [ 13 | "./src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "umd", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noImplicitReturns": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "outDir": "out", 10 | "declaration": true 11 | }, 12 | "include": [ 13 | "./src/server", 14 | "./src/common" 15 | ] 16 | } 17 | --------------------------------------------------------------------------------