From 46817ad4dbf85590027a3e6bacca8306ee0d57c8 Mon Sep 17 00:00:00 2001 From: taefi Date: Fri, 31 May 2024 10:16:15 +0300 Subject: [PATCH 1/8] feat: add full-stack signals base --- .../com/vaadin/hilla/EndpointController.java | 13 + .../EndpointControllerConfiguration.java | 16 +- .../com/vaadin/hilla/EndpointInvoker.java | 38 +- .../vaadin/hilla/signals/NumberSignal.java | 12 + .../signals/config/SignalsConfiguration.java | 29 + .../vaadin/hilla/signals/core/EventQueue.java | 121 +++ .../vaadin/hilla/signals/core/JsonEvent.java | 20 + .../hilla/signals/core/JsonEventMapper.java | 40 + .../hilla/signals/core/SignalQueue.java | 220 ++++++ .../hilla/signals/core/SignalsHandler.java | 38 + .../hilla/signals/core/SignalsRegistry.java | 44 ++ .../vaadin/hilla/signals/core/StateEvent.java | 15 + .../hilla/signals/core/package-info.java | 4 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../hilla/EndpointControllerMockBuilder.java | 8 +- .../vaadin/hilla/EndpointControllerTest.java | 8 +- .../com/vaadin/hilla/EndpointInvokerTest.java | 6 +- .../plugins/signals/SharedSignalsPlugin.java | 44 ++ .../ts/generator-core/src/PluginManager.ts | 1 + .../ts/generator-plugin-signals/.eslintrc | 6 + .../generator-plugin-signals/.lintstagedrc.js | 6 + packages/ts/generator-plugin-signals/LICENSE | 201 +++++ .../ts/generator-plugin-signals/README.md | 1 + .../ts/generator-plugin-signals/package.json | 82 ++ .../src/SharedSignalProcessor.ts | 82 ++ .../ts/generator-plugin-signals/src/index.ts | 44 ++ .../test/SignalsEndpoints.spec.ts | 24 + .../test/hilla-openapi.json | 223 ++++++ .../tsconfig.build.json | 10 + .../ts/generator-plugin-signals/tsconfig.json | 5 + .../ts/react-signals/src/SharedSignals.ts | 740 ++++++++++++++++++ 31 files changed, 2090 insertions(+), 12 deletions(-) create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/NumberSignal.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/EventQueue.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/JsonEvent.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/JsonEventMapper.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalQueue.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalsHandler.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalsRegistry.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/StateEvent.java create mode 100644 packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/package-info.java create mode 100644 packages/java/parser-jvm-plugin-signals/src/main/java/com/vaadin/hilla/parser/plugins/signals/SharedSignalsPlugin.java create mode 100644 packages/ts/generator-plugin-signals/.eslintrc create mode 100644 packages/ts/generator-plugin-signals/.lintstagedrc.js create mode 100644 packages/ts/generator-plugin-signals/LICENSE create mode 100644 packages/ts/generator-plugin-signals/README.md create mode 100644 packages/ts/generator-plugin-signals/package.json create mode 100644 packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts create mode 100644 packages/ts/generator-plugin-signals/src/index.ts create mode 100644 packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts create mode 100644 packages/ts/generator-plugin-signals/test/hilla-openapi.json create mode 100644 packages/ts/generator-plugin-signals/tsconfig.build.json create mode 100644 packages/ts/generator-plugin-signals/tsconfig.json create mode 100644 packages/ts/react-signals/src/SharedSignals.ts diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java index 5a797cca0e..21d7db582a 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java @@ -21,6 +21,7 @@ import java.util.Optional; import java.util.TreeMap; +import com.vaadin.experimental.FeatureFlags; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; @@ -128,6 +129,18 @@ public void registerEndpoints(URL openApiResource) { endpointBeans .putAll(context.getBeansWithAnnotation(BrowserCallable.class)); + if (FeatureFlags.get(VaadinService.getCurrent().getContext()) + .isEnabled(FeatureFlags.HILLA_FULLSTACK_SIGNALS)) { + LOGGER.debug("Fullstack signals feature is enabled."); + if (endpointBeans.containsKey("signalsHandler")) { + LOGGER.debug("SignalsHandler endpoint will be registered."); + } + } else { + LOGGER.debug("Fullstack signals feature is disabled."); + endpointBeans.remove("signalsHandler"); + LOGGER.debug("SignalsHandler endpoint will not be registered."); + } + // By default, only register those endpoints included in the Hilla // OpenAPI definition file registerEndpointsFromApiDefinition(endpointBeans, openApiResource); diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java index 287f93afd5..4dfa27d31b 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java @@ -18,6 +18,8 @@ import java.lang.reflect.Method; +import com.vaadin.hilla.signals.config.SignalsConfiguration; +import com.vaadin.hilla.signals.core.SignalsRegistry; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -25,6 +27,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import org.springframework.web.util.pattern.PathPatternParser; @@ -42,8 +45,10 @@ * A configuration class for customizing the {@link EndpointController} class. */ @Configuration +@Import(SignalsConfiguration.class) public class EndpointControllerConfiguration { private final EndpointProperties endpointProperties; + private final SignalsRegistry signalsRegistry; /** * Initializes the endpoint configuration. @@ -52,8 +57,10 @@ public class EndpointControllerConfiguration { * Hilla endpoint properties */ public EndpointControllerConfiguration( - EndpointProperties endpointProperties) { + EndpointProperties endpointProperties, + SignalsRegistry signalsRegistry) { this.endpointProperties = endpointProperties; + this.signalsRegistry = signalsRegistry; } /** @@ -120,7 +127,8 @@ EndpointInvoker endpointInvoker(ApplicationContext applicationContext, ExplicitNullableTypeChecker explicitNullableTypeChecker, ServletContext servletContext, EndpointRegistry endpointRegistry) { return new EndpointInvoker(applicationContext, endpointMapperFactory, - explicitNullableTypeChecker, servletContext, endpointRegistry); + explicitNullableTypeChecker, servletContext, endpointRegistry, + signalsRegistry); } /** @@ -237,9 +245,9 @@ private RequestMappingInfo prependEndpointPrefixUrl( } /** - * Can re-generate the TypeScipt code. + * Can re-generate the TypeScript code. * - * @param context + * @param servletContext * the servlet context * @param endpointController * the endpoint controller diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java index 24c388b3b0..b92069aba2 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.googlecode.gentyref.GenericTypeReflector; +import com.vaadin.experimental.FeatureFlags; +import com.vaadin.flow.server.VaadinService; import com.vaadin.flow.server.VaadinServletContext; import com.vaadin.hilla.EndpointInvocationException.EndpointAccessDeniedException; import com.vaadin.hilla.EndpointInvocationException.EndpointBadRequestException; @@ -32,6 +34,8 @@ import com.vaadin.hilla.exception.EndpointValidationException; import com.vaadin.hilla.exception.EndpointValidationException.ValidationErrorData; import com.vaadin.hilla.parser.jackson.JacksonObjectMapperFactory; +import com.vaadin.hilla.signals.core.SignalQueue; +import com.vaadin.hilla.signals.core.SignalsRegistry; import jakarta.servlet.ServletContext; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; @@ -80,6 +84,7 @@ public class EndpointInvoker { private final ExplicitNullableTypeChecker explicitNullableTypeChecker; private final ServletContext servletContext; private final Validator validator; + private final SignalsRegistry signalsRegistry; /** * Creates an instance of this bean. @@ -103,7 +108,8 @@ public class EndpointInvoker { public EndpointInvoker(ApplicationContext applicationContext, JacksonObjectMapperFactory endpointMapperFactory, ExplicitNullableTypeChecker explicitNullableTypeChecker, - ServletContext servletContext, EndpointRegistry endpointRegistry) { + ServletContext servletContext, EndpointRegistry endpointRegistry, + SignalsRegistry signalsRegistry) { this.applicationContext = applicationContext; this.servletContext = servletContext; this.endpointMapper = endpointMapperFactory != null @@ -115,6 +121,7 @@ public EndpointInvoker(ApplicationContext applicationContext, } this.explicitNullableTypeChecker = explicitNullableTypeChecker; this.endpointRegistry = endpointRegistry; + this.signalsRegistry = signalsRegistry; Validator validator = null; try { @@ -481,6 +488,35 @@ private Object invokeVaadinEndpointMethod(String endpointName, throw new EndpointInternalException(errorMessage); } + if (returnValue instanceof SignalQueue) { + if (FeatureFlags.get(VaadinService.getCurrent().getContext()) + .isEnabled(FeatureFlags.HILLA_FULLSTACK_SIGNALS)) { + if (signalsRegistry == null) { + throw new EndpointInternalException( + "Signal registry is not available"); + } + if (signalsRegistry + .contains(((SignalQueue) returnValue).getId())) { + getLogger().debug( + "Signal already registered as a result of calling {}", + methodName); + } else { + signalsRegistry.register((SignalQueue) returnValue); + getLogger().debug( + "Registered signal as a result of calling {}", + methodName); + } + } else { + String featureFlagFullName = FeatureFlags.SYSTEM_PROPERTY_PREFIX_EXPERIMENTAL + + FeatureFlags.HILLA_FULLSTACK_SIGNALS; + throw new EndpointInternalException(String.format( + "Full-Stack Signal usage are only allowed if the %s feature flag is enabled explicitly. " + + "You can enable it either through the Vaadin Copilot's UI, or by manually setting the " + + "%s=true in the src/main/resources/vaadin-featureflags.properties and restarting the application.", + featureFlagFullName, featureFlagFullName)); + } + } + return returnValue; } diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/NumberSignal.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/NumberSignal.java new file mode 100644 index 0000000000..48af4a1dc2 --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/NumberSignal.java @@ -0,0 +1,12 @@ +package com.vaadin.hilla.signals; + +import com.fasterxml.jackson.databind.node.IntNode; +import com.vaadin.hilla.signals.core.SignalQueue; + +public class NumberSignal extends SignalQueue { + + public NumberSignal(int defaultValue) { + super(IntNode.valueOf(defaultValue)); + } + +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java new file mode 100644 index 0000000000..d4ce43c87a --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java @@ -0,0 +1,29 @@ +package com.vaadin.hilla.signals.config; + +import com.vaadin.hilla.signals.core.SignalsHandler; +import com.vaadin.hilla.signals.core.SignalsRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SignalsConfiguration { + + private final SignalsRegistry signalsRegistry; + private final SignalsHandler signalsHandler; + + public SignalsConfiguration(SignalsRegistry signalsRegistry, + SignalsHandler signalsHandler) { + this.signalsRegistry = signalsRegistry; + this.signalsHandler = signalsHandler; + } + + @Bean + public SignalsRegistry signalsRegistry() { + return signalsRegistry; + } + + @Bean + public SignalsHandler signalsHandler() { + return signalsHandler; + } +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/EventQueue.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/EventQueue.java new file mode 100644 index 0000000000..6dfde477ec --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/EventQueue.java @@ -0,0 +1,121 @@ +package com.vaadin.hilla.signals.core; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.Sinks.Many; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; + +public abstract class EventQueue { + private final ReentrantLock lock = new ReentrantLock(); + + public static final UUID ROOT = UUID + .fromString("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"); + + private static class Entry { + private final T value; + private Entry next; + + private Entry(T value) { + this.value = value; + } + } + + private Entry head; + private Entry tail; + private final Map> idToEntry = new HashMap<>(); + + private final Set> subscribers = new HashSet<>(); + + public Flux subscribe(UUID continueFrom) { + System.out.println("Continue from " + continueFrom); + Many sink = Sinks.many().multicast().onBackpressureBuffer(); + + return sink.asFlux().doOnSubscribe(ignore -> { + System.out.println("New Flux subscription"); + + lock.lock(); + try { + Entry entry; + if (continueFrom != null + && (entry = idToEntry.get(continueFrom)) != null) { + entry = entry.next; + // TODO maybe some heuristic to determine whether it would + // be more efficient to restart from a snapshot instead of + // replaying lots of events? + while (entry != null) { + System.out.println("Replay " + entry.value.getId()); + sink.tryEmitNext(entry.value); + entry = entry.next; + } + ; + } else { + T snapshot = createSnapshot(); + if (snapshot != null) { + sink.tryEmitNext(snapshot); + } + } + + subscribers.add(sink); + } finally { + lock.unlock(); + } + }).doFinally(ignore -> { + System.out.println("doFinally"); + lock.lock(); + try { + subscribers.remove(sink); + } finally { + lock.unlock(); + } + }); + } + + public void submit(T event) { + // Thread.ofVirtual().start(() -> append(event)); + append(event); + } + + private void append(T event) { + lock.lock(); + try { + processEvent(event); + Entry entry = new Entry<>(event); + + // Add to linked list + idToEntry.put(event.getId(), entry); + if (head == null) { + head = tail = entry; + } else { + tail.next = tail = entry; + } + + // Truncate list + // TODO configurable or dynamic limit? + if (idToEntry.size() > 100) { + Entry removed = idToEntry.remove(head.value.getId()); + head = removed.next; + } + + // Notify subscribers + subscribers.removeIf(sink -> { + boolean failure = sink.tryEmitNext(event).isFailure(); + if (failure) { + System.out.println("Failed push"); + } + return failure; + }); + } finally { + lock.unlock(); + } + } + + protected abstract void processEvent(T newEvent); + + protected abstract T createSnapshot(); +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/JsonEvent.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/JsonEvent.java new file mode 100644 index 0000000000..2494e18524 --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/JsonEvent.java @@ -0,0 +1,20 @@ +package com.vaadin.hilla.signals.core; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.UUID; + +public class JsonEvent extends StateEvent { + + private final ObjectNode json; + + public JsonEvent(UUID id, ObjectNode json) { + super(id); + this.json = json; + } + + public ObjectNode getJson() { + return json; + } + +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/JsonEventMapper.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/JsonEventMapper.java new file mode 100644 index 0000000000..0cbd42274b --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/JsonEventMapper.java @@ -0,0 +1,40 @@ +package com.vaadin.hilla.signals.core; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Map; +import java.util.UUID; + +public class JsonEventMapper { + + private final ObjectMapper mapper; + + public JsonEventMapper(ObjectMapper mapper) { + this.mapper = mapper; + } + + public String toJson(JsonEvent jsonEvent) { + ObjectNode root = mapper.createObjectNode(); + for (Map.Entry entry : jsonEvent.getJson() + .properties()) { + root.set(entry.getKey(), entry.getValue()); + } + UUID id = jsonEvent.getId(); + root.put("id", id != null ? id.toString() : null); + return root.toString(); + } + + public JsonEvent fromJson(String json) { + try { + ObjectNode root = (ObjectNode) mapper.readTree(json); + UUID id = UUID.fromString(root.get("id").asText()); + root.remove("id"); + return new JsonEvent(id, root); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalQueue.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalQueue.java new file mode 100644 index 0000000000..26010d0d50 --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalQueue.java @@ -0,0 +1,220 @@ +package com.vaadin.hilla.signals.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.BaseJsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public class SignalQueue extends EventQueue { + public enum RootType { + LIST, VALUE; + } + + private static class Entry { + private final UUID id; + private UUID prev; + private UUID next; + private V value; + + public Entry(UUID id, UUID prev, UUID next, V value) { + this.id = id; + this.prev = prev; + this.next = next; + this.value = value; + } + + @Override + public String toString() { + return id + ": " + value; + } + } + + private final Map> entries = new HashMap<>(); + private final ObjectMapper mapper; + private final UUID id = UUID.randomUUID(); + + public SignalQueue(V defaultValue) { + this.mapper = new ObjectMapper(); + + V rootValue; + if (defaultValue != null) { + rootValue = defaultValue; + } else { + rootValue = createListRootValue(null, null); + } + entries.put(EventQueue.ROOT, + new Entry<>(EventQueue.ROOT, null, null, rootValue)); + } + + @Override + protected void processEvent(JsonEvent event) { + UUID id = event.getId(); + ObjectNode json = event.getJson(); + + if (json.has("conditions")) { + ArrayNode conditions = (ArrayNode) json.get("conditions"); + for (int i = 0; i < conditions.size(); i++) { + JsonNode condition = conditions.get(i); + Entry entry = entry(condition.get("id")); + if (entry == null) { + // Condition not satisfied if it references a missing node + return; + } + + if (condition.has("value") && !Objects + .equals(condition.get("value"), entry.value)) { + return; + } + } + } + + if (json.has("set")) { + Entry entry = entry(json.get("set")); + if (entry == null) { + // Ignore request for entry that might just have been removed + return; + } + entry.value = (V) json.get("value"); + } else if (json.has("remove")) { + Entry parent = entry(json.get("parent")); + Entry entry = entry(json.get("remove")); + + if (parent == null || entry == null) { + return; + } + + if (entry.prev != null) { + entries.get(entry.prev).next = entry.next; + } else { + ObjectNode listEntry = (ObjectNode) parent.value; + listEntry.put("head", toStringOrNull(entry.next)); + } + + if (entry.next != null) { + entries.get(entry.next).prev = entry.prev; + } else { + ObjectNode listEntry = (ObjectNode) parent.value; + listEntry.put("tail", toStringOrNull(entry.prev)); + } + + // XXX: Also detach any children + entries.remove(entry.id); + } else if (json.has("direction")) { + // Insert event + Entry listEntry = entry(json.get("entry")); + String direction = json.get("direction").asText(); + UUID referenceId = uuidOrNull(json.get("reference")); + V value = (V) json.get("value"); + + if (listEntry == null) { + return; + } + + ObjectNode listRoot = (ObjectNode) listEntry.value; + + UUID prev = null; + UUID next = null; + if ("AFTER".equals(direction)) { + prev = referenceId != null ? referenceId + : uuidOrNull(listRoot.get("tail")); + if (prev != null) { + Entry prevEntry = entries.get(prev); + if (prevEntry == null) { + return; + } + next = prevEntry.next; + } + } else { + next = referenceId != null ? referenceId + : uuidOrNull(listRoot.get("head")); + if (next != null) { + Entry nextEntry = entries.get(next); + if (nextEntry == null) { + return; + } + prev = nextEntry.prev; + } + } + + Entry newEntry = new Entry<>(id, prev, next, value); + entries.put(id, newEntry); + + if (next != null) { + entries.get(next).prev = id; + } else { + listRoot.put("tail", id.toString()); + } + + if (prev != null) { + entries.get(prev).next = id; + } else { + listRoot.put("head", id.toString()); + } + } else { + throw new RuntimeException("Unsupported JSON: " + json.toString()); + } + + List> state = new ArrayList<>(); + UUID key = uuidOrNull(entries.get(EventQueue.ROOT).value.get("head")); + while (key != null) { + Entry entry = entries.get(key); + state.add(entry); + key = entry.next; + } + } + + private Entry entry(JsonNode jsonNode) { + return entries.get(UUID.fromString(jsonNode.asText())); + } + + private static UUID uuidOrNull(JsonNode jsonNode) { + if (jsonNode == null || jsonNode.isNull()) { + return null; + } else { + return UUID.fromString(jsonNode.asText()); + } + } + + @Override + public JsonEvent createSnapshot() { + ArrayNode snapshotEntries = mapper.createArrayNode(); + entries.values().forEach(entry -> { + ObjectNode entryNode = snapshotEntries.addObject(); + entryNode.put("id", entry.id.toString()); + entryNode.put("next", toStringOrNull(entry.next)); + entryNode.put("prev", toStringOrNull(entry.prev)); + entryNode.set("value", entry.value); + }); + + ObjectNode snapshotData = mapper.createObjectNode(); + snapshotData.set("entries", snapshotEntries); + return new JsonEvent(null, snapshotData); + } + + private static String toStringOrNull(UUID uuid) { + return Objects.toString(uuid, null); + } + + private V createListRootValue(UUID head, UUID tail) { + ObjectNode node = mapper.createObjectNode(); + node.put("head", toStringOrNull(head)); + node.put("tail", toStringOrNull(tail)); + return (V) node; + } + + protected ObjectMapper mapper() { + return mapper; + } + + public UUID getId() { + return id; + } +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalsHandler.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalsHandler.java new file mode 100644 index 0000000000..8811c6eaa8 --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalsHandler.java @@ -0,0 +1,38 @@ +package com.vaadin.hilla.signals.core; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.hilla.BrowserCallable; +import com.vaadin.hilla.Nullable; +import reactor.core.publisher.Flux; + +import java.util.UUID; + +@AnonymousAllowed +@BrowserCallable +public class SignalsHandler { + + private final SignalsRegistry registry; + private final JsonEventMapper jsonEventMapper; + + public SignalsHandler(SignalsRegistry registry, ObjectMapper mapper) { + this.registry = registry; + this.jsonEventMapper = new JsonEventMapper(mapper); + } + + public Flux subscribe(UUID signalId, @Nullable UUID continueFrom) { + if (!registry.contains(signalId)) { + throw new IllegalStateException("Signal not found: " + signalId); + } + return registry.get(signalId).subscribe(continueFrom) + .map(jsonEventMapper::toJson); + } + + public void update(UUID signalId, String event) { + if (!registry.contains(signalId)) { + throw new IllegalStateException("Signal not found: " + signalId); + } + registry.get(signalId).submit(jsonEventMapper.fromJson(event)); + } + +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalsRegistry.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalsRegistry.java new file mode 100644 index 0000000000..4dced5b8c4 --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/SignalsRegistry.java @@ -0,0 +1,44 @@ +package com.vaadin.hilla.signals.core; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.UUID; +import java.util.WeakHashMap; + +@Component +public class SignalsRegistry { + + private static final Logger LOGGER = LoggerFactory + .getLogger(SignalsRegistry.class); + private final WeakHashMap> signals = new WeakHashMap<>(); + + public synchronized void register(SignalQueue signal) { + signals.put(signal.getId(), signal); + LOGGER.debug("Registered signal: {}", signal.getId()); + } + + public synchronized SignalQueue get(UUID uuid) { + return signals.get(uuid); + } + + public synchronized void remove(UUID uuid) { + signals.remove(uuid); + LOGGER.debug("Removed signal: {}", uuid); + } + + public synchronized void clear() { + signals.clear(); + LOGGER.debug("Cleared all signal instances"); + } + + public synchronized boolean contains(UUID uuid) { + return signals.containsKey(uuid); + } + + public synchronized boolean isEmpty() { + return signals.isEmpty(); + } + +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/StateEvent.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/StateEvent.java new file mode 100644 index 0000000000..45067a6507 --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/StateEvent.java @@ -0,0 +1,15 @@ +package com.vaadin.hilla.signals.core; + +import java.util.UUID; + +public class StateEvent { + private final UUID id; + + public StateEvent(UUID id) { + this.id = id; + } + + public UUID getId() { + return id; + } +} diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/package-info.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/package-info.java new file mode 100644 index 0000000000..05ef871418 --- /dev/null +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package com.vaadin.hilla.signals.core; + +import org.springframework.lang.NonNullApi; diff --git a/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 2906961e79..caa7181ba3 100644 --- a/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -7,3 +7,4 @@ com.vaadin.hilla.startup.RouteUnifyingServiceInitListener com.vaadin.hilla.route.ClientRouteRegistry com.vaadin.hilla.route.RouteUtil com.vaadin.hilla.route.RouteUnifyingConfiguration +com.vaadin.hilla.signals.config.SignalConfiguration diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerMockBuilder.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerMockBuilder.java index f212937c6d..3d11050aee 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerMockBuilder.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerMockBuilder.java @@ -2,6 +2,7 @@ import static org.mockito.Mockito.mock; +import com.vaadin.hilla.signals.core.SignalsRegistry; import org.mockito.Mockito; import org.springframework.context.ApplicationContext; @@ -26,11 +27,12 @@ public EndpointController build() { EndpointRegistry registry = new EndpointRegistry(endpointNameChecker); CsrfChecker csrfChecker = Mockito.mock(CsrfChecker.class); ServletContext servletContext = Mockito.mock(ServletContext.class); + SignalsRegistry signalsRegistry = Mockito.mock(SignalsRegistry.class); Mockito.when(csrfChecker.validateCsrfTokenInRequest(Mockito.any())) .thenReturn(true); - EndpointInvoker invoker = Mockito - .spy(new EndpointInvoker(applicationContext, factory, - explicitNullableTypeChecker, servletContext, registry)); + EndpointInvoker invoker = Mockito.spy(new EndpointInvoker( + applicationContext, factory, explicitNullableTypeChecker, + servletContext, registry, signalsRegistry)); EndpointController controller = Mockito.spy(new EndpointController( applicationContext, registry, invoker, csrfChecker)); Mockito.doReturn(mock(EndpointAccessChecker.class)).when(invoker) diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerTest.java index 11c8ed3be4..608b52fcfc 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerTest.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerTest.java @@ -28,6 +28,7 @@ import java.util.stream.Stream; import com.vaadin.hilla.engine.EngineConfiguration; +import com.vaadin.hilla.signals.core.SignalsRegistry; import org.junit.Assert; import org.junit.Before; import org.junit.Ignore; @@ -819,10 +820,11 @@ public void should_Never_UseSpringObjectMapper() { .thenReturn(Collections.emptyMap()); EndpointRegistry registry = new EndpointRegistry( mock(EndpointNameChecker.class)); + SignalsRegistry signalsRegistry = Mockito.mock(SignalsRegistry.class); EndpointInvoker invoker = new EndpointInvoker(contextMock, null, mock(ExplicitNullableTypeChecker.class), - mock(ServletContext.class), registry); + mock(ServletContext.class), registry, signalsRegistry); new EndpointController(contextMock, registry, invoker, null) .registerEndpoints(getDefaultOpenApiResourcePathInDevMode()); @@ -1296,11 +1298,11 @@ private EndpointController createVaadinController(T endpoint, ApplicationContext mockApplicationContext = mockApplicationContext( endpoint); EndpointRegistry registry = new EndpointRegistry(endpointNameChecker); - + SignalsRegistry signalsRegistry = Mockito.mock(SignalsRegistry.class); EndpointInvoker invoker = Mockito .spy(new EndpointInvoker(mockApplicationContext, endpointMapperFactory, explicitNullableTypeChecker, - mock(ServletContext.class), registry)); + mock(ServletContext.class), registry, signalsRegistry)); Mockito.doReturn(accessChecker).when(invoker).getAccessChecker(); diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java index 643e5bbb3c..81946ad3a8 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java @@ -1,5 +1,6 @@ package com.vaadin.hilla; +import com.vaadin.hilla.signals.core.SignalsRegistry; import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpServletRequest; @@ -55,6 +56,9 @@ public class EndpointInvokerTest { @Mock private ServletContext servletContext; + @Mock + private SignalsRegistry signalsRegistry; + private EndpointInvoker endpointInvoker; private EndpointRegistry endpointRegistry; @@ -67,7 +71,7 @@ public void setUp() { endpointRegistry = new EndpointRegistry(endpointNameChecker); endpointInvoker = new EndpointInvoker(applicationContext, null, - explicitNullableTypeChecker, servletContext, endpointRegistry) { + explicitNullableTypeChecker, servletContext, endpointRegistry, signalsRegistry) { protected EndpointAccessChecker getAccessChecker() { return endpointAccessChecker; } diff --git a/packages/java/parser-jvm-plugin-signals/src/main/java/com/vaadin/hilla/parser/plugins/signals/SharedSignalsPlugin.java b/packages/java/parser-jvm-plugin-signals/src/main/java/com/vaadin/hilla/parser/plugins/signals/SharedSignalsPlugin.java new file mode 100644 index 0000000000..b545a40531 --- /dev/null +++ b/packages/java/parser-jvm-plugin-signals/src/main/java/com/vaadin/hilla/parser/plugins/signals/SharedSignalsPlugin.java @@ -0,0 +1,44 @@ +package com.vaadin.hilla.parser.plugins.signals; + +import com.vaadin.hilla.parser.core.AbstractPlugin; +import com.vaadin.hilla.parser.core.Node; +import com.vaadin.hilla.parser.core.NodeDependencies; +import com.vaadin.hilla.parser.core.NodePath; +import com.vaadin.hilla.parser.core.Plugin; +import com.vaadin.hilla.parser.core.PluginConfiguration; +import com.vaadin.hilla.parser.plugins.backbone.BackbonePlugin; +import jakarta.annotation.Nonnull; + +import java.util.Collection; +import java.util.List; + +public class SharedSignalsPlugin extends AbstractPlugin { + + @Override + public void enter(NodePath nodePath) { + + } + + @Override + public void exit(NodePath nodePath) { + + } + + @Nonnull + @Override + public Node resolve(@Nonnull Node node, + @Nonnull NodePath parentPath) { + return super.resolve(node, parentPath); + } + + @Nonnull + @Override + public NodeDependencies scan(@Nonnull NodeDependencies nodeDependencies) { + return null; + } + + @Override + public Collection> getRequiredPlugins() { + return List.of(BackbonePlugin.class); + } +} diff --git a/packages/ts/generator-core/src/PluginManager.ts b/packages/ts/generator-core/src/PluginManager.ts index 4cda29e928..59f468f652 100644 --- a/packages/ts/generator-core/src/PluginManager.ts +++ b/packages/ts/generator-core/src/PluginManager.ts @@ -15,6 +15,7 @@ export default class PluginManager { 'ModelPlugin', 'PushPlugin', 'SubTypesPlugin', + 'SignalsPlugin', ]; const customPlugins = plugins.filter((p) => !standardPlugins.includes(p.name)); if (customPlugins.length > 0) { diff --git a/packages/ts/generator-plugin-signals/.eslintrc b/packages/ts/generator-plugin-signals/.eslintrc new file mode 100644 index 0000000000..6ac92d4ca9 --- /dev/null +++ b/packages/ts/generator-plugin-signals/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": ["../../../.eslintrc"], + "parserOptions": { + "project": "./tsconfig.json" + } +} diff --git a/packages/ts/generator-plugin-signals/.lintstagedrc.js b/packages/ts/generator-plugin-signals/.lintstagedrc.js new file mode 100644 index 0000000000..937dc6639f --- /dev/null +++ b/packages/ts/generator-plugin-signals/.lintstagedrc.js @@ -0,0 +1,6 @@ +import { commands, extensions } from '../../../.lintstagedrc.js'; + +export default { + [`src/**/*.{${extensions}}`]: commands, + [`test/**/*.{${extensions}}`]: commands, +}; diff --git a/packages/ts/generator-plugin-signals/LICENSE b/packages/ts/generator-plugin-signals/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/packages/ts/generator-plugin-signals/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/ts/generator-plugin-signals/README.md b/packages/ts/generator-plugin-signals/README.md new file mode 100644 index 0000000000..2f5574b596 --- /dev/null +++ b/packages/ts/generator-plugin-signals/README.md @@ -0,0 +1 @@ +# Hilla TypeScript Generator Signals Support Plugin diff --git a/packages/ts/generator-plugin-signals/package.json b/packages/ts/generator-plugin-signals/package.json new file mode 100644 index 0000000000..10e1f92b40 --- /dev/null +++ b/packages/ts/generator-plugin-signals/package.json @@ -0,0 +1,82 @@ +{ + "name": "@vaadin/hilla-generator-plugin-signals", + "version": "24.4.0-beta1", + "description": "A Hilla TypeScript Generator plugin to add Shared Signals support", + "main": "index.js", + "type": "module", + "engines": { + "node": ">= 16.13" + }, + "scripts": { + "clean:build": "git clean -fx . -e .vite -e node_modules", + "build": "concurrently npm:build:*", + "build:esbuild": "tsx ../../../scripts/build.ts", + "build:dts": "tsc --isolatedModules -p tsconfig.build.json", + "build:copy": "cd src && copyfiles **/*.d.ts ..", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "test": "mocha test/**/*.spec.ts --config ../../../.mocharc.cjs", + "test:update": "npm run test -- --update", + "test:coverage": "c8 -c ../../../.c8rc.json npm test", + "typecheck": "tsc --noEmit" + }, + "exports": { + ".": { + "default": "./index.js" + }, + "./index.js": { + "default": "./index.js" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/vaadin/hilla.git", + "directory": "packages/ts/generator-plugin-signals" + }, + "keywords": [ + "hilla", + "typescript", + "generator" + ], + "author": "Vaadin Ltd.", + "license": "Apache 2.0", + "bugs": { + "url": "https://github.com/vaadin/hilla/issues" + }, + "homepage": "https://hilla.dev", + "files": [ + "*.{d.ts.map,d.ts,js.map,js}" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@vaadin/hilla-generator-core": "24.4.0-beta1", + "@vaadin/hilla-generator-plugin-client": "24.4.0-beta1" + }, + "dependencies": { + "@vaadin/hilla-generator-plugin-backbone": "^24.4.0-beta1", + "@vaadin/hilla-generator-utils": "24.4.0-beta1", + "fast-deep-equal": "^3.1.3", + "openapi-types": "^12.1.3", + "typescript": "5.3.2" + }, + "devDependencies": { + "@types/chai": "^4.3.6", + "@types/mocha": "^10.0.2", + "@types/node": "^20.7.1", + "@types/sinon": "^10.0.17", + "@types/sinon-chai": "^3.2.10", + "@vaadin/hilla-generator-core": "24.4.0-beta1", + "@vaadin/hilla-generator-plugin-client": "24.4.0-beta1", + "c8": "^8.0.1", + "chai": "^4.3.10", + "concurrently": "^8.2.1", + "copyfiles": "^2.4.1", + "mocha": "^10.2.0", + "pino": "^8.15.1", + "sinon": "^16.0.0", + "sinon-chai": "^3.7.0", + "type-fest": "^4.3.2" + } +} diff --git a/packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts b/packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts new file mode 100644 index 0000000000..8bbfecf720 --- /dev/null +++ b/packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts @@ -0,0 +1,82 @@ +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/index.ts b/packages/ts/generator-plugin-signals/src/index.ts new file mode 100644 index 0000000000..bbe05cf655 --- /dev/null +++ b/packages/ts/generator-plugin-signals/src/index.ts @@ -0,0 +1,44 @@ +import type SharedStorage from "@vaadin/hilla-generator-core/SharedStorage.js"; +import Plugin from "@vaadin/hilla-generator-core/Plugin.js"; +import process from "./SharedSignalProcessor"; + +export type PathSignalType = { + 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 }); + } + }); + } + } + }); + return pathSignalTypes; + } + + override async execute(sharedStorage: SharedStorage): Promise { + const methodsWithSignals = this.#extractEndpointMethodsWithSignalsAsReturnType(sharedStorage); + process(methodsWithSignals, sharedStorage); + } + + declare ['constructor']: typeof SignalsPlugin; + + override get path(): string { + return import.meta.url; + } +} diff --git a/packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts b/packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts new file mode 100644 index 0000000000..774fe39e9a --- /dev/null +++ b/packages/ts/generator-plugin-signals/test/SignalsEndpoints.spec.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import Generator from '@vaadin/hilla-generator-core/Generator.js'; +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"; + +use(sinonChai); +use(snapshotMatcher); + +describe('SignalsPlugin', () => { + context('Endpoint methods with Signals as return type', () => { + it('correctly generates service wrapper', async () => { + const generator = new Generator([BackbonePlugin, SignalsPlugin], { + logger: new LoggerFactory({ name: 'model-plugin-test', verbose: true }), + }); + const input = await readFile(new URL('./hilla-openapi.json', import.meta.url), 'utf8'); + const files = await generator.process(input); + + }); + }); +}); diff --git a/packages/ts/generator-plugin-signals/test/hilla-openapi.json b/packages/ts/generator-plugin-signals/test/hilla-openapi.json new file mode 100644 index 0000000000..898e582620 --- /dev/null +++ b/packages/ts/generator-plugin-signals/test/hilla-openapi.json @@ -0,0 +1,223 @@ +{ + "openapi" : "3.0.1", + "info" : { + "title" : "Hilla Application", + "version" : "1.0.0" + }, + "servers" : [ + { + "url" : "http://localhost:8080/connect", + "description" : "Hilla Backend" + } + ], + "tags" : [ + { + "name" : "HelloWorldService", + "x-class-name" : "com.vaadin.hilla.test.service.HelloWorldService" + }, + { + "name" : "SharedCounterService", + "x-class-name" : "com.vaadin.hilla.test.service.SharedCounterService" + }, + { + "name" : "SignalsHandler", + "x-class-name" : "com.vaadin.hilla.signals.endpoint.SignalsHandler" + } + ], + "paths" : { + "/HelloWorldService/sayHello" : { + "post" : { + "tags" : [ + "HelloWorldService" + ], + "operationId" : "HelloWorldService_sayHello_POST", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "x-java-type" : "java.lang.String" + } + } + } + } + } + }, + "responses" : { + "200" : { + "description" : "", + "content" : { + "application/json" : { + "schema" : { + "type" : "string", + "x-java-type" : "java.lang.String" + } + } + } + } + } + } + }, + "/SharedCounterService/anotherCounter" : { + "post" : { + "tags" : [ + "SharedCounterService" + ], + "operationId" : "SharedCounterService_anotherCounter_POST", + "responses" : { + "200" : { + "description" : "", + "content" : { + "application/json" : { + "schema" : { + "anyOf" : [ + { + "$ref" : "#/components/schemas/com.vaadin.hilla.signals.NumberSignal" + } + ] + } + } + } + } + } + } + }, + "/SharedCounterService/counter" : { + "post" : { + "tags" : [ + "SharedCounterService" + ], + "operationId" : "SharedCounterService_counter_POST", + "responses" : { + "200" : { + "description" : "", + "content" : { + "application/json" : { + "schema" : { + "anyOf" : [ + { + "$ref" : "#/components/schemas/com.vaadin.hilla.signals.NumberSignal" + } + ] + } + } + } + } + } + } + }, + "/SignalsHandler/subscribe" : { + "post" : { + "tags" : [ + "SignalsHandler" + ], + "operationId" : "SignalsHandler_subscribe_POST", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "signalId" : { + "type" : "string", + "x-java-type" : "java.lang.String" + }, + "continueFrom" : { + "type" : "string", + "nullable" : true, + "x-java-type" : "java.lang.String" + } + } + } + } + } + }, + "responses" : { + "200" : { + "description" : "", + "content" : { + "application/json" : { + "schema" : { + "type" : "array", + "items" : { + "type" : "string", + "x-java-type" : "java.lang.String" + }, + "x-class-name" : "com.vaadin.hilla.runtime.transfertypes.Flux" + } + } + } + } + } + } + }, + "/SignalsHandler/update" : { + "post" : { + "tags" : [ + "SignalsHandler" + ], + "operationId" : "SignalsHandler_update_POST", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "signalId" : { + "type" : "string", + "x-java-type" : "java.lang.String" + }, + "event" : { + "type" : "string", + "x-java-type" : "java.lang.String" + } + } + } + } + } + }, + "responses" : { + "200" : { + "description" : "" + } + } + } + } + }, + "components" : { + "schemas" : { + "com.vaadin.hilla.signals.NumberSignal" : { + "anyOf" : [ + { + "$ref" : "#/components/schemas/com.vaadin.hilla.signals.core.SignalQueue" + }, + { + "type" : "object" + } + ] + }, + "com.vaadin.hilla.signals.core.SignalQueue" : { + "anyOf" : [ + { + "$ref" : "#/components/schemas/com.vaadin.hilla.signals.core.EventQueue" + }, + { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "x-java-type" : "java.lang.String" + } + } + } + ] + }, + "com.vaadin.hilla.signals.core.EventQueue" : { + "type" : "object" + } + } + } +} diff --git a/packages/ts/generator-plugin-signals/tsconfig.build.json b/packages/ts/generator-plugin-signals/tsconfig.build.json new file mode 100644 index 0000000000..a57d153410 --- /dev/null +++ b/packages/ts/generator-plugin-signals/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "." + }, + "include": ["src"] +} diff --git a/packages/ts/generator-plugin-signals/tsconfig.json b/packages/ts/generator-plugin-signals/tsconfig.json new file mode 100644 index 0000000000..cf0eb8454e --- /dev/null +++ b/packages/ts/generator-plugin-signals/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "test"], + "exclude": ["test/**/*.snap.ts"] +} diff --git a/packages/ts/react-signals/src/SharedSignals.ts b/packages/ts/react-signals/src/SharedSignals.ts new file mode 100644 index 0000000000..1344f060c8 --- /dev/null +++ b/packages/ts/react-signals/src/SharedSignals.ts @@ -0,0 +1,740 @@ +import type { ConnectClient, Subscription } from '@vaadin/hilla-frontend'; +import { type ReadonlySignal, Signal, batch, computed, effect, signal } from '@preact/signals-react'; + +const rootKey = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; + +declare module "@preact/signals-react" { + // https://github.com/preactjs/signals/issues/351#issuecomment-1515488634 + class Signal { + protected S(node: any): void; + protected U(node: any): void + } +} + +class DependencyTrackSignal extends Signal { + private readonly onSubscribe: () => void; + private readonly onUnsubscribe: () => void; + + private subscribeCount = 0; + + constructor(value: T | undefined, onSubscribe: () => void, onUnsubscribe: () => void) { + super(value); + this.onSubscribe = onSubscribe; + this.onUnsubscribe = onUnsubscribe; + } + + protected override S(node: any): void { + super.S(node); + if (this.subscribeCount++ == 0) { + this.onSubscribe.call(null); + } + } + + protected override U(node: any): void { + super.U(node); + if (--this.subscribeCount == 0) { + this.onUnsubscribe.call(null); + } + } +} + +export enum EntryType { + VALUE, + LIST, + NUMBER, +} + +interface ModifiableEntry { + value: T; + next: EntryId | null; + prev: EntryId | null; + type: EntryType; +} + +type Entry = Readonly>; + +interface EventCondition { + id: EntryId; + value?: any; + // TODO add conditions for prev / next pointers +} + +interface StateEvent { + id: string; + conditions?: EventCondition[]; +} + +interface SetEvent extends StateEvent { + set: EntryId; + value: any; +} + +interface InsertEvent extends StateEvent { + entry: EntryId; + direction: "BEFORE" | "AFTER"; + reference: EntryId | null; + value: any; +} + +interface RemoveEvent extends StateEvent { + remove: string; + parent: EntryId; +} + +interface SnapshotEvent extends StateEvent { + entries: { + id: EntryId; + next: EntryId | null; + prev: EntryId | null; + value: any; + }[]; +} + +type EntryId = string; +type EntryReference> = EntryId | S; +type Entries = Map; + +class State { + readonly entries: Entries = new Map(); + + evaluateBatch(events: StateEvent[]): void { + events.forEach((event) => this.evaluate(event)); + } + + evaluate(event: StateEvent): boolean { + const id = event.id; + + if (event.conditions) { + for(const condition of event.conditions) { + const entry = this.get(condition.id); + if (!entry) { + return false; + } + + if (condition.value !== undefined) { + // Poor man's deep equals + if (JSON.stringify(entry.value) !== JSON.stringify(condition.value)) { + return false; + } + } + + // TODO add conditions for prev/next entry in a list + } + } + + if ("entries" in event) { + const { entries } = event as SnapshotEvent; + for(const entry of entries) { + const {id, ...rest} = entry; + // XXX Clean up old values before applying new snapshot (but preserve signals that are already in use) + // XXX assuming all children are values for now + const existing = this.get(entry.id); + const type : EntryType = existing?.type || EntryType.VALUE; + this.entries.set(entry.id, {...rest, type}) + } + + } else if ("set" in event) { + const { set, value } = event as SetEvent; + + this.update(set).value = value; + + } else if ("remove" in event) { + const { remove, parent } = event as RemoveEvent; + + // XXX Verify that the entry to remove is a child of the suggested parent? + + const entry = this.get(remove); + if (!entry) { + console.log("Removing no-existent entry", remove); + return false; + } + + const parentEntry = this.get(parent); + if (!parentEntry) { + console.log("Removing from no-existent parent", parent); + return false; + } + + const newListRoot = {...parentEntry.value}; + + if (entry.prev) { + this.update(entry.prev).next = entry.next; + } else { + newListRoot.head = entry.next; + } + + if (entry.next) { + this.update(entry.next).prev = entry.prev; + } else { + newListRoot.tail = entry.prev; + } + + // XXX Also unlink any children + this.delete(remove); + + // Always update the list entry to trigger a signal update + this.update(parent).value = newListRoot; + + } else if ("direction" in event) { + const {entry: listId, direction, reference, value} = event as InsertEvent; + const listRoot = this.get(listId); + if (!listRoot) { + console.log("Inserting into non-existent list"); + return false; + } + + const { head, tail } = listRoot.value; + + let prev: EntryId | null = null; + let next: EntryId | null = null; + + if (direction == "AFTER") { + prev = reference || tail; + if (prev) { + const prevEntry = this.get(prev); + if (!prevEntry) { + console.log("Inserting before non-existent entry"); + return false; + } + next = prevEntry.next; + } + } else { + next = reference || head; + if (next) { + const nextEntry = this.get(next); + if (!nextEntry) { + console.log("Inserting after non-existent entry"); + return false; + } + prev = nextEntry.prev; + } + } + + // XXX Assuming all list children are values for now + this.insert(id, { value, next, prev, type: EntryType.VALUE }); + + const newListRoot = {...listRoot.value}; + + if (next) { + this.update(next).prev = id; + } else { + newListRoot.tail = id; + } + + if (prev) { + this.update(prev).next = id; + } else { + newListRoot.head = id; + } + + // Always update the list entry to trigger a signal update + this.update(listId).value = newListRoot; + + } else { + throw new Error("Unsupported event: " + JSON.stringify(event)); + } + + return true; + } + + ingest(source: DerivedState) { + for(const [key, entry] of source.entries.entries()) { + entry ? this.entries.set(key, entry) : this.delete(key); + } + } + + insert(id: EntryId, entry : Entry) { + if (this.get(id)) throw Error(id); + this.entries.set(id, entry); + } + + update(id: EntryId): ModifiableEntry> { + const original = this.get(id); + if (!original) throw Error(id); + + const copy = {...original}; + this.entries.set(id, copy); + return copy; + } + + delete(id: EntryId) { + this.entries.delete(id); + } + + get(id: EntryId): Entry | undefined { + return this.entries.get(id); + } +} + +class DerivedState extends State { + parent: State; + + constructor(parent: State) { + super(); + this.parent = parent; + } + + override delete(id: EntryId): void { + this.entries.set(id, undefined); + } + + override get(id: EntryId): Entry | undefined { + return super.get(id) || this.parent.get(id); + } + + collectTouchedKeys(touchedKeys: Set) { + for (const key of this.entries.keys()) { + touchedKeys.add(key); + } + + if (this.parent instanceof DerivedState) { + this.parent.collectTouchedKeys(touchedKeys); + } + } + + collectDiff(oldState: DerivedState): Entries { + const touchedKeys = new Set(); + this.collectTouchedKeys(touchedKeys); + oldState.collectTouchedKeys(touchedKeys); + + const diff: Entries = new Map(); + touchedKeys.forEach((key) => { + const oldEntry = oldState.get(key); + const newEntry = this.get(key); + if (oldEntry !== newEntry) { + diff.set(key, newEntry); + } + }); + + return diff; + } +} + +class EventLog { + private readonly connectClient: ConnectClient; + private readonly queue: EventQueueDescriptor; + private readonly options: { delay: boolean; }; + + private readonly subscribeCount = signal(0); + private readonly fluxConnectionActive = signal(true); + + private readonly pendingChanges: Record = {}; + private readonly pendingResults: Record void> = {}; + + private readonly confirmedState = new State(); + private visualState = new DerivedState(this.confirmedState); + private readonly internalSignals: Map = new Map(); + private readonly externalSignals: Map = new Map(); + + private subscription?: Subscription; + private lastEvent?: string; + + private fluxStateChangeListener = (event: CustomEvent<{active: boolean}>) => { + this.fluxConnectionActive.value = event.detail.active + }; + + constructor(queue: EventQueueDescriptor, connectClient: ConnectClient, options: SignalOptions, rootType: EntryType) { + this.queue = queue; + this.options = {...defaultOptions, ...options}; + this.connectClient = connectClient; + + let rootValue: any; + if (rootType == EntryType.LIST) { + const listRoot: ListRoot = { head: null, tail: null }; + rootValue = listRoot; + } else if (rootType == EntryType.VALUE || rootType == EntryType.NUMBER) { + rootValue = options.initialValue; + } else { + throw Error(rootType); + } + + const internalRootSignal = this.createInternalSignal(rootValue); + this.internalSignals.set(rootKey, internalRootSignal) ; + this.externalSignals.set(rootKey, this.createExternalSignal(rootKey, internalRootSignal, rootType)); + this.confirmedState.entries.set(rootKey, {type: rootType, next: null, prev: null, value: rootValue}); + + effect(() => { + if (this.subscribeCount.value > 0 && this.fluxConnectionActive.value) { + this.connect(); + } else { + this.disconnect(); + } + }); + } + + private subscribe(): void { + // Update asynchronously to avoid side effects when this is run inside compute() + setTimeout(() => this.subscribeCount.value++, 0); + } + + private unsubscribe(): void { + // Update asynchronously to avoid side effects when this is run inside compute() + setTimeout(() => this.subscribeCount.value--, 0); + } + + private connect() { + if (this.subscription) { + return; + } + console.log("Opening connection"); + + this.subscription = this.queue.subscribe(this.queue.id, this.lastEvent).onNext(json => { + const event = JSON.parse(json) as StateEvent; + this.lastEvent = event.id; + + if (event.id in this.pendingChanges) { + delete this.pendingChanges[event.id]; + } + + // Create as a derived state so we can diff against the old confirmed state + const newConfirmedState = new DerivedState(this.confirmedState); + const accepted = newConfirmedState.evaluate(event); + + if (accepted) { + // Create a new visible state by applying the current change + pending changes against the confirmed state + const newVisualState = new DerivedState(newConfirmedState); + newVisualState.evaluateBatch(Object.values(this.pendingChanges)); + + // Create a diff between old and new visible state + const diff = newVisualState.collectDiff(this.visualState); + + // Update confirmed state based on the current change + this.confirmedState.ingest(newConfirmedState); + newVisualState.parent = this.confirmedState; + + // Set the new visible state as the official visible state + this.visualState = newVisualState; + + // Update signals based on the diff + this.updateSignals(diff); + } + + if (event.id in this.pendingResults) { + this.pendingResults[event.id](accepted); + delete this.pendingResults[event.id]; + } + }); + + this.connectClient.fluxConnection.addEventListener('state-changed', this.fluxStateChangeListener); + } + + private disconnect() { + if (!this.subscription) { + return; + } + + console.log("Closing connection"); + this.subscription.cancel(); + this.subscription = undefined; + + this.connectClient.fluxConnection.removeEventListener('state-changed', this.fluxStateChangeListener); + } + + private updateSignals(diff: Entries) { + batch(() => { + for (const [key, entry] of diff.entries()) { + const signal = this.internalSignals.get(key); + if (signal) { + if (entry) { + // TODO re-create external signal if entry type has changed + signal.value = entry.value; + } else { + signal.value = null; + this.internalSignals.delete(key); + this.externalSignals.delete(key); + } + } else if (entry) { + const internalSignal = this.createInternalSignal(entry.value); + this.internalSignals.set(key, internalSignal); + this.externalSignals.set(key, this.createExternalSignal(key, internalSignal, entry.type)); + } + } + }); + } + + private addPendingChange(event: StateEvent) { + this.pendingChanges[event.id] = event; + + const newVisualState = new DerivedState(this.visualState); + if (newVisualState.evaluate(event)) { + const diff = newVisualState.collectDiff(this.visualState); + + this.visualState.ingest(newVisualState); + this.updateSignals(diff); + } + } + + private removePendingChange(event: StateEvent) { + delete this.pendingChanges[event.id]; + + const newVisualState = new DerivedState(this.confirmedState); + newVisualState.evaluateBatch(Object.values(this.pendingChanges)); + + const diff = newVisualState.collectDiff(this.visualState); + + this.visualState = newVisualState; + + this.updateSignals(diff); + } + + public publish(event : StateEvent, latencyCompensate : boolean): Promise { + if (latencyCompensate) { + this.addPendingChange(event); + } + return new Promise((resolve, reject) => { + this.pendingResults[event.id] = resolve; + + const action = () => this.queue.publish(this.queue.id, JSON.stringify(event)).catch((error) => { + if (latencyCompensate) { + this.removePendingChange(event); + } + reject(error); + }); + this.options.delay ? setTimeout(action, 2000) : action(); + }); + } + + getSignal(id: string): Signal | undefined { + return this.externalSignals.get(id); + } + + getRoot(): R { + return this.externalSignals.get(rootKey) as R; + } + + getEntry(key: string): Entry | undefined { + return this.visualState.get(key); + } + + private createInternalSignal(initialValue: T): DependencyTrackSignal { + return new DependencyTrackSignal(initialValue, () => this.subscribe(), () => this.unsubscribe()); + } + + private createExternalSignal(key: EntryId, internalSignal: Signal, type: EntryType): Signal { + switch(type) { + case EntryType.LIST: { + return new ListSignal(key, internalSignal, this); + } + case EntryType.VALUE: { + return new ValueSignal(key, internalSignal, this); + } + case EntryType.NUMBER: { + return new NumberSignal(key, internalSignal, this); + } + default: { + throw new Error("Unsupported entry type: " + type); + } + } + } +} + +declare class Computed extends Signal implements ReadonlySignal { + constructor(compute: () => T); +} + +function Computed (this: Computed, compute: () => T) { + // Replica of the private Computed constructor + Signal.call(this, undefined); + + const anyThis = this as any; + // _compute + anyThis.x = compute; + // _sources + anyThis.s = undefined; + // _globalVersion + anyThis.g = - 1; + // _flags = OUTDATED + anyThis.f = 1 << 2; +} + +// Create dummy real Computed to be able to get its prototype +Computed.prototype = (computed(() => {}) as any).__proto__; + +abstract class SharedSignal extends Computed { + override readonly key: EntryId; + + constructor(compute: () => T, key: EntryId) { + super(compute); + this.key = key; + } +} + +interface EventQueueDescriptor { + id: string; + subscribe(signalId: string, lastId?: string): Subscription; + publish(signalId: string, event: T): Promise; +} + +interface FullSignalOptions { + delay: boolean; + initialValue: any; +} + +const defaultOptions : FullSignalOptions = { + delay: false, + initialValue: null, +} + +type SignalOptions = Partial; + +interface ListInsertResult> { + readonly promise: Promise, + + readonly signal: S, +} + +// Entry value for the root of a list +interface ListRoot { + head: string | null, + tail: string | null +} + +export class ValueSignal extends SharedSignal { + private readonly eventLog: EventLog; + + constructor(key: EntryId, internalSignal: Signal, eventLog: EventLog) { + super(() => internalSignal.value, key); + + this.eventLog = eventLog; + } + + override get value() { + return super.value; + } + + override set value(value: T) { + this.set(value, true); + } + + set(value: T, eager: boolean): Promise { + const id = crypto.randomUUID(); + const event: SetEvent = { id, set: this.key, value }; + return this.eventLog.publish(event, eager).then((_) => undefined); + } + + compareAndSet(expectedValue: T, newValue: T, eager = true): Promise { + const id = crypto.randomUUID(); + const event: SetEvent = { + id, + set: this.key, + value: newValue, + conditions: [{ id: this.key, value: expectedValue }], + }; + + return this.eventLog.publish(event, eager); + } + + async update(updater: (value: T) => T): Promise { + // TODO detect accessing other signals and re-run if any of those are changed as well + // TODO conditional on last change id for the signal rather than the value itself to avoid the ABA problem + while (!(await this.compareAndSet(this.value, updater(this.value)))) {} + } +} + +export class NumberSignal extends ValueSignal { + + constructor(key: EntryId, internalSignal: Signal, eventLog: EventLog) { + super(key, internalSignal, eventLog); + } + + async increment(delta?: number): Promise { + delta ??= 1; + await this.compareAndSet(this.value, this.value + delta); + } +} + +export class NumberSignalQueue extends EventLog { + constructor(queue: EventQueueDescriptor, connectClient: ConnectClient, initialValue?: number, eager: boolean = true) { + const options: SignalOptions = { + delay: !eager, + initialValue: initialValue ?? 0, + }; + super(queue, connectClient, options, EntryType.NUMBER); + } +} + +function getKey(target: EntryReference): string { + if (typeof target == 'string') { + return target; + } else { + return target.key; + } +} + +export class ListSignal = ValueSignal> extends SharedSignal { + private readonly eventLog: EventLog; + + constructor(key: EntryId, internalSignal: Signal, eventLog: EventLog) { + super(() => { + const value: S[] = []; + const root = internalSignal.value as ListRoot; + if (root) { + let key = root.head; + while (key) { + const signal = eventLog.getSignal(key); + if (!signal) { + throw new Error("Should not happen?"); + } + value.push(signal as S); + + const node = eventLog.getEntry(key); + if (!node) { + throw new Error("Should not happen?"); + } + key = node.next; + } + } + return value; + }, key); + + this.eventLog = eventLog; + } + + get(key: EntryId): S | undefined { + // TODO: Avoid setting up a subscription + // TODO: Find the signal by id and verify that it's a child instead of looping through children + return this.value.find((signal) => signal.key == key); + } + + get items(): ReadonlySignal { + // TODO: Maybe lazy create and cache these computed signals? + return computed(() => this.value.map(({value}) => value)); + } + + get entries(): ReadonlySignal<[T, EntryId][]> { + // TODO: Maybe lazy create and cache these computed signals? + return computed(() => this.value.map(({key, value}) => [value, key])); + } + + insertLast(value: T): ListInsertResult { + const id = crypto.randomUUID(); + const event: InsertEvent = { entry: this.key, id, direction: "AFTER", reference: null, value }; + return {promise: this.eventLog.publish(event, true), signal: this.get(id)!}; + } + + insertFirst(value: T): ListInsertResult { + const id = crypto.randomUUID(); + const event: InsertEvent = { entry: this.key, id, direction: "BEFORE", reference: null, value }; + return {promise: this.eventLog.publish(event, true), signal: this.get(id)!}; + } + + insertBefore(reference: EntryReference, value: T): ListInsertResult { + const id = crypto.randomUUID(); + const event: InsertEvent = { entry: this.key, id, direction: "BEFORE", reference: getKey(reference), value }; + return {promise: this.eventLog.publish(event, true), signal: this.get(id)!}; + } + + insertAfter(reference: EntryReference, value: T): ListInsertResult { + const id = crypto.randomUUID(); + const event: InsertEvent = { entry: this.key, id, direction: "AFTER", reference: getKey(reference), value }; + return {promise: this.eventLog.publish(event, true), signal: this.get(id)!}; + } + + remove(child: EntryReference) { + const id = crypto.randomUUID(); + const event: RemoveEvent = { id, remove: getKey(child), parent: this.key}; + this.eventLog.publish(event, true); + } +} + +export { EventLog, type SignalOptions, type ListInsertResult }; From 4d76289474fc5f2d76ee20ee7bea8e427e505bc3 Mon Sep 17 00:00:00 2001 From: Vlad Rindevich Date: Fri, 31 May 2024 14:11:09 +0300 Subject: [PATCH 2/8] feat(generator-plugin-signals): add a plugin implementation --- .../src/SharedSignalProcessor.ts | 82 ------------- .../src/SignalProcessor.ts | 112 ++++++++++++++++++ .../ts/generator-plugin-signals/src/index.ts | 86 +++++++++----- .../test/SignalsEndpoints.spec.ts | 9 +- .../src/dependencies/ImportManager.ts | 44 +++++++ packages/ts/react-signals/src/index.ts | 1 + 6 files changed, 222 insertions(+), 112 deletions(-) delete mode 100644 packages/ts/generator-plugin-signals/src/SharedSignalProcessor.ts create mode 100644 packages/ts/generator-plugin-signals/src/SignalProcessor.ts 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'; From 89aab02bc2fa7b566fb499f0bcdef9b8db5b3582 Mon Sep 17 00:00:00 2001 From: taefi Date: Fri, 31 May 2024 15:11:50 +0300 Subject: [PATCH 3/8] fix plugin-signals dependencies --- packages/ts/generator-plugin-signals/package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ts/generator-plugin-signals/package.json b/packages/ts/generator-plugin-signals/package.json index 10e1f92b40..4ee8116786 100644 --- a/packages/ts/generator-plugin-signals/package.json +++ b/packages/ts/generator-plugin-signals/package.json @@ -1,6 +1,6 @@ { "name": "@vaadin/hilla-generator-plugin-signals", - "version": "24.4.0-beta1", + "version": "24.5.0-alpha1", "description": "A Hilla TypeScript Generator plugin to add Shared Signals support", "main": "index.js", "type": "module", @@ -51,12 +51,12 @@ "access": "public" }, "peerDependencies": { - "@vaadin/hilla-generator-core": "24.4.0-beta1", - "@vaadin/hilla-generator-plugin-client": "24.4.0-beta1" + "@vaadin/hilla-generator-plugin-client": "24.5.0-alpha1" }, "dependencies": { - "@vaadin/hilla-generator-plugin-backbone": "^24.4.0-beta1", - "@vaadin/hilla-generator-utils": "24.4.0-beta1", + "@vaadin/hilla-generator-core": "24.5.0-alpha1", + "@vaadin/hilla-generator-plugin-backbone": "^24.5.0-alpha1", + "@vaadin/hilla-generator-utils": "24.5.0-alpha1", "fast-deep-equal": "^3.1.3", "openapi-types": "^12.1.3", "typescript": "5.3.2" @@ -67,8 +67,8 @@ "@types/node": "^20.7.1", "@types/sinon": "^10.0.17", "@types/sinon-chai": "^3.2.10", - "@vaadin/hilla-generator-core": "24.4.0-beta1", - "@vaadin/hilla-generator-plugin-client": "24.4.0-beta1", + "@vaadin/hilla-generator-core": "24.5.0-alpha1", + "@vaadin/hilla-generator-plugin-client": "24.5.0-alpha1", "c8": "^8.0.1", "chai": "^4.3.10", "concurrently": "^8.2.1", From 886521d27d33841c77fe4a29b5396c10e688e8e4 Mon Sep 17 00:00:00 2001 From: taefi Date: Fri, 31 May 2024 15:25:23 +0300 Subject: [PATCH 4/8] remove the unused jvm parser module --- .../plugins/signals/SharedSignalsPlugin.java | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 packages/java/parser-jvm-plugin-signals/src/main/java/com/vaadin/hilla/parser/plugins/signals/SharedSignalsPlugin.java diff --git a/packages/java/parser-jvm-plugin-signals/src/main/java/com/vaadin/hilla/parser/plugins/signals/SharedSignalsPlugin.java b/packages/java/parser-jvm-plugin-signals/src/main/java/com/vaadin/hilla/parser/plugins/signals/SharedSignalsPlugin.java deleted file mode 100644 index b545a40531..0000000000 --- a/packages/java/parser-jvm-plugin-signals/src/main/java/com/vaadin/hilla/parser/plugins/signals/SharedSignalsPlugin.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.vaadin.hilla.parser.plugins.signals; - -import com.vaadin.hilla.parser.core.AbstractPlugin; -import com.vaadin.hilla.parser.core.Node; -import com.vaadin.hilla.parser.core.NodeDependencies; -import com.vaadin.hilla.parser.core.NodePath; -import com.vaadin.hilla.parser.core.Plugin; -import com.vaadin.hilla.parser.core.PluginConfiguration; -import com.vaadin.hilla.parser.plugins.backbone.BackbonePlugin; -import jakarta.annotation.Nonnull; - -import java.util.Collection; -import java.util.List; - -public class SharedSignalsPlugin extends AbstractPlugin { - - @Override - public void enter(NodePath nodePath) { - - } - - @Override - public void exit(NodePath nodePath) { - - } - - @Nonnull - @Override - public Node resolve(@Nonnull Node node, - @Nonnull NodePath parentPath) { - return super.resolve(node, parentPath); - } - - @Nonnull - @Override - public NodeDependencies scan(@Nonnull NodeDependencies nodeDependencies) { - return null; - } - - @Override - public Collection> getRequiredPlugins() { - return List.of(BackbonePlugin.class); - } -} From 3134deeea0b046678c22776247bb1a537f6115cb Mon Sep 17 00:00:00 2001 From: taefi Date: Fri, 31 May 2024 18:25:22 +0300 Subject: [PATCH 5/8] fix bean instantiation and conditional on FeatureFlag --- .../com/vaadin/hilla/EndpointController.java | 14 ++------ .../EndpointControllerConfiguration.java | 1 - .../com/vaadin/hilla/EndpointInvoker.java | 32 +++++-------------- .../signals/config/SignalsConfiguration.java | 21 ++++++++---- ...ot.autoconfigure.AutoConfiguration.imports | 2 +- 5 files changed, 26 insertions(+), 44 deletions(-) diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java index 21d7db582a..d464039aad 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java @@ -129,18 +129,6 @@ public void registerEndpoints(URL openApiResource) { endpointBeans .putAll(context.getBeansWithAnnotation(BrowserCallable.class)); - if (FeatureFlags.get(VaadinService.getCurrent().getContext()) - .isEnabled(FeatureFlags.HILLA_FULLSTACK_SIGNALS)) { - LOGGER.debug("Fullstack signals feature is enabled."); - if (endpointBeans.containsKey("signalsHandler")) { - LOGGER.debug("SignalsHandler endpoint will be registered."); - } - } else { - LOGGER.debug("Fullstack signals feature is disabled."); - endpointBeans.remove("signalsHandler"); - LOGGER.debug("SignalsHandler endpoint will not be registered."); - } - // By default, only register those endpoints included in the Hilla // OpenAPI definition file registerEndpointsFromApiDefinition(endpointBeans, openApiResource); @@ -275,6 +263,8 @@ private void registerEndpointsFromApiDefinition( .or(() -> Optional .ofNullable(tag.get("x-class-name")) .map(JsonNode::asText) + .filter(className -> !("com.vaadin.hilla.signals.core.SignalsHandler" + .equals(className))) .map(this::instantiateEndpointByClassName)) .ifPresent(endpointRegistry::registerEndpoint); }); diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java index 4dfa27d31b..168f329990 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java @@ -45,7 +45,6 @@ * A configuration class for customizing the {@link EndpointController} class. */ @Configuration -@Import(SignalsConfiguration.class) public class EndpointControllerConfiguration { private final EndpointProperties endpointProperties; private final SignalsRegistry signalsRegistry; diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java index b92069aba2..0e56f38ea5 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java @@ -489,31 +489,15 @@ private Object invokeVaadinEndpointMethod(String endpointName, } if (returnValue instanceof SignalQueue) { - if (FeatureFlags.get(VaadinService.getCurrent().getContext()) - .isEnabled(FeatureFlags.HILLA_FULLSTACK_SIGNALS)) { - if (signalsRegistry == null) { - throw new EndpointInternalException( - "Signal registry is not available"); - } - if (signalsRegistry - .contains(((SignalQueue) returnValue).getId())) { - getLogger().debug( - "Signal already registered as a result of calling {}", - methodName); - } else { - signalsRegistry.register((SignalQueue) returnValue); - getLogger().debug( - "Registered signal as a result of calling {}", - methodName); - } + if (signalsRegistry + .contains(((SignalQueue) returnValue).getId())) { + getLogger().debug( + "Signal already registered before. Ignoring the registration. Endpoint: '{}', method: '{}'", + endpointName, methodName); } else { - String featureFlagFullName = FeatureFlags.SYSTEM_PROPERTY_PREFIX_EXPERIMENTAL - + FeatureFlags.HILLA_FULLSTACK_SIGNALS; - throw new EndpointInternalException(String.format( - "Full-Stack Signal usage are only allowed if the %s feature flag is enabled explicitly. " - + "You can enable it either through the Vaadin Copilot's UI, or by manually setting the " - + "%s=true in the src/main/resources/vaadin-featureflags.properties and restarting the application.", - featureFlagFullName, featureFlagFullName)); + signalsRegistry.register((SignalQueue) returnValue); + getLogger().debug("Registered signal as a result of calling {}", + methodName); } } diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java index d4ce43c87a..ee0286d2d1 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java @@ -1,5 +1,7 @@ package com.vaadin.hilla.signals.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vaadin.hilla.ConditionalOnFeatureFlag; import com.vaadin.hilla.signals.core.SignalsHandler; import com.vaadin.hilla.signals.core.SignalsRegistry; import org.springframework.context.annotation.Bean; @@ -8,22 +10,29 @@ @Configuration public class SignalsConfiguration { - private final SignalsRegistry signalsRegistry; - private final SignalsHandler signalsHandler; + private SignalsRegistry signalsRegistry; + private SignalsHandler signalsHandler; + private final ObjectMapper objectMapper; - public SignalsConfiguration(SignalsRegistry signalsRegistry, - SignalsHandler signalsHandler) { - this.signalsRegistry = signalsRegistry; - this.signalsHandler = signalsHandler; + public SignalsConfiguration(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; } @Bean public SignalsRegistry signalsRegistry() { + if (signalsRegistry == null) { + signalsRegistry = new SignalsRegistry(); + } return signalsRegistry; } + @ConditionalOnFeatureFlag("fullstackSignals") @Bean public SignalsHandler signalsHandler() { + if (signalsHandler == null) { + signalsHandler = new SignalsHandler(signalsRegistry(), + objectMapper); + } return signalsHandler; } } diff --git a/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index caa7181ba3..32c3ccc048 100644 --- a/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -7,4 +7,4 @@ com.vaadin.hilla.startup.RouteUnifyingServiceInitListener com.vaadin.hilla.route.ClientRouteRegistry com.vaadin.hilla.route.RouteUtil com.vaadin.hilla.route.RouteUnifyingConfiguration -com.vaadin.hilla.signals.config.SignalConfiguration +com.vaadin.hilla.signals.config.SignalsConfiguration From 2d7cd93589717265438c9bc32f7e8bc3ed5a8c18 Mon Sep 17 00:00:00 2001 From: taefi Date: Sat, 1 Jun 2024 00:56:01 +0300 Subject: [PATCH 6/8] enable generator ts plugin --- .../java/com/vaadin/hilla/engine/GeneratorConfiguration.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/java/engine-core/src/main/java/com/vaadin/hilla/engine/GeneratorConfiguration.java b/packages/java/engine-core/src/main/java/com/vaadin/hilla/engine/GeneratorConfiguration.java index 85f209c98f..c049f91af1 100644 --- a/packages/java/engine-core/src/main/java/com/vaadin/hilla/engine/GeneratorConfiguration.java +++ b/packages/java/engine-core/src/main/java/com/vaadin/hilla/engine/GeneratorConfiguration.java @@ -143,7 +143,8 @@ static class PluginsProcessor extends ConfigList.Processor { new Plugin("@vaadin/hilla-generator-plugin-barrel"), new Plugin("@vaadin/hilla-generator-plugin-model"), new Plugin("@vaadin/hilla-generator-plugin-push"), - new Plugin("@vaadin/hilla-generator-plugin-subtypes")); + new Plugin("@vaadin/hilla-generator-plugin-subtypes"), + new Plugin("@vaadin/hilla-generator-plugin-signals")); PluginsProcessor() { super(DEFAULTS); From 5149e2d428a46e01dda3ace3715b98bf1ec11593 Mon Sep 17 00:00:00 2001 From: taefi Date: Sun, 2 Jun 2024 21:09:09 +0300 Subject: [PATCH 7/8] fix unit tests --- .../hilla/EndpointControllerConfigurationTest.java | 8 ++++++-- .../test/java/com/vaadin/hilla/EndpointInvokerTest.java | 8 ++++++-- .../com/vaadin/hilla/push/PushMessageHandlerTest.java | 9 ++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerConfigurationTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerConfigurationTest.java index 0bc4ebc6c5..ae1156c010 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerConfigurationTest.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerConfigurationTest.java @@ -1,9 +1,11 @@ package com.vaadin.hilla; +import com.vaadin.hilla.signals.config.SignalsConfiguration; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonProperties; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; @@ -14,8 +16,10 @@ @SpringBootTest(classes = { ServletContextTestSetup.class, EndpointProperties.class, Jackson2ObjectMapperBuilder.class, - JacksonProperties.class, EndpointController.class }) -@ContextConfiguration(classes = { EndpointControllerConfiguration.class }) + JacksonProperties.class, JacksonAutoConfiguration.class, + EndpointController.class }) +@ContextConfiguration(classes = { EndpointControllerConfiguration.class, + SignalsConfiguration.class }) @RunWith(SpringRunner.class) public class EndpointControllerConfigurationTest { diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java index 81946ad3a8..2aeb72a49c 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java @@ -1,5 +1,6 @@ package com.vaadin.hilla; +import com.vaadin.hilla.signals.config.SignalsConfiguration; import com.vaadin.hilla.signals.core.SignalsRegistry; import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpServletRequest; @@ -15,6 +16,7 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonProperties; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; @@ -28,8 +30,10 @@ @SpringBootTest(classes = { ServletContextTestSetup.class, EndpointProperties.class, Jackson2ObjectMapperBuilder.class, - JacksonProperties.class, EndpointController.class }) -@ContextConfiguration(classes = { EndpointControllerConfiguration.class }) + JacksonProperties.class, JacksonAutoConfiguration.class, + EndpointController.class }) +@ContextConfiguration(classes = { EndpointControllerConfiguration.class, + SignalsConfiguration.class }) @RunWith(SpringRunner.class) public class EndpointInvokerTest { diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/push/PushMessageHandlerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/push/PushMessageHandlerTest.java index a93794f8c7..4d4b3cde7e 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/push/PushMessageHandlerTest.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/push/PushMessageHandlerTest.java @@ -28,6 +28,7 @@ import com.vaadin.hilla.push.messages.toclient.ClientMessageComplete; import com.vaadin.hilla.push.messages.toclient.ClientMessageError; import com.vaadin.hilla.push.messages.toclient.ClientMessageUpdate; +import com.vaadin.hilla.signals.config.SignalsConfiguration; import net.jcip.annotations.NotThreadSafe; import org.junit.After; import org.junit.Assert; @@ -36,6 +37,7 @@ import org.junit.runner.RunWith; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonProperties; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -48,9 +50,10 @@ @SpringBootTest(classes = { PushMessageHandler.class, ServletContextTestSetup.class, EndpointProperties.class, Jackson2ObjectMapperBuilder.class, JacksonProperties.class, - PushMessageHandler.class, ObjectMapper.class, - EndpointController.class }) -@ContextConfiguration(classes = { EndpointControllerConfiguration.class }) + PushMessageHandler.class, JacksonAutoConfiguration.class, + ObjectMapper.class, EndpointController.class }) +@ContextConfiguration(classes = { EndpointControllerConfiguration.class, + SignalsConfiguration.class }) @RunWith(SpringRunner.class) @TestPropertySource(properties = "com.vaadin.hilla.FeatureFlagCondition.alwaysEnable=true") @NotThreadSafe From d34c909d625074a4281ec28726e66dc80e5be0f2 Mon Sep 17 00:00:00 2001 From: taefi Date: Mon, 3 Jun 2024 15:07:31 +0300 Subject: [PATCH 8/8] mark SignalsRegistry bean as optional based on feature flag --- .../hilla/EndpointControllerConfiguration.java | 5 +++-- .../main/java/com/vaadin/hilla/EndpointInvoker.java | 12 ++++++++++++ .../hilla/signals/config/SignalsConfiguration.java | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java index 168f329990..d3845d39c1 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java @@ -57,7 +57,7 @@ public class EndpointControllerConfiguration { */ public EndpointControllerConfiguration( EndpointProperties endpointProperties, - SignalsRegistry signalsRegistry) { + @Autowired(required = false) SignalsRegistry signalsRegistry) { this.endpointProperties = endpointProperties; this.signalsRegistry = signalsRegistry; } @@ -124,7 +124,8 @@ CsrfChecker csrfChecker(ServletContext servletContext) { EndpointInvoker endpointInvoker(ApplicationContext applicationContext, @Autowired(required = false) @Qualifier(EndpointController.ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER) JacksonObjectMapperFactory endpointMapperFactory, ExplicitNullableTypeChecker explicitNullableTypeChecker, - ServletContext servletContext, EndpointRegistry endpointRegistry) { + ServletContext servletContext, + @Autowired(required = false) EndpointRegistry endpointRegistry) { return new EndpointInvoker(applicationContext, endpointMapperFactory, explicitNullableTypeChecker, servletContext, endpointRegistry, signalsRegistry); diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java index 0e56f38ea5..3a3cc6a966 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java @@ -489,6 +489,18 @@ private Object invokeVaadinEndpointMethod(String endpointName, } if (returnValue instanceof SignalQueue) { + if (signalsRegistry == null) { + throw new IllegalStateException( + """ + Signals registry is not available, cannot register signal. + Please make sure you have enabled the Full Stack Signals + feature preview flag either through the Vaadin Copilot's + Features panel, or by manually setting the + 'com.vaadin.experimental.fullstackSignals=true' in + 'src/main/resources/vaadin-featureflags.properties'. + """ + .stripLeading()); + } if (signalsRegistry .contains(((SignalQueue) returnValue).getId())) { getLogger().debug( diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java index ee0286d2d1..fc79358e8f 100644 --- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java +++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java @@ -18,6 +18,7 @@ public SignalsConfiguration(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } + @ConditionalOnFeatureFlag("fullstackSignals") @Bean public SignalsRegistry signalsRegistry() { if (signalsRegistry == null) {