@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 +238 -0
- package/dist/define.d.ts +5 -0
- package/dist/define.js +29 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +29 -0
- package/dist/react/index.d.ts +5 -0
- package/dist/react/index.js +8 -0
- package/dist/react.d.ts +5 -0
- package/dist/react.js +8 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +15 -0
- package/dist/types-DkH89ULF.d.ts +9 -0
- package/package.json +74 -0
- package/src/astro/WormholeScript.astro +21 -0
- package/src/astro/index.ts +2 -0
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
|
package/dist/define.d.ts
ADDED
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
|
+
};
|
package/dist/index.d.ts
ADDED
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
|
+
};
|
package/dist/react.d.ts
ADDED
package/dist/react.js
ADDED
package/dist/server.d.ts
ADDED
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
|
+
};
|
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
|
+
}
|