platform-agnostic sqlite implementation + bun support #26

Merged
teidesu merged 5 commits from sqlite-abstract into master 2024-03-23 23:14:11 +03:00
41 changed files with 859 additions and 37 deletions
Showing only changes of commit 1e780ec4af - Show all commits

View file

@ -278,6 +278,14 @@ module.exports = {
'no-restricted-globals': 'off', 'no-restricted-globals': 'off',
}, },
}, },
{
files: ['packages/bun/**'],
rules: {
'import/no-unresolved': 'off',
'no-restricted-imports': 'off',
'import/no-relative-packages': 'off', // common-internals is symlinked from node
}
}
], ],
settings: { settings: {
'import/resolver': { 'import/resolver': {

View file

@ -5,5 +5,5 @@ const TEST_ENV = import.meta.env.TEST_ENV
if (TEST_ENV === 'browser') { if (TEST_ENV === 'browser') {
setPlatform(new (await import('../../packages/web/src/platform.js')).WebPlatform()) setPlatform(new (await import('../../packages/web/src/platform.js')).WebPlatform())
} else { } else {
setPlatform(new (await import('../../packages/node/src/platform.js')).NodePlatform()) setPlatform(new (await import('../../packages/node/src/common-internals-node/platform.js')).NodePlatform())
} }

View file

@ -26,7 +26,7 @@ const SKIP_TESTS = [
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { lib: {
entry: (() => { entry: process.env.ENTRYPOINT ? [process.env.ENTRYPOINT] : (() => {
const files: string[] = [] const files: string[] = []
const packages = resolve(__dirname, '../packages') const packages = resolve(__dirname, '../packages')
@ -57,11 +57,15 @@ export default defineConfig({
'module', 'module',
'fs', 'fs',
'fs/promises', 'fs/promises',
'node:fs',
'readline',
'worker_threads',
'events', 'events',
'path', 'path',
'util', 'util',
'os', 'os',
'bun:test', 'bun:test',
'bun:sqlite',
], ],
output: { output: {
chunkFileNames: 'chunk-[hash].js', chunkFileNames: 'chunk-[hash].js',
@ -73,7 +77,7 @@ export default defineConfig({
commonjsOptions: { commonjsOptions: {
ignoreDynamicRequires: true, ignoreDynamicRequires: true,
}, },
outDir: 'dist/tests', outDir: process.env.OUT_DIR || 'dist/tests',
emptyOutDir: true, emptyOutDir: true,
target: 'esnext', target: 'esnext',
minify: false, minify: false,

View file

@ -2,6 +2,7 @@
"name": "mtcute-e2e", "name": "mtcute-e2e",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@mtcute/bun": "*",
"@mtcute/core": "*", "@mtcute/core": "*",
"@mtcute/crypto-node": "*", "@mtcute/crypto-node": "*",
"@mtcute/dispatcher": "*", "@mtcute/dispatcher": "*",

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

@ -0,0 +1,25 @@
# @mtcute/bun
📖 [API Reference](https://ref.mtcute.dev/modules/_mtcute_node.html)
‼️ **Experimental** Bun support package for mtcute. Includes:
- SQLite storage (based on `bun:sqlite`)
- TCP transport (based on Bun-native APIs)
- `TelegramClient` implementation using the above
- HTML and Markdown parsers
## Usage
```typescript
import { TelegramClient } from '@mtcute/bun'
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 })

35
packages/bun/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "@mtcute/bun",
"private": true,
"version": "0.8.0",
"description": "Meta-package for Bun",
"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 bun"
},
"exports": {
".": "./src/index.ts",
"./utils.js": "./src/utils.ts"
},
"distOnlyFields": {
"exports": {
".": "./index.js",
"./utils.js": "./utils.js"
}
},
"dependencies": {
"@mtcute/core": "workspace:^",
"@mtcute/wasm": "workspace:^",
"@mtcute/markdown-parser": "workspace:^",
"@mtcute/html-parser": "workspace:^"
},
"devDependencies": {
"@mtcute/test": "workspace:^",
"bun-types": "1.0.33"
}
}

147
packages/bun/src/client.ts Normal file
View file

@ -0,0 +1,147 @@
import { createInterface, Interface as RlInterface } from 'readline'
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 { NodePlatform } from './common-internals-node/platform.js'
import { downloadToFile } from './methods/download-file.js'
import { downloadAsNodeStream } from './methods/download-node-stream.js'
import { SqliteStorage } from './sqlite/index.js'
import { BunCryptoProvider } 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 NodePlatform())
super({
crypto: new BunCryptoProvider(),
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({
input: process.stdin,
output: process.stdout,
})
}
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)
}
downloadAsNodeStream(location: FileDownloadLocation, params?: FileDownloadParameters | undefined) {
return downloadAsNodeStream(this, location, params)
}
}

View file

@ -0,0 +1 @@
/Users/teidesu/Projects/mtcute/packages/node/src/common-internals-node

View file

@ -0,0 +1,9 @@
export * from './client.js'
export * from './common-internals-node/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,3 @@
export { downloadToFile } from './methods/download-file.js'
export { downloadAsNodeStream } from './methods/download-node-stream.js'
export * from '@mtcute/core/methods.js'

View file

@ -0,0 +1,39 @@
import { unlinkSync } from 'node:fs'
import { FileDownloadLocation, FileDownloadParameters, FileLocation, ITelegramClient } from '@mtcute/core'
import { downloadAsIterable } from '@mtcute/core/methods.js'
/**
* 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 Bun.write(filename, location.location)
}
const output = Bun.file(filename).writer()
if (params?.abortSignal) {
params.abortSignal.addEventListener('abort', () => {
client.log.debug('aborting file download %s - cleaning up', filename)
Promise.resolve(output.end()).catch(() => {})
unlinkSync(filename)
})
}
for await (const chunk of downloadAsIterable(client, location, params)) {
output.write(chunk)
}
await output.end()
}

View file

@ -0,0 +1,19 @@
import { Readable } from 'stream'
import { FileDownloadLocation, FileDownloadParameters, ITelegramClient } from '@mtcute/core'
import { downloadAsStream } from '@mtcute/core/methods.js'
/**
* Download a remote file as a Node.js Readable stream
* (discouraged under Bun, since Web Streams are first-class).
*
* @param params File download parameters
*/
export function downloadAsNodeStream(
client: ITelegramClient,
location: FileDownloadLocation,
params?: FileDownloadParameters,
): Readable {
// @ts-expect-error typings are wrong
return Readable.fromWeb(downloadAsStream(client, location, params))
}

View file

@ -0,0 +1,36 @@
import { Database } from 'bun:sqlite'
import { BaseSqliteStorageDriver, ISqliteDatabase } from '@mtcute/core'
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)
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 === 'bun') {
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 === 'bun') {
describe('BunCryptoProvider', async () => {
const { BunCryptoProvider } = await import('./crypto.js')
testCryptoProvider(new BunCryptoProvider())
})
} else {
describe.skip('BunCryptoProvider', () => {})
}

View file

@ -0,0 +1,119 @@
// eslint-disable-next-line no-restricted-imports
import { readFile } from 'fs/promises'
import { BaseCryptoProvider, IAesCtr, ICryptoProvider, IEncryptionScheme } from '@mtcute/core/utils.js'
import {
createCtr256,
ctr256,
deflateMaxSize,
freeCtr256,
gunzip,
ige256Decrypt,
ige256Encrypt,
initSync,
} from '@mtcute/wasm'
// we currently prefer subtle crypto and wasm for ctr because bun uses browserify polyfills for node:crypto
// which are slow AND semi-broken
// we currently prefer wasm for gzip because bun uses browserify polyfills for node:zlib too
// native node-api addon is broken on macos so we don't support it either
//
// largely just copy-pasting from @mtcute/web, todo: maybe refactor this into common-internals-web?
const ALGO_TO_SUBTLE: Record<string, string> = {
sha256: 'SHA-256',
sha1: 'SHA-1',
sha512: 'SHA-512',
}
export class BunCryptoProvider extends BaseCryptoProvider implements ICryptoProvider {
async initialize(): Promise<void> {
// eslint-disable-next-line no-restricted-globals
const wasmFile = require.resolve('@mtcute/wasm/mtcute.wasm')
const wasm = await readFile(wasmFile)
initSync(wasm)
}
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)
},
}
}
createAesCtr(key: Uint8Array, iv: Uint8Array): IAesCtr {
const ctx = createCtr256(key, iv)
return {
process: (data) => ctr256(ctx, data),
close: () => freeCtr256(ctx),
}
}
async pbkdf2(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
keylen = 64,
algo = 'sha512',
): Promise<Uint8Array> {
const keyMaterial = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits'])
return crypto.subtle
.deriveBits(
{
name: 'PBKDF2',
salt,
iterations,
hash: algo ? ALGO_TO_SUBTLE[algo] : 'SHA-512',
},
keyMaterial,
(keylen || 64) * 8,
)
.then((result) => new Uint8Array(result))
}
sha1(data: Uint8Array): Uint8Array {
const res = new Uint8Array(Bun.SHA1.byteLength)
Bun.SHA1.hash(data, res)
return res
}
sha256(data: Uint8Array): Uint8Array {
const res = new Uint8Array(Bun.SHA256.byteLength)
Bun.SHA256.hash(data, res)
return res
}
async hmacSha256(data: Uint8Array, key: Uint8Array): Promise<Uint8Array> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign'],
)
const res = await crypto.subtle.sign({ name: 'HMAC' }, keyMaterial, data)
return new Uint8Array(res)
}
gzip(data: Uint8Array, maxSize: number): Uint8Array | null {
return deflateMaxSize(data, maxSize)
}
gunzip(data: Uint8Array): Uint8Array {
return gunzip(data)
}
randomFill(buf: Uint8Array) {
crypto.getRandomValues(buf)
}
}

View file

@ -0,0 +1,33 @@
import { ReadStream } from 'fs'
import { stat } from 'fs/promises'
import { basename } from 'path'
import { Readable as NodeReadable } from 'stream'
import { UploadFileLike } from '@mtcute/core'
export async function normalizeFile(file: UploadFileLike) {
if (typeof file === 'string') {
file = Bun.file(file)
}
// while these are not Bun-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>,
}
}
// string -> ReadStream, thus already handled
return null
}

View file

@ -0,0 +1,126 @@
import { Socket } from 'bun'
import EventEmitter from 'events'
import { IntermediatePacketCodec, IPacketCodec, ITelegramTransport, MtcuteError, TransportState } from '@mtcute/core'
import { BasicDcOption, ICryptoProvider, Logger } from '@mtcute/core/utils.js'
/**
* 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 @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)
Bun.connect({
hostname: dc.ipAddress,
port: dc.port,
socket: {
open: this.handleConnect.bind(this),
error: this.handleError.bind(this),
data: (socket, data) => this._packetCodec.feed(data),
close: this.close.bind(this),
},
}).catch((err) => {
this.handleError(null, err as Error)
this.close()
})
}
close(): void {
if (this._state === TransportState.Idle) return
this.log.info('connection closed')
this.emit('close')
this._state = TransportState.Idle
this._socket?.end()
this._socket = null
this._currentDc = null
this._packetCodec.reset()
}
handleError(socket: unknown, error: Error): void {
this.log.error('error: %s', error.stack)
this.emit('error', error)
}
handleConnect(socket: Socket): void {
this._socket = socket
this.log.info('connected')
Promise.resolve(this._packetCodec.tag())
.then((initialMessage) => {
if (initialMessage.length) {
this._socket!.write(initialMessage)
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')
}
this._socket!.write(framed)
}
}
export class TcpTransport extends BaseTcpTransport {
_packetCodec = new IntermediatePacketCodec()
}

View file

@ -0,0 +1,67 @@
import { parentPort, Worker } from 'worker_threads'
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 { NodePlatform } from './common-internals-node/platform.js'
export type { TelegramWorkerOptions, TelegramWorkerPortOptions, WorkerCustomMethods }
let _registered = false
export class TelegramWorker<T extends WorkerCustomMethods> extends TelegramWorkerBase<T> {
registerWorker(handler: WorkerMessageHandler): RespondFn {
if (!parentPort) {
throw new Error('TelegramWorker must be created from a worker thread')
}
if (_registered) {
throw new Error('TelegramWorker must be created only once')
}
_registered = true
const port = parentPort
const respond: RespondFn = port.postMessage.bind(port)
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
parentPort.on('message', (message) => handler(message, respond))
return respond
}
}
export class TelegramWorkerPort<T extends WorkerCustomMethods> extends TelegramWorkerPortBase<T> {
constructor(readonly options: TelegramWorkerPortOptions) {
setPlatform(new NodePlatform())
super(options)
}
connectToWorker(worker: SomeWorker, handler: ClientMessageHandler): [SendFn, () => void] {
if (!(worker instanceof Worker)) {
throw new Error('Only worker_threads are supported')
}
const send: SendFn = worker.postMessage.bind(worker)
worker.on('message', handler)
return [
send,
() => {
worker.off('message', handler)
},
]
}
}

View file

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

10
packages/bun/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

@ -25,6 +25,8 @@ const MAX_PART_COUNT_PREMIUM = 8000 // 512 kb * 8000 = 4000 MiB
// platform-specific // platform-specific
const HAS_FILE = typeof File !== 'undefined' const HAS_FILE = typeof File !== 'undefined'
const HAS_RESPONSE = typeof Response !== 'undefined' const HAS_RESPONSE = typeof Response !== 'undefined'
const HAS_URL = typeof URL !== 'undefined'
const HAS_BLOB = typeof Blob !== 'undefined'
// @available=both // @available=both
/** /**
@ -129,6 +131,15 @@ export async function uploadFile(
file = file.stream() file = file.stream()
} }
if (HAS_URL && file instanceof URL) {
file = await fetch(file)
}
if (HAS_BLOB && file instanceof Blob) {
fileSize = file.size
file = file.stream()
}
if (HAS_RESPONSE && file instanceof Response) { if (HAS_RESPONSE && file instanceof Response) {
const length = parseInt(file.headers.get('content-length') || '0') const length = parseInt(file.headers.get('content-length') || '0')
if (!isNaN(length) && length) fileSize = length if (!isNaN(length) && length) fileSize = length

View file

@ -11,16 +11,20 @@ import { UploadedFile } from './uploaded-file.js'
* Describes types that can be used in {@link TelegramClient.uploadFile} * Describes types that can be used in {@link TelegramClient.uploadFile}
* method. Can be one of: * method. Can be one of:
* - `Uint8Array`/`Buffer`, which will be interpreted as raw file contents * - `Uint8Array`/`Buffer`, which will be interpreted as raw file contents
* - `File` (from the Web API) * - `File`, `Blob` (from the Web API)
* - `string`, which will be interpreted as file path (**non-browser only!**) * - `string`, which will be interpreted as file path (**non-browser only!**)
* - `ReadStream` (for NodeJS, from the `fs` module) * - `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)
* - `BunFile` (from `Bun.file()`)
* - `ReadableStream` (Web API readable stream) * - `ReadableStream` (Web API readable stream)
* - `Readable` (NodeJS readable stream) * - `Readable` (Node.js/Bun readable stream)
* - `Response` (from `window.fetch`) * - `Response` (from `window.fetch`)
*/ */
export type UploadFileLike = export type UploadFileLike =
| URL
| Uint8Array | Uint8Array
| File | File
| Blob
| string | string
| ReadStream | ReadStream
| ReadableStream<Uint8Array> | ReadableStream<Uint8Array>
@ -34,14 +38,15 @@ export type UploadFileLike =
* Can be one of: * Can be one of:
* - `Buffer`, which will be interpreted as raw file contents * - `Buffer`, which will be interpreted as raw file contents
* - `File` (from the Web API) * - `File` (from the Web API)
* - `ReadStream` (for NodeJS, from the `fs` module) * - `ReadStream` (for Node.js/Bun, from the `fs` module)
* - `ReadableStream` (from the Web API, base readable stream) * - `ReadableStream` (from the Web API, base readable stream)
* - `Readable` (for NodeJS, base readable stream) * - `Readable` (for Node.js/Bun, base readable stream)
* - {@link UploadedFile} returned from {@link TelegramClient.uploadFile} * - {@link UploadedFile} returned from {@link TelegramClient.uploadFile}
* - `tl.TypeInputFile` and `tl.TypeInputMedia` TL objects * - `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`) * - `string` with a path to a local file prepended with `file:` (non-browser only) (e.g. `file:image.jpg`)
* - `string` with a URL to remote files (e.g. `https://example.com/image.jpg`) * - `string` with a URL to remote files (e.g. `https://example.com/image.jpg`)
* - `string` with TDLib and Bot API compatible File ID. * - `string` with TDLib and Bot API compatible File ID.
* - `URL` (from the Web API, will be `fetch()`-ed if needed; `file://` URLs are not available in browsers)
* - `td.RawFullRemoteFileLocation` (parsed File ID) * - `td.RawFullRemoteFileLocation` (parsed File ID)
*/ */
export type InputFileLike = export type InputFileLike =

View file

@ -10,9 +10,9 @@ import {
} from '@mtcute/core/client.js' } from '@mtcute/core/client.js'
import { setPlatform } from '@mtcute/core/platform.js' import { setPlatform } from '@mtcute/core/platform.js'
import { NodePlatform } from './common-internals-node/platform.js'
import { downloadToFile } from './methods/download-file.js' import { downloadToFile } from './methods/download-file.js'
import { downloadAsNodeStream } from './methods/download-node-stream.js' import { downloadAsNodeStream } from './methods/download-node-stream.js'
import { NodePlatform } from './platform.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'

View file

@ -2,17 +2,13 @@ import * as os from 'os'
import { ICorePlatform } from '@mtcute/core/platform.js' import { ICorePlatform } from '@mtcute/core/platform.js'
import { beforeExit } from './utils/exit-hook.js' import { normalizeFile } from '../utils/normalize-file.js'
import { defaultLoggingHandler } from './utils/logging.js' import { beforeExit } from './exit-hook.js'
import { normalizeFile } from './utils/normalize-file.js' import { defaultLoggingHandler } from './logging.js'
const BUFFER_BASE64_URL_AVAILABLE = typeof Buffer.isEncoding === 'function' && Buffer.isEncoding('base64url') const BUFFER_BASE64_URL_AVAILABLE = typeof Buffer.isEncoding === 'function' && Buffer.isEncoding('base64url')
const toBuffer = (buf: Uint8Array): Buffer => Buffer.from( const toBuffer = (buf: Uint8Array): Buffer => Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength)
buf.buffer,
buf.byteOffset,
buf.byteLength,
)
export class NodePlatform implements ICorePlatform { export class NodePlatform implements ICorePlatform {
// ICorePlatform // ICorePlatform

View file

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

View file

@ -1,5 +1,5 @@
export * from './client.js' export * from './client.js'
export * from './platform.js' export * from './common-internals-node/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'

View file

@ -1,5 +1,3 @@
/* eslint-disable import/export, simple-import-sort/exports */
export * from '@mtcute/core/methods.js'
export { downloadToFile } from './methods/download-file.js' export { downloadToFile } from './methods/download-file.js'
export { downloadAsNodeStream } from './methods/download-node-stream.js' export { downloadAsNodeStream } from './methods/download-node-stream.js'
export * from '@mtcute/core/methods.js'

View file

@ -3,7 +3,6 @@ import { BaseSqliteStorage } from '@mtcute/core'
import { SqliteStorageDriver, SqliteStorageDriverOptions } from './driver.js' import { SqliteStorageDriver, SqliteStorageDriverOptions } from './driver.js'
export { SqliteStorageDriver } from './driver.js' export { SqliteStorageDriver } from './driver.js'
export type { Statement } from 'better-sqlite3'
export class SqliteStorage extends BaseSqliteStorage { export class SqliteStorage extends BaseSqliteStorage {
constructor( constructor(

View file

@ -2,7 +2,7 @@ import { describe } from 'vitest'
import { testCryptoProvider } from '@mtcute/test' import { testCryptoProvider } from '@mtcute/test'
if (import.meta.env.TEST_ENV === 'node' || import.meta.env.TEST_ENV === 'bun') { if (import.meta.env.TEST_ENV === 'node') {
describe('NodeCryptoProvider', async () => { describe('NodeCryptoProvider', async () => {
const { NodeCryptoProvider } = await import('./crypto.js') const { NodeCryptoProvider } = await import('./crypto.js')

View file

@ -5,7 +5,7 @@ import { Readable } from 'stream'
import { UploadFileLike } from '@mtcute/core' import { UploadFileLike } from '@mtcute/core'
import { nodeStreamToWeb } from '../utils/stream-utils.js' import { nodeStreamToWeb } from './stream-utils.js'
export async function normalizeFile(file: UploadFileLike) { export async function normalizeFile(file: UploadFileLike) {
if (typeof file === 'string') { if (typeof file === 'string') {

View file

@ -4,7 +4,7 @@ import { isNodeVersionAfter } from './version.js'
export function nodeStreamToWeb(stream: Readable): ReadableStream<Uint8Array> { export function nodeStreamToWeb(stream: Readable): ReadableStream<Uint8Array> {
if (typeof Readable.toWeb === 'function') { if (typeof Readable.toWeb === 'function') {
return Readable.toWeb(stream) return Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>
} }
// otherwise, use a silly little adapter // otherwise, use a silly little adapter
@ -57,9 +57,12 @@ export function webStreamToNode(stream: ReadableStream<Uint8Array>): Readable {
}, },
destroy(error, cb) { destroy(error, cb) {
if (!ended) { if (!ended) {
void reader.cancel(error).catch(() => {}).then(() => { void reader
cb(error) .cancel(error)
}) .catch(() => {})
.then(() => {
cb(error)
})
return return
} }
@ -68,11 +71,13 @@ export function webStreamToNode(stream: ReadableStream<Uint8Array>): Readable {
}, },
}) })
reader.closed.then(() => { reader.closed
ended = true .then(() => {
}).catch((err) => { ended = true
readable.destroy(err as Error) })
}) .catch((err) => {
readable.destroy(err as Error)
})
return readable return readable
} }

View file

@ -1,5 +1,5 @@
export const NODE_VERSION = typeof process !== 'undefined' && 'node' in process.versions ? process.versions.node : null export const NODE_VERSION = typeof process !== 'undefined' && 'node' in process.versions ? process.versions.node : null
export const NODE_VERSION_TUPLE = NODE_VERSION ? NODE_VERSION.split('.').map(Number) : null export const NODE_VERSION_TUPLE = NODE_VERSION ? /*#__PURE__*/ NODE_VERSION.split('.').map(Number) : null
export function isNodeVersionAfter(major: number, minor: number, patch: number): boolean { export function isNodeVersionAfter(major: number, minor: number, patch: number): boolean {
if (!NODE_VERSION_TUPLE) return true // assume non-node environment is always "after" if (!NODE_VERSION_TUPLE) return true // assume non-node environment is always "after"

View file

@ -14,7 +14,7 @@ import {
WorkerMessageHandler, WorkerMessageHandler,
} from '@mtcute/core/worker.js' } from '@mtcute/core/worker.js'
import { NodePlatform } from './platform.js' import { NodePlatform } from './common-internals-node/platform.js'
export type { TelegramWorkerOptions, TelegramWorkerPortOptions, WorkerCustomMethods } export type { TelegramWorkerOptions, TelegramWorkerPortOptions, WorkerCustomMethods }

View file

@ -41,7 +41,7 @@ export class TlBinaryReader {
*/ */
constructor( constructor(
readonly objectsMap: TlReaderMap | undefined, readonly objectsMap: TlReaderMap | undefined,
data: ArrayBuffer, data: ArrayBuffer | ArrayBufferView,
start = 0, start = 0,
) { ) {
if (ArrayBuffer.isView(data)) { if (ArrayBuffer.isView(data)) {
@ -61,7 +61,7 @@ export class TlBinaryReader {
* @param data Buffer to read from * @param data Buffer to read from
* @param start Position to start reading from * @param start Position to start reading from
*/ */
static manual(data: ArrayBuffer, start = 0): TlBinaryReader { static manual(data: ArrayBuffer | ArrayBufferView, start = 0): TlBinaryReader {
return new TlBinaryReader(undefined, data, start) return new TlBinaryReader(undefined, data, start)
} }

View file

@ -115,6 +115,28 @@ importers:
specifier: 1.4.0 specifier: 1.4.0
version: 1.4.0(@types/node@20.10.0)(@vitest/browser@1.4.0)(@vitest/ui@1.4.0) version: 1.4.0(@types/node@20.10.0)(@vitest/browser@1.4.0)(@vitest/ui@1.4.0)
packages/bun:
dependencies:
'@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
devDependencies:
'@mtcute/test':
specifier: workspace:^
version: link:../test
bun-types:
specifier: 1.0.33
version: 1.0.33
packages/convert: packages/convert:
dependencies: dependencies:
'@mtcute/core': '@mtcute/core':
@ -294,6 +316,9 @@ importers:
'@types/better-sqlite3': '@types/better-sqlite3':
specifier: 7.6.4 specifier: 7.6.4
version: 7.6.4 version: 7.6.4
'@types/node':
specifier: 20.10.0
version: 20.10.0
packages/socks-proxy: packages/socks-proxy:
dependencies: dependencies:
@ -1403,6 +1428,12 @@ packages:
dependencies: dependencies:
undici-types: 5.26.5 undici-types: 5.26.5
/@types/node@20.11.30:
resolution: {integrity: sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==}
dependencies:
undici-types: 5.26.5
dev: true
/@types/normalize-package-data@2.4.1: /@types/normalize-package-data@2.4.1:
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
dev: true dev: true
@ -1423,6 +1454,12 @@ packages:
'@types/node': 20.10.0 '@types/node': 20.10.0
dev: true dev: true
/@types/ws@8.5.10:
resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==}
dependencies:
'@types/node': 20.10.0
dev: true
/@types/ws@8.5.4: /@types/ws@8.5.4:
resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==}
dependencies: dependencies:
@ -2032,6 +2069,13 @@ packages:
resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==}
dev: true dev: true
/bun-types@1.0.33:
resolution: {integrity: sha512-L5tBIf9g6rBBkvshqysi5NoLQ9NnhSPU1pfJ9FzqoSfofYdyac3WLUnOIuQ+M5za/sooVUOP2ko+E6Tco0OLIA==}
dependencies:
'@types/node': 20.11.30
'@types/ws': 8.5.10
dev: true
/cac@6.7.14: /cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'} engines: {node: '>=8'}