@astroscope/wormhole 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # @astroscope/wormhole
2
+
3
+ > **Note:** This package is in active development. APIs may change between versions.
4
+
5
+ Share dynamic server data with React islands and client scripts — typed, reactive.
6
+
7
+ ## Why this library?
8
+
9
+ Astro recommends [nanostores](https://docs.astro.build/en/recipes/sharing-state/) for sharing state between islands, but nanostores are client-only — there's no built-in way to hydrate them with server data during SSR.
10
+
11
+ `@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.
12
+
13
+ **Typical use cases:**
14
+
15
+ - Shopping cart state shared across header badge, product cards, and checkout
16
+ - Authenticated user / session data available in all islands
17
+ - Feature flags resolved on the server, consumed by client components
18
+ - Server-loaded configuration (theme, locale, permissions) bridged to the UI
19
+ - Any request-scoped data that multiple disconnected islands need to read
20
+
21
+ ## Examples
22
+
23
+ See the [demo/wormhole](../../demo/wormhole) directory for a working example.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install @astroscope/wormhole
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ### 1. Define a wormhole
34
+
35
+ Create a shared file imported by both server and client code:
36
+
37
+ ```ts
38
+ // src/wormholes.ts
39
+ import { defineWormhole } from '@astroscope/wormhole';
40
+
41
+ export type UserState = {
42
+ user: string;
43
+ role: string;
44
+ };
45
+
46
+ export const userState = defineWormhole<UserState>('user');
47
+ ```
48
+
49
+ ### 2. Populate in middleware
50
+
51
+ Use `open()` from the server entry point to provide data during request handling:
52
+
53
+ ```ts
54
+ // src/middleware.ts
55
+ import { open } from '@astroscope/wormhole/server';
56
+ import { defineMiddleware } from 'astro:middleware';
57
+ import { userState } from './wormholes';
58
+
59
+ export const onRequest = defineMiddleware((ctx, next) => {
60
+ const data = { user: 'Alice', role: 'admin' };
61
+
62
+ return open(userState, data, () => next());
63
+ });
64
+ ```
65
+
66
+ `open()` uses `AsyncLocalStorage` under the hood — each request gets its own isolated data.
67
+
68
+ ### 3. Bridge to the client
69
+
70
+ Add `<WormholeScript>` to your layout to serialize the data into an inline script:
71
+
72
+ ```astro
73
+ ---
74
+ import { WormholeScript } from '@astroscope/wormhole/astro';
75
+ import { userState } from '../wormholes';
76
+ ---
77
+
78
+ <html>
79
+ <head>
80
+ <WormholeScript wormhole={userState} />
81
+ </head>
82
+ <body>
83
+ <slot />
84
+ </body>
85
+ </html>
86
+ ```
87
+
88
+ ### 4. Read in components
89
+
90
+ #### Astro frontmatter (SSR)
91
+
92
+ ```astro
93
+ ---
94
+ import { userState } from '../wormholes';
95
+
96
+ const { user } = userState.get();
97
+ ---
98
+
99
+ <p>Hello, {user}</p>
100
+ ```
101
+
102
+ #### React islands
103
+
104
+ ```tsx
105
+ import { useWormhole } from '@astroscope/wormhole/react';
106
+ import { userState } from '../wormholes';
107
+
108
+ export function UserBadge() {
109
+ const { user, role } = useWormhole(userState);
110
+
111
+ return (
112
+ <span>
113
+ {user} ({role})
114
+ </span>
115
+ );
116
+ }
117
+ ```
118
+
119
+ #### Astro `<script>` blocks
120
+
121
+ ```astro
122
+ <p>User: <strong id="user">-</strong></p>
123
+
124
+ <script>
125
+ import { userState } from '../wormholes';
126
+
127
+ document.getElementById('user')!.textContent = userState.get().user;
128
+
129
+ userState.subscribe((data) => {
130
+ document.getElementById('user')!.textContent = data.user;
131
+ });
132
+ </script>
133
+ ```
134
+
135
+ ### 5. Update from the client
136
+
137
+ Call `set()` to update the wormhole — all `useWormhole()` hooks and `subscribe()` callbacks react immediately:
138
+
139
+ ```tsx
140
+ import { useWormhole } from '@astroscope/wormhole/react';
141
+ import { actions } from 'astro:actions';
142
+ import { userState } from '../wormholes';
143
+
144
+ export function RoleToggle() {
145
+ const { user, role } = useWormhole(userState);
146
+
147
+ async function toggle() {
148
+ const newRole = role === 'admin' ? 'viewer' : 'admin';
149
+ const result = await actions.updateRole({ role: newRole });
150
+
151
+ if (!result.error) {
152
+ userState.set(result.data);
153
+ }
154
+ }
155
+
156
+ return (
157
+ <button onClick={toggle}>
158
+ {user}: {role}
159
+ </button>
160
+ );
161
+ }
162
+ ```
163
+
164
+ ## API
165
+
166
+ ### `defineWormhole<T>(name)`
167
+
168
+ Creates a typed wormhole channel. The returned object is universal — works on both server and client.
169
+
170
+ ```ts
171
+ import { defineWormhole } from '@astroscope/wormhole';
172
+
173
+ const wh = defineWormhole<{ count: number }>('counter');
174
+ ```
175
+
176
+ | Method | Description |
177
+ | ------------------ | --------------------------------------------------------------------------------------------------- |
178
+ | `wh.get()` | Read current value (from `AsyncLocalStorage` on server, from `globalThis` or local store on client) |
179
+ | `wh.set(data)` | Update value and notify all subscribers (client-side) |
180
+ | `wh.subscribe(fn)` | Listen for changes, returns unsubscribe function |
181
+ | `wh.name` | The wormhole name |
182
+ | `wh.key` | The internal `globalThis` key |
183
+
184
+ ### `open(wh, data, fn)` <sub>server only</sub>
185
+
186
+ Runs `fn` with `data` available via `wh.get()` for the duration of the call. Uses `AsyncLocalStorage` for request isolation.
187
+
188
+ ```ts
189
+ import { open } from '@astroscope/wormhole/server';
190
+
191
+ return open(myWormhole, { count: 0 }, () => next());
192
+ ```
193
+
194
+ ### `useWormhole(wh)` <sub>React only</sub>
195
+
196
+ React hook that reads the wormhole and re-renders on changes. Uses `useSyncExternalStore` internally.
197
+
198
+ ```tsx
199
+ import { useWormhole } from '@astroscope/wormhole/react';
200
+
201
+ const data = useWormhole(myWormhole);
202
+ ```
203
+
204
+ ### `<WormholeScript wormhole={wh} />` <sub>Astro only</sub>
205
+
206
+ Serializes the current wormhole value into an inline `<script>` tag for client hydration.
207
+
208
+ ```astro
209
+ import {WormholeScript} from '@astroscope/wormhole/astro';
210
+
211
+ <WormholeScript wormhole={myWormhole} />
212
+ ```
213
+
214
+ ## How it works
215
+
216
+ 1. **Middleware** calls `open(wh, data, next)` — stores data in `AsyncLocalStorage` and sets `globalThis[key]` to read from it
217
+ 2. **`<WormholeScript>`** calls `wh.get()` during SSR, serializes the result into `<script is:inline>globalThis[key] = function(){return data;}</script>`
218
+ 3. **Client** calls `wh.get()` — reads from `globalThis[key]()` (the serialized getter)
219
+ 4. **`set()`** updates a local store and notifies all subscribers — `useWormhole()` hooks and `subscribe()` callbacks re-render/fire
220
+
221
+ ## Monorepo note
222
+
223
+ When using this package from a workspace in a monorepo, add `resolve.dedupe` to your Vite config to prevent duplicate React instances:
224
+
225
+ ```ts
226
+ // astro.config.ts
227
+ export default defineConfig({
228
+ vite: {
229
+ resolve: {
230
+ dedupe: ['react', 'react-dom'],
231
+ },
232
+ },
233
+ });
234
+ ```
235
+
236
+ ## License
237
+
238
+ MIT
@@ -0,0 +1,5 @@
1
+ import { W as Wormhole } from './types-DkH89ULF.js';
2
+
3
+ declare function defineWormhole<T>(name: string): Wormhole<T>;
4
+
5
+ export { Wormhole, defineWormhole };
package/dist/define.js ADDED
@@ -0,0 +1,29 @@
1
+ // src/define.ts
2
+ function defineWormhole(name) {
3
+ const key = `__wormhole_${name}__`;
4
+ let store;
5
+ const listeners = /* @__PURE__ */ new Set();
6
+ return {
7
+ name,
8
+ key,
9
+ get() {
10
+ if (store !== void 0) return store;
11
+ const getter = globalThis[key];
12
+ if (typeof getter === "function") return getter();
13
+ throw new Error(`wormhole "${name}" is not initialized`);
14
+ },
15
+ set(data) {
16
+ store = data;
17
+ for (const fn of listeners) fn(data);
18
+ },
19
+ subscribe(fn) {
20
+ listeners.add(fn);
21
+ return () => {
22
+ listeners.delete(fn);
23
+ };
24
+ }
25
+ };
26
+ }
27
+ export {
28
+ defineWormhole
29
+ };
@@ -0,0 +1,5 @@
1
+ import { W as Wormhole } from './types-DkH89ULF.js';
2
+
3
+ declare function defineWormhole<T>(name: string): Wormhole<T>;
4
+
5
+ export { Wormhole, defineWormhole };
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ // src/define.ts
2
+ function defineWormhole(name) {
3
+ const listeners = /* @__PURE__ */ new Set();
4
+ const key = `__wormhole_${name}__`;
5
+ let store;
6
+ return {
7
+ name,
8
+ key,
9
+ get() {
10
+ if (store !== void 0) return store;
11
+ const getter = globalThis[key];
12
+ if (typeof getter === "function") return getter();
13
+ throw new Error(`wormhole "${name}" is not initialized`);
14
+ },
15
+ set(data) {
16
+ store = data;
17
+ for (const fn of listeners) fn(data);
18
+ },
19
+ subscribe(fn) {
20
+ listeners.add(fn);
21
+ return () => {
22
+ listeners.delete(fn);
23
+ };
24
+ }
25
+ };
26
+ }
27
+ export {
28
+ defineWormhole
29
+ };
@@ -0,0 +1,5 @@
1
+ import { W as Wormhole } from '../types-DkH89ULF.js';
2
+
3
+ declare function useWormhole<T>(wormhole: Wormhole<T>): T;
4
+
5
+ export { useWormhole };
@@ -0,0 +1,8 @@
1
+ // src/react/index.ts
2
+ import { useSyncExternalStore } from "react";
3
+ function useWormhole(wormhole) {
4
+ return useSyncExternalStore(wormhole.subscribe, wormhole.get, wormhole.get);
5
+ }
6
+ export {
7
+ useWormhole
8
+ };
@@ -0,0 +1,5 @@
1
+ import { W as Wormhole } from './types-DkH89ULF.js';
2
+
3
+ declare function useWormhole<T>(wh: Wormhole<T>): T;
4
+
5
+ export { useWormhole };
package/dist/react.js ADDED
@@ -0,0 +1,8 @@
1
+ // src/react.ts
2
+ import { useSyncExternalStore } from "react";
3
+ function useWormhole(wh) {
4
+ return useSyncExternalStore(wh.subscribe, wh.get, wh.get);
5
+ }
6
+ export {
7
+ useWormhole
8
+ };
@@ -0,0 +1,5 @@
1
+ import { W as Wormhole } from './types-DkH89ULF.js';
2
+
3
+ declare function open<T, R>(wh: Wormhole<T>, data: T, fn: () => R): R;
4
+
5
+ export { open };
package/dist/server.js ADDED
@@ -0,0 +1,15 @@
1
+ // src/server.ts
2
+ import { AsyncLocalStorage } from "async_hooks";
3
+ var stores = /* @__PURE__ */ new Map();
4
+ function open(wh, data, fn) {
5
+ let als = stores.get(wh.key);
6
+ if (!als) {
7
+ als = new AsyncLocalStorage();
8
+ stores.set(wh.key, als);
9
+ }
10
+ globalThis[wh.key] = () => als.getStore();
11
+ return als.run(data, fn);
12
+ }
13
+ export {
14
+ open
15
+ };
@@ -0,0 +1,9 @@
1
+ interface Wormhole<T> {
2
+ readonly name: string;
3
+ readonly key: string;
4
+ get(): T;
5
+ set(data: T): void;
6
+ subscribe(fn: (data: T) => void): () => void;
7
+ }
8
+
9
+ export type { Wormhole as W };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@astroscope/wormhole",
3
+ "version": "0.1.0",
4
+ "description": "Share dynamic server data with React islands and client scripts — typed, reactive",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./server": {
14
+ "types": "./dist/server.d.ts",
15
+ "import": "./dist/server.js"
16
+ },
17
+ "./react": {
18
+ "types": "./dist/react/index.d.ts",
19
+ "import": "./dist/react/index.js"
20
+ },
21
+ "./astro": {
22
+ "import": "./src/astro/index.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src/astro"
28
+ ],
29
+ "sideEffects": false,
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/smnbbrv/astroscope.git",
36
+ "directory": "packages/wormhole"
37
+ },
38
+ "keywords": [
39
+ "astro",
40
+ "astro-integration",
41
+ "ssr",
42
+ "state",
43
+ "islands",
44
+ "react"
45
+ ],
46
+ "author": "smnbbrv",
47
+ "license": "MIT",
48
+ "bugs": {
49
+ "url": "https://github.com/smnbbrv/astroscope/issues"
50
+ },
51
+ "homepage": "https://github.com/smnbbrv/astroscope/tree/main/packages/wormhole#readme",
52
+ "scripts": {
53
+ "build": "tsup src/index.ts src/server.ts src/react/index.ts --format esm --dts",
54
+ "typecheck": "tsc --noEmit",
55
+ "lint": "eslint 'src/**/*.{ts,tsx}'",
56
+ "lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix"
57
+ },
58
+ "devDependencies": {
59
+ "@types/react": "^19.2.13",
60
+ "astro": "^5.17.1",
61
+ "react": "^19.2.4",
62
+ "tsup": "^8.5.1",
63
+ "typescript": "^5.9.3"
64
+ },
65
+ "peerDependencies": {
66
+ "astro": "^5.0.0",
67
+ "react": "^18.0.0 || ^19.0.0"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "react": {
71
+ "optional": true
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,21 @@
1
+ ---
2
+ interface Props {
3
+ wormhole: { readonly name: string; readonly key: string; get(): unknown };
4
+ }
5
+
6
+ const { wormhole } = Astro.props;
7
+ const data = wormhole.get();
8
+ const key = wormhole.key;
9
+ ---
10
+
11
+ {
12
+ data ? (
13
+ <script is:inline define:vars={{ data, key }}>
14
+ globalThis[key] = function(){return data;}
15
+ </script>
16
+ ) : (
17
+ <script is:inline define:vars={{ wormholeName: wormhole.name }}>
18
+ console.error(`[wormhole] "${wormholeName}" has no data — is the middleware configured?`);
19
+ </script>
20
+ )
21
+ }
@@ -0,0 +1,2 @@
1
+ // @ts-expect-error: Astro types
2
+ export { default as WormholeScript } from './WormholeScript.astro';