deno support (#52)

This commit is contained in:
alina sireneva 2024-04-30 15:58:04 +03:00 committed by GitHub
commit 4acd0d58e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1575 additions and 79 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

1
e2e/deno/.gitignore vendored
View file

@ -1,3 +1,4 @@
/.jsr-data
.env
/deno.lock
/.sessions

View file

@ -45,19 +45,27 @@ case "$method" in
source .env
fi
if [ -n "$DOCKER" ]; then
export JSR_URL=http://localhost:4873
if [ ! -z ${DOCKER+x} ]; then
# running behind a socat proxy seems to fix some of the docker networking issues (thx kamillaova)
socat TCP-LISTEN:4873,fork,reuseaddr TCP4:jsr:80 &
socat_pid=$!
# run `deno cache` with a few retries to make sure everything is cached
for i in {1..5}; do
if deno cache tests/*.ts; then
break
fi
done
trap "kill $socat_pid" EXIT
fi
export JSR_URL=http://localhost:4873
if [ -z "$@" ]; then
deno test -A tests/**/*.ts
if [ $# -eq 0 ]; then
deno test -A --unstable-ffi tests/**/*.ts
else
deno test -A $@
deno test -A --unstable-ffi $@
fi
;;
"run-docker")
@ -69,7 +77,7 @@ case "$method" in
if [ -d .jsr-data ]; then
# clean up data from previous runs
docker compose down
rm -rf .jsr-data
sudo rm -rf .jsr-data
fi
mkdir .jsr-data
./cli.sh start

View file

@ -4,6 +4,7 @@
"@mtcute/wasm": "jsr:@mtcute/wasm@*",
"@mtcute/tl": "jsr:@mtcute/tl@*",
"@mtcute/tl-runtime": "jsr:@mtcute/tl-runtime@*",
"@mtcute/core": "jsr:@mtcute/core@*"
"@mtcute/core": "jsr:@mtcute/core@*",
"@mtcute/deno": "jsr:@mtcute/deno@*"
}
}

83
e2e/deno/tests/01.auth.ts Normal file
View file

@ -0,0 +1,83 @@
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { MtcuteError } from '@mtcute/core'
import { BaseTelegramClient, TelegramClient } from '@mtcute/core/client.js'
import { getApiParams } from '../utils.ts'
const getAccountId = () =>
Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0')
Deno.test('1. authorization', { sanitizeResources: false }, async (t) => {
await t.step('should authorize in default dc', async () => {
const base = new BaseTelegramClient(getApiParams('dc2.session'))
const tg = new TelegramClient({ client: base })
// reset storage just in case
await base.mt.storage.load()
await base.storage.clear(true)
while (true) {
const phone = `999662${getAccountId()}`
let user
try {
user = await tg.start({
phone,
code: () => '22222',
})
} catch (e) {
if (e instanceof MtcuteError && e.message.match(/Signup is no longer supported|2FA is enabled/)) {
// retry with another number
continue
} else {
await tg.close()
throw e
}
}
await tg.close()
assertEquals(user.isSelf, true)
assertEquals(user.phoneNumber, phone)
break
}
})
await t.step('should authorize in dc 1', async () => {
const base = new BaseTelegramClient(getApiParams('dc1.session'))
const tg = new TelegramClient({ client: base })
// reset storage just in case
await base.mt.storage.load()
await base.mt.storage.clear(true)
while (true) {
const phone = `999661${getAccountId()}`
let user
try {
user = await tg.start({
phone,
code: () => '11111',
})
} catch (e) {
if (e instanceof MtcuteError && e.message.match(/Signup is no longer supported|2FA is enabled/)) {
// retry with another number
continue
} else {
await tg.close()
throw e
}
}
await tg.close()
assertEquals(user.isSelf, true)
assertEquals(user.phoneNumber, phone)
break
}
})
})

View file

@ -0,0 +1,47 @@
import { assertEquals, assertNotEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { TelegramClient } from '@mtcute/core/client.js'
import { getApiParams } from '../utils.ts'
Deno.test('2. calling methods', { sanitizeResources: false }, async (t) => {
const tg = new TelegramClient(getApiParams('dc2.session'))
await tg.connect()
await t.step('getUsers(@BotFather)', async () => {
const [user] = await tg.getUsers('botfather')
assertEquals(user?.isBot, true)
assertEquals(user?.displayName, 'BotFather')
})
await t.step('getUsers(@BotFather) - cached', async () => {
const [user] = await tg.getUsers('botfather')
assertEquals(user?.isBot, true)
assertEquals(user?.displayName, 'BotFather')
})
await t.step('getHistory(777000)', async () => {
const history = await tg.getHistory(777000, { limit: 5 })
assertEquals(history[0].chat.chatType, 'private')
assertEquals(history[0].chat.id, 777000)
assertEquals(history[0].chat.firstName, 'Telegram')
})
await t.step('updateProfile', async () => {
const bio = `mtcute e2e ${new Date().toISOString()}`
const oldSelf = await tg.getFullChat('self')
const res = await tg.updateProfile({ bio })
const newSelf = await tg.getFullChat('self')
assertEquals(res.isSelf, true)
assertNotEquals(oldSelf.bio, newSelf.bio)
assertEquals(newSelf.bio, bio)
})
await tg.close()
})

170
e2e/deno/tests/03.files.ts Normal file
View file

@ -0,0 +1,170 @@
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { createHash } from 'node:crypto'
import { FileDownloadLocation, Thumbnail } from '@mtcute/core'
import { TelegramClient } from '@mtcute/core/client.js'
import { sleep } from '@mtcute/core/utils.js'
import { getApiParams } from '../utils.ts'
const CINNAMOROLL_PFP_CHAT = 'test_file_dc2'
const CINNAMOROLL_PFP_THUMB_SHA256 = '3e6f220235a12547c16129f50c19ed3224d39b827414d1d500f79569a3431eae'
const CINNAMOROLL_PFP_SHA256 = '4d9836a71ac039f5656cde55b83525871549bfbff9cfb658c3f8381c5ba89ce8'
const UWU_MSG = 'https://t.me/test_file_dc2/8'
const UWU_SHA256 = '357b78c9f9d20e813f729a19dd90c6727f30ebd4c8c83557022285f283a705b9'
const SHREK_MSG = 'https://t.me/test_file_dc2/11'
const SHREK_SHA256 = 'd3e6434e027f3d31dc3e05c6ea2eaf84fdd1fb00774a215f89d9ed8b56f86258'
const LARGE_MSG = 'https://t.me/test_file_dc2/12'
async function downloadAsSha256(client: TelegramClient, location: FileDownloadLocation): Promise<string> {
const sha = createHash('sha256')
for await (const chunk of client.downloadAsIterable(location)) {
sha.update(chunk)
}
return sha.digest('hex')
}
Deno.test('3. working with files', { sanitizeResources: false }, async (t) => {
// sometimes test dcs are overloaded and we get FILE_REFERENCE_EXPIRED
// because we got multiple -500:No workers running errors in a row
// we currently don't have file references database, so we can just retry the test for now
//
// ...except we can't under deno because it's not implemented
// https://github.com/denoland/deno/issues/19882
// this.retries(2)
await t.step('same-dc', async (t) => {
const tg = new TelegramClient(getApiParams('dc2.session'))
await tg.connect()
await t.step('should download pfp thumbs', async () => {
const chat = await tg.getChat(CINNAMOROLL_PFP_CHAT)
if (!chat.photo) throw new Error('Chat has no photo')
assertEquals(await downloadAsSha256(tg, chat.photo.big), CINNAMOROLL_PFP_THUMB_SHA256)
})
await t.step('should download animated pfps', async () => {
const chat = await tg.getFullChat(CINNAMOROLL_PFP_CHAT)
const thumb = chat.fullPhoto?.getThumbnail(Thumbnail.THUMB_VIDEO_PROFILE)
if (!thumb) throw new Error('Chat has no animated pfp')
assertEquals(await downloadAsSha256(tg, thumb), CINNAMOROLL_PFP_SHA256)
})
await t.step('should download photos', async () => {
const msg = await tg.getMessageByLink(UWU_MSG)
if (msg?.media?.type !== 'photo') {
throw new Error('Message not found or not a photo')
}
assertEquals(await downloadAsSha256(tg, msg.media), UWU_SHA256)
})
await t.step('should download documents', async () => {
const msg = await tg.getMessageByLink(SHREK_MSG)
if (msg?.media?.type !== 'document') {
throw new Error('Message not found or not a document')
}
assertEquals(await downloadAsSha256(tg, msg.media), SHREK_SHA256)
})
await t.step('should cancel downloads', async () => {
const msg = await tg.getMessageByLink(LARGE_MSG)
if (msg?.media?.type !== 'document') {
throw new Error('Message not found or not a document')
}
const media = msg.media
const abort = new AbortController()
let downloaded = 0
async function download() {
const dl = tg.downloadAsIterable(media, { abortSignal: abort.signal })
try {
for await (const chunk of dl) {
downloaded += chunk.length
}
} catch (e) {
if (!(e instanceof DOMException && e.name === 'AbortError')) throw e
}
}
const promise = download()
// let it download for 10 seconds
await sleep(10000)
abort.abort()
// abort and snap the downloaded amount
const downloadedBefore = downloaded
const avgSpeed = downloaded / 10
// eslint-disable-next-line no-console
console.log('Average speed: %d KiB/s', avgSpeed / 1024)
// wait a bit more to make sure it's aborted
await sleep(2000)
await promise
assertEquals(downloaded, downloadedBefore, 'nothing should be downloaded after abort')
})
await tg.close()
})
await t.step('cross-dc', async (t) => {
const tg = new TelegramClient(getApiParams('dc1.session'))
await tg.connect()
await t.step('should download pfp thumbs', async () => {
const chat = await tg.getChat(CINNAMOROLL_PFP_CHAT)
if (!chat.photo) throw new Error('Chat has no photo')
assertEquals(await downloadAsSha256(tg, chat.photo.big), CINNAMOROLL_PFP_THUMB_SHA256)
})
await t.step('should download animated pfps', async () => {
const chat = await tg.getFullChat(CINNAMOROLL_PFP_CHAT)
const thumb = chat.fullPhoto?.getThumbnail(Thumbnail.THUMB_VIDEO_PROFILE)
if (!thumb) throw new Error('Chat has no animated pfp')
assertEquals(await downloadAsSha256(tg, thumb), CINNAMOROLL_PFP_SHA256)
})
await t.step('should download photos', async () => {
const msg = await tg.getMessageByLink(UWU_MSG)
if (msg?.media?.type !== 'photo') {
throw new Error('Message not found or not a photo')
}
assertEquals(await downloadAsSha256(tg, msg.media), UWU_SHA256)
})
await t.step('should download documents', async () => {
const msg = await tg.getMessageByLink(SHREK_MSG)
if (msg?.media?.type !== 'document') {
throw new Error('Message not found or not a document')
}
assertEquals(await downloadAsSha256(tg, msg.media), SHREK_SHA256)
})
await tg.close()
})
})

View file

@ -0,0 +1,47 @@
import { assertEquals, assertNotEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { Message } from '@mtcute/core'
import { TelegramClient } from '@mtcute/core/client.js'
import { getApiParams, waitFor } from '../utils.ts'
Deno.test('4. handling updates', { sanitizeResources: false }, async (t) => {
const tg1 = new TelegramClient(getApiParams('dc1.session'))
tg1.log.prefix = '[tg1] '
const tg2 = new TelegramClient(getApiParams('dc2.session'))
tg2.log.prefix = '[tg2] '
await tg1.connect()
await tg1.startUpdatesLoop()
await tg2.connect()
await t.step('should send and receive messages', async () => {
const tg1Messages: Message[] = []
tg1.on('new_message', (msg) => tg1Messages.push(msg))
const [tg1User] = await tg1.getUsers('self')
let username = tg1User!.username
if (!username) {
username = `mtcute_e2e_${Math.random().toString(36).slice(2)}`
await tg1.setMyUsername(username)
}
const messageText = `mtcute test message ${Math.random().toString(36).slice(2)}`
const sentMsg = await tg2.sendText(username, messageText)
assertEquals(sentMsg.text, messageText)
assertEquals(sentMsg.chat.id, tg1User!.id)
await waitFor(() => {
assertNotEquals(
tg1Messages.find((msg) => msg.text === messageText),
undefined,
)
})
})
await tg1.close()
await tg2.close()
})

View file

@ -0,0 +1,79 @@
import { assertEquals, assertGreater, assertInstanceOf } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { TelegramClient } from '@mtcute/core/client.js'
import { Message, TelegramWorkerPort, tl } from '@mtcute/deno'
import { getApiParams, waitFor } from '../utils.ts'
import type { CustomMethods } from './_worker.ts'
Deno.test('5. worker', { sanitizeResources: false }, async (t) => {
const worker = new Worker(new URL('_worker.ts', import.meta.url), {
type: 'module',
})
const port = new TelegramWorkerPort<CustomMethods>({
worker,
})
const portClient = new TelegramClient({ client: port })
await t.step('should make api calls', async function () {
const res = await port.call({ _: 'help.getConfig' })
assertEquals(res._, 'config')
})
await t.step('should call custom methods', async function () {
const hello = await port.invokeCustom('hello')
assertEquals(hello, 'world')
const sum = await port.invokeCustom('sum', 2, 3)
assertEquals(sum, 5)
})
await t.step('should throw errors', async function () {
try {
await port.call({ _: 'test.useConfigSimple' })
throw new Error('should have thrown')
} catch (e) {
assertInstanceOf(e, tl.RpcError)
}
})
await t.step('should receive updates', async function () {
const client2 = new TelegramClient(getApiParams('dc2.session'))
try {
await client2.connect()
await port.startUpdatesLoop()
const me = await portClient.getMe()
let username = me.username
if (!username) {
username = `mtcute_e2e_${Math.random().toString(36).slice(2, 8)}`
await portClient.setMyUsername(username)
}
const msgs: Message[] = []
portClient.on('new_message', (msg) => {
msgs.push(msg)
})
const testText = `test ${Math.random()}`
await client2.sendText(username, testText)
await waitFor(() => {
assertGreater(msgs.length, 0)
assertEquals(msgs[0].text, testText)
})
} catch (e) {
await client2.close()
throw e
}
await client2.close()
})
await port.close()
worker.terminate()
})

18
e2e/deno/tests/_worker.ts Normal file
View file

@ -0,0 +1,18 @@
import { WorkerCustomMethods } from '@mtcute/core/worker.js'
import { BaseTelegramClient, TelegramWorker } from '@mtcute/deno'
import { getApiParams } from '../utils.ts'
const customMethods = {
hello: async () => 'world',
sum: async (a: number, b: number) => a + b,
} as const satisfies WorkerCustomMethods
export type CustomMethods = typeof customMethods
const client = new BaseTelegramClient(getApiParams('dc1.session'))
// eslint-disable-next-line no-new
new TelegramWorker({
client,
customMethods,
})

View file

@ -1,25 +1,25 @@
import { MaybePromise, MemoryStorage } from '@mtcute/core'
import { setPlatform } from '@mtcute/core/platform.js'
import { LogManager, sleep } from '@mtcute/core/utils.js'
import { WebCryptoProvider, WebPlatform, WebSocketTransport } from '@mtcute/web'
import { DenoCryptoProvider, DenoPlatform, SqliteStorage, TcpTransport } from '@mtcute/deno'
export const getApiParams = (storage?: string) => {
if (storage) throw new Error('unsupported yet')
if (!Deno.env.has('API_ID') || !Deno.env.has('API_HASH')) {
throw new Error('API_ID and API_HASH env variables must be set')
}
setPlatform(new WebPlatform())
Deno.mkdirSync('.sessions', { recursive: true })
setPlatform(new DenoPlatform())
return {
apiId: parseInt(Deno.env.get('API_ID')!),
apiHash: Deno.env.get('API_HASH')!,
testMode: true,
storage: new MemoryStorage(),
storage: storage ? new SqliteStorage(`.sessions/${storage}`) : new MemoryStorage(),
logLevel: LogManager.VERBOSE,
transport: () => new WebSocketTransport(),
crypto: new WebCryptoProvider(),
transport: () => new TcpTransport(),
crypto: new DenoCryptoProvider(),
}
}

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/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",
@ -35,8 +35,9 @@
"devDependencies": {
"@commitlint/cli": "17.6.5",
"@commitlint/config-conventional": "17.6.5",
"@teidesu/slow-types-compiler": "1.0.2",
"@teidesu/slow-types-compiler": "1.1.0",
"@types/node": "20.10.0",
"@types/deno": "npm:@teidesu/deno-types@1.42.4",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "6.4.0",
"@typescript-eslint/parser": "6.4.0",

View file

@ -56,7 +56,7 @@ export class BaseTelegramClient extends BaseTelegramClientBase {
}
/**
* Telegram client for use in Node.js
* Telegram client for use in Bun
*/
export class TelegramClient extends TelegramClientBase {
constructor(opts: TelegramClientOptions) {

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

@ -91,8 +91,11 @@ export abstract class BaseTcpTransport extends EventEmitter implements ITelegram
handleError(socket: unknown, error: Error): void {
this.log.error('error: %s', error.stack)
if (this.listenerCount('error') > 0) {
this.emit('error', error)
}
}
handleConnect(socket: Socket): void {
this._socket = socket
@ -109,7 +112,11 @@ export abstract class BaseTcpTransport extends EventEmitter implements ITelegram
this.emit('ready')
}
})
.catch((err) => this.emit('error', err))
.catch((err) => {
if (this.listenerCount('error') > 0) {
this.emit('error', err)
}
})
}
async send(bytes: Uint8Array): Promise<void> {

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

@ -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

@ -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[]

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

@ -0,0 +1,25 @@
# @mtcute/deno
📖 [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,12 @@
module.exports = ({ outDir, fs, jsr }) => ({
buildCjs: false,
final() {
if (jsr) {
// jsr doesn't support symlinks, so we need to copy the files manually
const real = fs.realpathSync(`${outDir}/common-internals-web`)
fs.unlinkSync(`${outDir}/common-internals-web`)
// console.log(real)
fs.cpSync(real, `${outDir}/common-internals-web`, { recursive: true })
}
},
})

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:^"
}
}

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

@ -0,0 +1,145 @@
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 Deno
*/
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),
// eslint-disable-next-line
output: Writable.fromWeb(Deno.stdout.writable as any),
})
}
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,47 @@
import { BaseSqliteStorageDriver, ISqliteDatabase } from '@mtcute/core'
let Database: typeof import('@db/sqlite').Database
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()
}
async _load(): Promise<void> {
if (!Database) {
// we load this lazily to avoid loading ffi if it's not needed,
// in case the user doesn't use sqlite storage
Database = (await import('@db/sqlite')).Database
}
super._load()
}
_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,34 @@
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') {
// load sqlite in advance so test runner doesn't complain about us leaking the library
// (it's not on us, @db/sqlite doesn't provide an api to unload the library)
await import('@db/sqlite')
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 @@
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
globalThis.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,138 @@
import EventEmitter from 'node: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)
if (this.listenerCount('error') > 0) {
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,70 @@
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.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

@ -134,7 +134,8 @@ if (import.meta.env.TEST_ENV === 'node') {
it('should propagate errors', async () => {
const t = await create()
const spyEmit = vi.spyOn(t, 'emit').mockImplementation(() => true)
const spyEmit = vi.fn()
t.on('error', spyEmit)
t.connect(defaultProductionDc.main, false)
await vi.waitFor(() => expect(t.state()).toEqual(TransportState.Ready))
@ -147,7 +148,7 @@ if (import.meta.env.TEST_ENV === 'node') {
]
onErrorCall[1](new Error('test error'))
expect(spyEmit).toHaveBeenCalledWith('error', new Error('test error'))
expect(spyEmit).toHaveBeenCalledWith(new Error('test error'))
})
})
} else {

View file

@ -84,8 +84,11 @@ export abstract class BaseTcpTransport extends EventEmitter implements ITelegram
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')

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,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

@ -35,7 +35,7 @@ export class TelegramWorker<T extends WorkerCustomMethods> extends TelegramWorke
}
}
self.onconnect = (event) => {
self.onconnect = (event: MessageEvent) => {
const port = event.ports[0]
connections.push(port)
@ -91,7 +91,7 @@ export class TelegramWorker<T extends WorkerCustomMethods> extends TelegramWorke
const respond: RespondFn = self.postMessage.bind(self)
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
self.addEventListener('message', (message) => handler(message.data, respond))
self.addEventListener('message', (message: MessageEvent) => handler(message.data, respond))
return respond
}

View file

@ -19,8 +19,11 @@ importers:
specifier: 17.6.5
version: 17.6.5
'@teidesu/slow-types-compiler':
specifier: 1.0.2
version: 1.0.2(typescript@5.4.3)
specifier: 1.1.0
version: 1.1.0(typescript@5.4.3)
'@types/deno':
specifier: npm:@teidesu/deno-types@1.42.4
version: /@teidesu/deno-types@1.42.4
'@types/node':
specifier: 20.10.0
version: 20.10.0
@ -231,6 +234,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 +1217,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'}
@ -1362,8 +1470,12 @@ packages:
/@sinclair/typebox@0.27.8:
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
/@teidesu/slow-types-compiler@1.0.2(typescript@5.4.3):
resolution: {integrity: sha512-N0e3J/My4t405V5qD2kr6xXwLMlaB+el7bdYKLXJ2yyrLN1eAx4elf6qkeRTF4xy0GoiWrS0gKS0RZfddSOw1w==}
/@teidesu/deno-types@1.42.4:
resolution: {integrity: sha512-MMDkfWOsfedYWy+aPK4fAYyZfrBsY6+DeC7DGg6eESzh90zxuf1fSvXRsY8y09Hh4mm04tAf1632S2/JLaTXQg==}
dev: true
/@teidesu/slow-types-compiler@1.1.0(typescript@5.4.3):
resolution: {integrity: sha512-+WUHSKh56B32Jk5aJgXf07E2EOkMX1yilvgKLKBCJPFAJZ4xeo1U5aDu3wwHX3lrFl7AiVGXUP+FfuHy8X43BA==}
hasBin: true
peerDependencies:
typescript: ^5.0.0
@ -1371,6 +1483,7 @@ packages:
arg: 5.0.2
dedent: 1.5.3
eager-async-pool: 1.0.0
glob: 10.3.12
gunzip-maybe: 1.4.2
semver: 7.6.0
tar-stream: 3.1.7
@ -3634,6 +3747,18 @@ packages:
path-scurry: 1.10.1
dev: true
/glob@10.3.12:
resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
dependencies:
foreground-child: 3.1.1
jackspeak: 2.3.6
minimatch: 9.0.3
minipass: 7.0.4
path-scurry: 1.10.2
dev: true
/glob@7.2.0:
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
dependencies:
@ -4502,6 +4627,11 @@ packages:
get-func-name: 2.0.2
dev: true
/lru-cache@10.2.2:
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
engines: {node: 14 || >=16.14}
dev: true
/lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
@ -4747,6 +4877,11 @@ packages:
resolution: {integrity: sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==}
engines: {node: '>=16 || 14 >=14.17'}
/minipass@7.0.4:
resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==}
engines: {node: '>=16 || 14 >=14.17'}
dev: true
/minizlib@2.1.2:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
@ -5198,6 +5333,14 @@ packages:
lru-cache: 9.1.2
minipass: 6.0.2
/path-scurry@1.10.2:
resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==}
engines: {node: '>=16 || 14 >=14.17'}
dependencies:
lru-cache: 10.2.2
minipass: 7.0.4
dev: true
/path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}

View file

@ -428,11 +428,19 @@ if (buildConfig.buildTs && !IS_JSR) {
'from: (data: any, encoding?: string) => { toString(encoding?: string): string }, ' +
' }',
SharedWorker: ['type', 'never'],
WorkerGlobalScope:
'{ ' +
' new (): typeof WorkerGlobalScope, ' +
' postMessage: (message: any, transfer?: Transferable[]) => void, ' +
' addEventListener: (type: "message", listener: (ev: MessageEvent) => void) => void, ' +
' }',
process: '{ ' + 'hrtime: { bigint: () => bigint }, ' + '}',
}
for (const [name, decl_] of Object.entries(nodeSpecificApis)) {
if (fileContent.includes(name)) {
if (name === 'Buffer' && fileContent.includes('node:buffer')) continue
changed = true
const isType = Array.isArray(decl_) && decl_[0] === 'type'
const decl = isType ? decl_[1] : decl_
@ -499,6 +507,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}`
}
@ -543,6 +555,38 @@ if (IS_JSR) {
),
)
if (process.env.E2E) {
// populate dependencies, if any
const depsToPopulate = []
for (const dep of Object.values(importMap)) {
if (!dep.startsWith('jsr:')) continue
if (dep.startsWith('jsr:@mtcute/')) continue
depsToPopulate.push(dep.slice(4))
}
if (depsToPopulate.length) {
console.log('[i] Populating %d dependencies...', depsToPopulate.length)
cp.spawnSync(
'pnpm',
[
'exec',
'slow-types-compiler',
'populate',
'--downstream',
process.env.JSR_URL,
'--token',
process.env.JSR_TOKEN,
'--unstable-create-via-api',
...depsToPopulate,
],
{
stdio: 'inherit',
},
)
}
}
console.log('[i] Processing with slow-types-compiler...')
const project = stc.createProject()
stc.processPackage(project, denoJson)

View file

@ -1,6 +1,7 @@
const fs = require('fs')
const path = require('path')
const cp = require('child_process')
const stc = require('@teidesu/slow-types-compiler')
const IS_JSR = process.env.JSR === '1'
const MAIN_REGISTRY = IS_JSR ? 'http://jsr.test/' : 'https://registry.npmjs.org'
@ -25,6 +26,7 @@ const JSR_EXCEPTIONS = {
bun: 'never',
'create-bot': 'never',
'crypto-node': 'never',
deno: 'only',
node: 'never',
'http-proxy': 'never',
'socks-proxy': 'never',
@ -53,30 +55,6 @@ async function checkVersion(name, version) {
return fetchRetry(url).then((r) => r.status === 200)
}
async function jsrMaybeCreatePackage(name) {
// check if the package even exists
const packageMeta = await fetchRetry(`${REGISTRY}api/scopes/mtcute/packages/${name}`)
if (packageMeta.status === 404) {
console.error('[i] %s does not exist, creating..', name)
const create = await fetchRetry(`${REGISTRY}api/scopes/mtcute/packages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `token=${process.env.JSR_TOKEN}`,
},
body: JSON.stringify({ package: name }),
})
if (create.status !== 200) {
throw new Error(`Failed to create package: ${create.statusText} ${await create.text()}`)
}
} else if (packageMeta.status !== 200) {
throw new Error(`Failed to check package: ${packageMeta.statusText} ${await packageMeta.text()}`)
}
}
async function publishSinglePackage(name) {
let packageDir = path.join(__dirname, '../packages', name)
@ -109,7 +87,11 @@ async function publishSinglePackage(name) {
return
}
} else if (IS_JSR && process.env.JSR_TOKEN) {
await jsrMaybeCreatePackage(name)
await stc.jsrMaybeCreatePackage({
name: `@mtcute/${name}`,
token: process.env.JSR_TOKEN,
registry: REGISTRY,
})
}
if (IS_JSR) {
@ -156,7 +138,6 @@ function listPackages() {
.map((d) => d.slice(8))
}
const stc = require('@teidesu/slow-types-compiler')
packages = stc.determinePublishOrder(map)
console.log('[i] Publishing order:', packages.join(', '))
}

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/ns",
"vite/client"
],
"lib": [