@caspian-vega/transfer-state
v0.2.1
Published
SSR→client transfer state for Astro. Collects values in an AsyncLocalStorage scope during rendering and replays them on the client, so work done on the server is not repeated in the browser.
Maintainers
Readme
@caspian-vega/transfer-state
SSR to client transfer state for Astro. Values produced while rendering are collected per request, emitted with the document, and read back on the client instead of being recomputed.
Part of caspian-vega-astro-libs.
Install
npm install @caspian-vega/transfer-state
# or
pnpm add @caspian-vega/transfer-stateAstro is an optional peer dependency, needed only for the .astro component.
Setup
Two server-side steps.
- Open a scope per request in
src/middleware.ts:
import { sequence } from 'astro/middleware';
import { transferStateMiddleware } from '@caspian-vega/transfer-state/server';
export const onRequest = sequence(transferStateMiddleware, ...yourOtherMiddleware);- Emit the payload as the last element of the layout, after
</body>and inside</html>:
---
import TransferStateClient from '@caspian-vega/transfer-state/TransferStateClient.astro';
---
<html>
<head></head>
<body>
<slot />
</body><TransferStateClient />
</html>The component drains the bucket when it renders. Anything collected by a component rendered after it does not reach the client.
Usage
import { transferAction } from '@caspian-vega/transfer-state';
export const loadPost = transferAction('post:byId', (id: string) => api.getPost(id));
// Server: calls api.getPost and stores the result.
// Client: returns the stored result, api.getPost is not called.
const post = await loadPost('42');Manual store and read:
import { addTransferState, getTransferStateValue, makeStateKey } from '@caspian-vega/transfer-state';
const key = makeStateKey({ resource: 'posts', page: 1 });
addTransferState(key, posts);
const restored = getTransferStateValue(key);Resolution order
flowchart TD
Call[transferAction call] --> Scope{Server request scope open?}
Scope -->|yes| Run[Run callback]
Run --> Store[Write bucket under id + args hash]
Store --> Ret1[Return value]
Scope -->|no| Snap{Key present in snapshot?}
Snap -->|yes| Read[Read and consume]
Read --> Ret2[Return stored value]
Snap -->|no| Run2[Run callback]
Run2 --> Ret3[Return value]Entry points
node:async_hooks cannot be bundled for the client, so the package is split.
| Import | Contains | Client safe |
| -------------------------------------------------------- | ----------------------------------------------------- | -------------------- |
| @caspian-vega/transfer-state | transferAction, read and write helpers, key hashing | yes |
| @caspian-vega/transfer-state/server | scope, middleware, drain | no |
| @caspian-vega/transfer-state/TransferStateClient.astro | payload component | server rendered only |
Import /server in middleware.ts and .astro frontmatter only. Importing it in a client build
fails at import time with a message naming the fix.
API
transferAction(id, callback)
Wraps a callback so its server result is reused on the client.
id(string, required): stable name, combined with the call arguments to form the key.callback((...args) => TResult, required): sync or async. Its result must be JSON-serializable.- Returns:
TransferAction<TArgs, TResult>, an async function with the callback's parameters.
const loadFeed = transferAction('feed:list', (topic: string) => api.getFeed(topic));
const feed = await loadFeed('astro');makeStateKey(key)
Builds a stable short key.
key(string | object, required): JSON-serializable description. Property order is part of the result, so build it identically on both sides.- Returns:
string
getTransferState()
- Returns:
TransferStateBucket | undefined. The collecting bucket on the server, the snapshot on the client,undefinedwhen neither exists.
getTransferStateValue(key, options?)
Reads one value.
key(string, required): key returned bymakeStateKey.options.consumeOnce(boolean, defaulttrue): remove the entry after reading.- Returns:
unknown, orundefinedwhen the key is absent.
const value = getTransferStateValue(key, { consumeOnce: false });addTransferState(key, value)
Stores a value for the current request. No-op on the client, so callers do not branch on the environment.
key(string, required)value(unknown, required): must be JSON-serializable.- Returns:
void
isCollecting()
- Returns:
boolean,trueinside a server request scope.
createTransferState()
- Returns:
TransferStateAccess, an object carryingisCollecting,makeStateKey,getTransferState,getTransferStateValue,addTransferStateandtransferAction. Register it as a value in any container.
export class AccessTransferState {
private readonly access = createTransferState();
}TRANSFER_STATE_GLOBAL
string, name of the globalThis property holding the client snapshot.
transferStateMiddleware
Astro middleware opening one scope per request. Register it before any middleware that renders.
- Signature:
(context, next) => Promise<Response>
runWithTransferState(fn, bucket?)
Runs fn inside a fresh scope. Everything awaited within it reaches the same bucket.
fn(() => T, required)bucket(TransferStateBucket, default{}): pre-seeded bucket.- Returns:
T
const html = runWithTransferState(() => render(page));getRequestTransferState()
- Returns:
TransferStateBucket | undefined, the bucket of the in-flight request.
isTransferStateActive()
- Returns:
boolean,trueinside a scope opened byrunWithTransferState.
drainTransferState()
Reads the collected state and clears the bucket, so a second render pass cannot emit it twice.
- Returns:
TransferStateBucket
Types
TransferAction
(...args: TArgs) => Promise<Awaited<TResult>>
TransferStateBucket
Record<string, unknown> holding one request's collected values.
GetOptions
consumeOnce(boolean, optional): remove the entry after reading. Defaulttrue.
TransferStateAccess
ReturnType<typeof createTransferState>
Constraints
- Values travel through a
<script>tag, so they must be JSON-serializable. NoDate,Map,undefinedinside objects, or class instances. - Reads consume by default. Pass
{ consumeOnce: false }for a value read more than once. - The payload is plain text in the HTML. It is per request, not shared between users, but fully visible to the recipient.
- View transitions are supported. The snapshot is read on every access, so a
<ClientRouter />swap is picked up. - Requires a Node-compatible SSR runtime providing
node:async_hooks.
Synergies
See SYNERGIES.md.
License
MIT
