feat: introduce WebSocket support with new endpoints and transaction propagation enhancements

This commit is contained in:
Relism
2026-05-11 16:14:26 +02:00
parent a4a16bdb00
commit ccc5550598
55 changed files with 2705 additions and 752 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Deploy Publish JavaDoc
on:
push:
branches:
- master
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Build release artifacts
run: mvn -B -DskipTests clean verify
- name: Generate aggregated JavaDoc
run: mvn -B -DskipTests -pl flash,flash-extensions -am javadoc:aggregate
- name: Publish JavaDoc
uses: MathieuSoysal/Javadoc-publisher.yml@v3.0.2
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
javadoc-branch: javadoc
java-version: 21
target-folder: javadoc
project: maven
custom-command: mvn -B -DskipTests -pl flash,flash-extensions -am javadoc:aggregate -DskipTests
+172 -340
View File
@@ -4,292 +4,95 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="add core view extension with JTE and Thymeleaf support"> <list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="feat: introduce Spec and Query interfaces with transaction propagation enhancements">
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/pom.xml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/.github/workflows/publish-javadoc.yml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/benchmarks/websocket/benchmark.yml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Page.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/docs/README.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Repository.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Query.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/ResourceRegistry.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/RepositorySupport.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Sort.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Spec.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionIsolation.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/SpecBuilder.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionPropagation.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/SpecContext.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Tx.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/docs/README.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxDefinition.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateSpecContext.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxException.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/docs/README.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcSpecContext.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxOutcome.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxResourceKey.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Ws.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxStatus.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxSynchronization.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/websocket/WebSocketEndpoint.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/pom.xml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/websocket/WebSocketFrame.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateRepository.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/websocket/WebSocketHandler.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxStatus.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerTest.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/TestHelper.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/extension/PackageScannerTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/pom.xml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/AbstractWsRouterTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcRepository.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/websocket/WebSocketEndpointTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/websocket/WebSocketFrameTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxStatus.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/websocket/WebSocketSessionFrameTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerTest.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/websocket/WebSocketSessionTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteTarget.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/websocket/onlyws/OnlyWsEndpoint.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/websocket/scantest/ScanHttpHandler.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/Flash.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/websocket/scantest/ScanWsEndpoint.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/radix/RadixPathRouterImpl.java" afterDir="false" /> <change afterPath="$PROJECT_DIR$/qodana.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/encodings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/encodings.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonMiddleware.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Repository.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Repository.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/Json.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/Json.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/ResourceRegistry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/ResourceRegistry.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Sort.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Sort.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonMiddlewareTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonMiddlewareTest.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionPropagation.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionPropagation.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JsonTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JsonTest.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Tx.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Tx.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Bucket.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Bucket.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateRepository.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateRepository.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/BucketStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/BucketStore.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Guard.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Guard.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxStatus.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxStatus.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/KeyResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcRepository.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcRepository.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Limit.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Limit.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimitConfig.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxStatus.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxStatus.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimitStrategy.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/RateLimitStrategy.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/HttpServer.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/FixedWindowStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/FixedWindowStrategy.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/RequestParser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/RequestParser.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/SlidingWindowStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/SlidingWindowStrategy.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ServerHandle.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ServerHandle.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/TokenBucketStrategy.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashApp.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashApp.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/ext/limiter/LimiterOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/Authenticated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClaimsHolder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashScope.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashScope.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClientAuthMethod.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/DiscoveryClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/InMemoryOidcSessionStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/JwtUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/JwtValidator.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcAuthPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcProviderMetadata.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSession.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSessionStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcStateStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcTokenResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcValidationException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/PkceUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/RolesAllowed.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ScopesAllowed.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/TokenClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcAuthPolicyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcMiddlewareAuthzTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcUserScopesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/APIResponse.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponses.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/APIResponses.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ApiOperation.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ArraySchema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ArraySchema.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Content.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiContributor.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributorRegistry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiContributorRegistry.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiOperationContribution.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiOperationContribution.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiResponseContribution.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiResponseContribution.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Parameter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Parameter.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ParameterIn.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ParameterIn.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Parameters.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Parameters.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Schema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schema.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaProperty.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/SchemaProperty.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/SchemaType.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/GraphSerializer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/GraphSerializer.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerDataHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerDataHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerStaticHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerStaticHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteGraph.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteGraph.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteRecord.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouterNode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouterNode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/GlobalValue.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/GlobalValue.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/RenderedView.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/RenderedView.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewModel.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/ViewModel.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewRuntimeBridge.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/ViewRuntimeBridge.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/ext/view/core/ViewModelTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/ViewModelTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/docs/handlers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/docs/handlers.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteRuntime.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteSettings.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTarget.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteTargetResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/Template.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/Template.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteRuntimeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteRuntimeTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteSettingsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteTargetResolverTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/model/HomePage.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/model/HomePage.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Fragment.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/Fragment.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Template.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/Template.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafRuntime.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafSettings.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafSettings.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTarget.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTarget.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTargetResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntimeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafRuntimeTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafSettingsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafSettingsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTargetResolverTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/asset-sources.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/configuration.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/configuration.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/dev-lifecycle.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/frontend-selection.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/modes.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/modes.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/package-managers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/package-managers.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/performance.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/performance.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/prod-serving.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/routing-fallback.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/security-policies.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/security-policies.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/pom.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetCatalog.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetEntry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetIo.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetLoadRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetMetadata.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetPaths.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetsSources.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/BasePathEnforcementMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ClasspathAssetManifest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ClasspathAssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandOrchestrator.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandSafetyMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandSafetyPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandTokens.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FilesystemAssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendType.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendTypeResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/InstallCache.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/InstallPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/LoggingMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/MimeTypes.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ModeResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/OperationMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/PackageManager.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/PackageManagerAdapter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/RuntimeEnvironment.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/RuntimeMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/SpaFallbackPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/StaticAssetServingPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ViteFrontendStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WatchList.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/AssetsSourceTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/CommandSafetyPolicyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/InstallCacheTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/ModeResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/PackageManagerAdapterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerConfigTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionDevGuardTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/pom.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ChunkedInputStream.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/Flash.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/HttpServer.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/RequestParser.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ServerHandle.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/api/multipart/Multipart.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/api/multipart/Part.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/api/multipart/Part.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/HttpException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/InitializationException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/exceptions/InitializationException.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionPhase.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashApp.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashConfiguration.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashContext.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashContext.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashScope.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteDefinition.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteEvent.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteEvent.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteListener.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteListener.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/ContentType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/ContentType.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpMethod.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/HttpMethod.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpStatus.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/HttpStatus.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/HeaderMap.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/HeaderMap.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/PathParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/PathParams.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/QueryParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/QueryParams.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/Request.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestBody.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestBody.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHelper.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestHelper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestLine.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestLine.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/Response.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/SimpleHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/SimpleHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/CONNECT.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/CONNECT.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/DELETE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/DELETE.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/GET.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/GET.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/HEAD.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/HEAD.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Middleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Middleware.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/OPTIONS.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/OPTIONS.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PATCH.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PATCH.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/POST.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/POST.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PURGE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PURGE.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PUT.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PUT.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PathUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PathUtils.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Route.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Route.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Routes.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Routes.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/TRACE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/TRACE.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViews.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/template/ByteTemplate.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/template/ByteTemplate.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/template/ErrorPages.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/template/ErrorPages.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/ChunkedInputStreamTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/RequestParserTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/RequestParserTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/api/multipart/MultipartTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/api/multipart/MultipartTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/ContentTypeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/ContentTypeTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpMethodTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/HttpMethodTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpStatusTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/HeaderMapTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/PathParamsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/PathParamsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/QueryParamsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/QueryParamsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestBodyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestBodyTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestLineTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestLineTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/ResponseTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/ResponseTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/SimpleHandlerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/SimpleHandlerTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/PathUtilsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/PathUtilsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImplTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ByteTemplateTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/template/ByteTemplateTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ErrorPagesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/template/ErrorPagesTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" /> <option name="LAST_RESOLUTION" value="IGNORE" />
</component> </component>
<component name="CopilotChats">
<option name="panelChat">
<chat>
<option name="activeSessionId" value="82f48e09-2ca4-4a27-9a04-c65d7a5f0b88" />
<option name="sessions">
<session>
<option name="chatType" value="PANEL" />
<option name="id" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
<option name="modeId" value="Agent" />
<option name="modelType" value="builtin_family" />
<option name="modelValue" value="auto" />
<option name="status" value="Completed" />
<option name="targetType" value="LOCAL" />
</session>
<session>
<option name="chatType" value="PANEL" />
<option name="id" value="82f48e09-2ca4-4a27-9a04-c65d7a5f0b88" />
<option name="modeId" value="Agent" />
<option name="modelType" value="builtin_family" />
<option name="modelValue" value="auto" />
<option name="targetType" value="LOCAL" />
</session>
</option>
<option name="type" value="PANEL" />
</chat>
</option>
</component>
<component name="CopilotPersistence"> <component name="CopilotPersistence">
<persistenceIdMap> <persistenceIdMap>
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" /> <entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" />
@@ -298,7 +101,7 @@
</persistenceIdMap> </persistenceIdMap>
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="407" /> <option name="cachedIndexableFilesCount" value="440" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="FileTemplateManagerImpl"> <component name="FileTemplateManagerImpl">
@@ -341,64 +144,64 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;Application.(dev) flash-bench.executor&quot;: &quot;Run&quot;, "Application.(dev) flash-bench.executor": "Run",
&quot;Application.ExternalBenchmark (1).executor&quot;: &quot;Run&quot;, "Application.ExternalBenchmark (1).executor": "Run",
&quot;Application.ExternalBenchmark.executor&quot;: &quot;Run&quot;, "Application.ExternalBenchmark.executor": "Run",
&quot;Application.Main.executor&quot;: &quot;Run&quot;, "Application.Main.executor": "Run",
&quot;Application.MainAlt.executor&quot;: &quot;Run&quot;, "Application.MainAlt.executor": "Run",
&quot;Application.dev.relism.bench.Main.executor&quot;: &quot;Run&quot;, "Application.dev.relism.bench.Main.executor": "Run",
&quot;JUnit.RequestParserTest.executor&quot;: &quot;Run&quot;, "JUnit.RequestParserTest.executor": "Run",
&quot;JUnit.RequestParserTest.headers_caseInsensitive.executor&quot;: &quot;Debug&quot;, "JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
&quot;Maven.FlashPractice [test].executor&quot;: &quot;Run&quot;, "Maven.FlashPractice [test].executor": "Run",
&quot;Maven.flash [compile].executor&quot;: &quot;Run&quot;, "Maven.flash [compile].executor": "Run",
&quot;Maven.flash [install].executor&quot;: &quot;Run&quot;, "Maven.flash [install].executor": "Run",
&quot;Maven.flash [test].executor&quot;: &quot;Run&quot;, "Maven.flash [test].executor": "Run",
&quot;Maven.flash [verify].executor&quot;: &quot;Run&quot;, "Maven.flash [verify].executor": "Run",
&quot;Maven.flash-bench [clean].executor&quot;: &quot;Run&quot;, "Maven.flash-bench [clean].executor": "Run",
&quot;Maven.flash-bench [install].executor&quot;: &quot;Run&quot;, "Maven.flash-bench [install].executor": "Run",
&quot;Maven.flash-bench [package].executor&quot;: &quot;Run&quot;, "Maven.flash-bench [package].executor": "Run",
&quot;Maven.flash-bench [validate].executor&quot;: &quot;Run&quot;, "Maven.flash-bench [validate].executor": "Run",
&quot;Maven.flash-ext-limiter [install].executor&quot;: &quot;Run&quot;, "Maven.flash-ext-limiter [install].executor": "Run",
&quot;Maven.flash-ext-limiter [package].executor&quot;: &quot;Run&quot;, "Maven.flash-ext-limiter [package].executor": "Run",
&quot;Maven.flash-ext-view [verify].executor&quot;: &quot;Run&quot;, "Maven.flash-ext-view [verify].executor": "Run",
&quot;Maven.flash-parent [clean].executor&quot;: &quot;Run&quot;, "Maven.flash-parent [clean].executor": "Run",
&quot;Maven.flash-parent [compile].executor&quot;: &quot;Run&quot;, "Maven.flash-parent [compile].executor": "Run",
&quot;Maven.flash-parent [deploy].executor&quot;: &quot;Run&quot;, "Maven.flash-parent [deploy].executor": "Run",
&quot;Maven.flash-parent [install].executor&quot;: &quot;Run&quot;, "Maven.flash-parent [install].executor": "Run",
&quot;Maven.flash-parent [package].executor&quot;: &quot;Run&quot;, "Maven.flash-parent [package].executor": "Run",
&quot;Maven.flash-parent [test].executor&quot;: &quot;Run&quot;, "Maven.flash-parent [test].executor": "Run",
&quot;Maven.flash-parent [validate].executor&quot;: &quot;Run&quot;, "Maven.flash-parent [validate].executor": "Run",
&quot;Maven.flash-parent [verify].executor&quot;: &quot;Run&quot;, "Maven.flash-parent [verify].executor": "Run",
&quot;Maven.flash-web-bundler [test].executor&quot;: &quot;Run&quot;, "Maven.flash-web-bundler [test].executor": "Run",
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;, "ModuleVcsDetector.initialDetectionPerformed": "true",
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;, "RunOnceActivity.MCP Project settings loaded": "true",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;, "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;, "RunOnceActivity.typescript.service.memoryLimit.init": "true",
&quot;SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;, "SHARE_PROJECT_CONFIGURATION_FILES": "true",
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;, "codeWithMe.voiceChat.enabledByDefault": "false",
&quot;git-widget-placeholder&quot;: &quot;master&quot;, "git-widget-placeholder": "master",
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;, "ignore.virus.scanning.warn.message": "true",
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;, "kotlin-language-version-configured": "true",
&quot;last_opened_file_path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/Flash&quot;, "last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, "node.js.detected.package.tslint": "true",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "nodejs_package_manager_path": "npm",
&quot;npm.build.executor&quot;: &quot;Run&quot;, "npm.build.executor": "Run",
&quot;onboarding.tips.debug.path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/Flash/flash-extensions/flash-ext-data/src/main/java/dev/relism/Main.java&quot;, "onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash/flash-extensions/flash-ext-data/src/main/java/dev/relism/Main.java",
&quot;project.structure.last.edited&quot;: &quot;Modules&quot;, "project.structure.last.edited": "Modules",
&quot;project.structure.proportion&quot;: &quot;0.15&quot;, "project.structure.proportion": "0.15",
&quot;project.structure.side.proportion&quot;: &quot;0.1150748&quot;, "project.structure.side.proportion": "0.1150748",
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;, "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings",
&quot;ts.external.directory.path&quot;: &quot;C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib&quot;, "ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib",
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot; "vue.rearranger.settings.migration": "true"
} }
}</component> }]]></component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" /> <recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
@@ -534,7 +337,16 @@
<workItem from="1776931805422" duration="16122000" /> <workItem from="1776931805422" duration="16122000" />
<workItem from="1777051880577" duration="2650000" /> <workItem from="1777051880577" duration="2650000" />
<workItem from="1777150747725" duration="837000" /> <workItem from="1777150747725" duration="837000" />
<workItem from="1777198150359" duration="638000" /> <workItem from="1777198150359" duration="5628000" />
<workItem from="1777451402985" duration="709000" />
<workItem from="1777534738187" duration="13411000" />
<workItem from="1777970837797" duration="5042000" />
<workItem from="1778160998498" duration="2931000" />
<workItem from="1778319397472" duration="5040000" />
<workItem from="1778352978922" duration="2169000" />
<workItem from="1778414714349" duration="23000" />
<workItem from="1778417425077" duration="3241000" />
<workItem from="1778489168036" duration="7070000" />
</task> </task>
<task id="LOCAL-00001" summary="Initial"> <task id="LOCAL-00001" summary="Initial">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -632,7 +444,23 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1776724331443</updated> <updated>1776724331443</updated>
</task> </task>
<option name="localTasksCounter" value="13" /> <task id="LOCAL-00013" summary="refactor: rename packages and files to use 'flash' prefix for consistency">
<option name="closed" value="true" />
<created>1777199691803</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1777199691804</updated>
</task>
<task id="LOCAL-00014" summary="fix: enhance global key validation and update template parameters">
<option name="closed" value="true" />
<created>1777451475753</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1777451475753</updated>
</task>
<option name="localTasksCounter" value="15" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -674,7 +502,11 @@
<MESSAGE value="preparing for another refactoring..." /> <MESSAGE value="preparing for another refactoring..." />
<MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" /> <MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" />
<MESSAGE value="add core view extension with JTE and Thymeleaf support" /> <MESSAGE value="add core view extension with JTE and Thymeleaf support" />
<option name="LAST_COMMIT_MESSAGE" value="add core view extension with JTE and Thymeleaf support" /> <MESSAGE value="refactor: rename packages and files to use 'flash' prefix for consistency" />
<MESSAGE value="fix: enhance global key validation and update template parameters" />
<MESSAGE value="chore(release): prepare 2.1.0-SNAPSHOT" />
<MESSAGE value="feat: introduce Spec and Query interfaces with transaction propagation enhancements" />
<option name="LAST_COMMIT_MESSAGE" value="feat: introduce Spec and Query interfaces with transaction propagation enhancements" />
</component> </component>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />
+24
View File
@@ -0,0 +1,24 @@
config:
target: "ws://localhost:8080/echo"
engines:
ws: {}
phases:
- duration: 30
arrivalRate: 50
rampTo: 500
name: "Riscaldamento progressivo"
- duration: 120
arrivalRate: 1000 # 1000 nuovi utenti al secondo
name: "Carico Estremo"
ensure:
maxErrorRate: 5
p99: 150
scenarios:
- name: "Saturazione Totale"
engine: ws
flow:
- loop:
- send: "Benchmark data"
# Rimosso il 'think' per eliminare il limite artificiale di 10msg/s per utente
count: 100 # Ogni utente spara a raffica 100 messaggi senza pause
@@ -0,0 +1,81 @@
# flash-ext-data-core
Core comune per il layer dati di Flash.
## Scopo
Questo modulo definisce il contratto transazionale condiviso tra le implementazioni backend.
Non parla con Hibernate o JDBC direttamente: espone solo astrazioni e un runtime minimale.
## Componenti
- `TxDefinition`: metadata immutabile della transazione.
- `TxStatus`: stato runtime restituito dal manager.
- `TxManager`: contratto per `begin`, `commit`, `rollback`.
- `Tx`: orchestration runtime e stack transazionale per thread.
- `ResourceRegistry`: storage thread-local di risorse e synchronizations.
- `Repository<T, ID>`: base repository auto-transazionale.
- `Spec<T>`: predicato componibile.
- `Query<T>`: oggetto query con spec, sort e paging.
- `SpecBuilder<T>`: DSL fluente per costruire spec tipizzate.
- `RepositorySupport<T, ID>`: helper interno condiviso.
- `TransactionPropagation`: semantica di propagazione.
- `TransactionIsolation`: livello di isolamento.
- `TxSynchronization`: hook lifecycle.
## Modello di esecuzione
Il flusso è:
1. `Tx.call(definition, work)` chiama `TxManager.begin(definition)`.
2. Il `TxManager` crea un `TxStatus` backend-specific.
3. Lo status viene pushato nello stack thread-local.
4. Il lavoro usa `Tx.resource(Class)` per ottenere la risorsa corrente.
5. A fine lavoro `Tx` decide tra `commit` e `rollback`.
6. Lo stack viene poppato e il thread-local viene pulito se vuoto.
## Propagation supportata
- `REQUIRED`: usa la tx attiva oppure ne apre una nuova.
- `REQUIRES_NEW`: sospende la tx corrente e apre una nuova tx.
- `SUPPORTS`: se esiste una tx attiva si aggancia, altrimenti esegue senza tx.
- `NOT_SUPPORTED`: sospende la tx corrente ed esegue senza tx.
- `MANDATORY`: richiede una tx attiva.
## Uso di `Repository`
`Repository` è la base comune per le repository concrete.
Ogni operazione pubblica usa internamente una tx `REQUIRED` o `REQUIRED` read-only.
Le sottoclassi implementano i metodi `doXxx(...)` del nuovo modello:
- `doFind(Query<T>)`
- `doFindOne(Spec<T>)`
- `doFindPage(Query<T>)`
- `doDeleteAll(Spec<T>)`
- `doUpdateAll(Spec<T>, T)`
I vecchi overload di `findAll(...)` e `findPage(...)` sono stati ridotti a una combinazione di `Query<T>` e `Spec<T>`.
```java
public abstract class Repository<T, ID> {
protected Repository(Tx tx) { ... }
protected final <R> R tx(Tx.TxCallable<R> work) { ... }
}
```
## Composizione con Flash
`DataExtension` registra:
- `Tx` nel `FlashContext`
- `TxManager` nel `FlashContext`
- un annotation processor per `@Transactional`
Questo rende il layer dati componibile con il sistema di extension di Flash senza stato globale.
## Note implementative
- Lo stack transazionale è thread-local e viene ripulito quando torna vuoto.
- Le risorse backend sono sospese e ripristinate per `REQUIRES_NEW` e `NOT_SUPPORTED`.
- `TxSynchronization` è il punto di aggancio per hook di commit/rollback/completion.
@@ -7,7 +7,6 @@ import dev.relism.flash.ext.data.core.TransactionPropagation;
import dev.relism.flash.extension.ExtensionPhase; import dev.relism.flash.extension.ExtensionPhase;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@@ -16,14 +15,16 @@ import java.util.Objects;
public final class DataExtension implements FlashExtension { public final class DataExtension implements FlashExtension {
private final TxManager txManager; private final TxManager txManager;
private final Tx tx;
public DataExtension(TxManager txManager) { public DataExtension(TxManager txManager) {
this.txManager = Objects.requireNonNull(txManager); this.txManager = Objects.requireNonNull(txManager);
this.tx = new Tx(txManager);
} }
@Override @Override
public void provide(FlashContext ctx) { public void provide(FlashContext ctx) {
Tx.init(txManager); ctx.provide(Tx.class, tx);
ctx.provide(TxManager.class, txManager); ctx.provide(TxManager.class, txManager);
ctx.addAnnotationProcessor(handlerClass -> { ctx.addAnnotationProcessor(handlerClass -> {
Transactional ann = handlerClass.getAnnotation(Transactional.class); Transactional ann = handlerClass.getAnnotation(Transactional.class);
@@ -33,7 +34,7 @@ public final class DataExtension implements FlashExtension {
TxDefinition definition = TxDefinition.DEFAULTS TxDefinition definition = TxDefinition.DEFAULTS
.withPropagation(mapTxType(ann.value())); .withPropagation(mapTxType(ann.value()));
Middleware middleware = next -> (req, res) -> { Middleware middleware = next -> (req, res) -> {
return Tx.call(definition, () -> next.handle(req, res)); return tx.call(definition, () -> next.handle(req, res));
}; };
return List.of(middleware); return List.of(middleware);
}); });
@@ -46,8 +47,9 @@ public final class DataExtension implements FlashExtension {
private TransactionPropagation mapTxType(Transactional.TxType txType) { private TransactionPropagation mapTxType(Transactional.TxType txType) {
return switch (txType) { return switch (txType) {
case REQUIRED, SUPPORTS -> TransactionPropagation.REQUIRED; case REQUIRED -> TransactionPropagation.REQUIRED;
case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW; case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW;
case SUPPORTS -> TransactionPropagation.SUPPORTS;
case MANDATORY -> TransactionPropagation.MANDATORY; case MANDATORY -> TransactionPropagation.MANDATORY;
case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED; case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED;
}; };
@@ -0,0 +1,28 @@
package dev.relism.flash.ext.data.core;
public record Query<T>(Spec<T> spec, Sort sort, Integer page, Integer size) {
public Query {
spec = spec == null ? Spec.all() : spec;
sort = sort == null ? Sort.unsorted() : sort;
}
public static <T> Query<T> all() {
return new Query<>(Spec.all(), Sort.unsorted(), null, null);
}
public Query<T> where(Spec<T> spec) {
return new Query<>(spec, sort, page, size);
}
public Query<T> orderBy(Sort sort) {
return new Query<>(spec, sort, page, size);
}
public Query<T> page(int page, int size) {
return new Query<>(spec, sort, page, size);
}
public boolean isPaged() {
return page != null && size != null;
}
}
@@ -4,109 +4,118 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/** public abstract class Repository<T, ID> extends RepositorySupport<T, ID> {
* Base repository. Subclasses only extend this — never HibernateRepository
* or JdbcRepository directly. The concrete backing is transparent.
*
* Every method auto-wraps in REQUIRED transaction — safe to call with or
* without an active transaction on the thread.
*/
public abstract class Repository<T, ID> {
private final TxDefinition required = TxDefinition.DEFAULTS protected Repository(Tx tx) {
.withPropagation(TransactionPropagation.REQUIRED); super(tx);
}
// ── CRUD ──────────────────────────────────────────────────────────────────
public Optional<T> findById(ID id) { public Optional<T> findById(ID id) {
return tx(() -> doFindById(id)); return roQuery(() -> doFindById(id));
}
public List<T> findAll() {
return tx(this::doFindAll);
}
public List<T> findAll(int page, int size) {
return tx(() -> doFindAll(page, size));
}
public List<T> findAll(Sort sort) {
return tx(() -> doFindAll(sort));
}
public List<T> findAll(int page, int size, Sort sort) {
return tx(() -> doFindAll(page, size, sort));
}
public Page<T> findPage(int page, int size) {
return tx(() -> doFindPage(page, size));
}
public Page<T> findPage(int page, int size, Sort sort) {
return tx(() -> doFindPage(page, size, sort));
}
public T save(T entity) {
return tx(() -> doSave(entity));
}
public List<T> saveAll(Iterable<T> entities) {
return tx(() -> {
List<T> saved = new ArrayList<>();
for (T e : entities) saved.add(doSave(e));
return saved;
});
}
public T update(T entity) {
return tx(() -> doUpdate(entity));
}
public void delete(T entity) {
tx(() -> { doDelete(entity); return null; });
}
public void deleteById(ID id) {
tx(() -> { doDeleteById(id); return null; });
}
public void deleteAll(Iterable<T> entities) {
tx(() -> { entities.forEach(this::doDelete); return null; });
} }
public boolean existsById(ID id) { public boolean existsById(ID id) {
return tx(() -> doExistsById(id)); return roQuery(() -> doExistsById(id));
} }
public long count() { public long count() {
return tx(this::doCount); return roQuery(this::doCount);
} }
// ── Auto-wrap helper ────────────────────────────────────────────────────── public List<T> findAll() {
return findAll(Query.all());
/**
* Ensures the work runs inside a transaction.
* If one is already active (caller annotated @Transactional or inside Tx.run)
* it joins it — no new connection opened.
* If none is active it opens one, commits, and closes it transparently.
*/
protected final <R> R tx(Tx.TxCallable<R> work) {
return Tx.call(required, work);
} }
// ── Abstract — implemented by HibernateRepository / JdbcRepository ──────── public List<T> findAll(Spec<T> spec) {
return findAll(Query.<T>all().where(spec));
}
public List<T> findAll(Query<T> query) {
return roQuery(() -> doFind(query));
}
public Page<T> findPage(Query<T> query) {
return roQuery(() -> doFindPage(query));
}
public Optional<T> findOne(Spec<T> spec) {
return roQuery(() -> doFindOne(spec));
}
public T save(T entity) {
return rwQuery(() -> doSave(entity));
}
public T update(T entity) {
return rwQuery(() -> doUpdate(entity));
}
public List<T> saveAll(Iterable<T> entities) {
return rwQuery(() -> doSaveAll(entities));
}
public void delete(T entity) {
rwQuery(() -> {
doDelete(entity);
return null;
});
}
public void deleteById(ID id) {
rwQuery(() -> {
doDeleteById(id);
return null;
});
}
public int deleteAll(Spec<T> spec) {
return rwQuery(() -> doDeleteAll(spec));
}
public int updateAll(Spec<T> spec, T patch) {
return rwQuery(() -> doUpdateAll(spec, patch));
}
public List<T> findAll(int page, int size) {
return findAll(Query.<T>all().page(page, size));
}
public List<T> findAll(Sort sort) {
return findAll(Query.<T>all().orderBy(sort));
}
public List<T> findAll(int page, int size, Sort sort) {
return findAll(Query.<T>all().orderBy(sort).page(page, size));
}
public Page<T> findPage(int page, int size) {
return findPage(Query.<T>all().page(page, size));
}
public Page<T> findPage(int page, int size, Sort sort) {
return findPage(Query.<T>all().orderBy(sort).page(page, size));
}
public void deleteAll(Iterable<T> entities) {
rwQuery(() -> {
for (T entity : entities) {
doDelete(entity);
}
return null;
});
}
protected abstract Optional<T> doFindById(ID id); protected abstract Optional<T> doFindById(ID id);
protected abstract List<T> doFindAll(); protected abstract List<T> doFind(Query<T> query);
protected abstract List<T> doFindAll(int page, int size); protected abstract Optional<T> doFindOne(Spec<T> spec);
protected abstract List<T> doFindAll(Sort sort); protected abstract Page<T> doFindPage(Query<T> query);
protected abstract List<T> doFindAll(int page, int size, Sort sort); protected abstract boolean doExistsById(ID id);
protected abstract Page<T> doFindPage(int page, int size); protected abstract long doCount();
protected abstract Page<T> doFindPage(int page, int size, Sort sort);
protected abstract T doSave(T entity); protected abstract T doSave(T entity);
protected abstract List<T> doSaveAll(Iterable<T> entities);
protected abstract T doUpdate(T entity); protected abstract T doUpdate(T entity);
protected abstract void doDelete(T entity); protected abstract void doDelete(T entity);
protected abstract void doDeleteById(ID id); protected abstract void doDeleteById(ID id);
protected abstract boolean doExistsById(ID id); protected abstract int doDeleteAll(Spec<T> spec);
protected abstract long doCount(); protected abstract int doUpdateAll(Spec<T> spec, T patch);
} }
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.data.core;
public abstract class RepositorySupport<T, ID> {
private final Tx tx;
private final TxDefinition rw = TxDefinition.DEFAULTS
.withPropagation(TransactionPropagation.REQUIRED);
private final TxDefinition ro = rw.asReadOnly();
protected RepositorySupport(Tx tx) {
this.tx = tx;
}
protected final Tx tx() {
return tx;
}
protected final <R> R roQuery(Tx.TxCallable<R> work) {
return tx.call(ro, work);
}
protected final <R> R rwQuery(Tx.TxCallable<R> work) {
return tx.call(rw, work);
}
}
@@ -31,6 +31,11 @@ public final class ResourceRegistry {
SYNCHRONIZATIONS.get().clear(); SYNCHRONIZATIONS.get().clear();
} }
public static void cleanup() {
RESOURCES.remove();
SYNCHRONIZATIONS.remove();
}
public static <R> R get(TxResourceKey key, Class<R> type) { public static <R> R get(TxResourceKey key, Class<R> type) {
Object value = RESOURCES.get().get(key); Object value = RESOURCES.get().get(key);
if (value == null) { if (value == null) {
@@ -7,6 +7,10 @@ public record Sort(List<Column> columns) {
public record Column(String column, boolean asc) {} public record Column(String column, boolean asc) {}
public static Sort unsorted() { return new Sort(List.of()); }
public boolean isSorted() { return !columns.isEmpty(); }
public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); } public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); }
public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); } public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); }
public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); } public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); }
@@ -0,0 +1,26 @@
package dev.relism.flash.ext.data.core;
@FunctionalInterface
public interface Spec<T> {
String toFragment(SpecContext ctx);
default Spec<T> and(Spec<T> other) {
return ctx -> "(" + this.toFragment(ctx) + " AND " + other.toFragment(ctx) + ")";
}
default Spec<T> or(Spec<T> other) {
return ctx -> "(" + this.toFragment(ctx) + " OR " + other.toFragment(ctx) + ")";
}
default Spec<T> not() {
return ctx -> "NOT (" + this.toFragment(ctx) + ")";
}
static <T> Spec<T> all() {
return ctx -> "1=1";
}
static <T> Spec<T> none() {
return ctx -> "1=0";
}
}
@@ -0,0 +1,37 @@
package dev.relism.flash.ext.data.core;
import java.util.Collection;
import java.util.Objects;
import java.util.stream.Collectors;
public final class SpecBuilder<T> {
private SpecBuilder() {}
public static <T, V> FieldSpec<T, V> field(String column) {
return new FieldSpec<>(column);
}
public static final class FieldSpec<T, V> {
private final String column;
private FieldSpec(String column) {
this.column = Objects.requireNonNull(column);
}
public Spec<T> eq(V value) { return ctx -> column + " = " + ctx.bind(value); }
public Spec<T> neq(V value) { return ctx -> column + " != " + ctx.bind(value); }
public Spec<T> like(String pattern) { return ctx -> column + " like " + ctx.bind(pattern); }
public Spec<T> isNull() { return ctx -> column + " is null"; }
public Spec<T> isNotNull() { return ctx -> column + " is not null"; }
public Spec<T> in(Collection<V> values) {
return ctx -> column + " in (" + values.stream().map(ctx::bind).collect(Collectors.joining(", ")) + ")";
}
public <C extends Comparable<C>> Spec<T> gt(C value) { return ctx -> column + " > " + ctx.bind(value); }
public <C extends Comparable<C>> Spec<T> lt(C value) { return ctx -> column + " < " + ctx.bind(value); }
public <C extends Comparable<C>> Spec<T> between(C lo, C hi) {
return ctx -> column + " between " + ctx.bind(lo) + " and " + ctx.bind(hi);
}
}
}
@@ -0,0 +1,5 @@
package dev.relism.flash.ext.data.core;
public interface SpecContext {
String bind(Object value);
}
@@ -3,6 +3,7 @@ package dev.relism.flash.ext.data.core;
public enum TransactionPropagation { public enum TransactionPropagation {
REQUIRED, REQUIRED,
REQUIRES_NEW, REQUIRES_NEW,
SUPPORTS,
NOT_SUPPORTED, NOT_SUPPORTED,
MANDATORY MANDATORY
} }
@@ -2,83 +2,78 @@ package dev.relism.flash.ext.data.core;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.Deque; import java.util.Deque;
import java.util.Objects;
public final class Tx { public final class Tx {
private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK = private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK =
ThreadLocal.withInitial(ArrayDeque::new); ThreadLocal.withInitial(ArrayDeque::new);
private static volatile TxManager manager; private final TxManager manager;
private Tx() {} public Tx(TxManager txManager) {
this.manager = Objects.requireNonNull(txManager);
public static void init(TxManager txManager) {
if (manager != null) {
throw new IllegalStateException("TxManager already initialized");
}
manager = txManager;
} }
public static void run(TxRunnable work) { public void run(TxRunnable work) {
run(TxDefinition.DEFAULTS, work); run(TxDefinition.DEFAULTS, work);
} }
public static void run(TxDefinition definition, TxRunnable work) { public void run(TxDefinition definition, TxRunnable work) {
call(definition, () -> { call(definition, () -> {
work.run(); work.run();
return null; return null;
}); });
} }
public static <T> T call(TxCallable<T> work) { public <T> T call(TxCallable<T> work) {
return call(TxDefinition.DEFAULTS, work); return call(TxDefinition.DEFAULTS, work);
} }
public static <T> T call(TxDefinition definition, TxCallable<T> work) { public <T> T call(TxDefinition definition, TxCallable<T> work) {
TxStatus status = manager().begin(definition); TxStatus status = manager.begin(definition);
pushStatus(status); pushStatus(status);
try { try {
T result = work.call(); T result = work.call();
if (status.isRollbackOnly()) { if (status.isRollbackOnly()) {
manager().rollback(status); manager.rollback(status);
} else { } else {
manager().commit(status); manager.commit(status);
} }
return result; return result;
} catch (Exception e) { } catch (Exception e) {
manager().rollback(status); silentRollback(status);
throw (e instanceof TxException txException) ? txException : new TxException(e); throw (e instanceof TxException txException) ? txException : new TxException(e);
} catch (Throwable t) {
silentRollback(status);
throw sneakyThrow(t);
} finally { } finally {
popStatus(); popStatus();
if (STATUS_STACK.get().isEmpty()) {
STATUS_STACK.remove();
}
} }
} }
public static boolean isActive() { public boolean isActive() {
return !STATUS_STACK.get().isEmpty(); return !STATUS_STACK.get().isEmpty();
} }
public static void setRollbackOnly() { public void setRollbackOnly() {
currentStatus().markRollbackOnly(); currentStatus().markRollbackOnly();
} }
public static <R> R resource(Class<R> type) { public <R> R resource(Class<R> type) {
return currentStatus().resource(type); return currentStatus().resource(type);
} }
public static TxDefinition requiresNew() { public TxDefinition requiresNew() {
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW); return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
} }
public static TxDefinition readOnly() { public TxDefinition readOnly() {
return TxDefinition.DEFAULTS.asReadOnly(); return TxDefinition.DEFAULTS.asReadOnly();
} }
private static TxManager manager() { private TxStatus currentStatus() {
if (manager == null) {
throw new IllegalStateException("No TxManager installed");
}
return manager;
}
private static TxStatus currentStatus() {
TxStatus status = STATUS_STACK.get().peek(); TxStatus status = STATUS_STACK.get().peek();
if (status == null) { if (status == null) {
throw new IllegalStateException("No active transaction"); throw new IllegalStateException("No active transaction");
@@ -86,17 +81,29 @@ public final class Tx {
return status; return status;
} }
private static void pushStatus(TxStatus status) { private void pushStatus(TxStatus status) {
STATUS_STACK.get().push(status); STATUS_STACK.get().push(status);
} }
private static void popStatus() { private void popStatus() {
Deque<TxStatus> stack = STATUS_STACK.get(); Deque<TxStatus> stack = STATUS_STACK.get();
if (!stack.isEmpty()) { if (!stack.isEmpty()) {
stack.pop(); stack.pop();
} }
} }
private void silentRollback(TxStatus status) {
try {
manager.rollback(status);
} catch (Exception ignored) {
}
}
@SuppressWarnings("unchecked")
private static <E extends Throwable> RuntimeException sneakyThrow(Throwable t) throws E {
throw (E) t;
}
@FunctionalInterface @FunctionalInterface
public interface TxRunnable { public interface TxRunnable {
void run(); void run();
@@ -0,0 +1,92 @@
# flash-ext-data-hibernate
Backend Hibernate per `flash-ext-data-core`.
## Scopo
Questo modulo implementa `TxManager` sopra `SessionFactory` e fornisce una base repository Hibernate-centric.
## Come si usa
### 1. Creare il manager
```java
SessionFactory sessionFactory = ...;
HibernateTxManager txManager = new HibernateTxManager(sessionFactory);
DataExtension extension = new DataExtension(txManager);
```
### 2. Installare lestensione in Flash
Lestensione registra `Tx` e `TxManager` nel `FlashContext`.
Le handler class-based annotate con `@Transactional` vengono wrappate automaticamente.
### 3. Definire una repository
```java
public final class UserRepository extends HibernateRepository<User, Long> {
public UserRepository(Tx tx) {
super(tx, User.class);
}
}
```
Con il nuovo modello query/spec puoi esporre campi riusabili come costanti:
```java
public final class UserRepository extends HibernateRepository<User, Long> {
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("u.email");
public static final SpecBuilder.FieldSpec<User, Boolean> ACTIVE = SpecBuilder.field("u.active");
public UserRepository(Tx tx) {
super(tx, User.class);
}
public Optional<User> findByEmail(String email) {
return findOne(EMAIL.eq(email));
}
}
```
Le query domain-specific possono usare gli helper della base class:
```java
public List<User> findByEmailDomain(String domain) {
return findMany("from User u where u.email like :email", q ->
q.setParameter("email", "%@" + domain)
);
}
```
## Come funziona sotto
- La tx corrente è rappresentata da `HibernateTxStatus`.
- La risorsa esposta al core è una `Session`.
- `Tx.resource(Session.class)` recupera la `Session` dal contesto corrente.
- `REQUIRES_NEW` sospende lo status attivo e apre una nuova `Session`.
- `NOT_SUPPORTED` sospende la tx attiva e continua senza sessione bindata.
## Repository base
`HibernateRepository` fornisce:
- `findById`, `findAll`, `findPage`, `findOne`
- `save`, `update`, `delete`, `saveAll`
- bulk `deleteAll(Spec<T>)` e `updateAll(Spec<T>, T)`
- helper HQL: `hql(...)`, `hqlMutate(...)`
Le classi concrete devono solo implementare query di dominio, non il plumbing transazionale.
## Semantica transazionale
- `REQUIRED`: join o apertura nuova tx.
- `REQUIRES_NEW`: sospensione del contesto corrente.
- `SUPPORTS`: join se c’è tx, altrimenti no-op.
- `NOT_SUPPORTED`: sospende e prosegue senza tx.
- `MANDATORY`: fallisce se non c’è tx.
## Note
- `Session` viene chiusa a fine tx nuova.
- Le synchronizations vengono eseguite al commit/rollback.
- Il backend è pensato per essere usato tramite la base class, non direttamente.
@@ -1,79 +1,74 @@
package dev.relism.flash.ext.data.hibernate; package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.*; import dev.relism.flash.ext.data.core.*;
import jakarta.persistence.TypedQuery;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.query.MutationQuery; import org.hibernate.query.MutationQuery;
import jakarta.persistence.TypedQuery;
import java.io.Serializable; import java.io.Serializable;
import java.util.*; import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** public abstract class HibernateRepository<T, ID extends Serializable> extends Repository<T, ID> {
* Hibernate-backed repository base.
* Never extend this directly — extend {@link Repository} from the core.
* This class is instantiated internally by flash-ext-data-hibernate.
*/
public abstract class HibernateRepository<T, ID extends Serializable>
extends Repository<T, ID> {
private final Class<T> type; private final Class<T> type;
protected HibernateRepository(Class<T> type) { protected HibernateRepository(Tx tx, Class<T> type) {
super(tx);
this.type = type; this.type = type;
} }
// ── Session — always safe, tx() wrapper guarantees active transaction ─────
protected Session session() { protected Session session() {
return Tx.resource(Session.class); return tx().resource(Session.class);
} }
// ── Repository abstract impl ──────────────────────────────────────────────
@Override @Override
protected Optional<T> doFindById(ID id) { protected Optional<T> doFindById(ID id) {
return Optional.ofNullable(session().get(type, id)); return Optional.ofNullable(session().get(type, id));
} }
@Override @Override
protected List<T> doFindAll() { protected List<T> doFind(Query<T> query) {
return hql("from " + type.getSimpleName()).getResultList(); HibernateSpecContext ctx = new HibernateSpecContext();
String where = query.spec() != null ? " where " + query.spec().toFragment(ctx) : "";
String order = query.sort() != null && query.sort().isSorted() ? " order by " + orderClause(query.sort()) : "";
TypedQuery<T> q = session().createQuery("from " + type.getSimpleName() + where + order, type);
ctx.applyParameters(q);
if (query.isPaged()) {
q.setFirstResult(query.page() * query.size());
q.setMaxResults(query.size());
}
return q.getResultList();
} }
@Override @Override
protected List<T> doFindAll(int page, int size) { protected Optional<T> doFindOne(Spec<T> spec) {
return hql("from " + type.getSimpleName()) return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
} }
@Override @Override
protected List<T> doFindAll(Sort sort) { protected Page<T> doFindPage(Query<T> query) {
return hql("from " + type.getSimpleName() + orderClause(sort)) if (!query.isPaged()) {
.getResultList(); throw new IllegalArgumentException("Paged query requires page and size");
}
long total = countWhere(query.spec());
List<T> content = doFind(query);
return new Page<>(content, query.page(), query.size(), total);
} }
@Override @Override
protected List<T> doFindAll(int page, int size, Sort sort) { protected boolean doExistsById(ID id) {
return hql("from " + type.getSimpleName() + orderClause(sort)) return doFindById(id).isPresent();
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
} }
@Override @Override
protected Page<T> doFindPage(int page, int size) { protected long doCount() {
long total = doCount(); return countWhere(Spec.all());
return new Page<>(doFindAll(page, size), page, size, total);
}
@Override
protected Page<T> doFindPage(int page, int size, Sort sort) {
long total = doCount();
return new Page<>(doFindAll(page, size, sort), page, size, total);
} }
@Override @Override
@@ -82,6 +77,22 @@ public abstract class HibernateRepository<T, ID extends Serializable>
return entity; return entity;
} }
@Override
protected List<T> doSaveAll(Iterable<T> entities) {
List<T> saved = new ArrayList<>();
Session s = session();
int i = 0;
for (T entity : entities) {
s.persist(entity);
saved.add(entity);
if (++i % 50 == 0) {
s.flush();
s.clear();
}
}
return saved;
}
@Override @Override
protected T doUpdate(T entity) { protected T doUpdate(T entity) {
return session().merge(entity); return session().merge(entity);
@@ -99,66 +110,37 @@ public abstract class HibernateRepository<T, ID extends Serializable>
} }
@Override @Override
protected boolean doExistsById(ID id) { protected int doDeleteAll(Spec<T> spec) {
return doFindById(id).isPresent(); HibernateSpecContext ctx = new HibernateSpecContext();
String where = " where " + spec.toFragment(ctx);
MutationQuery q = session().createMutationQuery("delete from " + type.getSimpleName() + where);
ctx.applyParameters(q);
return q.executeUpdate();
} }
@Override @Override
protected long doCount() { protected int doUpdateAll(Spec<T> spec, T patch) {
return session() throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
.createQuery("select count(*) from " + type.getSimpleName(), Long.class)
.uniqueResultOptional()
.orElse(0L);
} }
// ── Query helpers — usabili nelle sottoclassi domain ───────────────────── protected List<T> hql(String hql, Consumer<TypedQuery<T>> params) {
return roQuery(() -> {
protected TypedQuery<T> hql(String hql) { TypedQuery<T> q = session().createQuery(hql, type);
return session().createQuery(hql, type);
}
protected <R> TypedQuery<R> hql(String hql, Class<R> resultType) {
return session().createQuery(hql, resultType);
}
protected Optional<T> findOne(String hql, Consumer<TypedQuery<T>> params) {
TypedQuery<T> q = hql(hql);
params.accept(q);
return q.getResultStream().findFirst();
}
protected List<T> findMany(String hql, Consumer<TypedQuery<T>> params) {
return tx(() -> {
TypedQuery<T> q = hql(hql);
params.accept(q); params.accept(q);
return q.getResultList(); return q.getResultList();
}); });
} }
protected List<T> findMany(String hql, Consumer<TypedQuery<T>> params, protected <R> List<R> hql(String hql, Class<R> resultType, Consumer<TypedQuery<R>> params) {
int page, int size) { return roQuery(() -> {
return tx(() -> { TypedQuery<R> q = session().createQuery(hql, resultType);
TypedQuery<T> q = hql(hql);
params.accept(q); params.accept(q);
return q.setFirstResult(page * size).setMaxResults(size).getResultList(); return q.getResultList();
}); });
} }
protected Page<T> findManyPaged(String hql, String countHql, protected int hqlMutate(String hql, Consumer<MutationQuery> params) {
Consumer<TypedQuery<T>> params, return rwQuery(() -> {
int page, int size) {
return tx(() -> {
long total = session()
.createQuery(countHql, Long.class)
.uniqueResultOptional()
.orElse(0L);
List<T> content = findMany(hql, params, page, size);
return new Page<>(content, page, size, total);
});
}
protected int execute(String hql, Consumer<MutationQuery> params) {
return tx(() -> {
MutationQuery q = session().createMutationQuery(hql); MutationQuery q = session().createMutationQuery(hql);
params.accept(q); params.accept(q);
return q.executeUpdate(); return q.executeUpdate();
@@ -169,8 +151,16 @@ public abstract class HibernateRepository<T, ID extends Serializable>
return type; return type;
} }
private long countWhere(Spec<T> spec) {
HibernateSpecContext ctx = new HibernateSpecContext();
String where = spec != null ? " where " + spec.toFragment(ctx) : "";
TypedQuery<Long> q = session().createQuery("select count(*) from " + type.getSimpleName() + where, Long.class);
ctx.applyParameters(q);
return q.getResultStream().findFirst().orElse(0L);
}
private String orderClause(Sort sort) { private String orderClause(Sort sort) {
return " order by " + sort.columns().stream() return sort.columns().stream()
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC")) .map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
} }
@@ -0,0 +1,28 @@
package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.SpecContext;
import jakarta.persistence.TypedQuery;
import org.hibernate.query.MutationQuery;
import java.util.LinkedHashMap;
import java.util.Map;
final class HibernateSpecContext implements SpecContext {
private final Map<String, Object> params = new LinkedHashMap<>();
private int counter;
@Override
public String bind(Object value) {
String name = "p" + (++counter);
params.put(name, value);
return ":" + name;
}
void applyParameters(TypedQuery<?> query) {
params.forEach(query::setParameter);
}
void applyParameters(MutationQuery query) {
params.forEach(query::setParameter);
}
}
@@ -8,6 +8,7 @@ import java.util.Objects;
public class HibernateTxManager implements TxManager { public class HibernateTxManager implements TxManager {
private static final TxResourceKey HIBERNATE_STATUS_KEY = TxResourceKey.of("hibernate.tx.status"); private static final TxResourceKey HIBERNATE_STATUS_KEY = TxResourceKey.of("hibernate.tx.status");
private static final TxResourceKey HIBERNATE_SUSPENDED_KEY = TxResourceKey.of("hibernate.tx.suspended");
private final SessionFactory sf; private final SessionFactory sf;
@@ -22,21 +23,29 @@ public class HibernateTxManager implements TxManager {
? joinExisting(definition) ? joinExisting(definition)
: beginNew(definition); : beginNew(definition);
case REQUIRES_NEW -> beginNew(definition); case REQUIRES_NEW -> beginNew(definition);
case SUPPORTS -> ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)
? joinExisting(definition)
: noOp(definition);
case MANDATORY -> { case MANDATORY -> {
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)) if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY))
throw new IllegalStateException("MANDATORY: no active transaction"); throw new IllegalStateException("MANDATORY: no active transaction");
yield joinExisting(definition); yield joinExisting(definition);
} }
case NOT_SUPPORTED -> throw new UnsupportedOperationException("NOT_SUPPORTED is not implemented"); case NOT_SUPPORTED -> {
HibernateTxStatus suspended = suspendIfNeeded();
yield noOp(definition, suspended);
}
}; };
} }
private TxStatus beginNew(TxDefinition definition) { private TxStatus beginNew(TxDefinition definition) {
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class); return beginNew(definition, suspendIfNeeded());
if (suspended != null) {
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
} }
private TxStatus beginNew(TxDefinition definition, HibernateTxStatus suspended) {
Session s = sf.openSession(); Session s = sf.openSession();
boolean bound = false;
try {
s.beginTransaction(); s.beginTransaction();
if (definition.readOnly()) s.setDefaultReadOnly(true); if (definition.readOnly()) s.setDefaultReadOnly(true);
if (definition.isolation() != TransactionIsolation.DEFAULT) { if (definition.isolation() != TransactionIsolation.DEFAULT) {
@@ -50,7 +59,20 @@ public class HibernateTxManager implements TxManager {
new HibernateTxStatus.RollbackMarker() new HibernateTxStatus.RollbackMarker()
); );
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status); ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
bound = true;
return status; return status;
} catch (RuntimeException e) {
silentClose(s);
throw e;
} catch (Exception e) {
silentClose(s);
throw new TxException(e);
} finally {
if (!bound && suspended != null) {
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
}
}
} }
private TxStatus joinExisting(TxDefinition definition) { private TxStatus joinExisting(TxDefinition definition) {
@@ -67,10 +89,29 @@ public class HibernateTxManager implements TxManager {
); );
} }
private TxStatus noOp(TxDefinition definition) {
return noOp(definition, null);
}
private TxStatus noOp(TxDefinition definition, HibernateTxStatus suspended) {
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker());
}
private HibernateTxStatus suspendIfNeeded() {
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
if (suspended != null) {
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
ResourceRegistry.bind(HIBERNATE_SUSPENDED_KEY, suspended);
}
return suspended;
}
@Override @Override
public void commit(TxStatus status) { public void commit(TxStatus status) {
HibernateTxStatus s = (HibernateTxStatus) status; HibernateTxStatus s = (HibernateTxStatus) status;
if (!s.isNewTransaction()) { if (!s.isNewTransaction()) {
resumeIfNeeded(s);
cleanupIfIdle();
return; return;
} }
try { try {
@@ -83,6 +124,7 @@ public class HibernateTxManager implements TxManager {
} }
} finally { } finally {
cleanupAndResume(s); cleanupAndResume(s);
cleanupIfIdle();
} }
} }
@@ -91,6 +133,8 @@ public class HibernateTxManager implements TxManager {
HibernateTxStatus s = (HibernateTxStatus) status; HibernateTxStatus s = (HibernateTxStatus) status;
if (!s.isNewTransaction()) { if (!s.isNewTransaction()) {
s.markRollbackOnly(); s.markRollbackOnly();
resumeIfNeeded(s);
cleanupIfIdle();
return; return;
} }
try { try {
@@ -100,15 +144,40 @@ public class HibernateTxManager implements TxManager {
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK); ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
} finally { } finally {
cleanupAndResume(s); cleanupAndResume(s);
cleanupIfIdle();
} }
} }
private void cleanupAndResume(HibernateTxStatus status) { private void cleanupAndResume(HibernateTxStatus status) {
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY); ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
status.session().close(); silentClose(status.session());
resumeIfNeeded(status);
}
private void cleanupIfIdle() {
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY) && !ResourceRegistry.isBound(HIBERNATE_SUSPENDED_KEY)) {
ResourceRegistry.cleanup();
}
}
private void resumeIfNeeded(HibernateTxStatus status) {
HibernateTxStatus suspended = status.suspended(); HibernateTxStatus suspended = status.suspended();
if (suspended == null) {
suspended = ResourceRegistry.getOrNull(HIBERNATE_SUSPENDED_KEY, HibernateTxStatus.class);
}
if (suspended != null) { if (suspended != null) {
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended); ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
} }
} }
private void silentClose(Session session) {
if (session == null) {
return;
}
try {
session.close();
} catch (Exception ignored) {
}
}
} }
@@ -35,6 +35,9 @@ class HibernateTxStatus implements TxStatus {
@Override @Override
public <R> R resource(Class<R> type) { public <R> R resource(Class<R> type) {
if (session == null) {
throw new IllegalStateException("No session bound to this transaction status");
}
return type.cast(session); return type.cast(session);
} }
@@ -0,0 +1,96 @@
# flash-ext-data-jdbc
Backend JDBC per `flash-ext-data-core`.
## Scopo
Questo modulo implementa `TxManager` sopra `DataSource` e fornisce una base repository SQL raw.
## Come si usa
### 1. Creare il manager
```java
DataSource dataSource = ...;
JdbcTxManager txManager = new JdbcTxManager(dataSource);
DataExtension extension = new DataExtension(txManager);
```
### 2. Installare lestensione in Flash
Come per Hibernate, `DataExtension` registra `Tx` nel `FlashContext` e abilita `@Transactional` sugli handler class-based.
### 3. Definire una repository
```java
public final class UserRepository extends JdbcRepository<User, Long> {
public UserRepository(Tx tx) {
super(tx, "users", "id");
}
@Override
protected User mapRow(ResultSet rs) throws SQLException {
return new User(rs.getLong("id"), rs.getString("name"));
}
}
```
Anche qui puoi esporre `Spec` riusabili e comporre query dal service layer:
```java
public final class UserRepository extends JdbcRepository<User, Long> {
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("email");
public UserRepository(Tx tx) {
super(tx, "users", "id");
}
}
```
Per il salvataggio e lupdate devi fornire il binding esplicito:
```java
@Override
protected String insertSql() {
return "insert into users(name) values(?)";
}
@Override
protected void bindInsert(PreparedStatement ps, User entity) throws SQLException {
ps.setString(1, entity.name());
}
```
## Come funziona sotto
- La tx corrente espone una `Connection`.
- `Tx.resource(Connection.class)` recupera la connessione bindata al thread.
- `REQUIRES_NEW` sospende la connessione attiva e ne apre una nuova.
- `NOT_SUPPORTED` sospende il contesto e prosegue senza tx.
## Repository base
`JdbcRepository` fornisce:
- query `select` con `queryOne`, `queryMany`
- mutation con `mutate`
- persistenza con `doSave`, `doUpdate`
- paging con `doFindPage`
- bulk `deleteAll(Spec<T>)`
- helper raw `queryOne(...)`, `queryMany(...)`, `mutate(...)`
Le repository concrete devono solo tradurre tra `ResultSet` e dominio.
## Semantica transazionale
- `REQUIRED`: join o apertura nuova tx.
- `REQUIRES_NEW`: sospensione del contesto corrente.
- `SUPPORTS`: join se c’è tx, altrimenti no-op.
- `NOT_SUPPORTED`: sospende e prosegue senza tx.
- `MANDATORY`: fallisce se non c’è tx.
## Note
- La `Connection` viene chiusa a fine tx nuova.
- Le synchronizations vengono eseguite al commit/rollback.
- Se una repository usa `doDelete(T)`, il comportamento predefinito è non supportato: usare `deleteById` o override specifico.
@@ -3,87 +3,99 @@ package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.*; import dev.relism.flash.ext.data.core.*;
import java.sql.*; import java.sql.*;
import java.util.*; import java.util.ArrayList;
import java.util.stream.Collectors; import java.util.List;
import java.util.Optional;
public abstract class JdbcRepository<T, ID> extends Repository<T, ID> { public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
private final String table; private final String table;
private final String idColumn; private final String idColumn;
protected JdbcRepository(String table, String idColumn) { protected JdbcRepository(Tx tx, String table, String idColumn) {
super(tx);
this.table = table; this.table = table;
this.idColumn = idColumn; this.idColumn = idColumn;
} }
protected Connection connection() { protected Connection connection() {
return Tx.resource(Connection.class); return tx().resource(Connection.class);
} }
// ── Subclass contract ─────────────────────────────────────────────────────
protected abstract T mapRow(ResultSet rs) throws SQLException; protected abstract T mapRow(ResultSet rs) throws SQLException;
protected abstract void bindInsert(PreparedStatement ps, T entity) throws SQLException; protected abstract void bindInsert(PreparedStatement ps, T entity) throws SQLException;
protected abstract void bindUpdate(PreparedStatement ps, T entity) throws SQLException; protected abstract void bindUpdate(PreparedStatement ps, T entity) throws SQLException;
protected abstract String insertSql(); protected abstract String insertSql();
protected abstract String updateSql(); protected abstract String updateSql();
// ── Repository abstract impl ──────────────────────────────────────────────
@Override @Override
protected Optional<T> doFindById(ID id) { protected Optional<T> doFindById(ID id) {
return queryOne("select * from " + table + " where " + idColumn + " = ?", return queryOne("select * from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
ps -> ps.setObject(1, id));
} }
@Override @Override
protected List<T> doFindAll() { protected List<T> doFind(Query<T> query) {
return queryMany("select * from " + table, ps -> {}); JdbcSpecContext ctx = new JdbcSpecContext();
} String where = query.spec() != null ? " where " + query.spec().toFragment(ctx) : "";
String order = query.sort() != null && query.sort().isSorted() ? " order by " + orderClause(query.sort()) : "";
String paging = query.isPaged() ? " limit ? offset ?" : "";
@Override return queryMany("select * from " + table + where + order + paging, ps -> {
protected List<T> doFindAll(int page, int size) { if (query.isPaged()) {
return queryMany("select * from " + table + " limit ? offset ?", ps -> { ctx.applyParameters(ps);
ps.setInt(1, size); int base = ctx.size();
ps.setInt(2, page * size); ps.setInt(base + 1, query.size());
ps.setInt(base + 2, query.page() * query.size());
return;
}
ctx.applyParameters(ps);
}); });
} }
@Override @Override
protected List<T> doFindAll(Sort sort) { protected Optional<T> doFindOne(Spec<T> spec) {
return queryMany("select * from " + table + orderClause(sort), ps -> {}); return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
} }
@Override @Override
protected List<T> doFindAll(int page, int size, Sort sort) { protected Page<T> doFindPage(Query<T> query) {
return queryMany("select * from " + table + orderClause(sort) + " limit ? offset ?", if (!query.isPaged()) {
ps -> { throw new IllegalArgumentException("Paged query requires page and size");
ps.setInt(1, size); }
ps.setInt(2, page * size); long total = countWhere(query.spec());
}); List<T> content = doFind(query);
return new Page<>(content, query.page(), query.size(), total);
} }
@Override @Override
protected Page<T> doFindPage(int page, int size) { protected boolean doExistsById(ID id) {
long total = doCount(); return queryOne("select 1 from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id), rs -> rs.getInt(1)).isPresent();
return new Page<>(doFindAll(page, size), page, size, total);
} }
@Override @Override
protected Page<T> doFindPage(int page, int size, Sort sort) { protected long doCount() {
long total = doCount(); return queryOne("select count(*) from " + table, ps -> {}, rs -> rs.getLong(1)).orElse(0L);
return new Page<>(doFindAll(page, size, sort), page, size, total);
} }
@Override @Override
protected T doSave(T entity) { protected T doSave(T entity) {
try (PreparedStatement ps = connection().prepareStatement( try (PreparedStatement ps = connection().prepareStatement(insertSql(), Statement.RETURN_GENERATED_KEYS)) {
insertSql(), Statement.RETURN_GENERATED_KEYS)) {
bindInsert(ps, entity); bindInsert(ps, entity);
ps.executeUpdate(); ps.executeUpdate();
applyGeneratedKey(ps, entity); applyGeneratedKey(ps, entity);
return entity; return entity;
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
}
@Override
protected List<T> doSaveAll(Iterable<T> entities) {
List<T> saved = new ArrayList<>();
for (T entity : entities) {
saved.add(doSave(entity));
}
return saved;
} }
@Override @Override
@@ -92,7 +104,9 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
bindUpdate(ps, entity); bindUpdate(ps, entity);
ps.executeUpdate(); ps.executeUpdate();
return entity; return entity;
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
} }
@Override @Override
@@ -102,38 +116,35 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
@Override @Override
protected void doDeleteById(ID id) { protected void doDeleteById(ID id) {
mutate("delete from " + table + " where " + idColumn + " = ?", mutate("delete from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
ps -> ps.setObject(1, id));
} }
@Override @Override
protected boolean doExistsById(ID id) { protected int doDeleteAll(Spec<T> spec) {
return queryOne("select 1 from " + table + " where " + idColumn + " = ?", JdbcSpecContext ctx = new JdbcSpecContext();
ps -> ps.setObject(1, id), String where = " where " + spec.toFragment(ctx);
rs -> rs.getInt(1)).isPresent(); return mutate("delete from " + table + where, ctx::applyParameters);
} }
@Override @Override
protected long doCount() { protected int doUpdateAll(Spec<T> spec, T patch) {
return queryOne("select count(*) from " + table, ps -> {}, throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
rs -> rs.getLong(1)).orElse(0L);
} }
// ── Query helpers ─────────────────────────────────────────────────────────
protected Optional<T> queryOne(String sql, SqlBinder params) { protected Optional<T> queryOne(String sql, SqlBinder params) {
List<T> r = queryMany(sql, params); List<T> r = queryMany(sql, params);
return r.isEmpty() ? Optional.empty() : Optional.of(r.get(0)); return r.isEmpty() ? Optional.empty() : Optional.of(r.get(0));
} }
protected <R> Optional<R> queryOne(String sql, SqlBinder params, protected <R> Optional<R> queryOne(String sql, SqlBinder params, SqlMapper<R> mapper) {
SqlMapper<R> mapper) {
try (PreparedStatement ps = connection().prepareStatement(sql)) { try (PreparedStatement ps = connection().prepareStatement(sql)) {
params.bind(ps); params.bind(ps);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty(); return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty();
} }
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
} }
protected List<T> queryMany(String sql, SqlBinder params) { protected List<T> queryMany(String sql, SqlBinder params) {
@@ -144,26 +155,47 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
while (rs.next()) results.add(mapRow(rs)); while (rs.next()) results.add(mapRow(rs));
return results; return results;
} }
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
} }
protected int mutate(String sql, SqlBinder params) { protected int mutate(String sql, SqlBinder params) {
try (PreparedStatement ps = connection().prepareStatement(sql)) { try (PreparedStatement ps = connection().prepareStatement(sql)) {
params.bind(ps); params.bind(ps);
return ps.executeUpdate(); return ps.executeUpdate();
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
} }
protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException { protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException {
// override when entity has a generated PK // override when entity has a generated PK
} }
private String orderClause(Sort sort) { protected Class<T> entityType() {
return " order by " + sort.columns().stream() return null;
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
.collect(Collectors.joining(", "));
} }
@FunctionalInterface public interface SqlBinder { void bind(PreparedStatement ps) throws SQLException; } private long countWhere(Spec<T> spec) {
@FunctionalInterface public interface SqlMapper<R> { R map(ResultSet rs) throws SQLException; } JdbcSpecContext ctx = new JdbcSpecContext();
String where = spec != null ? " where " + spec.toFragment(ctx) : "";
return queryOne("select count(*) from " + table + where, ctx::applyParameters, rs -> rs.getLong(1)).orElse(0L);
}
private String orderClause(Sort sort) {
return sort.columns().stream()
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
.collect(java.util.stream.Collectors.joining(", "));
}
@FunctionalInterface
public interface SqlBinder {
void bind(PreparedStatement ps) throws SQLException;
}
@FunctionalInterface
public interface SqlMapper<R> {
R map(ResultSet rs) throws SQLException;
}
} }
@@ -0,0 +1,28 @@
package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.SpecContext;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
final class JdbcSpecContext implements SpecContext {
private final List<Object> params = new ArrayList<>();
@Override
public String bind(Object value) {
params.add(value);
return "?";
}
void applyParameters(PreparedStatement ps) throws SQLException {
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
}
int size() {
return params.size();
}
}
@@ -9,6 +9,7 @@ import java.util.Objects;
public class JdbcTxManager implements TxManager { public class JdbcTxManager implements TxManager {
private static final TxResourceKey JDBC_STATUS_KEY = TxResourceKey.of("jdbc.tx.status"); private static final TxResourceKey JDBC_STATUS_KEY = TxResourceKey.of("jdbc.tx.status");
private static final TxResourceKey JDBC_SUSPENDED_KEY = TxResourceKey.of("jdbc.tx.suspended");
private final DataSource ds; private final DataSource ds;
@@ -23,22 +24,27 @@ public class JdbcTxManager implements TxManager {
? joinExisting(definition) ? joinExisting(definition)
: beginNew(definition); : beginNew(definition);
case REQUIRES_NEW -> beginNew(definition); case REQUIRES_NEW -> beginNew(definition);
case SUPPORTS -> ResourceRegistry.isBound(JDBC_STATUS_KEY)
? joinExisting(definition)
: noOp(definition);
case MANDATORY -> { case MANDATORY -> {
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY)) if (!ResourceRegistry.isBound(JDBC_STATUS_KEY))
throw new IllegalStateException("MANDATORY: no active transaction"); throw new IllegalStateException("MANDATORY: no active transaction");
yield joinExisting(definition); yield joinExisting(definition);
} }
case NOT_SUPPORTED -> throw new UnsupportedOperationException("NOT_SUPPORTED is not implemented"); case NOT_SUPPORTED -> {
JdbcTxStatus suspended = suspendIfNeeded();
yield noOp(definition, suspended);
}
}; };
} }
private TxStatus beginNew(TxDefinition definition) { private TxStatus beginNew(TxDefinition definition) {
Connection conn = null;
JdbcTxStatus suspended = suspendIfNeeded();
boolean bound = false;
try { try {
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class); conn = ds.getConnection();
if (suspended != null) {
ResourceRegistry.unbind(JDBC_STATUS_KEY);
}
Connection conn = ds.getConnection();
conn.setAutoCommit(false); conn.setAutoCommit(false);
if (definition.readOnly()) conn.setReadOnly(true); if (definition.readOnly()) conn.setReadOnly(true);
if (definition.isolation() != TransactionIsolation.DEFAULT) { if (definition.isolation() != TransactionIsolation.DEFAULT) {
@@ -52,9 +58,16 @@ public class JdbcTxManager implements TxManager {
new JdbcTxStatus.RollbackMarker() new JdbcTxStatus.RollbackMarker()
); );
ResourceRegistry.bind(JDBC_STATUS_KEY, status); ResourceRegistry.bind(JDBC_STATUS_KEY, status);
bound = true;
return status; return status;
} catch (SQLException e) { } catch (SQLException e) {
silentClose(conn);
throw new TxException(e); throw new TxException(e);
} finally {
if (!bound && suspended != null) {
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
}
} }
} }
@@ -72,10 +85,29 @@ public class JdbcTxManager implements TxManager {
); );
} }
private TxStatus noOp(TxDefinition definition) {
return noOp(definition, null);
}
private TxStatus noOp(TxDefinition definition, JdbcTxStatus suspended) {
return new JdbcTxStatus(null, false, definition.readOnly(), suspended, new JdbcTxStatus.RollbackMarker());
}
private JdbcTxStatus suspendIfNeeded() {
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class);
if (suspended != null) {
ResourceRegistry.unbind(JDBC_STATUS_KEY);
ResourceRegistry.bind(JDBC_SUSPENDED_KEY, suspended);
}
return suspended;
}
@Override @Override
public void commit(TxStatus status) { public void commit(TxStatus status) {
JdbcTxStatus s = (JdbcTxStatus) status; JdbcTxStatus s = (JdbcTxStatus) status;
if (!s.isNewTransaction()) { if (!s.isNewTransaction()) {
resumeIfNeeded(s);
cleanupIfIdle();
return; return;
} }
try { try {
@@ -90,6 +122,7 @@ public class JdbcTxManager implements TxManager {
throw new TxException(e); throw new TxException(e);
} finally { } finally {
cleanupAndResume(s); cleanupAndResume(s);
cleanupIfIdle();
} }
} }
@@ -98,6 +131,8 @@ public class JdbcTxManager implements TxManager {
JdbcTxStatus s = (JdbcTxStatus) status; JdbcTxStatus s = (JdbcTxStatus) status;
if (!s.isNewTransaction()) { if (!s.isNewTransaction()) {
s.markRollbackOnly(); s.markRollbackOnly();
resumeIfNeeded(s);
cleanupIfIdle();
return; return;
} }
try { try {
@@ -107,18 +142,45 @@ public class JdbcTxManager implements TxManager {
throw new TxException(e); throw new TxException(e);
} finally { } finally {
cleanupAndResume(s); cleanupAndResume(s);
cleanupIfIdle();
} }
} }
private void cleanupAndResume(JdbcTxStatus status) { private void cleanupAndResume(JdbcTxStatus status) {
ResourceRegistry.unbind(JDBC_STATUS_KEY); ResourceRegistry.unbind(JDBC_STATUS_KEY);
try { try {
if (status.connection() != null) {
status.connection().close(); status.connection().close();
}
} catch (SQLException ignored) { } catch (SQLException ignored) {
} }
resumeIfNeeded(status);
}
private void cleanupIfIdle() {
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY) && !ResourceRegistry.isBound(JDBC_SUSPENDED_KEY)) {
ResourceRegistry.cleanup();
}
}
private void resumeIfNeeded(JdbcTxStatus status) {
JdbcTxStatus suspended = status.suspended(); JdbcTxStatus suspended = status.suspended();
if (suspended == null) {
suspended = ResourceRegistry.getOrNull(JDBC_SUSPENDED_KEY, JdbcTxStatus.class);
}
if (suspended != null) { if (suspended != null) {
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended); ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
} }
} }
private void silentClose(Connection connection) {
if (connection == null) {
return;
}
try {
connection.close();
} catch (SQLException ignored) {
}
}
} }
@@ -37,6 +37,9 @@ class JdbcTxStatus implements TxStatus {
@Override @Override
public <R> R resource(Class<R> type) { public <R> R resource(Class<R> type) {
if (connection == null) {
throw new IllegalStateException("No connection bound to this transaction status");
}
return type.cast(connection); return type.cast(connection);
} }
@@ -12,6 +12,7 @@ import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response; import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET; import dev.relism.flash.routing.GET;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.websocket.WebSocketEndpoint;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.ArrayList; import java.util.ArrayList;
@@ -165,6 +166,11 @@ class OpenApiExtensionTest {
routes.put(method.name() + " " + path, handler); routes.put(method.name() + " " + path, handler);
} }
@Override
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
}
@Override @Override
protected void addMiddleware(Middleware mw) { protected void addMiddleware(Middleware mw) {
middlewares.add(mw); middlewares.add(mw);
@@ -5,6 +5,7 @@ import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response; import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET; import dev.relism.flash.routing.GET;
import dev.relism.flash.websocket.WebSocketEndpoint;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -172,6 +173,11 @@ class JteExtensionTest {
routes.put(method.name() + " " + path, handler); routes.put(method.name() + " " + path, handler);
} }
@Override
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
}
@Override @Override
protected void addMiddleware(dev.relism.flash.routing.Middleware mw) { protected void addMiddleware(dev.relism.flash.routing.Middleware mw) {
mws.add(mw); mws.add(mw);
@@ -1,14 +1,19 @@
package dev.relism.flash; package dev.relism.flash;
import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response; import dev.relism.flash.models.Response;
import dev.relism.fpr.core.ByteView;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter; import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import dev.relism.fpr.core.ByteView;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -17,33 +22,72 @@ import java.net.InetSocketAddress;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
/** /**
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread * Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
* executor, and the keep-alive accept loop. Routing is delegated to a single * executor, and the keep-alive accept loop. Routing is delegated to HTTP and WS routers.
* {@link AbstractRouter}.
* *
* <p>Package-private : use {@link FlashApp} as the single * <h3>Allocation model (unchanged)</h3>
* entry point. * <ul>
* <li>{@code LONG_BUF} (20 bytes) is the only {@link ThreadLocal} kept here.</li>
* <li>WS handshake SHA-1: {@link ThreadLocal}&lt;{@link MessageDigest}&gt; — one per
* accept thread (there are now {@code ACCEPT_THREADS} of them, not one).</li>
* </ul>
*/ */
@Slf4j @Slf4j
class HttpServer implements ServerHandle { class HttpServer implements ServerHandle {
// ── Tuning constants ──────────────────────────────────────────────────────
/**
* Number of platform threads competing on {@code serverSocket.accept()}.
* Rule of thumb: number of available CPU cores, capped at 8.
* More than this rarely helps — accept is cheap; the bottleneck is usually
* the virtual-thread executor dispatching the connection handler.
*/
private static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8);
/**
* TCP listen backlog. The kernel holds up to this many fully-established
* (SYN+ACK sent, ACK received) connections waiting for accept().
* 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value,
* or the kernel silently caps it. Raise somaxconn if needed:
* sysctl -w net.core.somaxconn=4096
*/
private static final int ACCEPT_BACKLOG = 4096;
/**
* Socket send/receive buffer sizes. Matched to the WS frame read buffer
* ({@link FlashConfiguration#getWsFrameBufferSize()}) so the kernel never
* needs to fragment a full frame into multiple TCP segments on the receive
* side, and never blocks a write waiting for the send buffer to drain.
*
* Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both
* to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads.
*/
private static final int SOCKET_BUF_SIZE = 256 * 1024;
// ── Instance fields ───────────────────────────────────────────────────────
private final FlashConfiguration configuration; private final FlashConfiguration configuration;
private final ServerSocket serverSocket; private final ServerSocket serverSocket;
private final AbstractRouter router; private final AbstractRouter router;
private final AbstractWsRouter wsRouter;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet(); private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false; private volatile boolean stopped = false;
private final AtomicReference<Thread> acceptThread = new AtomicReference<>();
/** Latch that reaches 0 when all accept threads have exited. */
private final CountDownLatch acceptLatch = new CountDownLatch(ACCEPT_THREADS);
// ── Static byte constants (written once, read-only on hot path) ──────────
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8); private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8); private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
@@ -55,36 +99,87 @@ class HttpServer implements ServerHandle {
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8); private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8); private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
// Per-thread scratch buffers — allocated once per VT, reused for every request. private static final byte[] WS_HANDSHAKE_PREFIX =
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]); ("HTTP/1.1 101 Switching Protocols\r\n" +
private static final ThreadLocal<byte[]> CHUNK_BUF = ThreadLocal.withInitial(() -> new byte[8192]); "Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: ")
.getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_HANDSHAKE_SUFFIX =
"\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_REJECT_400 =
"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.ISO_8859_1);
HttpServer(FlashConfiguration configuration, AbstractRouter router) throws IOException { private static final byte[] WS_GUID_BYTES =
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
private static final ThreadLocal<MessageDigest> SHA1 =
ThreadLocal.withInitial(() -> {
try { return MessageDigest.getInstance("SHA-1"); }
catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); }
});
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
private static final int SHA1_LEN = 20;
private static final int WS_ACCEPT_LEN = 28;
// ── Constructor ───────────────────────────────────────────────────────────
HttpServer(FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter) throws IOException {
this.configuration = configuration; this.configuration = configuration;
this.serverSocket = new ServerSocket(configuration.getPort());
this.router = router; this.router = router;
this.wsRouter = wsRouter;
// Explicit bind with raised backlog.
// setReuseAddress(true) must be called BEFORE bind().
this.serverSocket = new ServerSocket();
this.serverSocket.setReuseAddress(true);
this.serverSocket.setReceiveBufferSize(SOCKET_BUF_SIZE);
this.serverSocket.bind(new InetSocketAddress(configuration.getPort()), ACCEPT_BACKLOG);
log.info("HttpServer bound on port {} (backlog={}, acceptThreads={})",
configuration.getPort(), ACCEPT_BACKLOG, ACCEPT_THREADS);
} }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override @Override
public void start() { public void start() {
acceptThread.set(Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run)); for (int i = 0; i < ACCEPT_THREADS; i++) {
final int idx = i;
Thread.ofPlatform()
.name("flash-accept-" + idx)
.daemon(false)
.start(this::acceptLoop);
}
} }
@Override @Override
public void startAndBlock() { public void startAndBlock() {
start(); start();
try { acceptThread.get().join(); } try { acceptLatch.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
} }
private void run() { /**
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
* platform threads. All threads block on the same {@link ServerSocket};
* the JVM ensures only one wakes per incoming connection (no thundering herd).
*/
private void acceptLoop() {
try {
while (!stopped) { while (!stopped) {
try { try {
process(serverSocket.accept()); process(serverSocket.accept());
} catch (IOException e) { } catch (IOException e) {
if (!stopped) log.error("Accept loop error", e); if (!stopped) log.error("Accept error", e);
} }
} }
} finally {
acceptLatch.countDown();
}
} }
@Override @Override
@@ -104,7 +199,7 @@ class HttpServer implements ServerHandle {
}); });
} }
// ── Hot-path ───────────────────────────────────────────────────────────── // ── Hot-path ─────────────────────────────────────────────────────────────
private void process(Socket socket) { private void process(Socket socket) {
try { try {
@@ -113,13 +208,51 @@ class HttpServer implements ServerHandle {
try (socket; try (socket;
InputStream in = socket.getInputStream(); InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) { OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
// TCP_NODELAY: disable Nagle's algorithm.
// Small WS frames (< MSS) are sent immediately rather than
// waiting up to 200 ms for more data to coalesce. Latency
// drops significantly at the cost of slightly more TCP segments
// under sustained bulk transfer — acceptable for interactive WS.
socket.setTcpNoDelay(true);
socket.setSendBufferSize(SOCKET_BUF_SIZE);
// rawOut is the unbuffered socket stream — passed to WebSocketSession
// directly. WS writes are already bulk (header + payload in two calls);
// with TCP_NODELAY the kernel ships them without Nagle delay, so no
// userspace buffer is needed and no flush() is required per frame.
// HTTP responses continue to use the BufferedOutputStream (out) because
// writeResponse() does many small individual writes that benefit from
// userspace coalescing before a single syscall.
OutputStream rawOut = socket.getOutputStream();
RequestParser parser = new RequestParser( RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(), configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress()); (InetSocketAddress) socket.getRemoteSocketAddress());
while (!stopped) { while (!stopped) {
Request request = parser.parse(in); Request request = parser.parse(in);
if (request == null) break; if (request == null) break;
if (request.method() == HttpMethod.GET && isWebSocketUpgrade(request)) {
WebSocketHandler wsHandler = wsRouter.route(request);
if (wsHandler == null) {
out.write(WS_REJECT_400);
out.flush();
break;
}
// Flush buffered HTTP bytes (the 101 response) before WebSocketSession
// takes over rawOut — otherwise the handshake reply stays stuck in
// the BufferedOutputStream buffer and the client never sees it.
performHandshake(out, request);
out.flush();
request.drain();
WebSocketSession session = new WebSocketSession(
in, rawOut, configuration.getWsFrameBufferSize());
runWsLoop(session, wsHandler);
return;
}
boolean keepAlive = isKeepAlive(request); boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN); Response response = new Response(200, ContentType.TEXT_PLAIN);
@@ -140,6 +273,7 @@ class HttpServer implements ServerHandle {
request.drain(); request.drain();
if (!keepAlive) break; if (!keepAlive) break;
} }
} catch (IOException e) { } catch (IOException e) {
if (!stopped) { if (!stopped) {
if (e instanceof java.net.SocketException) if (e instanceof java.net.SocketException)
@@ -152,17 +286,107 @@ class HttpServer implements ServerHandle {
} }
}); });
} catch (RejectedExecutionException ignored) { } catch (RejectedExecutionException ignored) {
// Executor already shut down — close the socket so the client isn't left hanging.
try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); } try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); }
} }
} }
// ── WebSocket upgrade detection (zero-alloc) ──────────────────────────────
private static boolean isWebSocketUpgrade(Request request) {
ByteView upgrade = request.getRequestLine().getHeaders().view("Upgrade");
if (upgrade == null) return false;
if (!tokenEqualsIgnoreCase(upgrade, 0, upgrade.length(), "websocket")) return false;
return connectionContainsUpgrade(request);
}
private static boolean connectionContainsUpgrade(Request request) {
ByteView conn = request.getRequestLine().getHeaders().view("Connection");
if (conn == null) return false;
int len = conn.length(), i = 0;
while (i < len) {
while (i < len && conn.byteAt(i) == ' ') i++;
int start = i;
while (i < len && conn.byteAt(i) != ',') i++;
if (tokenEqualsIgnoreCase(conn, start, i, "upgrade")) return true;
i++;
}
return false;
}
private static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
int tlen = token.length();
int wlen = end - start;
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
if (wlen != tlen) return false;
for (int i = 0; i < tlen; i++) {
byte b = view.byteAt(start + i);
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) token.charAt(i)) return false;
}
return true;
}
// ── WebSocket handshake ───────────────────────────────────────────────────
private void performHandshake(OutputStream out, Request request) throws IOException {
ByteView keyView = request.getRequestLine().getHeaders().view("Sec-WebSocket-Key");
if (keyView == null) throw new IOException("Missing Sec-WebSocket-Key header");
MessageDigest sha1 = SHA1.get();
sha1.reset();
for (int i = 0, len = keyView.length(); i < len; i++) sha1.update(keyView.byteAt(i));
sha1.update(WS_GUID_BYTES);
byte[] accept = Base64.getEncoder().encode(sha1.digest());
out.write(WS_HANDSHAKE_PREFIX);
out.write(accept);
out.write(WS_HANDSHAKE_SUFFIX);
out.flush();
}
// ── WebSocket session loop ────────────────────────────────────────────────
private void runWsLoop(WebSocketSession session, WebSocketHandler handler) {
handler.onOpen(session);
WebSocketFrame frame = new WebSocketFrame();
try {
while (session.isOpen()) {
if (!session.readFrame(frame)) break;
switch (frame.opcode()) {
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY
-> handler.onMessage(session, frame);
case WebSocketFrame.OP_CLOSE
-> session.closeFromPeer(frame);
case WebSocketFrame.OP_PING
-> session.sendPong(frame);
case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ }
}
}
} catch (IOException e) {
handler.onError(session, e);
} finally {
handler.onClose(session, session.closeCode());
session.forceClose();
}
}
// ── HTTP keep-alive detection ─────────────────────────────────────────────
private static boolean isKeepAlive(Request request) { private static boolean isKeepAlive(Request request) {
if (request.headerEquals("Connection", "close")) return false; if (request.headerEquals("Connection", "close")) return false;
ByteView protocol = request.getRequestLine().getProtocol(); ByteView protocol = request.getRequestLine().getProtocol();
return protocol.length() == 8 && protocol.byteAt(7) == '1' int plen = protocol.length();
|| request.headerEquals("Connection", "keep-alive"); if (plen == 8) {
byte minor = protocol.byteAt(7);
if (minor == '1') return true;
if (minor == '0') return request.headerEquals("Connection", "keep-alive");
} }
log.debug("Unrecognised protocol '{}', treating as close", protocol);
return false;
}
// ── Response serialisation ────────────────────────────────────────────────
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException { private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
out.write(HTTP_1_1); out.write(HTTP_1_1);
@@ -223,7 +447,7 @@ class HttpServer implements ServerHandle {
} }
private static void writeChunked(OutputStream out, InputStream stream) throws IOException { private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
byte[] buf = CHUNK_BUF.get(); byte[] buf = new byte[8192];
int n; int n;
while ((n = stream.read(buf)) > 0) { while ((n = stream.read(buf)) > 0) {
writeHex(out, n); writeHex(out, n);
@@ -239,7 +463,10 @@ class HttpServer implements ServerHandle {
boolean leading = true; boolean leading = true;
while (shift >= 0) { while (shift >= 0) {
int digit = (value >>> shift) & 0xF; int digit = (value >>> shift) & 0xF;
if (digit != 0 || !leading) { leading = false; out.write(digit < 10 ? '0' + digit : 'a' + digit - 10); } if (digit != 0 || !leading) {
leading = false;
out.write(digit < 10 ? '0' + digit : 'a' + digit - 10);
}
shift -= 4; shift -= 4;
} }
if (leading) out.write('0'); if (leading) out.write('0');
@@ -14,20 +14,39 @@ import java.net.InetSocketAddress;
import java.util.Arrays; import java.util.Arrays;
/** /**
* One instance per connection, the buffer is allocated once and reused across keep-alive * One instance per connection. The buffer is allocated once and reused across
* requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}). Zero String * keep-alive requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}).
* allocations during parsing; paths, headers and protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices. *
* <h3>Zero-allocation design</h3>
* <ul>
* <li>No {@link String} allocations during parsing: paths, headers and
* protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices
* into the shared buffer.</li>
* <li>{@link #headerMap} is reset in-place per request — single allocation
* for the lifetime of the connection.</li>
* <li>Pipelining / keep-alive leftover bytes are tracked via {@code bufBase}
* and {@code bufLen} — no copy between requests on the common path.</li>
* </ul>
*
* <h3>State invariant</h3>
* {@code bufBase} and {@code bufLen} always reflect unconsumed bytes that belong
* to the <em>next</em> request. They are snapshotted at the top of {@link #parse}
* and reset to {@code 0/0} before any work begins, so an exception thrown mid-parse
* leaves the fields clean rather than pointing at stale data from a previous request.
*/ */
@Slf4j @Slf4j
public class RequestParser { public class RequestParser {
private static final int INITIAL_BUFFER_SIZE = 8192; private static final int INITIAL_BUFFER_SIZE = 8192;
private final int maxHeaderBufferSize; private final int maxHeaderBufferSize;
private final InetSocketAddress remoteAddress; // set once per connection, never changes private final InetSocketAddress remoteAddress;
private final HeaderMap headerMap = new HeaderMap(); private final HeaderMap headerMap = new HeaderMap();
private byte[] buffer; private byte[] buffer;
private int bufBase = 0; // absolute start of valid data in buffer
private int bufLen = 0; // number of valid bytes from bufBase // Unconsumed bytes belonging to the NEXT request.
// Reset to 0/0 at the start of every parse() call — see invariant above.
private int bufBase = 0;
private int bufLen = 0;
public RequestParser() { this(64 * 1024, null); } public RequestParser() { this(64 * 1024, null); }
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); } public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); }
@@ -37,9 +56,21 @@ public class RequestParser {
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)]; this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
} }
/**
* Parses the next HTTP request from {@code in}.
*
* <p><b>Exception safety:</b> {@code bufBase} and {@code bufLen} are reset
* to {@code 0} before any parsing work begins. If an exception is thrown,
* the connection will be closed by the caller, so stale leftover state is
* never a problem — but the reset ensures correctness in test scenarios where
* the same parser instance is reused after an error.
*
* @return the parsed {@link Request}, or {@code null} on clean EOF.
* @throws IOException on malformed headers or I/O failure.
*/
public Request parse(InputStream in) throws IOException { public Request parse(InputStream in) throws IOException {
// Take ownership of any leftover bytes from the previous request, then reset so // Snapshot leftover bytes from the previous request, then reset immediately.
// early-returns leave the fields in a clean state. // Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
int base = bufBase; int base = bufBase;
int totalRead = bufLen; int totalRead = bufLen;
bufBase = 0; bufBase = 0;
@@ -49,8 +80,8 @@ public class RequestParser {
while (headerEndIdx == -1) { while (headerEndIdx == -1) {
if (base + totalRead == buffer.length) { if (base + totalRead == buffer.length) {
if (base > 0) { if (base > 0) {
// Compact: slide valid data to position 0 — rare path (~every N requests // Compact: slide valid data to position 0.
// where N = bufferSize / avgRequestSize rather than every request). // Rare path (~every N requests where N bufferSize / avgRequestSize).
System.arraycopy(buffer, base, buffer, 0, totalRead); System.arraycopy(buffer, base, buffer, 0, totalRead);
base = 0; base = 0;
} else if (buffer.length >= maxHeaderBufferSize) { } else if (buffer.length >= maxHeaderBufferSize) {
@@ -70,6 +101,8 @@ public class RequestParser {
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes"); throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
} }
// ── Request line ─────────────────────────────────────────────────────
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' '); int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
if (methodEnd == -1) throw new IOException("Invalid request line (method)"); if (methodEnd == -1) throw new IOException("Invalid request line (method)");
@@ -91,7 +124,10 @@ public class RequestParser {
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r'); int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)"); if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)");
FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart); FastPathViews.RequestByteView protocolView =
new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
// ── Headers ──────────────────────────────────────────────────────────
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1; int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int current = sectionStart; int current = sectionStart;
@@ -118,12 +154,14 @@ public class RequestParser {
headerMap.reset(buffer, sectionStart, headerEndIdx); headerMap.reset(buffer, sectionStart, headerEndIdx);
// ── Body / pipelining accounting ─────────────────────────────────────
int bodyStart = headerEndIdx + 4; int bodyStart = headerEndIdx + 4;
int preBufLen = (base + totalRead) - bodyStart; int preBufLen = (base + totalRead) - bodyStart;
// Any bytes read beyond this request's body belong to the next request. // Bytes read beyond this request's body belong to the next request.
// Store their absolute position in the buffer — no copy needed; the next parse() // Store their absolute position — no copy needed; the next parse() call
// call will read directly from bufBase without touching the data. // reads directly from bufBase without touching the data.
if (!isChunked && contentLength == 0 && preBufLen > 0) { if (!isChunked && contentLength == 0 && preBufLen > 0) {
bufBase = bodyStart; bufBase = bodyStart;
bufLen = preBufLen; bufLen = preBufLen;
@@ -137,17 +175,20 @@ public class RequestParser {
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap); RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
if (isChunked) { if (isChunked) {
return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0, remoteAddress); return Request.forParsed(requestLine,
new ChunkedInputStream(in, buffer, bodyStart, preBufLen),
-1L, null, 0, 0, remoteAddress);
} }
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress); return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress);
} }
// ── Buffer scanning utilities (hot path — keep branch-free where possible) ──
private static int findEndOfHeader(byte[] buf, int from, int len) { private static int findEndOfHeader(byte[] buf, int from, int len) {
for (int i = from; i <= len - 4; i++) { for (int i = from; i <= len - 4; i++) {
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n') { if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n')
return i; return i;
} }
}
return -1; return -1;
} }
@@ -3,6 +3,7 @@ package dev.relism.flash;
import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration; import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter; import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -26,7 +27,9 @@ public interface ServerHandle {
/** Gracefully stops the server, draining active connections. */ /** Gracefully stops the server, draining active connections. */
CompletableFuture<Void> stop(); CompletableFuture<Void> stop();
static ServerHandle create(FlashConfiguration config, AbstractRouter router) throws IOException { static ServerHandle create(FlashConfiguration config,
return new HttpServer(config, router); AbstractRouter httpRouter,
AbstractWsRouter wsRouter) throws IOException {
return new HttpServer(config, httpRouter, wsRouter);
} }
} }
@@ -7,8 +7,14 @@ import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler; import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.AbstractRouter; import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
import dev.relism.flash.websocket.WebSocketEndpoint;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.io.IOException; import java.io.IOException;
@@ -52,16 +58,20 @@ import java.util.function.Consumer;
public final class FlashApp extends FlashRegistrar<FlashApp> { public final class FlashApp extends FlashRegistrar<FlashApp> {
private final AbstractRouter router = new FastPathRouterImpl(); private final AbstractRouter router = new FastPathRouterImpl();
private final AbstractWsRouter wsRouter = new FastPathWsRouterImpl();
private final ServerHandle server; private final ServerHandle server;
private final FlashContext ctx = new FlashContext(); private final FlashContext ctx = new FlashContext();
private final List<FlashExtension> extensions = new ArrayList<>(); private final List<FlashExtension> extensions = new ArrayList<>();
private final List<Middleware> globalMiddlewares = new ArrayList<>(); private final List<Middleware> globalMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>(); private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
private int port; private int port;
private record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
private FlashApp(FlashConfiguration config) { private FlashApp(FlashConfiguration config) {
try { try {
this.server = ServerHandle.create(config, router); this.server = ServerHandle.create(config, router, wsRouter);
this.port = config.getPort(); this.port = config.getPort();
} }
catch (IOException e) { throw new InitializationException("Failed to bind on port " + config.getPort(), e); } catch (IOException e) { throw new InitializationException("Failed to bind on port " + config.getPort(), e); }
@@ -83,6 +93,26 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
FlashScope scope = new FlashScope(namespace, ctx); FlashScope scope = new FlashScope(namespace, ctx);
configure.accept(scope); configure.accept(scope);
deferredRoutes.addAll(scope.routes()); deferredRoutes.addAll(scope.routes());
deferredWsRoutes.addAll(scope.wsRoutes().stream()
.map(r -> new WsRouteDefinition(r.path(), r.endpoint()))
.toList());
return this;
}
/**
* Registers a WebSocket endpoint at {@code path}.
* Runs on the same virtual-thread model as HTTP handlers.
* A path may have both an HTTP handler and a WS endpoint simultaneously.
*/
public FlashApp ws(String path, WebSocketHandler handler) {
WebSocketEndpoint endpoint = handler instanceof WebSocketEndpoint e ? e
: new WebSocketEndpoint() {
@Override public void onOpen(WebSocketSession s) { handler.onOpen(s); }
@Override public void onMessage(WebSocketSession s, WebSocketFrame f) { handler.onMessage(s, f); }
@Override public void onClose(WebSocketSession s, int c) { handler.onClose(s, c); }
@Override public void onError(WebSocketSession s, Throwable t) { handler.onError(s, t); }
};
deferredWsRoutes.add(new WsRouteDefinition(path, endpoint));
return this; return this;
} }
@@ -152,6 +182,11 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
!(handler instanceof SimpleHandler), ctx, "/")); !(handler instanceof SimpleHandler), ctx, "/"));
} }
@Override
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
deferredWsRoutes.add(new WsRouteDefinition(path, endpoint));
}
@Override @Override
protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); } protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); }
@@ -163,6 +198,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
ctx.resolveAll(); ctx.resolveAll();
extensions.forEach(e -> e.routes(this, ctx)); extensions.forEach(e -> e.routes(this, ctx));
compile(); compile();
compileWs();
} }
// ── Compilation ────────────────────────────────────────────────────────── // ── Compilation ──────────────────────────────────────────────────────────
@@ -187,6 +223,14 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
} }
} }
private void compileWs() {
for (WsRouteDefinition def : deferredWsRoutes) {
def.endpoint().bind(ctx);
emitWsEvent(def.path(), def.endpoint());
wsRouter.register(HttpMethod.GET, def.path(), def.endpoint());
}
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void emitEvent(RouteDefinition def, Middleware[] chain) { private void emitEvent(RouteDefinition def, Middleware[] chain) {
List<RouteListener> listeners = def.ctx().routeListeners(); List<RouteListener> listeners = def.ctx().routeListeners();
@@ -198,6 +242,14 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
listeners.forEach(l -> l.onRoute(event)); listeners.forEach(l -> l.onRoute(event));
} }
private void emitWsEvent(String path, WebSocketEndpoint endpoint) {
List<RouteListener> listeners = ctx.routeListeners();
if (listeners.isEmpty()) return;
RouteEvent event = new RouteEvent(HttpMethod.GET, path, "/", "FlashApp",
endpoint.getClass(), List.of());
listeners.forEach(l -> l.onRoute(event));
}
private static Middleware[] concat(List<Middleware> global, List<Middleware> scope, private static Middleware[] concat(List<Middleware> global, List<Middleware> scope,
List<Middleware> injected, List<Middleware> explicit) { List<Middleware> injected, List<Middleware> explicit) {
int total = global.size() + scope.size() + injected.size() + explicit.size(); int total = global.size() + scope.size() + injected.size() + explicit.size();
@@ -28,4 +28,8 @@ public class FlashConfiguration {
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */ /** Maximum size of the request header buffer in bytes. Default: 64 KB. */
@Builder.Default @Builder.Default
int maxHeaderBufferSize = 64 * 1024; int maxHeaderBufferSize = 64 * 1024;
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
@Builder.Default
int wsFrameBufferSize = 64 * 1024;
} }
@@ -4,9 +4,11 @@ import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler; import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.websocket.WebSocketEndpoint;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.Route; import dev.relism.flash.routing.Route;
import dev.relism.flash.routing.Routes; import dev.relism.flash.routing.Routes;
import dev.relism.flash.routing.Ws;
import java.util.List; import java.util.List;
@@ -68,10 +70,15 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
* class fails to load or instantiate * class fails to load or instantiate
*/ */
public final SELF scan(String packageName) { public final SELF scan(String packageName) {
PackageScanner.findHandlers(packageName).forEach(cls -> { PackageScanner.ScanResult found = PackageScanner.scan(packageName);
found.httpHandlers().forEach(cls -> {
Route ann = Routes.of(cls); Route ann = Routes.of(cls);
addRoute(ann.method(), ann.path(), instantiate(cls), List.of()); addRoute(ann.method(), ann.path(), instantiate(cls), List.of());
}); });
found.wsEndpoints().forEach(cls -> {
Ws ann = cls.getAnnotation(Ws.class);
addWsRoute(ann.value(), instantiateWs(cls));
});
return (SELF) this; return (SELF) this;
} }
@@ -87,6 +94,8 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
protected abstract void addRoute(HttpMethod method, String path, protected abstract void addRoute(HttpMethod method, String path,
RequestHandler handler, List<Middleware> mw); RequestHandler handler, List<Middleware> mw);
protected abstract void addWsRoute(String path, WebSocketEndpoint endpoint);
/** Registers a middleware in this registrar's own scope (global or scope-level). */ /** Registers a middleware in this registrar's own scope (global or scope-level). */
protected abstract void addMiddleware(Middleware mw); protected abstract void addMiddleware(Middleware mw);
@@ -103,4 +112,13 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
" — ensure it has a public no-arg constructor", e); " — ensure it has a public no-arg constructor", e);
} }
} }
protected static WebSocketEndpoint instantiateWs(Class<?> cls) {
try { return (WebSocketEndpoint) cls.getDeclaredConstructor().newInstance(); }
catch (Exception e) {
throw new InitializationException(
"Failed to instantiate WS endpoint " + cls.getName() +
" — ensure public no-arg constructor", e);
}
}
} }
@@ -5,6 +5,7 @@ import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler; import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.PathUtils; import dev.relism.flash.routing.PathUtils;
import dev.relism.flash.websocket.WebSocketEndpoint;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -29,6 +30,9 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
private final FlashContext ctx; private final FlashContext ctx;
private final List<Middleware> scopeMiddlewares = new ArrayList<>(); private final List<Middleware> scopeMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>(); private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
FlashScope(String namespace, FlashContext parentCtx) { FlashScope(String namespace, FlashContext parentCtx) {
this.namespace = PathUtils.sanitize(namespace); this.namespace = PathUtils.sanitize(namespace);
@@ -47,12 +51,18 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
!(handler instanceof SimpleHandler), ctx, namespace)); !(handler instanceof SimpleHandler), ctx, namespace));
} }
@Override
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
deferredWsRoutes.add(new WsRouteDefinition(ns(path), endpoint));
}
@Override @Override
protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); } protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); }
// ── Internal (called by FlashApp.mount) ─────────────────────────────────── // ── Internal (called by FlashApp.mount) ───────────────────────────────────
List<RouteDefinition> routes() { return deferredRoutes; } List<RouteDefinition> routes() { return deferredRoutes; }
List<WsRouteDefinition> wsRoutes() { return deferredWsRoutes; }
private String ns(String path) { return PathUtils.join(namespace, path); } private String ns(String path) { return PathUtils.join(namespace, path); }
} }
@@ -2,9 +2,12 @@ package dev.relism.flash.extension;
import dev.relism.flash.exceptions.InitializationException; import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.routing.Routes; import dev.relism.flash.routing.Routes;
import dev.relism.flash.websocket.WebSocketEndpoint;
import java.io.File; import java.io.File;
import java.lang.reflect.Modifier;
import java.net.URL; import java.net.URL;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Enumeration; import java.util.Enumeration;
@@ -26,6 +29,8 @@ final class PackageScanner {
private PackageScanner() {} private PackageScanner() {}
record ScanResult(List<Class<?>> httpHandlers, List<Class<?>> wsEndpoints) {}
/** /**
* Handlers in {@code packageName} that have {@link Routes#of(Class) resolvable} route metadata. * Handlers in {@code packageName} that have {@link Routes#of(Class) resolvable} route metadata.
* *
@@ -33,12 +38,17 @@ final class PackageScanner {
* handler class fails to load * handler class fails to load
*/ */
static List<Class<?>> findHandlers(String packageName) { static List<Class<?>> findHandlers(String packageName) {
return scan(packageName).httpHandlers();
}
static ScanResult scan(String packageName) {
if (packageName == null || packageName.isBlank()) if (packageName == null || packageName.isBlank())
throw new InitializationException("scan() called with null or blank package name"); throw new InitializationException("scan() called with null or blank package name");
String resourcePath = packageName.replace('.', '/'); String resourcePath = packageName.replace('.', '/');
ClassLoader cl = Thread.currentThread().getContextClassLoader(); ClassLoader cl = Thread.currentThread().getContextClassLoader();
List<Class<?>> result = new ArrayList<>(); List<Class<?>> http = new ArrayList<>();
List<Class<?>> ws = new ArrayList<>();
List<String> errors = new ArrayList<>(); List<String> errors = new ArrayList<>();
boolean packageFound = false; boolean packageFound = false;
@@ -49,17 +59,17 @@ final class PackageScanner {
URL url = resources.nextElement(); URL url = resources.nextElement();
String protocol = url.getProtocol(); String protocol = url.getProtocol();
if ("file".equals(protocol)) { if ("file".equals(protocol)) {
scanDirectory(new File(url.toURI()), packageName, cl, result, errors); scanDirectory(new File(url.toURI()), packageName, cl, http, ws, errors);
} else if ("jar".equals(protocol)) { } else if ("jar".equals(protocol)) {
String jarPath = url.getPath(); String jarPath = url.getPath();
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!')); String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
try (JarFile jar = new JarFile(filePart)) { try (JarFile jar = new JarFile(filePart)) {
scanJar(jar, resourcePath, packageName, cl, result, errors); scanJar(jar, resourcePath, packageName, cl, http, ws, errors);
} }
} }
} }
} catch (InitializationException e) { } catch (InitializationException e) {
throw e; // re-throw our own exceptions throw e;
} catch (Exception e) { } catch (Exception e) {
throw new InitializationException("Failed to scan package: " + packageName, e); throw new InitializationException("Failed to scan package: " + packageName, e);
} }
@@ -74,38 +84,38 @@ final class PackageScanner {
"scan(\"" + packageName + "\") — failed to load " + errors.size() + " handler(s):\n • " + "scan(\"" + packageName + "\") — failed to load " + errors.size() + " handler(s):\n • " +
String.join("\n • ", errors)); String.join("\n • ", errors));
if (result.isEmpty()) if (http.isEmpty() && ws.isEmpty())
throw new InitializationException( throw new InitializationException(
"scan(\"" + packageName + "\") — no routable handlers found. " + "scan(\"" + packageName + "\") — no routable handlers found. " +
"Ensure classes extend RequestHandler, declare @Route or @GET/@POST/…, are not abstract, " + "Ensure classes extend RequestHandler or WebSocketEndpoint, declare the right route annotation, " +
"and have a public no-arg constructor."); "are not abstract, and have a public no-arg constructor.");
return result; return new ScanResult(List.copyOf(http), List.copyOf(ws));
} }
private static void scanDirectory(File dir, String packageName, ClassLoader cl, private static void scanDirectory(File dir, String packageName, ClassLoader cl,
List<Class<?>> result, List<String> errors) { List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
File[] files = dir.listFiles(); File[] files = dir.listFiles();
if (files == null) return; if (files == null) return;
for (File file : files) { for (File file : files) {
if (file.isDirectory()) { if (file.isDirectory()) {
scanDirectory(file, packageName + '.' + file.getName(), cl, result, errors); scanDirectory(file, packageName + '.' + file.getName(), cl, http, ws, errors);
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) { } else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
String className = packageName + '.' + file.getName().replace(".class", ""); String className = packageName + '.' + file.getName().replace(".class", "");
tryLoad(className, cl, result, errors); tryLoad(className, cl, http, ws, errors);
} }
} }
} }
private static void scanJar(JarFile jar, String resourcePath, String packageName, private static void scanJar(JarFile jar, String resourcePath, String packageName,
ClassLoader cl, List<Class<?>> result, List<String> errors) { ClassLoader cl, List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
String prefix = resourcePath + "/"; String prefix = resourcePath + "/";
Enumeration<JarEntry> entries = jar.entries(); Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) { while (entries.hasMoreElements()) {
String name = entries.nextElement().getName(); String name = entries.nextElement().getName();
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) { if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) {
String className = name.replace('/', '.').replace(".class", ""); String className = name.replace('/', '.').replace(".class", "");
tryLoad(className, cl, result, errors); tryLoad(className, cl, http, ws, errors);
} }
} }
} }
@@ -125,22 +135,25 @@ final class PackageScanner {
} }
private static void tryLoad(String className, ClassLoader cl, private static void tryLoad(String className, ClassLoader cl,
List<Class<?>> result, List<String> errors) { List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
try { try {
Class<?> cls = cl.loadClass(className); Class<?> cls = cl.loadClass(className);
if (!RequestHandler.class.isAssignableFrom(cls)) return; if (Modifier.isAbstract(cls.getModifiers())) return;
if (java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) return;
if (Routes.of(cls) == null) return;
// Verify no-arg constructor exists — fail-fast if missing if (RequestHandler.class.isAssignableFrom(cls)
try { && Routes.of(cls) != null
cls.getDeclaredConstructor(); && !cls.isAnnotationPresent(Ws.class)) {
} catch (NoSuchMethodException e) { assertNoArgConstructor(cls, errors);
errors.add(className + " — missing public no-arg constructor"); http.add(cls);
return; return;
} }
result.add(cls); if (WebSocketEndpoint.class.isAssignableFrom(cls)
&& cls.isAnnotationPresent(Ws.class)) {
assertNoArgConstructor(cls, errors);
ws.add(cls);
return;
}
} catch (ClassNotFoundException e) { } catch (ClassNotFoundException e) {
errors.add(className + " — class not found: " + e.getMessage()); errors.add(className + " — class not found: " + e.getMessage());
} catch (NoClassDefFoundError e) { } catch (NoClassDefFoundError e) {
@@ -149,4 +162,9 @@ final class PackageScanner {
errors.add(className + " — linkage error: " + e.getMessage()); errors.add(className + " — linkage error: " + e.getMessage());
} }
} }
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
try { cls.getDeclaredConstructor(); }
catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); }
}
} }
@@ -0,0 +1,34 @@
package dev.relism.flash.routing;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.PathParams;
import dev.relism.flash.models.Request;
import dev.relism.fpr.core.MatchResult;
import dev.relism.flash.websocket.WebSocketHandler;
public abstract class AbstractWsRouter {
public final AbstractWsRouter register(HttpMethod method, String path, WebSocketHandler handler) {
return addRoute(method, PathUtils.sanitize(path), handler);
}
public abstract WebSocketHandler route(Request request);
protected abstract AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler);
protected static void setPathParams(Request request, MatchResult<WebSocketHandler> result,
String[] allNames, int methodLen) {
int count = result.paramCount();
if (count == 0) return;
String[] names = new String[count];
int[] starts = new int[count];
int[] lens = new int[count];
for (int i = 0; i < count; i++) {
names[i] = allNames[result.keyIdAt(i)];
starts[i] = result.startAt(i) - methodLen;
lens[i] = result.lenAt(i);
}
PathParams.inject(request,
new PathParams(request.getRequestLine().getPath(), names, starts, lens));
}
}
@@ -0,0 +1,25 @@
package dev.relism.flash.routing;
import dev.relism.flash.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a {@link dev.relism.flash.models.RequestHandler} subclass as a
* WebSocket endpoint. Resolved at boot time by {@link Routes#of} via the
* existing meta-annotation mechanism — no additional reflection required.
*
* <pre>{@code
* @Ws("/chat")
* public class ChatHandler extends RequestHandler { ... }
* }</pre>
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Route(method = HttpMethod.GET, path = "")
public @interface Ws {
String value();
}
@@ -0,0 +1,68 @@
package dev.relism.flash.routing.routers.fastpathrouter;
import dev.relism.fpr.core.ByteView;
import dev.relism.fpr.core.FastPathRouter;
import dev.relism.fpr.core.MatchResult;
import dev.relism.fpr.core.RouterBuilder;
import dev.relism.fpr.core.dsl.StringRouteParser;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.websocket.WebSocketHandler;
public final class FastPathWsRouterImpl extends AbstractWsRouter {
private final RouterBuilder<WebSocketHandler> builder = new RouterBuilder<>();
private volatile FastPathRouter<ByteView, WebSocketHandler> router;
private String[] cachedParamNames;
@Override
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
builder.add(StringRouteParser.parse(method.name() + path), handler);
this.router = null;
return this;
}
@Override
public WebSocketHandler route(Request request) {
ensureCompiled();
MatchResult<WebSocketHandler> result = Context.result();
result.reset();
HttpMethod method = request.getRequestLine().getMethod();
ByteView pathView = request.getRequestLine().getPath();
FastPathViews.MethodPathByteView combined = Context.combined();
combined.reset(method.getBytes(), pathView);
int labelId = router.match(combined, result);
if (labelId == FastPathRouter.NO_MATCH) return null;
if (result.paramCount() > 0) {
setPathParams(request, result, cachedParamNames, method.getBytes().length);
}
return result.handler();
}
private void ensureCompiled() {
if (router == null) {
synchronized (this) {
if (router == null) {
cachedParamNames = builder.paramNames();
router = builder.compile();
}
}
}
}
private static final class Context {
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED =
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
static MatchResult<WebSocketHandler> result() { return RESULT.get(); }
static FastPathViews.MethodPathByteView combined() { return COMBINED.get(); }
}
}
@@ -0,0 +1,42 @@
package dev.relism.flash.websocket;
import dev.relism.flash.extension.FlashContext;
import java.util.Optional;
/**
* Base class for class-based WebSocket endpoints registered via {@code @Ws} or
* {@code app.ws(path, endpoint)}.
*
* Lifecycle: bind() once at boot → onOpen / onMessage* / onClose | onError per connection.
* No relation to {@link dev.relism.flash.models.RequestHandler} — HTTP and WS lifecycles
* are fully separate.
*/
public abstract class WebSocketEndpoint implements WebSocketHandler {
private FlashContext ctx;
/** Called once by framework at boot. Do not call from user code. */
public final void bind(FlashContext ctx) {
this.ctx = ctx;
onInit();
}
/** Override to resolve and cache services before first connection. */
protected void onInit() {}
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
private void checkBound() {
if (ctx == null) throw new IllegalStateException(
getClass().getSimpleName() + " not bound — register via app.scan() or app.ws()");
}
}
@@ -0,0 +1,81 @@
package dev.relism.flash.websocket;
/**
* Zero-copy view over {@link WebSocketSession}'s read buffer.
* One instance allocated per connection, reset per frame — zero steady-state allocation.
*
* <p><b>Lifetime contract:</b> valid ONLY within the synchronous scope of
* {@link WebSocketHandler#onMessage(WebSocketSession, WebSocketFrame)}.
* The underlying buffer is owned by {@link WebSocketSession} and will be
* overwritten on the next frame read. Never retain a reference beyond the
* callback; use {@link #copyPayload()} when data must outlive the call.
*
* <p><b>Debug safety:</b> {@link #buffer()} is package-private to prevent
* accidental cross-boundary access. External consumers must go through
* {@link #copyPayload()} or process in-place within the callback.
*/
public final class WebSocketFrame {
public static final byte OP_CONTINUATION = 0x0;
public static final byte OP_TEXT = 0x1;
public static final byte OP_BINARY = 0x2;
public static final byte OP_CLOSE = 0x8;
public static final byte OP_PING = 0x9;
public static final byte OP_PONG = 0xA;
private byte[] buf;
private int payloadOff;
private int payloadLen;
private byte opcode;
private boolean fin;
// Monotonically incremented on every reset. Allows callers that hold a
// reference beyond onMessage() to detect stale access in assertions/tests.
private int generation;
/** Called by {@link WebSocketSession} only. */
void reset(byte[] buf, int off, int len, byte opcode, boolean fin) {
this.buf = buf;
this.payloadOff = off;
this.payloadLen = len;
this.opcode = opcode;
this.fin = fin;
this.generation++;
}
public byte opcode() { return opcode; }
public boolean isFin() { return fin; }
public int payloadOffset() { return payloadOff; }
public int payloadLength() { return payloadLen; }
/**
* Returns the generation counter at the moment of this call.
* Store it at callback entry and compare later to detect stale retention:
* <pre>{@code
* int gen = frame.generation();
* executor.submit(() -> {
* assert frame.generation() == gen : "frame buffer was recycled!";
* });
* }</pre>
*/
public int generation() { return generation; }
/**
* Direct reference to the session read buffer — package-private to prevent
* accidental retention outside the websocket package.
* External callers: use {@link #copyPayload()}.
*/
byte[] buffer() { return buf; }
/**
* Copies the payload into a fresh array. Allocates — O(n) in payload size.
* Use only when data must outlive the {@code onMessage} callback or be
* handed off to another thread. For payloads > a few KB consider wrapping
* the result in a pooled {@link java.nio.ByteBuffer} to avoid GC pressure.
*/
public byte[] copyPayload() {
byte[] copy = new byte[payloadLen];
System.arraycopy(buf, payloadOff, copy, 0, payloadLen);
return copy;
}
}
@@ -0,0 +1,20 @@
package dev.relism.flash.websocket;
/**
* Handler for a WebSocket endpoint. Lifecycle:
* onOpen → onMessage* → onClose | onError.
*
* Lifetime contract for {@link WebSocketFrame}: valid ONLY within
* the synchronous scope of {@link #onMessage}. Do not retain references
* to the frame or its buffer across calls — copy with {@link WebSocketFrame#copyPayload()}
* if data must outlive the callback.
*
* OP_CONTINUATION frames are delivered as-is; fragment reassembly is an
* application concern.
*/
public interface WebSocketHandler {
void onOpen(WebSocketSession session);
void onMessage(WebSocketSession session, WebSocketFrame frame);
default void onClose(WebSocketSession session, int code) {}
default void onError(WebSocketSession session, Throwable t) {}
}
@@ -0,0 +1,210 @@
package dev.relism.flash.websocket;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Per-connection WebSocket I/O state. One instance per virtual thread.
*
* <ul>
* <li>
* <p>With {@code TCP_NODELAY} enabled on the socket (set in {@code HttpServer}),
* Nagle's algorithm is disabled: the kernel sends data as soon as it lands in
* the send buffer, without waiting. {@link java.io.BufferedOutputStream} will
* still batch multiple small writes into one syscall when they happen in the
* same virtual-thread scheduling quantum — giving us coalescing where it's
* free, and immediate delivery where it matters.
*
* <p>The only place an explicit flush is still needed is after the WS
* handshake (one-time, not on the hot path) and after the CLOSE frame
* (end of session). Both are handled in {@link #close} and in
* {@code HttpServer#performHandshake}.</li>
*
* <li><b>Flush on CLOSE frame</b>: {@link #close} still flushes explicitly
* because the CLOSE frame is the last thing written before the stream is
* abandoned — without a flush, the 4 bytes could sit in the buffer forever.</li>
* </ul>
*
* <h3>Thread safety</h3>
* {@link #sendText}, {@link #send}, and {@link #close} are synchronized on
* {@code out} and safe to call from threads other than the session loop.
* {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE
* frame emission under concurrent calls.
*/
public final class WebSocketSession {
private final InputStream in;
private final OutputStream out;
private final byte[] readBuf;
private final AtomicBoolean open = new AtomicBoolean(true);
private int closeCode = 1000;
private final byte[] hdrScratch = new byte[10];
public WebSocketSession(InputStream in, OutputStream out, int bufferSize) {
this.in = in;
this.out = out;
this.readBuf = new byte[bufferSize];
}
public boolean isOpen() { return open.get(); }
public int closeCode() { return closeCode; }
// ── Public send API ────────────────────────────────────────────────────
public void sendText(byte[] utf8, int off, int len) throws IOException {
writeFrame(WebSocketFrame.OP_TEXT, utf8, off, len);
}
public void send(byte[] payload, int off, int len) throws IOException {
writeFrame(WebSocketFrame.OP_BINARY, payload, off, len);
}
/**
* Sends a CLOSE frame exactly once.
* No flush needed — {@code out} is the raw socket OutputStream (not buffered);
* each write goes directly to the kernel send buffer, and TCP_NODELAY ensures
* it's transmitted immediately.
*/
public void close(int code) throws IOException {
if (!open.compareAndSet(true, false)) return;
synchronized (out) {
out.write(0x88);
out.write(0x02);
out.write((code >> 8) & 0xFF);
out.write(code & 0xFF);
}
}
// ── Session loop internals ─────────────────────────────────────────────
public boolean readFrame(WebSocketFrame frame) throws IOException {
int b0 = in.read();
if (b0 < 0) return false;
int b1 = in.read();
if (b1 < 0) return false;
boolean fin = (b0 & 0x80) != 0;
byte opcode = (byte) (b0 & 0x0F);
boolean masked = (b1 & 0x80) != 0;
long payLen = (b1 & 0x7F);
if (payLen == 126) {
payLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF);
} else if (payLen == 127) {
payLen = 0;
for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (in.read() & 0xFF);
}
if (payLen > readBuf.length) throw new IOException(
"WS frame payload " + payLen + " bytes exceeds buffer " + readBuf.length);
byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
if (masked) {
m0 = (byte) in.read(); m1 = (byte) in.read();
m2 = (byte) in.read(); m3 = (byte) in.read();
}
int len = (int) payLen;
readFully(readBuf, 0, len);
if (masked) unmaskInPlace(readBuf, 0, len, m0, m1, m2, m3);
frame.reset(readBuf, 0, len, opcode, fin);
return true;
}
public void sendPong(WebSocketFrame ping) throws IOException {
writeFrame(WebSocketFrame.OP_PONG, ping.buffer(), ping.payloadOffset(), ping.payloadLength());
}
public void echo(WebSocketFrame frame) throws IOException {
if (frame.opcode() == WebSocketFrame.OP_TEXT)
sendText(frame.buffer(), frame.payloadOffset(), frame.payloadLength());
else
send(frame.buffer(), frame.payloadOffset(), frame.payloadLength());
}
public void closeFromPeer(WebSocketFrame frame) {
if (frame.payloadLength() >= 2) {
byte[] b = frame.copyPayload();
int o = frame.payloadOffset();
closeCode = ((b[o] & 0xFF) << 8) | (b[o + 1] & 0xFF);
}
open.set(false);
}
public void forceClose() {
open.set(false);
try { in.close(); } catch (IOException ignored) {}
}
// ── Private ────────────────────────────────────────────────────────────
/**
* Encodes the WS frame header into {@link #hdrScratch} (at most 10 bytes),
* then writes header + payload in two bulk calls to the raw socket stream.
*
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}
* (see {@code HttpServer#process}). Each {@code write()} lands directly in the
* kernel send buffer. With {@code TCP_NODELAY} set on the socket, the kernel
* transmits the segment immediately without Nagle coalescing. The two writes
* (header then payload) will be merged into a single TCP segment by the kernel
* because they arrive faster than the ACK from the peer — exactly the coalescing
* we want, at zero cost.
*/
private void writeFrame(byte opcode, byte[] payload, int off, int len) throws IOException {
synchronized (out) {
int hlen = 0;
hdrScratch[hlen++] = (byte) (0x80 | opcode);
if (len <= 125) {
hdrScratch[hlen++] = (byte) len;
} else if (len <= 0xFFFF) {
hdrScratch[hlen++] = 126;
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
hdrScratch[hlen++] = (byte) (len & 0xFF);
} else {
hdrScratch[hlen++] = 127;
hdrScratch[hlen++] = 0; hdrScratch[hlen++] = 0;
hdrScratch[hlen++] = 0; hdrScratch[hlen++] = 0;
hdrScratch[hlen++] = (byte) ((len >> 24) & 0xFF);
hdrScratch[hlen++] = (byte) ((len >> 16) & 0xFF);
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
hdrScratch[hlen++] = (byte) (len & 0xFF);
}
out.write(hdrScratch, 0, hlen);
out.write(payload, off, len);
// No flush — TCP_NODELAY handles delivery. See Javadoc above.
}
}
private void readFully(byte[] buf, int off, int len) throws IOException {
int remaining = len;
while (remaining > 0) {
int n = in.read(buf, off + (len - remaining), remaining);
if (n < 0) throw new EOFException("WebSocket stream closed mid-frame");
remaining -= n;
}
}
private static void unmaskInPlace(byte[] buf, int off, int len,
byte m0, byte m1, byte m2, byte m3) {
int i = off;
int end = off + len;
int end4 = off + (len & ~3);
while (i < end4) {
buf[i] ^= m0;
buf[i+1] ^= m1;
buf[i+2] ^= m2;
buf[i+3] ^= m3;
i += 4;
}
if (i < end) { buf[i++] ^= m0; }
if (i < end) { buf[i++] ^= m1; }
if (i < end) { buf[i] ^= m2; }
}
}
@@ -0,0 +1,193 @@
package dev.relism.flash;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class HttpServerWebSocketTest {
private FlashApp app;
private int port;
private final AtomicReference<Integer> closed = new AtomicReference<>();
@BeforeEach
void setUp() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
app = FlashApp.create(FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.build());
app.ws("/chat", new WebSocketHandler() {
@Override
public void onOpen(WebSocketSession session) {}
@Override
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
try {
if (frame.opcode() == WebSocketFrame.OP_TEXT) {
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
app.ws("/close", new WebSocketHandler() {
@Override public void onOpen(WebSocketSession session) {}
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
@Override public void onClose(WebSocketSession session, int code) { closed.set(code); }
});
app.start();
}
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
private static final int SOCKET_TIMEOUT_MS = 5000;
private static String handshakeKey() {
return Base64.getEncoder().encodeToString("flash-test-key".getBytes(StandardCharsets.UTF_8));
}
private static String readHeaders(InputStream in) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
int b, prev3 = -1, prev2 = -1, prev1 = -1;
while ((b = in.read()) != -1) {
out.write(b);
if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
prev3 = prev2; prev2 = prev1; prev1 = b;
}
return out.toString(StandardCharsets.UTF_8);
}
private static byte[] textFrame(String message) {
byte[] payload = message.getBytes(StandardCharsets.UTF_8);
byte[] frame = new byte[payload.length + 6];
frame[0] = (byte) 0x81;
frame[1] = (byte) (0x80 | payload.length);
byte[] mask = {1, 2, 3, 4};
System.arraycopy(mask, 0, frame, 2, 4);
for (int i = 0; i < payload.length; i++) {
frame[6 + i] = (byte) (payload[i] ^ mask[i & 3]);
}
return frame;
}
private static byte[] closeFrame(int code) {
byte[] frame = new byte[8];
frame[0] = (byte) 0x88;
frame[1] = (byte) 0x82;
byte[] mask = {1, 2, 3, 4};
System.arraycopy(mask, 0, frame, 2, 4);
frame[6] = (byte) (((code >> 8) & 0xFF) ^ mask[0]);
frame[7] = (byte) ((code & 0xFF) ^ mask[1]);
return frame;
}
@Test
void websocket_upgrade_returns101AndEchoesText() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
String key = handshakeKey();
String req = "GET /chat HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: keep-alive, Upgrade\r\n" +
"Sec-WebSocket-Key: " + key + "\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n";
out.write(req.getBytes(StandardCharsets.UTF_8));
out.flush();
String headers = readHeaders(in);
assertTrue(headers.startsWith("HTTP/1.1 101 Switching Protocols"));
assertTrue(headers.contains("Upgrade: websocket"));
assertTrue(headers.contains("Connection: Upgrade"));
assertTrue(headers.contains("Sec-WebSocket-Accept: "));
out.write(textFrame("hello"));
out.flush();
byte[] frame = in.readNBytes(7);
assertEquals((byte) 0x81, frame[0]);
assertEquals((byte) 0x05, frame[1]);
assertEquals("hello", new String(frame, 2, 5, StandardCharsets.UTF_8));
}
}
@Test
void websocket_ping_is_ponged() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
out.write(("GET /chat HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: " + handshakeKey() + "\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
out.flush();
readHeaders(in);
out.write(new byte[] {(byte) 0x89, (byte) 0x80, 1, 2, 3, 4});
out.flush();
byte[] pong = in.readNBytes(2);
assertEquals((byte) 0x8A, pong[0]);
assertEquals((byte) 0x00, pong[1]);
}
}
@Test
void websocket_close_frame_closesSession() throws Exception {
try (Socket socket = new Socket("127.0.0.1", port);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
out.write(("GET /close HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: " + handshakeKey() + "\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
out.flush();
readHeaders(in);
out.write(closeFrame(1000));
out.flush();
Thread.sleep(100);
assertEquals(1000, closed.get());
}
}
}
@@ -0,0 +1,47 @@
package dev.relism.flash.extension;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.net.ServerSocket;
import static org.junit.jupiter.api.Assertions.*;
class FlashAppWebSocketTest {
private FlashApp app;
private int port;
@BeforeEach
void setUp() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
app = FlashApp.create(FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.build());
}
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
@Test
void ws_registersDirectEndpoint() {
WebSocketHandler handler = new WebSocketHandler() {
public void onOpen(WebSocketSession session) {}
public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
};
assertSame(app, app.ws("/chat", handler));
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.extension;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PackageScannerTest {
@Test
void scan_separatesHttpHandlersAndWsEndpoints() {
PackageScanner.ScanResult result = PackageScanner.scan("dev.relism.flash.websocket.onlyws");
assertTrue(result.wsEndpoints().stream().anyMatch(c -> c.getSimpleName().equals("OnlyWsEndpoint")));
assertFalse(result.httpHandlers().stream().anyMatch(c -> c.getSimpleName().equals("OnlyWsEndpoint")));
}
@Test
void scan_includesHttpHandlerAndWsEndpointFromTestPackage() {
PackageScanner.ScanResult result = PackageScanner.scan("dev.relism.flash.websocket.scantest");
assertTrue(result.httpHandlers().stream().anyMatch(c -> c.getSimpleName().equals("ScanHttpHandler")));
assertTrue(result.wsEndpoints().stream().anyMatch(c -> c.getSimpleName().equals("ScanWsEndpoint")));
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.routing;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.websocket.WebSocketHandler;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AbstractWsRouterTest {
static class DummyWsRouter extends AbstractWsRouter {
WebSocketHandler lastHandler;
HttpMethod lastMethod;
String lastPath;
@Override
public WebSocketHandler route(Request request) { return null; }
@Override
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
this.lastMethod = method;
this.lastPath = path;
this.lastHandler = handler;
return this;
}
}
@Test
void register_sanitizesPathAndStoresHandler() {
DummyWsRouter router = new DummyWsRouter();
WebSocketHandler handler = new WebSocketHandler() {
public void onOpen(dev.relism.flash.websocket.WebSocketSession session) {}
public void onMessage(dev.relism.flash.websocket.WebSocketSession session, dev.relism.flash.websocket.WebSocketFrame frame) {}
};
router.register(HttpMethod.GET, "chat/", handler);
assertEquals(HttpMethod.GET, router.lastMethod);
assertEquals("/chat", router.lastPath);
assertSame(handler, router.lastHandler);
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.websocket;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketEndpointTest {
static class DummyEndpoint extends WebSocketEndpoint {
boolean initCalled;
@Override
protected void onInit() {
initCalled = true;
}
@Override
public void onOpen(WebSocketSession session) {}
@Override
public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
}
@Test
void bind_callsOnInit() {
DummyEndpoint endpoint = new DummyEndpoint();
endpoint.bind(new FlashContext());
assertTrue(endpoint.initCalled);
}
@Test
void requireBeforeBind_throws() {
DummyEndpoint endpoint = new DummyEndpoint();
assertThrows(IllegalStateException.class, () -> endpoint.require(String.class));
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.websocket;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketFrameTest {
@Test
void copyPayload_copiesActiveSlice() {
WebSocketFrame frame = new WebSocketFrame();
byte[] buf = "hello".getBytes();
frame.reset(buf, 1, 3, WebSocketFrame.OP_TEXT, true);
byte[] copy = frame.copyPayload();
assertArrayEquals("ell".getBytes(), copy);
assertNotSame(buf, copy);
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
assertTrue(frame.isFin());
assertEquals(1, frame.payloadOffset());
assertEquals(3, frame.payloadLength());
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.websocket;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketSessionFrameTest {
@Test
void readFrame_unmasksMaskedPayload() throws Exception {
byte[] raw = new byte[] {
(byte) 0x81,
(byte) 0x85,
1, 2, 3, 4,
(byte) ('h' ^ 1),
(byte) ('i' ^ 2),
(byte) ('!' ^ 3),
(byte) ('!' ^ 4),
(byte) ('?' ^ 1)
};
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 16);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
assertTrue(frame.isFin());
assertEquals(5, frame.payloadLength());
assertEquals("hi!!?", new String(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength()));
}
@Test
void readFrame_rejectsOversizedPayload() {
byte[] raw = new byte[] {(byte) 0x82, (byte) 0x7E, 0x01, 0x00};
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 8);
WebSocketFrame frame = new WebSocketFrame();
assertThrows(Exception.class, () -> session.readFrame(frame));
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.websocket;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketSessionTest {
@Test
void close_setsClosedAndWritesFrame() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), out, 64);
session.close(1000);
assertFalse(session.isOpen());
assertEquals(1000, session.closeCode());
byte[] bytes = out.toByteArray();
assertEquals((byte) 0x88, bytes[0]);
assertEquals((byte) 0x02, bytes[1]);
assertEquals((byte) 0x03, bytes[2]);
assertEquals((byte) 0xE8, bytes[3]);
}
@Test
void closeFromPeer_extractsCloseCode() {
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64);
WebSocketFrame frame = new WebSocketFrame();
frame.reset(new byte[] {(byte) 0x03, (byte) 0xE8}, 0, 2, WebSocketFrame.OP_CLOSE, true);
session.closeFromPeer(frame);
assertFalse(session.isOpen());
assertEquals(1000, session.closeCode());
}
}
@@ -0,0 +1,22 @@
package dev.relism.flash.websocket.onlyws;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.websocket.WebSocketEndpoint;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketSession;
@Ws("/only")
public class OnlyWsEndpoint extends WebSocketEndpoint {
@Override
public void onOpen(WebSocketSession session) {}
@Override
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
try {
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,16 @@
package dev.relism.flash.websocket.scantest;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
@GET("/http")
public class ScanHttpHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return "http";
}
}
@@ -0,0 +1,22 @@
package dev.relism.flash.websocket.scantest;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.websocket.WebSocketEndpoint;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketSession;
@Ws("/ws")
public class ScanWsEndpoint extends WebSocketEndpoint {
@Override
public void onOpen(WebSocketSession session) {}
@Override
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
try {
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
+49
View File
@@ -0,0 +1,49 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
projectJDK: "21" #(Applied in CI/CD pipeline)
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Qodana supports other languages, for example, Python, JavaScript, TypeScript, Go, C#, PHP
#For all supported languages see https://www.jetbrains.com/help/qodana/linters.html
linter: jetbrains/qodana-jvm-community:2025.3