From 6bdff254828586c74561056dea7f7a743ce407d3 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Wed, 29 Jul 2026 22:15:20 +0700 Subject: [PATCH] feat: better types + typegen output - Add dependencies to types package.json - Make temp file names deterministic --- lib/discord/src/types/index.ts | 35 ++- lib/discord/src/types/polyfills.ts | 8 +- scripts/types.ts | 416 +++++++++++++++++++---------- tsconfig.json | 1 - types/globals.d.ts | 7 +- types/globals.internal.d.ts | 6 + 6 files changed, 320 insertions(+), 153 deletions(-) create mode 100644 types/globals.internal.d.ts diff --git a/lib/discord/src/types/index.ts b/lib/discord/src/types/index.ts index dfef0a6..8532085 100644 --- a/lib/discord/src/types/index.ts +++ b/lib/discord/src/types/index.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from 'node:events' import type { StackScreenProps } from '@react-navigation/stack' import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation' import type { @@ -699,10 +698,40 @@ export namespace DiscordModules { export namespace TypedEventEmitter { export type DefaultEventMap = [never] export type EventMap = Record | DefaultEventMap + export type Listener = T[K] extends any[] + ? (...args: T[K]) => void + : never } - export class TypedEventEmitter< + export declare class TypedEventEmitter< T extends Record = Record, - > extends EventEmitter {} + > { + addListener( + event: K, + listener: TypedEventEmitter.Listener, + ): this + on( + event: K, + listener: TypedEventEmitter.Listener, + ): this + once( + event: K, + listener: TypedEventEmitter.Listener, + ): this + removeListener( + event: K, + listener: TypedEventEmitter.Listener, + ): this + off( + event: K, + listener: TypedEventEmitter.Listener, + ): this + removeAllListeners(event?: keyof T): this + emit(event: K, ...args: T[K]): boolean + listenerCount( + event: K, + listener?: TypedEventEmitter.Listener, + ): number + } } } diff --git a/lib/discord/src/types/polyfills.ts b/lib/discord/src/types/polyfills.ts index 5ad0e41..a1f4646 100644 --- a/lib/discord/src/types/polyfills.ts +++ b/lib/discord/src/types/polyfills.ts @@ -1,5 +1,9 @@ -import type { Buffer as _Buffer } from 'buffer' +/** + * Namespace import, as bundling types changes the named import back to `Buffer`, + * which makes the global reference itself. + */ +import type * as NodeBuffer from 'buffer' declare global { - var Buffer: typeof _Buffer + var Buffer: typeof NodeBuffer.Buffer } diff --git a/scripts/types.ts b/scripts/types.ts index 37e42be..62b4149 100644 --- a/scripts/types.ts +++ b/scripts/types.ts @@ -1,12 +1,45 @@ +import assets from 'pkg:assets' +import components from 'pkg:components' +import discord from 'pkg:discord' +import externals from 'pkg:externals' +import jsonStorage from 'pkg:json-storage' +import modules from 'pkg:modules' +import patcher from 'pkg:patcher' +import plugins from 'pkg:plugins' +import react from 'pkg:react' +import utils from 'pkg:utils' import { main } from 'bun' import chalk from 'chalk' -import { mkdir, readFile, rm, writeFile } from 'fs/promises' +import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises' import { rolldown } from 'rolldown' import { dts } from 'rolldown-plugin-dts' import pkg from '../package.json' +import { exists } from './_shared' const TYPES_PACKAGE_NAME = '@revenge-mod/types' +const TYPES_PACKAGE_DEPENDENCIES = ['buffer'] + +const TYPES_PACKAGE_PEER_DEPENDENCIES = [ + '@react-navigation/core', + '@react-navigation/native', + '@react-navigation/stack', + '@shopify/flash-list', + '@types/node', + 'react', + 'react-native', + 'react-native-safe-area-context', +] + +const TYPES_PACKAGE_OPTIONAL_PEER_DEPENDENCIES = [ + '@react-navigation/core', + '@react-navigation/native', + '@react-navigation/stack', + '@shopify/flash-list', + '@types/node', + 'react-native-safe-area-context', +] + const PATHS = { lib: 'lib', output: 'dist/types', @@ -14,33 +47,28 @@ const PATHS = { tsconfig: 'tsconfig.json', } as const -async function cleanup(dir: string, description: string): Promise { - try { - if (await exists(dir)) { - await rm(dir, { recursive: true, force: true }) - console.debug(chalk.gray(`🗑️ Deleted ${description}...`)) - } - } catch (error) { - console.warn( - chalk.yellow(`⚠️ Failed to delete ${description}: ${error}`), - ) - } -} +type TrimLeadingDot = T extends `.${infer R}` + ? R extends `/${infer S}` + ? S + : R + : T -await cleanup(PATHS.output, 'generated types') -await mkdir(PATHS.outputTemp, { recursive: true }) +type ExportBinding = T | [from: T, to: string] export default async function buildTypes(log = true): Promise { const start = performance.now() + await cleanup(PATHS.output, 'generated types') + await mkdir(PATHS.outputTemp, { recursive: true }) + if (log) console.info(chalk.gray('🏗️ Generating types...')) try { const input: Record = { ...Object.fromEntries( - Libraries.flatMap(([libName, exports]) => + getLibraries().flatMap(([libName, exports]) => exports.map(([exportName, path]) => [ - `lib/${libName}${exportName && `/${exportName}`}`, + `lib/${libName}${exportName ? `/${exportName}` : ''}`, path, ]), ), @@ -65,9 +93,9 @@ export default async function buildTypes(log = true): Promise { ], }) - await bundle.write({ - dir: PATHS.output, - }) + await bundle.write({ dir: PATHS.output }) + + await internalizeSpecifiers(input) await writeFile( `${PATHS.output}/index.d.ts`, @@ -88,12 +116,24 @@ export default async function buildTypes(log = true): Promise { imports: { '#*': { types: './*.d.ts' }, }, + dependencies: versionsOf(TYPES_PACKAGE_DEPENDENCIES), + peerDependencies: versionsOf( + TYPES_PACKAGE_PEER_DEPENDENCIES, + ), + peerDependenciesMeta: Object.fromEntries( + TYPES_PACKAGE_OPTIONAL_PEER_DEPENDENCIES.map(name => [ + name, + { optional: true }, + ]), + ), }, null, 4, )}\n`, ) + await auditExternalSpecifiers() + if (log) { const duration = (performance.now() - start).toFixed(2) console.info( @@ -110,41 +150,20 @@ export default async function buildTypes(log = true): Promise { } } -import assets from 'pkg:assets' -import components from 'pkg:components' -import discord from 'pkg:discord' -import externals from 'pkg:externals' -import jsonStorage from 'pkg:json-storage' -import modules from 'pkg:modules' -import patcher from 'pkg:patcher' -import plugins from 'pkg:plugins' -import react from 'pkg:react' -import utils from 'pkg:utils' -import { writeFileSync } from 'fs' -import { exists } from './_shared' - -type TrimLeadingDotAndMaybeSlash = T extends `.${infer R}` - ? R extends `/${infer S}` - ? S - : R - : T - -type NameBinding = [original: Exp, to: string] -type Exports = Exp | NameBinding - function library< E extends Record<`.${string}`, { default?: string; types?: string }>, >( pkg: { name: string; exports: E }, - exports: Exports>>[], + exports: ExportBinding>>[], ): [name: string, exports: [exportName: string, path: string][]] { const mapped: [exportName: string, path: string][] = [] - const bindings: Record = {} + const bindings: Record = {} const pkgName = pkg.name.split('/').at(-1)! - for (const nameOrBinding of exports) { - const isBinding = Array.isArray(nameOrBinding) - const name = isBinding ? nameOrBinding[0] : nameOrBinding + for (const item of exports) { + const isBinding = Array.isArray(item) + const name = isBinding ? item[0] : item + const actualName = isBinding ? item[1] : item const exportEntry = pkg.exports[`.${name ? `/${name}` : ''}`] if (!exportEntry) { @@ -156,10 +175,7 @@ function library< continue } - const { default: defaultPath, types } = exportEntry - const actualName = isBinding ? nameOrBinding[1] : name - const path = defaultPath ?? types - + const path = exportEntry.default ?? exportEntry.types if (!path) { console.warn( chalk.yellow( @@ -169,39 +185,39 @@ function library< continue } - if (isBinding) (bindings[actualName] ??= []).push([actualName, name]) - else { + if (isBinding) { + ;(bindings[actualName] ??= []).push({ + from: name, + path: `${PATHS.lib}/${pkgName}/${path}`, + }) + } else { console.debug( chalk.gray( - `📄 ${pkgName}${name && `/${name}`} -> ${pkg.name}${name && `/${name}`}`, + `📄 ${pkgName}${name ? `/${name}` : ''} -> ${pkg.name}${name ? `/${name}` : ''}`, ), ) mapped.push([actualName, `${PATHS.lib}/${pkgName}/${path}`]) } } - for (const [to, froms] of Object.entries(bindings)) { - if (froms.length > 1) { - const tempFileName = Array.from( - crypto.getRandomValues(new Uint8Array(4)), - ) - .map(b => b.toString(16).padStart(2, '0')) - .join('') - + for (const [to, items] of Object.entries(bindings)) { + if (items.length > 1) { + const tempFileName = `${pkgName}_${to.replace(/[/\\?%*:|"<>]/g, '_')}` const tempPath = `${PATHS.outputTemp}/${tempFileName}.ts` - const content = froms - .map(([, from]) => { + const content = items + .map(({ from }) => { const path = `${pkg.name}/${from}` console.debug( - chalk.gray(`↔️ ${pkgName}${to && `/${to}`} -> ${path}`), + chalk.gray( + `↔️ ${pkgName}${to ? `/${to}` : ''} -> ${path}`, + ), ) - return `export * from '${path}'` }) .join('\n') try { - writeFileSync(tempPath, content) + writeFile(tempPath, content) mapped.push([to, tempPath]) } catch (error) { console.error( @@ -209,35 +225,211 @@ function library< error, ) } - } else if (froms[0]) { - mapped.push(froms[0]) + } else if (items[0]) { + mapped.push([to, items[0].path]) } } return [pkgName, mapped] } -/** - * Generates `index.d.ts` for the published types package. - * - * Consumers install the types package and add it to their tsconfig `types`, which loads a file that: - * - * - References `globals.d.ts` so global declarations (eg. `plugin()`, `revenge`) apply. - * - Declares an ambient module for every public entry that re-exports the generated types file - * via a package-private `imports` (eg. `#lib/assets`), which resolves inside the types package - * without being importable to consumers. - */ +function getLibraries() { + return [ + library(assets, ['', 'types']), + library(components, [ + '', + 'FormSwitch', + 'Page', + 'SearchInput', + 'TableRowAssetIcon', + 'types', + ]), + library(discord, [ + 'actions', + 'common', + 'common/flux', + 'common/utils', + 'design', + 'flux', + 'modules/main_tabs_v2', + 'modules/settings', + 'modules/settings/renderer', + 'native', + 'types', + 'utils/modules/finders', + 'utils/modules/metro/subscriptions', + ]), + library(externals, [ + 'browserify', + 'react-native-clipboard', + 'react-native-safe-area-context', + 'react-navigation', + 'shopify', + ]), + library(modules, [ + 'finders', + 'finders/filters', + ['metro/subscriptions', 'metro'], + ['metro/utils', 'metro'], + 'native', + 'native/fs', + 'types', + ]), + library(patcher, ['', 'types']), + library(plugins, ['constants', 'types', 'utils']), + library(react, ['', 'jsx-runtime', 'native', 'types']), + library(jsonStorage, ['', 'types']), + library(utils, [ + 'callback', + 'discord', + 'error', + 'object', + 'promise', + 'proxy', + 'react', + 'tree', + 'types', + ]), + ] as const +} + +async function cleanup(dir: string, description: string): Promise { + try { + if (await exists(dir)) { + await rm(dir, { recursive: true, force: true }) + console.debug(chalk.gray(`🗑️ Deleted ${description}...`)) + } + } catch (error) { + console.warn( + chalk.yellow(`⚠️ Failed to delete ${description}: ${error}`), + ) + } +} + +/** Rewrites `@revenge-mod/*` to its package-private `#lib/*. */ +async function internalizeSpecifiers( + input: Record, +): Promise { + const entries = new Set( + Object.keys(input) + .filter(entry => entry.startsWith(`${PATHS.lib}/`)) + .map(entry => entry.slice(`${PATHS.lib}/`.length)), + ) + + const pattern = new RegExp( + `(['"])@revenge-mod/(${Array.from(entries) + .sort((a, b) => b.length - a.length) + .map(entry => entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|')})\\1`, + 'g', + ) + + const files = await readdir(PATHS.output, { + recursive: true, + withFileTypes: true, + }) + + await Promise.all( + files.map(async file => { + if (!file.isFile() || !file.name.endsWith('.d.ts')) return + + const path = `${file.parentPath}/${file.name}` + const code = await readFile(path, 'utf8') + const rewritten = code.replace( + pattern, + (_, quote, entry) => `${quote}#${PATHS.lib}/${entry}${quote}`, + ) + + if (rewritten !== code) await writeFile(path, rewritten) + }), + ) +} + +/** Resolves the version ranges this repository uses for the given packages. */ +function versionsOf(names: string[]): Record { + const versions: Record = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, + } + + return Object.fromEntries( + names.map(name => { + const version = versions[name] + if (!version) { + console.warn( + chalk.yellow( + `⚠️ "${name}" is declared for the types package but not installed here`, + ), + ) + } + return [name, version ?? '*'] + }), + ) +} + +/** Warns about packages generated types use without the types package.json declaring them. */ +async function auditExternalSpecifiers(): Promise { + const declared = new Set([ + ...TYPES_PACKAGE_DEPENDENCIES, + ...TYPES_PACKAGE_PEER_DEPENDENCIES, + ]) + + const files = await readdir(PATHS.output, { + recursive: true, + withFileTypes: true, + }) + + const undeclared = new Map() + + await Promise.all( + files.map(async file => { + if (!file.isFile() || !file.name.endsWith('.d.ts')) return + + const path = `${file.parentPath}/${file.name}` + const code = (await readFile(path, 'utf8')) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + + for (const [, specifier] of code.matchAll( + /(?:from|import\()\s*["']([^"']+)["']/g, + )) { + if (/^[.#]|^@revenge-mod\//.test(specifier!)) continue + + const name = specifier!.startsWith('@') + ? specifier!.split('/').slice(0, 2).join('/') + : specifier!.split('/')[0]! + + if (!declared.has(name)) undeclared.set(name, path) + } + }), + ) + + for (const [name, path] of undeclared) { + console.warn( + chalk.yellow( + `⚠️ "${name}" is imported by ${path} but not declared by the types package`, + ), + ) + } +} + +/** Generates `index.d.ts` for the published types package. */ async function generateIndex(input: Record): Promise { + const entries = Object.keys(input).sort() + + const references = entries.map( + entry => `/// `, + ) + const shims: string[] = [] - for (const entry of Object.keys(input).sort()) { - // not importable, it's included via the reference above + for (const entry of entries) { if (entry === 'globals') continue const moduleName = entry.replace(/^lib\//, '@revenge-mod/') const typesPath = `#${entry}` const dts = await readFile(`${PATHS.output}/${entry}.d.ts`, 'utf8') - // `export *` does not re-export default exports const hasDefault = /\bas default\b|\bexport default\b/.test(dts) shims.push( @@ -250,65 +442,7 @@ async function generateIndex(input: Record): Promise { ) } - return `/// \n\n${shims.join('\n\n')}\n` + return `${references.join('\n')}\n\n${shims.join('\n\n')}\n` } -const Libraries = [ - library(assets, ['', 'types']), - library(components, [ - '', - 'FormSwitch', - 'Page', - 'SearchInput', - 'TableRowAssetIcon', - 'types', - ]), - library(discord, [ - 'actions', - 'common', - 'common/flux', - 'common/utils', - 'design', - 'flux', - 'modules/main_tabs_v2', - 'modules/settings', - 'modules/settings/renderer', - 'native', - 'types', - 'utils/modules/finders', - 'utils/modules/metro/subscriptions', - ]), - library(externals, [ - 'browserify', - 'react-native-clipboard', - 'react-native-safe-area-context', - 'react-navigation', - 'shopify', - ]), - library(modules, [ - 'finders', - 'finders/filters', - ['metro/subscriptions', 'metro'], - ['metro/utils', 'metro'], - 'native', - 'native/fs', - 'types', - ]), - library(patcher, ['', 'types']), - library(plugins, ['constants', 'types', 'utils']), - library(react, ['', 'jsx-runtime', 'native', 'types']), - library(jsonStorage, ['', 'types']), - library(utils, [ - 'callback', - 'discord', - 'error', - 'object', - 'promise', - 'proxy', - 'react', - 'tree', - 'types', - ]), -] as const - if (main === import.meta.filename) await buildTypes() diff --git a/tsconfig.json b/tsconfig.json index a4c68d1..c5398a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,6 @@ "src", "types", "./types/*.d.ts", - "node_modules/@types/node/events.d.ts", "node_modules/@types/node/util.d.ts", "node_modules/react-native/types/index.d.ts", // Build artifacts required to emit types diff --git a/types/globals.d.ts b/types/globals.d.ts index 8f58aca..67958d6 100644 --- a/types/globals.d.ts +++ b/types/globals.d.ts @@ -23,8 +23,6 @@ declare module 'react' { /// HERMES declare global { - const HermesInternal: HermesInternalObject - function setTimeout( cb: (...args: unknown[]) => unknown, timeout?: number, @@ -95,10 +93,7 @@ declare global { var __METRO_GLOBAL_PREFIX__: '' var __d: Metro.DefineFn - var __r: Metro.RequireFn & { - importDefault: Metro.ImportDefaultFn - importAll: Metro.ImportAllFn - } + var __r: Metro.Require var __c: Metro.ClearFn var __registerSegment: Metro.RegisterSegmentFn } diff --git a/types/globals.internal.d.ts b/types/globals.internal.d.ts new file mode 100644 index 0000000..df6050c --- /dev/null +++ b/types/globals.internal.d.ts @@ -0,0 +1,6 @@ +// Globals declared for this codebase ONLY! + +// React Native declares `HermesInternal` as `null | {}`, so shipping a stricter redeclaration would give consumers an error. +// This repository doesn't use React Native types, so it can use a stricter declaration. + +declare const HermesInternal: HermesInternalObject