nix develop . --no-write-lock-file
sbt 'zioHttpExample/run'
- 1. What Spoonbill does
- 2. Start an application
- 3. Rendering, state and events
- 4. Typed actions and forms
- 5. Explicit state updates in version 7
- 6. Authentication, ownership and recovery
- 7. Sensitive presentation
- 8. Build larger interfaces
- 9. JavaScript interoperability
- 10. Effects, servers and streams
- 11. Testing
- 12. Storage, development and deployment
1. What Spoonbill does
Spoonbill keeps application logic and immutable presentation state on the server. A Scala function renders that state as HTML through Avocet. The first HTTP response contains rendered HTML; after connection, a browser client sends events and applies server-generated DOM changes. Applications can use their existing services directly from Scala without building a separate JSON API for every UI interaction.
This guide describes Spoonbill 7.0.0, built with Scala 3.3.8.
The repository’s Nix environment supplies JDK 21. Maven coordinates use the
organization com.natural-transformation and Scala 3 artifacts. Scala packages
are named spoonbill; many source directories retain the historical korolev
name. Source links below are pinned to the release tag.
Presentation state is distinct from domain storage and authentication authority. The browser remains an untrusted input source. For account-bearing applications, use the guarded authentication integration described in Authentication, ownership and recovery. The small public examples below intentionally have no accounts.
Readers upgrading should also read the 7.0 migration guide.
Version 7 replaces implicit identity updates with explicit NoUpdate and
Modify, changes rejection callback types, and requires applications to recompile.
2. Start an application
2.1. Use the managed development environment
The repository provides flake.nix and flake.lock. With Nix and flakes enabled,
run commands from the checkout through that environment:
The existing ZIO HTTP example listens at localhost:8088. Stop it before starting another application on that port. To run one command without an interactive shell:
nix develop . --no-write-lock-file --command sbt 'zioHttpExample/run'
Use the applicable project flake for dependency resolution, builds and tests. The examples in this guide do not require a template generator or host-installed JDK, sbt, Node.js or browser packages.
For a separate application project, create build.sbt with:
scalaVersion := "3.3.8"
libraryDependencies += "com.natural-transformation" %% "spoonbill-standalone" % "7.0.0"
Use sbt.version=1.10.7 in project/build.properties, matching this release’s
build. Save an application below under src/main/scala/. Enter that application’s
Nix environment before running sbt. If you are deliberately reusing the Spoonbill
checkout’s environment, run this from the application directory, replacing the
absolute path with your checkout:
nix develop /absolute/path/to/spoonbill --no-write-lock-file --command sbt 'runMain Counter'
The dependency versions are release coordinates. The repository’s historical
BaseVersion is a development fallback; release CI supplies RELEASE_VERSION.
It is not necessary to copy that fallback into an application build.
2.2. A complete counter
This is a complete runnable application, not a fragment. SpoonbillApp supplies
the context and execution context, starts the standalone server, and installs
its shutdown hook. It defaults to localhost:8080.
// guide-example: Counter.scala
import avocet.dsl.*
import avocet.dsl.html.*
import scala.concurrent.Future
import spoonbill.server.{SpoonbillApp, SpoonbillServiceConfig, StateLoader}
import spoonbill.state.javaSerialization.*
object Counter extends SpoonbillApp[Future, Array[Byte], Int, Any] {
import context.*
val config: Future[SpoonbillServiceConfig[Future, Int, Any]] =
Future.successful(
SpoonbillServiceConfig[Future, Int, Any](
stateLoader = StateLoader.default(0),
document = count => Html(
head(title("Spoonbill counter")),
body(
h1(s"Count: $count"),
button(
"Increment",
event("click")(access => access.transition(_ + 1))
)
)
)
)
)
}
Run sbt 'runMain Counter' inside the managed environment and open
localhost:8080. Each page has its own presentation state.
The reducer receives the current queued state when it executes.
For an existing server, construct spoonbillService(config) and use an HTTP
adapter. For explicit standalone lifecycle ownership, use
standalone.buildServer; it returns a handler with localAddress,
stopServingRequests() and awaitShutdown().
See the standalone adapter
and SpoonbillApp.
3. Rendering, state and events
3.1. Avocet templates
Import avocet.dsl. and avocet.dsl.html.. A tag such as div(…) contains
attributes, styles, children and Spoonbill bindings. id := "summary" sets an
attribute; backgroundColor @= "yellow" sets a CSS property. A collection of
nodes can be used as children, and optional attributes can express conditional
markup. Custom elements, attributes and styles use TagDef, AttrDef and
StyleDef.
A document has the type avocet.Document.Node[Context.Binding[F, S, M]].
Context[F, S, M] exposes convenient aliases such as Node and Access.
Here F is the effect, S is presentation state, and M is the component or
extension message type.
Rendering describes the current tree. Spoonbill compares trees and sends the changes; it does not resend the whole document for each ordinary transition. Keep rendering deterministic and put business effects in handlers or services. An empty visual diff can still accompany different event handlers, so it is not a reliable indication that a publication is unnecessary.
Some existing examples use Avocet’s optimize helper. It is optional for the
examples in this guide; no Scala 2 macro plugin is required in their build.
3.2. Devices, views and state loading
The deviceId cookie and a view/session ID identify framework storage and
connections. Qsid combines those identifiers. Neither identifier authenticates
a person or grants access to account data. Do not implement login by looking up
a user solely from the device cookie.
StateLoader[F, S] receives the device ID and HTTP request head and returns
initial presentation state. StateLoader.default(value) is suitable for a
public example with immutable initial state. An application loader can consult
trusted services; guarded applications must also authorize HTTP rendering and
connection attachment through their security integration.
Keep ordinary state limited to data that may safely participate in rendering, serialization and the chosen persistence policy. Credentials, one-use proofs, transaction handles, live authority capabilities and sensitive disclosure payloads do not belong in it. Domain writes go through domain services and transactions; changing a display counter is not a database commit.
3.3. Low-level event handlers and transitions
event("click") and event("submit") bind handlers returning F[Unit].
Bindings support an event phase and optional propagation stopping. An Access
provides state operations, browser properties, files and other view operations.
See Context.
access.transition(reduce) enqueues a state transformation. Its completion means
the transition was queued, not that rendering finished or the browser painted.
transitionForce waits for the server-side transition/render path. Async and
partial-transition variants are available for existing low-level integrations;
version 7 does not change their semantics.
A low-level identity transition still requests work. For typed actions, use the
explicit update descriptions in Explicit state updates in version 7. Do not use afterRender to
wait for a no-update action: it internally requests a forced identity transition.
4. Typed actions and forms
Use typed forms for declared, bounded inputs, policy checks and explicit action outcomes. The framework captures the declared values in the submit message, then decodes and dispatches them together. This avoids reading each field later through a separate browser RPC.
A PublicAction has an explicit PublicPolicy. An AuthenticatedAction has an
ActionPolicy and a server-resolved principal, and its form requires a configured
guarded runtime plus the matching SessionAuthority. An action name is a stable
identifier, not authorization. Hiding a button is not an access check.
4.1. A complete typed form
This public application accepts a bounded display name. It demonstrates a state
modification, an explicit no-update result, and the version 7 rejection callback.
Save it alongside the counter and run sbt 'runMain TypedGreeting' after stopping
the counter, since both use port 8080.
// guide-example: TypedGreeting.scala
import avocet.dsl.*
import avocet.dsl.html.*
import scala.concurrent.Future
import spoonbill.ActionForms
import spoonbill.action.*
import spoonbill.server.{SpoonbillApp, SpoonbillServiceConfig, StateLoader}
import spoonbill.state.javaSerialization.*
final case class GreetingPage(displayName: String = "visitor", message: String = "")
object TypedGreeting
extends SpoonbillApp[Future, Array[Byte], GreetingPage, Any] {
private val actions = new Actions[Future, GreetingPage, Unit]
private val displayName = FieldName.parse("display-name").toOption.get
private val nonempty = ValidationCode.parse("name-required").toOption.get
private val schema = InputSchema.text(displayName, 80)
.map(_.trim)
.validate(nonempty)(_.nonEmpty)
private val save = actions.public(
ActionName.parse("profile.save-name").toOption.get,
schema,
PublicPolicy.allow[Future, String]
) { (name, _) =>
Future.successful(UiOutcome.modify[GreetingPage](
_.copy(displayName = name, message = "Name saved.")
))
}
private val acknowledge = actions.public(
ActionName.parse("profile.acknowledge").toOption.get,
InputSchema.empty,
PublicPolicy.allow[Future, Unit]
) { (_, _) => Future.successful(UiOutcome.noUpdate[GreetingPage]) }
private val onRejected: ActionRejection => StateUpdate[GreetingPage] =
_ => StateUpdate.Modify(_.copy(message = "Enter a name within the limit."))
val config: Future[SpoonbillServiceConfig[Future, GreetingPage, Any]] =
Future.successful(
SpoonbillServiceConfig[Future, GreetingPage, Any](
stateLoader = StateLoader.default(GreetingPage()),
document = page => Html(
head(title("Typed greeting")),
body(
h1(s"Hello, ${page.displayName}"),
p(page.message),
form(
ActionForms.onSubmit[Future, GreetingPage, Any, String](save, onRejected),
label("Display name", input(
name := displayName.value,
`type` := "text",
value := page.displayName
)),
button("Save")
),
form(
ActionForms.onSubmit[Future, GreetingPage, Any, Unit](acknowledge, onRejected),
button("Acknowledge without updating")
)
)
)
)
)
}
The .toOption.get calls here validate fixed developer-written identifiers at
startup. Handle failures explicitly for identifiers derived from configuration
or other untrusted sources.
4.2. Inputs, rejection and native submission
InputSchema.text and InputSchema.secret take a maximum UTF-8 byte length.
InputSchema.checked decodes the explicit "true" or "false" sent for a
checkbox, including an unchecked control. Combine schemas with zip, transform
values with map, and validate with a bounded ValidationCode. Decoding rejects
undeclared fields, duplicates, missing values and exceeded limits. Transport
frame limits apply independently of schema limits.
A Secret has a redacted string representation and an explicit withValue
trusted-code boundary. Do not copy its plaintext into state, diagnostics or a
reusable closure; it cannot guarantee erasure of JVM strings.
Both ActionForms.onSubmit overloads require
ActionRejection ⇒ StateUpdate[S]. Return StateUpdate.noUpdate[S] to ignore a
rejection, or StateUpdate.Modify(…) to update presentation. UiOutcome.Invalid
uses that same input-rejection path. Superseded or output-suppressed invocations
have separate retirement behavior; they are not a request to replay the action.
For an ordinary browser navigation or an HTTP endpoint such as logout, use
ActionForms.nativeSubmit[F, S, M] on a form with its method and action.
The destination must validate its own origin/CSRF and authorization. Typed
onSubmit takes precedence if both markers are present.
Sources: input schemas, actions and dispatch, and form bindings.
5. Explicit state updates in version 7
StateUpdate[S] is a closed subtype of UiOutcome[S]:
| Result | Meaning |
|---|---|
|
Do not request a state transition, render or guarded patch for this outcome. |
|
Request one transition using the pure |
|
Construct those same values with the wider |
No-update skips state-update work only. Admission, authorization, sensitive acknowledgement handling, event-counter completion and cleanup still run. A real later publication can invalidate an earlier browser revision. No-update is not permission to accept a stale revision or to replay proof-bearing work.
The old UiOutcome.Updated, UiOutcome.update, UiOutcome.unchanged,
StateUpdate.unchanged and function-like StateUpdate(…)/update(state) APIs
are removed. There are no compatibility aliases.
5.1. Pure composition and zoom
andThen composes reducers in order. Combining either side with NoUpdate
reuses the other update. zoom lifts a child update into a parent state without
reading or writing the lens when constructing it. Reducers and lens functions
must be pure; they execute later against the queued state. An identity reducer,
or two changes that cancel out, remains a modification.
This complete example is executable with sbt 'runMain UpdateComposition' in the
same project. Its evaluator is a local demonstration, not a replacement for the
framework’s interpreter.
// guide-example: UpdateComposition.scala
import spoonbill.action.StateUpdate
object UpdateComposition {
final case class Page(counter: Int, message: String)
def main(args: Array[String]): Unit = {
val increment = StateUpdate.Modify[Int](_ + 1)
val update = increment
.zoom[Page](_.counter)((page, count) => page.copy(counter = count))
.andThen(StateUpdate.Modify[Page](_.copy(message = "Updated")))
.andThen(StateUpdate.noUpdate[Page])
val before = Page(2, "")
val after = update match {
case StateUpdate.NoUpdate() => before
case StateUpdate.Modify(reduce) => reduce(before)
}
assert(after == Page(3, "Updated"))
}
}
5.2. Completion callbacks and failures
Sensitive completion accepts DisclosureOutcome ⇒ StateUpdate[S]; rejection
accepts ActionRejection ⇒ StateUpdate[S]. These narrow callbacks cannot return
an authentication commit, replacement, validation result or another disclosure.
The framework evaluates callbacks through Effect.delay. Ordinary thrown
callback errors fail the effect and request no transition. For IO and ZIO the
callback remains deferred until execution. Future retains its eager semantics
inside the existing continuation. In the ZIO adapter, ordinary callback
exceptions enter the typed error channel through liftError, rather than
becoming defects. A reducer failure still follows the existing queued
transition/reporting behavior; enqueueing does not guarantee observing a later
render failure in the action effect.
See the algebra and the migration details.
6. Authentication, ownership and recovery
6.1. Install the guarded coordinator
Use BrowserAuthentication[F, S, P] with a BrowserPersistence[F, P],
BrowserPresentation[S, P] and BrowserConfiguration.
browser.configure(baseConfig) installs state storage, session access control,
authentication completion and the security installation together. Do not replace
one of those fields afterward. Guarded applications require WebSockets and
uncompressed WebSocket transport.
BrowserPresentation defines a fresh presentation, which states are allowed for
the current optional principal, and how a connected presentation is derived.
A display field saying an account is signed in is not authority. Authenticated
typed forms use browser.authority; protected domain writes must additionally
revalidate the captured view in the transaction performing the write.
Deploy with exact HTTPS origins and secure cookies. Insecure HTTP configuration is restricted to explicitly allowed loopback origins. Use a consistent realm, cookie namespace and provider scope. In-memory fixtures are useful for examples; production persistence, account policy, password verification and key ownership remain host responsibilities.
The complete browser authentication application shares its UI between Future memory, native Cats Effect IO memory and JDBC implementations. Its synthetic accounts, passwords and encryption material are test data, not deployment defaults. Follow the integration guide for provider-specific setup and host transaction contracts.
6.2. Sign-in and replacement
For anonymous sign-in, begin/resume a live ceremony, verify the proof under host
policy, and call the provider’s prepare. The transaction consumes proof,
creates the host session and records the browser preparation together. Only a
committed receipt can produce browser.complete(receipt). The transport delivers
an HttpOnly credential and activates it on a fresh handshake.
Changing credentials while authenticated requires the explicit replacement API:
beginReplacement, replacementInvocation, challenge binding and
prepareReplacement. The transaction revokes the exact predecessor and commits
a successor session with the host changes. Return the dedicated replacement
completion outcome. Recovery reconciles stored outcomes; it must not rerun the
proof or business mutation after a lost response.
Keep external notifications outside the database callback, using an outbox where needed. Do not commit, roll back, close a transaction, start detached work or wait on external services inside the provider’s host callback. See the full sign-in and replacement sequences and one-use authority.
6.3. Shared views and presentation snapshots
BrowserOwnership.ExclusiveScope declares one live owner for the scope.
BrowserOwnership.SharedViews enables shared-store coordination, ownership
claims and fenced view operations across web processes. Configure every node
with compatible database, scope, origins, credentials and policy.
Persist only an explicit non-sensitive presentation projection with a
SnapshotFormat and a StateSchema; schema derivation does not make arbitrary
state safe to persist. Restore current host/domain data first, then overlay the
allowed presentation projection. A saved counter or principal-shaped field
cannot become authorization evidence. See
shared PostgreSQL setup and recovery.
6.4. Custom guards and the version 6 initialization contract
Applications implementing SessionGuard directly must implement
applicationStateInitialization: ApplicationStateInitialization[F]:
-
Retained()adopts an existing local baseline when no snapshot restoration is configured. -
Reconstructed) ⇒ acknowledge(loads the validated application route and invokes the callback after the baseline is authorized and adopted.
With a snapshot session, either case starts from freshly loaded, routed and
authorized application state, then restores the permitted presentation projection
and authorizes the restored state. Retained() does not preserve an old local
baseline in this case.
The reconstruction acknowledgement is a thunk. Construct the F[Unit] inside
it so an eager Future cannot start the acknowledgement when the guard is built.
A reconstructed guard must discard unfinished presentation on close until its
acknowledgement completes. Its callback runs after baseline adoption, including
snapshot restoration when configured.
This contract is already part of version 6 and remains required in version 7. See the guard API and custom guard initialization.
7. Sensitive presentation
Render an empty SensitiveRegion[Binding](region) in the ordinary document.
The payload belongs to a transient browser region, not ordinary DOM text,
attributes, view state or snapshots. A separate SensitiveAccess[F, S] policy
must authorize purpose and audience; an authenticated session does not grant
sensitive presentation automatically.
Inside an admitted typed handler, its second argument is the action context:
actionContext: PublicActionContext, or an AuthenticatedActionContext[P] for
an authenticated action. Use its presentSensitive method with a validated
SensitivePayload, region, purpose and finite lifetime. It returns
UiOutcome[Page] and creates a one-shot disclosure bound to the invocation.
Do not construct, reuse or transfer its internal ownership yourself.
actionContext.presentSensitive[Page](region, purpose, payload, 30.seconds) {
case DisclosureOutcome.BrowserProcessed => StateUpdate.noUpdate[Page]
case _ => StateUpdate.Modify[Page](
_.copy(message = "Delivery could not be confirmed.")
)
}
The fragment’s Page, region, purpose, payload and imports come from its enclosing
application; it is not a standalone program. The rendering Context supplies
the template/event DSL. Low-level Context.Access.presentSensitive is a separate
API returning F[DisclosureOutcome], without an update callback. See the compiled
application
for complete declarations and policy wiring.
BrowserProcessed means the matching browser acknowledgement arrived, not that
a human read the information. NotSent and Uncertain distinguish failure
before emission from uncertain delivery. An uncertain result is not permission
to replay a one-use secret. The client clears presentations on expiry, replacement,
navigation/departure, disconnection and relevant security retirement. Ordinary
unrelated rendering can preserve an authorized region.
The client uses a closed shadow root and clears owned presentation resources. These boundaries reduce accidental DOM/RPC/snapshot exposure; they cannot prevent authorized viewing, screenshots, compromised browser code or guarantee physical memory erasure. Keep plaintext out of logs and generic serialization.
Version 7 lets a successful acknowledgement return NoUpdate, so it does not
itself replace handlers or publish a new guarded revision. Returning
Modify(identity) deliberately retains publication behavior.
See disclosure outcomes
and region hosts.
8. Build larger interfaces
8.1. Components and scoped access
Component[F, S, P, M] owns child state S, receives parameters P, and can
publish messages M to its parent. Use it when independent state or lifecycle is
useful; ordinary rendering functions are sufficient for stateless markup.
Components can start with a value or a parameter-dependent P ⇒ F[S] loader.
renderNoState supplies a loading view, while maybeUpdateState can request
reloading after parameters change. Parent bindings handle published messages.
See the component example.
context.scope(read, write) or context.scope(lens) focuses access on child
state. A Lens can also be passed to transition methods. eventUnscoped gives a
handler access to the enclosing context. Use lawful reads and writes and handle
partial-state cases deliberately; scoping is a presentation convenience, not an
authorization boundary. Typed pure updates use StateUpdate.zoom as shown above.
See scoped access.
8.2. Routing and mount paths
Router[F, S] contains fromState: PartialFunction[S, PathAndQuery] and
toState: PartialFunction[PathAndQuery, S ⇒ F[S]]. It maps state changes to URLs
and browser navigation back to state. Compose routers with ++; left-hand
matches take precedence. Validate requested resources through your services and
guards before rendering them.
PathAndQuery.Root / "search" builds a path. withParam adds optional query
values; :? exposes query parameters, QueryParam and OptionQueryParam support
required and optional extraction, and & combines extractors. Exact matching
uses :? for the first query pair and :& for subsequent pairs when ordering
and arity matter. See the compiled
routing example,
query example
and path tests.
For a nested deployment such as /ui/, set SpoonbillServiceConfig.rootPath to
PathAndQuery.Root / "ui". This is a service setting, not a Router constructor
parameter. Configure the reverse proxy consistently so HTML, static resources
and transport endpoints use the same mount. Test direct links, browser history,
encoded segments and reconnects at that mount.
8.3. Properties, event data and focus
Declare elementId() once in the relevant context and attach it to the rendered
element. In a handler, access.valueOf(id) reads its value, valueAs[T](id) uses
a Context.ValueDecoder[T], and checked(id) decodes a boolean property.
access.property(id).get("value") and .set("value", text) expose other
properties. These are asynchronous browser operations; typed form submission is
preferable when several declared fields must be captured together.
access.eventData returns serialized event data. Use eventDataAs[T] with an
application Context.EventDataDecoder[T] for its expected shape. Validate data
from the browser rather than treating the decoded type as authority.
See event data
and focus.
afterRender sequences a client operation after the server’s pending render
work, for example focusing an element introduced by a transition. It requests a
forced identity transition internally and does not acknowledge browser paint.
It therefore belongs in explicit DOM-effect workflows, not no-update completion.
8.4. Delays and server push
delay(duration)(handler) attaches delayed work to a rendered binding. This
helper has been deprecated since 0.18.0. Existing presentation timers can still
use it, but it is not durable scheduling. See
the delay example.
An Extension acquires session-scoped resources and receives state/message
notifications. Use Extension.managed or managedF for subscriptions with cleanup;
Extension.Handlers.onDestroy is a () ⇒ F[Unit] thunk. Resolve subscriptions
from current trusted authority, not a device cookie. Retire them when the view
ends; they are not durable jobs. See
extension lifecycle
and server push.
8.5. Forms and file streams
access.downloadFormData(formId) requests a form’s multipart data. This is a
separate facility from typed InputSchema forms. See
FormData.
File API names are from the server’s perspective:
| Operation | Direction and use |
|---|---|
|
Browser to server; materializes selected files as |
|
Browser to server; returns a stream per file. |
|
Inspect selections, then consume chosen files. |
|
Server to browser; serves a byte stream as a download. |
uploadFile takes size: Option[Long]: use Some(byteLength) when known, such
as Some(11L), or None otherwise. Its optional mimeType argument defaults to
application/octet-stream.
Bound file sizes, counts and processing in the application. Treat names and MIME types as untrusted metadata; do not use a submitted filename directly as a filesystem destination. Consume or cancel streams and close any storage resources you own. Prefer streaming for large inputs. See file streaming and selective downloads.
9. JavaScript interoperability
Web Components can provide custom browser behavior while Scala owns application state. Preserve the boundaries of renderer-managed nodes and custom-element-owned DOM. See the Web Component example.
access.evalJs(code) evaluates client JavaScript and returns its JSON result;
Promise results are awaited. The js interpolator can refer to framework
ElementId values. Treat evaluated code as trusted application code, validate
returned values, and do not concatenate untrusted input into executable strings.
access.registerCallback(name)(handler) accepts a string argument from the
client. Browser code calls Spoonbill.invokeCallback(name, argument). These
callbacks still need application validation and authorization for protected work;
they are not typed form actions. See
both interoperability directions.
With presetIds = true, Spoonbill emits identifiers for its managed DOM nodes,
which helps it distinguish nodes inserted by other scripts. This does not make
arbitrary mutation of managed DOM safe or remove lifecycle requirements.
10. Effects, servers and streams
10.1. Choose an effect
Core uses spoonbill.effect.Effect[F]; it does not require Cats or ZIO.
Future support is built in. Perform business effects in the action’s F, then
return a pure update or a dedicated control outcome. Do not introduce an
unsafeRun boundary inside a handler to mix runtimes.
| Runtime | Artifact | Integration |
|---|---|---|
Cats Effect 3 |
|
|
ZIO 2 |
|
|
Monix |
|
|
Legacy Cats Effect 2 / ZIO 1 |
|
Separate integrations; choose the artifact matching the runtime major version. |
All coordinates use "com.natural-transformation" %% artifact % "7.0.0".
Avoid combining two generations that expose the same integration package in one
application. Future construction is eager; IO and ZIO descriptions are lazy.
Effect.delay captures ordinary exceptions; delayAsync also captures effect
construction and flattens the result. With Future these operations still run
eagerly and do not introduce a scheduling barrier. These distinctions matter for
callbacks and resource ownership.
Sources and examples: IO adapter, IO application, ZIO 2 adapter, ZIO application, and Monix application.
10.2. HTTP adapters
| Server | Artifact | Entry point |
|---|---|---|
Standalone |
|
|
Pekko HTTP |
|
|
Akka HTTP |
|
|
ZIO HTTP |
|
|
The release’s ZIO HTTP integration targets ZIO HTTP 3.11.6 and ZIO 2.1.26.
Use ZIOAppDefault, a Runtime[R], and Zio2Effect; old ZEnv and
DefaultRuntime examples are not the ZIO 2 setup. The complete
ZIO HTTP application
constructs Routes[Any, Response] and runs Server.serve(routes) with a server
layer. Complete examples also exist for
Pekko HTTP and
Akka HTTP.
Custom service wrappers must preserve WebSocketResponse.Duplex versus
SendThenClose. Transform output with mapOutput or withOutput, preserve
ordering/cancellation, and execute release() when the upgrade is abandoned or
the transport ends. Let the adapter own live transport teardown before awaiting
session release; recursive release can deadlock. Never infer disposition from
payload bytes or turn an authorization failure into successful output. See
the transport contract.
10.3. Stream conversion and logging
Spoonbill’s Stream[F, A] is a pull-based stream with explicit cancellation.
Adapters bridge it to application streaming libraries:
-
spoonbill-fs2-ce3for FS2 3 / Cats Effect 3;spoonbill-fs2-ce2for FS2 2 / Cats Effect 2. -
spoonbill-zio2-streamsfor ZIO 2 streams;spoonbill-zio-streamsfor ZIO 1. -
Akka/Pekko integration utilities for reactive-streams publishers and their native streams.
Use the matching runtime version and preserve cancellation when bridging. See the release’s FS2 extensions, ZIO 2 stream extensions and Pekko stream instances for conversion and resource semantics.
The default reporter writes to standard output. Add spoonbill-slf4j and set
reporter = spoonbill.slf4j.Slf4jReporter to use your SLF4J backend. Guarded
applications should log bounded event metadata and error codes, not credentials,
submitted secret fields or sensitive payloads.
11. Testing
Add "com.natural-transformation" %% "spoonbill-testkit" % "7.0.0" % Test
to use spoonbill.testkit.Browser. It supplies property/form/file fixtures,
JavaScript mocks, handler execution and simulated DOM event propagation.
Assertions inspect recorded Action values. See
the compiled test example
and testkit capabilities.
The testkit is not a real browser or a substitute for guarded transport tests. Its legacy access fixture does not supply the full typed-submission/authority runtime. Test pure schemas, policies and reducers directly, then verify typed forms, cookies, disclosure acknowledgement, revisions and reconnects through the real service/client path. A successful unit test cannot establish browser cookie or physical-connection behavior.
The repository has deterministic Scala suites plus pinned Chromium/WebKit gates. These commands run from its root:
nix develop . --no-write-lock-file --command sbt 'spoonbill/test' 'testkit/test'
nix develop .#browser --no-write-lock-file --command bash scripts/test-native-browsers.sh
nix develop . --no-write-lock-file --command bash scripts/test-sensitive-acknowledgement.sh
nix develop . --no-write-lock-file --command bash scripts/test-browser-auth.sh
The acknowledgement runner uses the actual standalone service and normal client submissions. It covers no-update, identity modification, real modification and authority revocation while holding publication delivery. See browser test setup and additional provider gates.
12. Storage, development and deployment
12.1. Presentation storage
StateStorage and its serializers support framework presentation. The
spoonbill.state.javaSerialization.* import in the introductory applications
supplies the Java object serialization codecs (StateSerializer and
StateDeserializer). The default unguarded store normally keeps state values
in memory without calling these codecs. Development mode enables file-backed
state serialization, as described below. This codec is not a durable, versioned
business-data format.
For a custom unguarded integration, StateStorage.fromRepository accepts
existence, snapshot load/create, node write/delete and repository removal
operations. Lower-level StateManager.cached and serialized expose reusable
storage mechanics. Guarded authentication instead owns its installed storage
bundle and explicit snapshot projection; do not replace it with a generic
repository adapter. See
state storage.
12.2. Development and render scheduling
For the default unguarded store, the JVM property -Dspoonbill.dev=true or the
environment variable spoonbill.dev set to true enables file-backed state storage.
With the imported Java codecs, writes use ObjectOutputStream, so the state and
its reachable field values must be serializable. The typed starter’s Scala 3
GreetingPage case class and its String fields already satisfy this requirement.
Custom storage and the guarded coordinator’s installed storage have their own
policies. A process restart is not a production recovery mechanism. If using
sbt-revolver in your application, run
its restart commands inside the managed environment. Keep domain durability and
authentication in their own integrations; development presentation retention
cannot preserve arbitrary external mutable objects or authorize old sessions.
delayedRender can coalesce queued render work. Its default is zero; use it only
after measuring your application. It does not change the meaning of NoUpdate,
and transitionForce still waits for the server render path. Do not use state
equality or an empty DOM diff to bypass publication of changed handlers.
Classpath resources under static are served beneath the service’s static path,
such as /static/ at the root mount. Use appropriate external hosting for large
assets. See service settings.
12.3. Connections and operations
The default heartbeat interval is five seconds and the default limit is two
unanswered heartbeats. heartbeatInterval and heartbeatLimit configure this;
None disables the client’s unanswered-heartbeat limit and server heartbeat
replies, while the client continues sending heartbeats. Client connection loss
can lead to reconnect or reload. A recovered guarded view must reauthorize and
establish its baseline before user events are admitted.
Configure proxies for WebSocket upgrades, the correct mount path, and idle/drain timeouts consistent with your service. Test rolling shutdown and reconnects through the actual proxy; increasing one vendor-specific timeout is not a portable fix. Multi-node routing requires a compatible ownership and recovery strategy, not merely routing a reconnect to another process.
Use the authentication deployment checklist and shared-view deployment for origins, cookies, provider ownership, credential vaults and recovery. Existing validation and performance reports are evidence for their recorded versions and workloads, not a claim that every deployment has been tested.
12.4. Troubleshooting
First identify whether a failure is input validation, action policy, current
authority/ownership, stale view admission, transport setup, or application logic.
A successful browser acknowledgement followed by an explicit NoUpdate should
not itself advance the guarded revision. A genuine modification still can.
Inspect connection/revision metadata and completion signals without replaying
action frames or weakening stale-view checks.
For client protocol debugging, the browser exposes:
Spoonbill.setProtocolDebugEnabled(true);
Use this in a controlled development session and keep captured data appropriately restricted. Consult the release’s protocol description and generated protocol constants; application code should use the public bindings rather than hand-building protocol frames.