16 Commits
Author SHA1 Message Date
Relism 4032567d75 Merge pull request 'feat(core): add HTTP QUERY method support' (#5) from feature/core/http-query-method into master
CI / Build & Test (push) Canceled after 5s
Publish Snapshot / Deploy Snapshot (push) Canceled after 7s
Reviewed-on: #5
2026-08-10 13:29:24 +00:00
Zakaria El OrcheandClaude Sonnet 5 fa0a2d79b4 feat(core): add HTTP QUERY method support
CI / Build & Test (push) Failing after 4m55s
CI / Build & Test (pull_request) Canceled after 50s
Adds the QUERY method (RFC 10008) — safe and idempotent like GET,
but carries a request body like POST, useful for complex filters
that don't fit in a URL query string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 13:09:26 +00:00
Relism 391ae6778e Merge pull request 'feat(core): ALPN configuration and TLS visibility on Request/WebSocketSession' (#4) from feature/core/tls-alpn into master
Publish Snapshot / Deploy Snapshot (push) Failing after 4m48s
CI / Build & Test (push) Failing after 5m4s
Reviewed-on: #4
2026-08-09 23:38:27 +00:00
Zakaria El OrcheandClaude Sonnet 5 1b48d14b4f feat(core): ALPN configuration and TLS visibility on Request/WebSocketSession
CI / Build & Test (push) Failing after 5m3s
CI / Build & Test (pull_request) Failing after 4m53s
- TlsConfig.applicationProtocols(String...) sets the listener's negotiable
  ALPN protocol list via SSLParameters, inherited by every accepted socket
  like clientAuth — works on both keystore() and ofContext(), untouched
  unless called. Enables TLS-ALPN-01 (RFC 8737) style on-demand cert
  issuance: a custom KeyManager can read the already-resolved protocol via
  engine/socket getHandshakeApplicationProtocol() inside
  chooseEngineServerAlias/chooseServerAlias, since ALPN is resolved during
  ClientHello/ServerHello, always before Certificate production.
- Request gains isSecure()/sslSession(), threaded through RequestParser from
  the accepted SSLSocket exactly like remoteAddress() — reference-only,
  zero per-request allocation. sslSession() defers to SSLSocket#getSession()
  lazily, so it's a cached-field read (handshake already completed by the
  time a handler can call it), never a forced handshake.
- WebSocketSession.isSecure()/sslSession() delegate to the upgrading
  Request rather than tracking the socket a second time.
- Documents TLS end-to-end in README.md (listeners, TlsConfig, SNI, ALPN,
  mTLS, Request/WebSocketSession accessors).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:34:22 +00:00
Relism 7cd8b3869c Merge pull request 'feat(core): add TLS/mTLS support with multi-listener and SNI' (#3) from feature/core/tls-support into master
Publish Snapshot / Deploy Snapshot (push) Failing after 4m58s
CI / Build & Test (push) Failing after 4m59s
Reviewed-on: #3
2026-08-09 22:23:53 +00:00
Zakaria El OrcheandClaude Sonnet 5 9f6808e90c feat(core): add TLS/mTLS support with multi-listener and SNI
CI / Build & Test (pull_request) Failing after 4m54s
CI / Build & Test (push) Failing after 4m54s
Flash can now serve HTTPS and WSS, on one or many listeners per app:

- FlashConfiguration gains an optional `tls` field for the single default
  listener, and a `listeners` list for apps that bind multiple ports (each
  independently plain or TLS).
- New dev.relism.flash.tls package: TlsConfig.keystore(path, password) builds
  an SSLContext from a PKCS12/JKS keystore, with SNI-based certificate
  selection for free when the keystore holds more than one alias (matched by
  SAN/CN, pure JDK APIs). TlsConfig.ofContext(sslContext) is a full escape
  hatch — Flash never calls setSSLParameters on that path, so caller-set
  protocols/cipher suites/ALPN survive untouched. TlsConfig.clientAuth(...)
  adds optional/required mTLS on either path.
- HttpServer moves from a single ServerSocket to a list of bound listeners;
  the per-request hot path (RequestParser, routing, response writing) is
  untouched — TLS only changes which bytes come out of accept(), so WSS needs
  no separate code path from WS.
- process()'s catch is widened to log non-IOException failures (e.g. a
  misbehaving custom KeyManager/TrustManager on the ofContext path) instead
  of swallowing them silently; the failure was already isolated to the one
  connection via the existing try-with-resources/executor-submission
  boundary — this only fixes the missing log line.
- Fixes a pre-existing gap where FlashConfiguration#host was accepted but
  never used to bind (listeners always bound to the wildcard address).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:41:57 +00:00
Relism bf2d54ca1a Merge pull request 'feat(core): WS client-mode masking, zero-copy header iteration, shared I/O relay buffer' (#1) from feature/core/ws-client-mode-io-reuse into master
Publish Snapshot / Deploy Snapshot (push) Failing after 5m32s
CI / Build & Test (push) Failing after 5m38s
Reviewed-on: #1
2026-08-09 20:38:20 +00:00
Zakaria El OrcheandClaude Sonnet 5 a037456634 feat(core): WS client-mode masking, zero-copy header iteration, shared I/O relay buffer
CI / Build & Test (push) Failing after 6m7s
CI / Build & Test (pull_request) Failing after 4m56s
- WebSocketSession supports client-mode outgoing frame masking (RFC 6455) via
  in-place XOR, reusing the unmask routine already used for inbound frames.
- HeaderMap gains an allocation-free forEach(HeaderConsumer) for callers that
  must handle an open-ended set of header names (e.g. proxying).
- HttpServer relays streaming/chunked response bodies through a shared
  per-connection ThreadLocal buffer instead of relying on InputStream#transferTo
  (which allocates internally) or a fresh byte[8192] per chunked write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:24:56 +00:00
Relism 524bdeb28b Fix badge HTML syntax in docs workflow 2026-05-12 16:34:59 +02:00
Relism c619c17949 Refactor JavaDoc generation and verification steps 2026-05-12 16:28:20 +02:00
Relism c368843ad4 ci: fix javadoc path, encoding and delegate docs to docs.yml 2026-05-12 16:15:33 +02:00
Relism 35293a0a57 feat(ci): add workflow for publishing JavaDoc to GitHub Pages 2026-05-12 15:42:39 +02:00
Relism c514897ffc fix(ci): publish versioned javadocs only on gh-pages 2026-05-11 16:53:17 +02:00
Relism 6314bcff5f fix(ci): correct javadoc publish path and maven settings schema 2026-05-11 16:18:49 +02:00
Relism ccc5550598 feat: introduce WebSocket support with new endpoints and transaction propagation enhancements 2026-05-11 16:14:26 +02:00
github-actions[bot] a4a16bdb00 chore(release): prepare 2.1.0-SNAPSHOT 2026-05-11 13:54:19 +00:00
87 changed files with 4536 additions and 812 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/maven-1.0.0.xsd"> http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers> <servers>
<server> <server>
<id>Personal</id> <id>Personal</id>
+348
View File
@@ -0,0 +1,348 @@
name: Publish Docs
on:
workflow_dispatch:
inputs:
version:
description: 'Docs version to publish (e.g. 2.1.0)'
required: true
type: string
workflow_call:
inputs:
version:
description: 'Docs version to publish (e.g. 2.1.0)'
required: true
type: string
secrets:
MAVEN_USERNAME:
required: true
MAVEN_PASSWORD:
required: true
jobs:
docs:
name: Build JavaDoc & Update gh-pages
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Build project (compile + resolve deps, skip tests)
run: mvn -B --settings .github/settings.xml clean verify -DskipTests
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
# javadoc:aggregate runs on the reactor root.
# With flash + flash-extensions both declared as modules in root pom.xml,
# this produces a single aggregated Javadoc covering all modules.
# Default output path: target/site/apidocs/ (no custom reportOutputDirectory set).
- name: Generate aggregated JavaDoc
run: |
mvn -B \
--settings .github/settings.xml \
-DskipTests \
org.apache.maven.plugins:maven-javadoc-plugin:3.6.3:aggregate
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Verify JavaDoc output exists
run: |
APIDOCS=""
if [ -f "target/site/apidocs/index.html" ]; then
APIDOCS="target/site/apidocs"
elif [ -f "target/reports/apidocs/index.html" ]; then
APIDOCS="target/reports/apidocs"
else
echo "ERROR: JavaDoc output not found."
echo
echo "Contents of target/:"
find target -maxdepth 5 2>/dev/null || echo "(empty)"
exit 1
fi
echo "APIDOCS_DIR=$APIDOCS" >> $GITHUB_ENV
COUNT=$(find "$APIDOCS" -name '*.html' | wc -l)
echo "JavaDoc OK — $COUNT HTML files at $APIDOCS"
- name: Checkout gh-pages
uses: actions/checkout@v4
with:
ref: gh-pages
path: gh-pages-out
token: ${{ secrets.GITHUB_TOKEN }}
- name: Copy JavaDoc to versioned folder and latest
run: |
VERSION=${{ inputs.version }}
mkdir -p gh-pages-out/javadoc/$VERSION
cp -r "$APIDOCS_DIR"/. gh-pages-out/javadoc/$VERSION/
rm -rf gh-pages-out/latest
mkdir -p gh-pages-out/latest
cp -r "$APIDOCS_DIR"/. gh-pages-out/latest/
- name: Regenerate index.html
run: |
cd gh-pages-out
python3 - <<'EOF'
import os, re
def version_key(v):
parts = re.findall(r'\d+', v)
return [int(p) for p in parts] if parts else [0]
versions = sorted(
[d for d in os.listdir("javadoc") if os.path.isdir(f"javadoc/{d}")],
key=version_key,
reverse=True
)
latest = versions[0] if versions else None
rows = "\n".join(
f'''
<div class="release">
<div class="release-info">
<span class="version">{v}</span>
{"<span class='badge'>latest</span>" if v == latest else ""}
</div>
<a href="javadoc/{v}/index.html">Open</a>
</div>
'''
for v in versions
)
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flash — JavaDoc</title>
<style>
:root {
--bg: #ffffff;
--surface: #fafafa;
--border: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--accent: #111827;
--accent-hover: #000000;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 64px 24px;
}
.container {
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.header {
margin-bottom: 40px;
}
.header h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.04em;
}
.header p {
margin-top: 10px;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.6;
}
.latest {
display: inline-flex;
align-items: center;
margin-top: 20px;
padding-bottom: 2px;
color: var(--accent);
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
border-bottom: 1px solid transparent;
transition:
border-color 0.15s ease,
color 0.15s ease;
}
.latest:hover {
border-color: var(--accent);
color: var(--accent-hover);
}
.list {
border-top: 1px solid var(--border);
}
.release {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 0;
border-bottom: 1px solid var(--border);
}
.release-info {
display: flex;
align-items: center;
gap: 12px;
}
.version {
font-size: 0.96rem;
font-weight: 500;
letter-spacing: -0.01em;
}
.badge {
font-size: 0.72rem;
font-weight: 600;
color: var(--muted);
border: 1px solid var(--border);
padding: 2px 8px;
}
.release a {
color: var(--accent);
text-decoration: none;
font-size: 0.92rem;
}
.release a:hover {
text-decoration: underline;
}
.empty {
padding: 32px 0;
color: var(--muted);
font-size: 0.95rem;
}
@media (max-width: 640px) {
body {
padding: 40px 20px;
}
.header h1 {
font-size: 1.7rem;
}
.release {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>Flash JavaDoc</h1>
<p>
API documentation for all published Flash releases.
</p>
""" + (
f'''
<a class="latest" href="latest/index.html">
Latest release — {latest}
</a>
'''
if latest else ""
) + """
</header>
""" + (
f'''
<div class="list">
{rows}
</div>
'''
if rows else
'''
<div class="empty">
No versions published yet.
</div>
'''
) + """
</div>
</body>
</html>"""
with open("index.html", "w", encoding="utf-8") as f:
f.write(html)
print(f"index.html generated — {len(versions)} version(s): {versions}")
EOF
- name: Push gh-pages
run: |
cd gh-pages-out
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "docs(javadoc): publish ${{ inputs.version }}"
git push origin gh-pages
+18 -84
View File
@@ -6,13 +6,15 @@ on:
- 'v*' - 'v*'
jobs: jobs:
# ── 1. Build, GPG-sign, deploy to Maven releases ──────────────────────────
release: release:
name: Build, Sign, Deploy & Publish name: Build, Sign & Deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
pages: write
id-token: write outputs:
version: ${{ steps.version.outputs.VERSION }}
steps: steps:
- name: Checkout - name: Checkout
@@ -47,90 +49,22 @@ jobs:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Generate aggregated JavaDoc
run: |
mvn -B --settings .github/settings.xml \
-pl flash,flash-extensions -am \
javadoc:aggregate -DskipTests
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Checkout gh-pages
uses: actions/checkout@v4
with:
ref: gh-pages
path: gh-pages-out
token: ${{ secrets.GITHUB_TOKEN }}
- name: Copy JavaDoc to versioned folder
run: |
VERSION=${{ steps.version.outputs.VERSION }}
mkdir -p gh-pages-out/javadoc/$VERSION
cp -r target/reports/apidocs/. gh-pages-out/javadoc/$VERSION/
rm -rf gh-pages-out/latest
mkdir -p gh-pages-out/latest
cp -r target/reports/apidocs/. gh-pages-out/latest/
- name: Regenerate index.html
run: |
cd gh-pages-out
python3 - <<'EOF'
import os, re
versions = sorted(
[d for d in os.listdir("javadoc") if os.path.isdir(f"javadoc/{d}")],
key=lambda v: [int(x) for x in re.sub(r'[^0-9.]', '', v).split('.') if x],
reverse=True
)
rows = "\n".join(
f' <li><a href="javadoc/{v}/index.html">{v}</a></li>'
for v in versions
)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Flash JavaDoc</title>
<style>
body {{ font-family: sans-serif; max-width: 600px; margin: 4rem auto; }}
h1 {{ font-size: 1.6rem; }}
ul {{ line-height: 2; }}
a {{ color: #0070f3; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<h1>Flash — JavaDoc</h1>
<p><a href="latest/index.html">&#8594; Latest</a></p>
<h2>All versions</h2>
<ul>
{rows}
</ul>
</body>
</html>"""
with open("index.html", "w") as f:
f.write(html)
print(f"index.html generated with {len(versions)} versions: {versions}")
EOF
- name: Push gh-pages
run: |
cd gh-pages-out
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "docs(javadoc): release ${{ steps.version.outputs.VERSION }}"
git push origin gh-pages
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{ steps.version.outputs.VERSION }} tag_name: ${{ github.ref_name }}
name: v${{ steps.version.outputs.VERSION }} name: ${{ github.ref_name }}
generate_release_notes: true generate_release_notes: true
draft: false draft: false
prerelease: false prerelease: false
# ── 2. Publish JavaDoc (single source of truth: docs.yml) ─────────────────
docs:
name: Publish JavaDoc
needs: release
uses: ./.github/workflows/docs.yml
with:
version: ${{ needs.release.outputs.version }}
secrets:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+72 -283
View File
@@ -4,292 +4,42 @@
<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 WebSocket support with new endpoints and transaction propagation enhancements">
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/pom.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.github/workflows/release.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.github/workflows/release.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$/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/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/ResourceRegistry.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/TransactionIsolation.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/Tx.java" 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-core/src/main/java/dev/relism/flash/ext/data/core/TxException.java" 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-core/src/main/java/dev/relism/flash/ext/data/core/TxOutcome.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-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxStatus.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-extensions/flash-ext-data-hibernate/pom.xml" 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-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.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-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerTest.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-extensions/flash-ext-data-jdbc/pom.xml" 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-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.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-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerTest.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-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/Flash.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/radix/RadixPathRouterImpl.java" 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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
<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 +48,7 @@
</persistenceIdMap> </persistenceIdMap>
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="407" /> <option name="cachedIndexableFilesCount" value="448" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="FileTemplateManagerImpl"> <component name="FileTemplateManagerImpl">
@@ -534,7 +284,17 @@
<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="9828000" />
<workItem from="1778576795735" duration="5051000" />
</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 +392,31 @@
<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>
<task id="LOCAL-00015" summary="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
<option name="closed" value="true" />
<created>1778508899541</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1778508899541</updated>
</task>
<option name="localTasksCounter" value="16" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -674,7 +458,12 @@
<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" />
<MESSAGE value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
<option name="LAST_COMMIT_MESSAGE" value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
</component> </component>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />
+89
View File
@@ -163,8 +163,96 @@ app.onException((ex, req, res) -> {
|---|---|---| |---|---|---|
| `port` | — | TCP port to bind | | `port` | — | TCP port to bind |
| `host` | `"0.0.0.0"` | Bind address | | `host` | `"0.0.0.0"` | Bind address |
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) | | `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
## TLS
HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted
`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view
onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore
not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed.
### Quick start
```java
FlashApp.create(FlashConfiguration.builder()
.port(443)
.tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
.build())
.get("/ping", (req, res) -> "pong") // HTTPS
.ws("/live", handler) // WSS, same route API
.start();
```
### Multiple listeners
One app can bind any number of ports, each independently plain or TLS:
```java
FlashApp.create(FlashConfiguration.builder()
.listener(new FlashConfiguration.Listener(80)) // plain
.listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(cert, pass))) // TLS
.build());
```
A non-empty `listeners` list takes precedence over the top-level `port`/`host`/`tls` fields.
Each listener gets its own accept threads; the router, WS router, and virtual-thread executor
are shared by all of them — one app, N ports.
### `TlsConfig`
| Factory | Use |
|---|---|
| `TlsConfig.keystore(Path, String)` | Builds the `SSLContext` from a PKCS12/JKS keystore (type guessed from the extension). Pins `TLSv1.2`/`TLSv1.3` as enabled protocols; cipher suites are left at the JDK's own curated default. |
| `TlsConfig.ofContext(SSLContext)` | Escape hatch — the given `SSLContext` is used exactly as built. Flash never calls `setSSLParameters` on this path beyond what you explicitly request via `clientAuth`/`applicationProtocols`, so anything else you configured (custom `KeyManager`, ALPN, cipher suites) is authoritative. |
Chainable on either factory:
```java
TlsConfig.keystore(cert, pass)
.clientAuth(ClientAuth.REQUIRE) // mTLS: NONE (default) | OPTIONAL | REQUIRE
.applicationProtocols("acme-tls/1", "http/1.1") // ALPN, in preference order
```
**SNI** falls out of `keystore()` for free: a keystore holding more than one certificate entry
is matched against the requested hostname by each certificate's SAN (falling back to CN) — no
per-hostname config. The first entry in the keystore is the default when SNI is absent or
matches nothing (same convention as nginx/HAProxy's `default_server`).
**ALPN and custom certificate selection** (e.g. TLS-ALPN-01 / RFC 8737 for on-demand ACME
issuance): ALPN is resolved while consuming `ClientHello`/producing `ServerHello`, which always
precedes `Certificate` production. A custom `X509ExtendedKeyManager` passed via `ofContext`
can therefore read `engine.getHandshakeApplicationProtocol()` (or
`((SSLSocket) socket).getHandshakeApplicationProtocol()`) inside
`chooseEngineServerAlias`/`chooseServerAlias` — the negotiated protocol is already resolved by
then, so the certificate decision can key off it.
**mTLS with a private CA**: `clientAuth(...)` only requests/requires a client certificate;
`keystore()` deliberately doesn't expose a way to configure which CAs are trusted for that
certificate (it uses the JDK default trust store). For a private CA, build the `SSLContext`
yourself with a `TrustManagerFactory` and use `ofContext(...)`.
### Reading TLS info from a request
```java
app.get("/whoami", (req, res) -> {
if (!req.isSecure()) return "plain";
SSLSession session = req.sslSession(); // null iff !isSecure()
X509Certificate peer = (X509Certificate) session.getPeerCertificates()[0]; // mTLS only
return session.getCipherSuite() + " / " + session.getProtocol();
});
```
`Request.isSecure()` / `Request.sslSession()` cost nothing extra per request: the `SSLSocket`
reference is threaded through once per connection (same mechanism as `remoteAddress()`), and
`sslSession()` only calls `SSLSocket#getSession()` — a cached-field read once the handshake
that got the request this far has already completed, never a forced handshake.
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
upgrading `Request` — no separate TLS state is tracked for WS.
## Architecture ## Architecture
``` ```
@@ -181,6 +269,7 @@ ServerSocket.accept()
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation. - **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection. - **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported. - **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
## Build & test ## Build & test
+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.
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-data-core</artifactId> <artifactId>flash-ext-data-core</artifactId>
@@ -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))); }
@@ -19,4 +23,4 @@ public record Sort(List<Column> columns) {
next.add(new Column(column, asc)); next.add(new Column(column, asc));
return new Sort(next); return new Sort(next);
} }
} }
@@ -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.
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-data-hibernate</artifactId> <artifactId>flash-ext-data-hibernate</artifactId>
@@ -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,9 +151,17 @@ 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,35 +23,56 @@ 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();
s.beginTransaction(); boolean bound = false;
if (definition.readOnly()) s.setDefaultReadOnly(true); try {
if (definition.isolation() != TransactionIsolation.DEFAULT) { s.beginTransaction();
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level())); if (definition.readOnly()) s.setDefaultReadOnly(true);
if (definition.isolation() != TransactionIsolation.DEFAULT) {
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level()));
}
HibernateTxStatus status = new HibernateTxStatus(
s,
true,
definition.readOnly(),
suspended,
new HibernateTxStatus.RollbackMarker()
);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
bound = true;
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);
}
} }
HibernateTxStatus status = new HibernateTxStatus(
s,
true,
definition.readOnly(),
suspended,
new HibernateTxStatus.RollbackMarker()
);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
return status;
} }
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.
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-data-jdbc</artifactId> <artifactId>flash-ext-data-jdbc</artifactId>
@@ -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) {
this.table = table; super(tx);
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 void bindInsert(PreparedStatement ps, T entity) throws SQLException;
protected abstract T mapRow(ResultSet rs) throws SQLException; protected abstract void bindUpdate(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 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 {
status.connection().close(); if (status.connection() != null) {
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);
} }
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-jackson</artifactId> <artifactId>flash-ext-jackson</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-limiter</artifactId> <artifactId>flash-ext-limiter</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-oidc</artifactId> <artifactId>flash-ext-oidc</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-openapi</artifactId> <artifactId>flash-ext-openapi</artifactId>
@@ -118,7 +118,7 @@ public class OpenApiExtension implements FlashExtension {
"});\n" + "});\n" +
"</script>\n" + "</script>\n" +
"</body>\n" + "</body>\n" +
"</html>"; "</html>";
} }
private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) { private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) {
@@ -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);
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-routeviewer</artifactId> <artifactId>flash-ext-routeviewer</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-view-core</artifactId> <artifactId>flash-ext-view-core</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-view-jte</artifactId> <artifactId>flash-ext-view-jte</artifactId>
@@ -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);
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-view-thymeleaf</artifactId> <artifactId>flash-ext-view-thymeleaf</artifactId>
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-web-bundler</artifactId> <artifactId>flash-ext-web-bundler</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-parent</artifactId> <artifactId>flash-parent</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-parent</artifactId> <artifactId>flash-parent</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash</artifactId> <artifactId>flash</artifactId>
@@ -1,89 +1,251 @@
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.tls.TlsConfig;
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;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSocket;
import java.io.*; import java.io.*;
import java.net.InetSocketAddress; 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.ArrayList;
import java.util.Base64;
import java.util.List;
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 one {@link ServerSocket} per configured listener (plain or
* executor, and the keep-alive accept loop. Routing is delegated to a single * TLS), the virtual-thread executor, and the keep-alive accept loop. Routing is delegated to
* {@link AbstractRouter}. * HTTP and WS routers — identically, regardless of which listener accepted the connection.
* *
* <p>Package-private : use {@link FlashApp} as the single * <p>TLS is a transport-level concern only: once a {@link BoundListener} is bound, an accepted
* entry point. * {@link Socket} is either plain or an {@code SSLSocket} indistinguishably from here on —
* {@link #process} never branches on it. This is also why WSS needs no separate code path from
* WS: the WebSocket upgrade happens over whatever transport {@link #process} was handed.
*
* <h3>Allocation model</h3>
* <ul>
* <li>{@code LONG_BUF} (20 bytes) and {@code STREAM_RELAY_BUFFER} (8 KB, for a streaming
* {@link Response} body — see {@link #writeStreamingBody}) are the only {@link ThreadLocal}s
* kept here. Both are per-connection, not per-request: one virtual thread runs a
* connection's whole keep-alive request loop (see {@link #process}), so a handler that
* streams a large response on every request allocates its relay buffer once per
* connection, not once per request.</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 {
private final FlashConfiguration configuration; // ── Tuning constants ──────────────────────────────────────────────────────
private final ServerSocket serverSocket;
private final AbstractRouter router;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false;
private final AtomicReference<Thread> acceptThread = new AtomicReference<>();
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); * Number of platform threads competing on {@code serverSocket.accept()}.
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8); * Rule of thumb: number of available CPU cores, capped at 8.
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8); * More than this rarely helps — accept is cheap; the bottleneck is usually
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8); * the virtual-thread executor dispatching the connection handler.
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8); */
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8); private static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8);
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_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 List<BoundListener> boundListeners;
private final AbstractRouter router;
private final AbstractWsRouter wsRouter;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false;
/** Latch that reaches 0 when all accept threads, across all listeners, have exited. */
private final CountDownLatch acceptLatch;
/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */
private record BoundListener(ServerSocket socket, boolean secure) {}
// ── 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[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\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]);
/**
* Relay buffer for copying a streaming {@link Response} body to the client — shared by
* {@link #writeStreamingBody}'s non-chunked path and {@link #writeChunked}, so both draw
* from the same reused array instead of each allocating its own {@code byte[8192]} (the
* non-chunked path previously relied on {@link InputStream#transferTo}, which allocates
* internally on every call). Sized to match the pre-existing behavior this replaces, not
* newly tuned — not exposed as a {@link FlashConfiguration} tunable since nothing here
* needed one before.
*/
private static final int STREAM_RELAY_BUFFER_SIZE = 8192;
private static final ThreadLocal<byte[]> STREAM_RELAY_BUFFER =
ThreadLocal.withInitial(() -> new byte[STREAM_RELAY_BUFFER_SIZE]);
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;
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
? List.of(new FlashConfiguration.Listener(
configuration.getPort(), configuration.getHost(), configuration.getTls()))
: configuration.getListeners();
List<BoundListener> bound = new ArrayList<>(specs.size());
for (FlashConfiguration.Listener spec : specs) bound.add(bind(spec));
this.boundListeners = List.copyOf(bound);
this.acceptLatch = new CountDownLatch(ACCEPT_THREADS * boundListeners.size());
for (BoundListener bl : boundListeners) {
log.info("HttpServer bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
ACCEPT_BACKLOG, ACCEPT_THREADS);
}
} }
/**
* Binds one listener. A TLS listener gets its {@link ServerSocket} from
* {@link TlsConfig#serverSocketFactory()} instead of {@code new ServerSocket()}, and its
* protocol/client-auth parameters from {@link TlsConfig#applyTo} — reuse-address, receive
* buffer size, backlog and the bind call itself are identical either way. TLS only changes
* which bytes come out of {@code accept()}; it never changes how the accept loop, or
* anything downstream of it, treats them.
*/
private static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
TlsConfig tls = spec.tls();
ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
// setReuseAddress(true) must be called BEFORE bind().
socket.setReuseAddress(true);
socket.setReceiveBufferSize(SOCKET_BUF_SIZE);
if (tls != null) tls.applyTo((SSLServerSocket) socket);
InetSocketAddress addr = spec.host() != null
? new InetSocketAddress(spec.host(), spec.port())
: new InetSocketAddress(spec.port());
socket.bind(addr, ACCEPT_BACKLOG);
return new BoundListener(socket, tls != null);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override @Override
public void start() { public void start() {
acceptThread.set(Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run)); for (int li = 0; li < boundListeners.size(); li++) {
BoundListener listener = boundListeners.get(li);
for (int i = 0; i < ACCEPT_THREADS; i++) {
Thread.ofPlatform()
.name("flash-accept-" + li + "-" + i)
.daemon(false)
.start(() -> acceptLoop(listener));
}
}
} }
@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() { /**
while (!stopped) { * Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
try { * platform threads bound to one {@code listener}. All threads for that listener block on
process(serverSocket.accept()); * the same {@link ServerSocket}; the JVM ensures only one wakes per incoming connection
} catch (IOException e) { * (no thundering herd). Other listeners' accept threads are entirely independent.
if (!stopped) log.error("Accept loop error", e); */
private void acceptLoop(BoundListener listener) {
try {
while (!stopped) {
try {
process(listener.socket().accept());
} catch (IOException e) {
if (!stopped) log.error("Accept error", e);
}
} }
} finally {
acceptLatch.countDown();
} }
} }
@@ -91,7 +253,9 @@ class HttpServer implements ServerHandle {
public CompletableFuture<Void> stop() { public CompletableFuture<Void> stop() {
return CompletableFuture.runAsync(() -> { return CompletableFuture.runAsync(() -> {
stopped = true; stopped = true;
try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); } for (BoundListener bl : boundListeners) {
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
}
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} }); activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
executorService.shutdown(); executorService.shutdown();
try { try {
@@ -104,24 +268,63 @@ class HttpServer implements ServerHandle {
}); });
} }
// ── Hot-path ───────────────────────────────────────────────────────────── // ── Hot-path ─────────────────────────────────────────────────────────────
private void process(Socket socket) { private void process(Socket socket) {
try { try {
executorService.submit(() -> { executorService.submit(() -> {
activeSockets.add(socket); activeSockets.add(socket);
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(),
socket instanceof SSLSocket sslSocket ? sslSocket : null);
while (!stopped) { while (!stopped) {
Request request = parser.parse(in); Request request = parser.parse(in);
if (request == null) break; if (request == null) break;
boolean keepAlive = isKeepAlive(request); if (request.method() == HttpMethod.GET && isWebSocketUpgrade(request)) {
Response response = new Response(200, ContentType.TEXT_PLAIN); 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(), request, false);
runWsLoop(session, wsHandler);
return;
}
boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = router.route(request); RequestHandler handler = router.route(request);
if (handler == null) handler = router.getNotFoundHandler(); if (handler == null) handler = router.getNotFoundHandler();
@@ -140,6 +343,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)
@@ -147,23 +351,121 @@ class HttpServer implements ServerHandle {
else else
log.error("I/O error handling request", e); log.error("I/O error handling request", e);
} }
} catch (Exception e) {
// Anything not an IOException here means a collaborator misbehaved on the TLS
// handshake path — most likely a custom TlsConfig#ofContext KeyManager/
// TrustManager throwing (e.g. a failed DB lookup or on-demand cert issuance).
// That failure is isolated to this one virtual thread/connection: the
// try-with-resources above still closes the socket, the finally below still
// runs, and the accept loop (a different thread entirely) never sees this.
if (!stopped) log.error("Unexpected error handling connection", e);
} finally { } finally {
activeSockets.remove(socket); activeSockets.remove(socket);
} }
}); });
} 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);
byte[] statusBytes = response.getStatusBytes(); byte[] statusBytes = response.getStatusBytes();
@@ -196,7 +498,7 @@ class HttpServer implements ServerHandle {
out.write(CRLF); out.write(CRLF);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF); out.write(CRLF);
response.getStream().transferTo(out); relay(response.getStream(), out);
} else { } else {
out.write(TRANSFER_CHUNKED); out.write(TRANSFER_CHUNKED);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
@@ -205,6 +507,18 @@ class HttpServer implements ServerHandle {
} }
} }
/**
* Copies {@code in} to {@code out} until EOF, same contract as {@link InputStream#transferTo}
* — but via {@link #STREAM_RELAY_BUFFER} instead of a fresh {@code byte[]} per call, which is
* what {@code transferTo}'s own (JDK-internal) implementation would otherwise allocate on
* every streamed response.
*/
private static void relay(InputStream in, OutputStream out) throws IOException {
byte[] buf = STREAM_RELAY_BUFFER.get();
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
}
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException { private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
byte[] phrase = HttpStatus.bytesForCode(statusCode); byte[] phrase = HttpStatus.bytesForCode(statusCode);
if (phrase != null) out.write(phrase); if (phrase != null) out.write(phrase);
@@ -223,7 +537,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 = STREAM_RELAY_BUFFER.get();
int n; int n;
while ((n = stream.read(buf)) > 0) { while ((n = stream.read(buf)) > 0) {
writeHex(out, n); writeHex(out, n);
@@ -235,13 +549,16 @@ class HttpServer implements ServerHandle {
} }
private static void writeHex(OutputStream out, int value) throws IOException { private static void writeHex(OutputStream out, int value) throws IOException {
int shift = 28; int shift = 28;
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');
} }
} }
@@ -8,38 +8,78 @@ import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLSocket;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress; 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 SSLSocket sslSocket;
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
public RequestParser() { this(64 * 1024, null); } // Unconsumed bytes belonging to the NEXT request.
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); } // 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, null); }
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null, null); }
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) { public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) {
this(maxHeaderBufferSize, remoteAddress, null);
}
/** {@code sslSocket} is {@code null} for a plain connection — see {@link Request#isSecure()}. */
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
this.maxHeaderBufferSize = maxHeaderBufferSize; this.maxHeaderBufferSize = maxHeaderBufferSize;
this.remoteAddress = remoteAddress; this.remoteAddress = remoteAddress;
this.sslSocket = sslSocket;
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 +89,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 +110,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)");
@@ -81,20 +123,23 @@ public class RequestParser {
if (pathEnd == -1) throw new IOException("Invalid request line (path)"); if (pathEnd == -1) throw new IOException("Invalid request line (path)");
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?'); int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart, FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart); queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
FastPathViews.RequestByteView queryView = queryMark != -1 FastPathViews.RequestByteView queryView = queryMark != -1
? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1) ? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1)
: null; : null;
int protocolStart = pathEnd + 1; int protocolStart = pathEnd + 1;
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;
long contentLength = 0; long contentLength = 0;
boolean isChunked = false; boolean isChunked = false;
@@ -118,35 +163,40 @@ 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;
preBufLen = 0; preBufLen = 0;
} else if (!isChunked && contentLength > 0 && preBufLen > contentLength) { } else if (!isChunked && contentLength > 0 && preBufLen > contentLength) {
bufBase = bodyStart + (int) contentLength; bufBase = bodyStart + (int) contentLength;
bufLen = preBufLen - (int) contentLength; bufLen = preBufLen - (int) contentLength;
preBufLen = (int) contentLength; preBufLen = (int) contentLength;
} }
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, sslSocket);
} }
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress); return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket);
} }
// ── 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;
} }
@@ -177,4 +227,4 @@ public class RequestParser {
} }
return value; return value;
} }
} }
@@ -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,17 +58,19 @@ 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 int port; private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
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();
} }
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 +91,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;
} }
@@ -127,7 +155,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
public FlashApp start() { public FlashApp start() {
boot(); boot();
server.start(); server.start();
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port); if (Flash.DEV) log.info("[Flash] Started (dev mode) — see HttpServer bind logs above for listener details");
return this; return this;
} }
@@ -137,7 +165,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
*/ */
public void startAndBlock() { public void startAndBlock() {
boot(); boot();
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port); if (Flash.DEV) log.info("[Flash] Started (dev mode) — see HttpServer bind logs above for listener details");
server.startAndBlock(); server.startAndBlock();
} }
@@ -152,6 +180,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 +196,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 +221,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 +240,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();
@@ -1,8 +1,13 @@
package dev.relism.flash.extension; package dev.relism.flash.extension;
import dev.relism.flash.tls.TlsConfig;
import lombok.Builder; import lombok.Builder;
import lombok.Singular;
import lombok.Value; import lombok.Value;
import java.util.List;
/** /**
* Configuration for a {@link FlashApp} instance. * Configuration for a {@link FlashApp} instance.
* *
@@ -16,6 +21,18 @@ import lombok.Value;
* .host("127.0.0.1") * .host("127.0.0.1")
* .maxHeaderBufferSize(128 * 1024) * .maxHeaderBufferSize(128 * 1024)
* .build()); * .build());
*
* // TLS on the single default listener
* FlashApp.create(FlashConfiguration.builder()
* .port(443)
* .tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
* .build());
*
* // Multiple listeners on one app — takes precedence over port/host/tls above
* FlashApp.create(FlashConfiguration.builder()
* .listener(new FlashConfiguration.Listener(80))
* .listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(Path.of("cert.p12"), "changeit")))
* .build());
* }</pre> * }</pre>
*/ */
@Value @Value
@@ -25,7 +42,24 @@ public class FlashConfiguration {
int port; int port;
String host; String host;
/** TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link #listeners} is non-empty. */
TlsConfig tls;
/** One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link #host}/{@link #tls}. */
@Singular
List<Listener> listeners;
/** 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;
/** One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. */
public record Listener(int port, String host, TlsConfig tls) {
public Listener(int port) { this(port, null, null); }
public Listener(int port, TlsConfig tls) { this(port, null, tls); }
}
} }
@@ -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;
@@ -43,6 +45,7 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
public final SELF trace (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.TRACE, path, h, mw); } public final SELF trace (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.TRACE, path, h, mw); }
public final SELF connect(String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.CONNECT, path, h, mw); } public final SELF connect(String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.CONNECT, path, h, mw); }
public final SELF purge (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PURGE, path, h, mw); } public final SELF purge (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PURGE, path, h, mw); }
public final SELF query (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.QUERY, path, h, mw); }
// ── Middleware ──────────────────────────────────────────────────────────── // ── Middleware ────────────────────────────────────────────────────────────
@@ -68,10 +71,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 +95,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 +113,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"); }
}
} }
@@ -5,7 +5,7 @@ import java.nio.charset.StandardCharsets;
@Getter @Getter
public enum HttpMethod { public enum HttpMethod {
GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, TRACE, CONNECT, PURGE; GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, TRACE, CONNECT, PURGE, QUERY;
private static final HttpMethod[] VALUES = values(); private static final HttpMethod[] VALUES = values();
private final byte[] bytes; private final byte[] bytes;
@@ -35,6 +35,7 @@ public enum HttpMethod {
case 'H' -> (len == 4 && buf[off + 1] == 'E' && buf[off + 2] == 'A' && buf[off + 3] == 'D') ? HEAD : null; case 'H' -> (len == 4 && buf[off + 1] == 'E' && buf[off + 2] == 'A' && buf[off + 3] == 'D') ? HEAD : null;
case 'T' -> (len == 5 && buf[off + 1] == 'R' && buf[off + 2] == 'A' && buf[off + 3] == 'C' && buf[off + 4] == 'E') ? TRACE : null; case 'T' -> (len == 5 && buf[off + 1] == 'R' && buf[off + 2] == 'A' && buf[off + 3] == 'C' && buf[off + 4] == 'E') ? TRACE : null;
case 'C' -> (len == 7 && buf[off + 1] == 'O' && buf[off + 2] == 'N' && buf[off + 3] == 'N' && buf[off + 4] == 'E' && buf[off + 5] == 'C' && buf[off + 6] == 'T') ? CONNECT : null; case 'C' -> (len == 7 && buf[off + 1] == 'O' && buf[off + 2] == 'N' && buf[off + 3] == 'N' && buf[off + 4] == 'E' && buf[off + 5] == 'C' && buf[off + 6] == 'T') ? CONNECT : null;
case 'Q' -> (len == 5 && buf[off + 1] == 'U' && buf[off + 2] == 'E' && buf[off + 3] == 'R' && buf[off + 4] == 'Y') ? QUERY : null;
default -> null; default -> null;
}; };
} }
@@ -36,6 +36,13 @@ public class HeaderMap {
private int sectionStart; private int sectionStart;
private int sectionEnd; private int sectionEnd;
// Lazily created, then reused for the life of this HeaderMap (i.e. the connection —
// see the class javadoc) across every #forEach call and every header within a call.
// Same idiom as #view's per-call anonymous ByteView, just amortized to zero allocations
// instead of two per header: the slices are repositioned in place, not reallocated.
private Slice nameSlice;
private Slice valueSlice;
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */ /** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */
public void reset(byte[] buffer, int sectionStart, int sectionEnd) { public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
this.buffer = buffer; this.buffer = buffer;
@@ -43,6 +50,62 @@ public class HeaderMap {
this.sectionEnd = sectionEnd; this.sectionEnd = sectionEnd;
} }
/**
* Visits every header in declaration order without allocating — no per-header {@code
* String}/{@link ByteView}/list-entry object, unlike {@link #all()}. {@code name}/{@code
* value} are the same two {@link ByteView} instances on every call, repositioned in place;
* they are valid only for the duration of that single {@link HeaderConsumer#accept} call —
* same "do not retain past the handler" rule as {@link #view}, just per-invocation instead
* of per-request. Prefer a non-capturing or field-reusing {@link HeaderConsumer} (see its
* javadoc) if the call site itself needs to stay allocation-free too.
*
* <p>Exists for callers that must handle an open-ended set of header names — e.g. a reverse
* proxy forwarding whatever the client sent — where {@link #first}/{@link #all}'s per-name
* lookup isn't usable because the set of names isn't known upfront.
*/
public void forEach(HeaderConsumer consumer) {
if (buffer == null) return;
if (nameSlice == null) {
nameSlice = new Slice();
valueSlice = new Slice();
}
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1) {
int vs = skipSpaces(colon + 1, lineEnd);
nameSlice.start = i;
nameSlice.len = colon - i;
valueSlice.start = vs;
valueSlice.len = lineEnd - vs;
consumer.accept(nameSlice, valueSlice);
}
i = lineEnd + 2;
}
}
/**
* Callback for {@link #forEach}. Implement with a reusable, field-holding instance (reset
* before each {@code forEach} call) rather than a capturing lambda if the call site itself
* needs to be allocation-free too — a capturing lambda is its own per-call allocation, same
* as anywhere else on a hot path (see {@code docs/CODE-STYLE.md} in the Pathway project for
* the idiom this mirrors).
*/
@FunctionalInterface
public interface HeaderConsumer {
void accept(ByteView name, ByteView value);
}
/** Mutable zero-copy slice into {@link #buffer} — see {@link #forEach}. */
private final class Slice implements ByteView {
int start;
int len;
@Override public int length() { return len; }
@Override public byte byteAt(int i) { return buffer[start + i]; }
}
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */ /** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
public String first(String name) { public String first(String name) {
long r = findFirst(name); long r = findFirst(name);
@@ -9,6 +9,9 @@ import lombok.ToString;
import lombok.Value; import lombok.Value;
import lombok.experimental.NonFinal; import lombok.experimental.NonFinal;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -60,12 +63,30 @@ public class Request {
@ToString.Exclude @ToString.Exclude
InetSocketAddress remoteAddress; InetSocketAddress remoteAddress;
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress) { /**
* The accepted socket for this connection, or {@code null} if plain HTTP — set once per
* connection by {@link RequestParser}, same lifetime and reference-only cost as
* {@link #remoteAddress}. Every request on the same keep-alive connection shares the
* identical instance.
*
* <p>Never exposed directly: {@link #isSecure()} and {@link #sslSession()} are the public
* surface. {@link javax.net.ssl.SSLSocket#getSession()} is deferred to {@link #sslSession()}
* rather than called here — by the time a handler can call it, the handshake this connection
* needed to reach the handler has already completed, so it is a cached-field read, never a
* forced handshake.
*/
@Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude
@ToString.Exclude
SSLSocket sslSocket;
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
this.requestLine = requestLine; this.requestLine = requestLine;
this.body = body; this.body = body;
this.pathParams = null; this.pathParams = null;
this.queryParams = null; this.queryParams = null;
this.remoteAddress = remoteAddress; this.remoteAddress = remoteAddress;
this.sslSocket = sslSocket;
} }
/** /**
@@ -75,19 +96,19 @@ public class Request {
*/ */
void setPathParams(PathParams p) { this.pathParams = p; } void setPathParams(PathParams p) { this.pathParams = p; }
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}. */ /** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */
public Request(RequestLine requestLine, byte[] body) { public Request(RequestLine requestLine, byte[] body) {
this(requestLine, RequestBody.of(body), null); this(requestLine, RequestBody.of(body), null, null);
} }
public static Request forParsed(RequestLine requestLine, InputStream stream, public static Request forParsed(RequestLine requestLine, InputStream stream,
long contentLength, byte[] headerBuf, long contentLength, byte[] headerBuf,
int bodyStart, int preBufLen, int bodyStart, int preBufLen,
InetSocketAddress remoteAddress) { InetSocketAddress remoteAddress, SSLSocket sslSocket) {
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen) RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
: contentLength == 0 ? RequestBody.empty() : contentLength == 0 ? RequestBody.empty()
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0); : /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
return new Request(requestLine, rb, remoteAddress); return new Request(requestLine, rb, remoteAddress, sslSocket);
} }
// ── Request line ────────────────────────────────────────────────────────── // ── Request line ──────────────────────────────────────────────────────────
@@ -170,6 +191,21 @@ public class Request {
*/ */
public InetSocketAddress remoteAddress() { return remoteAddress; } public InetSocketAddress remoteAddress() { return remoteAddress; }
// ── TLS ───────────────────────────────────────────────────────────────────
/** Whether this request arrived over TLS (HTTPS). */
public boolean isSecure() { return sslSocket != null; }
/**
* Returns the TLS session for this connection, or {@code null} for plain HTTP.
* Gives access to {@link SSLSession#getPeerCertificates()} (mTLS — the caller's certificate
* chain, if {@code TlsConfig.clientAuth} required or requested one), and to
* {@link SSLSession#getCipherSuite()} / {@link SSLSession#getProtocol()} for logging and
* diagnostics. {@code null} rather than throwing when {@link #isSecure()} is {@code false} —
* check that first, or just null-check the result.
*/
public SSLSession sslSession() { return sslSocket != null ? sslSocket.getSession() : null; }
// ── Body ────────────────────────────────────────────────────────────────── // ── Body ──────────────────────────────────────────────────────────────────
/** /**
@@ -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,16 @@
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;
/** Shorthand for {@code @Route(method = HttpMethod.QUERY, path = "…")}. */
@Route(method = HttpMethod.QUERY, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface QUERY {
String value();
}
@@ -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,14 @@
package dev.relism.flash.tls;
/**
* Client-certificate requirement for a TLS listener, applied via
* {@link TlsConfig#clientAuth(ClientAuth)}.
*/
public enum ClientAuth {
/** No client certificate requested. Default — Flash makes no client-auth call at all. */
NONE,
/** Client certificate requested; handshake still succeeds if the client presents none. */
OPTIONAL,
/** Handshake fails unless the client presents a certificate trusted by this listener. */
REQUIRE
}
@@ -0,0 +1,126 @@
package dev.relism.flash.tls;
import javax.net.ssl.ExtendedSSLSession;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SNIServerName;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.X509ExtendedKeyManager;
import java.net.Socket;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Wraps a keystore's default {@link X509ExtendedKeyManager} with SNI-based alias selection:
* every alias's certificate is inspected for its Subject Alternative Names (Common Name as
* fallback) so a keystore holding one entry per domain serves the right certificate per
* {@code ClientHello} — no explicit hostname-to-alias mapping needed from the caller.
*
* <p>{@link #defaultAlias} is simply the keystore's first key entry — same convention as
* nginx/HAProxy's {@code default_server}: used when the client sends no SNI, or an SNI name
* that matches nothing here.
*
* <p>Only server-alias selection is overridden; every other {@link X509ExtendedKeyManager}
* method (client aliases, certificate chains, private keys) delegates unchanged.
*/
final class SniKeyManager extends X509ExtendedKeyManager {
private final X509ExtendedKeyManager delegate;
private final Map<String, String> aliasByHostname;
private final String defaultAlias;
SniKeyManager(X509ExtendedKeyManager delegate, KeyStore keyStore) throws KeyStoreException {
this.delegate = delegate;
Map<String, String> byHostname = new HashMap<>();
String first = null;
for (String alias : Collections.list(keyStore.aliases())) {
if (!keyStore.isKeyEntry(alias)) continue;
if (first == null) first = alias;
Certificate cert = keyStore.getCertificate(alias);
if (cert instanceof X509Certificate x509) {
for (String host : hostnamesOf(x509)) byHostname.putIfAbsent(host, alias);
}
}
this.aliasByHostname = byHostname;
this.defaultAlias = first;
}
/** DNS SANs (preferred) or, failing that, the certificate's CN — all lower-cased for matching. */
private static List<String> hostnamesOf(X509Certificate cert) {
List<String> names = new ArrayList<>();
try {
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans != null) {
for (List<?> san : sans) {
if (san.get(0).equals(2)) // dNSName, see X509Certificate#getSubjectAlternativeNames
names.add(san.get(1).toString().toLowerCase(Locale.ROOT));
}
}
} catch (CertificateParsingException ignored) {
// Fall through to the CN below.
}
if (names.isEmpty()) {
for (String part : cert.getSubjectX500Principal().getName().split(",")) {
part = part.trim();
if (part.regionMatches(true, 0, "CN=", 0, 3)) {
names.add(part.substring(3).toLowerCase(Locale.ROOT));
break;
}
}
}
return names;
}
@Override
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
return resolve(engine.getHandshakeSession());
}
@Override
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
return resolve(socket instanceof SSLSocket ssl ? ssl.getHandshakeSession() : null);
}
/**
* Every TLS implementation in practice sends at most one {@code server_name} entry (RFC 6066
* permits a list, but only the {@code host_name} type exists and clients send zero or one of
* it), so indexing {@code names.get(0)} directly — rather than a for-each, which would
* allocate an {@link java.util.Iterator} per handshake — is both correct and allocation-free
* on the JDK's own {@code List.copyOf}-backed {@link SSLSession#getRequestedServerNames()},
* deterministically rather than relying on the JIT to prove the iterator never escapes.
*/
private String resolve(SSLSession session) {
if (session instanceof ExtendedSSLSession ext) {
List<SNIServerName> names = ext.getRequestedServerNames();
if (!names.isEmpty() && names.get(0) instanceof SNIHostName host) {
String alias = aliasByHostname.get(host.getAsciiName().toLowerCase(Locale.ROOT));
if (alias != null) return alias;
}
}
return defaultAlias;
}
// ── Delegated — this class only changes server-alias selection ─────────────
@Override public String[] getClientAliases(String keyType, Principal[] issuers) { return delegate.getClientAliases(keyType, issuers); }
@Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { return delegate.chooseClientAlias(keyType, issuers, socket); }
@Override public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { return delegate.chooseEngineClientAlias(keyType, issuers, engine); }
@Override public String[] getServerAliases(String keyType, Principal[] issuers) { return delegate.getServerAliases(keyType, issuers); }
@Override public X509Certificate[] getCertificateChain(String alias) { return delegate.getCertificateChain(alias); }
@Override public PrivateKey getPrivateKey(String alias) { return delegate.getPrivateKey(alias); }
}
@@ -0,0 +1,132 @@
package dev.relism.flash.tls;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
import javax.net.ssl.X509ExtendedKeyManager;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
/**
* Declarative TLS configuration for a {@link dev.relism.flash.extension.FlashConfiguration.Listener}.
*
* <h3>Two ways in</h3>
* <ul>
* <li>{@link #keystore(Path, String)} — Flash builds the {@link SSLContext} from a PKCS12/JKS
* keystore. A keystore holding more than one certificate entry gets SNI-based selection
* for free (see {@link SniKeyManager}) — no per-hostname config needed. Flash also pins
* {@code TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the
* JDK's own curated default, which each JDK security release keeps current — Flash does
* not maintain its own suite allow-list.</li>
* <li>{@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used
* exactly as built: Flash never calls {@code setSSLParameters} on this path unless you
* explicitly call {@link #applicationProtocols} or {@link #clientAuth} yourself, so
* anything else you configured on it is 100% authoritative.</li>
* </ul>
*
* <p>{@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on
* either path — they are explicit instructions through this API, not Flash-chosen defaults, so
* each is only ever applied when called. Neither has a value by default, on either path.
*
* <h3>ALPN (e.g. TLS-ALPN-01 / RFC 8737)</h3>
* {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via
* {@link SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like
* {@link ClientAuth} — no per-connection code needed. ALPN is resolved during {@code ClientHello}
* processing/{@code ServerHello} production, which always precedes {@code Certificate} production
* — so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve
* can read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()}
* (or {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside
* {@code chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then.
*/
public final class TlsConfig {
private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" };
private final SSLContext context;
private final boolean hardenDefaults;
private final ClientAuth clientAuth;
private final String[] applicationProtocols;
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth, String[] applicationProtocols) {
this.context = context;
this.hardenDefaults = hardenDefaults;
this.clientAuth = clientAuth;
this.applicationProtocols = applicationProtocols;
}
/**
* Builds an {@link SSLContext} from a PKCS12/JKS keystore — type is guessed from the file
* extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is
* assumed equal to the store password, the common case for PKCS12.
*/
public static TlsConfig keystore(Path path, String password) {
try {
KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12");
try (InputStream in = Files.newInputStream(path)) {
store.load(in, password.toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(store, password.toCharArray());
KeyManager[] managers = kmf.getKeyManagers();
for (int i = 0; i < managers.length; i++) {
if (managers[i] instanceof X509ExtendedKeyManager x509) {
managers[i] = new SniKeyManager(x509, store);
}
}
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(managers, null, null);
return new TlsConfig(ctx, true, ClientAuth.NONE, null);
} catch (GeneralSecurityException | IOException e) {
throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e);
}
}
/**
* Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond what you
* explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance.
*/
public static TlsConfig ofContext(SSLContext context) {
return new TlsConfig(context, false, ClientAuth.NONE, null);
}
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
public TlsConfig clientAuth(ClientAuth mode) {
return new TlsConfig(context, hardenDefaults, mode, applicationProtocols);
}
/**
* ALPN protocols this listener negotiates, in preference order (e.g.
* {@code "acme-tls/1", "http/1.1"}). Applies on either construction path — see class Javadoc
* for how a custom {@code KeyManager} observes the negotiated value.
*/
public TlsConfig applicationProtocols(String... protocols) {
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone());
}
// ── Consumed by HttpServer at bind time — not meant for direct use ──────────
public SSLServerSocketFactory serverSocketFactory() {
return context.getServerSocketFactory();
}
public void applyTo(SSLServerSocket socket) {
if (hardenDefaults || applicationProtocols != null) {
SSLParameters params = socket.getSSLParameters();
if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS);
if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols);
socket.setSSLParameters(params);
}
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
}
}
@@ -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,268 @@
package dev.relism.flash.websocket;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLSession;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.ThreadLocalRandom;
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 Request request;
private final boolean maskOutgoing;
private final AtomicBoolean open = new AtomicBoolean(true);
private int closeCode = 1000;
/** 1 opcode byte + up to 8 extended-length bytes + up to 4 mask-key bytes (masked mode only). */
private final byte[] hdrScratch = new byte[14];
public WebSocketSession(InputStream in, OutputStream out, int bufferSize) {
this(in, out, bufferSize, null, false);
}
/**
* @param request the HTTP request that upgraded to this session, or {@code null} if the
* caller has no use for it (e.g. a session opened as a WS client rather
* than accepted as a WS server). Stored as-is, no copy.
* @param maskOutgoing {@code true} if this session is acting as a WS <em>client</em> — RFC 6455
* requires client-to-server frames to be masked, unlike the server-to-client
* direction {@link #writeFrame} originally only supported. See {@link
* #writeFrame} for how masking is applied without allocating.
*/
public WebSocketSession(InputStream in, OutputStream out, int bufferSize, Request request, boolean maskOutgoing) {
this.in = in;
this.out = out;
this.readBuf = new byte[bufferSize];
this.request = request;
this.maskOutgoing = maskOutgoing;
}
public boolean isOpen() { return open.get(); }
public int closeCode() { return closeCode; }
/** The request that upgraded this connection, or {@code null} — see the 4-arg constructor. */
public Request request() { return request; }
/**
* Whether this connection is TLS (WSS). Delegates to the upgrading {@link #request}'s
* {@link Request#isSecure()} rather than tracking the socket a second time — the request
* already carries it. {@code false} if this session has no backing request (e.g. one opened
* in WS *client* mode via the 5-arg constructor with {@code request == null}).
*/
public boolean isSecure() { return request != null && request.isSecure(); }
/** TLS session for this connection, or {@code null} for plain WS or no backing request. */
public SSLSession sslSession() { return request != null ? request.sslSession() : null; }
// ── 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 14 bytes: 1 opcode + up to 8
* extended-length + up to 4 mask-key), 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.
*
* <p><b>{@link #maskOutgoing} (client mode):</b> RFC 6455 requires every client-to-server frame
* to be masked. The mask key is generated into {@link #hdrScratch} (no new allocation — same
* fixed field every frame reuses) and the payload is masked <em>in place</em> via {@link
* #unmaskInPlace} — XOR is its own inverse, so the exact routine {@link #readFrame} already
* uses to unmask an inbound payload masks an outbound one too, with no separate code path and
* no copy. This mutates the caller's {@code payload} array as a side effect: callers using
* masked mode must not reuse that buffer expecting it unchanged after the call.
*/
private void writeFrame(byte opcode, byte[] payload, int off, int len) throws IOException {
synchronized (out) {
int hlen = 0;
hdrScratch[hlen++] = (byte) (0x80 | opcode);
int maskBit = maskOutgoing ? 0x80 : 0x00;
if (len <= 125) {
hdrScratch[hlen++] = (byte) (maskBit | len);
} else if (len <= 0xFFFF) {
hdrScratch[hlen++] = (byte) (maskBit | 126);
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
hdrScratch[hlen++] = (byte) (len & 0xFF);
} else {
hdrScratch[hlen++] = (byte) (maskBit | 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);
}
if (maskOutgoing) {
// ThreadLocalRandom needs no seeding/allocation per call; the four mask bytes are
// carved out of one int, never boxed.
int mask = ThreadLocalRandom.current().nextInt();
byte m0 = (byte) (mask >>> 24), m1 = (byte) (mask >>> 16), m2 = (byte) (mask >>> 8), m3 = (byte) mask;
hdrScratch[hlen++] = m0;
hdrScratch[hlen++] = m1;
hdrScratch[hlen++] = m2;
hdrScratch[hlen++] = m3;
unmaskInPlace(payload, off, len, m0, m1, m2, m3);
}
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; }
}
}
@@ -228,4 +228,37 @@ class HttpServerTest {
assertTrue(second.contains("Connection: keep-alive")); assertTrue(second.contains("Connection: keep-alive"));
} }
} }
/**
* Regression guard for the shared {@code STREAM_RELAY_BUFFER}: both the non-chunked
* ({@code /api/stream}) and chunked ({@code /api/chunked-out}) streaming paths reuse the same
* per-connection buffer now — sending one of each back to back on one connection must not
* leave either response corrupted by the other reusing the array mid-transfer.
*/
@Test
void testKeepAlive_streamedAndChunkedResponsesOnSameConnectionDontCorruptEachOther() throws Exception {
String streamReq = "GET /api/stream HTTP/1.1\r\nHost: localhost\r\n\r\n";
String chunkedReq = "GET /api/chunked-out HTTP/1.1\r\nHost: localhost\r\n\r\n";
try (Socket socket = new Socket("127.0.0.1", port);
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
out.write(streamReq.getBytes(StandardCharsets.UTF_8));
out.write(chunkedReq.getBytes(StandardCharsets.UTF_8));
out.write(streamReq.getBytes(StandardCharsets.UTF_8));
out.flush();
String first = readOneResponse(in);
String second = readOneResponse(in);
String third = readOneResponse(in);
assertTrue(first.contains("Content-Length: 23"));
assertTrue(first.endsWith("streaming response body"));
assertTrue(second.contains("Transfer-Encoding: chunked"));
assertTrue(second.endsWith("streaming response body"));
assertTrue(third.contains("Content-Length: 23"));
assertTrue(third.endsWith("streaming response body"));
}
}
} }
@@ -0,0 +1,571 @@
package dev.relism.flash;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.tls.ClientAuth;
import dev.relism.flash.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509ExtendedKeyManager;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
/**
* End-to-end TLS coverage over real sockets — same style as {@link HttpServerTest}, just with
* an {@link SSLSocketFactory} client instead of a raw one. Certificates are generated per test
* via {@link TestKeystores} (JDK {@code keytool}, no fixture files, no extra crypto dependency).
*/
class HttpServerTlsTest {
private static final int SOCKET_TIMEOUT_MS = 5000;
private FlashApp app;
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
private static int freePort() throws IOException {
try (ServerSocket s = new ServerSocket(0)) { return s.getLocalPort(); }
}
private static String httpGet(SSLSocket socket, String path) throws IOException {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
socket.startHandshake();
OutputStream out = socket.getOutputStream();
out.write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
out.flush();
return new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
// ── Basic HTTPS ──────────────────────────────────────────────────────────
@Test
void httpsRequest_servedOverModernTls(@TempDir java.nio.file.Path dir) throws Exception {
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.tls(TlsConfig.keystore(ks, "changeit"))
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
String response = httpGet(socket, "/ping");
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
assertTrue(response.endsWith("pong"));
assertTrue(java.util.List.of("TLSv1.2", "TLSv1.3").contains(socket.getSession().getProtocol()));
}
}
// ── SNI: one keystore, two domains, two certificates ────────────────────
@Test
void sni_servesCertificateMatchingRequestedHostname(@TempDir java.nio.file.Path dir) throws Exception {
var ks = TestKeystores.build(dir, "sni.p12", "changeit",
TestKeystores.Entry.of("a", "a.test"),
TestKeystores.Entry.of("b", "b.test"));
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.tls(TlsConfig.keystore(ks, "changeit"))
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
assertEquals("a.test", peerCn(port, "a.test"));
assertEquals("b.test", peerCn(port, "b.test"));
// No/unknown SNI falls back to the first keystore entry ("a") — same convention as
// nginx/HAProxy's default_server.
assertEquals("a.test", peerCn(port, null));
}
private static String peerCn(int port, String sniHostname) throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
try (SSLSocket socket = (SSLSocket) ctx.getSocketFactory().createSocket("127.0.0.1", port)) {
if (sniHostname != null) {
SSLParameters params = socket.getSSLParameters();
params.setServerNames(java.util.List.of(new SNIHostName(sniHostname)));
socket.setSSLParameters(params);
}
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
socket.startHandshake();
X509Certificate cert = (X509Certificate) socket.getSession().getPeerCertificates()[0];
String dn = cert.getSubjectX500Principal().getName();
for (String part : dn.split(",")) {
part = part.trim();
if (part.regionMatches(true, 0, "CN=", 0, 3)) return part.substring(3);
}
return null;
}
}
// ── mTLS ─────────────────────────────────────────────────────────────────
@Test
void mTls_requireRejectsClientWithNoCertificate(@TempDir java.nio.file.Path dir) throws Exception {
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.tls(TlsConfig.keystore(ks, "changeit").clientAuth(ClientAuth.REQUIRE))
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
// TLS 1.3 validates the (here: empty) client certificate chain only after the
// client's Finished message — startHandshake() alone can return cleanly from the
// client's point of view. The server's fatal alert only surfaces on the next I/O,
// so the round trip (not the handshake call itself) is what must throw.
assertThrows(IOException.class, () -> httpGet(socket, "/ping"));
}
}
@Test
void mTls_requireAcceptsClientWithTrustedCertificate(@TempDir java.nio.file.Path dir) throws Exception {
var serverKs = TestKeystores.build(dir, "server.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
var clientKs = TestKeystores.build(dir, "client.p12", "changeit", TestKeystores.Entry.of("cli", "test-client"));
// Escape hatch: mutual trust needs a trust store on both sides, which the keystore()
// convenience path deliberately doesn't expose (see TlsConfig javadoc) — this is
// exactly the case ofContext() exists for.
KeyStore serverIdentity = load(serverKs, "changeit");
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(serverIdentity, "changeit".toCharArray());
KeyStore clientTrust = load(clientKs, "changeit"); // client's own cert acts as its trust anchor here
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(clientTrust);
SSLContext serverCtx = SSLContext.getInstance("TLS");
serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.REQUIRE))
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
KeyStore clientIdentity = load(clientKs, "changeit");
KeyManagerFactory clientKmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
clientKmf.init(clientIdentity, "changeit".toCharArray());
SSLContext clientCtx = SSLContext.getInstance("TLS");
clientCtx.init(clientKmf.getKeyManagers(), new javax.net.ssl.TrustManager[] { trustAll() }, null);
try (SSLSocket socket = (SSLSocket) clientCtx.getSocketFactory().createSocket("127.0.0.1", port)) {
String response = httpGet(socket, "/ping");
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
}
}
private static KeyStore load(java.nio.file.Path path, String password) throws Exception {
KeyStore store = KeyStore.getInstance("PKCS12");
try (InputStream in = java.nio.file.Files.newInputStream(path)) { store.load(in, password.toCharArray()); }
return store;
}
private static javax.net.ssl.X509TrustManager trustAll() {
return new javax.net.ssl.X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
};
}
// ── Multiple listeners, one app ──────────────────────────────────────────
@Test
void multipleListeners_plainAndTlsServeTheSameApp(@TempDir java.nio.file.Path dir) throws Exception {
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
int plainPort = freePort();
int tlsPort = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.listener(new FlashConfiguration.Listener(plainPort, "127.0.0.1", null))
.listener(new FlashConfiguration.Listener(tlsPort, "127.0.0.1", TlsConfig.keystore(ks, "changeit")))
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
try (Socket socket = new Socket("127.0.0.1", plainPort)) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
socket.getOutputStream().write("GET /ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.UTF_8));
String response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
}
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", tlsPort)) {
String response = httpGet(socket, "/ping");
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
}
}
// ── KeyManager failure isolation ──────────────────────────────────────────
/** Throws on its first invocation (any keyType), then delegates normally — simulates a
* one-off failure in a custom KeyManager (e.g. a failed DB lookup or on-demand cert
* issuance) without permanently breaking the listener. */
private static final class FlakyKeyManager extends X509ExtendedKeyManager {
private final X509ExtendedKeyManager delegate;
private final AtomicInteger calls = new AtomicInteger();
FlakyKeyManager(X509ExtendedKeyManager delegate) { this.delegate = delegate; }
private void maybeFail() {
if (calls.getAndIncrement() == 0) throw new RuntimeException("simulated KeyManager failure");
}
@Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
maybeFail();
return delegate.chooseEngineServerAlias(keyType, issuers, engine);
}
@Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
maybeFail();
return delegate.chooseServerAlias(keyType, issuers, socket);
}
@Override public String[] getClientAliases(String keyType, Principal[] issuers) { return delegate.getClientAliases(keyType, issuers); }
@Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { return delegate.chooseClientAlias(keyType, issuers, socket); }
@Override public String[] getServerAliases(String keyType, Principal[] issuers) { return delegate.getServerAliases(keyType, issuers); }
@Override public X509Certificate[] getCertificateChain(String alias) { return delegate.getCertificateChain(alias); }
@Override public PrivateKey getPrivateKey(String alias) { return delegate.getPrivateKey(alias); }
}
@Test
void keyManagerFailure_isolatedToOneConnection_listenerServesTheNext(@TempDir java.nio.file.Path dir) throws Exception {
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
KeyStore store = load(ks, "changeit");
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(store, "changeit".toCharArray());
KeyManager[] managers = kmf.getKeyManagers();
for (int i = 0; i < managers.length; i++) {
if (managers[i] instanceof X509ExtendedKeyManager x509) managers[i] = new FlakyKeyManager(x509);
}
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(managers, null, null);
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.tls(TlsConfig.ofContext(ctx))
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
// Connection 1: the KeyManager throws — handshake must fail, but must not take the
// listener down with it.
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
assertThrows(IOException.class, socket::startHandshake);
}
// Connection 2: same listener, no reconfiguration — must succeed and serve normally.
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
String response = httpGet(socket, "/ping");
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
assertTrue(response.endsWith("pong"));
}
}
// ── ALPN visibility for on-demand cert issuance (TLS-ALPN-01 / RFC 8737) ──
/** Delegates alias selection, but first records what the KeyManager itself observed as the
* negotiated ALPN protocol at the exact point a real ACME-style KeyManager would decide
* whether to serve a challenge certificate instead of the real one. */
private static final class RecordingKeyManager extends X509ExtendedKeyManager {
private final X509ExtendedKeyManager delegate;
private final AtomicReference<String> observedProtocol = new AtomicReference<>();
RecordingKeyManager(X509ExtendedKeyManager delegate) { this.delegate = delegate; }
@Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
observedProtocol.set(engine.getHandshakeApplicationProtocol());
return delegate.chooseEngineServerAlias(keyType, issuers, engine);
}
@Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
observedProtocol.set(((SSLSocket) socket).getHandshakeApplicationProtocol());
return delegate.chooseServerAlias(keyType, issuers, socket);
}
@Override public String[] getClientAliases(String keyType, Principal[] issuers) { return delegate.getClientAliases(keyType, issuers); }
@Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { return delegate.chooseClientAlias(keyType, issuers, socket); }
@Override public String[] getServerAliases(String keyType, Principal[] issuers) { return delegate.getServerAliases(keyType, issuers); }
@Override public X509Certificate[] getCertificateChain(String alias) { return delegate.getCertificateChain(alias); }
@Override public PrivateKey getPrivateKey(String alias) { return delegate.getPrivateKey(alias); }
}
private static RecordingKeyManager buildRecordingContext(java.nio.file.Path ks, SSLContext[] outCtx) throws Exception {
KeyStore store = load(ks, "changeit");
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(store, "changeit".toCharArray());
RecordingKeyManager recorder = null;
KeyManager[] managers = kmf.getKeyManagers();
for (int i = 0; i < managers.length; i++) {
if (managers[i] instanceof X509ExtendedKeyManager x509) {
recorder = new RecordingKeyManager(x509);
managers[i] = recorder;
}
}
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(managers, null, null);
outCtx[0] = ctx;
return recorder;
}
@Test
void alpn_negotiatedProtocolIsVisibleToKeyManagerBeforeCertificateIsChosen(@TempDir java.nio.file.Path dir) throws Exception {
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
SSLContext[] boxedCtx = new SSLContext[1];
RecordingKeyManager recorder = buildRecordingContext(ks, boxedCtx);
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.tls(TlsConfig.ofContext(boxedCtx[0]).applicationProtocols("acme-tls/1", "http/1.1"))
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
// Each connection below uses its own fresh client SSLContext, deliberately — reusing one
// SSLContext across connections lets JSSE resume the second handshake's TLS session,
// which skips the Certificate message (and therefore the KeyManager call) entirely. A
// fresh SSLContext has nothing to resume, forcing a full handshake every time, which is
// what this test needs to observe the KeyManager on every connection.
// A client offering only the ACME challenge protocol: the KeyManager must see it
// *before* choosing which certificate to serve — this is the hook the challenge-cert
// decision hangs off.
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
.getSocketFactory().createSocket("127.0.0.1", port)) {
SSLParameters params = socket.getSSLParameters();
params.setApplicationProtocols(new String[] { "acme-tls/1" });
socket.setSSLParameters(params);
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
socket.startHandshake();
assertEquals("acme-tls/1", recorder.observedProtocol.get());
assertEquals("acme-tls/1", socket.getApplicationProtocol());
}
// Zero regression: a normal client (http/1.1) behaves exactly as before ALPN existed —
// same route, same response, and the KeyManager sees the normal protocol, not the
// challenge one.
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
.getSocketFactory().createSocket("127.0.0.1", port)) {
SSLParameters params = socket.getSSLParameters();
params.setApplicationProtocols(new String[] { "http/1.1" });
socket.setSSLParameters(params);
String response = httpGet(socket, "/ping");
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
assertTrue(response.endsWith("pong"));
assertEquals("http/1.1", recorder.observedProtocol.get());
}
// Zero regression: a client sending no ALPN at all — today's default — is unaffected.
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
.getSocketFactory().createSocket("127.0.0.1", port)) {
String response = httpGet(socket, "/ping");
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
assertTrue(response.endsWith("pong"));
// "" — not null — is JSSE's sentinel for "peer sent no ALPN extension at all".
assertEquals("", recorder.observedProtocol.get());
}
}
// ── Request.isSecure() / sslSession() ─────────────────────────────────────
@Test
void request_isSecureAndSessionAvailableOverTls(@TempDir java.nio.file.Path dir) throws Exception {
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.tls(TlsConfig.keystore(ks, "changeit"))
.build());
app.get("/secure-info", (req, res) -> {
SSLSession session = req.sslSession();
return req.isSecure() + ":" + (session != null) + ":" + (session != null ? session.getCipherSuite() : "");
});
app.start();
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
.getSocketFactory().createSocket("127.0.0.1", port)) {
String response = httpGet(socket, "/secure-info");
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
assertTrue(response.contains("true:true:TLS_"),
"expected isSecure=true, non-null session, real cipher suite; got: " + response);
}
}
@Test
void request_peerCertificateVisibleWhenClientPresentsOne(@TempDir java.nio.file.Path dir) throws Exception {
var serverKs = TestKeystores.build(dir, "server.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
var clientKs = TestKeystores.build(dir, "client.p12", "changeit", TestKeystores.Entry.of("cli", "test-client"));
KeyStore serverIdentity = load(serverKs, "changeit");
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(serverIdentity, "changeit".toCharArray());
KeyStore clientTrust = load(clientKs, "changeit"); // client's own cert as its trust anchor, as elsewhere in this file
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(clientTrust);
SSLContext serverCtx = SSLContext.getInstance("TLS");
serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
// OPTIONAL, not REQUIRE: proves getPeerCertificates() works without also
// re-testing the REQUIRE-rejection path already covered elsewhere in this file.
.tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.OPTIONAL))
.build());
app.get("/secure-info", (req, res) -> {
try {
X509Certificate peer = (X509Certificate) req.sslSession().getPeerCertificates()[0];
return "peer:" + peer.getSubjectX500Principal().getName();
} catch (SSLPeerUnverifiedException e) {
return "no-peer-cert";
}
});
app.start();
KeyStore clientIdentity = load(clientKs, "changeit");
KeyManagerFactory clientKmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
clientKmf.init(clientIdentity, "changeit".toCharArray());
SSLContext clientCtx = SSLContext.getInstance("TLS");
clientCtx.init(clientKmf.getKeyManagers(), new TrustManager[] { trustAll() }, null);
try (SSLSocket socket = (SSLSocket) clientCtx.getSocketFactory().createSocket("127.0.0.1", port)) {
String response = httpGet(socket, "/secure-info");
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
assertTrue(response.contains("peer:CN=test-client"), "response was: " + response);
}
}
@Test
void request_isNotSecureAndSessionIsNullOnPlainListener() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder().port(port).host("127.0.0.1").build());
app.get("/secure-info", (req, res) -> req.isSecure() + ":" + (req.sslSession() == null));
app.start();
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
socket.getOutputStream().write(
"GET /secure-info HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.UTF_8));
String response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
assertTrue(response.endsWith("false:true"), "expected isSecure=false, session=null; got: " + response);
}
}
// ── WebSocketSession.isSecure() / sslSession() — same info, WSS path ──────
private static String wsHandshakeKey() {
return Base64.getEncoder().encodeToString("flash-tls-test-key".getBytes(StandardCharsets.UTF_8));
}
private static String readWsHandshakeResponse(InputStream in) throws IOException {
java.io.ByteArrayOutputStream out = new java.io.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);
}
@Test
void wss_sessionIsSecureAndExposesSslSession(@TempDir java.nio.file.Path dir) throws Exception {
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.tls(TlsConfig.keystore(ks, "changeit"))
.build());
AtomicReference<Boolean> observedSecure = new AtomicReference<>();
AtomicReference<SSLSession> observedSession = new AtomicReference<>();
// onOpen runs on the server's virtual thread, asynchronously with respect to the client
// reading the 101 response bytes below (both happen after the same flush, in either
// order) — the latch is what makes the assertions below wait for onOpen, not the socket read.
java.util.concurrent.CountDownLatch opened = new java.util.concurrent.CountDownLatch(1);
app.ws("/echo", new WebSocketHandler() {
@Override public void onOpen(WebSocketSession session) {
observedSecure.set(session.isSecure());
observedSession.set(session.sslSession());
opened.countDown();
}
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
});
app.start();
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
.getSocketFactory().createSocket("127.0.0.1", port)) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
socket.startHandshake();
OutputStream out = socket.getOutputStream();
out.write(("GET /echo HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: " + wsHandshakeKey() + "\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
out.flush();
String headers = readWsHandshakeResponse(socket.getInputStream());
assertTrue(headers.startsWith("HTTP/1.1 101 Switching Protocols"), "handshake response: " + headers);
assertTrue(opened.await(SOCKET_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS),
"onOpen was not called within timeout");
}
assertEquals(Boolean.TRUE, observedSecure.get());
assertNotNull(observedSession.get());
}
}
@@ -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());
}
}
}
@@ -75,6 +75,16 @@ class RequestParserTest {
assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8)); assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8));
} }
@Test
void body_parsed_forQueryMethod() throws IOException {
String body = "{\"filter\":\"active\"}";
String raw = "QUERY / HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(dev.relism.flash.http.HttpMethod.QUERY, r.method());
assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8));
}
@Test @Test
void body_emptyWhenNoContentLength() throws IOException { void body_emptyWhenNoContentLength() throws IOException {
Request r = parse(req("GET / HTTP/1.1", "Host: localhost")); Request r = parse(req("GET / HTTP/1.1", "Host: localhost"));
@@ -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")));
}
}
@@ -34,6 +34,7 @@ class HttpMethodTest {
assertEquals(HttpMethod.TRACE, parse("TRACE")); assertEquals(HttpMethod.TRACE, parse("TRACE"));
assertEquals(HttpMethod.CONNECT, parse("CONNECT")); assertEquals(HttpMethod.CONNECT, parse("CONNECT"));
assertEquals(HttpMethod.PURGE, parse("PURGE")); assertEquals(HttpMethod.PURGE, parse("PURGE"));
assertEquals(HttpMethod.QUERY, parse("QUERY"));
} }
@Test @Test
@@ -90,4 +90,41 @@ class HeaderMapTest {
assertTrue(map.all("Host").isEmpty()); assertTrue(map.all("Host").isEmpty());
assertTrue(map.all().isEmpty()); assertTrue(map.all().isEmpty());
} }
// --- forEach ---
@Test
void forEach_visitsEveryHeaderInDeclarationOrder() {
HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
List<String> seen = new java.util.ArrayList<>();
map.forEach((name, value) -> seen.add(toStr(name) + "=" + toStr(value)));
assertEquals(List.of("Host=localhost", "Accept=text/plain", "Cookie=a=1"), seen);
}
@Test
void forEach_emptyMap_neverInvokesConsumer() {
HeaderMap map = new HeaderMap();
map.forEach((name, value) -> fail("must not be called on an empty map"));
}
@Test
void forEach_reusesTheSameTwoViewInstancesAcrossEveryHeader() {
// The zero-allocation contract: forEach must reposition two ByteViews in place, not
// allocate a fresh pair per header — same instances across all three calls here.
HeaderMap map = parse("A: 1", "B: 2", "C: 3");
List<ByteView> names = new java.util.ArrayList<>();
List<ByteView> values = new java.util.ArrayList<>();
map.forEach((name, value) -> { names.add(name); values.add(value); });
assertSame(names.get(0), names.get(1));
assertSame(names.get(1), names.get(2));
assertSame(values.get(0), values.get(1));
assertSame(values.get(1), values.get(2));
}
private static String toStr(ByteView v) {
byte[] b = new byte[v.length()];
for (int i = 0; i < b.length; i++) b[i] = v.byteAt(i);
return new String(b, StandardCharsets.UTF_8);
}
} }
@@ -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,67 @@
package dev.relism.flash.tls;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.IOException;
import java.nio.file.Path;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
/**
* Test-only: builds self-signed PKCS12 keystores via the JDK's own {@code keytool} so TLS
* tests need no external certificate fixtures and no extra crypto test-dependency.
*/
public final class TestKeystores {
private TestKeystores() {}
public record Entry(String alias, String cn, String... sans) {
public static Entry of(String alias, String cn, String... sans) { return new Entry(alias, cn, sans); }
}
public static Path build(Path dir, String fileName, String password, Entry... entries)
throws IOException, InterruptedException {
Path keystore = dir.resolve(fileName);
String keytool = Path.of(System.getProperty("java.home"), "bin", "keytool").toString();
for (Entry e : entries) {
List<String> cmd = new ArrayList<>(List.of(
keytool, "-genkeypair", "-noprompt",
"-alias", e.alias(),
"-keyalg", "RSA", "-keysize", "2048",
"-validity", "3650",
"-keystore", keystore.toString(),
"-storetype", "PKCS12",
"-storepass", password,
"-dname", "CN=" + e.cn()));
if (e.sans().length > 0) {
StringBuilder san = new StringBuilder();
for (String s : e.sans()) {
if (!san.isEmpty()) san.append(',');
san.append("dns:").append(s);
}
cmd.add("-ext");
cmd.add("SAN=" + san);
}
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
String output = new String(p.getInputStream().readAllBytes());
if (p.waitFor() != 0) throw new IOException("keytool failed: " + output);
}
return keystore;
}
/** A client-side {@link SSLContext} that trusts any server certificate — self-signed test certs only. */
public static SSLContext trustAllClientContext() throws NoSuchAlgorithmException, KeyManagementException {
TrustManager trustAll = new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
};
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[] { trustAll }, null);
return ctx;
}
}
@@ -0,0 +1,123 @@
package dev.relism.flash.tls;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLServerSocket;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class TlsConfigTest {
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
}
@Test
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
Path ks = TestKeystores.build(dir, "id.p12", "changeit",
TestKeystores.Entry.of("only", "single.test"));
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
assertTrue(protocols.contains("TLSv1.2"));
assertTrue(protocols.contains("TLSv1.3"));
assertFalse(protocols.contains("SSLv3"));
assertFalse(protocols.contains("TLSv1"));
assertFalse(protocols.contains("TLSv1.1"));
}
}
@Test
void ofContext_appliesNoParameterOverlay() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters before = socket.getSSLParameters();
String[] protocolsBefore = before.getProtocols();
tls.applyTo(socket);
assertArrayEquals(protocolsBefore, socket.getSSLParameters().getProtocols(),
"ofContext must not narrow/override protocols set on the caller's SSLContext");
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
}
@Test
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
// the caller sets its own ALPN protocol list — and, to make the point unambiguous,
// a protocol list *narrower* than what Flash's own keystore() path would pin — directly
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
// place such configuration can live; this test is the contract that makes it safe to
// rely on.
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters custom = socket.getSSLParameters();
custom.setApplicationProtocols(new String[] { "acme-tls/1", "http/1.1" });
custom.setProtocols(new String[] { "TLSv1.3" });
socket.setSSLParameters(custom);
tls.applyTo(socket);
SSLParameters after = socket.getSSLParameters();
assertArrayEquals(new String[] { "acme-tls/1", "http/1.1" }, after.getApplicationProtocols(),
"ofContext must not touch ALPN protocols the caller configured on its own socket");
assertArrayEquals(new String[] { "TLSv1.3" }, after.getProtocols(),
"ofContext must not widen/override the caller's own protocol list");
// clientAuth still applies — it is the caller's own explicit instruction through
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
assertTrue(socket.getWantClientAuth());
}
}
@Test
void clientAuth_none_makesNoClientAuthCall() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
}
@Test
void clientAuth_require_setsNeedClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getNeedClientAuth());
}
}
@Test
void clientAuth_optional_setsWantClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getWantClientAuth());
assertFalse(socket.getNeedClientAuth());
}
}
}
@@ -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,104 @@
package dev.relism.flash.websocket;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import dev.relism.fpr.core.ByteView;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketSessionTest {
private static ByteView viewOf(String s) {
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
return new ByteView() {
public int length() { return bytes.length; }
public byte byteAt(int idx) { return bytes[idx]; }
};
}
@Test
void request_returnsWhatWasPassedToConstructor() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new HeaderMap());
Request req = new Request(line, new byte[0]);
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64, req, false);
assertSame(req, session.request());
}
@Test
void request_defaultsToNullOnThreeArgConstructor() {
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64);
assertNull(session.request());
}
@Test
void sendText_masksWhenActingAsClient() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(new byte[0]), out, 64, null, true);
byte[] payload = "hi".getBytes();
session.sendText(payload, 0, payload.length);
byte[] bytes = out.toByteArray();
assertEquals((byte) 0x81, bytes[0]); // FIN + TEXT
assertEquals((byte) (0x80 | 2), bytes[1]); // masked bit + length 2
byte m0 = bytes[2], m1 = bytes[3], m2 = bytes[4], m3 = bytes[5];
assertEquals((byte) ('h' ^ m0), bytes[6]);
assertEquals((byte) ('i' ^ m1), bytes[7]);
// The caller's buffer is mutated in place by the mask (documented, zero-copy tradeoff).
assertEquals((byte) ('h' ^ m0), payload[0]);
}
@Test
void sendText_doesNotMaskWhenActingAsServer() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), out, 64);
byte[] payload = "hi".getBytes();
session.sendText(payload, 0, payload.length);
byte[] bytes = out.toByteArray();
assertEquals((byte) 0x81, bytes[0]);
assertEquals((byte) 2, bytes[1]); // no masked bit
assertEquals('h', bytes[2]);
assertEquals('i', bytes[3]);
}
@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);
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-parent</artifactId> <artifactId>flash-parent</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<modules> <modules>
+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