diff --git a/docs/files.md b/docs/files.md index e6b751739..5ed1dc312 100644 --- a/docs/files.md +++ b/docs/files.md @@ -1,115 +1,199 @@ # File Systems -**OUT OF DATE** +Last updated July 2025 (`version-5`) -Storing, serving, and using user-provided files is a common requirement, and as such, is built-in directly. +Storing, serving, and using user-provided files is a common requirement, so Lightning Server provides a file-system +abstraction and a set of ready-made upload endpoints. The abstraction lives in the service-abstractions library +(`com.lightningkite.services.files`), and the HTTP endpoints that wrap it live in the `files` / `files-shared` +modules. -Valid file backends that have been built so far are Local, S3, and Azure Blob Storage. SFTP is also partially -supported - it doesn't support public URLs. +Available backends include a local (KotlinX-IO) file system and AWS S3. The API is modeled loosely on Kotlin's +built-in file functions. -The API is roughly based on Kotlin's built-in file functions. +## Declaring the need for a file system -## Declaring the need for a file sysstem +Add a setting whose value is a `PublicFileSystem`: -Add a setting as follows: +```kotlin +import com.lightningkite.services.files.PublicFileSystem + +object Server : ServerBuilder() { + val files = setting("files", PublicFileSystem.Settings()) +} +``` + +To make additional backends available, reference them in an `init` block so their loaders register themselves: ```kotlin -object Server { - //... - val files = setting(name = "files", default = FilesSettings()) - //... +import com.lightningkite.services.files.s3.S3PublicFileSystem + +object Server : ServerBuilder() { + init { S3PublicFileSystem } + val files = setting("files", PublicFileSystem.Settings()) } ``` +Like every service, the setting resolves to a `Runtime`. You invoke it inside a +`ServerRuntime` context (for example, within a handler) to get the live `PublicFileSystem`. + ## Accessing files +`FileObject` is the internal handle used to read and write files. Get one from the file system's `root` and +navigate with `then`: + ```kotlin -val rootFolder = Server.files().root +import com.lightningkite.services.data.TypedData +import com.lightningkite.MediaType -val testFile = rootFolder.resolve("some/path/file.txt") +val root = files().root -// Files are written in terms of `HttpContent` -// There's no mkdir. If a folder does not exist it will be created. -testFile.put(HttpContent.Text("Hello world!", ContentType.Text.Plain)) +val testFile = root.then("some/path/file.txt") -// Files are read in terms of `HttpContent` as well -testFile.get()!!.stream().use { it: InputStream -> - it.readAllBytes() -} +// Files are written and read as TypedData. Parent folders are created implicitly. +testFile.put(TypedData.text("Hello world!", MediaType.Text.Plain)) -// You can just get metadata too -testFile.head() +// get() returns TypedData?, null if the file does not exist. +val text = testFile.get()?.text() -// Generates a file reference with a large random identifier in it. -val newFile = rootFolder.resolveRandom("test", "txt") +// Metadata only (media type, size) without downloading the body. +val info = testFile.head() -// You can list files too. -rootFolder.list().forEach { - println(it) -} +// Remove a file. +testFile.delete() ``` ## Serving files -These files have URLs that are signed for retrieval. Performing an HTTP GET will result in the file. +Files can be served over signed URLs. Performing an HTTP GET against a signed URL returns the file: ```kotlin -// Duration of the signature is determined by the file system settings +// The signature duration is determined by the file system settings. println(testFile.signedUrl) ``` -## Uploading files from a client +### `FileSystemEndpoints` -You can sign an upload URL like so: +`FileSystemEndpoints` exposes HEAD/GET/PUT handlers for a `PublicFileSystem`, including HTTP Range support on GET +for partial/resumable downloads. GET streams the bytes, HEAD returns metadata, and PUT accepts an upload to a +signed upload URL (only the local `KotlinxIoPublicFileSystem` accepts server-generated uploads this way; S3/Azure +sign their own upload URLs). ```kotlin -// Include the expiration duration -alt.uploadUrl(Duration.ofMinutes(10)) +val fileServing = path.path("files") include FileSystemEndpoints(files) ``` -Performing an HTTP PUT with the file's contents will overwrite that file. +## Uploading files from a client -## Serialization +You can sign a PUT upload URL for any file reference: -`FileObject` is a file-system resolve object which can be used to read and write files. They are purely internal to the -server. +```kotlin +import kotlin.time.Duration.Companion.minutes -`ServerFile` is a wrapper around a string that contains a public URL for an object. They are used in APIs and -serialization. +val uploadUrl = root.then("uploads/photo.jpg").uploadUrl(10.minutes) +``` -You can switch between the two using `FileObject.serverFile` and `ServerFile.fileObject`. +Performing an HTTP PUT of the file's contents to that URL writes the file. Upload URLs never grant read access. -### Security +## `ServerFile` and serialization -When a `ServerFile` is sent to a client, the url is automatically signed for reading. Therefore, if you wish to keep a -file in your file system secure, only serialize references to it for the people you want to read it. +Two types work together: -## Default File Upload Endpoints +- `FileObject` is the internal read/write handle. It never leaves the server. +- `ServerFile` (`com.lightningkite.services.files.ServerFile`) is a serializable wrapper around a URL string. + It is what you store in models and send over your API. -There is a pre-built upload endpoint for uploading files to use in subsequent requests. It requires a reference to the -intended file system to use, a database to track whether the file has been used (if it's unused, it is garbage -collected), and a `JwtSigner` setting to secure file reuse. +```kotlin +import com.lightningkite.services.files.ServerFile + +@GenerateDataClassPaths +@Serializable +data class Post( + override val _id: Uuid = Uuid.random(), + val title: String, + @MimeType("image/*") val coverImage: ServerFile? = null, +) : HasId +``` -This endpoint prevents abuse of your file system by returning two URLs: a `uploadUrl` and a `futureCallToken`. +You move between the two by resolving the `ServerFile`'s location against the file system: + +```kotlin +val obj = files().root.then(post.coverImage!!.location) // ServerFile -> FileObject +val ref = ServerFile(obj.url) // FileObject -> ServerFile +``` + +### Security -`uploadUrl` is the URL which the client should PUT their file to. +When a `ServerFile` is serialized out to a client, its URL is automatically signed for reading. Consequently, a +file stays private as long as you only serialize references to it for people you want to be able to read it. Do not +build a public file-sharing feature on top of this behavior by accident. -`futureCallToken` is a URL that can be used as a `ServerFile` in a subsequent request. +## The `UploadEarlyEndpoint` (recommended upload flow) -Neither URL will allow reading of the file, and thus, you cannot abuse this endpoint as a file-sharing system. +`UploadEarlyEndpoint` is an opinionated group of endpoints for the common "upload now, reference later" pattern. It +lets a client upload a file before the request that actually uses it, without turning your file system into an open +file-sharing service. -It is *strongly* recommended that you use this endpoint for handling files in your API rather than attempting to -implement it yourself. +Construct it with the file system, a database (used to track pending uploads for garbage collection), and a list of +`FileScanner`s (often empty): ```kotlin -val upload = UploadEarlyEndpoint(path("early-upload"), files, database, signer) +import com.lightningkite.lightningserver.files.UploadEarlyEndpoint +import com.lightningkite.lightningserver.definition.Runtime + +object Server : ServerBuilder() { + val files = setting("files", PublicFileSystem.Settings()) + val database = setting("database", Database.Settings()) + + // Register ONCE. See the warning below. + val uploadEarly = path.path("upload") module UploadEarlyEndpoint( + files = files, + database = database, + fileScanner = Runtime.Constant(listOf()), + ) +} ``` +### The flow + +1. The client calls the `endpoint` (GET) to obtain an `UploadInformation`: + - `uploadUrl` — a presigned PUT URL to upload the bytes to. + - `futureCallToken` — a token that can be sent as a serialized `ServerFile` in a later request. +2. The client PUTs the file to `uploadUrl`. +3. The client includes `futureCallToken` as the `ServerFile` value in a subsequent API call. + +Neither URL grants read access, so the endpoint cannot be abused as a file host. A daily `cleanupSchedule` deletes +uploads that were prepared but never used before their `expiration` (default one day). + +It is strongly recommended that you use this endpoint for API file handling rather than implementing the flow +yourself. + +### Quarantine (jail) and scanning + +If you pass a non-empty list of `FileScanner`s (for example, the ClamAV scanner from the `files-clamav` module), +uploads are first written to a jailed location (`jailFilePath`, default `upload-jail`) instead of the ready +location (`filePath`, default `uploaded`). Before the +file can be used it must be scanned and moved out of jail. The client does this by calling the `verify` (POST) +endpoint with the token; if the file is safe it is moved to the ready location and a reusable reference is returned. +Verifying up front also makes the subsequent request faster, since the scan has already happened. + +With an empty scanner list, uploads go straight to the ready location and are certified as already scanned. + +### ⚠️ Only one `UploadEarlyEndpoint` per server + +`UploadEarlyEndpoint` registers a **contextual serializer for `ServerFile`** (via its `externalSerialization`) so +that upload tokens can be decoded as `ServerFile` values. If you instantiate `UploadEarlyEndpoint` more than once +(for example, a separate instance created only for a test), the multiple instances register **conflicting +`ServerFile` serializers**. This does not fail at compile time — it surfaces at runtime as serialization errors, +typically appearing as `500 Internal Server Error` responses. + +**Instantiate `UploadEarlyEndpoint` exactly once in your server definition and reference that single instance +everywhere, including from tests.** + ## Available Backends ### Local -Simply use a local filesystem folder. +Use a local filesystem folder: ```json5 // settings.json @@ -122,9 +206,8 @@ Simply use a local filesystem folder. ```kotlin // Server.kt -object Server: ServerPathGroup(ServerPath.root) { - // Adds S3FileSystem to the possible file system loaders - init { S3FileSystem } +object Server : ServerBuilder() { + init { S3PublicFileSystem } } ``` @@ -134,20 +217,3 @@ object Server: ServerPathGroup(ServerPath.root) { "files": { "url": "s3://[user]:[password]@[bucket].[region].amazonaws.com" } } ``` - -### Azure Blob Storage - -```kotlin -// Server.kt -object Server: ServerPathGroup(ServerPath.root) { - // Adds AzureFileSystem to the possible file system loaders - init { AzureFileSystem } -} -``` - -```json5 -// settings.json -{ - "files": { "url": "azbs://key@account/container" } -} -``` \ No newline at end of file diff --git a/docs/migration-v4-to-v5.md b/docs/migration-v4-to-v5.md new file mode 100644 index 000000000..ed78c9811 --- /dev/null +++ b/docs/migration-v4-to-v5.md @@ -0,0 +1,200 @@ +# Migrating from v4 to v5 + +Last updated July 2025 (`version-5`) + +Lightning Server 5 is a substantial reorganization of version 4. The biggest change is that the service +abstractions (database, cache, files, email, notifications) were extracted into a separate +`com.lightningkite.services.*` library, and the server itself was split into a **definition** phase and a +**runtime** phase. This guide covers the server-side changes. + +!!! note + The authoritative, continually-updated migration reference — including the KiteUI 7 client-side changes — is + the `ls5-kui7-migration` skill. This page summarizes the server portions; consult the skill for the full + checklist and for client/app migration. + +## Overview of major changes + +- **Service abstractions extracted.** Database, files, cache, email, and notifications now live under + `com.lightningkite.services.*` instead of `com.lightningkite.lightningserver.*`. +- **Definition vs. runtime split.** A server is now declared as a `ServerBuilder` (the definition). Services and + settings resolve to `Runtime` values that are only realized inside a `ServerRuntime` context. +- **Context parameters.** Kotlin context parameters are used heavily — most runtime code carries + `context(runtime: ServerRuntime)`. This requires the `-Xcontext-parameters` compiler flag. +- **Standard-library types.** `com.lightningkite.UUID` → `kotlin.uuid.Uuid`, and `kotlinx.datetime.Instant` → + `kotlin.time.Instant`. +- **Module split.** The single `...Shared` client artifact was split into `core-shared`, `typed-shared`, + `sessions-shared`, `files-shared`, and `media-shared`. + +## Dependencies + +The old single server and single shared dependencies become several targeted modules, and database/files/etc. +come from the service-abstractions library. Representative server dependencies: + +```kotlin +dependencies { + api(libs.comLightningKite.services.database) + api(libs.comLightningKite.services.database.jsonfile) + api(libs.comLightningKite.lightningServer.core) + api(libs.comLightningKite.lightningServer.typed) + api(libs.comLightningKite.lightningServer.files) + api(libs.comLightningKite.lightningServer.sessions) + ksp(libs.comLightningKite.services.database.processor) +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + freeCompilerArgs.add("-Xcontext-parameters") + } +} +``` + +The database KSP processor moved from `...lightningserver...Processor` to +`com.lightningkite.services:database-processor`. See the migration skill for the full artifact-name mapping. + +## Imports + +The most common import moves: + +```kotlin +// Standard-library types +import kotlin.uuid.Uuid // was com.lightningkite.UUID +import kotlin.time.Instant // was kotlinx.datetime.Instant +import com.lightningkite.lightningserver.runtime.now // was com.lightningkite.now + +// Database & data (was com.lightningkite.lightningdb.*) +import com.lightningkite.services.data.* +import com.lightningkite.services.database.* +import com.lightningkite.services.files.* + +// Services (were under com.lightningkite.lightningserver.*) +import com.lightningkite.services.files.ServerFile // was ...lightningserver.files.ServerFile +import com.lightningkite.services.files.PublicFileSystem +import com.lightningkite.services.database.Database +import com.lightningkite.services.cache.Cache +import com.lightningkite.services.email.Email +import com.lightningkite.services.notifications.NotificationService + +// Server builder (was com.lightningkite.lightningserver.core.ServerPathGroup) +import com.lightningkite.lightningserver.definition.builder.ServerBuilder +import com.lightningkite.lightningserver.runtime.ServerRuntime +``` + +## Server definition: `ServerPathGroup` → `ServerBuilder` + +Servers and endpoint groups are now `ServerBuilder` objects. Settings use the service `Settings()` factories, and +sub-groups are attached with `module`/`include`: + +```kotlin +// OLD +object Server : ServerPathGroup(ServerPath.root) { + val database = setting(name = "database", default = DatabaseSettings()) + val files = setting(name = "files", default = FilesSettings()) + val users = UserEndpoints(path("users")) +} + +// NEW +object Server : ServerBuilder() { + val database = setting("database", Database.Settings()) + val files = setting("files", PublicFileSystem.Settings()) + val users = path.path("users") module UserEndpoints +} +``` + +Settings changed from standalone `*Settings()` constructors to nested `Service.Settings()` factories, for example +`CacheSettings()` → `Cache.Settings()`, `FilesSettings()` → `PublicFileSystem.Settings()`, +`NotificationSettings("console")` → `NotificationService.Settings("console")`. + +## Definition vs. runtime + +In v4, calling a setting gave you the service directly. In v5 a setting is a `Runtime` in the definition, and +you resolve it inside a `ServerRuntime` context (for example within a handler, where the context is present). Code +that touches services must therefore carry `context(runtime: ServerRuntime)`: + +```kotlin +// A helper that uses the database now declares the runtime context. +context(runtime: ServerRuntime) +suspend fun countUsers(): Int = database().table().count() +``` + +This is the most pervasive source of migration compile errors: functions called from handlers, hooks, or signals +need the `context(runtime: ServerRuntime)` receiver added. + +## Endpoint registration + +Handlers and sub-groups are attached to paths with infix operators instead of constructor-with-path: + +```kotlin +// Sub-group of endpoints +val users = path.path("users") module UserEndpoints + +// A single ServerBuilder component (e.g. generated REST endpoints) +val rest = path.path("rest") include ModelRestEndpoints(info) + +// A single handler / task / topic +val root = path.get bind HttpHandler { HttpResponse.plainText("Hello") } +``` + +## Database changes + +- `.collection()` → `.table()`, and `.baseCollection()` → `.baseTable()`. +- `UUID` → `Uuid` throughout models: `HasId` → `HasId`, `UUID.random()` → `Uuid.random()`. +- Instants use `kotlin.time.Instant`; use `now()` from `com.lightningkite.lightningserver.runtime.now`. +- `ModelInfo` signals operate on tables and take context-parameterized hooks. + +```kotlin +// OLD +info.collection().insertOne(model) + +// NEW +info.table().insertOne(model) +``` + +## Typed endpoints + +Typed endpoints are declared with `ApiHttpHandler` bound to a path, taking the auth requirement as `auth` and the +logic as `implementation`: + +```kotlin +val hello = path.path("hello").get bind ApiHttpHandler( + summary = "Hello", + description = "Returns a greeting", + auth = noAuth, + implementation = { input: Unit -> "Hello" } +) +``` + +See [Typed Endpoints](typed-endpoints.md) for the full API. + +## WebSockets + +The websocket package moved from `...websocket` (singular) to `...websockets` (plural), and handlers are attached +with `bind`/`include`: + +```kotlin +// OLD +import com.lightningkite.lightningserver.websocket.MultiplexWebSocketHandler +val multiplex = path("multiplex").websocket(MultiplexWebSocketHandler(cache)) + +// NEW +import com.lightningkite.lightningserver.websockets.MultiplexWebSocketHandler +val multiplex = path.path("multiplex") bind MultiplexWebSocketHandler() +``` + +See [WebSockets](websockets.md) for the current API. + +## Common pitfalls + +- **Missing context parameter.** "Missing context receiver" compile errors mean a function that uses services + needs `context(runtime: ServerRuntime)`. +- **`collection()` unresolved.** Replace `.collection()`/`.baseCollection()` with `.table()`/`.baseTable()`. +- **Wrong `Instant` import.** Use `kotlin.time.Instant`, not `kotlinx.datetime.Instant`. +- **Duplicate `UploadEarlyEndpoint`.** Instantiate it exactly once; multiple instances register conflicting + `ServerFile` serializers and cause runtime 500s. See [File Systems](files.md). + +## Client and KiteUI migration + +This page intentionally focuses on the server. The client SDK and KiteUI 7 changes (dependency renames, the dash → +dot view-modifier syntax, theme derivations, `Api2` → `Api`, `OtpSecret` → `TotpSecret`, and more) are extensive +and are documented in full in the `ls5-kui7-migration` skill. diff --git a/docs/websockets.md b/docs/websockets.md index f73902fbc..743383088 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -1,5 +1,155 @@ -# Websockets +# WebSockets -**OUT OF DATE** +Last updated July 2025 (`version-5`) -TODO \ No newline at end of file +Lightning Server supports WebSockets for real-time, two-way communication. The core primitives live in +`com.lightningkite.lightningserver.websockets`; the `typed` module adds type-safe WebSockets that participate in +SDK generation. + +A key design goal is that the same handler runs identically whether the server is a single local process or a +fleet of AWS Lambda instances behind API Gateway. To make that work, connection state and cross-connection +delivery are modeled explicitly (serializable storage and pub/sub topics) rather than kept in local memory. + +## Declaring a WebSocket endpoint + +Bind a `WebSocketHandler` to a path with `bind`. The handler is built from lifecycle callbacks. The value returned +by `willConnect` becomes this connection's `STORAGE` (its per-connection state); its type is inferred. + +```kotlin +import com.lightningkite.lightningserver.websockets.* +import kotlin.uuid.Uuid + +object ChatEndpoints : ServerBuilder() { + val echo = path.path("ws").path("echo") bind WebSocketHandler( + // Runs before the connection is accepted. Returns the initial STORAGE. + willConnect = { request -> "echo-${Uuid.random()}" }, + // Runs once the connection is open. `this` is the connection. + didConnect = { send("Echo server ready") }, + // Runs for each inbound frame from the client. + messageFromClient = { frame -> send("Echo: ${frame.text}") }, + // Runs when the connection closes. + disconnect = { reason -> println("Closed: $currentState ($reason)") }, + ) +} +``` + +### Connection lifecycle + +The callbacks receive different contexts: + +- `willConnect: suspend ServerRuntime.(request) -> STORAGE` — runs with a `ServerRuntime`. Return the initial + state. Throwing here rejects the connection. +- `didConnect`, `messageFromClient`, `disconnect` — run with the `WebSocketConnection` as receiver, which extends + `ServerRuntime` and adds WebSocket-specific members. + +Inside those callbacks you have access to: + +- `currentState: STORAGE` — the current per-connection state. +- `send(...)` — send a frame to the client. Overloads accept `String`, `ByteArray`, or a `WebSocketFrame`. +- `subscribe(topic)` / `unsubscribe(topic)` — join or leave a pub/sub topic. +- `updateStateImmediately { }` / `queueStateUpdate { }` — atomically update `STORAGE`. +- `close(reason)` — close with a `WebSocketClose` code (e.g. `WebSocketClose.NORMAL`). + +Inbound frames are `WebSocketFrame` (`WebSocketFrame.Text` or `WebSocketFrame.Binary`). Use `frame.text` for the +string form. + +## Topics and pub/sub delivery + +Because a connection may live on a different instance than the code that wants to message it, server-to-client +broadcasts go through **topics**. A topic is a named, typed channel that connections subscribe to; publishing to it +delivers the message to every subscribed connection, across all instances, via the configured pub/sub service. + +Declare a topic on a path with `.topic(serializer)`: + +```kotlin +import com.lightningkite.lightningserver.runtime.send // topic.send(...) extension +import kotlinx.serialization.builtins.serializer + +object ChatEndpoints : ServerBuilder() { + val chatTopic = path.path("ws").path("chat-topic").topic(ChatMessage.serializer()) + + val chatSocket = path.path("ws").path("chat") bind WebSocketHandler( + willConnect = { Uuid.random().toString() }, + didConnect = { + subscribe(chatTopic) // start receiving topic messages + send("Welcome, session $currentState") + }, + messageFromClient = { frame -> + val incoming = Json.decodeFromString(ChatMessage.serializer(), frame.text) + chatTopic.send(incoming) // broadcast to all subscribers + }, + // Handle messages that arrive from subscribed topics. + topicHandlers = { + chatTopic bind { message -> + send(Json.encodeToString(ChatMessage.serializer(), message.value)) + } + }, + ) +} +``` + +You can also publish to a topic from anywhere that has a `ServerRuntime` context — for example, from an HTTP +handler — using the `send` extension (`import com.lightningkite.lightningserver.runtime.send`): + +```kotlin +val announce = path.path("announce").arg("text").get bind HttpHandler { request -> + chatTopic.send(ChatMessage(content = request.path.arg1)) + HttpResponse.plainText("sent") +} +``` + +Topics may carry path parameters, so subscriptions can be scoped to a specific resource. A single-argument topic is +published with `topic.send(path1 = "user123", value = ...)` and subscribed to with `subscribe(topic, "user123")`. + +## Multiplexed connections + +`MultiplexWebSocketHandler` lets a client run many logical channels over a single physical WebSocket connection, +which is useful when a client wants several simultaneous subscriptions without opening many sockets. + +```kotlin +val multiplex = path.path("ws").path("multiplex") bind MultiplexWebSocketHandler() +``` + +`QueryParamWebSocketHandler` is a related helper that selects the underlying handler based on a query parameter. + +## Typed WebSockets + +The `typed` module provides `ApiWebsocketHandler`, which adds typed `INPUT`/`OUTPUT` messages, authentication, and +SDK generation. Instead of raw frames, your callbacks receive already-deserialized `INPUT` values and `send` takes +an `OUTPUT` value; content negotiation (JSON/CBOR) is handled for you. + +```kotlin +import com.lightningkite.lightningserver.typed.ApiWebsocketHandler +import com.lightningkite.lightningserver.auth.noAuth + +val liveFeed = path.path("ws").path("feed") bind ApiWebsocketHandler<_, Unit, User?, FeedRequest, FeedUpdate>( + summary = "Live Feed", + description = "Streams feed updates to the client.", + auth = noAuth, + willConnectType = { access -> Unit }, + messageFromClientType = { request: FeedRequest -> + send(FeedUpdate(/* ... */)) // send takes a typed OUTPUT + }, +) +``` + +Inside the typed callbacks, `auth()` resolves the connection's authenticated principal, and `send`, `subscribe`, +and the state helpers behave as they do for raw handlers. + +`ModelRestUpdatesWebsocket` (and the combined `ModelRestEndpoints(info) + ModelRestUpdatesWebsocket(info)`) build +on this to push live database changes to clients — see the model REST documentation. + +## Local vs. AWS execution models + +The lifecycle above is identical across engines, but the machinery underneath differs: + +- **Local engines (Ktor, Netty, JDK).** Everything runs in one process. Handlers that implement + `DirectExecutableWebSocketHandler` are driven directly, bypassing pub/sub overhead. Per-connection state and + subscriptions are kept in-process. +- **AWS (API Gateway + Lambda).** Each frame may be handled by a different Lambda instance, so nothing can rely on + local memory. The `STORAGE` object is serialized and stored (in DynamoDB), subscriptions are tracked there too, + and outbound messages are routed either directly to the API Gateway connection (via the connection's + `engineSocketId`) or through the pub/sub service. This is why `STORAGE` must be `@Serializable` and why + cross-connection messaging goes through topics rather than direct references. + +Writing to the lifecycle/topic API keeps your code portable between the two.