Spoonbill User Guide
Source Repository

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:

nix develop . --no-write-lock-file
sbt 'zioHttpExample/run'

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.

Counter.scala — complete runnable application
// 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.

TypedGreeting.scala — complete runnable application
// 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.

5. Explicit state updates in version 7

StateUpdate[S] is a closed subtype of UiOutcome[S]:

Result Meaning

StateUpdate.NoUpdate() / StateUpdate.noUpdate[S]

Do not request a state transition, render or guarded patch for this outcome.

StateUpdate.Modify(reduce)

Request one transition using the pure S ⇒ S reducer, even for identity.

UiOutcome.noUpdate[S] / UiOutcome.modify[S](reduce)

Construct those same values with the wider UiOutcome[S] result type, useful in action effects.

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.

UpdateComposition.scala — complete executable example
// 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.

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.

Contextual fragment using the typed handler’s second argument; application types and policy are supplied by the host
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

downloadFiles(inputId)

Browser to server; materializes selected files as Bytes in memory.

downloadFilesAsStream(inputId)

Browser to server; returns a stream per file.

listFiles(inputId) / downloadFileAsStream(handler)

Inspect selections, then consume chosen files.

uploadFile(name, stream, size, mimeType)

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

spoonbill-ce3

spoonbill.cats.* supplies the default IO instance; IO3Effect accepts an explicit IORuntime.

ZIO 2

spoonbill-zio2

taskEffectLayer / taskEffectInstance, or Zio2Effect[R, E] with error conversions.

Monix

spoonbill-monix

spoonbill.monix.*, with the application’s Monix scheduler.

Legacy Cats Effect 2 / ZIO 1

spoonbill-ce2 / spoonbill-zio

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.

10.2. HTTP adapters

Server Artifact Entry point

Standalone

spoonbill-standalone

spoonbill.server.standalone.buildServer or SpoonbillApp.

Pekko HTTP

spoonbill-pekko

spoonbill.pekko.pekkoHttpService(config) with PekkoHttpServerConfig.

Akka HTTP

spoonbill-akka

spoonbill.akka.akkaHttpService(config) with AkkaHttpServerConfig.

ZIO HTTP

spoonbill-zio-http

new ZioHttpSpoonbill[R].service(config) with a compatible ZIO 2 effect/runtime.

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-ce3 for FS2 3 / Cats Effect 3; spoonbill-fs2-ce2 for FS2 2 / Cats Effect 2.

  • spoonbill-zio2-streams for ZIO 2 streams; spoonbill-zio-streams for 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.