diff --git a/packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts b/packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts deleted file mode 100644 index 8bbfecf720..0000000000 --- a/packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type SharedStorage from "@vaadin/hilla-generator-core/SharedStorage.js"; -import type {PathSignalType} from "./index"; -import ts, {type CallExpression, type Identifier, type Node} from "typescript"; -import { template, transform } from "@vaadin/hilla-generator-utils/ast.js"; - -type MethodInfo = { - name: string; - signalType: string; -} - -type ServiceInfo = { - service: string; - methods: MethodInfo[]; -} - -const FUNCTION_NAME = '$FUNCTION_NAME$'; -const RETURN_TYPE = '$RETURN_TYPE$'; -const ENDPOINT_CALL_EXPRESSION = '$ENDPOINT_CALL_EXPRESSION$'; - -const groupByService = (signals: PathSignalType[]): Map => { - const serviceMap = new Map(); - - signals.forEach(signal => { - const [_, service, method] = signal.path.split('/'); - - const serviceMethods = serviceMap.get(service) ?? [] ; - - serviceMethods.push({ - name: method, - signalType: signal.signalType, - }); - - serviceMap.set(service, serviceMethods); - }); - - return serviceMap; -}; - -function extractEndpointCallExpression(method: MethodInfo, serviceSource: ts.SourceFile): ts.CallExpression | undefined { - const fn = serviceSource.statements.filter((node) => ts.isFunctionDeclaration(node) && node.name?.text === method.name)[0]; - let callExpression: CallExpression | undefined; - ts.transform(fn as Node, [transform((node) => { - if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === 'call') { - callExpression = node; - } - return node; - })]); - return callExpression; -} - -function transformMethod(method: MethodInfo, sourceFile: ts.SourceFile): void { - const endpointCallExpression = extractEndpointCallExpression(method, sourceFile); - - const ast = template(` - const sharedSignal = await ${ENDPOINT_CALL_EXPRESSION}; - const queueDescriptor = { - id: sharedSignal.id, - subscribe: SignalsHandler.subscribe, - publish: SignalsHandler.update, - } - const valueLog = new NumberSignalQueue(queueDescriptor, connectClient); - return valueLog.getRoot(); - `, (statements) => statements, - []); -} - -function processSignalService(service: string, methods: MethodInfo[], sharedStorage: SharedStorage): void { - // Process the signal service - const serviceSource = sharedStorage.sources.filter((source) => source.fileName === `${service}.ts`)[0]; - if (serviceSource) { - methods.forEach((method) => transformMethod(method, serviceSource)); - } - sharedStorage.sources.splice(sharedStorage.sources.indexOf(serviceSource), 1); -} - -export default function process(pathsWithSignals: PathSignalType[], sharedStorage: SharedStorage): void { - // group methods by service: - const services = groupByService(pathsWithSignals); - services.forEach((serviceInfo) => { - processSignalService(serviceInfo.service, serviceInfo.methods, sharedStorage); - }); -} diff --git a/packages/ts/generator-plugin-signals/src/SignalProcessor.ts b/packages/ts/generator-plugin-signals/src/SignalProcessor.ts new file mode 100644 index 0000000000..f3d64e1d98 --- /dev/null +++ b/packages/ts/generator-plugin-signals/src/SignalProcessor.ts @@ -0,0 +1,112 @@ +import type Plugin from '@vaadin/hilla-generator-core/Plugin.js'; +import { template, transform } from '@vaadin/hilla-generator-utils/ast.js'; +import createSourceFile from '@vaadin/hilla-generator-utils/createSourceFile.js'; +import DependencyManager from '@vaadin/hilla-generator-utils/dependencies/DependencyManager.js'; +import PathManager from '@vaadin/hilla-generator-utils/dependencies/PathManager.js'; +import ts, { type CallExpression, type FunctionDeclaration, type ReturnStatement, type SourceFile } from 'typescript'; + +export type MethodInfo = Readonly<{ + name: string; + signalType: string; +}>; + +const ENDPOINT_CALL_EXPRESSION = '$ENDPOINT_CALL_EXPRESSION$'; +const NUMBER_SIGNAL_QUEUE = '$NUMBER_SIGNAL_QUEUE$'; +const SIGNALS_HANDLER = '$SIGNALS_HANDLER$'; +const CONNECT_CLIENT = '$CONNECT_CLIENT$'; +const HILLA_REACT_SIGNALS = '@vaadin/hilla-react-signals'; +const ENDPOINTS = 'Frontend/generated/endpoints.js'; + +export default class SignalProcessor { + readonly #dependencyManager: DependencyManager; + readonly #owner: Plugin; + readonly #service: string; + readonly #methods: MethodInfo[]; + readonly #sourceFile: SourceFile; + + constructor(service: string, methods: MethodInfo[], sourceFile: SourceFile, owner: Plugin) { + this.#service = service; + this.#methods = methods; + this.#sourceFile = sourceFile; + this.#owner = owner; + this.#dependencyManager = new DependencyManager(new PathManager({ extension: '.js' })); + this.#dependencyManager.imports.fromCode(this.#sourceFile); + } + + process(): SourceFile { + this.#owner.logger.debug(`Processing signals: ${this.#service}`); + const { imports } = this.#dependencyManager; + const numberSignalQueueId = imports.named.add(HILLA_REACT_SIGNALS, 'NumberSignalQueue'); + const signalHandlerId = imports.named.add(ENDPOINTS, 'SignalsHandler'); + + const [_p, _isType, connectClientId] = imports.default.find((p) => p.includes('connect-client'))!; + + this.#processNumberSignalImport('com/vaadin/hilla/signals/NumberSignal'); + + const [file] = ts.transform(this.#sourceFile, [ + ...this.#methods.map((method) => + transform((node) => { + if (ts.isFunctionDeclaration(node) && node.name?.text === method.name) { + const callExpression = (node.body?.statements[0] as ReturnStatement).expression as CallExpression; + const body = template( + ` +function dummy() { + const sharedSignal = await ${ENDPOINT_CALL_EXPRESSION}; + const queueDescriptor = { + id: sharedSignal.id, + subscribe: ${SIGNALS_HANDLER}.subscribe, + publish: ${SIGNALS_HANDLER}.update, + }; + const valueLog = new ${NUMBER_SIGNAL_QUEUE}(queueDescriptor, ${CONNECT_CLIENT}); + return valueLog.getRoot(); +}`, + (statements) => (statements[0] as FunctionDeclaration).body?.statements, + [ + transform((node) => + ts.isIdentifier(node) && node.text === ENDPOINT_CALL_EXPRESSION ? callExpression : node, + ), + transform((node) => + ts.isIdentifier(node) && node.text === NUMBER_SIGNAL_QUEUE ? numberSignalQueueId : node, + ), + transform((node) => (ts.isIdentifier(node) && node.text === SIGNALS_HANDLER ? signalHandlerId : node)), + transform((node) => (ts.isIdentifier(node) && node.text === CONNECT_CLIENT ? connectClientId : node)), + ], + ); + + return ts.factory.createFunctionDeclaration( + node.modifiers, + node.asteriskToken, + node.name, + node.typeParameters, + node.parameters, + node.type, + ts.factory.createBlock(body ?? [], true), + ); + } + + return node; + }), + ), + ]).transformed; + + return createSourceFile( + [ + ...this.#dependencyManager.imports.toCode(), + ...file.statements.filter((statement) => !ts.isImportDeclaration(statement)), + ], + file.fileName, + ); + } + + #processNumberSignalImport(path: string) { + const { imports } = this.#dependencyManager; + + const result = imports.default.find((p) => p.includes(path)); + + if (result) { + const [path, _, id] = result; + imports.default.remove(path); + imports.named.add(HILLA_REACT_SIGNALS, id.text, true, id); + } + } +} diff --git a/packages/ts/generator-plugin-signals/src/index.ts b/packages/ts/generator-plugin-signals/src/index.ts index bbe05cf655..9110f05e8c 100644 --- a/packages/ts/generator-plugin-signals/src/index.ts +++ b/packages/ts/generator-plugin-signals/src/index.ts @@ -1,39 +1,69 @@ -import type SharedStorage from "@vaadin/hilla-generator-core/SharedStorage.js"; -import Plugin from "@vaadin/hilla-generator-core/Plugin.js"; -import process from "./SharedSignalProcessor"; +import type SharedStorage from '@vaadin/hilla-generator-core/SharedStorage.js'; +import Plugin from '@vaadin/hilla-generator-core/Plugin.js'; +import SignalProcessor, { type MethodInfo } from './SignalProcessor.js'; -export type PathSignalType = { +export type PathSignalType = Readonly<{ path: string; signalType: string; -} +}>; -export default class SignalsPlugin extends Plugin { - static readonly SIGNAL_CLASSES = [ - '#/components/schemas/com.vaadin.hilla.signals.NumberSignal' - ] - - #extractEndpointMethodsWithSignalsAsReturnType(storage: SharedStorage): PathSignalType[] { - const pathSignalTypes: PathSignalType[] = []; - Object.entries(storage.api.paths).forEach(([path, pathObject]) => { - const response200 = pathObject?.post?.responses['200']; - if (response200 && !("$ref" in response200)) { // OpenAPIV3.ResponseObject - const responseSchema = response200.content?.['application/json'].schema; - if (responseSchema && ("anyOf" in responseSchema)) { // OpenAPIV3.SchemaObject - responseSchema.anyOf?.some((c) => { - const isSignal = ("$ref" in c) && c.$ref && SignalsPlugin.SIGNAL_CLASSES.includes(c.$ref); - if (isSignal) { - pathSignalTypes.push({ path, signalType: c.$ref }); - } - }); - } +function extractEndpointMethodsWithSignalsAsReturnType(storage: SharedStorage): PathSignalType[] { + const pathSignalTypes: PathSignalType[] = []; + Object.entries(storage.api.paths).forEach(([path, pathObject]) => { + const response200 = pathObject?.post?.responses['200']; + if (response200 && !('$ref' in response200)) { + // OpenAPIV3.ResponseObject + const responseSchema = response200.content?.['application/json'].schema; + if (responseSchema && 'anyOf' in responseSchema) { + // OpenAPIV3.SchemaObject + responseSchema.anyOf?.some((c) => { + const isSignal = '$ref' in c && c.$ref && SignalsPlugin.SIGNAL_CLASSES.includes(c.$ref); + if (isSignal) { + pathSignalTypes.push({ path, signalType: c.$ref }); + } + }); } + } + }); + return pathSignalTypes; +} + +function groupByService(signals: PathSignalType[]): Map { + const serviceMap = new Map(); + + signals.forEach((signal) => { + const [_, service, method] = signal.path.split('/'); + + const serviceMethods = serviceMap.get(service) ?? []; + + serviceMethods.push({ + name: method, + signalType: signal.signalType, }); - return pathSignalTypes; - } + + serviceMap.set(service, serviceMethods); + }); + + return serviceMap; +} + +export default class SignalsPlugin extends Plugin { + static readonly SIGNAL_CLASSES = ['#/components/schemas/com.vaadin.hilla.signals.NumberSignal']; override async execute(sharedStorage: SharedStorage): Promise { - const methodsWithSignals = this.#extractEndpointMethodsWithSignalsAsReturnType(sharedStorage); - process(methodsWithSignals, sharedStorage); + const methodsWithSignals = extractEndpointMethodsWithSignalsAsReturnType(sharedStorage); + const services = groupByService(methodsWithSignals); + services.forEach((methods, service) => { + let index = sharedStorage.sources.findIndex((source) => source.fileName === `${service}.ts`); + if (index >= 0) { + sharedStorage.sources[index] = new SignalProcessor( + service, + methods, + sharedStorage.sources[index], + this, + ).process(); + } + }); } declare ['constructor']: typeof SignalsPlugin; diff --git a/packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts b/packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts index 774fe39e9a..5a4f31ccbb 100644 --- a/packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts +++ b/packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts @@ -4,8 +4,8 @@ import LoggerFactory from '@vaadin/hilla-generator-utils/LoggerFactory.js'; import snapshotMatcher from '@vaadin/hilla-generator-utils/testing/snapshotMatcher.js'; import { expect, use } from 'chai'; import sinonChai from 'sinon-chai'; -import SignalsPlugin from "../index.js"; -import BackbonePlugin from "@vaadin/hilla-generator-plugin-backbone"; +import SignalsPlugin from '../src/index.js'; +import BackbonePlugin from '@vaadin/hilla-generator-plugin-backbone'; use(sinonChai); use(snapshotMatcher); @@ -19,6 +19,11 @@ describe('SignalsPlugin', () => { const input = await readFile(new URL('./hilla-openapi.json', import.meta.url), 'utf8'); const files = await generator.process(input); + let i = 0; + for (const file of files) { + await expect(await file.text()).toMatchSnapshot(`number-signal-${i}`, import.meta.url); + i++; + } }); }); }); diff --git a/packages/ts/generator-utils/src/dependencies/ImportManager.ts b/packages/ts/generator-utils/src/dependencies/ImportManager.ts index 1a74eb5997..14ec71a8b3 100644 --- a/packages/ts/generator-utils/src/dependencies/ImportManager.ts +++ b/packages/ts/generator-utils/src/dependencies/ImportManager.ts @@ -25,6 +25,18 @@ export class NamedImportManager extends StatementRecordManager boolean): [string, string, boolean, Identifier] | undefined { + for (const [path, specifiers] of this.#map) { + for (const [specifier, { id, isType }] of specifiers) { + if (predicate(path, specifier)) { + return [path, specifier, isType, id]; + } + } + } + } + *identifiers(): IterableIterator { for (const [path, specifiers] of this.#map) { for (const [specifier, { id, isType }] of specifiers) { @@ -97,6 +119,14 @@ export class NamespaceImportManager extends StatementRecordManager boolean): string | undefined { + for (const [path, id] of this.#map) { + if (predicate(id)) { + return path; + } + } + } + getIdentifier(path: string): Identifier | undefined { return this.#map.get(path); } @@ -138,6 +168,20 @@ export class DefaultImportManager extends StatementRecordManager boolean): [string, boolean, Identifier] | undefined { + for (const [path, { id, isType }] of this.#map) { + if (predicate(path)) { + return [path, isType, id]; + } + } + } + override clear(): void { this.#map.clear(); } diff --git a/packages/ts/react-signals/src/index.ts b/packages/ts/react-signals/src/index.ts index 047f8d9520..525f8510d6 100644 --- a/packages/ts/react-signals/src/index.ts +++ b/packages/ts/react-signals/src/index.ts @@ -4,3 +4,4 @@ import { installAutoSignalTracking } from '@preact/signals-react/runtime'; installAutoSignalTracking(); export * from '@preact/signals-react'; +export * from './SharedSignals.js';