chore: initial migration to fuman networking

This commit is contained in:
alina 🌸 2024-08-28 19:10:53 +03:00
parent f621b0e81a
commit d512da6831
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
47 changed files with 2395 additions and 2657 deletions

View file

@ -53,6 +53,11 @@ export function processPackageJson(packageDir) {
const dependencies = packageJson[field] const dependencies = packageJson[field]
for (const name of Object.keys(dependencies)) { for (const name of Object.keys(dependencies)) {
if (name.startsWith('@fuman/')) {
delete dependencies[name] // fuman is bundled with vite for now
continue
}
const value = dependencies[name] const value = dependencies[name]
if (value.startsWith('workspace:')) { if (value.startsWith('workspace:')) {

View file

@ -45,6 +45,7 @@ if (typeof globalThis !== 'undefined' && !globalThis._MTCUTE_CJS_DEPRECATION_WAR
...(customConfig?.rollupPluginsPre ?? []), ...(customConfig?.rollupPluginsPre ?? []),
nodeExternals({ nodeExternals({
builtinsPrefix: 'ignore', builtinsPrefix: 'ignore',
exclude: /^@fuman\//,
}), }),
{ {
name: 'mtcute-finalize', name: 'mtcute-finalize',

View file

@ -1,4 +1,3 @@
**/node_modules **/node_modules
**/private
**/dist **/dist
/e2e /e2e

View file

@ -14,6 +14,12 @@ jobs:
if: github.actor != 'mtcute-bot' # do not run after release if: github.actor != 'mtcute-bot' # do not run after release
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Fetch fuman
uses: actions/checkout@v4
with:
repository: teidesu/fuman
path: private/fuman
token: ${{ secrets.BOT_PAT }}
- uses: ./.github/actions/init - uses: ./.github/actions/init
- name: 'TypeScript' - name: 'TypeScript'
run: pnpm run lint:tsc:ci run: pnpm run lint:tsc:ci
@ -30,6 +36,12 @@ jobs:
node-version: [18.x, 20.x, 22.x] node-version: [18.x, 20.x, 22.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Fetch fuman
uses: actions/checkout@v4
with:
repository: teidesu/fuman
path: private/fuman
token: ${{ secrets.BOT_PAT }}
- uses: ./.github/actions/init - uses: ./.github/actions/init
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
@ -47,6 +59,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Fetch fuman
uses: actions/checkout@v4
with:
repository: teidesu/fuman
path: private/fuman
token: ${{ secrets.BOT_PAT }}
- uses: ./.github/actions/init - uses: ./.github/actions/init
- uses: oven-sh/setup-bun@v1 - uses: oven-sh/setup-bun@v1
with: with:
@ -60,6 +78,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Fetch fuman
uses: actions/checkout@v4
with:
repository: teidesu/fuman
path: private/fuman
token: ${{ secrets.BOT_PAT }}
- uses: ./.github/actions/init - uses: ./.github/actions/init
- uses: denoland/setup-deno@v1 - uses: denoland/setup-deno@v1
with: with:
@ -77,6 +101,12 @@ jobs:
browser: [chromium, firefox] browser: [chromium, firefox]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Fetch fuman
uses: actions/checkout@v4
with:
repository: teidesu/fuman
path: private/fuman
token: ${{ secrets.BOT_PAT }}
- name: 'Build Docker image' - name: 'Build Docker image'
run: docker build . -f .github/Dockerfile.test-web --build-arg BROWSER=${{ matrix.browser }} -t mtcute/test-web run: docker build . -f .github/Dockerfile.test-web --build-arg BROWSER=${{ matrix.browser }} -t mtcute/test-web
- name: 'Run tests' - name: 'Run tests'
@ -96,6 +126,12 @@ jobs:
actions: write actions: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Fetch fuman
uses: actions/checkout@v4
with:
repository: teidesu/fuman
path: private/fuman
token: ${{ secrets.BOT_PAT }}
- name: Run end-to-end tests - name: Run end-to-end tests
env: env:
API_ID: ${{ secrets.TELEGRAM_API_ID }} API_ID: ${{ secrets.TELEGRAM_API_ID }}
@ -110,22 +146,22 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY: 'https://npm.tei.su' REGISTRY: 'https://npm.tei.su'
run: cd e2e/node && ./cli.sh ci-publish run: cd e2e/node && ./cli.sh ci-publish
e2e-deno: # e2e-deno:
runs-on: ubuntu-latest # runs-on: ubuntu-latest
needs: [lint, test-node, test-web, test-bun, test-deno] # needs: [lint, test-node, test-web, test-bun, test-deno]
permissions: # permissions:
contents: read # contents: read
actions: write # actions: write
steps: # steps:
- uses: actions/checkout@v4 # - uses: actions/checkout@v4
- name: Run end-to-end tests under Deno # - name: Run end-to-end tests under Deno
env: # env:
API_ID: ${{ secrets.TELEGRAM_API_ID }} # API_ID: ${{ secrets.TELEGRAM_API_ID }}
API_HASH: ${{ secrets.TELEGRAM_API_HASH }} # API_HASH: ${{ secrets.TELEGRAM_API_HASH }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-fields/retry@v2 # uses: nick-fields/retry@v2
# thanks docker networking very cool # # thanks docker networking very cool
with: # with:
max_attempts: 3 # max_attempts: 3
timeout_minutes: 30 # timeout_minutes: 30
command: cd e2e/deno && ./cli.sh ci # command: cd e2e/deno && ./cli.sh ci

View file

@ -18,7 +18,7 @@ import { downloadAsNodeStream } from './methods/download-node-stream.js'
import { BunPlatform } from './platform.js' import { BunPlatform } from './platform.js'
import { SqliteStorage } from './sqlite/index.js' import { SqliteStorage } from './sqlite/index.js'
import { BunCryptoProvider } from './utils/crypto.js' import { BunCryptoProvider } from './utils/crypto.js'
import { TcpTransport } from './utils/tcp.js' // import { TcpTransport } from './utils/tcp.js'
export type { TelegramClientOptions } export type { TelegramClientOptions }
@ -49,7 +49,7 @@ export class BaseTelegramClient extends BaseTelegramClientBase {
super({ super({
crypto: new BunCryptoProvider(), crypto: new BunCryptoProvider(),
transport: () => new TcpTransport(), transport: {} as any, // todo
...opts, ...opts,
storage: storage:
typeof opts.storage === 'string' typeof opts.storage === 'string'

View file

@ -2,7 +2,7 @@ export * from './client.js'
export * from './platform.js' export * from './platform.js'
export * from './sqlite/index.js' export * from './sqlite/index.js'
export * from './utils/crypto.js' export * from './utils/crypto.js'
export * from './utils/tcp.js' // export * from './utils/tcp.js'
export * from './worker.js' export * from './worker.js'
export * from '@mtcute/core' export * from '@mtcute/core'
export * from '@mtcute/html-parser' export * from '@mtcute/html-parser'

View file

@ -1,154 +1,152 @@
// eslint-disable-next-line unicorn/prefer-node-protocol // todo
import EventEmitter from 'events' // import EventEmitter from 'node:events'
import type { Socket } from 'bun' // import { IntermediatePacketCodec, IPacketCodec, ITelegramConnection, MtcuteError } from '@mtcute/core'
import type { IPacketCodec, ITelegramTransport } from '@mtcute/core' // import { BasicDcOption, ICryptoProvider, Logger } from '@mtcute/core/utils.js'
import { IntermediatePacketCodec, MtcuteError, TransportState } from '@mtcute/core'
import type { BasicDcOption, ICryptoProvider, Logger } from '@mtcute/core/utils.js'
/** // /**
* Base for TCP transports. // * Base for TCP transports.
* Subclasses must provide packet codec in `_packetCodec` property // * Subclasses must provide packet codec in `_packetCodec` property
*/ // */
export abstract class BaseTcpTransport extends EventEmitter implements ITelegramTransport { // export abstract class BaseTcpTransport extends EventEmitter implements ITelegramConnection {
protected _currentDc: BasicDcOption | null = null // protected _currentDc: BasicDcOption | null = null
protected _state: TransportState = TransportState.Idle // protected _state: TransportState = TransportState.Idle
protected _socket: Socket | null = null // protected _socket: Socket | null = null
abstract _packetCodec: IPacketCodec // abstract _packetCodec: IPacketCodec
protected _crypto!: ICryptoProvider // protected _crypto!: ICryptoProvider
protected log!: Logger // protected log!: Logger
packetCodecInitialized = false // packetCodecInitialized = false
private _updateLogPrefix() { // private _updateLogPrefix() {
if (this._currentDc) { // if (this._currentDc) {
this.log.prefix = `[TCP:${this._currentDc.ipAddress}:${this._currentDc.port}] ` // this.log.prefix = `[TCP:${this._currentDc.ipAddress}:${this._currentDc.port}] `
} else { // } else {
this.log.prefix = '[TCP:disconnected] ' // this.log.prefix = '[TCP:disconnected] '
} // }
} // }
setup(crypto: ICryptoProvider, log: Logger): void { // setup(crypto: ICryptoProvider, log: Logger): void {
this._crypto = crypto // this._crypto = crypto
this.log = log.create('tcp') // this.log = log.create('tcp')
this._updateLogPrefix() // this._updateLogPrefix()
} // }
state(): TransportState { // state(): TransportState {
return this._state // return this._state
} // }
currentDc(): BasicDcOption | null { // currentDc(): BasicDcOption | null {
return this._currentDc // return this._currentDc
} // }
// eslint-disable-next-line unused-imports/no-unused-vars // // eslint-disable-next-line unused-imports/no-unused-vars
connect(dc: BasicDcOption, testMode: boolean): void { // connect(dc: BasicDcOption, testMode: boolean): void {
if (this._state !== TransportState.Idle) { // if (this._state !== TransportState.Idle) {
throw new MtcuteError('Transport is not IDLE') // throw new MtcuteError('Transport is not IDLE')
} // }
if (!this.packetCodecInitialized) { // if (!this.packetCodecInitialized) {
this._packetCodec.setup?.(this._crypto, this.log) // this._packetCodec.setup?.(this._crypto, this.log)
this._packetCodec.on('error', err => this.emit('error', err)) // this._packetCodec.on('error', err => this.emit('error', err))
this._packetCodec.on('packet', buf => this.emit('message', buf)) // this._packetCodec.on('packet', buf => this.emit('message', buf))
this.packetCodecInitialized = true // this.packetCodecInitialized = true
} // }
this._state = TransportState.Connecting // this._state = TransportState.Connecting
this._currentDc = dc // this._currentDc = dc
this._updateLogPrefix() // this._updateLogPrefix()
this.log.debug('connecting to %j', dc) // this.log.debug('connecting to %j', dc)
Bun.connect({ // Bun.connect({
hostname: dc.ipAddress, // hostname: dc.ipAddress,
port: dc.port, // port: dc.port,
socket: { // socket: {
open: this.handleConnect.bind(this), // open: this.handleConnect.bind(this),
error: this.handleError.bind(this), // error: this.handleError.bind(this),
data: (socket, data) => this._packetCodec.feed(data), // data: (socket, data) => this._packetCodec.feed(data),
close: this.close.bind(this), // close: this.close.bind(this),
drain: this.handleDrained.bind(this), // drain: this.handleDrained.bind(this),
}, // },
}).catch((err) => { // }).catch((err) => {
this.handleError(null, err as Error) // this.handleError(null, err as Error)
this.close() // this.close()
}) // })
} // }
close(): void { // close(): void {
if (this._state === TransportState.Idle) return // if (this._state === TransportState.Idle) return
this.log.info('connection closed') // this.log.info('connection closed')
this._state = TransportState.Idle // this._state = TransportState.Idle
this._socket?.end() // this._socket?.end()
this._socket = null // this._socket = null
this._currentDc = null // this._currentDc = null
this._packetCodec.reset() // this._packetCodec.reset()
this._sendOnceDrained = [] // this._sendOnceDrained = []
this.emit('close') // this.emit('close')
} // }
handleError(socket: unknown, error: Error): void { // handleError(socket: unknown, error: Error): void {
this.log.error('error: %s', error.stack) // this.log.error('error: %s', error.stack)
if (this.listenerCount('error') > 0) { // if (this.listenerCount('error') > 0) {
this.emit('error', error) // this.emit('error', error)
} // }
} // }
handleConnect(socket: Socket): void { // handleConnect(socket: Socket): void {
this._socket = socket // this._socket = socket
this.log.info('connected') // this.log.info('connected')
Promise.resolve(this._packetCodec.tag()) // Promise.resolve(this._packetCodec.tag())
.then((initialMessage) => { // .then((initialMessage) => {
if (initialMessage.length) { // if (initialMessage.length) {
this._socket!.write(initialMessage) // this._socket!.write(initialMessage)
this._state = TransportState.Ready // this._state = TransportState.Ready
this.emit('ready') // this.emit('ready')
} else { // } else {
this._state = TransportState.Ready // this._state = TransportState.Ready
this.emit('ready') // this.emit('ready')
} // }
}) // })
.catch((err) => { // .catch((err) => {
if (this.listenerCount('error') > 0) { // if (this.listenerCount('error') > 0) {
this.emit('error', err) // this.emit('error', err)
} // }
}) // })
} // }
async send(bytes: Uint8Array): Promise<void> { // async send(bytes: Uint8Array): Promise<void> {
const framed = await this._packetCodec.encode(bytes) // const framed = await this._packetCodec.encode(bytes)
if (this._state !== TransportState.Ready) { // if (this._state !== TransportState.Ready) {
throw new MtcuteError('Transport is not READY') // throw new MtcuteError('Transport is not READY')
} // }
const written = this._socket!.write(framed) // const written = this._socket!.write(framed)
if (written < framed.length) { // if (written < framed.length) {
this._sendOnceDrained.push(framed.subarray(written)) // this._sendOnceDrained.push(framed.subarray(written))
} // }
} // }
private _sendOnceDrained: Uint8Array[] = [] // private _sendOnceDrained: Uint8Array[] = []
private handleDrained(): void { // private handleDrained(): void {
while (this._sendOnceDrained.length) { // while (this._sendOnceDrained.length) {
const data = this._sendOnceDrained.shift()! // const data = this._sendOnceDrained.shift()!
const written = this._socket!.write(data) // const written = this._socket!.write(data)
if (written < data.length) { // if (written < data.length) {
this._sendOnceDrained.unshift(data.subarray(written)) // this._sendOnceDrained.unshift(data.subarray(written))
break // break
} // }
} // }
} // }
} // }
export class TcpTransport extends BaseTcpTransport { // export class TcpTransport extends BaseTcpTransport {
_packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec() // _packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec()
} // }

View file

@ -1,36 +1,39 @@
{ {
"name": "@mtcute/core", "name": "@mtcute/core",
"type": "module", "type": "module",
"version": "0.16.13", "version": "0.16.13",
"private": true, "private": true,
"description": "Type-safe library for MTProto (Telegram API)", "description": "Type-safe library for MTProto (Telegram API)",
"author": "alina sireneva <alina@tei.su>", "author": "alina sireneva <alina@tei.su>",
"license": "MIT", "license": "MIT",
"sideEffects": false, "sideEffects": false,
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./utils.js": "./src/utils/index.ts", "./utils.js": "./src/utils/index.ts",
"./client.js": "./src/highlevel/client.ts", "./client.js": "./src/highlevel/client.ts",
"./worker.js": "./src/highlevel/worker/index.ts", "./worker.js": "./src/highlevel/worker/index.ts",
"./methods.js": "./src/highlevel/methods.ts", "./methods.js": "./src/highlevel/methods.ts",
"./platform.js": "./src/platform.ts" "./platform.js": "./src/platform.ts"
}, },
"scripts": { "scripts": {
"build": "pnpm run -w build-package core", "build": "pnpm run -w build-package core",
"gen-client": "node ./scripts/generate-client.cjs", "gen-client": "node ./scripts/generate-client.cjs",
"gen-updates": "node ./scripts/generate-updates.cjs" "gen-updates": "node ./scripts/generate-updates.cjs"
}, },
"dependencies": { "dependencies": {
"@mtcute/file-id": "workspace:^", "@fuman/io": "workspace:^",
"@mtcute/tl": "workspace:^", "@fuman/net": "workspace:^",
"@mtcute/tl-runtime": "workspace:^", "@fuman/utils": "workspace:^",
"@types/events": "3.0.0", "@mtcute/file-id": "workspace:^",
"events": "3.2.0", "@mtcute/tl": "workspace:^",
"long": "5.2.3" "@mtcute/tl-runtime": "workspace:^",
}, "@types/events": "3.0.0",
"devDependencies": { "events": "3.2.0",
"@mtcute/test": "workspace:^", "long": "5.2.3"
"@types/ws": "8.5.4", },
"ws": "8.13.0" "devDependencies": {
} "@mtcute/test": "workspace:^",
"@types/ws": "8.5.4",
"ws": "8.13.0"
}
} }

View file

@ -6,6 +6,7 @@ import { tl } from '@mtcute/tl'
import { __tlReaderMap as defaultReaderMap } from '@mtcute/tl/binary/reader.js' import { __tlReaderMap as defaultReaderMap } from '@mtcute/tl/binary/reader.js'
import { __tlWriterMap as defaultWriterMap } from '@mtcute/tl/binary/writer.js' import { __tlWriterMap as defaultWriterMap } from '@mtcute/tl/binary/writer.js'
import type { TlReaderMap, TlWriterMap } from '@mtcute/tl-runtime' import type { TlReaderMap, TlWriterMap } from '@mtcute/tl-runtime'
import type { ReconnectionStrategy } from '@fuman/net'
import type { IMtStorageProvider } from '../storage/provider.js' import type { IMtStorageProvider } from '../storage/provider.js'
import type { StorageManagerExtraOptions } from '../storage/storage.js' import type { StorageManagerExtraOptions } from '../storage/storage.js'
@ -29,9 +30,7 @@ import {
import { ConfigManager } from './config-manager.js' import { ConfigManager } from './config-manager.js'
import type { NetworkManagerExtraParams, RpcCallOptions } from './network-manager.js' import type { NetworkManagerExtraParams, RpcCallOptions } from './network-manager.js'
import { NetworkManager } from './network-manager.js' import { NetworkManager } from './network-manager.js'
import type { PersistentConnectionParams } from './persistent-connection.js' import type { TelegramTransport } from './transports'
import type { ReconnectionStrategy } from './reconnection.js'
import type { TransportFactory } from './transports/index.js'
/** Options for {@link MtClient} */ /** Options for {@link MtClient} */
export interface MtClientOptions { export interface MtClientOptions {
@ -96,18 +95,18 @@ export interface MtClientOptions {
initConnectionOptions?: Partial<Omit<tl.RawInitConnectionRequest, 'apiId' | 'query'>> initConnectionOptions?: Partial<Omit<tl.RawInitConnectionRequest, 'apiId' | 'query'>>
/** /**
* Transport factory to use in the client. * Transport to use in the client.
* *
* @default platform-specific transport: WebSocket on the web, TCP in node * @default platform-specific transport: WebSocket on the web, TCP in node
*/ */
transport: TransportFactory transport: TelegramTransport
/** /**
* Reconnection strategy. * Reconnection strategy.
* *
* @default simple reconnection strategy: first 0ms, then up to 5s (increasing by 1s) * @default simple reconnection strategy: first 0ms, then up to 5s (increasing by 1s)
*/ */
reconnectionStrategy?: ReconnectionStrategy<PersistentConnectionParams> reconnectionStrategy?: ReconnectionStrategy
/** /**
* If true, all API calls will be wrapped with `tl.invokeWithoutUpdates`, * If true, all API calls will be wrapped with `tl.invokeWithoutUpdates`,

View file

@ -7,6 +7,6 @@ export type {
RpcCallMiddlewareContext, RpcCallMiddlewareContext,
RpcCallOptions, RpcCallOptions,
} from './network-manager.js' } from './network-manager.js'
export * from './reconnection.js' // export * from './reconnection.js'
export * from './session-connection.js' export * from './session-connection.js'
export * from './transports/index.js' export * from './transports/index.js'

View file

@ -9,7 +9,7 @@ import { createControllablePromise } from '../utils/index.js'
import { MtprotoSession } from './mtproto-session.js' import { MtprotoSession } from './mtproto-session.js'
import type { SessionConnectionParams } from './session-connection.js' import type { SessionConnectionParams } from './session-connection.js'
import { SessionConnection } from './session-connection.js' import { SessionConnection } from './session-connection.js'
import type { TransportFactory } from './transports/index.js' import type { TelegramTransport } from './transports'
export class MultiSessionConnection extends EventEmitter { export class MultiSessionConnection extends EventEmitter {
private _log: Logger private _log: Logger
@ -323,7 +323,7 @@ export class MultiSessionConnection extends EventEmitter {
} }
} }
changeTransport(factory: TransportFactory): void { changeTransport(factory: TelegramTransport): void {
this._connections.forEach(conn => conn.changeTransport(factory)) this._connections.forEach(conn => conn.changeTransport(factory))
} }

View file

@ -1,6 +1,7 @@
import type { mtp, tl } from '@mtcute/tl' import type { mtp, tl } from '@mtcute/tl'
import type { TlReaderMap, TlWriterMap } from '@mtcute/tl-runtime' import type { TlReaderMap, TlWriterMap } from '@mtcute/tl-runtime'
import type Long from 'long' import type Long from 'long'
import { type ReconnectionStrategy, defaultReconnectionStrategy } from '@fuman/net'
import { getPlatform } from '../platform.js' import { getPlatform } from '../platform.js'
import type { StorageManager } from '../storage/storage.js' import type { StorageManager } from '../storage/storage.js'
@ -14,12 +15,9 @@ import { assertTypeIs, isTlRpcError } from '../utils/type-assertions.js'
import type { ConfigManager } from './config-manager.js' import type { ConfigManager } from './config-manager.js'
import { basic as defaultMiddlewares } from './middlewares/default.js' import { basic as defaultMiddlewares } from './middlewares/default.js'
import { MultiSessionConnection } from './multi-session-connection.js' import { MultiSessionConnection } from './multi-session-connection.js'
import type { PersistentConnectionParams } from './persistent-connection.js'
import type { ReconnectionStrategy } from './reconnection.js'
import { defaultReconnectionStrategy } from './reconnection.js'
import { ServerSaltManager } from './server-salt.js' import { ServerSaltManager } from './server-salt.js'
import type { SessionConnection, SessionConnectionParams } from './session-connection.js' import type { SessionConnection, SessionConnectionParams } from './session-connection.js'
import type { TransportFactory } from './transports/index.js' import type { TelegramTransport } from './transports/abstract.js'
export type ConnectionKind = 'main' | 'upload' | 'download' | 'downloadSmall' export type ConnectionKind = 'main' | 'upload' | 'download' | 'downloadSmall'
@ -35,8 +33,8 @@ export interface NetworkManagerParams {
enableErrorReporting: boolean enableErrorReporting: boolean
apiId: number apiId: number
initConnectionOptions?: Partial<Omit<tl.RawInitConnectionRequest, 'apiId' | 'query'>> initConnectionOptions?: Partial<Omit<tl.RawInitConnectionRequest, 'apiId' | 'query'>>
transport: TransportFactory transport: TelegramTransport
reconnectionStrategy?: ReconnectionStrategy<PersistentConnectionParams> reconnectionStrategy?: ReconnectionStrategy
disableUpdates?: boolean disableUpdates?: boolean
testMode: boolean testMode: boolean
layer: number layer: number
@ -241,7 +239,7 @@ export class DcConnectionManager {
const baseConnectionParams = (): SessionConnectionParams => ({ const baseConnectionParams = (): SessionConnectionParams => ({
crypto: this.manager.params.crypto, crypto: this.manager.params.crypto,
initConnection: this.manager._initConnectionParams, initConnection: this.manager._initConnectionParams,
transportFactory: this.manager._transportFactory, transport: this.manager._transport,
dc: this._dcs.media, dc: this._dcs.media,
testMode: this.manager.params.testMode, testMode: this.manager.params.testMode,
reconnectionStrategy: this.manager._reconnectionStrategy, reconnectionStrategy: this.manager._reconnectionStrategy,
@ -474,8 +472,8 @@ export class NetworkManager {
readonly _storage: StorageManager readonly _storage: StorageManager
readonly _initConnectionParams: tl.RawInitConnectionRequest readonly _initConnectionParams: tl.RawInitConnectionRequest
readonly _transportFactory: TransportFactory readonly _transport: TelegramTransport
readonly _reconnectionStrategy: ReconnectionStrategy<PersistentConnectionParams> readonly _reconnectionStrategy: ReconnectionStrategy
readonly _connectionCount: ConnectionCountDelegate readonly _connectionCount: ConnectionCountDelegate
protected readonly _dcConnections: Map<number, DcConnectionManager> = new Map() protected readonly _dcConnections: Map<number, DcConnectionManager> = new Map()
@ -503,7 +501,7 @@ export class NetworkManager {
query: null as any, query: null as any,
} }
this._transportFactory = params.transport this._transport = params.transport
this._reconnectionStrategy = params.reconnectionStrategy ?? defaultReconnectionStrategy this._reconnectionStrategy = params.reconnectionStrategy ?? defaultReconnectionStrategy
this._connectionCount = params.connectionCount ?? defaultConnectionCountDelegate this._connectionCount = params.connectionCount ?? defaultConnectionCountDelegate
this._updateHandler = params.onUpdate this._updateHandler = params.onUpdate
@ -858,12 +856,12 @@ export class NetworkManager {
return res return res
} }
changeTransport(factory: TransportFactory): void { changeTransport(transport: TelegramTransport): void {
for (const dc of this._dcConnections.values()) { for (const dc of this._dcConnections.values()) {
dc.main.changeTransport(factory) dc.main.changeTransport(transport)
dc.upload.changeTransport(factory) dc.upload.changeTransport(transport)
dc.download.changeTransport(factory) dc.download.changeTransport(transport)
dc.downloadSmall.changeTransport(factory) dc.downloadSmall.changeTransport(transport)
} }
} }

View file

@ -1,223 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // todo: move to fuman
import { StubTelegramTransport, createStub, defaultTestCryptoProvider } from '@mtcute/test' // import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// import { StubTelegramTransport, createStub, defaultTestCryptoProvider } from '@mtcute/test'
import { LogManager, timers } from '../utils/index.js' // import { LogManager, timers } from '../utils/index.js'
import type { PersistentConnectionParams } from './persistent-connection.js' // import type { PersistentConnectionParams } from './persistent-connection.js'
import { PersistentConnection } from './persistent-connection.js' // import { PersistentConnection } from './persistent-connection.js'
import { defaultReconnectionStrategy } from './reconnection.js' // import { defaultReconnectionStrategy } from './reconnection.js'
class FakePersistentConnection extends PersistentConnection { // class FakePersistentConnection extends PersistentConnection {
constructor(params: PersistentConnectionParams) { // constructor(params: PersistentConnectionParams) {
const log = new LogManager() // const log = new LogManager()
log.level = 0 // log.level = 0
super(params, log) // super(params, log)
} // }
onConnected() { // onConnected() {
this.onConnectionUsable() // this.onConnectionUsable()
} // }
onError() {} // onError() {}
onMessage() {} // onMessage() {}
} // }
describe('PersistentConnection', () => { // describe('PersistentConnection', () => {
beforeEach(() => void vi.useFakeTimers()) // beforeEach(() => void vi.useFakeTimers())
afterEach(() => void vi.useRealTimers()) // afterEach(() => void vi.useRealTimers())
const create = async (params?: Partial<PersistentConnectionParams>) => { // const create = async (params?: Partial<PersistentConnectionParams>) => {
return new FakePersistentConnection({ // return new FakePersistentConnection({
crypto: await defaultTestCryptoProvider(), // crypto: await defaultTestCryptoProvider(),
transportFactory: () => new StubTelegramTransport({}), // transportFactory: () => new StubTelegramTransport({}),
dc: createStub('dcOption'), // dc: createStub('dcOption'),
testMode: false, // testMode: false,
reconnectionStrategy: defaultReconnectionStrategy, // reconnectionStrategy: defaultReconnectionStrategy,
...params, // ...params,
}) // })
} // }
it('should set up listeners on transport', async () => { // it('should set up listeners on transport', async () => {
const transportFactory = vi.fn().mockImplementation(() => { // const transportFactory = vi.fn().mockImplementation(() => {
const transport = new StubTelegramTransport({}) // const transport = new StubTelegramTransport({})
vi.spyOn(transport, 'on') // vi.spyOn(transport, 'on')
return transport // return transport
}) // })
await create({ transportFactory }) // await create({ transportFactory })
const transport = transportFactory.mock.results[0].value as StubTelegramTransport // const transport = transportFactory.mock.results[0].value as StubTelegramTransport
expect(transport.on).toHaveBeenCalledWith('ready', expect.any(Function)) // expect(transport.on).toHaveBeenCalledWith('ready', expect.any(Function))
expect(transport.on).toHaveBeenCalledWith('message', expect.any(Function)) // expect(transport.on).toHaveBeenCalledWith('message', expect.any(Function))
expect(transport.on).toHaveBeenCalledWith('error', expect.any(Function)) // expect(transport.on).toHaveBeenCalledWith('error', expect.any(Function))
expect(transport.on).toHaveBeenCalledWith('close', expect.any(Function)) // expect(transport.on).toHaveBeenCalledWith('close', expect.any(Function))
}) // })
it('should properly reset old transport', async () => { // it('should properly reset old transport', async () => {
const transportFactory = vi.fn().mockImplementation(() => { // const transportFactory = vi.fn().mockImplementation(() => {
const transport = new StubTelegramTransport({}) // const transport = new StubTelegramTransport({})
vi.spyOn(transport, 'close') // vi.spyOn(transport, 'close')
return transport // return transport
}) // })
const pc = await create({ transportFactory }) // const pc = await create({ transportFactory })
const transport = transportFactory.mock.results[0].value as StubTelegramTransport // const transport = transportFactory.mock.results[0].value as StubTelegramTransport
pc.changeTransport(transportFactory) // pc.changeTransport(transportFactory)
expect(transport.close).toHaveBeenCalledOnce() // expect(transport.close).toHaveBeenCalledOnce()
}) // })
it('should buffer unsent packages', async () => { // it('should buffer unsent packages', async () => {
const transportFactory = vi.fn().mockImplementation(() => { // const transportFactory = vi.fn().mockImplementation(() => {
const transport = new StubTelegramTransport({}) // const transport = new StubTelegramTransport({})
const transportConnect = transport.connect // const transportConnect = transport.connect
vi.spyOn(transport, 'connect').mockImplementation((dc, test) => { // vi.spyOn(transport, 'connect').mockImplementation((dc, test) => {
timers.setTimeout(() => { // timers.setTimeout(() => {
transportConnect.call(transport, dc, test) // transportConnect.call(transport, dc, test)
}, 100) // }, 100)
}) // })
vi.spyOn(transport, 'send') // vi.spyOn(transport, 'send')
return transport // return transport
}) // })
const pc = await create({ transportFactory }) // const pc = await create({ transportFactory })
const transport = transportFactory.mock.results[0].value as StubTelegramTransport // const transport = transportFactory.mock.results[0].value as StubTelegramTransport
const data1 = new Uint8Array([1, 2, 3]) // const data1 = new Uint8Array([1, 2, 3])
const data2 = new Uint8Array([4, 5, 6]) // const data2 = new Uint8Array([4, 5, 6])
await pc.send(data1) // await pc.send(data1)
await pc.send(data2) // await pc.send(data2)
expect(transport.send).toHaveBeenCalledTimes(0) // expect(transport.send).toHaveBeenCalledTimes(0)
await vi.advanceTimersByTimeAsync(150) // await vi.advanceTimersByTimeAsync(150)
expect(transport.send).toHaveBeenCalledTimes(2) // expect(transport.send).toHaveBeenCalledTimes(2)
expect(transport.send).toHaveBeenCalledWith(data1) // expect(transport.send).toHaveBeenCalledWith(data1)
expect(transport.send).toHaveBeenCalledWith(data2) // expect(transport.send).toHaveBeenCalledWith(data2)
}) // })
it('should reconnect on close', async () => { // it('should reconnect on close', async () => {
const reconnectionStrategy = vi.fn().mockImplementation(() => 1000) // const reconnectionStrategy = vi.fn().mockImplementation(() => 1000)
const transportFactory = vi.fn().mockImplementation(() => new StubTelegramTransport({})) // const transportFactory = vi.fn().mockImplementation(() => new StubTelegramTransport({}))
const pc = await create({ // const pc = await create({
reconnectionStrategy, // reconnectionStrategy,
transportFactory, // transportFactory,
}) // })
const transport = transportFactory.mock.results[0].value as StubTelegramTransport // const transport = transportFactory.mock.results[0].value as StubTelegramTransport
pc.connect() // pc.connect()
await vi.waitFor(() => expect(pc.isConnected).toBe(true)) // await vi.waitFor(() => expect(pc.isConnected).toBe(true))
transport.close() // transport.close()
expect(reconnectionStrategy).toHaveBeenCalledOnce() // expect(reconnectionStrategy).toHaveBeenCalledOnce()
expect(pc.isConnected).toBe(false) // expect(pc.isConnected).toBe(false)
await vi.advanceTimersByTimeAsync(1000) // await vi.advanceTimersByTimeAsync(1000)
expect(pc.isConnected).toBe(true) // expect(pc.isConnected).toBe(true)
}) // })
describe('inactivity timeout', () => { // describe('inactivity timeout', () => {
it('should disconnect on inactivity (passed in constructor)', async () => { // it('should disconnect on inactivity (passed in constructor)', async () => {
const pc = await create({ // const pc = await create({
inactivityTimeout: 1000, // inactivityTimeout: 1000,
}) // })
pc.connect() // pc.connect()
await vi.waitFor(() => expect(pc.isConnected).toBe(true)) // await vi.waitFor(() => expect(pc.isConnected).toBe(true))
vi.advanceTimersByTime(1000) // vi.advanceTimersByTime(1000)
await vi.waitFor(() => expect(pc.isConnected).toBe(false)) // await vi.waitFor(() => expect(pc.isConnected).toBe(false))
}) // })
it('should disconnect on inactivity (set up with setInactivityTimeout)', async () => { // it('should disconnect on inactivity (set up with setInactivityTimeout)', async () => {
const pc = await create() // const pc = await create()
pc.connect() // pc.connect()
pc.setInactivityTimeout(1000) // pc.setInactivityTimeout(1000)
await vi.waitFor(() => expect(pc.isConnected).toBe(true)) // await vi.waitFor(() => expect(pc.isConnected).toBe(true))
vi.advanceTimersByTime(1000) // vi.advanceTimersByTime(1000)
await vi.waitFor(() => expect(pc.isConnected).toBe(false)) // await vi.waitFor(() => expect(pc.isConnected).toBe(false))
}) // })
it('should not disconnect on inactivity if disabled', async () => { // it('should not disconnect on inactivity if disabled', async () => {
const pc = await create({ // const pc = await create({
inactivityTimeout: 1000, // inactivityTimeout: 1000,
}) // })
pc.connect() // pc.connect()
pc.setInactivityTimeout(undefined) // pc.setInactivityTimeout(undefined)
await vi.waitFor(() => expect(pc.isConnected).toBe(true)) // await vi.waitFor(() => expect(pc.isConnected).toBe(true))
vi.advanceTimersByTime(1000) // vi.advanceTimersByTime(1000)
await vi.waitFor(() => expect(pc.isConnected).toBe(true)) // await vi.waitFor(() => expect(pc.isConnected).toBe(true))
}) // })
it('should reconnect after inactivity before sending', async () => { // it('should reconnect after inactivity before sending', async () => {
const transportFactory = vi.fn().mockImplementation(() => { // const transportFactory = vi.fn().mockImplementation(() => {
const transport = new StubTelegramTransport({}) // const transport = new StubTelegramTransport({})
vi.spyOn(transport, 'connect') // vi.spyOn(transport, 'connect')
vi.spyOn(transport, 'send') // vi.spyOn(transport, 'send')
return transport // return transport
}) // })
const pc = await create({ // const pc = await create({
inactivityTimeout: 1000, // inactivityTimeout: 1000,
transportFactory, // transportFactory,
}) // })
const transport = transportFactory.mock.results[0].value as StubTelegramTransport // const transport = transportFactory.mock.results[0].value as StubTelegramTransport
pc.connect() // pc.connect()
vi.advanceTimersByTime(1000) // vi.advanceTimersByTime(1000)
await vi.waitFor(() => expect(pc.isConnected).toBe(false)) // await vi.waitFor(() => expect(pc.isConnected).toBe(false))
vi.mocked(transport.connect).mockClear() // vi.mocked(transport.connect).mockClear()
await pc.send(new Uint8Array([1, 2, 3])) // await pc.send(new Uint8Array([1, 2, 3]))
expect(transport.connect).toHaveBeenCalledOnce() // expect(transport.connect).toHaveBeenCalledOnce()
expect(transport.send).toHaveBeenCalledOnce() // expect(transport.send).toHaveBeenCalledOnce()
}) // })
it('should propagate errors', async () => { // it('should propagate errors', async () => {
const transportFactory = vi.fn().mockImplementation(() => new StubTelegramTransport({})) // const transportFactory = vi.fn().mockImplementation(() => new StubTelegramTransport({}))
const pc = await create({ transportFactory }) // const pc = await create({ transportFactory })
const transport = transportFactory.mock.results[0].value as StubTelegramTransport // const transport = transportFactory.mock.results[0].value as StubTelegramTransport
pc.connect() // pc.connect()
await vi.waitFor(() => expect(pc.isConnected).toBe(true)) // await vi.waitFor(() => expect(pc.isConnected).toBe(true))
const onErrorSpy = vi.spyOn(pc, 'onError') // const onErrorSpy = vi.spyOn(pc, 'onError')
transport.emit('error', new Error('test error')) // transport.emit('error', new Error('test error'))
expect(onErrorSpy).toHaveBeenCalledOnce() // expect(onErrorSpy).toHaveBeenCalledOnce()
}) // })
}) // })
}) // })

View file

@ -1,20 +1,21 @@
// eslint-disable-next-line unicorn/prefer-node-protocol // eslint-disable-next-line unicorn/prefer-node-protocol
import EventEmitter from 'events' import EventEmitter from 'events'
import { MtcuteError } from '../types/index.js' import type { ReconnectionStrategy } from '@fuman/net'
import { PersistentConnection as FumanPersistentConnection } from '@fuman/net'
import { FramedReader, FramedWriter } from '@fuman/io'
import type { BasicDcOption, ICryptoProvider, Logger } from '../utils/index.js' import type { BasicDcOption, ICryptoProvider, Logger } from '../utils/index.js'
import { timers } from '../utils/index.js' import { timers } from '../utils/index.js'
import type { ReconnectionStrategy } from './reconnection.js' import type { IPacketCodec, ITelegramConnection, TelegramTransport } from './transports/abstract.js'
import type { ITelegramTransport, TransportFactory } from './transports/index.js'
import { TransportState } from './transports/index.js'
export interface PersistentConnectionParams { export interface PersistentConnectionParams {
crypto: ICryptoProvider crypto: ICryptoProvider
transportFactory: TransportFactory transport: TelegramTransport
dc: BasicDcOption dc: BasicDcOption
testMode: boolean testMode: boolean
reconnectionStrategy: ReconnectionStrategy<PersistentConnectionParams> reconnectionStrategy: ReconnectionStrategy
inactivityTimeout?: number inactivityTimeout?: number
} }
@ -29,9 +30,11 @@ export abstract class PersistentConnection extends EventEmitter {
private _uid = nextConnectionUid++ private _uid = nextConnectionUid++
readonly params: PersistentConnectionParams readonly params: PersistentConnectionParams
protected _transport!: ITelegramTransport // protected _transport!: ITelegramConnection
private _sendOnceConnected: Uint8Array[] = [] private _sendOnceConnected: Uint8Array[] = []
private _codec: IPacketCodec
private _fuman: FumanPersistentConnection<BasicDcOption, ITelegramConnection>
// reconnection // reconnection
private _lastError: Error | null = null private _lastError: Error | null = null
@ -49,6 +52,7 @@ export abstract class PersistentConnection extends EventEmitter {
_usable = false _usable = false
protected abstract onConnected(): void protected abstract onConnected(): void
protected abstract onClosed(): void
protected abstract onError(err: Error): void protected abstract onError(err: Error): void
@ -60,174 +64,233 @@ export abstract class PersistentConnection extends EventEmitter {
) { ) {
super() super()
this.params = params this.params = params
this.changeTransport(params.transportFactory)
this.log.prefix = `[UID ${this._uid}] ` this.log.prefix = `[UID ${this._uid}] `
this._codec = this.params.transport.packetCodec()
this._codec.setup?.(this.params.crypto, this.log)
this._onInactivityTimeout = this._onInactivityTimeout.bind(this) this._onInactivityTimeout = this._onInactivityTimeout.bind(this)
this._fuman = new FumanPersistentConnection({
connect: dc => params.transport.connect(dc, params.testMode),
onOpen: this._onOpen.bind(this),
onClose: this._onClose.bind(this),
onError: this._onError.bind(this),
onWait: wait => this.emit('wait', wait),
})
} }
get isConnected(): boolean { get isConnected(): boolean {
return this._transport.state() !== TransportState.Idle return this._fuman.isConnected
} }
changeTransport(factory: TransportFactory): void { private _writer?: FramedWriter
if (this._transport) {
Promise.resolve(this._transport.close()).catch((err) => {
this.log.warn('error closing previous transport: %e', err)
})
}
this._transport = factory() private async _onOpen(conn: ITelegramConnection) {
this._transport.setup?.(this.params.crypto, this.log) await conn.write(await this._codec.tag())
this._transport.on('ready', this.onTransportReady.bind(this)) const reader = new FramedReader(conn, this._codec)
this._transport.on('message', this.onMessage.bind(this)) this._writer = new FramedWriter(conn, this._codec)
this._transport.on('error', this.onTransportError.bind(this))
this._transport.on('close', this.onTransportClose.bind(this))
}
onTransportReady(): void { while (this._sendOnceConnected.length) {
// transport ready does not mean actual mtproto is ready const data = this._sendOnceConnected.shift()!
if (this._sendOnceConnected.length) {
const sendNext = () => {
if (!this._sendOnceConnected.length) {
this.onConnected()
return try {
} await this._writer.write(data)
} catch (e) {
const data = this._sendOnceConnected.shift()! this._sendOnceConnected.unshift(data)
this._transport throw e
.send(data)
.then(sendNext)
.catch((err) => {
this.log.error('error sending queued data: %e', err)
this._sendOnceConnected.unshift(data)
})
} }
sendNext()
return
} }
this.onConnected()
}
protected onConnectionUsable(): void {
const isReconnection = this._consequentFails > 0
// reset reconnection related state
this._lastError = null
this._consequentFails = 0
this._previousWait = null
this._usable = true
this.emit('usable', isReconnection)
this._rescheduleInactivity() this._rescheduleInactivity()
this.emit('usable') // is this needed?
this.onConnected()
while (true) {
const msg = await reader.read()
if (msg) {
this.onMessage(msg)
}
}
} }
onTransportError(err: Error): void { private async _onClose() {
this._writer = undefined
this._codec.reset()
this.onClosed()
}
private async _onError(err: Error) {
this._lastError = err this._lastError = err
this.onError(err) this.onError(err)
// transport is expected to emit `close` after `error`
} }
onTransportClose(): void { async changeTransport(transport: TelegramTransport): Promise<void> {
// transport closed because of inactivity await this._fuman.close()
// obviously we dont want to reconnect then
if (this._inactive || this._disconnectedManually) return
if (this._shouldReconnectImmediately) { this._codec = transport.packetCodec()
this._shouldReconnectImmediately = false this._codec.setup?.(this.params.crypto, this.log)
this.connect()
return await this._fuman.changeTransport(() => transport.connect(this.params.dc, this.params.testMode))
} this._fuman.connect(this.params.dc)
// if (this._transport) {
// Promise.resolve(this._transport.close()).catch((err) => {
// this.log.warn('error closing previous transport: %e', err)
// })
// }
this._consequentFails += 1 // this._transport = conn()
// this._transport.setup?.(this.params.crypto, this.log)
const wait = this.params.reconnectionStrategy( // this._transport.on('ready', this.onTransportReady.bind(this))
this.params, // this._transport.on('message', this.onMessage.bind(this))
this._lastError, // this._transport.on('error', this.onTransportError.bind(this))
this._consequentFails, // this._transport.on('close', this.onTransportClose.bind(this))
this._previousWait,
)
if (wait === false) {
this.destroy().catch((err) => {
this.log.warn('error destroying connection: %e', err)
})
return
}
this.emit('wait', wait)
this._previousWait = wait
if (this._reconnectionTimeout != null) {
timers.clearTimeout(this._reconnectionTimeout)
}
this._reconnectionTimeout = timers.setTimeout(() => {
if (this._destroyed) return
this._reconnectionTimeout = null
this.connect()
}, wait)
} }
// onTransportReady(): void {
// // transport ready does not mean actual mtproto is ready
// if (this._sendOnceConnected.length) {
// const sendNext = () => {
// if (!this._sendOnceConnected.length) {
// this.onConnected()
// return
// }
// const data = this._sendOnceConnected.shift()!
// this._transport
// .send(data)
// .then(sendNext)
// .catch((err) => {
// this.log.error('error sending queued data: %e', err)
// this._sendOnceConnected.unshift(data)
// })
// }
// sendNext()
// return
// }
// this.onConnected()
// }
// protected onConnectionUsable(): void {
// const isReconnection = this._consequentFails > 0
// // reset reconnection related state
// this._lastError = null
// this._consequentFails = 0
// this._previousWait = null
// this._usable = true
// this.emit('usable', isReconnection)
// this._rescheduleInactivity()
// }
// onTransportError(err: Error): void {
// // transport is expected to emit `close` after `error`
// }
// onTransportClose(): void {
// // transport closed because of inactivity
// // obviously we dont want to reconnect then
// if (this._inactive || this._disconnectedManually) return
// if (this._shouldReconnectImmediately) {
// this._shouldReconnectImmediately = false
// this.connect()
// return
// }
// this._consequentFails += 1
// const wait = this.params.reconnectionStrategy(
// this.params,
// this._lastError,
// this._consequentFails,
// this._previousWait,
// )
// if (wait === false) {
// this.destroy().catch((err) => {
// this.log.warn('error destroying connection: %e', err)
// })
// return
// }
// this._previousWait = wait
// if (this._reconnectionTimeout != null) {
// timers.clearTimeout(this._reconnectionTimeout)
// }
// this._reconnectionTimeout = timers.setTimeout(() => {
// if (this._destroyed) return
// this._reconnectionTimeout = null
// this.connect()
// }, wait)
// }
connect(): void { connect(): void {
if (this.isConnected) { this._fuman.connect(this.params.dc)
throw new MtcuteError('Connection is already opened!')
}
if (this._destroyed) {
throw new MtcuteError('Connection is already destroyed!')
}
if (this._reconnectionTimeout != null) {
timers.clearTimeout(this._reconnectionTimeout)
this._reconnectionTimeout = null
}
this._inactive = false this._inactive = false
this._disconnectedManually = false // if (this.isConnected) {
this._transport.connect(this.params.dc, this.params.testMode) // throw new MtcuteError('Connection is already opened!')
// }
// if (this._destroyed) {
// throw new MtcuteError('Connection is already destroyed!')
// }
// if (this._reconnectionTimeout != null) {
// clearTimeout(this._reconnectionTimeout)
// this._reconnectionTimeout = null
// }
// this._inactive = false
// this._disconnectedManually = false
// this._transport.connect(this.params.dc, this.params.testMode)
} }
reconnect(): void { reconnect(): void {
if (this._inactive) return // if (this._inactive) return
// if we are already connected // // if we are already connected
if (this.isConnected) { // if (this.isConnected) {
this._shouldReconnectImmediately = true // this._shouldReconnectImmediately = true
Promise.resolve(this._transport.close()).catch((err) => { // Promise.resolve(this._transport.close()).catch((err) => {
this.log.error('error closing transport: %e', err) // this.log.error('error closing transport: %e', err)
}) // })
return // return
} // }
// if reconnection timeout is pending, it will be cancelled in connect() // // if reconnection timeout is pending, it will be cancelled in connect()
this.connect() // this.connect()
this._fuman.reconnect(true)
} }
async disconnectManual(): Promise<void> { async disconnectManual(): Promise<void> {
this._disconnectedManually = true // this._disconnectedManually = true
await this._transport.close() // await this._transport.close()
await this._fuman.close()
} }
async destroy(): Promise<void> { async destroy(): Promise<void> {
this._disconnectedManually = true // if (this._reconnectionTimeout != null) {
if (this._reconnectionTimeout != null) { // clearTimeout(this._reconnectionTimeout)
timers.clearTimeout(this._reconnectionTimeout) // }
} // if (this._inactivityTimeout != null) {
if (this._inactivityTimeout != null) { // clearTimeout(this._inactivityTimeout)
timers.clearTimeout(this._inactivityTimeout) // }
}
await this._transport.close() await this._fuman.close()
this._transport.removeAllListeners() // this._transport.removeAllListeners()
this._destroyed = true // this._destroyed = true
} }
protected _rescheduleInactivity(): void { protected _rescheduleInactivity(): void {
@ -240,7 +303,7 @@ export abstract class PersistentConnection extends EventEmitter {
this.log.info('disconnected because of inactivity for %d', this.params.inactivityTimeout) this.log.info('disconnected because of inactivity for %d', this.params.inactivityTimeout)
this._inactive = true this._inactive = true
this._inactivityTimeout = null this._inactivityTimeout = null
Promise.resolve(this._transport.close()).catch((err) => { Promise.resolve(this._fuman.close()).catch((err) => {
this.log.warn('error closing transport: %e', err) this.log.warn('error closing transport: %e', err)
}) })
} }
@ -262,8 +325,9 @@ export abstract class PersistentConnection extends EventEmitter {
this.connect() this.connect()
} }
if (this._transport.state() === TransportState.Ready) { if (this._writer) {
await this._transport.send(data) this._rescheduleInactivity()
await this._writer.write(data)
} else { } else {
this._sendOnceConnected.push(data) this._sendOnceConnected.push(data)
} }

View file

@ -1,28 +0,0 @@
/**
* Declares a strategy to handle reconnection.
* When a number is returned, that number of MS will be waited before trying to reconnect.
* When `false` is returned, connection is not reconnected
*/
export type ReconnectionStrategy<T> = (
params: T,
lastError: Error | null,
consequentFails: number,
previousWait: number | null,
) => number | false
/**
* default reconnection strategy: first - immediate reconnection,
* then 1s with linear increase up to 5s (with 1s step)
*/
export const defaultReconnectionStrategy: ReconnectionStrategy<object> = (
params,
lastError,
consequentFails,
previousWait,
) => {
if (previousWait === null) return 0
if (previousWait === 0) return 1000
if (previousWait >= 5000) return 5000
return Math.min(5000, previousWait + 1000)
}

View file

@ -131,9 +131,7 @@ export class SessionConnection extends PersistentConnection {
this._resetSession() this._resetSession()
} }
onTransportClose(): void { onClosed(): void {
super.onTransportClose()
Object.values(this._pendingWaitForUnencrypted).forEach(([prom, timeout]) => { Object.values(this._pendingWaitForUnencrypted).forEach(([prom, timeout]) => {
prom.reject(new MtcuteError('Connection closed')) prom.reject(new MtcuteError('Connection closed'))
timers.clearTimeout(timeout) timers.clearTimeout(timeout)
@ -262,8 +260,6 @@ export class SessionConnection extends PersistentConnection {
} }
protected onConnectionUsable(): void { protected onConnectionUsable(): void {
super.onConnectionUsable()
if (this.params.withUpdates) { if (this.params.withUpdates) {
// we must send some user-related rpc to the server to make sure that // we must send some user-related rpc to the server to make sure that
// it will send us updates // it will send us updates
@ -1363,13 +1359,13 @@ export class SessionConnection extends PersistentConnection {
// either acked or returns rpc_result // either acked or returns rpc_result
this.log.debug('wrapping %s with initConnection, layer: %d', method, this.params.layer) this.log.debug('wrapping %s with initConnection, layer: %d', method, this.params.layer)
const proxy = this._transport.getMtproxyInfo?.() // const proxy = this._transport.getMtproxyInfo?.()
obj = { obj = {
_: 'invokeWithLayer', _: 'invokeWithLayer',
layer: this.params.layer, layer: this.params.layer,
query: { query: {
...this.params.initConnection, ...this.params.initConnection,
proxy, // proxy,
query: obj, query: obj,
}, },
} }

View file

@ -1,58 +1,14 @@
// eslint-disable-next-line unicorn/prefer-node-protocol
import type EventEmitter from 'events'
import type { tl } from '@mtcute/tl' import type { tl } from '@mtcute/tl'
import type { IFrameDecoder, IFrameEncoder } from '@fuman/io'
import type { IConnection } from '@fuman/net'
import type { MaybePromise } from '../../types/index.js' import type { MaybePromise } from '../../types/index.js'
import type { BasicDcOption, ICryptoProvider, Logger } from '../../utils/index.js' import type { BasicDcOption, ICryptoProvider, Logger } from '../../utils/index.js'
/** Current state of the transport */
export enum TransportState {
/**
* Transport has no active connections nor trying to connect to anything
* Can be a result of network failure, close() call, or connect() call was
* never called on this instance of Transport.
*
* This state is usually immediately handled by more higher-level components,
* thus rarely seen
*/
Idle = 'idle',
/**
* Transport has no active connections, but is trying to connect to something
*/
Connecting = 'connecting',
/**
* Transport has an active connection
*/
Ready = 'ready',
}
/** /**
* Interface implementing a transport to interact with Telegram servers. * Interface implementing a transport to interact with Telegram servers.
*
* Events:
* - `ready` event is emitted once connection has been established: `() => void`
* - `close` event is emitted once connection has been closed: `() => void`
* - `error` event is event is emitted when there was some error
* (either mtproto related or network related): `(error: Error) => void`
* - `message` event is emitted when a mtproto message is received: `(message: Buffer) => void`
*/ */
export interface ITelegramTransport extends EventEmitter { export interface ITelegramConnection extends IConnection<any, any> {
/** returns current state */
state(): TransportState
/** returns current DC. should return null if state == IDLE */
currentDc(): BasicDcOption | null
/**
* Start trying to connect to a specified DC.
* Will throw an error if state != IDLE
*/
connect(dc: BasicDcOption, testMode: boolean): void
/** call to close existing connection to some DC */
close(): MaybePromise<void>
/** send a message */
send(data: Uint8Array): Promise<void>
/** /**
* Provides crypto and logging for the transport. * Provides crypto and logging for the transport.
* Not done in constructor to simplify factory. * Not done in constructor to simplify factory.
@ -64,36 +20,20 @@ export interface ITelegramTransport extends EventEmitter {
getMtproxyInfo?(): tl.RawInputClientProxy getMtproxyInfo?(): tl.RawInputClientProxy
} }
/** Transport factory function */ export interface TelegramTransport {
export type TransportFactory = () => ITelegramTransport connect: (dc: BasicDcOption, testMode: boolean) => Promise<ITelegramConnection>
packetCodec: () => IPacketCodec
}
/** /**
* Interface declaring handling of received packets. * Interface declaring handling of received packets.
* When receiving a packet, its content is sent to feed(), * When receiving a packet, its content is sent to feed(),
* and codec is supposed to emit `packet` or `error` event when packet is parsed. * and codec is supposed to emit `packet` or `error` event when packet is parsed.
*/ */
export interface IPacketCodec { export interface IPacketCodec extends IFrameDecoder, IFrameEncoder {
/** Initial tag of the codec. Will be sent immediately once connected. */ /** Initial tag of the codec. Will be sent immediately once connected. */
tag(): MaybePromise<Uint8Array> tag(): MaybePromise<Uint8Array>
/** Encodes and frames a single packet */
encode(packet: Uint8Array): MaybePromise<Uint8Array>
/** Feed packet to the codec. Once packet is processed, codec is supposed to emit `packet` or `error` */
feed(data: Uint8Array): void
/** Reset codec state (for example, reset buffer) */
reset(): void
/** Remove all listeners from a codec, used to safely dispose it */
removeAllListeners(): void
/**
* Emitted when a packet containing a
* (Transport error)[https://core.telegram.org/mtproto/mtproto-transports#transport-errors] is encountered.
*/
on(event: 'error', handler: (error: Error) => void): void
on(event: 'packet', handler: (packet: Uint8Array) => void): void
/** /**
* For codecs that use crypto functions and/or logging. * For codecs that use crypto functions and/or logging.
* This method is called before any other. * This method is called before any other.

View file

@ -1,5 +1,3 @@
export * from './abstract.js' export * from './abstract.js'
export * from './intermediate.js' export * from './intermediate.js'
export * from './obfuscated.js' export * from './obfuscated.js'
export * from './streamed.js'
export * from './wrapped.js'

View file

@ -1,108 +1,109 @@
import { describe, expect, it } from 'vitest' // todo: fix test
import { defaultTestCryptoProvider, useFakeMathRandom } from '@mtcute/test' // import { describe, expect, it } from 'vitest'
// import { defaultTestCryptoProvider, useFakeMathRandom } from '@mtcute/test'
import { IntermediatePacketCodec, PaddedIntermediatePacketCodec, TransportError } from '../../index.js' // import { IntermediatePacketCodec, PaddedIntermediatePacketCodec, TransportError } from '../../index.js'
import { getPlatform } from '../../platform.js' // import { getPlatform } from '../../platform.js'
const p = getPlatform() // const p = getPlatform()
describe('IntermediatePacketCodec', () => { // describe('IntermediatePacketCodec', () => {
it('should return correct tag', () => { // it('should return correct tag', () => {
expect(p.hexEncode(new IntermediatePacketCodec().tag())).eq('eeeeeeee') // expect(p.hexEncode(new IntermediatePacketCodec().tag())).eq('eeeeeeee')
}) // })
it('should correctly parse immediate framing', () => // it('should correctly parse immediate framing', () =>
new Promise<void>((done) => { // new Promise<void>((done) => {
const codec = new IntermediatePacketCodec() // const codec = new IntermediatePacketCodec()
codec.on('packet', (data: Uint8Array) => { // codec.on('packet', (data: Uint8Array) => {
expect([...data]).eql([5, 1, 2, 3, 4]) // expect([...data]).eql([5, 1, 2, 3, 4])
done() // done()
}) // })
codec.feed(p.hexDecode('050000000501020304')) // codec.feed(p.hexDecode('050000000501020304'))
})) // }))
it('should correctly parse incomplete framing', () => // it('should correctly parse incomplete framing', () =>
new Promise<void>((done) => { // new Promise<void>((done) => {
const codec = new IntermediatePacketCodec() // const codec = new IntermediatePacketCodec()
codec.on('packet', (data: Uint8Array) => { // codec.on('packet', (data: Uint8Array) => {
expect([...data]).eql([5, 1, 2, 3, 4]) // expect([...data]).eql([5, 1, 2, 3, 4])
done() // done()
}) // })
codec.feed(p.hexDecode('050000000501')) // codec.feed(p.hexDecode('050000000501'))
codec.feed(p.hexDecode('020304')) // codec.feed(p.hexDecode('020304'))
})) // }))
it('should correctly parse multiple streamed packets', () => // it('should correctly parse multiple streamed packets', () =>
new Promise<void>((done) => { // new Promise<void>((done) => {
const codec = new IntermediatePacketCodec() // const codec = new IntermediatePacketCodec()
let number = 0 // let number = 0
codec.on('packet', (data: Uint8Array) => { // codec.on('packet', (data: Uint8Array) => {
if (number === 0) { // if (number === 0) {
expect([...data]).eql([5, 1, 2, 3, 4]) // expect([...data]).eql([5, 1, 2, 3, 4])
number = 1 // number = 1
} else { // } else {
expect([...data]).eql([3, 1, 2, 3, 1]) // expect([...data]).eql([3, 1, 2, 3, 1])
done() // done()
} // }
}) // })
codec.feed(p.hexDecode('050000000501')) // codec.feed(p.hexDecode('050000000501'))
codec.feed(p.hexDecode('020304050000')) // codec.feed(p.hexDecode('020304050000'))
codec.feed(p.hexDecode('000301020301')) // codec.feed(p.hexDecode('000301020301'))
})) // }))
it('should correctly parse transport errors', () => // it('should correctly parse transport errors', () =>
new Promise<void>((done) => { // new Promise<void>((done) => {
const codec = new IntermediatePacketCodec() // const codec = new IntermediatePacketCodec()
codec.on('error', (err: TransportError) => { // codec.on('error', (err: TransportError) => {
expect(err).to.have.instanceOf(TransportError) // expect(err).to.have.instanceOf(TransportError)
expect(err.code).eq(404) // expect(err.code).eq(404)
done() // done()
}) // })
codec.feed(p.hexDecode('040000006cfeffff')) // codec.feed(p.hexDecode('040000006cfeffff'))
})) // }))
it('should reset when called reset()', () => // it('should reset when called reset()', () =>
new Promise<void>((done) => { // new Promise<void>((done) => {
const codec = new IntermediatePacketCodec() // const codec = new IntermediatePacketCodec()
codec.on('packet', (data: Uint8Array) => { // codec.on('packet', (data: Uint8Array) => {
expect([...data]).eql([1, 2, 3, 4, 5]) // expect([...data]).eql([1, 2, 3, 4, 5])
done() // done()
}) // })
codec.feed(p.hexDecode('ff0000001234567812345678')) // codec.feed(p.hexDecode('ff0000001234567812345678'))
codec.reset() // codec.reset()
codec.feed(p.hexDecode('050000000102030405')) // codec.feed(p.hexDecode('050000000102030405'))
})) // }))
it('should correctly frame packets', () => { // it('should correctly frame packets', () => {
const data = p.hexDecode('6cfeffff') // const data = p.hexDecode('6cfeffff')
expect(p.hexEncode(new IntermediatePacketCodec().encode(data))).toEqual('040000006cfeffff') // expect(p.hexEncode(new IntermediatePacketCodec().encode(data))).toEqual('040000006cfeffff')
}) // })
}) // })
describe('PaddedIntermediatePacketCodec', () => { // describe('PaddedIntermediatePacketCodec', () => {
useFakeMathRandom() // useFakeMathRandom()
const create = async () => { // const create = async () => {
const codec = new PaddedIntermediatePacketCodec() // const codec = new PaddedIntermediatePacketCodec()
codec.setup!(await defaultTestCryptoProvider()) // codec.setup!(await defaultTestCryptoProvider())
return codec // return codec
} // }
it('should return correct tag', async () => { // it('should return correct tag', async () => {
expect(p.hexEncode((await create()).tag())).eq('dddddddd') // expect(p.hexEncode((await create()).tag())).eq('dddddddd')
}) // })
it('should correctly frame packets', async () => { // it('should correctly frame packets', async () => {
const data = p.hexDecode('6cfeffff') // const data = p.hexDecode('6cfeffff')
expect(p.hexEncode((await create()).encode(data))).toEqual('0a0000006cfeffff29afd26df40f') // expect(p.hexEncode((await create()).encode(data))).toEqual('0a0000006cfeffff29afd26df40f')
}) // })
}) // })

View file

@ -1,9 +1,11 @@
import type { Bytes, ISyncWritable } from '@fuman/io'
import { read, write } from '@fuman/io'
import type { ICryptoProvider } from '../../utils/index.js' import type { ICryptoProvider } from '../../utils/index.js'
import { dataViewFromBuffer, getRandomInt } from '../../utils/index.js' import { dataViewFromBuffer, getRandomInt } from '../../utils/index.js'
import type { IPacketCodec } from './abstract.js' import type { IPacketCodec } from './abstract.js'
import { TransportError } from './abstract.js' import { TransportError } from './abstract.js'
import { StreamedCodec } from './streamed.js'
const TAG = new Uint8Array([0xEE, 0xEE, 0xEE, 0xEE]) const TAG = new Uint8Array([0xEE, 0xEE, 0xEE, 0xEE])
const PADDED_TAG = new Uint8Array([0xDD, 0xDD, 0xDD, 0xDD]) const PADDED_TAG = new Uint8Array([0xDD, 0xDD, 0xDD, 0xDD])
@ -12,51 +14,46 @@ const PADDED_TAG = new Uint8Array([0xDD, 0xDD, 0xDD, 0xDD])
* Intermediate packet codec. * Intermediate packet codec.
* See https://core.telegram.org/mtproto/mtproto-transports#intermediate * See https://core.telegram.org/mtproto/mtproto-transports#intermediate
*/ */
export class IntermediatePacketCodec extends StreamedCodec implements IPacketCodec { export class IntermediatePacketCodec implements IPacketCodec {
tag(): Uint8Array { tag(): Uint8Array {
return TAG return TAG
} }
encode(packet: Uint8Array): Uint8Array { decode(reader: Bytes, eof: boolean): Uint8Array | null {
const ret = new Uint8Array(packet.length + 4) if (eof) return null
const dv = dataViewFromBuffer(ret)
dv.setUint32(0, packet.length, true)
ret.set(packet, 4)
return ret if (reader.available < 8) return null
}
protected _packetAvailable(): boolean { const length = read.uint32le(reader)
return this._stream.length >= 8
}
protected _handlePacket(): boolean { if (length === 4) {
const dv = dataViewFromBuffer(this._stream) // error
const payloadLength = dv.getUint32(0, true) const code = read.uint32le(reader)
throw new TransportError(code)
if (payloadLength <= this._stream.length - 4) {
if (payloadLength === 4) {
const code = dv.getInt32(4, true) * -1
this.emit('error', new TransportError(code))
} else {
const payload = this._stream.subarray(4, payloadLength + 4)
this.emit('packet', payload)
}
this._stream = this._stream.subarray(payloadLength + 4)
return true
} }
return false if (reader.available < length) {
reader.unread(4)
return null
}
return read.exactly(reader, length)
} }
encode(frame: Uint8Array, into: ISyncWritable): void {
write.uint32le(into, frame.length)
write.bytes(into, frame)
}
reset(): void {}
} }
/** /**
* Padded intermediate packet codec. * Padded intermediate packet codec.
* See https://core.telegram.org/mtproto/mtproto-transports#padded-intermediate * See https://core.telegram.org/mtproto/mtproto-transports#padded-intermediate
*/ */
export class PaddedIntermediatePacketCodec extends IntermediatePacketCodec { export class PaddedIntermediatePacketCodec extends IntermediatePacketCodec implements IPacketCodec {
tag(): Uint8Array { tag(): Uint8Array {
return PADDED_TAG return PADDED_TAG
} }
@ -66,16 +63,15 @@ export class PaddedIntermediatePacketCodec extends IntermediatePacketCodec {
this._crypto = crypto this._crypto = crypto
} }
encode(packet: Uint8Array): Uint8Array { encode(frame: Uint8Array, into: ISyncWritable): void {
// padding size, 0-15 // padding size, 0-15
const padSize = getRandomInt(16) const padSize = getRandomInt(16)
const ret = new Uint8Array(packet.length + 4 + padSize) const ret = into.writeSync(frame.length + 4 + padSize)
const dv = dataViewFromBuffer(ret) const dv = dataViewFromBuffer(ret)
dv.setUint32(0, packet.length + padSize, true) dv.setUint32(0, frame.length + padSize, true)
ret.set(packet, 4) ret.set(frame, 4)
this._crypto.randomFill(ret.subarray(4 + packet.length)) this._crypto.randomFill(ret.subarray(4 + frame.length))
into.disposeWriteSync()
return ret
} }
} }

View file

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { defaultTestCryptoProvider, u8HexDecode } from '@mtcute/test' import { defaultTestCryptoProvider, u8HexDecode } from '@mtcute/test'
import { Bytes } from '@fuman/io'
import { getPlatform } from '../../platform.js' import { getPlatform } from '../../platform.js'
import { LogManager } from '../../utils/index.js' import { LogManager } from '../../utils/index.js'
@ -7,6 +8,7 @@ import { LogManager } from '../../utils/index.js'
import { IntermediatePacketCodec } from './intermediate.js' import { IntermediatePacketCodec } from './intermediate.js'
import type { MtProxyInfo } from './obfuscated.js' import type { MtProxyInfo } from './obfuscated.js'
import { ObfuscatedPacketCodec } from './obfuscated.js' import { ObfuscatedPacketCodec } from './obfuscated.js'
import { TransportError } from './abstract'
const p = getPlatform() const p = getPlatform()
@ -162,8 +164,13 @@ describe('ObfuscatedPacketCodec', () => {
await codec.tag() await codec.tag()
expect(p.hexEncode(await codec.encode(data))).toEqual(msg1) const buf = Bytes.alloc()
expect(p.hexEncode(await codec.encode(data))).toEqual(msg2) await codec.encode(data, buf)
expect(p.hexEncode(buf.result())).toEqual(msg1)
buf.reset()
await codec.encode(data, buf)
expect(p.hexEncode(buf.result())).toEqual(msg2)
}) })
it('should correctly decrypt the underlying codec', async () => { it('should correctly decrypt the underlying codec', async () => {
@ -174,16 +181,8 @@ describe('ObfuscatedPacketCodec', () => {
await codec.tag() await codec.tag()
const log: string[] = [] expect(codec.decode(Bytes.from(p.hexDecode(msg1)), false)).rejects.toThrow(TransportError)
expect(codec.decode(Bytes.from(p.hexDecode(msg2)), false)).rejects.toThrow(TransportError)
codec.on('error', (e: Error) => {
log.push(e.toString())
})
codec.feed(p.hexDecode(msg1))
codec.feed(p.hexDecode(msg2))
await vi.waitFor(() => expect(log).toEqual(['Error: Transport error: 404', 'Error: Transport error: 404']))
}) })
it('should correctly reset', async () => { it('should correctly reset', async () => {

View file

@ -1,8 +1,10 @@
import type { ISyncWritable } from '@fuman/io'
import { Bytes, write } from '@fuman/io'
import { bufferToReversed, concatBuffers, dataViewFromBuffer } from '../../utils/buffer-utils.js' import { bufferToReversed, concatBuffers, dataViewFromBuffer } from '../../utils/buffer-utils.js'
import type { IAesCtr } from '../../utils/index.js' import type { IAesCtr, ICryptoProvider, Logger } from '../../utils/index.js'
import type { IPacketCodec } from './abstract.js' import type { IPacketCodec } from './abstract.js'
import { WrappedCodec } from './wrapped.js'
export interface MtProxyInfo { export interface MtProxyInfo {
dcId: number dcId: number
@ -11,14 +13,22 @@ export interface MtProxyInfo {
media: boolean media: boolean
} }
export class ObfuscatedPacketCodec extends WrappedCodec implements IPacketCodec { export class ObfuscatedPacketCodec implements IPacketCodec {
private _encryptor?: IAesCtr private _encryptor?: IAesCtr
private _decryptor?: IAesCtr private _decryptor?: IAesCtr
private _crypto!: ICryptoProvider
private _inner: IPacketCodec
private _proxy?: MtProxyInfo private _proxy?: MtProxyInfo
setup(crypto: ICryptoProvider, log: Logger): void {
this._crypto = crypto
this._inner.setup?.(crypto, log)
}
constructor(inner: IPacketCodec, proxy?: MtProxyInfo) { constructor(inner: IPacketCodec, proxy?: MtProxyInfo) {
super(inner) // super(inner)
this._inner = inner
this._proxy = proxy this._proxy = proxy
} }
@ -87,14 +97,17 @@ export class ObfuscatedPacketCodec extends WrappedCodec implements IPacketCodec
return random return random
} }
async encode(packet: Uint8Array): Promise<Uint8Array> { async encode(packet: Uint8Array, into: ISyncWritable): Promise<void> {
return this._encryptor!.process(await this._inner.encode(packet)) const temp = Bytes.alloc(packet.length)
await this._inner.encode(packet, into)
write.bytes(into, this._encryptor!.process(temp.result()))
} }
feed(data: Uint8Array): void { async decode(reader: Bytes, eof: boolean): Promise<Uint8Array | null> {
const dec = this._decryptor!.process(data) const inner = await this._inner.decode(reader, eof)
if (!inner) return null
this._inner.feed(dec) return this._decryptor!.process(inner)
} }
reset(): void { reset(): void {

View file

@ -1,39 +0,0 @@
// eslint-disable-next-line unicorn/prefer-node-protocol
import EventEmitter from 'events'
import { concatBuffers } from '../../utils/index.js'
/**
* Base for streamed codecs.
*
* Streamed means that MTProto packet can be divided into
* multiple transport packets.
*/
export abstract class StreamedCodec extends EventEmitter {
protected _stream: Uint8Array = new Uint8Array(0)
/**
* Should return whether a full packet is available
* in `_stream` buffer
*/
protected abstract _packetAvailable(): boolean
/**
* Handle a single (!) packet from `_stream` and emit `packet` or `error`.
*
* Should return true if there are more packets available, false otherwise
*/
protected abstract _handlePacket(): boolean
feed(data: Uint8Array): void {
this._stream = concatBuffers([this._stream, data])
while (this._packetAvailable()) {
if (!this._handlePacket()) break
}
}
reset(): void {
this._stream = new Uint8Array(0)
}
}

View file

@ -1,30 +0,0 @@
// eslint-disable-next-line unicorn/prefer-node-protocol
import EventEmitter from 'events'
import type { ICryptoProvider, Logger } from '../../utils/index.js'
import type { IPacketCodec } from './abstract.js'
export abstract class WrappedCodec extends EventEmitter {
protected _crypto!: ICryptoProvider
protected _inner: IPacketCodec
constructor(inner: IPacketCodec) {
super()
this._inner = inner
this._inner.on('error', err => this.emit('error', err))
this._inner.on('packet', buf => this.emit('packet', buf))
}
removeAllListeners(): this {
super.removeAllListeners()
this._inner.removeAllListeners()
return this
}
setup(crypto: ICryptoProvider, log: Logger): void {
this._crypto = crypto
this._inner.setup?.(crypto, log)
}
}

View file

@ -17,7 +17,6 @@ import { downloadToFile } from './methods/download-file.js'
import { DenoPlatform } from './platform.js' import { DenoPlatform } from './platform.js'
import { SqliteStorage } from './sqlite/index.js' import { SqliteStorage } from './sqlite/index.js'
import { DenoCryptoProvider } from './utils/crypto.js' import { DenoCryptoProvider } from './utils/crypto.js'
import { TcpTransport } from './utils/tcp.js'
export type { TelegramClientOptions } export type { TelegramClientOptions }
@ -48,7 +47,7 @@ export class BaseTelegramClient extends BaseTelegramClientBase {
super({ super({
crypto: new DenoCryptoProvider(), crypto: new DenoCryptoProvider(),
transport: () => new TcpTransport(), transport: {} as any, // todo
...opts, ...opts,
storage: storage:
typeof opts.storage === 'string' typeof opts.storage === 'string'

View file

@ -2,7 +2,7 @@ export * from './client.js'
export * from './platform.js' export * from './platform.js'
export * from './sqlite/index.js' export * from './sqlite/index.js'
export * from './utils/crypto.js' export * from './utils/crypto.js'
export * from './utils/tcp.js' // export * from './utils/tcp.js'
export * from './worker.js' export * from './worker.js'
export * from '@mtcute/core' export * from '@mtcute/core'
export * from '@mtcute/html-parser' export * from '@mtcute/html-parser'

View file

@ -1,146 +1,146 @@
import EventEmitter from 'node:events' // import EventEmitter from 'node:events'
import type { IPacketCodec, ITelegramTransport } from '@mtcute/core' // import { IntermediatePacketCodec, IPacketCodec, ITelegramTransport, MtcuteError, TransportState } from '@mtcute/core'
import { IntermediatePacketCodec, MtcuteError, TransportState } from '@mtcute/core' // import { BasicDcOption, ICryptoProvider, Logger } from '@mtcute/core/utils.js'
import type { BasicDcOption, ICryptoProvider, Logger } from '@mtcute/core/utils.js'
import { writeAll } from '@std/io/write-all'
/** // import { writeAll } from '@std/io/write-all'
* Base for TCP transports.
* Subclasses must provide packet codec in `_packetCodec` property
*/
export abstract class BaseTcpTransport extends EventEmitter implements ITelegramTransport {
protected _currentDc: BasicDcOption | null = null
protected _state: TransportState = TransportState.Idle
protected _socket: Deno.TcpConn | null = null
abstract _packetCodec: IPacketCodec // /**
protected _crypto!: ICryptoProvider // * Base for TCP transports.
protected log!: Logger // * Subclasses must provide packet codec in `_packetCodec` property
// */
// export abstract class BaseTcpTransport extends EventEmitter implements ITelegramConnection {
// protected _currentDc: BasicDcOption | null = null
// protected _state: TransportState = TransportState.Idle
// protected _socket: Deno.TcpConn | null = null
packetCodecInitialized = false // abstract _packetCodec: IPacketCodec
// protected _crypto!: ICryptoProvider
// protected log!: Logger
private _updateLogPrefix() { // packetCodecInitialized = false
if (this._currentDc) {
this.log.prefix = `[TCP:${this._currentDc.ipAddress}:${this._currentDc.port}] `
} else {
this.log.prefix = '[TCP:disconnected] '
}
}
setup(crypto: ICryptoProvider, log: Logger): void { // private _updateLogPrefix() {
this._crypto = crypto // if (this._currentDc) {
this.log = log.create('tcp') // this.log.prefix = `[TCP:${this._currentDc.ipAddress}:${this._currentDc.port}] `
this._updateLogPrefix() // } else {
} // this.log.prefix = '[TCP:disconnected] '
// }
// }
state(): TransportState { // setup(crypto: ICryptoProvider, log: Logger): void {
return this._state // this._crypto = crypto
} // this.log = log.create('tcp')
// this._updateLogPrefix()
// }
currentDc(): BasicDcOption | null { // state(): TransportState {
return this._currentDc // return this._state
} // }
// eslint-disable-next-line unused-imports/no-unused-vars // currentDc(): BasicDcOption | null {
connect(dc: BasicDcOption, testMode: boolean): void { // return this._currentDc
if (this._state !== TransportState.Idle) { // }
throw new MtcuteError('Transport is not IDLE')
}
if (!this.packetCodecInitialized) { // // eslint-disable-next-line unused-imports/no-unused-vars
this._packetCodec.setup?.(this._crypto, this.log) // connect(dc: BasicDcOption, testMode: boolean): void {
this._packetCodec.on('error', err => this.emit('error', err)) // if (this._state !== TransportState.Idle) {
this._packetCodec.on('packet', buf => this.emit('message', buf)) // throw new MtcuteError('Transport is not IDLE')
this.packetCodecInitialized = true // }
}
this._state = TransportState.Connecting // if (!this.packetCodecInitialized) {
this._currentDc = dc // this._packetCodec.setup?.(this._crypto, this.log)
this._updateLogPrefix() // this._packetCodec.on('error', err => this.emit('error', err))
// this._packetCodec.on('packet', buf => this.emit('message', buf))
// this.packetCodecInitialized = true
// }
this.log.debug('connecting to %j', dc) // this._state = TransportState.Connecting
// this._currentDc = dc
// this._updateLogPrefix()
Deno.connect({ // this.log.debug('connecting to %j', dc)
hostname: dc.ipAddress,
port: dc.port,
transport: 'tcp',
})
.then(this.handleConnect.bind(this))
.catch((err) => {
this.handleError(err)
this.close()
})
}
close(): void { // Deno.connect({
if (this._state === TransportState.Idle) return // hostname: dc.ipAddress,
this.log.info('connection closed') // port: dc.port,
// transport: 'tcp',
// })
// .then(this.handleConnect.bind(this))
// .catch((err) => {
// this.handleError(err)
// this.close()
// })
// }
this._state = TransportState.Idle // close(): void {
// if (this._state === TransportState.Idle) return
// this.log.info('connection closed')
try { // this._state = TransportState.Idle
this._socket?.close()
} catch (e) {
if (!(e instanceof Deno.errors.BadResource)) {
this.handleError(e)
}
}
this._socket = null // try {
this._currentDc = null // this._socket?.close()
this._packetCodec.reset() // } catch (e) {
this.emit('close') // if (!(e instanceof Deno.errors.BadResource)) {
} // this.handleError(e)
// }
// }
handleError(error: unknown): void { // this._socket = null
this.log.error('error: %s', error) // this._currentDc = null
// this._packetCodec.reset()
// this.emit('close')
// }
if (this.listenerCount('error') > 0) { // handleError(error: unknown): void {
this.emit('error', error) // this.log.error('error: %s', error)
}
}
async handleConnect(socket: Deno.TcpConn): Promise<void> { // if (this.listenerCount('error') > 0) {
this._socket = socket // this.emit('error', error)
this.log.info('connected') // }
// }
try { // async handleConnect(socket: Deno.TcpConn): Promise<void> {
const packet = await this._packetCodec.tag() // this._socket = socket
// this.log.info('connected')
if (packet.length) { // try {
await writeAll(this._socket, packet) // const packet = await this._packetCodec.tag()
}
this._state = TransportState.Ready // if (packet.length) {
this.emit('ready') // await writeAll(this._socket, packet)
// }
const reader = this._socket.readable.getReader() // this._state = TransportState.Ready
// this.emit('ready')
while (true) { // const reader = this._socket.readable.getReader()
const { done, value } = await reader.read()
if (done) break
this._packetCodec.feed(value) // while (true) {
} // const { done, value } = await reader.read()
} catch (e) { // if (done) break
this.handleError(e)
}
this.close() // this._packetCodec.feed(value)
} // }
// } catch (e) {
// this.handleError(e)
// }
async send(bytes: Uint8Array): Promise<void> { // this.close()
const framed = await this._packetCodec.encode(bytes) // }
if (this._state !== TransportState.Ready) { // async send(bytes: Uint8Array): Promise<void> {
throw new MtcuteError('Transport is not READY') // const framed = await this._packetCodec.encode(bytes)
}
await writeAll(this._socket!, framed) // if (this._state !== TransportState.Ready) {
} // throw new MtcuteError('Transport is not READY')
} // }
export class TcpTransport extends BaseTcpTransport { // await writeAll(this._socket!, framed)
_packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec() // }
} // }
// export class TcpTransport extends BaseTcpTransport {
// _packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec()
// }

View file

@ -1,164 +1,165 @@
import { connect as connectTcp } from 'node:net' // todo: move to fuman
import type { SecureContextOptions } from 'node:tls' // import { connect as connectTcp } from 'node:net'
import { connect as connectTls } from 'node:tls' // import type { SecureContextOptions } from 'node:tls'
// import { connect as connectTls } from 'node:tls'
import type { tl } from '@mtcute/node' // import type { tl } from '@mtcute/node'
import { BaseTcpTransport, IntermediatePacketCodec, MtcuteError, NodePlatform, TransportState } from '@mtcute/node' // import { BaseTcpTransport, IntermediatePacketCodec, MtcuteError, NodePlatform, TransportState } from '@mtcute/node'
/** // /**
* An error has occurred while connecting to an HTTP(s) proxy // * An error has occurred while connecting to an HTTP(s) proxy
*/ // */
export class HttpProxyConnectionError extends Error { // export class HttpProxyConnectionError extends Error {
readonly proxy: HttpProxySettings // readonly proxy: HttpProxySettings
constructor(proxy: HttpProxySettings, message: string) { // constructor(proxy: HttpProxySettings, message: string) {
super(`Error while connecting to ${proxy.host}:${proxy.port}: ${message}`) // super(`Error while connecting to ${proxy.host}:${proxy.port}: ${message}`)
this.proxy = proxy // this.proxy = proxy
} // }
} // }
/** // /**
* HTTP(s) proxy settings // * HTTP(s) proxy settings
*/ // */
export interface HttpProxySettings { // export interface HttpProxySettings {
/** // /**
* Host or IP of the proxy (e.g. `proxy.example.com`, `1.2.3.4`) // * Host or IP of the proxy (e.g. `proxy.example.com`, `1.2.3.4`)
*/ // */
host: string // host: string
/** // /**
* Port of the proxy (e.g. `8888`) // * Port of the proxy (e.g. `8888`)
*/ // */
port: number // port: number
/** // /**
* Proxy authorization username, if needed // * Proxy authorization username, if needed
*/ // */
user?: string // user?: string
/** // /**
* Proxy authorization password, if needed // * Proxy authorization password, if needed
*/ // */
password?: string // password?: string
/** // /**
* Proxy connection headers, if needed // * Proxy connection headers, if needed
*/ // */
headers?: Record<string, string> // headers?: Record<string, string>
/** // /**
* Whether this is a HTTPS proxy (i.e. the client // * Whether this is a HTTPS proxy (i.e. the client
* should connect to the proxy server via TLS) // * should connect to the proxy server via TLS)
*/ // */
tls?: boolean // tls?: boolean
/** // /**
* Additional TLS options, used if `tls = true`. // * Additional TLS options, used if `tls = true`.
* Can contain stuff like custom certificate, host, // * Can contain stuff like custom certificate, host,
* or whatever. // * or whatever.
*/ // */
tlsOptions?: SecureContextOptions // tlsOptions?: SecureContextOptions
} // }
/** // /**
* TCP transport that connects via an HTTP(S) proxy. // * TCP transport that connects via an HTTP(S) proxy.
*/ // */
export abstract class BaseHttpProxyTcpTransport extends BaseTcpTransport { // export abstract class BaseHttpProxyTcpTransport extends BaseTcpTransport {
readonly _proxy: HttpProxySettings // readonly _proxy: HttpProxySettings
constructor(proxy: HttpProxySettings) { // constructor(proxy: HttpProxySettings) {
super() // super()
this._proxy = proxy // this._proxy = proxy
} // }
private _platform = new NodePlatform() // private _platform = new NodePlatform()
connect(dc: tl.RawDcOption): void { // connect(dc: tl.RawDcOption): void {
if (this._state !== TransportState.Idle) { // if (this._state !== TransportState.Idle) {
throw new MtcuteError('Transport is not IDLE') // throw new MtcuteError('Transport is not IDLE')
} // }
if (!this.packetCodecInitialized) { // if (!this.packetCodecInitialized) {
this._packetCodec.on('error', err => this.emit('error', err)) // this._packetCodec.on('error', err => this.emit('error', err))
this._packetCodec.on('packet', buf => this.emit('message', buf)) // this._packetCodec.on('packet', buf => this.emit('message', buf))
this.packetCodecInitialized = true // this.packetCodecInitialized = true
} // }
this._state = TransportState.Connecting // this._state = TransportState.Connecting
this._currentDc = dc // this._currentDc = dc
this._socket = this._proxy.tls // this._socket = this._proxy.tls
? connectTls(this._proxy.port, this._proxy.host, this._proxy.tlsOptions, this._onProxyConnected.bind(this)) // ? connectTls(this._proxy.port, this._proxy.host, this._proxy.tlsOptions, this._onProxyConnected.bind(this))
: connectTcp(this._proxy.port, this._proxy.host, this._onProxyConnected.bind(this)) // : connectTcp(this._proxy.port, this._proxy.host, this._onProxyConnected.bind(this))
this._socket.on('error', this.handleError.bind(this)) // this._socket.on('error', this.handleError.bind(this))
this._socket.on('close', this.close.bind(this)) // this._socket.on('close', this.close.bind(this))
} // }
private _onProxyConnected() { // private _onProxyConnected() {
this.log.debug('[%s:%d] connected to proxy, sending CONNECT', this._proxy.host, this._proxy.port) // this.log.debug('[%s:%d] connected to proxy, sending CONNECT', this._proxy.host, this._proxy.port)
let ip = `${this._currentDc!.ipAddress}:${this._currentDc!.port}` // let ip = `${this._currentDc!.ipAddress}:${this._currentDc!.port}`
if (this._currentDc!.ipv6) ip = `[${ip}]` // if (this._currentDc!.ipv6) ip = `[${ip}]`
const headers = { // const headers = {
...(this._proxy.headers ?? {}), // ...(this._proxy.headers ?? {}),
} // }
headers.Host = ip // headers.Host = ip
if (this._proxy.user) { // if (this._proxy.user) {
let auth = this._proxy.user // let auth = this._proxy.user
if (this._proxy.password) { // if (this._proxy.password) {
auth += `:${this._proxy.password}` // auth += `:${this._proxy.password}`
} // }
headers['Proxy-Authorization'] = `Basic ${this._platform.base64Encode(this._platform.utf8Encode(auth))}` // headers['Proxy-Authorization'] = `Basic ${this._platform.base64Encode(this._platform.utf8Encode(auth))}`
} // }
headers['Proxy-Connection'] = 'Keep-Alive' // headers['Proxy-Connection'] = 'Keep-Alive'
const headersStr = Object.keys(headers) // const headersStr = Object.keys(headers)
.map(k => `\r\n${k}: ${headers[k]}`) // .map(k => `\r\n${k}: ${headers[k]}`)
.join('') // .join('')
const packet = `CONNECT ${ip} HTTP/1.1${headersStr}\r\n\r\n` // const packet = `CONNECT ${ip} HTTP/1.1${headersStr}\r\n\r\n`
this._socket!.write(packet) // this._socket!.write(packet)
this._socket!.once('data', (msg) => { // this._socket!.once('data', (msg) => {
this.log.debug('[%s:%d] CONNECT resulted in: %s', this._proxy.host, this._proxy.port, msg) // this.log.debug('[%s:%d] CONNECT resulted in: %s', this._proxy.host, this._proxy.port, msg)
const [proto, code, name] = msg.toString().split(' ') // const [proto, code, name] = msg.toString().split(' ')
if (!proto.match(/^HTTP\/1.[01]$/i)) { // if (!proto.match(/^HTTP\/1.[01]$/i)) {
// wtf? // // wtf?
this._socket!.emit( // this._socket!.emit(
'error', // 'error',
new HttpProxyConnectionError(this._proxy, `Server returned invalid protocol: ${proto}`), // new HttpProxyConnectionError(this._proxy, `Server returned invalid protocol: ${proto}`),
) // )
return // return
} // }
if (code[0] !== '2') { // if (code[0] !== '2') {
this._socket!.emit( // this._socket!.emit(
'error', // 'error',
new HttpProxyConnectionError(this._proxy, `Server returned error: ${code} ${name}`), // new HttpProxyConnectionError(this._proxy, `Server returned error: ${code} ${name}`),
) // )
return // return
} // }
// all ok, connection established, can now call handleConnect // // all ok, connection established, can now call handleConnect
this._socket!.on('data', data => this._packetCodec.feed(data)) // this._socket!.on('data', data => this._packetCodec.feed(data))
this.handleConnect() // this.handleConnect()
}) // })
} // }
} // }
/** // /**
* HTTP(s) TCP transport using an intermediate packet codec. // * HTTP(s) TCP transport using an intermediate packet codec.
* // *
* Should be the one passed as `transport` to `TelegramClient` constructor // * Should be the one passed as `transport` to `TelegramClient` constructor
* (unless you want to use a custom codec). // * (unless you want to use a custom codec).
*/ // */
export class HttpProxyTcpTransport extends BaseHttpProxyTcpTransport { // export class HttpProxyTcpTransport extends BaseHttpProxyTcpTransport {
_packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec() // _packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec()
} // }

View file

@ -1,351 +1,352 @@
/* eslint-disable no-restricted-globals */ // /* eslint-disable no-restricted-globals */
import type { IPacketCodec } from '@mtcute/node' // todo fixme
import { WrappedCodec } from '@mtcute/node' // import type { IPacketCodec } from '@mtcute/node'
import type { ICryptoProvider } from '@mtcute/node/utils.js' // import { WrappedCodec } from '@mtcute/node'
import { bigIntModInv, bigIntModPow, bigIntToBuffer, bufferToBigInt } from '@mtcute/node/utils.js' // import type { ICryptoProvider } from '@mtcute/node/utils.js'
// import { bigIntModInv, bigIntModPow, bigIntToBuffer, bufferToBigInt } from '@mtcute/node/utils.js'
const MAX_TLS_PACKET_LENGTH = 2878 // const MAX_TLS_PACKET_LENGTH = 2878
const TLS_FIRST_PREFIX = Buffer.from('140303000101', 'hex') // const TLS_FIRST_PREFIX = Buffer.from('140303000101', 'hex')
// ref: https://github.com/tdlib/td/blob/master/td/mtproto/TlsInit.cpp // // ref: https://github.com/tdlib/td/blob/master/td/mtproto/TlsInit.cpp
const KEY_MOD = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEDn // const KEY_MOD = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEDn
// 2^255 - 19 // // 2^255 - 19
const QUAD_RES_MOD = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEDn // const QUAD_RES_MOD = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEDn
// (mod - 1) / 2 = 2^254 - 10 // // (mod - 1) / 2 = 2^254 - 10
const QUAD_RES_POW = 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF6n // const QUAD_RES_POW = 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF6n
function _getY2(x: bigint, mod: bigint): bigint { // function _getY2(x: bigint, mod: bigint): bigint {
// returns y = x^3 + x^2 * 486662 + x // // returns y = x^3 + x^2 * 486662 + x
let y = x // let y = x
y = (y + 486662n) % mod // y = (y + 486662n) % mod
y = (y * x) % mod // y = (y * x) % mod
y = (y + 1n) % mod // y = (y + 1n) % mod
y = (y * x) % mod // y = (y * x) % mod
return y // return y
} // }
function _getDoubleX(x: bigint, mod: bigint): bigint { // function _getDoubleX(x: bigint, mod: bigint): bigint {
// returns x_2 = (x^2 - 1)^2/(4*y^2) // // returns x_2 = (x^2 - 1)^2/(4*y^2)
let denominator = _getY2(x, mod) // let denominator = _getY2(x, mod)
denominator = (denominator * 4n) % mod // denominator = (denominator * 4n) % mod
let numerator = (x * x) % mod // let numerator = (x * x) % mod
numerator = (numerator - 1n) % mod // numerator = (numerator - 1n) % mod
numerator = (numerator * numerator) % mod // numerator = (numerator * numerator) % mod
denominator = bigIntModInv(denominator, mod) // denominator = bigIntModInv(denominator, mod)
numerator = (numerator * denominator) % mod // numerator = (numerator * denominator) % mod
return numerator // return numerator
} // }
function _isQuadraticResidue(a: bigint): boolean { // function _isQuadraticResidue(a: bigint): boolean {
const r = bigIntModPow(a, QUAD_RES_POW, QUAD_RES_MOD) // const r = bigIntModPow(a, QUAD_RES_POW, QUAD_RES_MOD)
return r === 1n // return r === 1n
} // }
interface TlsOperationHandler { // interface TlsOperationHandler {
string: (buf: Buffer) => void // string: (buf: Buffer) => void
zero: (size: number) => void // zero: (size: number) => void
random: (size: number) => void // random: (size: number) => void
domain: () => void // domain: () => void
grease: (seed: number) => void // grease: (seed: number) => void
beginScope: () => void // beginScope: () => void
endScope: () => void // endScope: () => void
key: () => void // key: () => void
} // }
function executeTlsOperations(h: TlsOperationHandler): void { // function executeTlsOperations(h: TlsOperationHandler): void {
h.string(Buffer.from('1603010200010001fc0303', 'hex')) // h.string(Buffer.from('1603010200010001fc0303', 'hex'))
h.zero(32) // h.zero(32)
h.string(Buffer.from('20', 'hex')) // h.string(Buffer.from('20', 'hex'))
h.random(32) // h.random(32)
h.string(Buffer.from('0020', 'hex')) // h.string(Buffer.from('0020', 'hex'))
h.grease(0) // h.grease(0)
h.string(Buffer.from('130113021303c02bc02fc02cc030cca9cca8c013c014009c009d002f003501000193', 'hex')) // h.string(Buffer.from('130113021303c02bc02fc02cc030cca9cca8c013c014009c009d002f003501000193', 'hex'))
h.grease(2) // h.grease(2)
h.string(Buffer.from('00000000', 'hex')) // h.string(Buffer.from('00000000', 'hex'))
h.beginScope() // h.beginScope()
h.beginScope() // h.beginScope()
h.string(Buffer.from('00', 'hex')) // h.string(Buffer.from('00', 'hex'))
h.beginScope() // h.beginScope()
h.domain() // h.domain()
h.endScope() // h.endScope()
h.endScope() // h.endScope()
h.endScope() // h.endScope()
h.string(Buffer.from('00170000ff01000100000a000a0008', 'hex')) // h.string(Buffer.from('00170000ff01000100000a000a0008', 'hex'))
h.grease(4) // h.grease(4)
h.string( // h.string(
Buffer.from( // Buffer.from(
'001d00170018000b00020100002300000010000e000c02683208687474702f312e31000500050100000000000d0012001004030804040105030805050108060601001200000033002b0029', // '001d00170018000b00020100002300000010000e000c02683208687474702f312e31000500050100000000000d0012001004030804040105030805050108060601001200000033002b0029',
'hex', // 'hex',
), // ),
) // )
h.grease(4) // h.grease(4)
h.string(Buffer.from('000100001d0020', 'hex')) // h.string(Buffer.from('000100001d0020', 'hex'))
h.key() // h.key()
h.string(Buffer.from('002d00020101002b000b0a', 'hex')) // h.string(Buffer.from('002d00020101002b000b0a', 'hex'))
h.grease(6) // h.grease(6)
h.string(Buffer.from('0304030303020301001b0003020002', 'hex')) // h.string(Buffer.from('0304030303020301001b0003020002', 'hex'))
h.grease(3) // h.grease(3)
h.string(Buffer.from('0001000015', 'hex')) // h.string(Buffer.from('0001000015', 'hex'))
} // }
// // i dont know why is this needed, since it is always padded to 517 bytes
// // this was in tdlib sources, so whatever. not used here though, and works just fine
// // class TlsHelloCounter implements TlsOperationHandler {
// // size = 0
// //
// // private _domain: Buffer
// //
// // constructor(domain: Buffer) {
// // this._domain = domain
// // }
// //
// // string(buf: Buffer) {
// // this.size += buf.length
// // }
// //
// // random(size: number) {
// // this.size += size
// // }
// //
// // zero(size: number) {
// // this.size += size
// // }
// //
// // domain() {
// // this.size += this._domain.length
// // }
// //
// // grease() {
// // this.size += 2
// // }
// //
// // key() {
// // this.size += 32
// // }
// //
// // beginScope() {
// // this.size += 2
// // }
// //
// // endScope() {
// // // no-op, since this does not affect size
// // }
// //
// // finish(): number {
// // const zeroPad = 515 - this.size
// // this.beginScope()
// // this.zero(zeroPad)
// // this.endScope()
// //
// // return this.size
// // }
// // }
// function initGrease(crypto: ICryptoProvider, size: number): Buffer {
// const buf = crypto.randomBytes(size)
// for (let i = 0; i < size; i++) {
// buf[i] = (buf[i] & 0xF0) + 0x0A
// }
// for (let i = 1; i < size; i += 2) {
// if (buf[i] === buf[i - 1]) {
// buf[i] ^= 0x10
// }
// }
// return Buffer.from(buf)
// }
// class TlsHelloWriter implements TlsOperationHandler {
// buf: Buffer
// pos = 0
// i dont know why is this needed, since it is always padded to 517 bytes
// this was in tdlib sources, so whatever. not used here though, and works just fine
// class TlsHelloCounter implements TlsOperationHandler {
// size = 0
//
// private _domain: Buffer // private _domain: Buffer
// // private _grease
// constructor(domain: Buffer) { // private _scopes: number[] = []
// constructor(
// readonly crypto: ICryptoProvider,
// size: number,
// domain: Buffer,
// ) {
// this._domain = domain // this._domain = domain
// this.buf = Buffer.allocUnsafe(size)
// this._grease = initGrease(this.crypto, 7)
// } // }
//
// string(buf: Buffer) { // string(buf: Buffer) {
// this.size += buf.length // buf.copy(this.buf, this.pos)
// this.pos += buf.length
// } // }
//
// random(size: number) { // random(size: number) {
// this.size += size // this.string(Buffer.from(this.crypto.randomBytes(size)))
// } // }
//
// zero(size: number) { // zero(size: number) {
// this.size += size // this.string(Buffer.alloc(size, 0))
// } // }
//
// domain() { // domain() {
// this.size += this._domain.length // this.string(this._domain)
// } // }
//
// grease() { // grease(seed: number) {
// this.size += 2 // this.buf[this.pos] = this.buf[this.pos + 1] = this._grease[seed]
// this.pos += 2
// } // }
//
// key() { // key() {
// this.size += 32 // for (;;) {
// const key = this.crypto.randomBytes(32)
// key[31] &= 127
// let x = bufferToBigInt(key)
// const y = _getY2(x, KEY_MOD)
// if (_isQuadraticResidue(y)) {
// for (let i = 0; i < 3; i++) {
// x = _getDoubleX(x, KEY_MOD)
// }
// const key = bigIntToBuffer(x, 32, true)
// this.string(Buffer.from(key))
// return
// }
// }
// } // }
//
// beginScope() { // beginScope() {
// this.size += 2 // this._scopes.push(this.pos)
// this.pos += 2
// } // }
//
// endScope() { // endScope() {
// // no-op, since this does not affect size // const begin = this._scopes.pop()
// if (begin === undefined) {
// throw new Error('endScope called without beginScope')
// }
// const end = this.pos
// const size = end - begin - 2
// this.buf.writeUInt16BE(size, begin)
// } // }
//
// finish(): number { // async finish(secret: Buffer): Promise<Buffer> {
// const zeroPad = 515 - this.size // const padSize = 515 - this.pos
// const unixTime = ~~(Date.now() / 1000)
// this.beginScope() // this.beginScope()
// this.zero(zeroPad) // this.zero(padSize)
// this.endScope() // this.endScope()
//
// return this.size // const hash = Buffer.from(await this.crypto.hmacSha256(this.buf, secret))
// const old = hash.readInt32LE(28)
// hash.writeInt32LE(old ^ unixTime, 28)
// hash.copy(this.buf, 11)
// return this.buf
// } // }
// } // }
function initGrease(crypto: ICryptoProvider, size: number): Buffer { // /** @internal */
const buf = crypto.randomBytes(size) // export async function generateFakeTlsHeader(domain: string, secret: Buffer, crypto: ICryptoProvider): Promise<Buffer> {
// const domainBuf = Buffer.from(domain)
for (let i = 0; i < size; i++) { // const writer = new TlsHelloWriter(crypto, 517, domainBuf)
buf[i] = (buf[i] & 0xF0) + 0x0A // executeTlsOperations(writer)
}
for (let i = 1; i < size; i += 2) { // return writer.finish(secret)
if (buf[i] === buf[i - 1]) { // }
buf[i] ^= 0x10
}
}
return Buffer.from(buf) // /**
} // * Fake TLS packet codec, used for some MTProxies.
// *
// * Must only be used inside {@link MtProxyTcpTransport}
// * @internal
// */
// export class FakeTlsPacketCodec extends WrappedCodec implements IPacketCodec {
// protected _stream: Buffer = Buffer.alloc(0)
class TlsHelloWriter implements TlsOperationHandler { // private _header!: Buffer
buf: Buffer // private _isFirstTls = true
pos = 0
private _domain: Buffer // async tag(): Promise<Buffer> {
private _grease // this._header = Buffer.from(await this._inner.tag())
private _scopes: number[] = []
constructor( // return Buffer.alloc(0)
readonly crypto: ICryptoProvider, // }
size: number,
domain: Buffer,
) {
this._domain = domain
this.buf = Buffer.allocUnsafe(size)
this._grease = initGrease(this.crypto, 7)
}
string(buf: Buffer) { // private _encodeTls(packet: Buffer): Buffer {
buf.copy(this.buf, this.pos) // if (this._header.length) {
this.pos += buf.length // packet = Buffer.concat([this._header, packet])
} // this._header = Buffer.alloc(0)
// }
random(size: number) { // const header = Buffer.from([0x17, 0x03, 0x03, 0x00, 0x00])
this.string(Buffer.from(this.crypto.randomBytes(size))) // header.writeUInt16BE(packet.length, 3)
}
zero(size: number) { // if (this._isFirstTls) {
this.string(Buffer.alloc(size, 0)) // this._isFirstTls = false
}
domain() { // return Buffer.concat([TLS_FIRST_PREFIX, header, packet])
this.string(this._domain) // }
}
grease(seed: number) { // return Buffer.concat([header, packet])
this.buf[this.pos] = this.buf[this.pos + 1] = this._grease[seed] // }
this.pos += 2
}
key() { // async encode(packet: Buffer): Promise<Buffer> {
for (;;) { // packet = Buffer.from(await this._inner.encode(packet))
const key = this.crypto.randomBytes(32)
key[31] &= 127
let x = bufferToBigInt(key) // if (packet.length + this._header.length > MAX_TLS_PACKET_LENGTH) {
const y = _getY2(x, KEY_MOD) // const ret: Buffer[] = []
if (_isQuadraticResidue(y)) { // while (packet.length) {
for (let i = 0; i < 3; i++) { // const buf = packet.slice(0, MAX_TLS_PACKET_LENGTH - this._header.length)
x = _getDoubleX(x, KEY_MOD) // packet = packet.slice(buf.length)
} // ret.push(this._encodeTls(buf))
// }
const key = bigIntToBuffer(x, 32, true) // return Buffer.concat(ret)
this.string(Buffer.from(key)) // }
return // return this._encodeTls(packet)
} // }
}
}
beginScope() { // feed(data: Buffer): void {
this._scopes.push(this.pos) // this._stream = Buffer.concat([this._stream, data])
this.pos += 2
}
endScope() { // for (;;) {
const begin = this._scopes.pop() // if (this._stream.length < 5) return
if (begin === undefined) { // if (!(this._stream[0] === 0x17 && this._stream[1] === 0x03 && this._stream[2] === 0x03)) {
throw new Error('endScope called without beginScope') // this.emit('error', new Error('Invalid TLS header'))
}
const end = this.pos // return
const size = end - begin - 2 // }
this.buf.writeUInt16BE(size, begin) // const length = this._stream.readUInt16BE(3)
} // if (length < this._stream.length - 5) return
async finish(secret: Buffer): Promise<Buffer> { // this._inner.feed(this._stream.slice(5, length + 5))
const padSize = 515 - this.pos // this._stream = this._stream.slice(length + 5)
const unixTime = ~~(Date.now() / 1000) // }
// }
this.beginScope() // reset(): void {
this.zero(padSize) // this._stream = Buffer.alloc(0)
this.endScope() // this._isFirstTls = true
// }
const hash = Buffer.from(await this.crypto.hmacSha256(this.buf, secret)) // }
const old = hash.readInt32LE(28)
hash.writeInt32LE(old ^ unixTime, 28)
hash.copy(this.buf, 11)
return this.buf
}
}
/** @internal */
export async function generateFakeTlsHeader(domain: string, secret: Buffer, crypto: ICryptoProvider): Promise<Buffer> {
const domainBuf = Buffer.from(domain)
const writer = new TlsHelloWriter(crypto, 517, domainBuf)
executeTlsOperations(writer)
return writer.finish(secret)
}
/**
* Fake TLS packet codec, used for some MTProxies.
*
* Must only be used inside {@link MtProxyTcpTransport}
* @internal
*/
export class FakeTlsPacketCodec extends WrappedCodec implements IPacketCodec {
protected _stream: Buffer = Buffer.alloc(0)
private _header!: Buffer
private _isFirstTls = true
async tag(): Promise<Buffer> {
this._header = Buffer.from(await this._inner.tag())
return Buffer.alloc(0)
}
private _encodeTls(packet: Buffer): Buffer {
if (this._header.length) {
packet = Buffer.concat([this._header, packet])
this._header = Buffer.alloc(0)
}
const header = Buffer.from([0x17, 0x03, 0x03, 0x00, 0x00])
header.writeUInt16BE(packet.length, 3)
if (this._isFirstTls) {
this._isFirstTls = false
return Buffer.concat([TLS_FIRST_PREFIX, header, packet])
}
return Buffer.concat([header, packet])
}
async encode(packet: Buffer): Promise<Buffer> {
packet = Buffer.from(await this._inner.encode(packet))
if (packet.length + this._header.length > MAX_TLS_PACKET_LENGTH) {
const ret: Buffer[] = []
while (packet.length) {
const buf = packet.slice(0, MAX_TLS_PACKET_LENGTH - this._header.length)
packet = packet.slice(buf.length)
ret.push(this._encodeTls(buf))
}
return Buffer.concat(ret)
}
return this._encodeTls(packet)
}
feed(data: Buffer): void {
this._stream = Buffer.concat([this._stream, data])
for (;;) {
if (this._stream.length < 5) return
if (!(this._stream[0] === 0x17 && this._stream[1] === 0x03 && this._stream[2] === 0x03)) {
this.emit('error', new Error('Invalid TLS header'))
return
}
const length = this._stream.readUInt16BE(3)
if (length < this._stream.length - 5) return
this._inner.feed(this._stream.slice(5, length + 5))
this._stream = this._stream.slice(length + 5)
}
}
reset(): void {
this._stream = Buffer.alloc(0)
this._isFirstTls = true
}
}

View file

@ -1,250 +1,250 @@
/* eslint-disable no-restricted-globals */ // /* eslint-disable no-restricted-globals */
// todo fixme // todo fixme
import { connect } from 'node:net' // import { connect } from 'node:net'
import type { // import type {
IPacketCodec, // IPacketCodec,
tl, // tl,
} from '@mtcute/node' // } from '@mtcute/node'
import { // import {
BaseTcpTransport, // BaseTcpTransport,
IntermediatePacketCodec, // IntermediatePacketCodec,
MtSecurityError, // MtSecurityError,
MtUnsupportedError, // MtUnsupportedError,
MtcuteError, // MtcuteError,
ObfuscatedPacketCodec, // ObfuscatedPacketCodec,
PaddedIntermediatePacketCodec, // PaddedIntermediatePacketCodec,
TransportState, // TransportState,
} from '@mtcute/node' // } from '@mtcute/node'
import { buffersEqual } from '@mtcute/node/utils.js' // import { buffersEqual } from '@mtcute/node/utils.js'
import { FakeTlsPacketCodec, generateFakeTlsHeader } from './fake-tls.js' // import { FakeTlsPacketCodec, generateFakeTlsHeader } from './fake-tls.js'
/** // /**
* MTProto proxy settings // * MTProto proxy settings
*/ // */
export interface MtProxySettings { // export interface MtProxySettings {
/** // /**
* Host or IP of the proxy (e.g. `proxy.example.com`, `1.2.3.4`) // * Host or IP of the proxy (e.g. `proxy.example.com`, `1.2.3.4`)
*/ // */
host: string // host: string
/** // /**
* Port of the proxy (e.g. `8888`) // * Port of the proxy (e.g. `8888`)
*/ // */
port: number // port: number
/** // /**
* Secret of the proxy, optionally encoded either as hex or base64 // * Secret of the proxy, optionally encoded either as hex or base64
*/ // */
secret: string | Buffer // secret: string | Buffer
} // }
const MAX_DOMAIN_LENGTH = 182 // must be small enough not to overflow TLS-hello length // const MAX_DOMAIN_LENGTH = 182 // must be small enough not to overflow TLS-hello length
const TLS_START = [Buffer.from('160303', 'hex'), Buffer.from('140303000101170303', 'hex')] // const TLS_START = [Buffer.from('160303', 'hex'), Buffer.from('140303000101170303', 'hex')]
/** // /**
* TCP transport that connects via an MTProxy // * TCP transport that connects via an MTProxy
*/ // */
export class MtProxyTcpTransport extends BaseTcpTransport { // export class MtProxyTcpTransport extends BaseTcpTransport {
readonly _proxy: MtProxySettings // readonly _proxy: MtProxySettings
private _rawSecret: Buffer // private _rawSecret: Buffer
private _randomPadding = false // private _randomPadding = false
private _fakeTlsDomain: string | null = null // private _fakeTlsDomain: string | null = null
/** // /**
* @param proxy Information about the proxy // * @param proxy Information about the proxy
*/ // */
constructor(proxy: MtProxySettings) { // constructor(proxy: MtProxySettings) {
super() // super()
this._proxy = proxy // this._proxy = proxy
// validate and parse secret // // validate and parse secret
let secret: Buffer // let secret: Buffer
if (Buffer.isBuffer(proxy.secret)) { // if (Buffer.isBuffer(proxy.secret)) {
secret = proxy.secret // secret = proxy.secret
} else if (proxy.secret.match(/^[0-9a-f]+$/i)) { // } else if (proxy.secret.match(/^[0-9a-f]+$/i)) {
secret = Buffer.from(proxy.secret, 'hex') // secret = Buffer.from(proxy.secret, 'hex')
} else { // } else {
secret = Buffer.from(proxy.secret, 'base64url') // secret = Buffer.from(proxy.secret, 'base64url')
} // }
if (secret.length > 17 + MAX_DOMAIN_LENGTH) { // if (secret.length > 17 + MAX_DOMAIN_LENGTH) {
throw new MtSecurityError('Invalid secret: too long') // throw new MtSecurityError('Invalid secret: too long')
} // }
if (secret.length < 16) { // if (secret.length < 16) {
throw new MtSecurityError('Invalid secret: too short') // throw new MtSecurityError('Invalid secret: too short')
} // }
if (secret.length === 16) { // if (secret.length === 16) {
this._rawSecret = secret // this._rawSecret = secret
} else if (secret.length === 17 && secret[0] === 0xDD) { // } else if (secret.length === 17 && secret[0] === 0xDD) {
this._rawSecret = secret.slice(1) // this._rawSecret = secret.slice(1)
this._randomPadding = true // this._randomPadding = true
} else if (secret.length >= 18 && secret[0] === 0xEE) { // } else if (secret.length >= 18 && secret[0] === 0xEE) {
this._rawSecret = secret.slice(1, 17) // this._rawSecret = secret.slice(1, 17)
this._fakeTlsDomain = secret.slice(17).toString() // this._fakeTlsDomain = secret.slice(17).toString()
} else { // } else {
throw new MtUnsupportedError('Unsupported secret') // throw new MtUnsupportedError('Unsupported secret')
} // }
} // }
getMtproxyInfo(): tl.RawInputClientProxy { // getMtproxyInfo(): tl.RawInputClientProxy {
return { // return {
_: 'inputClientProxy', // _: 'inputClientProxy',
address: this._proxy.host, // address: this._proxy.host,
port: this._proxy.port, // port: this._proxy.port,
} // }
} // }
_packetCodec!: IPacketCodec // _packetCodec!: IPacketCodec
connect(dc: tl.RawDcOption, testMode: boolean): void { // connect(dc: tl.RawDcOption, testMode: boolean): void {
if (this._state !== TransportState.Idle) { // if (this._state !== TransportState.Idle) {
throw new MtcuteError('Transport is not IDLE') // throw new MtcuteError('Transport is not IDLE')
} // }
if (this._packetCodec && this._currentDc?.id !== dc.id) { // if (this._packetCodec && this._currentDc?.id !== dc.id) {
// dc changed, thus the codec's init will change too // // dc changed, thus the codec's init will change too
// clean up to avoid memory leaks // // clean up to avoid memory leaks
this.packetCodecInitialized = false // this.packetCodecInitialized = false
this._packetCodec.reset() // this._packetCodec.reset()
this._packetCodec.removeAllListeners() // this._packetCodec.removeAllListeners()
delete (this as Partial<MtProxyTcpTransport>)._packetCodec // delete (this as Partial<MtProxyTcpTransport>)._packetCodec
} // }
if (!this._packetCodec) { // if (!this._packetCodec) {
const proxy = { // const proxy = {
dcId: dc.id, // dcId: dc.id,
media: dc.mediaOnly!, // media: dc.mediaOnly!,
test: testMode, // test: testMode,
secret: this._rawSecret, // secret: this._rawSecret,
} // }
if (!this._fakeTlsDomain) { // if (!this._fakeTlsDomain) {
let inner: IPacketCodec // let inner: IPacketCodec
if (this._randomPadding) { // if (this._randomPadding) {
inner = new PaddedIntermediatePacketCodec() // inner = new PaddedIntermediatePacketCodec()
} else { // } else {
inner = new IntermediatePacketCodec() // inner = new IntermediatePacketCodec()
} // }
this._packetCodec = new ObfuscatedPacketCodec(inner, proxy) // this._packetCodec = new ObfuscatedPacketCodec(inner, proxy)
} else { // } else {
this._packetCodec = new FakeTlsPacketCodec( // this._packetCodec = new FakeTlsPacketCodec(
new ObfuscatedPacketCodec(new PaddedIntermediatePacketCodec(), proxy), // new ObfuscatedPacketCodec(new PaddedIntermediatePacketCodec(), proxy),
) // )
} // }
this._packetCodec.setup?.(this._crypto, this.log) // this._packetCodec.setup?.(this._crypto, this.log)
this._packetCodec.on('error', err => this.emit('error', err)) // this._packetCodec.on('error', err => this.emit('error', err))
this._packetCodec.on('packet', buf => this.emit('message', buf)) // this._packetCodec.on('packet', buf => this.emit('message', buf))
} // }
this._state = TransportState.Connecting // this._state = TransportState.Connecting
this._currentDc = dc // this._currentDc = dc
if (this._fakeTlsDomain) { // if (this._fakeTlsDomain) {
this._socket = connect( // this._socket = connect(
this._proxy.port, // this._proxy.port,
this._proxy.host, // this._proxy.host,
// MTQ-55 // // MTQ-55
// eslint-disable-next-line ts/no-misused-promises // // eslint-disable-next-line ts/no-misused-promises
this._handleConnectFakeTls.bind(this), // this._handleConnectFakeTls.bind(this),
) // )
} else { // } else {
this._socket = connect( // this._socket = connect(
this._proxy.port, // this._proxy.port,
this._proxy.host, // this._proxy.host,
// MTQ-55 // // MTQ-55
this.handleConnect.bind(this), // this.handleConnect.bind(this),
) // )
this._socket.on('data', data => this._packetCodec.feed(data)) // this._socket.on('data', data => this._packetCodec.feed(data))
} // }
this._socket.on('error', this.handleError.bind(this)) // this._socket.on('error', this.handleError.bind(this))
this._socket.on('close', this.close.bind(this)) // this._socket.on('close', this.close.bind(this))
} // }
private async _handleConnectFakeTls(): Promise<void> { // private async _handleConnectFakeTls(): Promise<void> {
try { // try {
const hello = await generateFakeTlsHeader(this._fakeTlsDomain!, this._rawSecret, this._crypto) // const hello = await generateFakeTlsHeader(this._fakeTlsDomain!, this._rawSecret, this._crypto)
const helloRand = hello.slice(11, 11 + 32) // const helloRand = hello.slice(11, 11 + 32)
let serverHelloBuffer: Buffer | null = null // let serverHelloBuffer: Buffer | null = null
const checkHelloResponse = async (buf: Buffer): Promise<boolean> => { // const checkHelloResponse = async (buf: Buffer): Promise<boolean> => {
if (serverHelloBuffer) { // if (serverHelloBuffer) {
buf = Buffer.concat([serverHelloBuffer, buf]) // buf = Buffer.concat([serverHelloBuffer, buf])
} // }
const resp = buf // const resp = buf
for (const first of TLS_START) { // for (const first of TLS_START) {
if (buf.length < first.length + 2) { // if (buf.length < first.length + 2) {
throw new MtSecurityError('Server hello is too short') // throw new MtSecurityError('Server hello is too short')
} // }
if (!buffersEqual(buf.slice(0, first.length), first)) { // if (!buffersEqual(buf.slice(0, first.length), first)) {
throw new MtSecurityError('Server hello is invalid') // throw new MtSecurityError('Server hello is invalid')
} // }
buf = buf.slice(first.length) // buf = buf.slice(first.length)
const skipSize = buf.readUInt16BE() // const skipSize = buf.readUInt16BE()
buf = buf.slice(2) // buf = buf.slice(2)
if (buf.length < skipSize) { // if (buf.length < skipSize) {
// likely got split into multiple packets // // likely got split into multiple packets
if (serverHelloBuffer) { // if (serverHelloBuffer) {
throw new MtSecurityError('Server hello is too short') // throw new MtSecurityError('Server hello is too short')
} // }
serverHelloBuffer = resp // serverHelloBuffer = resp
return false // return false
} // }
buf = buf.slice(skipSize) // buf = buf.slice(skipSize)
} // }
const respRand = resp.slice(11, 11 + 32) // const respRand = resp.slice(11, 11 + 32)
const hash = await this._crypto.hmacSha256( // const hash = await this._crypto.hmacSha256(
Buffer.concat([helloRand, resp.slice(0, 11), Buffer.alloc(32, 0), resp.slice(11 + 32)]), // Buffer.concat([helloRand, resp.slice(0, 11), Buffer.alloc(32, 0), resp.slice(11 + 32)]),
this._rawSecret, // this._rawSecret,
) // )
if (!buffersEqual(hash, respRand)) { // if (!buffersEqual(hash, respRand)) {
throw new MtSecurityError('Response hash is invalid') // throw new MtSecurityError('Response hash is invalid')
} // }
return true // return true
} // }
const packetHandler = (buf: Buffer): void => { // const packetHandler = (buf: Buffer): void => {
checkHelloResponse(buf) // checkHelloResponse(buf)
.then((done) => { // .then((done) => {
if (!done) return // if (!done) return
this._socket!.off('data', packetHandler) // this._socket!.off('data', packetHandler)
this._socket!.on('data', (data) => { // this._socket!.on('data', (data) => {
this._packetCodec.feed(data) // this._packetCodec.feed(data)
}) // })
return this.handleConnect() // return this.handleConnect()
}) // })
.catch(err => this._socket!.emit('error', err)) // .catch(err => this._socket!.emit('error', err))
} // }
this._socket!.write(hello) // this._socket!.write(hello)
this._socket!.on('data', packetHandler) // this._socket!.on('data', packetHandler)
} catch (e) { // } catch (e) {
this._socket!.emit('error', e) // this._socket!.emit('error', e)
} // }
} // }
} // }

View file

@ -1,30 +1,31 @@
{ {
"name": "@mtcute/node", "name": "@mtcute/node",
"type": "module", "type": "module",
"version": "0.16.13", "version": "0.16.13",
"private": true, "private": true,
"description": "Meta-package for Node.js", "description": "Meta-package for Node.js",
"author": "alina sireneva <alina@tei.su>", "author": "alina sireneva <alina@tei.su>",
"license": "MIT", "license": "MIT",
"sideEffects": false, "sideEffects": false,
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./utils.js": "./src/utils.ts", "./utils.js": "./src/utils.ts",
"./methods.js": "./src/methods.ts" "./methods.js": "./src/methods.ts"
}, },
"scripts": { "scripts": {
"docs": "typedoc", "docs": "typedoc",
"build": "pnpm run -w build-package node" "build": "pnpm run -w build-package node"
}, },
"dependencies": { "dependencies": {
"@mtcute/core": "workspace:^", "@mtcute/core": "workspace:^",
"@mtcute/html-parser": "workspace:^", "@mtcute/html-parser": "workspace:^",
"@mtcute/markdown-parser": "workspace:^", "@mtcute/markdown-parser": "workspace:^",
"@mtcute/wasm": "workspace:^", "@mtcute/wasm": "workspace:^",
"better-sqlite3": "11.3.0" "@fuman/node-net": "workspace:^",
}, "better-sqlite3": "11.3.0"
"devDependencies": { },
"@mtcute/test": "workspace:^", "devDependencies": {
"@types/better-sqlite3": "7.6.4" "@mtcute/test": "workspace:^",
} "@types/better-sqlite3": "7.6.4"
}
} }

View file

@ -19,6 +19,7 @@ import { downloadAsNodeStream } from './methods/download-node-stream.js'
import { SqliteStorage } from './sqlite/index.js' import { SqliteStorage } from './sqlite/index.js'
import { NodeCryptoProvider } from './utils/crypto.js' import { NodeCryptoProvider } from './utils/crypto.js'
import { TcpTransport } from './utils/tcp.js' import { TcpTransport } from './utils/tcp.js'
// import { TcpTransport } from './utils/tcp.js'
export type { TelegramClientOptions } export type { TelegramClientOptions }
@ -60,7 +61,7 @@ export class BaseTelegramClient extends BaseTelegramClientBase {
super({ super({
// eslint-disable-next-line // eslint-disable-next-line
crypto: nativeCrypto ? new nativeCrypto() : new NodeCryptoProvider(), crypto: nativeCrypto ? new nativeCrypto() : new NodeCryptoProvider(),
transport: () => new TcpTransport(), transport: TcpTransport,
...opts, ...opts,
storage: storage:
typeof opts.storage === 'string' typeof opts.storage === 'string'

View file

@ -1,157 +1,158 @@
import type { Socket } from 'node:net' // import type { Socket } from 'node:net'
import type { MockedObject } from 'vitest' // import type { MockedObject } from 'vitest'
import { describe, expect, it, vi } from 'vitest' // import { describe, expect, it, vi } from 'vitest'
import { TransportState } from '@mtcute/core' // import { TransportState } from '@mtcute/core'
import { getPlatform } from '@mtcute/core/platform.js' // import { getPlatform } from '@mtcute/core/platform.js'
import { LogManager, defaultProductionDc } from '@mtcute/core/utils.js' // import { LogManager, defaultProductionDc } from '@mtcute/core/utils.js'
if (import.meta.env.TEST_ENV === 'node') { // todo: move to fuman
vi.doMock('net', () => ({ // if (import.meta.env.TEST_ENV === 'node') {
connect: vi.fn().mockImplementation((port: number, ip: string, cb: () => void) => { // vi.doMock('net', () => ({
cb() // connect: vi.fn().mockImplementation((port: number, ip: string, cb: () => void) => {
// cb()
return { // return {
on: vi.fn(), // on: vi.fn(),
write: vi.fn().mockImplementation((data: Uint8Array, cb: () => void) => { // write: vi.fn().mockImplementation((data: Uint8Array, cb: () => void) => {
cb() // cb()
}), // }),
end: vi.fn(), // end: vi.fn(),
removeAllListeners: vi.fn(), // removeAllListeners: vi.fn(),
destroy: vi.fn(), // destroy: vi.fn(),
} // }
}), // }),
})) // }))
const net = await import('node:net') // const net = await import('node:net')
const connect = vi.mocked(net.connect) // const connect = vi.mocked(net.connect)
const { TcpTransport } = await import('./tcp.js') // const { TcpTransport } = await import('./tcp.js')
const { defaultTestCryptoProvider, u8HexDecode } = await import('@mtcute/test') // const { defaultTestCryptoProvider, u8HexDecode } = await import('@mtcute/test')
describe('TcpTransport', () => { // describe('TcpTransport', () => {
const getLastSocket = () => { // const getLastSocket = () => {
return connect.mock.results[connect.mock.results.length - 1].value as MockedObject<Socket> // return connect.mock.results[connect.mock.results.length - 1].value as MockedObject<Socket>
} // }
const create = async () => { // const create = async () => {
const transport = new TcpTransport() // const transport = new TcpTransport()
const logger = new LogManager() // const logger = new LogManager()
logger.level = 0 // logger.level = 0
transport.setup(await defaultTestCryptoProvider(), logger) // transport.setup(await defaultTestCryptoProvider(), logger)
return transport // return transport
} // }
it('should initiate a tcp connection to the given dc', async () => { // it('should initiate a tcp connection to the given dc', async () => {
const t = await create() // const t = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
expect(connect).toHaveBeenCalledOnce() // expect(connect).toHaveBeenCalledOnce()
expect(connect).toHaveBeenCalledWith( // expect(connect).toHaveBeenCalledWith(
defaultProductionDc.main.port, // defaultProductionDc.main.port,
defaultProductionDc.main.ipAddress, // defaultProductionDc.main.ipAddress,
expect.any(Function), // expect.any(Function),
) // )
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
}) // })
it('should set up event handlers', async () => { // it('should set up event handlers', async () => {
const t = await create() // const t = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
const socket = getLastSocket() // const socket = getLastSocket()
expect(socket.on).toHaveBeenCalledTimes(3) // expect(socket.on).toHaveBeenCalledTimes(3)
expect(socket.on).toHaveBeenCalledWith('data', expect.any(Function)) // expect(socket.on).toHaveBeenCalledWith('data', expect.any(Function))
expect(socket.on).toHaveBeenCalledWith('error', expect.any(Function)) // expect(socket.on).toHaveBeenCalledWith('error', expect.any(Function))
expect(socket.on).toHaveBeenCalledWith('close', expect.any(Function)) // expect(socket.on).toHaveBeenCalledWith('close', expect.any(Function))
}) // })
it('should write packet codec tag once connected', async () => { // it('should write packet codec tag once connected', async () => {
const t = await create() // const t = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
const socket = getLastSocket() // const socket = getLastSocket()
await vi.waitFor(() => // await vi.waitFor(() =>
expect(socket.write).toHaveBeenCalledWith( // expect(socket.write).toHaveBeenCalledWith(
u8HexDecode('eeeeeeee'), // intermediate // u8HexDecode('eeeeeeee'), // intermediate
expect.any(Function), // expect.any(Function),
), // ),
) // )
}) // })
it('should write to the underlying socket', async () => { // it('should write to the underlying socket', async () => {
const t = await create() // const t = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
await t.send(getPlatform().hexDecode('00010203040506070809')) // await t.send(getPlatform().hexDecode('00010203040506070809'))
const socket = getLastSocket() // const socket = getLastSocket()
expect(socket.write).toHaveBeenCalledWith(u8HexDecode('0a00000000010203040506070809'), expect.any(Function)) // expect(socket.write).toHaveBeenCalledWith(u8HexDecode('0a00000000010203040506070809'), expect.any(Function))
}) // })
it('should correctly close', async () => { // it('should correctly close', async () => {
const t = await create() // const t = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
t.close() // t.close()
const socket = getLastSocket() // const socket = getLastSocket()
expect(socket.removeAllListeners).toHaveBeenCalledOnce() // expect(socket.removeAllListeners).toHaveBeenCalledOnce()
expect(socket.destroy).toHaveBeenCalledOnce() // expect(socket.destroy).toHaveBeenCalledOnce()
}) // })
it('should feed data to the packet codec', async () => { // it('should feed data to the packet codec', async () => {
const t = await create() // const t = await create()
const codec = t._packetCodec // const codec = t._packetCodec
const spyFeed = vi.spyOn(codec, 'feed') // const spyFeed = vi.spyOn(codec, 'feed')
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
const socket = getLastSocket() // const socket = getLastSocket()
const onDataCall = socket.on.mock.calls.find(c => (c as string[])[0] === 'data') as unknown as [ // const onDataCall = socket.on.mock.calls.find(c => (c as string[])[0] === 'data') as unknown as [
string, // string,
(data: Uint8Array) => void, // (data: Uint8Array) => void,
] // ]
onDataCall[1](u8HexDecode('00010203040506070809')) // onDataCall[1](u8HexDecode('00010203040506070809'))
expect(spyFeed).toHaveBeenCalledWith(u8HexDecode('00010203040506070809')) // expect(spyFeed).toHaveBeenCalledWith(u8HexDecode('00010203040506070809'))
}) // })
it('should propagate errors', async () => { // it('should propagate errors', async () => {
const t = await create() // const t = await create()
const spyEmit = vi.fn() // const spyEmit = vi.fn()
t.on('error', spyEmit) // t.on('error', spyEmit)
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
const socket = getLastSocket() // const socket = getLastSocket()
const onErrorCall = socket.on.mock.calls.find(c => (c as string[])[0] === 'error') as unknown as [ // const onErrorCall = socket.on.mock.calls.find(c => (c as string[])[0] === 'error') as unknown as [
string, // string,
(error: Error) => void, // (error: Error) => void,
] // ]
onErrorCall[1](new Error('test error')) // onErrorCall[1](new Error('test error'))
expect(spyEmit).toHaveBeenCalledWith(new Error('test error')) // expect(spyEmit).toHaveBeenCalledWith(new Error('test error'))
}) // })
}) // })
} else { // } else {
describe.skip('TcpTransport', () => {}) // describe.skip('TcpTransport', () => {})
} // }

View file

@ -1,140 +1,8 @@
import EventEmitter from 'node:events' import { connectTcp } from '@fuman/node-net'
import type { Socket } from 'node:net' import type { TelegramTransport } from '@mtcute/core'
import { connect } from 'node:net' import { IntermediatePacketCodec } from '@mtcute/core'
import type { IPacketCodec, ITelegramTransport } from '@mtcute/core' export const TcpTransport: TelegramTransport = {
import { IntermediatePacketCodec, MtcuteError, TransportState } from '@mtcute/core' connect: dc => connectTcp({ address: dc.ipAddress, port: dc.port }),
import type { BasicDcOption, ICryptoProvider, Logger } from '@mtcute/core/utils.js' packetCodec: () => new IntermediatePacketCodec(),
/**
* Base for TCP transports.
* Subclasses must provide packet codec in `_packetCodec` property
*/
export abstract class BaseTcpTransport extends EventEmitter implements ITelegramTransport {
protected _currentDc: BasicDcOption | null = null
protected _state: TransportState = TransportState.Idle
protected _socket: Socket | null = null
abstract _packetCodec: IPacketCodec
protected _crypto!: ICryptoProvider
protected log!: Logger
packetCodecInitialized = false
private _updateLogPrefix() {
if (this._currentDc) {
this.log.prefix = `[TCP:${this._currentDc.ipAddress}:${this._currentDc.port}] `
} else {
this.log.prefix = '[TCP:disconnected] '
}
}
setup(crypto: ICryptoProvider, log: Logger): void {
this._crypto = crypto
this.log = log.create('tcp')
this._updateLogPrefix()
}
state(): TransportState {
return this._state
}
currentDc(): BasicDcOption | null {
return this._currentDc
}
// eslint-disable-next-line unused-imports/no-unused-vars
connect(dc: BasicDcOption, testMode: boolean): void {
if (this._state !== TransportState.Idle) {
throw new MtcuteError('Transport is not IDLE')
}
if (!this.packetCodecInitialized) {
this._packetCodec.setup?.(this._crypto, this.log)
this._packetCodec.on('error', err => this.emit('error', err))
this._packetCodec.on('packet', buf => this.emit('message', buf))
this.packetCodecInitialized = true
}
this._state = TransportState.Connecting
this._currentDc = dc
this._updateLogPrefix()
this.log.debug('connecting to %j', dc)
this._socket = connect(dc.port, dc.ipAddress, this.handleConnect.bind(this))
this._socket.on('data', (data) => {
this._packetCodec.feed(data)
})
this._socket.on('error', this.handleError.bind(this))
this._socket.on('close', this.close.bind(this))
}
close(): void {
if (this._state === TransportState.Idle) return
this.log.info('connection closed')
this._state = TransportState.Idle
this._socket!.removeAllListeners()
this._socket!.destroy()
this._socket = null
this._currentDc = null
this._packetCodec.reset()
this.emit('close')
}
handleError(error: Error): void {
this.log.error('error: %s', error.stack)
if (this.listenerCount('error') > 0) {
this.emit('error', error)
}
}
handleConnect(): void {
this.log.info('connected')
Promise.resolve(this._packetCodec.tag())
.then((initialMessage) => {
if (initialMessage.length) {
this._socket!.write(initialMessage, (err) => {
if (err) {
this.log.error('failed to write initial message: %s', err.stack)
this.emit('error')
this.close()
} else {
this._state = TransportState.Ready
this.emit('ready')
}
})
} else {
this._state = TransportState.Ready
this.emit('ready')
}
})
.catch(err => this.emit('error', err))
}
async send(bytes: Uint8Array): Promise<void> {
const framed = await this._packetCodec.encode(bytes)
if (this._state !== TransportState.Ready) {
throw new MtcuteError('Transport is not READY')
}
return new Promise((resolve, reject) => {
this._socket!.write(framed, (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
}
export class TcpTransport extends BaseTcpTransport {
_packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec()
} }

View file

@ -1,419 +1,420 @@
import { connect } from 'node:net' // todo: move to fuman
// import { connect } from 'node:net'
// @ts-expect-error no typings
import { normalize } from 'ip6' // // @ts-expect-error no typings
import type { tl } from '@mtcute/node' // import { normalize } from 'ip6'
import { BaseTcpTransport, IntermediatePacketCodec, MtArgumentError, NodePlatform, TransportState, assertNever } from '@mtcute/node' // import type { tl } from '@mtcute/node'
import { dataViewFromBuffer } from '@mtcute/node/utils.js' // import { BaseTcpTransport, IntermediatePacketCodec, MtArgumentError, NodePlatform, TransportState, assertNever } from '@mtcute/node'
// import { dataViewFromBuffer } from '@mtcute/node/utils.js'
const p = new NodePlatform()
// const p = new NodePlatform()
/**
* An error has occurred while connecting to an SOCKS proxy // /**
*/ // * An error has occurred while connecting to an SOCKS proxy
export class SocksProxyConnectionError extends Error { // */
readonly proxy: SocksProxySettings // export class SocksProxyConnectionError extends Error {
// readonly proxy: SocksProxySettings
constructor(proxy: SocksProxySettings, message: string) {
super(`Error while connecting to ${proxy.host}:${proxy.port}: ${message}`) // constructor(proxy: SocksProxySettings, message: string) {
this.proxy = proxy // super(`Error while connecting to ${proxy.host}:${proxy.port}: ${message}`)
} // this.proxy = proxy
} // }
// }
/**
* Settings for a SOCKS4/5 proxy // /**
*/ // * Settings for a SOCKS4/5 proxy
export interface SocksProxySettings { // */
/** // export interface SocksProxySettings {
* Host or IP of the proxy (e.g. `proxy.example.com`, `1.2.3.4`) // /**
*/ // * Host or IP of the proxy (e.g. `proxy.example.com`, `1.2.3.4`)
host: string // */
// host: string
/**
* Port of the proxy (e.g. `8888`) // /**
*/ // * Port of the proxy (e.g. `8888`)
port: number // */
// port: number
/**
* Proxy authorization username, if needed // /**
*/ // * Proxy authorization username, if needed
user?: string // */
// user?: string
/**
* Proxy authorization password, if needed // /**
*/ // * Proxy authorization password, if needed
password?: string // */
// password?: string
/**
* Version of the SOCKS proxy (4 or 5) // /**
* // * Version of the SOCKS proxy (4 or 5)
* @default `5` // *
*/ // * @default `5`
version?: 4 | 5 // */
} // version?: 4 | 5
// }
function writeIpv4(ip: string, buf: Uint8Array, offset: number): void {
const parts = ip.split('.') // function writeIpv4(ip: string, buf: Uint8Array, offset: number): void {
// const parts = ip.split('.')
if (parts.length !== 4) {
throw new MtArgumentError('Invalid IPv4 address') // if (parts.length !== 4) {
} // throw new MtArgumentError('Invalid IPv4 address')
for (let i = 0; i < 4; i++) { // }
const n = Number.parseInt(parts[i]) // for (let i = 0; i < 4; i++) {
// const n = Number.parseInt(parts[i])
if (Number.isNaN(n) || n < 0 || n > 255) {
throw new MtArgumentError('Invalid IPv4 address') // if (Number.isNaN(n) || n < 0 || n > 255) {
} // throw new MtArgumentError('Invalid IPv4 address')
// }
buf[offset + i] = n
} // buf[offset + i] = n
} // }
// }
function buildSocks4ConnectRequest(ip: string, port: number, username = ''): Uint8Array {
const userId = p.utf8Encode(username) // function buildSocks4ConnectRequest(ip: string, port: number, username = ''): Uint8Array {
const buf = new Uint8Array(9 + userId.length) // const userId = p.utf8Encode(username)
// const buf = new Uint8Array(9 + userId.length)
buf[0] = 0x04 // VER
buf[1] = 0x01 // CMD = establish a TCP/IP stream connection // buf[0] = 0x04 // VER
dataViewFromBuffer(buf).setUint16(2, port, false) // buf[1] = 0x01 // CMD = establish a TCP/IP stream connection
writeIpv4(ip, buf, 4) // DSTIP // dataViewFromBuffer(buf).setUint16(2, port, false)
buf.set(userId, 8) // writeIpv4(ip, buf, 4) // DSTIP
buf[8 + userId.length] = 0x00 // ID (null-termination) // buf.set(userId, 8)
// buf[8 + userId.length] = 0x00 // ID (null-termination)
return buf
} // return buf
// }
function buildSocks5Greeting(authAvailable: boolean): Uint8Array {
const buf = new Uint8Array(authAvailable ? 4 : 3) // function buildSocks5Greeting(authAvailable: boolean): Uint8Array {
// const buf = new Uint8Array(authAvailable ? 4 : 3)
buf[0] = 0x05 // VER
// buf[0] = 0x05 // VER
if (authAvailable) {
buf[1] = 0x02 // NAUTH // if (authAvailable) {
buf[2] = 0x00 // AUTH[0] = No authentication // buf[1] = 0x02 // NAUTH
buf[3] = 0x02 // AUTH[1] = Username/password // buf[2] = 0x00 // AUTH[0] = No authentication
} else { // buf[3] = 0x02 // AUTH[1] = Username/password
buf[1] = 0x01 // NAUTH // } else {
buf[2] = 0x00 // AUTH[0] = No authentication // buf[1] = 0x01 // NAUTH
} // buf[2] = 0x00 // AUTH[0] = No authentication
// }
return buf
} // return buf
// }
function buildSocks5Auth(username: string, password: string) {
const usernameBuf = p.utf8Encode(username) // function buildSocks5Auth(username: string, password: string) {
const passwordBuf = p.utf8Encode(password) // const usernameBuf = p.utf8Encode(username)
// const passwordBuf = p.utf8Encode(password)
if (usernameBuf.length > 255) {
throw new MtArgumentError(`Too long username (${usernameBuf.length} > 255)`) // if (usernameBuf.length > 255) {
} // throw new MtArgumentError(`Too long username (${usernameBuf.length} > 255)`)
if (passwordBuf.length > 255) { // }
throw new MtArgumentError(`Too long password (${passwordBuf.length} > 255)`) // if (passwordBuf.length > 255) {
} // throw new MtArgumentError(`Too long password (${passwordBuf.length} > 255)`)
// }
const buf = new Uint8Array(3 + usernameBuf.length + passwordBuf.length)
buf[0] = 0x01 // VER of auth // const buf = new Uint8Array(3 + usernameBuf.length + passwordBuf.length)
buf[1] = usernameBuf.length // buf[0] = 0x01 // VER of auth
buf.set(usernameBuf, 2) // buf[1] = usernameBuf.length
buf[2 + usernameBuf.length] = passwordBuf.length // buf.set(usernameBuf, 2)
buf.set(passwordBuf, 3 + usernameBuf.length) // buf[2 + usernameBuf.length] = passwordBuf.length
// buf.set(passwordBuf, 3 + usernameBuf.length)
return buf
} // return buf
// }
function writeIpv6(ip: string, buf: Uint8Array, offset: number): void {
// eslint-disable-next-line ts/no-unsafe-call // function writeIpv6(ip: string, buf: Uint8Array, offset: number): void {
ip = normalize(ip) as string // // eslint-disable-next-line ts/no-unsafe-call
const parts = ip.split(':') // ip = normalize(ip) as string
// const parts = ip.split(':')
if (parts.length !== 8) {
throw new MtArgumentError('Invalid IPv6 address') // if (parts.length !== 8) {
} // throw new MtArgumentError('Invalid IPv6 address')
// }
const dv = dataViewFromBuffer(buf)
// const dv = dataViewFromBuffer(buf)
for (let i = 0, j = offset; i < 8; i++, j += 2) {
const n = Number.parseInt(parts[i]) // for (let i = 0, j = offset; i < 8; i++, j += 2) {
// const n = Number.parseInt(parts[i])
if (Number.isNaN(n) || n < 0 || n > 0xFFFF) {
throw new MtArgumentError('Invalid IPv6 address') // if (Number.isNaN(n) || n < 0 || n > 0xFFFF) {
} // throw new MtArgumentError('Invalid IPv6 address')
// }
dv.setUint16(j, n, false)
} // dv.setUint16(j, n, false)
} // }
// }
function buildSocks5Connect(ip: string, port: number, ipv6 = false): Uint8Array {
const buf = new Uint8Array(ipv6 ? 22 : 10) // function buildSocks5Connect(ip: string, port: number, ipv6 = false): Uint8Array {
const dv = dataViewFromBuffer(buf) // const buf = new Uint8Array(ipv6 ? 22 : 10)
// const dv = dataViewFromBuffer(buf)
buf[0] = 0x05 // VER
buf[1] = 0x01 // CMD = establish a TCP/IP stream connection // buf[0] = 0x05 // VER
buf[2] = 0x00 // RSV // buf[1] = 0x01 // CMD = establish a TCP/IP stream connection
// buf[2] = 0x00 // RSV
if (ipv6) {
buf[3] = 0x04 // TYPE = IPv6 // if (ipv6) {
writeIpv6(ip, buf, 4) // ADDR // buf[3] = 0x04 // TYPE = IPv6
dv.setUint16(20, port, false) // writeIpv6(ip, buf, 4) // ADDR
} else { // dv.setUint16(20, port, false)
buf[3] = 0x01 // TYPE = IPv4 // } else {
writeIpv4(ip, buf, 4) // ADDR // buf[3] = 0x01 // TYPE = IPv4
dv.setUint16(8, port, false) // writeIpv4(ip, buf, 4) // ADDR
} // dv.setUint16(8, port, false)
// }
return buf
} // return buf
// }
const SOCKS4_ERRORS: Record<number, string> = {
91: 'Request rejected or failed', // const SOCKS4_ERRORS: Record<number, string> = {
92: 'Request failed because client is not running identd', // 91: 'Request rejected or failed',
93: "Request failed because client's identd could not confirm the user ID in the request", // 92: 'Request failed because client is not running identd',
} // 93: "Request failed because client's identd could not confirm the user ID in the request",
// }
const SOCKS5_ERRORS: Record<number, string> = {
1: 'General failure', // const SOCKS5_ERRORS: Record<number, string> = {
2: 'Connection not allowed by ruleset', // 1: 'General failure',
3: 'Network unreachable', // 2: 'Connection not allowed by ruleset',
4: 'Host unreachable', // 3: 'Network unreachable',
5: 'Connection refused by destination host', // 4: 'Host unreachable',
6: 'TTL expired', // 5: 'Connection refused by destination host',
7: 'Command not supported / protocol error', // 6: 'TTL expired',
8: 'Address type not supported', // 7: 'Command not supported / protocol error',
} // 8: 'Address type not supported',
// }
/**
* TCP transport that connects via a SOCKS4/5 proxy. // /**
*/ // * TCP transport that connects via a SOCKS4/5 proxy.
export abstract class BaseSocksTcpTransport extends BaseTcpTransport { // */
readonly _proxy: SocksProxySettings // export abstract class BaseSocksTcpTransport extends BaseTcpTransport {
// readonly _proxy: SocksProxySettings
constructor(proxy: SocksProxySettings) {
super() // constructor(proxy: SocksProxySettings) {
// super()
if (proxy.version != null && proxy.version !== 4 && proxy.version !== 5) {
throw new SocksProxyConnectionError( // if (proxy.version != null && proxy.version !== 4 && proxy.version !== 5) {
proxy, // throw new SocksProxyConnectionError(
`Invalid SOCKS version: ${proxy.version}`, // proxy,
) // `Invalid SOCKS version: ${proxy.version}`,
} // )
// }
this._proxy = proxy
} // this._proxy = proxy
// }
connect(dc: tl.RawDcOption): void {
if (this._state !== TransportState.Idle) { // connect(dc: tl.RawDcOption): void {
throw new MtArgumentError('Transport is not IDLE') // if (this._state !== TransportState.Idle) {
} // throw new MtArgumentError('Transport is not IDLE')
// }
if (!this.packetCodecInitialized) {
this._packetCodec.on('error', err => this.emit('error', err)) // if (!this.packetCodecInitialized) {
this._packetCodec.on('packet', buf => this.emit('message', buf)) // this._packetCodec.on('error', err => this.emit('error', err))
this.packetCodecInitialized = true // this._packetCodec.on('packet', buf => this.emit('message', buf))
} // this.packetCodecInitialized = true
// }
this._state = TransportState.Connecting
this._currentDc = dc // this._state = TransportState.Connecting
// this._currentDc = dc
this._socket = connect(this._proxy.port, this._proxy.host, this._onProxyConnected.bind(this))
// this._socket = connect(this._proxy.port, this._proxy.host, this._onProxyConnected.bind(this))
this._socket.on('error', this.handleError.bind(this))
this._socket.on('close', this.close.bind(this)) // this._socket.on('error', this.handleError.bind(this))
} // this._socket.on('close', this.close.bind(this))
// }
private _onProxyConnected() {
let packetHandler: (msg: Uint8Array) => void // private _onProxyConnected() {
// let packetHandler: (msg: Uint8Array) => void
if (this._proxy.version === 4) {
packetHandler = (msg) => { // if (this._proxy.version === 4) {
if (msg[0] !== 0x04) { // packetHandler = (msg) => {
// VER, must be 4 // if (msg[0] !== 0x04) {
this._socket!.emit( // // VER, must be 4
'error', // this._socket!.emit(
new SocksProxyConnectionError(this._proxy, `Server returned version ${msg[0]}`), // 'error',
) // new SocksProxyConnectionError(this._proxy, `Server returned version ${msg[0]}`),
// )
return
} // return
const code = msg[1] // }
// const code = msg[1]
this.log.debug('[%s:%d] CONNECT returned code %d', this._proxy.host, this._proxy.port, code)
// this.log.debug('[%s:%d] CONNECT returned code %d', this._proxy.host, this._proxy.port, code)
if (code === 0x5A) {
this._socket!.off('data', packetHandler) // if (code === 0x5A) {
this._socket!.on('data', data => this._packetCodec.feed(data)) // this._socket!.off('data', packetHandler)
this.handleConnect() // this._socket!.on('data', data => this._packetCodec.feed(data))
} else { // this.handleConnect()
const msg // } else {
= code in SOCKS4_ERRORS ? SOCKS4_ERRORS[code] : `Unknown error code: 0x${code.toString(16)}` // const msg
this._socket!.emit('error', new SocksProxyConnectionError(this._proxy, msg)) // = code in SOCKS4_ERRORS ? SOCKS4_ERRORS[code] : `Unknown error code: 0x${code.toString(16)}`
} // this._socket!.emit('error', new SocksProxyConnectionError(this._proxy, msg))
} // }
// }
this.log.debug('[%s:%d] connected to proxy, sending CONNECT', this._proxy.host, this._proxy.port)
// this.log.debug('[%s:%d] connected to proxy, sending CONNECT', this._proxy.host, this._proxy.port)
try {
this._socket!.write( // try {
buildSocks4ConnectRequest(this._currentDc!.ipAddress, this._currentDc!.port, this._proxy.user), // this._socket!.write(
) // buildSocks4ConnectRequest(this._currentDc!.ipAddress, this._currentDc!.port, this._proxy.user),
} catch (e) { // )
this._socket!.emit('error', e) // } catch (e) {
} // this._socket!.emit('error', e)
} else { // }
let state: 'greeting' | 'auth' | 'connect' = 'greeting' // } else {
// let state: 'greeting' | 'auth' | 'connect' = 'greeting'
const sendConnect = () => {
this.log.debug('[%s:%d] sending CONNECT', this._proxy.host, this._proxy.port) // const sendConnect = () => {
// this.log.debug('[%s:%d] sending CONNECT', this._proxy.host, this._proxy.port)
try {
this._socket!.write( // try {
buildSocks5Connect(this._currentDc!.ipAddress, this._currentDc!.port, this._currentDc!.ipv6), // this._socket!.write(
) // buildSocks5Connect(this._currentDc!.ipAddress, this._currentDc!.port, this._currentDc!.ipv6),
state = 'connect' // )
} catch (e) { // state = 'connect'
this._socket!.emit('error', e) // } catch (e) {
} // this._socket!.emit('error', e)
} // }
// }
packetHandler = (msg) => {
switch (state) { // packetHandler = (msg) => {
case 'greeting': { // switch (state) {
if (msg[0] !== 0x05) { // case 'greeting': {
// VER, must be 5 // if (msg[0] !== 0x05) {
this._socket!.emit( // // VER, must be 5
'error', // this._socket!.emit(
new SocksProxyConnectionError(this._proxy, `Server returned version ${msg[0]}`), // 'error',
) // new SocksProxyConnectionError(this._proxy, `Server returned version ${msg[0]}`),
// )
return
} // return
// }
const chosen = msg[1]
// const chosen = msg[1]
this.log.debug(
'[%s:%d] GREETING returned auth method %d', // this.log.debug(
this._proxy.host, // '[%s:%d] GREETING returned auth method %d',
this._proxy.port, // this._proxy.host,
chosen, // this._proxy.port,
) // chosen,
// )
switch (chosen) {
case 0x00: // switch (chosen) {
// "No authentication" // case 0x00:
sendConnect() // // "No authentication"
break // sendConnect()
case 0x02: // break
// Username/password // case 0x02:
if (!this._proxy.user || !this._proxy.password) { // // Username/password
// should not happen // if (!this._proxy.user || !this._proxy.password) {
this._socket!.emit( // // should not happen
'error', // this._socket!.emit(
new SocksProxyConnectionError( // 'error',
this._proxy, // new SocksProxyConnectionError(
'Authentication is required, but not provided', // this._proxy,
), // 'Authentication is required, but not provided',
) // ),
break // )
} // break
// }
try {
this._socket!.write(buildSocks5Auth(this._proxy.user, this._proxy.password)) // try {
state = 'auth' // this._socket!.write(buildSocks5Auth(this._proxy.user, this._proxy.password))
} catch (e) { // state = 'auth'
this._socket!.emit('error', e) // } catch (e) {
} // this._socket!.emit('error', e)
break // }
case 0xFF: // break
default: // case 0xFF:
// "no acceptable methods were offered" // default:
this._socket!.emit( // // "no acceptable methods were offered"
'error', // this._socket!.emit(
new SocksProxyConnectionError( // 'error',
this._proxy, // new SocksProxyConnectionError(
'Authentication is required, but not provided/supported', // this._proxy,
), // 'Authentication is required, but not provided/supported',
) // ),
break // )
} // break
// }
break
} // break
case 'auth': // }
if (msg[0] !== 0x01) { // case 'auth':
// VER of auth, must be 1 // if (msg[0] !== 0x01) {
this._socket!.emit( // // VER of auth, must be 1
'error', // this._socket!.emit(
new SocksProxyConnectionError(this._proxy, `Server returned version ${msg[0]}`), // 'error',
) // new SocksProxyConnectionError(this._proxy, `Server returned version ${msg[0]}`),
// )
return
} // return
// }
this.log.debug('[%s:%d] AUTH returned code %d', this._proxy.host, this._proxy.port, msg[1])
// this.log.debug('[%s:%d] AUTH returned code %d', this._proxy.host, this._proxy.port, msg[1])
if (msg[1] === 0x00) {
// success // if (msg[1] === 0x00) {
sendConnect() // // success
} else { // sendConnect()
this._socket!.emit( // } else {
'error', // this._socket!.emit(
new SocksProxyConnectionError(this._proxy, 'Authentication failure'), // 'error',
) // new SocksProxyConnectionError(this._proxy, 'Authentication failure'),
} // )
break // }
// break
case 'connect': {
if (msg[0] !== 0x05) { // case 'connect': {
// VER, must be 5 // if (msg[0] !== 0x05) {
this._socket!.emit( // // VER, must be 5
'error', // this._socket!.emit(
new SocksProxyConnectionError(this._proxy, `Server returned version ${msg[0]}`), // 'error',
) // new SocksProxyConnectionError(this._proxy, `Server returned version ${msg[0]}`),
// )
return
} // return
// }
const code = msg[1]
// const code = msg[1]
this.log.debug('[%s:%d] CONNECT returned code %d', this._proxy.host, this._proxy.port, code)
// this.log.debug('[%s:%d] CONNECT returned code %d', this._proxy.host, this._proxy.port, code)
if (code === 0x00) {
// Request granted // if (code === 0x00) {
this._socket!.off('data', packetHandler) // // Request granted
this._socket!.on('data', data => this._packetCodec.feed(data)) // this._socket!.off('data', packetHandler)
this.handleConnect() // this._socket!.on('data', data => this._packetCodec.feed(data))
} else { // this.handleConnect()
const msg // } else {
= code in SOCKS5_ERRORS // const msg
? SOCKS5_ERRORS[code] // = code in SOCKS5_ERRORS
: `Unknown error code: 0x${code.toString(16)}` // ? SOCKS5_ERRORS[code]
this._socket!.emit('error', new SocksProxyConnectionError(this._proxy, msg)) // : `Unknown error code: 0x${code.toString(16)}`
} // this._socket!.emit('error', new SocksProxyConnectionError(this._proxy, msg))
break // }
} // break
default: // }
assertNever(state) // default:
} // assertNever(state)
} // }
// }
this.log.debug('[%s:%d] connected to proxy, sending GREETING', this._proxy.host, this._proxy.port)
// this.log.debug('[%s:%d] connected to proxy, sending GREETING', this._proxy.host, this._proxy.port)
try {
this._socket!.write(buildSocks5Greeting(Boolean(this._proxy.user && this._proxy.password))) // try {
} catch (e) { // this._socket!.write(buildSocks5Greeting(Boolean(this._proxy.user && this._proxy.password)))
this._socket!.emit('error', e) // } catch (e) {
} // this._socket!.emit('error', e)
} // }
// }
this._socket!.on('data', packetHandler)
} // this._socket!.on('data', packetHandler)
} // }
// }
/**
* Socks TCP transport using an intermediate packet codec. // /**
* // * Socks TCP transport using an intermediate packet codec.
* Should be the one passed as `transport` to `TelegramClient` constructor // *
* (unless you want to use a custom codec). // * Should be the one passed as `transport` to `TelegramClient` constructor
*/ // * (unless you want to use a custom codec).
export class SocksTcpTransport extends BaseSocksTcpTransport { // */
_packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec() // export class SocksTcpTransport extends BaseSocksTcpTransport {
} // _packetCodec: IntermediatePacketCodec = new IntermediatePacketCodec()
// }

View file

@ -1,11 +1,11 @@
import type { MaybePromise, MustEqual, RpcCallOptions } from '@mtcute/core' import type { MaybePromise, MustEqual, RpcCallOptions } from '@mtcute/core'
import { tl } from '@mtcute/core' import { IntermediatePacketCodec, tl } from '@mtcute/core'
import type { BaseTelegramClientOptions } from '@mtcute/core/client.js' import type { BaseTelegramClientOptions } from '@mtcute/core/client.js'
import { BaseTelegramClient } from '@mtcute/core/client.js' import { BaseTelegramClient } from '@mtcute/core/client.js'
import { defaultCryptoProvider } from './platform.js' import { defaultCryptoProvider } from './platform.js'
import { StubMemoryTelegramStorage } from './storage.js' import { StubMemoryTelegramStorage } from './storage.js'
import { StubTelegramTransport } from './transport.js' // import { StubTelegramTransport } from './transport.js'
import type { InputResponder } from './types.js' import type { InputResponder } from './types.js'
import { markedIdToPeer } from './utils.js' import { markedIdToPeer } from './utils.js'
@ -29,27 +29,32 @@ export class StubTelegramClient extends BaseTelegramClient {
logLevel: 5, logLevel: 5,
storage, storage,
disableUpdates: true, disableUpdates: true,
transport: () => { transport: {
const transport = new StubTelegramTransport({ connect: () => {
onMessage: (data) => { // const transport = new StubTelegramTransport({
if (!this._onRawMessage) { // onMessage: (data) => {
if (this._responders.size) { // if (!this._onRawMessage) {
this.emitError(new Error('Unexpected outgoing message')) // if (this._responders.size) {
} // this.emitError(new Error('Unexpected outgoing message'))
// }
return // return
} // }
const dcId = transport._currentDc!.id // const dcId = transport._currentDc!.id
const key = storage.authKeys.get(dcId) // const key = storage.authKeys.get(dcId)
if (key) { // if (key) {
this._onRawMessage(storage.decryptOutgoingMessage(transport._crypto, data, dcId)) // this._onRawMessage(storage.decryptOutgoingMessage(transport._crypto, data, dcId))
} // }
}, // },
}) // })
return transport // return transport
// todo: fuman
throw new Error('not implemented')
},
packetCodec: () => new IntermediatePacketCodec(),
}, },
crypto: defaultCryptoProvider, crypto: defaultCryptoProvider,
...params, ...params,

View file

@ -4,5 +4,4 @@ export * from './platform.js'
export * from './storage.js' export * from './storage.js'
export * from './storage/index.js' export * from './storage/index.js'
export * from './stub.js' export * from './stub.js'
export * from './transport.js'
export * from './types.js' export * from './types.js'

View file

@ -1,44 +1,45 @@
import { describe, expect, it, vi } from 'vitest' // todo: fuman
import { MemoryStorage } from '@mtcute/core' // import { describe, expect, it, vi } from 'vitest'
import { BaseTelegramClient } from '@mtcute/core/client.js' // import { MemoryStorage } from '@mtcute/core'
// import { BaseTelegramClient } from '@mtcute/core/client.js'
import { defaultCryptoProvider } from './platform.js' // import { defaultCryptoProvider } from './platform.js'
import { createStub } from './stub.js' // import { createStub } from './stub.js'
import { StubTelegramTransport } from './transport.js' // import { StubTelegramTransport } from './transport.js'
describe('transport stub', () => { // describe('transport stub', () => {
it('should correctly intercept calls', async () => { // it('should correctly intercept calls', async () => {
const log: string[] = [] // const log: string[] = []
const client = new BaseTelegramClient({ // const client = new BaseTelegramClient({
apiId: 0, // apiId: 0,
apiHash: '', // apiHash: '',
logLevel: 0, // logLevel: 0,
defaultDcs: { // defaultDcs: {
main: createStub('dcOption', { ipAddress: '1.2.3.4', port: 1234 }), // main: createStub('dcOption', { ipAddress: '1.2.3.4', port: 1234 }),
media: createStub('dcOption', { ipAddress: '1.2.3.4', port: 5678 }), // media: createStub('dcOption', { ipAddress: '1.2.3.4', port: 5678 }),
}, // },
storage: new MemoryStorage(), // storage: new MemoryStorage(),
crypto: defaultCryptoProvider, // crypto: defaultCryptoProvider,
transport: () => // transport: () =>
new StubTelegramTransport({ // new StubTelegramTransport({
onConnect: (dc, testMode) => { // onConnect: (dc, testMode) => {
log.push(`connect ${dc.ipAddress}:${dc.port} test=${testMode}`) // log.push(`connect ${dc.ipAddress}:${dc.port} test=${testMode}`)
client.close().catch(() => {}) // client.close().catch(() => {})
}, // },
onMessage(msg) { // onMessage(msg) {
log.push(`message size=${msg.length}`) // log.push(`message size=${msg.length}`)
}, // },
}), // }),
}) // })
client.connect().catch(() => {}) // ignore "client closed" error // client.connect().catch(() => {}) // ignore "client closed" error
await vi.waitFor(() => // await vi.waitFor(() =>
expect(log).toEqual([ // expect(log).toEqual([
'message size=40', // req_pq_multi // 'message size=40', // req_pq_multi
'connect 1.2.3.4:1234 test=false', // 'connect 1.2.3.4:1234 test=false',
]), // ]),
) // )
}) // })
}) // })

View file

@ -1,68 +1,68 @@
// eslint-disable-next-line unicorn/prefer-node-protocol // todo: implement in fuman
import EventEmitter from 'events' // import EventEmitter from 'node:events'
import type { ITelegramTransport } from '@mtcute/core' // import type { ITelegramTransport } from '@mtcute/core'
import { TransportState } from '@mtcute/core' // import { TransportState } from '@mtcute/core'
import type { ICryptoProvider, Logger } from '@mtcute/core/utils.js' // import type { ICryptoProvider, Logger } from '@mtcute/core/utils.js'
import type { tl } from '@mtcute/tl' // import type { tl } from '@mtcute/tl'
export class StubTelegramTransport extends EventEmitter implements ITelegramTransport { // export class StubTelegramTransport extends EventEmitter implements ITelegramConnection {
constructor( // constructor(
readonly params: { // readonly params: {
getMtproxyInfo?: () => tl.RawInputClientProxy // getMtproxyInfo?: () => tl.RawInputClientProxy
onConnect?: (dc: tl.RawDcOption, testMode: boolean) => void // onConnect?: (dc: tl.RawDcOption, testMode: boolean) => void
onClose?: () => void // onClose?: () => void
onMessage?: (msg: Uint8Array) => void // onMessage?: (msg: Uint8Array) => void
}, // },
) { // ) {
super() // super()
if (params.getMtproxyInfo) { // if (params.getMtproxyInfo) {
(this as unknown as ITelegramTransport).getMtproxyInfo = params.getMtproxyInfo // (this as unknown as ITelegramTransport).getMtproxyInfo = params.getMtproxyInfo
} // }
} // }
_state: TransportState = TransportState.Idle // _state: TransportState = TransportState.Idle
_currentDc: tl.RawDcOption | null = null // _currentDc: tl.RawDcOption | null = null
_crypto!: ICryptoProvider // _crypto!: ICryptoProvider
_log!: Logger // _log!: Logger
write(data: Uint8Array): void { // write(data: Uint8Array): void {
this.emit('message', data) // this.emit('message', data)
} // }
setup(crypto: ICryptoProvider, log: Logger): void { // setup(crypto: ICryptoProvider, log: Logger): void {
this._crypto = crypto // this._crypto = crypto
this._log = log // this._log = log
} // }
state(): TransportState { // state(): TransportState {
return this._state // return this._state
} // }
currentDc(): tl.RawDcOption | null { // currentDc(): tl.RawDcOption | null {
return this._currentDc // return this._currentDc
} // }
connect(dc: tl.RawDcOption, testMode: boolean): void { // connect(dc: tl.RawDcOption, testMode: boolean): void {
this._currentDc = dc // this._currentDc = dc
this._state = TransportState.Ready // this._state = TransportState.Ready
this.emit('ready') // this.emit('ready')
this._log.debug('stubbing connection to %s:%d', dc.ipAddress, dc.port) // this._log.debug('stubbing connection to %s:%d', dc.ipAddress, dc.port)
this.params.onConnect?.(dc, testMode) // this.params.onConnect?.(dc, testMode)
} // }
close(): void { // close(): void {
this._currentDc = null // this._currentDc = null
this._state = TransportState.Idle // this._state = TransportState.Idle
this.emit('close') // this.emit('close')
this._log.debug('stub connection closed') // this._log.debug('stub connection closed')
this.params.onClose?.() // this.params.onClose?.()
} // }
async send(data: Uint8Array): Promise<void> { // async send(data: Uint8Array): Promise<void> {
this.params.onMessage?.(data) // this.params.onMessage?.(data)
} // }
} // }

View file

@ -1,36 +1,37 @@
{ {
"name": "@mtcute/web", "name": "@mtcute/web",
"type": "module", "type": "module",
"version": "0.16.13", "version": "0.16.13",
"private": true, "private": true,
"description": "Meta-package for the web platform", "description": "Meta-package for the web platform",
"author": "alina sireneva <alina@tei.su>", "author": "alina sireneva <alina@tei.su>",
"license": "MIT", "license": "MIT",
"sideEffects": false, "sideEffects": false,
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./utils.js": "./src/utils.ts", "./utils.js": "./src/utils.ts",
"./methods.js": "./src/methods.ts" "./methods.js": "./src/methods.ts"
}, },
"scripts": { "scripts": {
"docs": "typedoc", "docs": "typedoc",
"build": "pnpm run -w build-package web" "build": "pnpm run -w build-package web"
}, },
"dependencies": { "dependencies": {
"@mtcute/core": "workspace:^", "@mtcute/core": "workspace:^",
"@mtcute/wasm": "workspace:^", "@mtcute/wasm": "workspace:^",
"events": "3.2.0" "@fuman/net": "workspace:^",
}, "events": "3.2.0"
"devDependencies": { },
"@mtcute/test": "workspace:^" "devDependencies": {
}, "@mtcute/test": "workspace:^"
"denoJson": { },
"compilerOptions": { "denoJson": {
"lib": [ "compilerOptions": {
"dom", "lib": [
"dom.iterable", "dom",
"WebWorker" "dom.iterable",
] "WebWorker"
]
}
} }
}
} }

View file

@ -43,7 +43,7 @@ export class BaseTelegramClient extends BaseTelegramClientBase {
super({ super({
crypto: new WebCryptoProvider(), crypto: new WebCryptoProvider(),
transport: () => new WebSocketTransport(), transport: new WebSocketTransport(),
...opts, ...opts,
storage: storage:
typeof opts.storage === 'string' typeof opts.storage === 'string'

View file

@ -1,140 +1,141 @@
import type { Mock, MockedObject } from 'vitest' // todo: move to fuman
import { describe, expect, it, vi } from 'vitest' // import type { Mock, MockedObject } from 'vitest'
import { TransportState } from '@mtcute/core' // import { describe, expect, it, vi } from 'vitest'
import { getPlatform } from '@mtcute/core/platform.js' // import { TransportState } from '@mtcute/core'
import { LogManager, defaultProductionDc } from '@mtcute/core/utils.js' // import { getPlatform } from '@mtcute/core/platform.js'
import { defaultTestCryptoProvider, u8HexDecode } from '@mtcute/test' // import { LogManager, defaultProductionDc } from '@mtcute/core/utils.js'
// import { defaultTestCryptoProvider, u8HexDecode } from '@mtcute/test'
import { WebSocketTransport } from './websocket.js' // import { WebSocketTransport } from './websocket.js'
const p = getPlatform() // const p = getPlatform()
describe('WebSocketTransport', () => { // describe('WebSocketTransport', () => {
const create = async () => { // const create = async () => {
let closeListener: () => void = () => {} // let closeListener: () => void = () => {}
const fakeWs = vi.fn().mockImplementation(() => ({ // const fakeWs = vi.fn().mockImplementation(() => ({
addEventListener: vi.fn().mockImplementation((event: string, cb: () => void) => { // addEventListener: vi.fn().mockImplementation((event: string, cb: () => void) => {
if (event === 'open') { // if (event === 'open') {
cb() // cb()
} // }
if (event === 'close') { // if (event === 'close') {
closeListener = cb // closeListener = cb
} // }
}), // }),
removeEventListener: vi.fn(), // removeEventListener: vi.fn(),
close: vi.fn().mockImplementation(() => closeListener()), // close: vi.fn().mockImplementation(() => closeListener()),
send: vi.fn(), // send: vi.fn(),
})) // }))
const transport = new WebSocketTransport({ ws: fakeWs }) // const transport = new WebSocketTransport({ ws: fakeWs })
const logger = new LogManager() // const logger = new LogManager()
logger.level = 10 // logger.level = 10
transport.setup(await defaultTestCryptoProvider(), logger) // transport.setup(await defaultTestCryptoProvider(), logger)
return [transport, fakeWs] as const // return [transport, fakeWs] as const
} // }
const getLastSocket = (ws: Mock) => { // const getLastSocket = (ws: Mock) => {
return ws.mock.results[ws.mock.results.length - 1].value as MockedObject<WebSocket> // return ws.mock.results[ws.mock.results.length - 1].value as MockedObject<WebSocket>
} // }
it('should initiate a websocket connection to the given dc', async () => { // it('should initiate a websocket connection to the given dc', async () => {
const [t, ws] = await create() // const [t, ws] = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
expect(ws).toHaveBeenCalledOnce() // expect(ws).toHaveBeenCalledOnce()
expect(ws).toHaveBeenCalledWith('wss://venus.web.telegram.org/apiws', 'binary') // expect(ws).toHaveBeenCalledWith('wss://venus.web.telegram.org/apiws', 'binary')
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
}) // })
it('should set up event handlers', async () => { // it('should set up event handlers', async () => {
const [t, ws] = await create() // const [t, ws] = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
const socket = getLastSocket(ws) // const socket = getLastSocket(ws)
expect(socket.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)) // expect(socket.addEventListener).toHaveBeenCalledWith('message', expect.any(Function))
expect(socket.addEventListener).toHaveBeenCalledWith('error', expect.any(Function)) // expect(socket.addEventListener).toHaveBeenCalledWith('error', expect.any(Function))
expect(socket.addEventListener).toHaveBeenCalledWith('close', expect.any(Function)) // expect(socket.addEventListener).toHaveBeenCalledWith('close', expect.any(Function))
}) // })
it('should write packet codec tag to the socket', async () => { // it('should write packet codec tag to the socket', async () => {
const [t, ws] = await create() // const [t, ws] = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
const socket = getLastSocket(ws) // const socket = getLastSocket(ws)
await vi.waitFor(() => // await vi.waitFor(() =>
expect(socket.send).toHaveBeenCalledWith( // expect(socket.send).toHaveBeenCalledWith(
u8HexDecode( // u8HexDecode(
'29afd26df40fb8ed10b6b4ad6d56ef5df9453f88e6ee6adb6e0544ba635dc6a8a990c9b8b980c343936b33fa7f97bae025102532233abb26d4a1fe6d34f1ba08', // '29afd26df40fb8ed10b6b4ad6d56ef5df9453f88e6ee6adb6e0544ba635dc6a8a990c9b8b980c343936b33fa7f97bae025102532233abb26d4a1fe6d34f1ba08',
), // ),
), // ),
) // )
}) // })
it('should write to the underlying socket', async () => { // it('should write to the underlying socket', async () => {
const [t, ws] = await create() // const [t, ws] = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
const socket = getLastSocket(ws) // const socket = getLastSocket(ws)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
await t.send(p.hexDecode('00010203040506070809')) // await t.send(p.hexDecode('00010203040506070809'))
expect(socket.send).toHaveBeenCalledWith(u8HexDecode('af020630c8ef14bcf53af33853ea')) // expect(socket.send).toHaveBeenCalledWith(u8HexDecode('af020630c8ef14bcf53af33853ea'))
}) // })
it('should correctly close', async () => { // it('should correctly close', async () => {
const [t, ws] = await create() // const [t, ws] = await create()
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
const socket = getLastSocket(ws) // const socket = getLastSocket(ws)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
await t.close() // await t.close()
expect(socket.close).toHaveBeenCalled() // expect(socket.close).toHaveBeenCalled()
}) // })
it('should correctly handle incoming messages', async () => { // it('should correctly handle incoming messages', async () => {
const [t, ws] = await create() // const [t, ws] = await create()
const feedSpy = vi.spyOn(t._packetCodec, 'feed') // const feedSpy = vi.spyOn(t._packetCodec, 'feed')
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
const socket = getLastSocket(ws) // const socket = getLastSocket(ws)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
const data = p.hexDecode('00010203040506070809') // const data = p.hexDecode('00010203040506070809')
const message = new MessageEvent('message', { data }) // const message = new MessageEvent('message', { data })
const onMessageCall = socket.addEventListener.mock.calls.find(([event]) => event === 'message') as unknown as [ // const onMessageCall = socket.addEventListener.mock.calls.find(([event]) => event === 'message') as unknown as [
string, // string,
(evt: MessageEvent) => void, // (evt: MessageEvent) => void,
] // ]
onMessageCall[1](message) // onMessageCall[1](message)
expect(feedSpy).toHaveBeenCalledWith(u8HexDecode('00010203040506070809')) // expect(feedSpy).toHaveBeenCalledWith(u8HexDecode('00010203040506070809'))
}) // })
it('should propagate errors', async () => { // it('should propagate errors', async () => {
const [t, ws] = await create() // const [t, ws] = await create()
const spyEmit = vi.spyOn(t, 'emit').mockImplementation(() => true) // const spyEmit = vi.spyOn(t, 'emit').mockImplementation(() => true)
t.connect(defaultProductionDc.main, false) // t.connect(defaultProductionDc.main, false)
const socket = getLastSocket(ws) // const socket = getLastSocket(ws)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready)) // await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
const error = new Error('test') // const error = new Error('test')
const onErrorCall = socket.addEventListener.mock.calls.find(([event]) => event === 'error') as unknown as [ // const onErrorCall = socket.addEventListener.mock.calls.find(([event]) => event === 'error') as unknown as [
string, // string,
(evt: { error: Error }) => void, // (evt: { error: Error }) => void,
] // ]
onErrorCall[1]({ error }) // onErrorCall[1]({ error })
expect(spyEmit).toHaveBeenCalledWith('error', error) // expect(spyEmit).toHaveBeenCalledWith('error', error)
}) // })
}) // })

View file

@ -1,26 +1,16 @@
// eslint-disable-next-line unicorn/prefer-node-protocol
import EventEmitter from 'events'
import type { import type {
IPacketCodec, IPacketCodec,
ITelegramTransport, ITelegramConnection,
TelegramTransport,
} from '@mtcute/core' } from '@mtcute/core'
import { import {
IntermediatePacketCodec, IntermediatePacketCodec,
MtUnsupportedError, MtUnsupportedError,
MtcuteError,
ObfuscatedPacketCodec, ObfuscatedPacketCodec,
TransportState,
} from '@mtcute/core' } from '@mtcute/core'
import type { import { WebSocketConnection } from '@fuman/net'
BasicDcOption,
ControllablePromise, import type { BasicDcOption } from './utils'
ICryptoProvider,
Logger,
} from '@mtcute/core/utils.js'
import {
createControllablePromise,
} from '@mtcute/core/utils.js'
export interface WebSocketConstructor { export interface WebSocketConstructor {
new (address: string, protocol?: string): WebSocket new (address: string, protocol?: string): WebSocket
@ -34,20 +24,7 @@ const subdomainsMap: Record<string, string> = {
5: 'flora', 5: 'flora',
} }
/** export class WebSocketTransport implements TelegramTransport {
* Base for WebSocket transports.
* Subclasses must provide packet codec in `_packetCodec` property
*/
export abstract class BaseWebSocketTransport extends EventEmitter implements ITelegramTransport {
private _currentDc: BasicDcOption | null = null
private _state: TransportState = TransportState.Idle
private _socket: WebSocket | null = null
private _crypto!: ICryptoProvider
protected log!: Logger
abstract _packetCodec: IPacketCodec
packetCodecInitialized = false
private _baseDomain: string private _baseDomain: string
private _subdomains: Record<string, string> private _subdomains: Record<string, string>
private _WebSocket: WebSocketConstructor private _WebSocket: WebSocketConstructor
@ -64,8 +41,6 @@ export abstract class BaseWebSocketTransport extends EventEmitter implements ITe
/** Map of sub-domains (key is DC ID, value is string) */ /** Map of sub-domains (key is DC ID, value is string) */
subdomains?: Record<string, string> subdomains?: Record<string, string>
} = {}) { } = {}) {
super()
if (!ws) { if (!ws) {
throw new MtUnsupportedError( throw new MtUnsupportedError(
'To use WebSocket transport with NodeJS, install `ws` package and pass it to constructor', 'To use WebSocket transport with NodeJS, install `ws` package and pass it to constructor',
@ -82,114 +57,25 @@ export abstract class BaseWebSocketTransport extends EventEmitter implements ITe
this._WebSocket = ws this._WebSocket = ws
} }
private _updateLogPrefix() { async connect(dc: BasicDcOption, testMode: boolean): Promise<ITelegramConnection> {
if (this._currentDc) { const url = `wss://${this._subdomains[dc.id]}.${this._baseDomain}/apiws${testMode ? '_test' : ''}`
this.log.prefix = `[WS:${this._subdomains[this._currentDc.id]}.${this._baseDomain}] `
} else {
this.log.prefix = '[WS:disconnected] '
}
}
setup(crypto: ICryptoProvider, log: Logger): void { return new Promise((resolve, reject) => {
this._crypto = crypto const socket = new this._WebSocket(url)
this.log = log.create('ws')
}
state(): TransportState { const onError = (event: Event) => {
return this._state socket.removeEventListener('error', onError)
} reject(event)
}
currentDc(): BasicDcOption | null { socket.addEventListener('error', onError)
return this._currentDc socket.addEventListener('open', () => {
} socket.removeEventListener('error', onError)
resolve(new WebSocketConnection(socket))
connect(dc: BasicDcOption, testMode: boolean): void {
if (this._state !== TransportState.Idle) {
throw new MtcuteError('Transport is not IDLE')
}
if (!this.packetCodecInitialized) {
this._packetCodec.setup?.(this._crypto, this.log)
this._packetCodec.on('error', err => this.emit('error', err))
this._packetCodec.on('packet', buf => this.emit('message', buf))
this.packetCodecInitialized = true
}
this._state = TransportState.Connecting
this._currentDc = dc
this._socket = new this._WebSocket(
`wss://${this._subdomains[dc.id]}.${this._baseDomain}/apiws${testMode ? '_test' : ''}`,
'binary',
)
this._updateLogPrefix()
this.log.debug('connecting to %s (%j)', this._socket.url, dc)
this._socket.binaryType = 'arraybuffer'
this._socket.addEventListener('message', evt =>
this._packetCodec.feed(new Uint8Array(evt.data as ArrayBuffer)))
this._socket.addEventListener('open', this.handleConnect.bind(this))
this._socket.addEventListener('error', this.handleError.bind(this))
this._socket.addEventListener('close', this.handleClosed.bind(this))
}
private _closeWaiters: ControllablePromise<void>[] = []
async close(): Promise<void> {
if (this._state === TransportState.Idle) return
const promise = createControllablePromise<void>()
this._closeWaiters.push(promise)
this._socket!.close()
return promise
}
handleClosed(): void {
this.log.info('connection closed')
this._state = TransportState.Idle
this._socket = null
this._currentDc = null
this._packetCodec.reset()
this.emit('close')
for (const waiter of this._closeWaiters) {
waiter.resolve()
}
this._closeWaiters = []
}
handleError(event: Event | { error: Error }): void {
const error = 'error' in event ? event.error : new Error('unknown WebSocket error')
this.log.error('error: %s', error.stack)
this.emit('error', error)
}
handleConnect(): void {
this.log.info('connected')
Promise.resolve(this._packetCodec.tag())
.then((initialMessage) => {
this._socket!.send(initialMessage)
this._state = TransportState.Ready
this.emit('ready')
}) })
.catch(err => this.emit('error', err)) })
} }
async send(bytes: Uint8Array): Promise<void> { packetCodec(): IPacketCodec {
if (this._state !== TransportState.Ready) { return new ObfuscatedPacketCodec(new IntermediatePacketCodec())
throw new MtcuteError('Transport is not READY')
}
const framed = await this._packetCodec.encode(bytes)
this._socket!.send(framed)
} }
} }
export class WebSocketTransport extends BaseWebSocketTransport {
_packetCodec: ObfuscatedPacketCodec = new ObfuscatedPacketCodec(new IntermediatePacketCodec())
}

View file

@ -130,6 +130,15 @@ importers:
packages/core: packages/core:
dependencies: dependencies:
'@fuman/io':
specifier: workspace:^
version: link:../../private/fuman/packages/io
'@fuman/net':
specifier: workspace:^
version: link:../../private/fuman/packages/net
'@fuman/utils':
specifier: workspace:^
version: link:../../private/fuman/packages/utils
'@mtcute/file-id': '@mtcute/file-id':
specifier: workspace:^ specifier: workspace:^
version: link:../file-id version: link:../file-id
@ -298,6 +307,9 @@ importers:
packages/node: packages/node:
dependencies: dependencies:
'@fuman/node-net':
specifier: workspace:^
version: link:../../private/fuman/packages/node-net
'@mtcute/core': '@mtcute/core':
specifier: workspace:^ specifier: workspace:^
version: link:../core version: link:../core
@ -415,6 +427,9 @@ importers:
packages/web: packages/web:
dependencies: dependencies:
'@fuman/net':
specifier: workspace:^
version: link:../../private/fuman/packages/net
'@mtcute/core': '@mtcute/core':
specifier: workspace:^ specifier: workspace:^
version: link:../core version: link:../core
@ -6808,7 +6823,7 @@ snapshots:
acorn: 8.12.1 acorn: 8.12.1
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
espree: 9.6.1 espree: 9.6.1
semver: 7.5.1 semver: 7.6.3
jsonfile@4.0.0: jsonfile@4.0.0:
optionalDependencies: optionalDependencies:
@ -6897,7 +6912,7 @@ snapshots:
make-dir@4.0.0: make-dir@4.0.0:
dependencies: dependencies:
semver: 7.6.0 semver: 7.6.3
markdown-it@14.1.0: markdown-it@14.1.0:
dependencies: dependencies:
@ -7036,7 +7051,7 @@ snapshots:
node-abi@3.15.0: node-abi@3.15.0:
dependencies: dependencies:
semver: 7.5.1 semver: 7.6.3
node-gyp-build@4.8.1: {} node-gyp-build@4.8.1: {}
@ -8031,7 +8046,7 @@ snapshots:
espree: 9.6.1 espree: 9.6.1
esquery: 1.6.0 esquery: 1.6.0
lodash: 4.17.21 lodash: 4.17.21
semver: 7.5.1 semver: 7.6.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color

View file

@ -1,3 +1,4 @@
packages: packages:
- packages/* - packages/*
- private/fuman/packages/*
- '!e2e/*' - '!e2e/*'

View file

@ -130,6 +130,11 @@ function listPackages(all = false) {
for (const f of fs.readdirSync(path.join(__dirname, '../packages'))) { for (const f of fs.readdirSync(path.join(__dirname, '../packages'))) {
if (f[0] === '.') continue if (f[0] === '.') continue
if (f === 'mtproxy' || f === 'socks-proxy' || f === 'http-proxy' || f === 'bun') {
// todo: return once we figure out fuman
continue
}
if (!all) { if (!all) {
if (IS_JSR && JSR_EXCEPTIONS[f] === 'never') continue if (IS_JSR && JSR_EXCEPTIONS[f] === 'never') continue
if (!IS_JSR && JSR_EXCEPTIONS[f] === 'only') continue if (!IS_JSR && JSR_EXCEPTIONS[f] === 'only') continue

View file

@ -16,7 +16,8 @@ export async function validateDepsVersions() {
if (!deps) return if (!deps) return
Object.entries(deps).forEach(([depName, depVersions]) => { Object.entries(deps).forEach(([depName, depVersions]) => {
if (depName.startsWith('@mtcute/')) return // todo: remove this when fuman/net is published
if (depName.startsWith('@mtcute/') || depName.startsWith('@fuman/')) return
if (!versions[depName]) { if (!versions[depName]) {
versions[depName] = {} versions[depName] = {}