You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Serializing #server exported function results during SSR and consuming that
payload during client hydration.
Using hash-addressed RPC transport for server function calls.
Adding server-side and cross-request caching using a stable file + function + args hash key.
Defining explicit behavior and diagnostics for universal async functions used
in components.
Adding argument validation hooks for #server functions (TypeScript-informed
and schema-based).
The goal is to improve TTFB-to-interactive continuity and make server/client
execution semantics explicit and safe.
Serialization and hydration in this proposal use devalue.stringify(...) and devalue.parse(...).
Motivation
Current SSR + hydration flow does not provide explicit protocol negotiation for #server function calls. The same function may run:
Once during SSR.
Again during client hydration or immediate client interaction.
This can create state divergence if client-side cached results drift from server
state. In addition, universal async functions (plain async functions reachable
from components) can create ambiguity about where execution happens. Finally,
server function arguments need consistent runtime validation to avoid
trust-by-annotation problems.
Goals
Keep server function result caching on the server side.
Hydrate precomputed #server function results from SSR into client runtime.
Use a deterministic cache key based on module identity, export name, and
canonicalized args.
Use hash-addressed RPC calls (/_$_ripple_rpc_$_/<function_hash>) for server
function invocation.
Negotiate wire protocol by sending client-supported serialization metadata in
generated RPC calls and letting the server choose a compatible response version.
Introduce configurable cache invalidation and TTL controls.
Provide compile-time diagnostics for unsafe universal async usage.
Provide runtime validation hooks for #server function parameters.
Non-Goals
Full offline cache storage strategy (Service Worker/IndexedDB) in this RFC.
Replacing the current /_$_ripple_rpc_$_/<function_hash> transport in this RFC.
Arbitrary closure serialization for function arguments/results.
Persistent client-side server-function result caching beyond one-time hydration
payload consumption.
Terminology
Server function: Named export inside a #server block callable from compiled
component code.
Universal function: A plain function declared in module scope that may run on
server and client depending on usage.
Hydration payload: Data serialized into SSR HTML for client hydration bootstrap.
1. SSR Serialization and Client Hydration for #server Exports
1.1 Compile-time transform
Compiler already emits a module-scoped server object (_$_server_$_) for #server exports in both client and server compilations. This RFC builds on that
existing mechanism.
In server compilation, _$_server_$_.<name> points to the real server function
implementation via a generated wrapper.
In client compilation, _$_server_$_.<name> is an RPC stub that calls _$_.rpc(function_hash, args, protocol).
Server wrapper responsibilities:
Receive original call arguments.
Run useValidation(...) before user logic.
Compute function/args/cache hashes.
Check server cache and return cached value when present.
On miss, call the user-authored function body and store/register the result.
Server-side cache checks in this RFC happen at this module-object boundary (inside
generated _$_server_$_ function properties), rather than via a separate global
callsite transform.
Illustrative shape:
_$_server_$_.<export_name>(...args)
During SSR, if the call executes successfully, runtime stores the result in an SSR
hydration object keyed by cache key.
1.2 Script payload format
SSR emits one script block per server function call result (streaming-friendly),
using the cache hash in the script id:
the outer JSON and embedded payload string must be escaped for script-safe HTML
output.
script id format is: __ripple_server_data_{cache_hash}.
Serialization options:
runtime calls devalue.stringify(entry, reducers) and devalue.parse(payload, revivers).
reducers/revivers come from global config plus function-level overrides.
1.3 Client behavior
At runtime, generated client _$_server_$_.<export_name>(...args) stubs perform a
one-time hydration payload lookup first, then fall back to RPC. Client runtime
does not keep a persistent in-memory result cache.
Hydration lookup flow:
Derive cache_hash from function_hash + serialized_args_hash.
Read <script id="__ripple_server_data_{cache_hash}"> if present.
Parse envelope JSON and restore entry via devalue.parse(payload, revivers).
Consume and remove the script node after successful parse.
Return hydrated value immediately for that call.
If no hydration payload exists, perform RPC request.
The generated RPC call includes protocol negotiation metadata hard-coded at client
build time:
version (for example 1) for wire envelope schema.
acceptEncodings (for example ['devalue@5']) for supported decode formats.
The server chooses an appropriate response version/encoding and returns those in
the response envelope.
RPC response bodies also use devalue.stringify(result, reducers) on the server
and devalue.parse(result_payload, revivers) on the client.
In server compilation, generated _$_server_$_.<export_name>(...args) wrappers
perform the same cache-key lookup against module-level server cache before
executing the function body.
1.4 Custom serializer/parser extension points
Ripple exposes devalue-compatible extension points for custom types.
If same key and unexpired data exists in server cache, return cached response.
Client always re-requests via RPC after hydration; no client-side result reuse
is performed.
5. Universal Functions: Semantics and Diagnostics
5.1 Problem
Plain async functions in component scope can be used in SSR render and also in
client effects/handlers, creating uncertainty about environment and timing.
5.2 Compiler warnings
Emit warning when compiler detects:
await/Promise-consuming call in render-reachable component code.
No explicit environment boundary (#server, client-only effect/handler, or
guarded branch).
Do not emit this warning for async usage inside effect(...) or event handlers.
Those paths are client-only, are not compiled into SSR server execution, and
therefore are out of scope for this server/client ambiguity diagnostic.
Do not emit this warning for async calls that are explicitly environment-guarded
by if (import.meta.env.SSR) { ... } or if (!import.meta.env.SSR) { ... }.
These branches are environment-specific by construction and do not represent
ambiguous universal execution.
Diagnostic message (proposed):
Async universal function used in render path may execute in both server and client.
Use a #server export for data-fetching or guard execution by environment.
5.3 Environment behavior
effect(...) and event handlers are client-only and must not execute on SSR.
SSR render path should not execute client-only hooks.
For universal function use in SSR, runtime may optionally resolve via fetch if
explicitly configured, but default guidance is to migrate data access to #server exports.
5.4 Diagnostic scope exclusion
Async function calls within effect(...) callbacks are excluded from
universal-async SSR warnings.
Async function calls within event handlers (for example, onclick, oninput)
are excluded from universal-async SSR warnings.
Async function calls inside if (import.meta.env.SSR) branches are excluded
from universal-async SSR warnings.
Async function calls inside if (!import.meta.env.SSR) branches are excluded
from universal-async SSR warnings.
The warning only applies to render-reachable code paths that are emitted into
server output.
6. Validation Model for #server Function Arguments
6.1 Validation hook
Require each #server function to call useValidation(schema, argsObject) as the
first executable statement.
Attempt one-time hydration payload consumption by script id before RPC.
Send generated protocol metadata (version, acceptEncodings) with every RPC
call.
Do not keep a persistent cache of server function results on client.
Decode server-selected response encoding/version and return value directly.
Backward Compatibility and Migration
Keep current /_$_ripple_rpc_$_/<function_hash> endpoint shape.
Existing deployments should enforce configured request/response size limits.
Alternatives Considered
GET query transport (h/k/a) with client-supplied cache key: not chosen for
current implementation alignment.
SSR payload in HTML comments instead of script JSON: rejected due to complexity
and parsing overhead.
Type-only validation: rejected because TS types are erased at runtime.
Open Questions
What are sane default limits for request body/response/hydration payload sizes
across common deployments?
What is the canonical serializer spec for special JS types and bigint
precision?
Which adapter-level cache API should be standardized first (KV-like vs
fetch-like)?
Should Ripple publish helper docs/examples for common schema libs (Zod,
Valibot, ArkType) in v1?
Rollout Plan
Land internal key serializer + hydration payload behind feature flag.
Keep current hash-addressed POST transport and enforce explicit
request/response size-limit config.
Land schema-required validation enforcement via useValidation(...).
Stabilize ripple.config.ts schema and remove feature flag.
Appendix: Full Concrete Example
This example uses the provided source and generated server/client output shape,
and adds the RFC runtime logic for validation, serialization, transport protocol
negotiation, and server-side cache lookup by key.
B. Server compilation shape with RFC runtime logic
exportconst_$_server_$_=(()=>{var_$_server_$_={};// Module-level server cache map: key -> { ok, value, expires_at }const__server_fn_cache=newMap();// User-authored body extracted from #server block.asyncfunction__user_get_email(validated_id){constresponse=awaitfetch('https://dummyjson.com/users/'+validated_id);constdata=awaitresponse.json();returndata['email'];}_$_server_$_.get_email=asyncfunctionget_email(id){// Generated wrapper: validation, hashing, server cache, then user fn.// Required validation hook (first executable statement)const{id: validated_id}=_$_.useValidation(getEmailSchema,{ id });// Adapter-owned key creation from function-hash + serialized-args-hashconstfunction_hash='4c57113b';// file + function identity hashconstserialized_args=devalue.stringify([validated_id]);constargs_hash=_$_.adapter.createArgsHash(serialized_args);constcache_hash=_$_.adapter.createCacheKeyHash(`${function_hash}::${args_hash}`,);// Server cache hitconstcached=__server_fn_cache.get(cache_hash);if(cached&&(cached.expires_at===null||cached.expires_at>Date.now())){return{value: cached.value, cache_hash };}// Execute user function body on cache missconstvalue=await__user_get_email(validated_id);// Server cache store__server_fn_cache.set(cache_hash,{ok: true,
value,expires_at: Date.now()+5000,});// Register entry for hydration script emission during SSR_$_.register_server_cache_entry(cache_hash,{ok: true, value });return{ value, cache_hash };};return_$_server_$_;})();exportasyncfunctionApp(__output){return_$_.async(async()=>{_$_.push_component();const{_$_value: value,_$_cache_hash: cache_has}=await_$_server_$_.get_email(1);constdata=_$_value;__output.push(`<div>${_$_.escape(data)}</div>`);// SSR emits one script per function call using cache-hash idconstentry=_$_.consume_registered_server_cache_entry(_$_cache_hash);constpayload=devalue.stringify(entry);__output.push(`<script id="__ripple_server_data_${_$_cache_hash}" type="application/json">`+JSON.stringify({v: 1,encoding: 'devalue@5', payload })+'</script>',);});}
C. Client compilation shape with RFC runtime logic
var_$_server_$_={get_email(...args){// Generated client protocol metadata is hard-coded at build time.return_$_.rpc('4c57113b',args,{version: 1,acceptEncodings: ['devalue@5'],});},};exportfunctionApp(__anchor,_,__block){_$_.async(async()=>{_$_.push_component();constdata=(await_$_.maybe_tracked(_$_.with_scope(__block,async()=>_$_server_$_.get_email(1)),))();// ...the rest of the component});}
D. Client runtime hydration + rpc protocol negotiation
import*asdevaluefrom'devalue';exportasyncfunctionrpc(function_hash,args,protocol){constserialized_args=devalue.stringify(args);constargs_hash=_$_.adapter.createArgsHash(serialized_args);constcache_hash=_$_.adapter.createCacheKeyHash(`${function_hash}::${args_hash}`,);// One-time hydration handoff: consume SSR payload if present.constscript_id=`__ripple_server_data_${cache_hash}`;constscript_node=document.getElementById(script_id);if(script_node?.textContent){constscript_envelope=JSON.parse(script_node.textContent);consthydrated_entry=devalue.parse(script_envelope.payload);script_node.remove();if(hydrated_entry.ok){returnhydrated_entry.value;}thrownewError(hydrated_entry.error?.message??'Hydrated server error');}constrequest_body=devalue.stringify({
args,protocol: {version: protocol.version,acceptEncodings: protocol.acceptEncodings,},});// Actual rpc transport shape: POST /_$_ripple_rpc_$_/<function_hash>constres=awaitfetch(`/_$_ripple_rpc_$_/${function_hash}`,{method: 'POST',headers: {'Content-Type': 'application/json',},body: request_body,});if(!res.ok){thrownewError(`Server function call failed with status ${res.status}`);}consttext=awaitres.text();constenvelope=devalue.parse(text);if(envelope.version!==protocol.version||!protocol.acceptEncodings.includes(envelope.encoding)){thrownewError('Server returned unsupported protocol encoding/version');}returnenvelope.value;}
E. End-to-end sequence
SSR calls _$_server_$_.get_email(1).
useValidation(getEmailSchema, { id }) validates input before logic runs.
Server computes cache_hash through adapter and checks module cache.
On miss, server fetches email, stores cache entry, registers hydration entry.
SSR writes <script id="__ripple_server_data_{cache_hash}"> with devalue.stringify(entry).
During hydration, client computes the same cache_hash, consumes the matching
SSR script once, and returns that value without an RPC call.
For later interactions (or hydration misses), client calls POST /_$_ripple_rpc_$_/<function_hash> with build-generated protocol metadata
(version, acceptEncodings), then decodes server-selected encoding/version
without storing a persistent local result cache.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
RFC: Server Function Serialization And Hydration
Summary
This RFC proposes a unified model for:
#serverexported function results during SSR and consuming thatpayload during client hydration.
file + function + argshash key.in components.
#serverfunctions (TypeScript-informedand schema-based).
The goal is to improve TTFB-to-interactive continuity and make server/client
execution semantics explicit and safe.
Serialization and hydration in this proposal use
devalue.stringify(...)anddevalue.parse(...).Motivation
Current SSR + hydration flow does not provide explicit protocol negotiation for
#serverfunction calls. The same function may run:This can create state divergence if client-side cached results drift from server
state. In addition, universal async functions (plain async functions reachable
from components) can create ambiguity about where execution happens. Finally,
server function arguments need consistent runtime validation to avoid
trust-by-annotation problems.
Goals
#serverfunction results from SSR into client runtime.canonicalized args.
/_$_ripple_rpc_$_/<function_hash>) for serverfunction invocation.
generated RPC calls and letting the server choose a compatible response version.
#serverfunction parameters.Non-Goals
/_$_ripple_rpc_$_/<function_hash>transport in this RFC.payload consumption.
Terminology
#serverblock callable from compiledcomponent code.
server and client depending on usage.
module_id + export_name + args.Detailed Proposal
1. SSR Serialization and Client Hydration for
#serverExports1.1 Compile-time transform
Compiler already emits a module-scoped server object (
_$_server_$_) for#serverexports in both client and server compilations. This RFC builds on thatexisting mechanism.
_$_server_$_.<name>points to the real server functionimplementation via a generated wrapper.
_$_server_$_.<name>is an RPC stub that calls_$_.rpc(function_hash, args, protocol).Server wrapper responsibilities:
useValidation(...)before user logic.Server-side cache checks in this RFC happen at this module-object boundary (inside
generated
_$_server_$_function properties), rather than via a separate globalcallsite transform.
Illustrative shape:
During SSR, if the call executes successfully, runtime stores the result in an SSR
hydration object keyed by cache key.
1.2 Script payload format
SSR emits one script block per server function call result (streaming-friendly),
using the cache hash in the script id:
Requirements:
vis payload schema version.encodingidentifies serialization format (devalue@5).payloadis the serialized output ofdevalue.stringify(entry).{ "ok": true, "value": <serializable> }{ "ok": false, "error": { "code": string, "message": string } }output.
__ripple_server_data_{cache_hash}.Serialization options:
devalue.stringify(entry, reducers)anddevalue.parse(payload, revivers).1.3 Client behavior
At runtime, generated client
_$_server_$_.<export_name>(...args)stubs perform aone-time hydration payload lookup first, then fall back to RPC. Client runtime
does not keep a persistent in-memory result cache.
Hydration lookup flow:
cache_hashfromfunction_hash + serialized_args_hash.<script id="__ripple_server_data_{cache_hash}">if present.devalue.parse(payload, revivers).The generated RPC call includes protocol negotiation metadata hard-coded at client
build time:
version(for example1) for wire envelope schema.acceptEncodings(for example['devalue@5']) for supported decode formats.The server chooses an appropriate response version/encoding and returns those in
the response envelope.
RPC response bodies also use
devalue.stringify(result, reducers)on the serverand
devalue.parse(result_payload, revivers)on the client.In server compilation, generated
_$_server_$_.<export_name>(...args)wrappersperform the same cache-key lookup against module-level server cache before
executing the function body.
1.4 Custom serializer/parser extension points
Ripple exposes devalue-compatible extension points for custom types.
Config shape:
Contract:
reducersmap is passed as second argument todevalue.stringify(...).reviversmap is passed as second argument todevalue.parse(...).(devalue semantics).
Safety rules:
not depend on server-only globals.
diagnostics.
Merge order:
ripple.config.ts.devalueis imported from a ripple module where reducers and reviverswill be injected when calling the original devalue.
Later entries override earlier entries by key.
2. Cache Key and Hash Strategy
2.1 Canonical key input
The key material is:
normalized_module_id: build-stable module reference (Vite/Rollup normalizedpath or virtual module id).
export_name: exact#serverexport identifier.canonical_args_devalue: deterministic serialization usingdevalue.stringify(...)after key canonicalization:devaluesupport forundefined,Date,Map,Set, and cyclicalstructures
schema_version: server cache schema version (allows invalidation on wireformat change).
To avoid key drift, both server and client must use identical reducer
configuration for key material.
2.2 Hash algorithm
Compiler/runtime must not compute hashes directly.
truncation, matching current implementation:
adapter.createCacheKeyHash(cache_input)hexorbase64url) and truncation policyhex(default):sha256(input).digest('hex'), optionally truncatedbase64url(optional): shorter URL-safe form2.3 Collision handling
Collisions are treated as improbable but guarded:
debug_inputfor mismatches and warn loudly.3. RPC Transport: Hash-Addressed POST Endpoint
3.1 Transport shape
Current runtime transport is POST to a hash-addressed endpoint.
URL shape example:
Where
function_hashis the server function route id derived from<file_path>#<export_name>.Headers:
Content-Type: application/jsonBody:
devalue.stringify({ args, protocol: { version, acceptEncodings } })Client does not send a cache key. The server computes its cache key internally
from function identity plus request arguments.
Protocol negotiation contract:
protocol.versionandprotocol.acceptEncodingswith every RPCcall.
response.versionandresponse.encodingfrom supportedoptions.
UNSUPPORTED_PROTOCOL.3.2 RPC policy
All server function RPC calls go through
_$_.rpc(function_hash, args, protocol).Proposed function-level directive options:
"use cache: timeout <ms>";implies idempotent read semantics.all #server block functions:
3.3 Security and limits
REQUEST_TOO_LARGEand appropriate HTTP status (413for body overflow).Recommended size-related config:
transport.maxFunctionHashBytes: maximum<function_hash>length in the RPCpath segment.
transport.maxRequestBodyBytes: maximum byte length ofdevalue.stringify({ args, protocol }).serialization.maxRpcResponseBytes: maximum serialized RPC response size.serialization.maxHydrationScriptBytes: maximum SSR hydration script payloadsize.
cache.maxEntryBytes: maximum per-entry serialized cache size.cache.maxTotalBytes: maximum server in-memory cache footprint per module.4.
#serverBlock Caching4.1 Module-level in-memory cache
Server runtime keeps a per-module map:
Behavior:
4.2 Cross-request persistence (optional)
Add
ripple.config.tscache provider interface:Adapters may back this with Redis/KV, but this RFC only standardizes the runtime
contract.
4.3 Invalidation controls
Config-level invalidation:
Function-level invalidation via directives:
"use cache-timer: 5000"for TTL."use cache-tag: user".Page refresh behavior:
is performed.
5. Universal Functions: Semantics and Diagnostics
5.1 Problem
Plain async functions in component scope can be used in SSR render and also in
client effects/handlers, creating uncertainty about environment and timing.
5.2 Compiler warnings
Emit warning when compiler detects:
await/Promise-consuming call in render-reachable component code.#server, client-only effect/handler, orguarded branch).
Do not emit this warning for async usage inside
effect(...)or event handlers.Those paths are client-only, are not compiled into SSR server execution, and
therefore are out of scope for this server/client ambiguity diagnostic.
Do not emit this warning for async calls that are explicitly environment-guarded
by
if (import.meta.env.SSR) { ... }orif (!import.meta.env.SSR) { ... }.These branches are environment-specific by construction and do not represent
ambiguous universal execution.
Diagnostic message (proposed):
5.3 Environment behavior
effect(...)and event handlers are client-only and must not execute on SSR.explicitly configured, but default guidance is to migrate data access to
#serverexports.5.4 Diagnostic scope exclusion
effect(...)callbacks are excluded fromuniversal-async SSR warnings.
onclick,oninput)are excluded from universal-async SSR warnings.
if (import.meta.env.SSR)branches are excludedfrom universal-async SSR warnings.
if (!import.meta.env.SSR)branches are excludedfrom universal-async SSR warnings.
server output.
6. Validation Model for
#serverFunction Arguments6.1 Validation hook
Require each
#serverfunction to calluseValidation(schema, argsObject)as thefirst executable statement.
Example:
Compiler/runtime contract:
useValidation(...)must be present as the first executable statement in eachexported
#serverfunction.invocation and RPC invocation).
#serverexport does not satisfy theuseValidation(...)rule, emit acompiler error in strict mode (default).
code: "VALIDATION_ERROR".Canonical input contract:
useValidationshould receive an objectcontaining all arguments (
{ query, limit }).parsed/validated output.
while preserving ergonomic function signatures.
6.2 Third-party schema integration (required)
TypeScript types alone are not sufficient at runtime.
Ripple should not implement its own validation engine. Instead, it should accept
third-party schema objects.
Proposed schema contract:
parse,safeParse, or equivalent).useValidation(...)performs runtime parsing and returns validated data.useValidation(...)exists as first executable statementuseValidationconceptual signature:Config:
Recommended default:
schema-requiredin all environments.6.3 Enforcement behavior
#serverfunction is exported but lacks a valid first-statementuseValidation(...)call, compilation fails (default policy).useValidationschema/input arguments are invalid or unresolved, compilationfails.
VALIDATION_INTERNAL_ERROR.6.4 How to enforce coverage across different libraries
libraries.
useValidation(schema, argsObject)callargsObjectmust be object-like and include declared function arguments(directly or via spread from validated source)
useValidation(...)parse result, notvia compile-time schema introspection.
7. Error and Serialization Constraints
7.1 Serializable value contract
Hydration and RPC payload values must be serializable by
devalue.stringify(...)and restorable by
devalue.parse(...).Non-serializable values (function, symbol, cyclical object without encoder)
produce:
Custom-type mismatch behavior:
SERIALIZATION_REVIVER_MISSING.SERIALIZATION_REVIVER_INVALID_INPUT.SERIALIZATION_UNKNOWN_TYPE.7.2 Error transport
Do not serialize full stack traces to client by default.
Return envelope:
{ "ok": false, "error": { "code": "INTERNAL_ERROR", "message": "..." } }Dev mode may include stack under gated flag.
Compiler and Runtime Implementation Sketch
Compiler
#serverexported functions and annotate metadata table (module_id,export, directives).
_$_server_$_object for both client/server builds andinject cache checks at generated function-property wrappers.
perform validation, hash generation, cache lookup, and miss execution.
Server runtime
generation.
/_$_ripple_rpc_$_/<function_hash>).Client runtime
version,acceptEncodings) with every RPCcall.
Backward Compatibility and Migration
/_$_ripple_rpc_$_/<function_hash>endpoint shape.Alternatives Considered
GETquery transport (h/k/a) with client-supplied cache key: not chosen forcurrent implementation alignment.
and parsing overhead.
Open Questions
across common deployments?
precision?
fetch-like)?
Valibot, ArkType) in v1?
Rollout Plan
request/response size-limit config.
schema-requiredvalidation enforcement viauseValidation(...).ripple.config.tsschema and remove feature flag.Appendix: Full Concrete Example
This example uses the provided source and generated server/client output shape,
and adds the RFC runtime logic for validation, serialization, transport protocol
negotiation, and server-side cache lookup by key.
A. Source module
B. Server compilation shape with RFC runtime logic
C. Client compilation shape with RFC runtime logic
D. Client runtime hydration + rpc protocol negotiation
E. End-to-end sequence
_$_server_$_.get_email(1).useValidation(getEmailSchema, { id })validates input before logic runs.cache_hashthrough adapter and checks module cache.<script id="__ripple_server_data_{cache_hash}">withdevalue.stringify(entry).cache_hash, consumes the matchingSSR script once, and returns that value without an RPC call.
POST /_$_ripple_rpc_$_/<function_hash>with build-generated protocol metadata(
version,acceptEncodings), then decodes server-selected encoding/versionwithout storing a persistent local result cache.
Beta Was this translation helpful? Give feedback.
All reactions