diff --git a/.config/eslint.cjs b/.config/eslint.cjs index 2a1ea066..02b8bd5b 100644 --- a/.config/eslint.cjs +++ b/.config/eslint.cjs @@ -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': { diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2377652e..aae1b802 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..41583e36 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/e2e/deno/.gitignore b/e2e/deno/.gitignore index 75a15d22..6771fc36 100644 --- a/e2e/deno/.gitignore +++ b/e2e/deno/.gitignore @@ -1,3 +1,4 @@ /.jsr-data .env -/deno.lock \ No newline at end of file +/deno.lock +/.sessions \ No newline at end of file diff --git a/e2e/deno/cli.sh b/e2e/deno/cli.sh index 7d6afda7..79c7c8ff 100755 --- a/e2e/deno/cli.sh +++ b/e2e/deno/cli.sh @@ -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 diff --git a/e2e/deno/deno.json b/e2e/deno/deno.json index f1651f62..71f4e17a 100644 --- a/e2e/deno/deno.json +++ b/e2e/deno/deno.json @@ -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@*" } } \ No newline at end of file diff --git a/e2e/deno/tests/01.auth.ts b/e2e/deno/tests/01.auth.ts new file mode 100644 index 00000000..1b739983 --- /dev/null +++ b/e2e/deno/tests/01.auth.ts @@ -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 + } + }) +}) diff --git a/e2e/deno/tests/02.methods.ts b/e2e/deno/tests/02.methods.ts new file mode 100644 index 00000000..e3e5a140 --- /dev/null +++ b/e2e/deno/tests/02.methods.ts @@ -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() +}) diff --git a/e2e/deno/tests/03.files.ts b/e2e/deno/tests/03.files.ts new file mode 100644 index 00000000..3cbbbf5a --- /dev/null +++ b/e2e/deno/tests/03.files.ts @@ -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 { + 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() + }) +}) diff --git a/e2e/deno/tests/04.updates.ts b/e2e/deno/tests/04.updates.ts new file mode 100644 index 00000000..37bc49e1 --- /dev/null +++ b/e2e/deno/tests/04.updates.ts @@ -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() +}) diff --git a/e2e/deno/tests/05.worker.ts b/e2e/deno/tests/05.worker.ts new file mode 100644 index 00000000..fe8ea4ce --- /dev/null +++ b/e2e/deno/tests/05.worker.ts @@ -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({ + 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() +}) diff --git a/e2e/deno/tests/_worker.ts b/e2e/deno/tests/_worker.ts new file mode 100644 index 00000000..1b5e3938 --- /dev/null +++ b/e2e/deno/tests/_worker.ts @@ -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, +}) diff --git a/e2e/deno/utils.ts b/e2e/deno/utils.ts index 25711aa8..dfba3630 100644 --- a/e2e/deno/utils.ts +++ b/e2e/deno/utils.ts @@ -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(), } } diff --git a/package.json b/package.json index ebd5c48a..a336e2fb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index ee3f66f2..7ab5e88d 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -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) { diff --git a/packages/bun/src/platform.ts b/packages/bun/src/platform.ts index ef805446..7af79895 100644 --- a/packages/bun/src/platform.ts +++ b/packages/bun/src/platform.ts @@ -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 diff --git a/packages/bun/src/utils/tcp.ts b/packages/bun/src/utils/tcp.ts index 5bfcc7b5..e8141d8b 100644 --- a/packages/bun/src/utils/tcp.ts +++ b/packages/bun/src/utils/tcp.ts @@ -91,7 +91,10 @@ export abstract class BaseTcpTransport extends EventEmitter implements ITelegram handleError(socket: unknown, error: Error): void { this.log.error('error: %s', error.stack) - this.emit('error', error) + + if (this.listenerCount('error') > 0) { + this.emit('error', error) + } } handleConnect(socket: Socket): void { @@ -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 { diff --git a/packages/bun/src/worker.ts b/packages/bun/src/worker.ts index 0c953a52..877db612 100644 --- a/packages/bun/src/worker.ts +++ b/packages/bun/src/worker.ts @@ -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 extends TelegramWorke export class TelegramWorkerPort extends TelegramWorkerPortBase { constructor(readonly options: TelegramWorkerPortOptions) { - setPlatform(new NodePlatform()) + setPlatform(new BunPlatform()) super(options) } diff --git a/packages/core/src/highlevel/types/files/utils.ts b/packages/core/src/highlevel/types/files/utils.ts index 0eac28bf..a339fc21 100644 --- a/packages/core/src/highlevel/types/files/utils.ts +++ b/packages/core/src/highlevel/types/files/utils.ts @@ -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 - | NodeJS.ReadableStream - | Response + | AnyToNever + | AnyToNever> + | AnyToNever + | AnyToNever + | AnyToNever + +// 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`) diff --git a/packages/core/src/storage/sqlite/repository/peers.ts b/packages/core/src/storage/sqlite/repository/peers.ts index 2e48b9ad..3426e0c8 100644 --- a/packages/core/src/storage/sqlite/repository/peers.ts +++ b/packages/core/src/storage/sqlite/repository/peers.ts @@ -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, ]) } diff --git a/packages/core/src/types/utils.ts b/packages/core/src/types/utils.ts index 666c6914..2f58e50f 100644 --- a/packages/core/src/types/utils.ts +++ b/packages/core/src/types/utils.ts @@ -1,6 +1,8 @@ export type MaybePromise = T | Promise export type PartialExcept = Partial> & Pick export type PartialOnly = Partial> & Omit +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyToNever = any extends T ? never : T export type MaybeArray = T | T[] diff --git a/packages/deno/README.md b/packages/deno/README.md new file mode 100644 index 00000000..8c3dec62 --- /dev/null +++ b/packages/deno/README.md @@ -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}`) +}) +``` diff --git a/packages/deno/build.config.cjs b/packages/deno/build.config.cjs new file mode 100644 index 00000000..f8e7735c --- /dev/null +++ b/packages/deno/build.config.cjs @@ -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 }) + } + }, +}) diff --git a/packages/deno/package.json b/packages/deno/package.json new file mode 100644 index 00000000..fa148f60 --- /dev/null +++ b/packages/deno/package.json @@ -0,0 +1,30 @@ +{ + "name": "@mtcute/deno", + "private": true, + "version": "0.11.0", + "description": "Meta-package for Deno", + "author": "alina sireneva ", + "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:^" + } +} diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts new file mode 100644 index 00000000..75040a49 --- /dev/null +++ b/packages/deno/src/client.ts @@ -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, '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 { + 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 { + this._rl?.close() + + return super.close() + } + + start(params: Parameters[0] = {}): Promise { + 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[0] | ((user: User) => void | Promise), + then?: (user: User) => void | Promise, + ): 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 { + return downloadToFile(this, filename, location, params) + } +} diff --git a/packages/deno/src/common-internals-web b/packages/deno/src/common-internals-web new file mode 120000 index 00000000..3f10a5de --- /dev/null +++ b/packages/deno/src/common-internals-web @@ -0,0 +1 @@ +../../web/src/common-internals-web \ No newline at end of file diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts new file mode 100644 index 00000000..87f84c57 --- /dev/null +++ b/packages/deno/src/index.ts @@ -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' diff --git a/packages/deno/src/methods.ts b/packages/deno/src/methods.ts new file mode 100644 index 00000000..f25b163c --- /dev/null +++ b/packages/deno/src/methods.ts @@ -0,0 +1,2 @@ +export { downloadToFile } from './methods/download-file.js' +export * from '@mtcute/core/methods.js' diff --git a/packages/deno/src/methods/download-file.ts b/packages/deno/src/methods/download-file.ts new file mode 100644 index 00000000..fc2adf48 --- /dev/null +++ b/packages/deno/src/methods/download-file.ts @@ -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 { + 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() +} diff --git a/packages/deno/src/platform.ts b/packages/deno/src/platform.ts new file mode 100644 index 00000000..aaff526a --- /dev/null +++ b/packages/deno/src/platform.ts @@ -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 diff --git a/packages/deno/src/sqlite/driver.ts b/packages/deno/src/sqlite/driver.ts new file mode 100644 index 00000000..fd58c458 --- /dev/null +++ b/packages/deno/src/sqlite/driver.ts @@ -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 { + 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 + } +} diff --git a/packages/deno/src/sqlite/index.ts b/packages/deno/src/sqlite/index.ts new file mode 100644 index 00000000..882b228d --- /dev/null +++ b/packages/deno/src/sqlite/index.ts @@ -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)) + } +} diff --git a/packages/deno/src/sqlite/sqlite.test.ts b/packages/deno/src/sqlite/sqlite.test.ts new file mode 100644 index 00000000..499fd46d --- /dev/null +++ b/packages/deno/src/sqlite/sqlite.test.ts @@ -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', () => {}) +} diff --git a/packages/deno/src/utils.ts b/packages/deno/src/utils.ts new file mode 100644 index 00000000..3356b98c --- /dev/null +++ b/packages/deno/src/utils.ts @@ -0,0 +1 @@ +export * from '@mtcute/core/utils.js' diff --git a/packages/deno/src/utils/crypto.test.ts b/packages/deno/src/utils/crypto.test.ts new file mode 100644 index 00000000..4095b5e2 --- /dev/null +++ b/packages/deno/src/utils/crypto.test.ts @@ -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', () => {}) +} diff --git a/packages/deno/src/utils/crypto.ts b/packages/deno/src/utils/crypto.ts new file mode 100644 index 00000000..9c08a6ed --- /dev/null +++ b/packages/deno/src/utils/crypto.ts @@ -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 { + // 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 { + 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) + } +} diff --git a/packages/deno/src/utils/exit-hook.ts b/packages/deno/src/utils/exit-hook.ts new file mode 100644 index 00000000..ecaa0e40 --- /dev/null +++ b/packages/deno/src/utils/exit-hook.ts @@ -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) + } +} diff --git a/packages/deno/src/utils/normalize-file.ts b/packages/deno/src/utils/normalize-file.ts new file mode 100644 index 00000000..7e5b10e8 --- /dev/null +++ b/packages/deno/src/utils/normalize-file.ts @@ -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, + fileName, + fileSize, + } + } + + if (file instanceof NodeReadable) { + return { + file: NodeReadable.toWeb(file) as unknown as ReadableStream, + } + } + + return null +} diff --git a/packages/deno/src/utils/tcp.ts b/packages/deno/src/utils/tcp.ts new file mode 100644 index 00000000..f1a3faab --- /dev/null +++ b/packages/deno/src/utils/tcp.ts @@ -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 { + 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 { + 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() +} diff --git a/packages/deno/src/worker.ts b/packages/deno/src/worker.ts new file mode 100644 index 00000000..facc7363 --- /dev/null +++ b/packages/deno/src/worker.ts @@ -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 extends TelegramWorkerBase { + 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 extends TelegramWorkerPortBase { + 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') + } +} diff --git a/packages/deno/tsconfig.json b/packages/deno/tsconfig.json new file mode 100644 index 00000000..66079ea1 --- /dev/null +++ b/packages/deno/tsconfig.json @@ -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" } + ] +} diff --git a/packages/deno/typedoc.cjs b/packages/deno/typedoc.cjs new file mode 100644 index 00000000..e1f51e65 --- /dev/null +++ b/packages/deno/typedoc.cjs @@ -0,0 +1,10 @@ +module.exports = { + extends: ['../../.config/typedoc/config.base.cjs'], + entryPoints: ['./src/index.ts'], + externalPattern: [ + '../core/**', + '../html-parser/**', + '../markdown-parser/**', + '../sqlite/**', + ], +} diff --git a/packages/dispatcher/tsconfig.json b/packages/dispatcher/tsconfig.json index 6a3f7a53..919db0b1 100644 --- a/packages/dispatcher/tsconfig.json +++ b/packages/dispatcher/tsconfig.json @@ -6,5 +6,8 @@ }, "include": [ "./src", + ], + "references": [ + { "path": "../core" }, ] } diff --git a/packages/node/src/common-internals-node/platform.ts b/packages/node/src/common-internals-node/platform.ts index afd58299..f74035a5 100644 --- a/packages/node/src/common-internals-node/platform.ts +++ b/packages/node/src/common-internals-node/platform.ts @@ -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 { diff --git a/packages/node/src/utils/crypto.ts b/packages/node/src/utils/crypto.ts index a04d1f13..96709dfa 100644 --- a/packages/node/src/utils/crypto.ts +++ b/packages/node/src/utils/crypto.ts @@ -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 { + ): Promise { return new Promise((resolve, reject) => pbkdf2(password, salt, iterations, keylen, algo, (err: Error | null, buf: Uint8Array) => err !== null ? reject(err) : resolve(buf), diff --git a/packages/node/src/utils/tcp.test.ts b/packages/node/src/utils/tcp.test.ts index 684e3cf4..88b71e3e 100644 --- a/packages/node/src/utils/tcp.test.ts +++ b/packages/node/src/utils/tcp.test.ts @@ -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 { diff --git a/packages/node/src/utils/tcp.ts b/packages/node/src/utils/tcp.ts index 3fadbeb2..56d952ba 100644 --- a/packages/node/src/utils/tcp.ts +++ b/packages/node/src/utils/tcp.ts @@ -84,7 +84,10 @@ export abstract class BaseTcpTransport extends EventEmitter implements ITelegram handleError(error: Error): void { this.log.error('error: %s', error.stack) - this.emit('error', error) + + if (this.listenerCount('error') > 0) { + this.emit('error', error) + } } handleConnect(): void { diff --git a/packages/web/src/encodings/base64.ts b/packages/web/src/common-internals-web/base64.ts similarity index 100% rename from packages/web/src/encodings/base64.ts rename to packages/web/src/common-internals-web/base64.ts diff --git a/packages/web/src/encodings/hex.ts b/packages/web/src/common-internals-web/hex.ts similarity index 100% rename from packages/web/src/encodings/hex.ts rename to packages/web/src/common-internals-web/hex.ts diff --git a/packages/web/src/logging.ts b/packages/web/src/common-internals-web/logging.ts similarity index 100% rename from packages/web/src/logging.ts rename to packages/web/src/common-internals-web/logging.ts diff --git a/packages/web/src/common-internals-web/readme.md b/packages/web/src/common-internals-web/readme.md new file mode 100644 index 00000000..b03e8b77 --- /dev/null +++ b/packages/web/src/common-internals-web/readme.md @@ -0,0 +1,2 @@ +this folder is for common code across `@mtcute/web` and `@mtcute/deno`. +it is symlinked into `@mtcute/deno` \ No newline at end of file diff --git a/packages/web/src/encodings/utf8.ts b/packages/web/src/common-internals-web/utf8.ts similarity index 90% rename from packages/web/src/encodings/utf8.ts rename to packages/web/src/common-internals-web/utf8.ts index a4cb1168..8febf7e8 100644 --- a/packages/web/src/encodings/utf8.ts +++ b/packages/web/src/common-internals-web/utf8.ts @@ -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 diff --git a/packages/web/src/platform.ts b/packages/web/src/platform.ts index d90f2c3a..84d388e7 100644 --- a/packages/web/src/platform.ts +++ b/packages/web/src/platform.ts @@ -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 } diff --git a/packages/web/src/worker.ts b/packages/web/src/worker.ts index e45f7463..5e38206f 100644 --- a/packages/web/src/worker.ts +++ b/packages/web/src/worker.ts @@ -35,7 +35,7 @@ export class TelegramWorker extends TelegramWorke } } - self.onconnect = (event) => { + self.onconnect = (event: MessageEvent) => { const port = event.ports[0] connections.push(port) @@ -91,7 +91,7 @@ export class TelegramWorker 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 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c2a5e5d..5fb4187d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/scripts/build-package.js b/scripts/build-package.js index fc7bec73..151c2075 100644 --- a/scripts/build-package.js +++ b/scripts/build-package.js @@ -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) diff --git a/scripts/publish.js b/scripts/publish.js index e07c75c2..f6454788 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -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(', ')) } diff --git a/scripts/remove-jsr-sourcefiles.mjs b/scripts/remove-jsr-sourcefiles.mjs new file mode 100644 index 00000000..3ed84a26 --- /dev/null +++ b/scripts/remove-jsr-sourcefiles.mjs @@ -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`) diff --git a/tsconfig.json b/tsconfig.json index 92538b93..fac8a718 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "composite": true, "types": [ "node", + "deno/ns", "vite/client" ], "lib": [