diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fd30eed6..e389d2e8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -110,22 +110,22 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REGISTRY: 'https://npm.tei.su' run: cd e2e/node && ./cli.sh ci-publish - # e2e-deno: - # runs-on: ubuntu-latest - # needs: [lint, test-node, test-web, test-bun, test-deno] - # permissions: - # contents: read - # actions: write - # steps: - # - uses: actions/checkout@v4 - # - name: Run end-to-end tests under Deno - # env: - # API_ID: ${{ secrets.TELEGRAM_API_ID }} - # API_HASH: ${{ secrets.TELEGRAM_API_HASH }} - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # uses: nick-fields/retry@v2 - # # thanks docker networking very cool - # with: - # max_attempts: 3 - # timeout_minutes: 30 - # command: cd e2e/deno && ./cli.sh ci + e2e-deno: + runs-on: ubuntu-latest + needs: [lint, test-node, test-web, test-bun, test-deno] + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@v4 + - name: Run end-to-end tests under Deno + env: + API_ID: ${{ secrets.TELEGRAM_API_ID }} + API_HASH: ${{ secrets.TELEGRAM_API_HASH }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: nick-fields/retry@v2 + # thanks docker networking very cool + with: + max_attempts: 3 + timeout_minutes: 30 + command: cd e2e/deno && ./cli.sh ci diff --git a/packages/core/build.config.js b/packages/core/build.config.js index d0280ced..430ffc71 100644 --- a/packages/core/build.config.js +++ b/packages/core/build.config.js @@ -1,5 +1,7 @@ import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import * as fs from 'node:fs' const KNOWN_DECORATORS = ['memoizeGetters', 'makeInspectable'] @@ -68,5 +70,13 @@ export default () => { }, }, ], + finalJsr({ outDir }) { + const networkMgrFile = resolve(outDir, 'network/network-manager.ts') + const code = fs.readFileSync(networkMgrFile, 'utf8') + + const require = createRequire(import.meta.url) + const version = require(fileURLToPath(new URL('./package.json', import.meta.url))).version + fs.writeFileSync(networkMgrFile, code.replace('%VERSION%', version)) + }, } } diff --git a/packages/core/src/network/server-salt.ts b/packages/core/src/network/server-salt.ts index a7257623..2dd90326 100644 --- a/packages/core/src/network/server-salt.ts +++ b/packages/core/src/network/server-salt.ts @@ -1,7 +1,7 @@ import Long from 'long' import type { mtp } from '@mtcute/tl' -import { timers } from '../utils' +import { timers } from '../utils/index.js' export class ServerSaltManager { private _futureSalts: mtp.RawMt_future_salt[] = [] diff --git a/packages/core/src/utils/timers.ts b/packages/core/src/utils/timers.ts index d323a1e6..16ab7e43 100644 --- a/packages/core/src/utils/timers.ts +++ b/packages/core/src/utils/timers.ts @@ -4,48 +4,58 @@ // to the globals being typed incorrectly. // instead, we can treat the timers as opaque objects, and expose // them through the `timers` esm namespace. -// this has zero runtime cost (as everything is stripped at compile time), -// but makes everything type-safe +// this has near-zero runtime cost, but makes everything type-safe // -// the `import.meta.env.MODE === 'test'` is a workaround for vitest -// not being able to mock timers because it mocks the globals -// todo: we should probably do this as a vite plugin instead +// NB: we are using wrapper functions instead of... +// - directly exposing the globals because the standard doesn't allow that +// - .bind()-ing because it makes it harder to mock the timer globals export interface Timer { readonly __type: 'Timer' } export interface Interval { readonly __type: 'Interval' } export interface Immediate { readonly __type: 'Immediate' } const setTimeoutWrap = ( - import.meta.env?.MODE === 'test' ? (...args: Parameters) => setTimeout(...args) : setTimeout + (...args: Parameters) => setTimeout(...args) ) as unknown as any>( fn: T, ms: number, ...args: Parameters ) => Timer const setIntervalWrap = ( - import.meta.env?.MODE === 'test' ? (...args: Parameters) => setInterval(...args) : setInterval + (...args: Parameters) => setInterval(...args) ) as unknown as any>( fn: T, ms: number, ...args: Parameters ) => Interval -const setImmediateWrap = ( - typeof setImmediate !== 'undefined' ? setImmediate : setTimeout -) as unknown as ( - fn: () => void + +let setImmediateWrap: any +if (typeof setImmediate !== 'undefined') { + setImmediateWrap = (...args: Parameters) => setImmediate(...args) +} else { + // eslint-disable-next-line + setImmediateWrap = (fn: (...args: any[]) => void, ...args: any[]) => setTimeout(fn, 0, ...args) +} +const setImmediateWrapExported = setImmediateWrap as any>( + fn: T, ...args: Parameters ) => Immediate const clearTimeoutWrap = ( - import.meta.env?.MODE === 'test' ? (...args: Parameters) => clearTimeout(...args) : clearTimeout + (...args: Parameters) => clearTimeout(...args) ) as unknown as (timer?: Timer) => void const clearIntervalWrap = ( - import.meta.env?.MODE === 'test' ? (...args: Parameters) => clearInterval(...args) : clearInterval + (...args: Parameters) => clearInterval(...args) ) as unknown as (timer?: Interval) => void -const clearImmediateWrap = ( - typeof clearImmediate !== 'undefined' ? clearImmediate : clearTimeout -) as unknown as (timer?: Immediate) => void + +let clearImmediateWrap: any +if (typeof clearImmediate !== 'undefined') { + clearImmediateWrap = (...args: Parameters) => clearImmediate(...args) +} else { + clearImmediateWrap = (timer: number) => clearTimeout(timer) +} +const clearImmediateWrapExported = clearImmediateWrap as (timer?: Immediate) => void export { setTimeoutWrap as setTimeout, setIntervalWrap as setInterval, - setImmediateWrap as setImmediate, + setImmediateWrapExported as setImmediate, clearTimeoutWrap as clearTimeout, clearIntervalWrap as clearInterval, - clearImmediateWrap as clearImmediate, + clearImmediateWrapExported as clearImmediate, } diff --git a/packages/deno/build.config.cjs b/packages/deno/build.config.cjs deleted file mode 100644 index f8e7735c..00000000 --- a/packages/deno/build.config.cjs +++ /dev/null @@ -1,12 +0,0 @@ -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/build.config.js b/packages/deno/build.config.js new file mode 100644 index 00000000..7eed9821 --- /dev/null +++ b/packages/deno/build.config.js @@ -0,0 +1,10 @@ +import * as fs from 'node:fs' + +export default () => ({ + finalJsr({ outDir }) { + // 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`) + fs.cpSync(real, `${outDir}/common-internals-web`, { recursive: true }) + }, +}) diff --git a/packages/wasm/build.config.js b/packages/wasm/build.config.js index d000f3ea..8bd8326b 100644 --- a/packages/wasm/build.config.js +++ b/packages/wasm/build.config.js @@ -2,7 +2,6 @@ import { resolve } from 'node:path' import * as fs from 'node:fs' export default () => ({ - // esmOnlyDirectives: true, finalPackageJson(pkg) { pkg.exports['./mtcute.wasm'] = './mtcute.wasm' }, diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 7e63c776..49f9a089 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -51,7 +51,8 @@ export function initSync(module: SyncInitInput): void { module = new WebAssembly.Instance(module) } - wasm = module.exports as unknown as MtcuteWasmModule + // eslint-disable-next-line + wasm = (module as unknown as WebAssembly.Instance).exports as unknown as MtcuteWasmModule initCommon() } diff --git a/scripts/build-package-jsr.js b/scripts/build-package-jsr.js new file mode 100644 index 00000000..17339dd9 --- /dev/null +++ b/scripts/build-package-jsr.js @@ -0,0 +1,220 @@ +import { fileURLToPath } from 'node:url' +import * as fs from 'node:fs' +import * as cp from 'node:child_process' +import { resolve } from 'node:path' + +import * as glob from 'glob' +import ts from 'typescript' + +import { processPackageJson } from '../.config/vite-utils/package-json.js' + +export function packageJsonToDeno({ packageJson, packageJsonOrig }) { + // https://jsr.io/docs/package-configuration + + const importMap = {} + const exports = {} + + if (packageJson.dependencies) { + for (const [name, version] of Object.entries(packageJson.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}` + } + } + } + + if (packageJsonOrig.exports) { + let tmpExports + if (typeof packageJsonOrig.exports === 'string') { + tmpExports = { '.': packageJsonOrig.exports } + } else if (typeof packageJsonOrig.exports !== 'object') { + throw new TypeError('package.json exports must be an object') + } else { + tmpExports = packageJsonOrig.exports + } + + for (const [name, value] of Object.entries(tmpExports)) { + if (typeof value !== 'string') { + throw new TypeError(`package.json exports value must be a string: ${name}`) + } + if (value.endsWith('.wasm')) continue + + exports[name] = value + .replace(/^\.\/src\//, './') + .replace(/\.js$/, '.ts') + } + } + + return { + name: packageJson.name, + version: packageJson.version, + exports, + exclude: ['**/*.test.ts', '**/*.test-utils.ts', '**/__fixtures__/**'], + imports: importMap, + publish: { + exclude: ['!../dist'], // lol + }, + ...packageJson.denoJson, + } +} + +export async function runJsrBuildSync(packageName) { + const packageDir = fileURLToPath(new URL(`../packages/${packageName}`, import.meta.url)) + const outDir = fileURLToPath(new URL(`../packages/${packageName}/dist/jsr`, import.meta.url)) + fs.rmSync(outDir, { recursive: true, force: true }) + fs.mkdirSync(outDir, { recursive: true }) + + console.log('[i] Copying sources...') + fs.cpSync(resolve(packageDir, 'src'), outDir, { recursive: true }) + + const printer = ts.createPrinter() + + for (const f of glob.sync(resolve(outDir, '**/*.ts'))) { + let fileContent = fs.readFileSync(f, 'utf8') + let changed = false + + // replace .js imports with .ts + const file = ts.createSourceFile(f, fileContent, ts.ScriptTarget.ESNext, true) + let changedTs = false + + for (const imp of file.statements) { + if (imp.kind !== ts.SyntaxKind.ImportDeclaration && imp.kind !== ts.SyntaxKind.ExportDeclaration) { + continue + } + if (imp.kind === ts.SyntaxKind.ExportDeclaration && !imp.moduleSpecifier) { + continue + } + const mod = imp.moduleSpecifier.text + + if (mod[0] === '.' && mod.endsWith('.js')) { + changedTs = true + imp.moduleSpecifier = { + kind: ts.SyntaxKind.StringLiteral, + text: `${mod.slice(0, -3)}.ts`, + } + } + } + + if (changedTs) { + fileContent = printer.printFile(file) + changed = true + } + + // add shims for node-specific APIs and replace NodeJS.* types + // pretty fragile, but it works for now + const typesToReplace = { + 'NodeJS\\.Timeout': 'number', + 'NodeJS\\.Immediate': 'number', + } + const nodeSpecificApis = { + setImmediate: '(cb: (...args: any[]) => void, ...args: any[]) => number', + clearImmediate: '(id: number) => void', + Buffer: + '{ ' + + 'concat: (...args: any[]) => Uint8Array, ' + + '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_ + + if (isType) { + fileContent = `declare type ${name} = ${decl};\n${fileContent}` + } else { + fileContent = `declare const ${name}: ${decl};\n${fileContent}` + } + } + } + + for (const [oldType, newType] of Object.entries(typesToReplace)) { + if (fileContent.match(oldType)) { + changed = true + fileContent = fileContent.replace(new RegExp(oldType, 'g'), newType) + } + } + + if (changed) { + fs.writeFileSync(f, fileContent) + } + } + + const { packageJson, packageJsonOrig } = processPackageJson(packageDir) + const denoJson = packageJsonToDeno({ packageJson, packageJsonOrig }) + + fs.writeFileSync(resolve(outDir, 'deno.json'), JSON.stringify(denoJson, null, 2)) + fs.cpSync(new URL('../LICENSE', import.meta.url), resolve(outDir, 'LICENSE'), { recursive: true }) + + if (process.env.E2E) { + // populate dependencies, if any + const depsToPopulate = [] + + for (const dep of Object.values(denoJson.imports)) { + 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', + }, + ) + } + } + + let customConfig + try { + customConfig = await (await import(resolve(packageDir, 'build.config.js'))).default() + } catch {} + + if (customConfig) { + await customConfig.finalJsr?.({ packageDir, outDir }) + } + + console.log('[i] Trying to publish with --dry-run') + cp.execSync('deno publish --dry-run --allow-dirty --quiet', { cwd: outDir, stdio: 'inherit' }) + console.log('[v] All good!') +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const PACKAGE_NAME = process.argv[2] + + if (!PACKAGE_NAME) { + throw new Error('package name not specified') + } + + await runJsrBuildSync(PACKAGE_NAME) +} diff --git a/scripts/build-package.js b/scripts/build-package.js index f829d5f1..9dfc1b97 100644 --- a/scripts/build-package.js +++ b/scripts/build-package.js @@ -4,6 +4,7 @@ import { resolve } from 'node:path' import { processPackageJson } from '../.config/vite-utils/package-json.js' +import { packageJsonToDeno, runJsrBuildSync } from './build-package-jsr.js' import { runViteBuildSync } from './build-package-vite.js' if (process.argv.length < 3) { @@ -13,16 +14,20 @@ if (process.argv.length < 3) { const IS_JSR = process.env.JSR === '1' -if (IS_JSR) { - throw new Error('JSR build is temporarily disabled') -} - const packageName = process.argv[2] +function transformFile(file, transform) { + const content = fs.readFileSync(file, 'utf8') + const res = transform(content, file) + if (res != null) fs.writeFileSync(file, res) +} + if (packageName === 'tl') { // create package by copying all the needed files const packageDir = fileURLToPath(new URL('../packages/tl', import.meta.url)) - const outDir = fileURLToPath(new URL('../packages/tl/dist', import.meta.url)) + let outDir = fileURLToPath(new URL('../packages/tl/dist', import.meta.url)) + if (IS_JSR) outDir = resolve(outDir, 'jsr') + fs.rmSync(outDir, { recursive: true, force: true }) const files = [ @@ -48,65 +53,65 @@ if (packageName === 'tl') { } fs.cpSync(new URL('../LICENSE', import.meta.url), resolve(outDir, 'LICENSE'), { recursive: true }) - const { packageJson } = processPackageJson(packageDir) - fs.writeFileSync(resolve(outDir, 'package.json'), JSON.stringify(packageJson, null, 4)) + const { packageJson, packageJsonOrig } = processPackageJson(packageDir) - // todo - // if (jsr) { - // // jsr doesn't support cjs, so we'll need to add some shims - // // todo: remove this god awfulness when tl esm rewrite - // transformFile(path.join(outDir, 'index.js'), (content) => { - // return [ - // '/// ', - // 'const exports = {};', - // content, - // 'export const tl = exports.tl;', - // 'export const mtp = exports.mtp;', - // ].join('\n') - // }) - // transformFile(path.join(outDir, 'binary/reader.js'), (content) => { - // return [ - // '/// ', - // 'const exports = {};', - // content, - // 'export const __tlReaderMap = exports.__tlReaderMap;', - // ].join('\n') - // }) - // transformFile(path.join(outDir, 'binary/writer.js'), (content) => { - // return [ - // '/// ', - // 'const exports = {};', - // content, - // 'export const __tlWriterMap = exports.__tlWriterMap;', - // ].join('\n') - // }) - // transformFile(path.join(outDir, 'binary/rsa-keys.js'), (content) => { - // return [ - // '/// ', - // 'const exports = {};', - // content, - // 'export const __publicKeyIndex = exports.__publicKeyIndex;', - // ].join('\n') - // }) + if (IS_JSR) { + // jsr doesn't support cjs, so we'll need to add some shims + // todo: remove this god awfulness when tl esm rewrite + transformFile(resolve(outDir, 'index.js'), (content) => { + return [ + '/// ', + 'const exports = {};', + content, + 'export const tl = exports.tl;', + 'export const mtp = exports.mtp;', + ].join('\n') + }) + transformFile(resolve(outDir, 'binary/reader.js'), (content) => { + return [ + '/// ', + 'const exports = {};', + content, + 'export const __tlReaderMap = exports.__tlReaderMap;', + ].join('\n') + }) + transformFile(resolve(outDir, 'binary/writer.js'), (content) => { + return [ + '/// ', + 'const exports = {};', + content, + 'export const __tlWriterMap = exports.__tlWriterMap;', + ].join('\n') + }) + transformFile(resolve(outDir, 'binary/rsa-keys.js'), (content) => { + return [ + '/// ', + 'const exports = {};', + content, + 'export const __publicKeyIndex = exports.__publicKeyIndex;', + ].join('\n') + }) - // // patch deno.json to add some export maps - // transformFile(path.join(outDir, 'deno.json'), (content) => { - // const json = JSON.parse(content) - // json.exports = {} + // patch deno.json to add some export maps + const denoJson = packageJsonToDeno({ packageJson, packageJsonOrig }) + denoJson.exports = {} - // for (const f of files) { - // if (!f.match(/\.js(?:on)?$/)) continue - - // if (f === 'index.js') { - // json.exports['.'] = './index.js' - // } else { - // json.exports[`./${f}`] = `./${f}` - // } - // } - - // return JSON.stringify(json, null, 2) - // }) - // } + for (const f of files) { + if (!f.match(/\.js(?:on)?$/)) continue + if (f === 'index.js') { + denoJson.exports['.'] = './index.js' + } else { + denoJson.exports[`./${f}`] = `./${f}` + } + } + fs.writeFileSync(resolve(outDir, 'deno.json'), JSON.stringify(denoJson, null, 2)) + } else { + fs.writeFileSync(resolve(outDir, 'package.json'), JSON.stringify(packageJson, null, 2)) + } } else { - runViteBuildSync(packageName) + if (IS_JSR) { + await runJsrBuildSync(packageName) + } else { + runViteBuildSync(packageName) + } }