Skip to content

Commit

Permalink
feat(generator-plugin-signals): add a plugin implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
Lodin committed May 31, 2024
1 parent 46817ad commit 4d76289
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 112 deletions.
82 changes: 0 additions & 82 deletions packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts

This file was deleted.

112 changes: 112 additions & 0 deletions packages/ts/generator-plugin-signals/src/SignalProcessor.ts
Original file line number Diff line number Diff line change
@@ -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<SourceFile>(this.#sourceFile, [
...this.#methods.map((method) =>
transform<SourceFile>((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);
}
}
}
86 changes: 58 additions & 28 deletions packages/ts/generator-plugin-signals/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, MethodInfo[]> {
const serviceMap = new Map<string, MethodInfo[]>();

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<void> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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++;
}
});
});
});
44 changes: 44 additions & 0 deletions packages/ts/generator-utils/src/dependencies/ImportManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export class NamedImportManager extends StatementRecordManager<ImportDeclaration
return record.id;
}

remove(path: string, specifier: string): void {
const specifiers = this.#map.get(path);

if (specifiers) {
specifiers.delete(specifier);

if (specifiers.size === 0) {
this.#map.delete(path);
}
}
}

override clear(): void {
this.#map.clear();
}
Expand All @@ -33,6 +45,16 @@ export class NamedImportManager extends StatementRecordManager<ImportDeclaration
return this.#map.get(path)?.get(specifier)?.id;
}

find(predicate: (path: string, specifier: string) => 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<readonly [path: string, specifier: string, id: Identifier, isType: boolean]> {
for (const [path, specifiers] of this.#map) {
for (const [specifier, { id, isType }] of specifiers) {
Expand Down Expand Up @@ -97,6 +119,14 @@ export class NamespaceImportManager extends StatementRecordManager<ImportDeclara
this.#map.clear();
}

find(predicate: (id: Identifier) => 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);
}
Expand Down Expand Up @@ -138,6 +168,20 @@ export class DefaultImportManager extends StatementRecordManager<ImportDeclarati
return this.#map.get(path)?.id;
}

remove(path: string): void {
if (this.#map.has(path)) {
this.#map.delete(path);
}
}

find(predicate: (path: string) => 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();
}
Expand Down
1 change: 1 addition & 0 deletions packages/ts/react-signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ import { installAutoSignalTracking } from '@preact/signals-react/runtime';
installAutoSignalTracking();

export * from '@preact/signals-react';
export * from './SharedSignals.js';

0 comments on commit 4d76289

Please sign in to comment.