refactor(electron): nestjsfy

This commit is contained in:
Peng Xiao
2025-04-24 14:54:30 +08:00
parent 899ffd1ad3
commit 68ab87f5c5
178 changed files with 7139 additions and 5617 deletions
@@ -5,6 +5,7 @@ import { getBuildConfig } from '@affine-tools/utils/build-config';
import { Package } from '@affine-tools/utils/workspace';
import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin';
import type { BuildOptions, Plugin } from 'esbuild';
import esbuildPluginTsc from 'esbuild-plugin-tsc';
export const electronDir = fileURLToPath(new URL('..', import.meta.url));
@@ -38,7 +39,12 @@ export const config = (): BuildOptions => {
),
};
const plugins: Plugin[] = [];
const plugins: Plugin[] = [
// ensures nestjs decorators are working (emitDecoratorMetadata not supported in esbuild)
esbuildPluginTsc({
tsconfigPath: resolve(electronDir, 'tsconfig.json'),
}),
];
if (
process.env.SENTRY_AUTH_TOKEN &&
@@ -78,16 +84,30 @@ export const config = (): BuildOptions => {
return {
entryPoints: [
resolve(electronDir, './src/main/index.ts'),
resolve(electronDir, './src/preload/index.ts'),
resolve(electronDir, './src/helper/index.ts'),
resolve(electronDir, './src/entries/main/index.ts'),
resolve(electronDir, './src/entries/preload/index.ts'),
resolve(electronDir, './src/entries/helper/index.ts'),
],
entryNames: '[dir]',
outdir: resolve(electronDir, './dist'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', 'electron-updater', 'yjs', 'semver'],
external: [
'electron',
'electron-updater',
'yjs',
'semver',
// nestjs related:
'@nestjs/platform-express',
'@nestjs/microservices',
'@nestjs/websockets/socket-module',
'@apollo/subgraph',
'@apollo/gateway',
'ts-morph',
'class-validator',
'class-transformer',
],
format: 'cjs',
loader: {
'.node': 'copy',
@@ -0,0 +1,119 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Project } from 'ts-morph';
import { parseIpcEvents } from './ipc-generator/events-parser';
import { parseIpcHandlers } from './ipc-generator/handlers-parser';
import {
type CollectedApisMap,
type CollectedEventsMap,
type OutputPaths,
} from './ipc-generator/types';
import {
generateApiTypesFile,
generateCombinedMetaFile,
generateEventTypesFile,
} from './ipc-generator/utils';
// ES module equivalent for __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const electronRoot = path.resolve(__dirname, '../');
const rootDir = path.resolve(electronRoot, '..', '..', '..', '..');
// Configure output paths
const paths: OutputPaths = {
// Generate api types under @affine/electron-api
apiTypes: path.resolve(
rootDir,
'packages/frontend/electron-api/src/ipc-api-types.gen.ts'
),
ipcMeta: path.resolve(electronRoot, 'src/entries/preload/ipc-meta.gen.ts'),
// Event type definitions
eventTypes: path.resolve(
rootDir,
'packages/frontend/electron-api/src/ipc-event-types.gen.ts'
),
};
function ensureDirectories(paths: OutputPaths): void {
Object.values(paths)
.filter(Boolean) // Skip empty paths
.forEach(filePath => {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
} catch (e: any) {
// Only warn about errors other than "directory already exists"
if (e.code !== 'EEXIST') {
console.warn(`[WARN] Could not create directory: ${e.message}`);
throw new Error(
`Failed to create directory for ${filePath}: ${e.message}`
);
}
}
});
}
function writeGeneratedFiles(
collectedApiHandlers: CollectedApisMap,
collectedEvents: CollectedEventsMap,
paths: OutputPaths
): void {
// Write API handler types file
fs.writeFileSync(paths.apiTypes, generateApiTypesFile(collectedApiHandlers));
console.log(`IPC API type definitions generated at: ${paths.apiTypes}`);
// Write event types file if events were found
if (collectedEvents.size > 0) {
fs.writeFileSync(paths.eventTypes, generateEventTypesFile(collectedEvents));
console.log(`IPC Event type definitions generated at: ${paths.eventTypes}`);
} else {
console.log('No IPC Events found. Skipping event types generation.');
}
// Write combined metadata file
fs.writeFileSync(
paths.ipcMeta,
generateCombinedMetaFile(collectedApiHandlers, collectedEvents)
);
console.log(`IPC combined metadata generated at: ${paths.ipcMeta}`);
}
/**
* Main function to generate IPC definitions
*/
function generateIpcDefinitions() {
// Initialize ts-morph project
const project = new Project({
tsConfigFilePath: path.resolve(electronRoot, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true,
});
// Add relevant source files
project.addSourceFilesAtPaths([
path.resolve(electronRoot, 'src/**/*.ts'),
// Add other paths where IPC handlers might be defined
]);
// Parse handlers and events from the source files
const { apis: collectedApiHandlers } = parseIpcHandlers(project);
const { events: collectedEvents } = parseIpcEvents(project);
if (collectedApiHandlers.size === 0) {
console.log(
'No IPC handlers found. Generated files will be empty or contain minimal structure.'
);
}
// Ensure directories exist
ensureDirectories(paths);
// Write generated files
writeGeneratedFiles(collectedApiHandlers, collectedEvents, paths);
}
// Run the generator
generateIpcDefinitions();
@@ -0,0 +1,277 @@
import { Node, Project, PropertyDeclaration } from 'ts-morph';
import { type CollectedEventsMap, type ParsedEventInfo } from './types';
import { determineEntry } from './utils';
export const IpcEventDecoratorName = 'IpcEvent';
/**
* Parses the @IpcEvent decorator and extracts relevant information
*/
export function parseIpcEventDecorator(
propertyDeclaration: PropertyDeclaration
): Omit<ParsedEventInfo, 'description'> | { error: string } {
const decorator = propertyDeclaration
.getDecorators()
.find(d => d.getName() === IpcEventDecoratorName);
if (!decorator) return { error: 'Decorator not found' };
const args = decorator.getArguments();
const sourceFile = propertyDeclaration.getSourceFile();
const propertyNameInCode = propertyDeclaration.getName();
if (args.length === 0) {
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode} in ${sourceFile.getFilePath()} is missing arguments.`,
};
}
const optionsArg = args[0];
if (!Node.isObjectLiteralExpression(optionsArg)) {
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode} in ${sourceFile.getFilePath()} requires an object argument.`,
};
}
let scopeValue: string | undefined;
const scopeProperty = optionsArg.getProperty('scope');
if (scopeProperty && Node.isPropertyAssignment(scopeProperty)) {
const initializer = scopeProperty.getInitializer();
if (initializer) {
if (Node.isStringLiteral(initializer))
scopeValue = initializer.getLiteralValue();
else if (Node.isPropertyAccessExpression(initializer)) {
const type = initializer.getType();
if (type.isStringLiteral())
scopeValue = type.getLiteralValue() as string;
else scopeValue = initializer.getNameNode().getText();
}
}
}
if (!scopeValue)
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode}: missing valid 'scope'.`,
};
let declaredName: string | undefined;
const nameProperty = optionsArg.getProperty('name');
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
const initializer = nameProperty.getInitializer();
if (initializer && Node.isStringLiteral(initializer))
declaredName = initializer.getLiteralValue();
else if (initializer)
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode}: 'name' must be a string literal.`,
};
}
const eventName = declaredName ?? propertyNameInCode.replace(/\$$/, '');
const ipcChannel = `${scopeValue}:${eventName}`;
let payloadType = 'any[]'; // Default
const propertyTypeNode = propertyDeclaration.getTypeNode();
const propertyType = propertyDeclaration.getType(); // Get the full Type object
// Attempt 1: Regex on TypeNode text (faster, good for common cases)
if (propertyTypeNode) {
const typeNodeText = propertyTypeNode.getText();
// Consolidated regex for known stream types including Observable
const knownStreamTypesRegex =
/(?:BehaviorSubject|ReplaySubject|Subject|Observable|EventEmitter)<([^>]+)>/;
const typeMatch = typeNodeText.match(knownStreamTypesRegex);
if (typeMatch && typeMatch[1]) {
payloadType = typeMatch[1].trim();
}
}
// Attempt 2: If regex failed or resulted in default, try more robust Type object inspection
if (payloadType === 'any[]') {
// Only if not found by Attempt 1 (explicit type annotation)
const typesToInspect: import('ts-morph').Type[] = [propertyType];
if (propertyType.isIntersection()) {
typesToInspect.push(...propertyType.getIntersectionTypes());
}
// Consider base types if the primary type itself is not a directly recognized Observable symbol
// This helps with classes extending Observable<T>
const primarySymbolName = propertyType.getSymbol()?.getName();
const isPrimaryRecognizedObservable = [
'Subject',
'BehaviorSubject',
'ReplaySubject',
'EventEmitter',
'Observable',
].includes(primarySymbolName || '');
if (!isPrimaryRecognizedObservable && propertyType.isClassOrInterface()) {
typesToInspect.push(...propertyType.getBaseTypes());
}
for (const typeToInspect of typesToInspect) {
// Ensure we are dealing with a type that can have generics and a symbol (class/interface)
// isAnonymous handles cases within intersections that might not be directly isClassOrInterface
if (
!typeToInspect.isClassOrInterface() &&
!typeToInspect.isAnonymous() &&
!typeToInspect.isObject()
)
continue;
const typeName = typeToInspect.getSymbol()?.getName();
if (
typeName === 'Subject' ||
typeName === 'BehaviorSubject' ||
typeName === 'ReplaySubject' ||
typeName === 'EventEmitter' ||
typeName === 'Observable'
) {
const typeArguments = typeToInspect.getTypeArguments();
if (typeArguments.length > 0) {
const argText = typeArguments[0].getText(sourceFile).trim();
// Prioritize more specific types over 'any' or 'unknown' if multiple paths yield a type.
if (
payloadType === 'any[]' ||
((payloadType === 'any' || payloadType === 'unknown') &&
argText !== 'any' &&
argText !== 'unknown')
) {
payloadType = argText;
}
// If we found a concrete type (not any/unknown/any[]), we can stop searching.
if (
payloadType !== 'any' &&
payloadType !== 'unknown' &&
payloadType !== 'any[]'
) {
break; // Found a good type from typesToInspect loop
}
}
// If typeArguments is empty here, it implies Observable (or Subject, etc.) without a generic.
// The later fallback `if (payloadType === 'any[]') payloadType = 'any'` will handle this.
}
}
// If, after checking all typesToInspect, payloadType is still 'any[]' or a non-specific type,
// and we found a specific type from the primary propertyType earlier (even if it was 'any'),
// we might need to ensure the most specific one found is kept.
// However, the current logic of only updating if more specific should handle this.
}
// Attempt 3: Look at initializer expression (e.g., new Subject<void>()) if still default
if (payloadType === 'any[]') {
const initializer = propertyDeclaration.getInitializer();
if (initializer && Node.isNewExpression(initializer)) {
const typeArgs = initializer.getType().getTypeArguments();
if (typeArgs.length > 0) {
payloadType = typeArgs[0].getText(sourceFile).trim();
}
}
}
// If still default any[] but we have detected Subject without generic, treat as any not array
if (payloadType === 'any[]') {
payloadType = 'any';
}
// Final cleanup for void
if (payloadType.toLowerCase() === 'void') {
payloadType = ''; // Represent void as empty params for callback
}
return {
scope: scopeValue,
eventName,
ipcChannel,
payloadType,
originalPropertyName: propertyNameInCode,
entry: determineEntry(sourceFile.getFilePath()),
filePath: sourceFile.getFilePath(),
};
}
/**
* Parses all IPC events in the project and collects their information
*/
export function parseIpcEvents(project: Project): {
events: CollectedEventsMap;
} {
const collectedEvents: CollectedEventsMap = new Map();
project.getSourceFiles().forEach(sourceFile => {
sourceFile.getClasses().forEach(classDeclaration => {
classDeclaration.getProperties().forEach(propertyDeclaration => {
const decorator = propertyDeclaration
.getDecorators()
.find(d => d.getName() === IpcEventDecoratorName);
if (!decorator) return;
const parsedEventInfo = parseIpcEventDecorator(propertyDeclaration);
if ('error' in parsedEventInfo) {
if (parsedEventInfo.error !== 'Decorator not found')
console.error(`[Event ERR] ${parsedEventInfo.error}`);
return;
}
const {
scope,
eventName,
payloadType,
entry,
filePath,
originalPropertyName,
} = parsedEventInfo;
// derive modulePath and className here
const absPath = filePath as string;
const idx = absPath.lastIndexOf('/src/');
let modulePath = absPath;
if (idx !== -1) {
modulePath =
'@affine/electron/' + absPath.substring(idx + '/src/'.length);
}
modulePath = modulePath.replace(/\.[tj]sx?$/, '');
const clsName =
propertyDeclaration.getParent()?.getName?.() || 'UnknownClass';
const propName = originalPropertyName;
const description = propertyDeclaration
.getJsDocs()
.map(doc => doc.getDescription().trim())
.filter(Boolean)
.join('\n');
if (!collectedEvents.has(scope)) collectedEvents.set(scope, []);
const eventScopeMethods = collectedEvents.get(scope);
if (eventScopeMethods) {
const existingEvent = eventScopeMethods.find(
event => event.eventName === eventName
);
if (existingEvent) {
throw new Error(
`[Event ERR] Duplicate event found for scope '${scope}' and eventName '${eventName}'.\n` +
` Original: ${existingEvent.filePath} (${existingEvent.className}.${existingEvent.propertyName})\n` +
` Duplicate: ${filePath as string} (${clsName}.${propName})`
);
}
eventScopeMethods.push({
eventName,
payloadType,
modulePath,
className: clsName,
propertyName: propName,
description,
entry,
filePath: filePath as string,
});
} else {
console.error(
`[CRITICAL] Failed to retrieve event methods array for scope: ${scope}`
);
}
});
});
});
return {
events: collectedEvents,
};
}
@@ -0,0 +1,201 @@
import {
MethodDeclaration,
Node,
Project,
PropertyDeclaration,
} from 'ts-morph';
import { type CollectedApisMap, type ParsedDecoratorInfo } from './types';
import { determineEntry } from './utils';
export const IpcHandleDecoratorName = 'IpcHandle';
type IpcDecoratedMember = MethodDeclaration | PropertyDeclaration;
/**
* Parses the @IpcHandle decorator and extracts relevant information
*/
function parseIpcHandleDecorator(
memberDeclaration: IpcDecoratedMember
): ParsedDecoratorInfo {
const ipcHandleDecorator = memberDeclaration
.getDecorators()
.find(d => d.getName() === IpcHandleDecoratorName);
if (!ipcHandleDecorator) {
return {}; // No decorator found
}
const decoratorArgs = ipcHandleDecorator.getArguments();
const sourceFile = memberDeclaration.getSourceFile();
const methodNameInCode = memberDeclaration.getName(); // For error messages and fallback
if (decoratorArgs.length === 0) {
return {
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} is missing arguments.`,
};
}
const optionsArg = decoratorArgs[0];
if (Node.isObjectLiteralExpression(optionsArg)) {
const scopeProperty = optionsArg.getProperty('scope');
let scopeValue: string | undefined;
if (scopeProperty && Node.isPropertyAssignment(scopeProperty)) {
const scopeInitializer = scopeProperty.getInitializer();
if (scopeInitializer) {
if (Node.isStringLiteral(scopeInitializer)) {
scopeValue = scopeInitializer.getLiteralValue();
} else if (Node.isPropertyAccessExpression(scopeInitializer)) {
const checker = scopeInitializer.getProject().getTypeChecker();
const propertyAccessType = scopeInitializer.getType(); // Type of the e.g. IpcScope.MAIN expression
if (propertyAccessType.isStringLiteral()) {
const literalValue = propertyAccessType.getLiteralValue();
if (typeof literalValue === 'string') {
scopeValue = literalValue;
} else {
// This case should be rare if isStringLiteral() is true
return {
error: `Scope for ${methodNameInCode} in ${sourceFile.getFilePath()} resolved to a string literal type, but its value is not a string: ${literalValue}. Please use a string enum or string literal for scope.`,
};
}
} else {
// The type of the expression (e.g., IpcScope.MAIN) is not itself a string literal type.
// This might happen if IpcScope is a numeric enum, or a more complex type.
// Attempt to get the constant value of the expression.
const constant = checker.compilerObject.getConstantValue(
scopeInitializer.compilerNode
);
if (typeof constant === 'string') {
scopeValue = constant;
} else {
let errorMessage = `Unable to resolve 'scope' for ${methodNameInCode} in ${sourceFile.getFilePath()} to a string constant. `;
if (typeof constant === 'number') {
errorMessage += `Resolved to a number (${constant}). Please use a string enum or string literal.`;
} else if (constant === undefined) {
errorMessage += `The expression does not resolve to a compile-time constant string. Ensure it's a direct string enum member (e.g., MyEnum.Value) or a string literal.`;
} else {
errorMessage += `Resolved to an unexpected type '${typeof constant}' with value '${constant}'. Please use a string enum or string literal.`;
}
return { error: errorMessage };
}
}
}
}
}
if (!scopeValue) {
return {
error: `@${IpcHandleDecoratorName} in ${methodNameInCode} in ${sourceFile.getFilePath()} is missing a valid 'scope'.`,
};
}
let nameValue: string | undefined;
const nameProperty = optionsArg.getProperty('name');
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
const nameInitializer = nameProperty.getInitializer();
if (nameInitializer && Node.isStringLiteral(nameInitializer)) {
nameValue = nameInitializer.getLiteralValue();
} else if (nameInitializer) {
return {
error: `@${IpcHandleDecoratorName} in ${methodNameInCode} in ${sourceFile.getFilePath()} has an invalid 'name' property. It must be a string literal.`,
};
}
}
return {
scope: scopeValue,
apiMethodName: nameValue ?? methodNameInCode,
entry: determineEntry(sourceFile.getFilePath()),
filePath: sourceFile.getFilePath() as string,
};
} else if (Node.isStringLiteral(optionsArg)) {
return {
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} uses legacy string literal. Please update to object format { scope: string, name?: string }.`,
};
} else {
return {
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} has invalid arguments.`,
};
}
}
/**
* Parses all IPC handlers in the project and collects their information
*/
export function parseIpcHandlers(project: Project): {
apis: CollectedApisMap;
} {
const collectedApiHandlers: CollectedApisMap = new Map();
project.getSourceFiles().forEach(sourceFile => {
sourceFile.getClasses().forEach(classDeclaration => {
// Iterate over both traditional methods and property declarations (which can
// hold arrow-function handlers) so that handlers like
// `handleWebContentsResize = () => {}` are also detected.
const members: IpcDecoratedMember[] = [
...classDeclaration.getMethods(),
...classDeclaration.getProperties(),
];
members.forEach(memberDeclaration => {
const parsedHandlerInfo = parseIpcHandleDecorator(memberDeclaration);
if (parsedHandlerInfo.error) {
console.error(`[API Handler ERR] ${parsedHandlerInfo.error}`);
return;
}
if (
!parsedHandlerInfo.scope ||
!parsedHandlerInfo.apiMethodName ||
!parsedHandlerInfo.entry ||
!parsedHandlerInfo.filePath
)
return;
const { scope, apiMethodName, entry, filePath } = parsedHandlerInfo;
const classDecl = memberDeclaration.getParent();
const className = (classDecl as any)?.getName?.() || 'UnknownClass';
// Build module path for import specifier (strip up to /src/ and .ts extension)
const absPath = sourceFile.getFilePath() as unknown as string;
const srcIdx = absPath.lastIndexOf('/src/');
let modulePath = absPath;
if (srcIdx !== -1) {
modulePath =
'@affine/electron/' + absPath.substring(srcIdx + '/src/'.length);
}
modulePath = modulePath.replace(/\.[tj]sx?$/, '');
const description = memberDeclaration
.getJsDocs()
.map(doc => doc.getDescription().trim())
.filter(Boolean)
.join('\n');
if (!collectedApiHandlers.has(scope))
collectedApiHandlers.set(scope, []);
const handlerScopeMethods = collectedApiHandlers.get(scope);
if (handlerScopeMethods) {
handlerScopeMethods.push({
apiMethodName,
modulePath,
className,
methodName: memberDeclaration.getName(),
description,
entry,
filePath: filePath as string,
});
} else {
console.error(
`[CRITICAL] Failed to retrieve handler methods array for scope: ${scope}`
);
}
});
});
});
return {
apis: collectedApiHandlers,
};
}
@@ -0,0 +1,67 @@
/**
* Types for IPC generator
*/
export interface ParsedDecoratorInfo {
/** ipc scope */
scope?: string;
/** method name that will show up in the generated API */
apiMethodName?: string;
/** optional error message when decorator is invalid */
error?: string;
/** entry: main/helper */
entry?: Entry;
/** absolute path to the source file */
filePath?: string;
}
export interface ParsedEventInfo {
scope: string;
/** event name without the scope prefix */
eventName: string;
/** full ipc channel (e.g. ui:maximized) */
ipcChannel: string;
/** payload type extracted from rxjs observable kept for compatibility */
payloadType: string;
/** original property name in the class */
originalPropertyName: string;
/** entry of the source file */
entry: Entry;
description?: string;
error?: string;
/** absolute path to the source file */
filePath?: string;
}
export type Entry = 'main' | 'helper';
export interface CollectedApiMethodInfo {
apiMethodName: string;
modulePath: string;
className: string;
methodName: string;
description?: string;
entry: Entry;
filePath: string;
}
export type CollectedApisMap = Map<string, CollectedApiMethodInfo[]>;
export interface CollectedEventInfoForMeta {
eventName: string;
}
export interface CollectedEventInfoForTypes extends CollectedEventInfoForMeta {
payloadType: string;
description?: string;
entry: Entry;
filePath: string;
className: string; // Class containing the event property
propertyName: string; // Original property name
modulePath: string;
}
export type CollectedEventsMap = Map<string, CollectedEventInfoForTypes[]>; // Keyed by scope
export interface OutputPaths {
apiTypes: string; // Type definitions for API handlers
eventTypes: string; // Type definitions for events
ipcMeta: string; // Combined metadata for both handlers and events
}
@@ -0,0 +1,111 @@
import {
type CollectedApisMap,
type CollectedEventsMap,
type Entry,
} from './types';
export const determineEntry = (path: string): Entry => {
if (path.includes('src/entries/helper')) return 'helper';
return 'main';
};
/**
* Generates the API type definitions file content
*/
export function generateApiTypesFile(collectedApis: CollectedApisMap): string {
let content = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n`;
content += `// Utility type: remove trailing IpcMainInvokeEvent param and ensure Promise return\n`;
content += `type EnsurePromise<T> = T extends Promise<any> ? T : Promise<T>;\n`;
content += `export type ApiMethod<T> = T extends (...args: infer P) => infer R ? (...args: P) => EnsurePromise<R> : never;\n\n`;
content += `export interface ElectronApis {\n`;
for (const [scope, methods] of collectedApis) {
content += ` ${scope}: {\n`;
for (const method of methods) {
if (method.description && method.description.trim().length > 0) {
const lines = method.description.split('\n').map(l => ` * ${l}`);
content += ' /**\n';
content += lines.join('\n') + '\n';
content += ' */\n';
}
content += ` ${method.apiMethodName}: ApiMethod<import('${method.modulePath}').${method.className}['${method.methodName}']>;\n`;
}
content += ` };\n`;
}
content += `}\n`;
return content;
}
/**
* Generates the event type definitions file content
*/
export function generateEventTypesFile(
collectedEvents: CollectedEventsMap
): string {
let out = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport type { Observable } from 'rxjs';\n\n`;
out += `type ToSubscribe<T extends Observable<unknown>> = T extends Observable<infer P> ? (callback: (payload: P) => void) => () => void : never;\n\n`;
out += `export interface ElectronEvents {\n`;
for (const [scope, events] of collectedEvents) {
out += ` ${scope}: {\n`;
for (const evt of events) {
const capName =
evt.eventName.charAt(0).toUpperCase() + evt.eventName.slice(1);
if (evt.description && evt.description.trim().length > 0) {
const lines = evt.description.split('\n').map(l => ` * ${l}`);
out += ' /**\n';
out += lines.join('\n') + '\n';
out += ' */\n';
}
out += ` on${capName}: ToSubscribe<import('${evt.modulePath}').${evt.className}['${evt.propertyName}']>;\n`;
}
out += ` };\n`;
}
out += `}\n`;
return out;
}
/**
* Generates a combined metadata file for both IPC handlers and events
*/
export function generateCombinedMetaFile(
apiHandlers: CollectedApisMap,
events: CollectedEventsMap
): string {
let content = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n`;
// Build nested structure: entry -> scope -> names[]
const handlersMeta: Record<string, Record<string, string[]>> = {};
apiHandlers.forEach((methods, scope) => {
methods.forEach(m => {
const ent = m.entry;
if (!handlersMeta[ent]) handlersMeta[ent] = {};
if (!handlersMeta[ent][scope]) handlersMeta[ent][scope] = [];
handlersMeta[ent][scope].push(m.apiMethodName);
});
});
const eventsMeta: Record<string, Record<string, string[]>> = {};
events.forEach((evtList, scope) => {
evtList.forEach(e => {
const ent = e.entry;
if (!eventsMeta[ent]) eventsMeta[ent] = {};
if (!eventsMeta[ent][scope]) eventsMeta[ent][scope] = [];
eventsMeta[ent][scope].push(e.eventName);
});
});
// Serialize handlersMeta & eventsMeta
content += `export const handlersMeta = ${JSON.stringify(
handlersMeta,
null,
2
)} as const;\n\n`;
content += `export const eventsMeta = ${JSON.stringify(eventsMeta, null, 2)} as const;\n`;
return content;
}