@astroscope/wormhole
v0.1.1
Published
Share dynamic server data with React islands and client scripts — typed, reactive
Maintainers
Readme
@astroscope/wormhole
Note: This package is in active development. APIs may change between versions.
Share dynamic server data with React islands and client scripts — typed, reactive.
Why this library?
Astro recommends nanostores for sharing state between islands, but nanostores are client-only — there's no built-in way to hydrate them with server data during SSR.
@astroscope/wormhole bridges this gap: populate data in middleware, read it in Astro frontmatter, React islands, or <script> blocks — same typed API on server and client. Multiple wormholes per page, reactive updates across all consumers, zero configuration.
Typical use cases:
- Shopping cart state shared across header badge, product cards, and checkout
- Authenticated user / session data available in all islands
- Feature flags resolved on the server, consumed by client components
- Server-loaded configuration (theme, locale, permissions) bridged to the UI
- Any request-scoped data that multiple disconnected islands need to read
Examples
See the demo/wormhole directory for a working example.
Installation
npm install @astroscope/wormholeUsage
1. Define a wormhole
Create a shared file imported by both server and client code:
// src/wormholes.ts
import { defineWormhole } from '@astroscope/wormhole';
export type UserState = {
user: string;
role: string;
};
export const userState = defineWormhole<UserState>('user');2. Populate in middleware
Use open() from the server entry point to provide data during request handling:
// src/middleware.ts
import { open } from '@astroscope/wormhole/server';
import { defineMiddleware } from 'astro:middleware';
import { userState } from './wormholes';
export const onRequest = defineMiddleware((ctx, next) => {
const data = { user: 'Alice', role: 'admin' };
return open(userState, data, () => next());
});open() uses AsyncLocalStorage under the hood — each request gets its own isolated data.
3. Bridge to the client
Add <WormholeScript> to your layout to serialize the data into an inline script:
---
import { WormholeScript } from '@astroscope/wormhole/astro';
import { userState } from '../wormholes';
---
<html>
<head>
<WormholeScript wormhole={userState} />
</head>
<body>
<slot />
</body>
</html>4. Read in components
Astro frontmatter (SSR)
---
import { userState } from '../wormholes';
const { user } = userState.get();
---
<p>Hello, {user}</p>React islands
import { useWormhole } from '@astroscope/wormhole/react';
import { userState } from '../wormholes';
export function UserBadge() {
const { user, role } = useWormhole(userState);
return (
<span>
{user} ({role})
</span>
);
}Astro <script> blocks
<p>User: <strong id="user">-</strong></p>
<script>
import { userState } from '../wormholes';
document.getElementById('user')!.textContent = userState.get().user;
userState.subscribe((data) => {
document.getElementById('user')!.textContent = data.user;
});
</script>5. Update from the client
Call set() to update the wormhole — all useWormhole() hooks and subscribe() callbacks react immediately:
import { useWormhole } from '@astroscope/wormhole/react';
import { actions } from 'astro:actions';
import { userState } from '../wormholes';
export function RoleToggle() {
const { user, role } = useWormhole(userState);
async function toggle() {
const newRole = role === 'admin' ? 'viewer' : 'admin';
const result = await actions.updateRole({ role: newRole });
if (!result.error) {
userState.set(result.data);
}
}
return (
<button onClick={toggle}>
{user}: {role}
</button>
);
}API
defineWormhole<T>(name)
Creates a typed wormhole channel. The returned object is universal — works on both server and client.
import { defineWormhole } from '@astroscope/wormhole';
const wh = defineWormhole<{ count: number }>('counter');| Method | Description |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| wh.get() | Read current value (from AsyncLocalStorage on server, from globalThis or local store on client) |
| wh.set(data) | Update value and notify all subscribers (client-side) |
| wh.subscribe(fn) | Listen for changes, returns unsubscribe function |
| wh.name | The wormhole name |
| wh.key | The internal globalThis key |
open(wh, data, fn) server only
Runs fn with data available via wh.get() for the duration of the call. Uses AsyncLocalStorage for request isolation.
import { open } from '@astroscope/wormhole/server';
return open(myWormhole, { count: 0 }, () => next());useWormhole(wh) React only
React hook that reads the wormhole and re-renders on changes. Uses useSyncExternalStore internally.
import { useWormhole } from '@astroscope/wormhole/react';
const data = useWormhole(myWormhole);<WormholeScript wormhole={wh} /> Astro only
Serializes the current wormhole value into an inline <script> tag for client hydration.
import {WormholeScript} from '@astroscope/wormhole/astro';
<WormholeScript wormhole={myWormhole} />How it works
- Middleware calls
open(wh, data, next)— stores data inAsyncLocalStorageand setsglobalThis[key]to read from it <WormholeScript>callswh.get()during SSR, serializes the result into<script is:inline>globalThis[key] = function(){return data;}</script>- Client calls
wh.get()— reads fromglobalThis[key]()(the serialized getter) set()updates a local store and notifies all subscribers —useWormhole()hooks andsubscribe()callbacks re-render/fire
License
MIT
