feat: initial deno support

This commit is contained in:
alina 🌸 2024-04-28 22:41:28 +03:00
parent 2bc9f898e5
commit a2cdc73735
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
50 changed files with 1013 additions and 27 deletions

View file

@ -279,7 +279,7 @@ module.exports = {
},
},
{
files: ['packages/bun/**'],
files: ['packages/bun/**', 'packages/deno/**'],
rules: {
'import/no-unresolved': 'off',
'no-restricted-imports': 'off',
@ -291,7 +291,7 @@ module.exports = {
rules: {
'import/no-unresolved': 'off',
}
}
},
],
settings: {
'import/resolver': {

View file

@ -67,7 +67,7 @@ jobs:
- name: 'Build tests'
run: pnpm exec vite build -c .config/vite.deno.mts
- name: 'Run tests'
run: cd dist/tests && deno test
run: cd dist/tests && deno test -A --unstable-ffi
test-web:
runs-on: ubuntu-latest

1
.npmrc Normal file
View file

@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View file

@ -10,7 +10,7 @@
],
"scripts": {
"prepare": "husky install .config/husky",
"postinstall": "node scripts/validate-deps-versions.mjs",
"postinstall": "node scripts/validate-deps-versions.mjs && node scripts/fetch-deno-dts.mjs && node scripts/remove-jsr-sourcefiles.mjs",
"test": "pnpm run -r test && vitest --config .config/vite.mts run",
"test:dev": "vitest --config .config/vite.mts watch",
"test:ui": "vitest --config .config/vite.mts --ui",

View file

@ -1,8 +1,14 @@
import * as os from 'os'
import { NodePlatform } from './common-internals-node/platform.js'
import { normalizeFile } from './utils/normalize-file.js'
export class BunPlatform extends NodePlatform {
declare normalizeFile: typeof normalizeFile
getDeviceModel(): string {
return `Bun/${process.version} (${os.type()} ${os.arch()})`
}
}
BunPlatform.prototype.normalizeFile = normalizeFile

View file

@ -14,7 +14,7 @@ import {
WorkerMessageHandler,
} from '@mtcute/core/worker.js'
import { NodePlatform } from './common-internals-node/platform.js'
import { BunPlatform } from './platform.js'
export type { TelegramWorkerOptions, TelegramWorkerPortOptions, WorkerCustomMethods }
@ -44,7 +44,7 @@ export class TelegramWorker<T extends WorkerCustomMethods> extends TelegramWorke
export class TelegramWorkerPort<T extends WorkerCustomMethods> extends TelegramWorkerPortBase<T> {
constructor(readonly options: TelegramWorkerPortOptions) {
setPlatform(new NodePlatform())
setPlatform(new BunPlatform())
super(options)
}

View file

@ -171,7 +171,8 @@ export async function uploadFile(
throw new MtArgumentError('Fetch response contains `null` body')
}
file = file.body
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
file = file.body as ReadableStream<Uint8Array>
}
if (!(file instanceof ReadableStream)) {

View file

@ -1,9 +1,7 @@
/* eslint-disable no-restricted-imports */
import type { ReadStream } from 'node:fs'
import { tdFileId } from '@mtcute/file-id'
import { tl } from '@mtcute/tl'
import { AnyToNever } from '../../../types/utils.js'
import { FileLocation } from './file-location.js'
import { UploadedFile } from './uploaded-file.js'
@ -14,8 +12,9 @@ import { UploadedFile } from './uploaded-file.js'
* - `File`, `Blob` (from the Web API)
* - `string`, which will be interpreted as file path (**non-browser only!**)
* - `URL` (from the Web API, will be `fetch()`-ed; `file://` URLs are not available in browsers)
* - `ReadStream` (for Node.js/Bun, from the `fs` module)
* - `ReadStream` (for Node.js/Bun, from the `node:fs` module)
* - `BunFile` (from `Bun.file()`)
* - `Deno.FsFile` (from `Deno.open()` in Deno)
* - `ReadableStream` (Web API readable stream)
* - `Readable` (Node.js/Bun readable stream)
* - `Response` (from `window.fetch`)
@ -26,10 +25,14 @@ export type UploadFileLike =
| File
| Blob
| string
| ReadStream
| ReadableStream<Uint8Array>
| NodeJS.ReadableStream
| Response
| AnyToNever<import('node:fs').ReadStream>
| AnyToNever<ReadableStream<Uint8Array>>
| AnyToNever<NodeJS.ReadableStream>
| AnyToNever<Response>
| AnyToNever<Deno.FsFile>
// AnyToNever in the above type ensures we don't make the entire type `any`
// if some of the types are not available in the current environment
/**
* Describes types that can be used as an input
@ -41,6 +44,8 @@ export type UploadFileLike =
* - `ReadStream` (for Node.js/Bun, from the `fs` module)
* - `ReadableStream` (from the Web API, base readable stream)
* - `Readable` (for Node.js/Bun, base readable stream)
* - `BunFile` (from `Bun.file()`)
* - `Deno.FsFile` (from `Deno.open()` in Deno)
* - {@link UploadedFile} returned from {@link TelegramClient.uploadFile}
* - `tl.TypeInputFile` and `tl.TypeInputMedia` TL objects
* - `string` with a path to a local file prepended with `file:` (non-browser only) (e.g. `file:image.jpg`)

View file

@ -1,3 +1,4 @@
/// <reference lib="dom" />
import type { Worker as NodeWorker } from 'node:worker_threads'
import { tl } from '@mtcute/tl'

View file

@ -62,10 +62,9 @@ export class SqlitePeersRepository implements IPeersRepository {
this._driver._writeLater(this._store, [
peer.id,
peer.accessHash,
// add commas to make it easier to search with LIKE
JSON.stringify(peer.usernames),
peer.updated,
peer.phone,
peer.phone ?? null,
peer.complete,
])
}

View file

@ -1,6 +1,8 @@
export type MaybePromise<T> = T | Promise<T>
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>
export type PartialOnly<T, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyToNever<T> = any extends T ? never : T
export type MaybeArray<T> = T | T[]

View file

@ -7,7 +7,8 @@ export function reportUnknownError(log: Logger, error: tl.RpcError, method: stri
fetch(`https://rpc.pwrtelegram.xyz/?code=${error.code}&method=${method}&error=${error.text}`)
.then((r) => r.json())
.then((r) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then((r: any) => {
if (r.ok) {
log.info('telerpc responded with error info for %s: %s', error.text, r.result)
} else {

25
packages/deno/README.md Normal file
View file

@ -0,0 +1,25 @@
# @mtcute/bun
📖 [API Reference](https://ref.mtcute.dev/modules/_mtcute_deno.html)
‼️ **Experimental** Deno support package for mtcute. Includes:
- SQLite storage (based on [`@db/sqlite`](https://jsr.io/@db/sqlite))
- TCP transport (based on Deno-native APIs)
- `TelegramClient` implementation using the above
- HTML and Markdown parsers
## Usage
```typescript
import { TelegramClient } from '@mtcute/deno'
const tg = new TelegramClient({
apiId: 12345,
apiHash: 'abcdef',
storage: 'my-account'
})
tg.run(async (user) => {
console.log(`✨ logged in as ${user.displayName}`)
})
```

View file

@ -0,0 +1 @@
module.exports = () => ({ buildCjs: false })

View file

@ -0,0 +1,30 @@
{
"name": "@mtcute/deno",
"private": true,
"version": "0.11.0",
"description": "Meta-package for Deno",
"author": "alina sireneva <alina@tei.su>",
"license": "MIT",
"main": "src/index.ts",
"type": "module",
"sideEffects": false,
"scripts": {
"docs": "typedoc",
"build": "pnpm run -w build-package deno"
},
"exports": {
".": "./src/index.ts",
"./utils.js": "./src/utils.ts"
},
"dependencies": {
"@mtcute/core": "workspace:^",
"@mtcute/wasm": "workspace:^",
"@mtcute/markdown-parser": "workspace:^",
"@mtcute/html-parser": "workspace:^",
"@db/sqlite": "npm:@jsr/db__sqlite@0.11.1",
"@std/io": "npm:@jsr/std__io@0.223.0"
},
"devDependencies": {
"@mtcute/test": "workspace:^"
}
}

144
packages/deno/src/client.ts Normal file
View file

@ -0,0 +1,144 @@
import { createInterface, Interface as RlInterface } from 'node:readline'
import { Readable, Writable } from 'node:stream'
import { FileDownloadLocation, FileDownloadParameters, ITelegramStorageProvider, PartialOnly, User } from '@mtcute/core'
import {
BaseTelegramClient as BaseTelegramClientBase,
BaseTelegramClientOptions as BaseTelegramClientOptionsBase,
TelegramClient as TelegramClientBase,
TelegramClientOptions,
} from '@mtcute/core/client.js'
import { setPlatform } from '@mtcute/core/platform.js'
import { downloadToFile } from './methods/download-file.js'
import { DenoPlatform } from './platform.js'
import { SqliteStorage } from './sqlite/index.js'
import { DenoCryptoProvider } from './utils/crypto.js'
import { TcpTransport } from './utils/tcp.js'
export type { TelegramClientOptions }
export interface BaseTelegramClientOptions
extends PartialOnly<Omit<BaseTelegramClientOptionsBase, 'storage'>, 'transport' | 'crypto'> {
/**
* Storage to use for this client.
*
* If a string is passed, it will be used as
* a name for an SQLite database file.
*
* @default `"client.session"`
*/
storage?: string | ITelegramStorageProvider
/**
* **ADVANCED USE ONLY**
*
* Whether to not set up the platform.
* This is useful if you call `setPlatform` yourself.
*/
platformless?: boolean
}
export class BaseTelegramClient extends BaseTelegramClientBase {
constructor(opts: BaseTelegramClientOptions) {
if (!opts.platformless) setPlatform(new DenoPlatform())
super({
crypto: new DenoCryptoProvider(),
transport: () => new TcpTransport(),
...opts,
storage:
typeof opts.storage === 'string' ?
new SqliteStorage(opts.storage) :
opts.storage ?? new SqliteStorage('client.session'),
})
}
}
/**
* Telegram client for use in Node.js
*/
export class TelegramClient extends TelegramClientBase {
constructor(opts: TelegramClientOptions) {
if ('client' in opts) {
super(opts)
return
}
super({
client: new BaseTelegramClient(opts),
})
}
private _rl?: RlInterface
/**
* Tiny wrapper over Node `readline` package
* for simpler user input for `.run()` method.
*
* Associated `readline` interface is closed
* after `run()` returns, or with the client.
*
* @param text Text of the question
*/
input(text: string): Promise<string> {
if (!this._rl) {
this._rl = createInterface({
// eslint-disable-next-line
input: Readable.fromWeb(Deno.stdin.readable as any),
output: Writable.fromWeb(Deno.stdout.writable),
})
}
return new Promise((res) => this._rl?.question(text, res))
}
close(): Promise<void> {
this._rl?.close()
return super.close()
}
start(params: Parameters<TelegramClientBase['start']>[0] = {}): Promise<User> {
if (!params.botToken) {
if (!params.phone) params.phone = () => this.input('phone > ')
if (!params.code) params.code = () => this.input('code > ')
if (!params.password) {
params.password = () => this.input('2fa password > ')
}
}
return super.start(params).then((user) => {
if (this._rl) {
this._rl.close()
delete this._rl
}
return user
})
}
run(
params: Parameters<TelegramClient['start']>[0] | ((user: User) => void | Promise<void>),
then?: (user: User) => void | Promise<void>,
): void {
if (typeof params === 'function') {
then = params
params = {}
}
this.start(params)
.then(then)
.catch((err) => this.emitError(err))
}
downloadToFile(
filename: string,
location: FileDownloadLocation,
params?: FileDownloadParameters | undefined,
): Promise<void> {
return downloadToFile(this, filename, location, params)
}
}

View file

@ -0,0 +1 @@
../../web/src/common-internals-web

View file

@ -0,0 +1,9 @@
export * from './client.js'
export * from './platform.js'
export * from './sqlite/index.js'
export * from './utils/crypto.js'
export * from './utils/tcp.js'
export * from './worker.js'
export * from '@mtcute/core'
export * from '@mtcute/html-parser'
export * from '@mtcute/markdown-parser'

View file

@ -0,0 +1,2 @@
export { downloadToFile } from './methods/download-file.js'
export * from '@mtcute/core/methods.js'

View file

@ -0,0 +1,39 @@
import { FileDownloadLocation, FileDownloadParameters, FileLocation, ITelegramClient } from '@mtcute/core'
import { downloadAsIterable } from '@mtcute/core/methods.js'
import { writeAll } from '@std/io/write-all'
/**
* Download a remote file to a local file (only for NodeJS).
* Promise will resolve once the download is complete.
*
* @param filename Local file name to which the remote file will be downloaded
* @param params File download parameters
*/
export async function downloadToFile(
client: ITelegramClient,
filename: string,
location: FileDownloadLocation,
params?: FileDownloadParameters,
): Promise<void> {
if (location instanceof FileLocation && ArrayBuffer.isView(location.location)) {
// early return for inline files
await Deno.writeFile(filename, location.location)
}
const fd = await Deno.open(filename, { write: true, create: true, truncate: true })
if (params?.abortSignal) {
params.abortSignal.addEventListener('abort', () => {
client.log.debug('aborting file download %s - cleaning up', filename)
fd.close()
Deno.removeSync(filename)
})
}
for await (const chunk of downloadAsIterable(client, location, params)) {
await writeAll(fd, chunk)
}
fd.close()
}

View file

@ -0,0 +1,48 @@
import { ICorePlatform } from '@mtcute/core/platform.js'
import { base64Decode, base64Encode } from './common-internals-web/base64.js'
import { hexDecode, hexEncode } from './common-internals-web/hex.js'
import { defaultLoggingHandler } from './common-internals-web/logging.js'
import { utf8ByteLength, utf8Decode, utf8Encode } from './common-internals-web/utf8.js'
import { beforeExit } from './utils/exit-hook.js'
import { normalizeFile } from './utils/normalize-file.js'
export class DenoPlatform implements ICorePlatform {
declare log: typeof defaultLoggingHandler
declare beforeExit: typeof beforeExit
declare normalizeFile: typeof normalizeFile
getDeviceModel(): string {
return `Deno/${Deno.version.deno} (${Deno.build.os} ${Deno.build.arch})`
}
getDefaultLogLevel(): number | null {
const envLogLevel = parseInt(Deno.env.get('MTCUTE_LOG_LEVEL') ?? '')
if (!isNaN(envLogLevel)) {
return envLogLevel
}
return null
}
// ITlPlatform
declare utf8ByteLength: typeof utf8ByteLength
declare utf8Encode: typeof utf8Encode
declare utf8Decode: typeof utf8Decode
declare hexEncode: typeof hexEncode
declare hexDecode: typeof hexDecode
declare base64Encode: typeof base64Encode
declare base64Decode: typeof base64Decode
}
DenoPlatform.prototype.utf8ByteLength = utf8ByteLength
DenoPlatform.prototype.utf8Encode = utf8Encode
DenoPlatform.prototype.utf8Decode = utf8Decode
DenoPlatform.prototype.hexEncode = hexEncode
DenoPlatform.prototype.hexDecode = hexDecode
DenoPlatform.prototype.base64Encode = base64Encode
DenoPlatform.prototype.base64Decode = base64Decode
DenoPlatform.prototype.log = defaultLoggingHandler
DenoPlatform.prototype.beforeExit = beforeExit
DenoPlatform.prototype.normalizeFile = normalizeFile

View file

@ -0,0 +1,38 @@
import { BaseSqliteStorageDriver, ISqliteDatabase } from '@mtcute/core'
import { Database } from '@db/sqlite'
export interface SqliteStorageDriverOptions {
/**
* By default, WAL mode is enabled, which
* significantly improves performance.
* [Learn more](https://bun.sh/docs/api/sqlite#wal-mode)
*
* However, you might encounter some issues,
* and if you do, you can disable WAL by passing `true`
*
* @default false
*/
disableWal?: boolean
}
export class SqliteStorageDriver extends BaseSqliteStorageDriver {
constructor(
readonly filename = ':memory:',
readonly params?: SqliteStorageDriverOptions,
) {
super()
}
_createDatabase(): ISqliteDatabase {
const db = new Database(this.filename, {
int64: true,
})
if (!this.params?.disableWal) {
db.exec('PRAGMA journal_mode = WAL;')
}
return db as ISqliteDatabase
}
}

View file

@ -0,0 +1,14 @@
import { BaseSqliteStorage } from '@mtcute/core'
import { SqliteStorageDriver, SqliteStorageDriverOptions } from './driver.js'
export { SqliteStorageDriver } from './driver.js'
export class SqliteStorage extends BaseSqliteStorage {
constructor(
readonly filename = ':memory:',
readonly params?: SqliteStorageDriverOptions,
) {
super(new SqliteStorageDriver(filename, params))
}
}

View file

@ -0,0 +1,31 @@
import { afterAll, beforeAll, describe } from 'vitest'
import { LogManager } from '@mtcute/core/utils.js'
import {
testAuthKeysRepository,
testKeyValueRepository,
testPeersRepository,
testRefMessagesRepository,
} from '@mtcute/test'
if (import.meta.env.TEST_ENV === 'deno') {
const { SqliteStorage } = await import('./index.js')
describe('SqliteStorage', () => {
const storage = new SqliteStorage(':memory:')
beforeAll(async () => {
storage.driver.setup(new LogManager())
await storage.driver.load()
})
testAuthKeysRepository(storage.authKeys)
testKeyValueRepository(storage.kv, storage.driver)
testPeersRepository(storage.peers, storage.driver)
testRefMessagesRepository(storage.refMessages, storage.driver)
afterAll(() => storage.driver.destroy())
})
} else {
describe.skip('SqliteStorage', () => {})
}

View file

@ -0,0 +1 @@
export * from '@mtcute/core/utils.js'

View file

@ -0,0 +1,13 @@
import { describe } from 'vitest'
import { testCryptoProvider } from '@mtcute/test'
if (import.meta.env.TEST_ENV === 'deno') {
describe('DenoCryptoProvider', async () => {
const { DenoCryptoProvider } = await import('./crypto.js')
testCryptoProvider(new DenoCryptoProvider())
})
} else {
describe.skip('DenoCryptoProvider', () => {})
}

View file

@ -0,0 +1,97 @@
/* eslint-disable no-restricted-globals */
import { Buffer } from 'node:buffer'
import { createCipheriv, createHash, createHmac, pbkdf2 } from 'node:crypto'
import { deflateSync, gunzipSync } from 'node:zlib'
import { BaseCryptoProvider, IAesCtr, ICryptoProvider, IEncryptionScheme } from '@mtcute/core/utils.js'
import { getWasmUrl, ige256Decrypt, ige256Encrypt, initSync } from '@mtcute/wasm'
// node:crypto is properly implemented in deno, so we can just use it
// largely just copy-pasting from @mtcute/node
const toUint8Array = (buf: Buffer | Uint8Array): Uint8Array => {
if (!Buffer.isBuffer(buf)) return buf
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
}
export class DenoCryptoProvider extends BaseCryptoProvider implements ICryptoProvider {
async initialize(): Promise<void> {
// eslint-disable-next-line no-restricted-globals
const wasm = await fetch(getWasmUrl()).then((res) => res.arrayBuffer())
initSync(wasm)
}
createAesCtr(key: Uint8Array, iv: Uint8Array): IAesCtr {
const cipher = createCipheriv(`aes-${key.length * 8}-ctr`, key, iv)
const update = (data: Uint8Array) => toUint8Array(cipher.update(data))
return {
process: update,
}
}
pbkdf2(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
keylen = 64,
algo = 'sha512',
): Promise<Uint8Array> {
return new Promise((resolve, reject) =>
pbkdf2(password, salt, iterations, keylen, algo, (err: Error | null, buf: Uint8Array) =>
err !== null ? reject(err) : resolve(toUint8Array(buf)),
),
)
}
sha1(data: Uint8Array): Uint8Array {
return toUint8Array(createHash('sha1').update(data).digest())
}
sha256(data: Uint8Array): Uint8Array {
return toUint8Array(createHash('sha256').update(data).digest())
}
hmacSha256(data: Uint8Array, key: Uint8Array): Uint8Array {
return toUint8Array(createHmac('sha256', key).update(data).digest())
}
createAesIge(key: Uint8Array, iv: Uint8Array): IEncryptionScheme {
return {
encrypt(data: Uint8Array): Uint8Array {
return ige256Encrypt(data, key, iv)
},
decrypt(data: Uint8Array): Uint8Array {
return ige256Decrypt(data, key, iv)
},
}
}
gzip(data: Uint8Array, maxSize: number): Uint8Array | null {
try {
// telegram accepts both zlib and gzip, but zlib is faster and has less overhead, so we use it here
return toUint8Array(
deflateSync(data, {
maxOutputLength: maxSize,
}),
)
// hot path, avoid additional runtime checks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
if (e.code === 'ERR_BUFFER_TOO_LARGE') {
return null
}
throw e
}
}
gunzip(data: Uint8Array): Uint8Array {
return toUint8Array(gunzipSync(data))
}
randomFill(buf: Uint8Array) {
crypto.getRandomValues(buf)
}
}

View file

@ -0,0 +1,21 @@
const callbacks = new Set<() => void>()
let registered = false
export function beforeExit(fn: () => void): () => void {
if (!registered) {
registered = true
window.addEventListener('unload', () => {
for (const callback of callbacks) {
callback()
}
})
}
callbacks.add(fn)
return () => {
callbacks.delete(fn)
}
}

View file

@ -0,0 +1,50 @@
import { ReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { Readable as NodeReadable } from 'node:stream'
import { UploadFileLike } from '@mtcute/core'
import { extractFileName } from '@mtcute/core/utils.js'
export async function normalizeFile(file: UploadFileLike) {
if (typeof file === 'string') {
const fd = await Deno.open(file, { read: true })
return {
file: fd.readable,
fileSize: (await fd.stat()).size,
fileName: extractFileName(file),
}
}
if (file instanceof Deno.FsFile) {
const stat = await file.stat()
return {
file: file.readable,
// https://github.com/denoland/deno/issues/23591
// fileName: ...,
fileSize: stat.size,
}
}
// while these are not Deno-specific, they still may happen
if (file instanceof ReadStream) {
const fileName = basename(file.path.toString())
const fileSize = await stat(file.path.toString()).then((stat) => stat.size)
return {
file: NodeReadable.toWeb(file) as unknown as ReadableStream<Uint8Array>,
fileName,
fileSize,
}
}
if (file instanceof NodeReadable) {
return {
file: NodeReadable.toWeb(file) as unknown as ReadableStream<Uint8Array>,
}
}
return null
}

View file

@ -0,0 +1,135 @@
import EventEmitter from 'events'
import { IntermediatePacketCodec, IPacketCodec, ITelegramTransport, MtcuteError, TransportState } from '@mtcute/core'
import { BasicDcOption, ICryptoProvider, Logger } from '@mtcute/core/utils.js'
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
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 @typescript-eslint/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)
Deno.connect({
hostname: dc.ipAddress,
port: dc.port,
transport: 'tcp',
})
.then(this.handleConnect.bind(this))
.catch((err) => {
this.handleError(err)
this.close()
})
}
close(): void {
if (this._state === TransportState.Idle) return
this.log.info('connection closed')
this._state = TransportState.Idle
this._socket?.close()
this._socket = null
this._currentDc = null
this._packetCodec.reset()
this.emit('close')
}
handleError(error: unknown): void {
this.log.error('error: %s', error)
this.emit('error', error)
}
async handleConnect(socket: Deno.TcpConn): Promise<void> {
this._socket = socket
this.log.info('connected')
try {
const packet = await this._packetCodec.tag()
if (packet.length) {
await writeAll(this._socket, packet)
}
this._state = TransportState.Ready
this.emit('ready')
const reader = this._socket.readable.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
this._packetCodec.feed(value)
}
} catch (e) {
this.handleError(e)
}
this.close()
}
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')
}
await writeAll(this._socket!, framed)
}
}
export class TcpTransport extends BaseTcpTransport {
_packetCodec = new IntermediatePacketCodec()
}

View file

@ -0,0 +1,71 @@
/// <reference lib="WebWorker" />
import { setPlatform } from '@mtcute/core/platform.js'
import {
ClientMessageHandler,
RespondFn,
SendFn,
SomeWorker,
TelegramWorker as TelegramWorkerBase,
TelegramWorkerOptions,
TelegramWorkerPort as TelegramWorkerPortBase,
TelegramWorkerPortOptions,
WorkerCustomMethods,
WorkerMessageHandler,
} from '@mtcute/core/worker.js'
import { DenoPlatform } from './platform.js'
export type { TelegramWorkerOptions, TelegramWorkerPortOptions, WorkerCustomMethods }
let _registered = false
export class TelegramWorker<T extends WorkerCustomMethods> extends TelegramWorkerBase<T> {
registerWorker(handler: WorkerMessageHandler): RespondFn {
if (_registered) {
throw new Error('TelegramWorker must be created only once')
}
_registered = true
if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) {
const respond: RespondFn = self.postMessage.bind(self)
// eslint-disable-next-line
self.addEventListener('message', (message) => handler((message as any).data, respond))
return respond
}
throw new Error('TelegramWorker must be created from a worker')
}
}
const platform = new DenoPlatform()
export class TelegramWorkerPort<T extends WorkerCustomMethods> extends TelegramWorkerPortBase<T> {
constructor(readonly options: TelegramWorkerPortOptions) {
setPlatform(platform)
super(options)
}
connectToWorker(worker: SomeWorker, handler: ClientMessageHandler): [SendFn, () => void] {
if (worker instanceof Worker) {
const send: SendFn = worker.postMessage.bind(worker)
const messageHandler = (ev: MessageEvent) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
handler(ev.data)
}
worker.addEventListener('message', messageHandler)
return [
send,
() => {
worker.removeEventListener('message', messageHandler)
},
]
}
throw new Error('Only workers are supported')
}
}

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
},
"include": [
"./src",
],
"references": [
{ "path": "../core" },
{ "path": "../dispatcher" },
{ "path": "../html-parser" },
{ "path": "../markdown-parser" }
]
}

10
packages/deno/typedoc.cjs Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
extends: ['../../.config/typedoc/config.base.cjs'],
entryPoints: ['./src/index.ts'],
externalPattern: [
'../core/**',
'../html-parser/**',
'../markdown-parser/**',
'../sqlite/**',
],
}

View file

@ -6,5 +6,8 @@
},
"include": [
"./src",
],
"references": [
{ "path": "../core" },
]
}

View file

@ -17,7 +17,7 @@ export class NodePlatform implements ICorePlatform {
declare normalizeFile: typeof normalizeFile
getDeviceModel(): string {
return `${os.type()} ${os.arch()} ${os.release()}`
return `Node.js/${process.version} (${os.type()} ${os.arch()})`
}
getDefaultLogLevel(): number | null {

View file

@ -4,7 +4,6 @@ import { readFile } from 'fs/promises'
import { createRequire } from 'module'
import { deflateSync, gunzipSync } from 'zlib'
import { MaybePromise } from '@mtcute/core'
import { BaseCryptoProvider, IAesCtr, ICryptoProvider, IEncryptionScheme } from '@mtcute/core/utils.js'
import { ige256Decrypt, ige256Encrypt, initSync } from '@mtcute/wasm'
@ -25,7 +24,7 @@ export abstract class BaseNodeCryptoProvider extends BaseCryptoProvider {
iterations: number,
keylen = 64,
algo = 'sha512',
): MaybePromise<Uint8Array> {
): Promise<Uint8Array> {
return new Promise((resolve, reject) =>
pbkdf2(password, salt, iterations, keylen, algo, (err: Error | null, buf: Uint8Array) =>
err !== null ? reject(err) : resolve(buf),

View file

@ -0,0 +1,2 @@
this folder is for common code across `@mtcute/web` and `@mtcute/deno`.
it is symlinked into `@mtcute/deno`

View file

@ -9,7 +9,7 @@ export function utf8ByteLength(str: string) {
const code = str.charCodeAt(i)
if (code > 0x7f && code <= 0x7ff) s++
else if (code > 0x7ff && code <= 0xffff) s += 2
if (code >= 0xDC00 && code <= 0xDFFF) i-- //trail surrogate
if (code >= 0xdc00 && code <= 0xdfff) i-- //trail surrogate
}
return s

View file

@ -1,3 +1,5 @@
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
import { BaseStorageDriver, MtUnsupportedError } from '@mtcute/core'
import { txToPromise } from './utils.js'

View file

@ -1,10 +1,10 @@
import { ICorePlatform } from '@mtcute/core/platform.js'
import { base64Decode, base64Encode } from './encodings/base64.js'
import { hexDecode, hexEncode } from './encodings/hex.js'
import { utf8ByteLength, utf8Decode, utf8Encode } from './encodings/utf8.js'
import { base64Decode, base64Encode } from './common-internals-web/base64.js'
import { hexDecode, hexEncode } from './common-internals-web/hex.js'
import { defaultLoggingHandler } from './common-internals-web/logging.js'
import { utf8ByteLength, utf8Decode, utf8Encode } from './common-internals-web/utf8.js'
import { beforeExit } from './exit-hook.js'
import { defaultLoggingHandler } from './logging.js'
export class WebPlatform implements ICorePlatform {
// ICorePlatform
@ -52,7 +52,6 @@ export class WebPlatform implements ICorePlatform {
declare utf8Decode: typeof utf8Decode
declare hexEncode: typeof hexEncode
declare hexDecode: typeof hexDecode
declare base64Encode: typeof base64Encode
declare base64Decode: typeof base64Decode
}

View file

@ -1,3 +1,5 @@
/// <reference lib="dom" />
/// <reference lib="webworker" />
import { setPlatform } from '@mtcute/core/platform.js'
import {
ClientMessageHandler,

View file

@ -231,6 +231,31 @@ importers:
specifier: workspace:^
version: link:../test
packages/deno:
dependencies:
'@db/sqlite':
specifier: npm:@jsr/db__sqlite@0.11.1
version: /@jsr/db__sqlite@0.11.1
'@mtcute/core':
specifier: workspace:^
version: link:../core
'@mtcute/html-parser':
specifier: workspace:^
version: link:../html-parser
'@mtcute/markdown-parser':
specifier: workspace:^
version: link:../markdown-parser
'@mtcute/wasm':
specifier: workspace:^
version: link:../wasm
'@std/io':
specifier: npm:@jsr/std__io@0.223.0
version: /@jsr/std__io@0.223.0
devDependencies:
'@mtcute/test':
specifier: workspace:^
version: link:../test
packages/dispatcher:
dependencies:
'@mtcute/core':
@ -1189,6 +1214,86 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.11
dev: true
/@jsr/db__sqlite@0.11.1:
resolution: {integrity: sha512-6IKyfi+TQan431kwOy3WrdzgKwITuDdSKfq6nkWINfNWknPQ+lQ6/R008bAUUz+AwW8e0prxi3IMMxzELUV8Lw==, tarball: https://npm.jsr.io/~/8/@jsr/db__sqlite/0.11.1.tgz}
dependencies:
'@jsr/denosaurs__plug': 1.0.6
'@jsr/std__path': 0.217.0
dev: false
/@jsr/denosaurs__plug@1.0.6:
resolution: {integrity: sha512-2uqvX2xpDy5W76jJVKazXvHuh5WPNg8eUV+2u+Hcn5XLwKqWGr/xj4wQFRMXrS12Xhya+ToZdUg4gxLh+XOOCg==, tarball: https://npm.jsr.io/~/8/@jsr/denosaurs__plug/1.0.6.tgz}
dependencies:
'@jsr/std__encoding': 0.221.0
'@jsr/std__fmt': 0.221.0
'@jsr/std__fs': 0.221.0
'@jsr/std__path': 0.221.0
dev: false
/@jsr/std__assert@0.217.0:
resolution: {integrity: sha512-RCQbXJeUVCgDGEPsrO57CI9Cgbo9NAWsJUhZ7vrHgtD//Ic32YmUQazdGKPZzao5Zn8dP6xV4Nma3HHZC5ySTw==, tarball: https://npm.jsr.io/~/8/@jsr/std__assert/0.217.0.tgz}
dependencies:
'@jsr/std__fmt': 0.217.0
dev: false
/@jsr/std__assert@0.221.0:
resolution: {integrity: sha512-2B+5fq4Rar8NmLms7sv9YfYlMukZDTNMQV5fXjtZvnaKc8ljt+59UsMtIjTXFsDwsAx7VoxkMKmmdHxlP+h5JA==, tarball: https://npm.jsr.io/~/8/@jsr/std__assert/0.221.0.tgz}
dependencies:
'@jsr/std__fmt': 0.221.0
dev: false
/@jsr/std__assert@0.223.0:
resolution: {integrity: sha512-9FWOoAQN1uF5SliWw3IgdXmk2usz5txvausX4sLAASHfQMbUSCe1akcD7HgFV01J/2Mr9TfCjPvsSUzuuASouQ==, tarball: https://npm.jsr.io/~/8/@jsr/std__assert/0.223.0.tgz}
dependencies:
'@jsr/std__fmt': 0.223.0
dev: false
/@jsr/std__bytes@0.223.0:
resolution: {integrity: sha512-BBjhj0uFlB3+AVEmaPygEwY5CL5mj3vSZlusC8xxjCRNWDYGukfQT/F5GOTTfjeaq7njduk7TYe6e5cDg659yg==, tarball: https://npm.jsr.io/~/8/@jsr/std__bytes/0.223.0.tgz}
dev: false
/@jsr/std__encoding@0.221.0:
resolution: {integrity: sha512-FT5i/WHNtXJvqOITDK0eOVIyyOphqtxwhzo5PiVWoYTFmUuFcRYKas39GT1UQDi4s24FcHd2deQEBbi3tPAj1Q==, tarball: https://npm.jsr.io/~/8/@jsr/std__encoding/0.221.0.tgz}
dev: false
/@jsr/std__fmt@0.217.0:
resolution: {integrity: sha512-L3mVYP7DsujrJ001SvPr4Fl/Fu0e3uzgHJ6NYTRUk7sgi9k7YKeLOLVwRijUX7qIsp3Ourp2DyAHHgYDgT4GcQ==, tarball: https://npm.jsr.io/~/8/@jsr/std__fmt/0.217.0.tgz}
dev: false
/@jsr/std__fmt@0.221.0:
resolution: {integrity: sha512-VLqM052U78LQ11p/KfqI49a2/sDbKtHFHuxO/h+3Cnvhze9beIZU4Lg3Gpu8rGYjB2YS6CfXzKXHuyAJn5FJFg==, tarball: https://npm.jsr.io/~/8/@jsr/std__fmt/0.221.0.tgz}
dev: false
/@jsr/std__fmt@0.223.0:
resolution: {integrity: sha512-J6SVTw/l3C4hOwEuqnZ4ZHD1jVIIZt09fb5LP9CMGyVGNnoW8/lxJvCNhIOv+3ZXC1ErGlIzW4bgYSxHwbvSaQ==, tarball: https://npm.jsr.io/~/8/@jsr/std__fmt/0.223.0.tgz}
dev: false
/@jsr/std__fs@0.221.0:
resolution: {integrity: sha512-2XMlO67zQlKoxbCsfGOBVlnyWhMMdOzYUWfajvggfw2p+yITd9hJj9+tpfiwLf/88CzknhlMLwSCamSYjHKloA==, tarball: https://npm.jsr.io/~/8/@jsr/std__fs/0.221.0.tgz}
dependencies:
'@jsr/std__assert': 0.221.0
'@jsr/std__path': 0.221.0
dev: false
/@jsr/std__io@0.223.0:
resolution: {integrity: sha512-K+OXJHsIf9227aYgNTaapEkpphHrI+oYVkl14UV+le+Fk9MzkJmebU0XAU6krgVS283mW7VPJsXVV3gD5JWvJw==, tarball: https://npm.jsr.io/~/8/@jsr/std__io/0.223.0.tgz}
dependencies:
'@jsr/std__assert': 0.223.0
'@jsr/std__bytes': 0.223.0
dev: false
/@jsr/std__path@0.217.0:
resolution: {integrity: sha512-OkP+yiBJpFZKTH3gHqlepYU3TQzXM/UjEQ0U1gYw8BwVr87TwKfzwAb1WT1vY/Bs8NXScvuP4Kpu/UhEsNHD3A==, tarball: https://npm.jsr.io/~/8/@jsr/std__path/0.217.0.tgz}
dependencies:
'@jsr/std__assert': 0.217.0
dev: false
/@jsr/std__path@0.221.0:
resolution: {integrity: sha512-uOWaY4cWp28CFBSisr8M/92FtpyjiFO0+wQSH7GgmiXQUls+vALqdCGewFkunG8RfA/25RGdot5hFXedmtPdOg==, tarball: https://npm.jsr.io/~/8/@jsr/std__path/0.221.0.tgz}
dependencies:
'@jsr/std__assert': 0.221.0
dev: false
/@ljharb/through@2.3.11:
resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==}
engines: {node: '>= 0.4'}

View file

@ -499,6 +499,10 @@ if (IS_JSR) {
for (const [name, version] of Object.entries(builtPkgJson.dependencies)) {
if (name.startsWith('@mtcute/')) {
importMap[name] = `jsr:${name}@${version}`
} else if (version.startsWith('npm:@jsr/')) {
const jsrName = version.slice(9).split('@')[0].replace('__', '/')
const jsrVersion = version.slice(9).split('@')[1]
importMap[name] = `jsr:@${jsrName}@${jsrVersion}`
} else {
importMap[name] = `npm:${name}@${version}`
}

View file

@ -0,0 +1,33 @@
import { createHash } from 'crypto'
import * as fs from 'fs/promises'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
const DTS_URL = 'https://github.com/denoland/deno/releases/download/v1.42.4/lib.deno.d.ts'
const SHA256 = '554b5da7baf05e5693ca064fcf1665b0b847743ccfd0db89cb6f2388f2de0276'
const LIB_TARGET = fileURLToPath(new URL('../node_modules/@types/deno/index.d.ts', import.meta.url))
const stat = await fs.stat(LIB_TARGET).catch(() => null)
if (stat?.isFile()) {
const sha256 = createHash('sha256').update(await fs.readFile(LIB_TARGET)).digest('hex')
if (sha256 === SHA256) {
console.log('lib.deno.d.ts is up to date')
process.exit(0)
}
}
const stream = await fetch(DTS_URL)
const dts = await stream.text()
const sha256 = createHash('sha256').update(dts).digest('hex')
if (sha256 !== SHA256) {
console.error(`lib.deno.d.ts SHA256 mismatch: expected ${SHA256}, got ${sha256}`)
process.exit(1)
}
await fs.mkdir(dirname(LIB_TARGET), { recursive: true }).catch(() => null)
await fs.writeFile(LIB_TARGET, dts)
console.log('lib.deno.d.ts updated')

View file

@ -25,6 +25,7 @@ const JSR_EXCEPTIONS = {
bun: 'never',
'create-bot': 'never',
'crypto-node': 'never',
deno: 'only',
node: 'never',
'http-proxy': 'never',
'socks-proxy': 'never',

View file

@ -0,0 +1,23 @@
import * as fs from 'fs'
import { globSync } from 'glob'
import { join } from 'path'
import { fileURLToPath } from 'url'
// for whatever reason, jsr's npm compatibility jayer doesn't remove
// original typescript source files, which results in type errors when
// trying to build the project. this script removes all source files from @jsr/*
// https://discord.com/channels/684898665143206084/1203185670508515399/1234222204044967967
const nodeModules = fileURLToPath(new URL('../node_modules', import.meta.url))
let count = 0
for (const file of globSync(join(nodeModules, '.pnpm/**/node_modules/@jsr/**/*.ts'))) {
if (file.endsWith('.d.ts')) continue
if (!fs.existsSync(file)) continue
fs.unlinkSync(file)
count++
}
console.log(`[jsr] removed ${count} source files`)

View file

@ -19,6 +19,7 @@
"composite": true,
"types": [
"node",
"deno",
"vite/client"
],
"lib": [