@astroscope/wormhole 1.1.0 → 3.0.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 CHANGED
@@ -1,14 +1,16 @@
1
1
  # @astroscope/wormhole
2
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.
3
+ Share dynamic server data with React islands and client scripts — typed, streamed with the HTML, sliced per island.
6
4
 
7
5
  ## Why this library?
8
6
 
9
7
  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
8
 
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.
9
+ `@astroscope/wormhole` bridges this gap: define your wormholes in a registry — each with the handler that resolves its value per request — and read them anywhere — Astro frontmatter, React islands, `<script>` blocks — through one typed `wormholes` proxy. Client code imports nothing of yours: the data streams into the HTML exactly where it is needed.
10
+
11
+ **Loading is scoped per route.** The build scans server and client code to see which wormholes each route can reach; at runtime only those handlers run for a request. A wormhole nothing on the route reads costs **nothing** — no database call, no bytes.
12
+
13
+ **Delivery is sliced per island.** Every island receives exactly the wormholes its code reads, embedded in the HTML right before it. A wormhole no client code reads ships **zero bytes**.
12
14
 
13
15
  **Typical use cases:**
14
16
 
@@ -20,9 +22,12 @@ Astro recommends [nanostores](https://docs.astro.build/en/recipes/sharing-state/
20
22
 
21
23
  ## Important notes
22
24
 
23
- - **No secrets in wormholes.** All wormhole data is serialized into an inline `<script>` tag and sent to the browser. Never store tokens, API keys, credentials, or any sensitive data in a wormhole.
24
- - **`set()` is client-only.** Calling `set()` does not work on the server. Use `open(wormhole, data, fn)` from `@astroscope/wormhole/server` to populate data in middleware.
25
- - **Values are deeply readonly.** `get()`, `subscribe()` and `useWormhole()` expose the value as `DeepReadonly<T>` — in-place mutation would silently bypass subscribers (and on the server would corrupt the request-scoped store). The only way to update is `set()` with a new value: `wh.set({ ...wh.get(), count: 1 })`.
25
+ - **No secrets in wormholes.** Wormhole data is serialized into inline `<script>` tags and sent to the browser. Never store tokens, API keys, credentials, or any sensitive data in a wormhole. (In dev, _all_ open wormholes ship with every page; production slicing is an optimization, not a security boundary.)
26
+ - **Requires `@astroscope/node`** — per-island delivery uses its islands pipeline in production.
27
+ - **The registry is server-only.** It carries the handlers, and with them your server code. Client code must never import `src/wormholes.ts` — it accesses values through the `wormholes` proxy instead. (The client build fails on such an import, and the eslint plugin's `no-registry-import` rule flags it in island code.)
28
+ - **`set()` is client-only.** Server values are request-scoped and come from the handlers.
29
+ - **Values are deeply readonly.** `get()`, `subscribe()` and `useWormhole()` expose the value as `ReadonlyDeep<T>` — in-place mutation would silently bypass subscribers. The only way to update is `set()` with a new value: `wormholes.counter.set({ ...wormholes.counter.get(), count: 1 })`.
30
+ - **Prerendered pages get no wormhole data** — values are per-request by definition.
26
31
 
27
32
  ## Examples
28
33
 
@@ -34,72 +39,56 @@ See the [demo/wormhole](../../demo/wormhole) directory for a working example.
34
39
  npm install @astroscope/wormhole
35
40
  ```
36
41
 
42
+ ```ts
43
+ // astro.config.ts
44
+ import node from '@astroscope/node';
45
+ import wormhole from '@astroscope/wormhole';
46
+
47
+ export default defineConfig({
48
+ output: 'server',
49
+ adapter: node(),
50
+ integrations: [wormhole()],
51
+ });
52
+ ```
53
+
54
+ The integration registers the build-time scanner, injects the middleware after your own `src/middleware.ts` (so handlers see everything it put on `locals`), and generates a type stub from your registry, so `wormholes.<name>` is fully typed everywhere. `wormhole({ exclude })` takes the same patterns as other astroscope middlewares (defaults to `RECOMMENDED_EXCLUDES`).
55
+
37
56
  ## Usage
38
57
 
39
- ### 1. Define a wormhole
58
+ ### 1. Define the registry
40
59
 
41
- Create a shared file imported by both server and client code:
60
+ One file, one export named `wormholes` — the keys are the wormhole names, each entry carries its handler (same pattern as Astro Actions' `src/actions/index.ts`):
42
61
 
43
62
  ```ts
44
63
  // src/wormholes.ts
45
64
  import { defineWormhole } from '@astroscope/wormhole';
46
65
 
47
- export type UserState = {
66
+ export type Session = {
48
67
  user: string;
49
68
  role: string;
50
69
  };
51
70
 
52
- export const userState = defineWormhole<UserState>('user');
53
- ```
54
-
55
- ### 2. Populate in middleware
56
-
57
- Use `open()` from the server entry point to provide data during request handling:
58
-
59
- ```ts
60
- // src/middleware.ts
61
- import { open } from '@astroscope/wormhole/server';
62
- import { defineMiddleware } from 'astro:middleware';
63
- import { userState } from './wormholes';
64
-
65
- export const onRequest = defineMiddleware((ctx, next) => {
66
- const data = { user: 'Alice', role: 'admin' };
67
-
68
- return open(userState, data, () => next());
69
- });
71
+ export const wormholes = {
72
+ session: defineWormhole({
73
+ handler: (ctx): Session => ({ user: ctx.locals.user, role: ctx.locals.role }),
74
+ }),
75
+ cart: defineWormhole({
76
+ handler: async (ctx) => loadCart(ctx),
77
+ }),
78
+ };
70
79
  ```
71
80
 
72
- `open()` uses `AsyncLocalStorage` under the hood — each request gets its own isolated data.
73
-
74
- ### 3. Bridge to the client
81
+ `src/wormholes/index.ts` works too. A handler receives the request's `APIContext` and runs only for routes the build found a reader on; return `undefined` to leave the wormhole closed for the request. Each request is isolated.
75
82
 
76
- Add `<WormholeScript>` to your layout to serialize the data into an inline script:
77
-
78
- ```astro
79
- ---
80
- import { WormholeScript } from '@astroscope/wormhole/astro';
81
- import { userState } from '../wormholes';
82
- ---
83
-
84
- <html>
85
- <head>
86
- <WormholeScript wormhole={userState} />
87
- </head>
88
- <body>
89
- <slot />
90
- </body>
91
- </html>
92
- ```
93
-
94
- ### 4. Read in components
83
+ ### 2. Read anywhere
95
84
 
96
85
  #### Astro frontmatter (SSR)
97
86
 
98
87
  ```astro
99
88
  ---
100
- import { userState } from '../wormholes';
89
+ import { wormholes } from '@astroscope/wormhole';
101
90
 
102
- const { user } = userState.get();
91
+ const { user } = wormholes.session.get();
103
92
  ---
104
93
 
105
94
  <p>Hello, {user}</p>
@@ -108,11 +97,11 @@ const { user } = userState.get();
108
97
  #### React islands
109
98
 
110
99
  ```tsx
100
+ import { wormholes } from '@astroscope/wormhole';
111
101
  import { useWormhole } from '@astroscope/wormhole/react';
112
- import { userState } from '../wormholes';
113
102
 
114
103
  export function UserBadge() {
115
- const { user, role } = useWormhole(userState);
104
+ const { user, role } = useWormhole(wormholes.session);
116
105
 
117
106
  return (
118
107
  <span>
@@ -128,34 +117,33 @@ export function UserBadge() {
128
117
  <p>User: <strong id="user">-</strong></p>
129
118
 
130
119
  <script>
131
- import { userState } from '../wormholes';
120
+ import { wormholes } from '@astroscope/wormhole';
132
121
 
133
- document.getElementById('user')!.textContent = userState.get().user;
122
+ document.getElementById('user')!.textContent = wormholes.session.get().user;
134
123
 
135
- userState.subscribe((data) => {
124
+ wormholes.session.subscribe((data) => {
136
125
  document.getElementById('user')!.textContent = data.user;
137
126
  });
138
127
  </script>
139
128
  ```
140
129
 
141
- ### 5. Update from the client
130
+ ### 3. Update from the client
142
131
 
143
- Call `set()` to update the wormhole — all `useWormhole()` hooks and `subscribe()` callbacks react immediately:
132
+ Call `set()` to update the wormhole — every `useWormhole()` hook and `subscribe()` callback on the page reacts, across islands:
144
133
 
145
134
  ```tsx
135
+ import { wormholes } from '@astroscope/wormhole';
146
136
  import { useWormhole } from '@astroscope/wormhole/react';
147
137
  import { actions } from 'astro:actions';
148
- import { userState } from '../wormholes';
149
138
 
150
139
  export function RoleToggle() {
151
- const { user, role } = useWormhole(userState);
140
+ const { user, role } = useWormhole(wormholes.session);
152
141
 
153
142
  async function toggle() {
154
- const newRole = role === 'admin' ? 'viewer' : 'admin';
155
- const result = await actions.updateRole({ role: newRole });
143
+ const result = await actions.updateRole({ role: role === 'admin' ? 'viewer' : 'admin' });
156
144
 
157
145
  if (!result.error) {
158
- userState.set(result.data);
146
+ wormholes.session.set(result.data);
159
147
  }
160
148
  }
161
149
 
@@ -169,83 +157,39 @@ export function RoleToggle() {
169
157
 
170
158
  ## API
171
159
 
172
- ### `defineWormhole<T>(name)`
160
+ ### `wormholes` <sub>proxy, server + client</sub>
173
161
 
174
- Creates a typed wormhole channel. The returned object is universal — works on both server and client.
162
+ The single access point, typed from your registry. `wormholes.cart` returns a `Wormhole<T>`:
175
163
 
176
- ```ts
177
- import { defineWormhole } from '@astroscope/wormhole';
164
+ | Method | Description |
165
+ | --------------------------- | ------------------------------------------------------------------------------------------ |
166
+ | `wormholes.x.get()` | Read the current value as `ReadonlyDeep<T>` (server: request scope; client: streamed data) |
167
+ | `wormholes.x.set(data)` | Replace the value and notify all subscribers (client only) |
168
+ | `wormholes.x.subscribe(fn)` | Listen for `set()` updates, returns an unsubscribe function |
178
169
 
179
- const wh = defineWormhole<{ count: number }>('counter');
180
- ```
181
-
182
- | Method | Description |
183
- | ------------------ | --------------------------------------------------------------------------------------------------- |
184
- | `wh.get()` | Read current value as `DeepReadonly<T>` (from `AsyncLocalStorage` on server, from `globalThis` or local store on client) |
185
- | `wh.set(data)` | Replace value with a new one and notify all subscribers (client-side) |
186
- | `wh.subscribe(fn)` | Listen for changes, returns unsubscribe function |
187
- | `wh.name` | The wormhole name |
188
- | `wh.key` | The internal `globalThis` key |
189
-
190
- ### `DeepReadonly<T>`
191
-
192
- Exported helper type — recursively `readonly` version of `T`. Wormhole values are exposed with this type; use it in your own function signatures when passing wormhole data around:
193
-
194
- ```ts
195
- import type { DeepReadonly } from '@astroscope/wormhole';
196
-
197
- function isWithinCart(cart: DeepReadonly<Cart>, id: string) {
198
- return cart.items.some((item) => item.productId === id);
199
- }
200
- ```
201
-
202
- ### `open(wh, data, fn)` <sub>server only</sub>
203
-
204
- Runs `fn` with `data` available via `wh.get()` for the duration of the call. Uses `AsyncLocalStorage` for request isolation.
205
-
206
- ```ts
207
- import { open } from '@astroscope/wormhole/server';
170
+ Accesses must be static (`wormholes.cart`, `wormholes['cart']`). A dynamic access (`wormholes[name]`) still works, but the build can no longer tell which wormholes that chunk needs and delivers all open ones to its islands.
208
171
 
209
- return open(myWormhole, { count: 0 }, () => next());
210
- ```
172
+ ### `defineWormhole({ handler, eager? })` <sub>registry only</sub>
211
173
 
212
- To open several wormholes at once, pass an array of `[wormhole, data]` pairs — each pair is type-checked against its own wormhole:
174
+ Creates a typed wormhole for the registry; the value type is inferred from the handler (or given as `defineWormhole<T>(...)`), the name comes from the registry key. `eager: true` runs the handler on every request instead of only where the build found a reader — for wormholes read by islands the build cannot attribute to a route (components chosen dynamically at render time). Registry entries are full `Wormhole<T>` objects, so server code can also import `wormholes` from your own registry file directly.
213
175
 
214
- ```ts
215
- return open([
216
- [cartStore, cart],
217
- [sessionStore, session],
218
- ], () => next());
219
- ```
176
+ ### `openWormholes(wh, data, fn)` <sub>server only</sub>
220
177
 
221
- Nested `open()` calls shadow only the wormholes they name; everything else stays visible.
178
+ Provides values to server code that runs outside the request pipeline — tests, out-of-request rendering. Never delivered to the client; inside the app, the middleware is the way. Accepts a single pair or an array of `[wormhole, data]` pairs.
222
179
 
223
180
  ### `useWormhole(wh)` <sub>React only</sub>
224
181
 
225
- React hook that reads the wormhole and re-renders on changes. Uses `useSyncExternalStore` internally.
182
+ React hook that reads a wormhole and re-renders on `set()`. During SSR it reads the request scope, so server and client render identically.
226
183
 
227
- ```tsx
228
- import { useWormhole } from '@astroscope/wormhole/react';
229
-
230
- const data = useWormhole(myWormhole);
231
- ```
184
+ ### `UnwrapWormholes<R>`
232
185
 
233
- ### `<WormholeScript wormhole={wh} />` <sub>Astro only</sub>
234
-
235
- Serializes the current wormhole value into an inline `<script>` tag for client hydration.
236
-
237
- ```astro
238
- import {WormholeScript} from '@astroscope/wormhole/astro';
239
-
240
- <WormholeScript wormhole={myWormhole} />
241
- ```
186
+ Exported helper type mapping a registry shape to its value types. The recursively-readonly value type is `ReadonlyDeep<T>` from `@entwico/dash`.
242
187
 
243
188
  ## How it works
244
189
 
245
- 1. **Middleware** calls `open(wh, data, next)` — stores data in `AsyncLocalStorage` and sets `globalThis[key]` to read from it
246
- 2. **`<WormholeScript>`** calls `wh.get()` during SSR, serializes the result into `<script is:inline>globalThis[key] = function(){return data;}</script>`
247
- 3. **Client** calls `wh.get()` — reads from `globalThis[key]()` (the serialized getter)
248
- 4. **`set()`** updates a local store and notifies all subscribers — `useWormhole()` hooks and `subscribe()` callbacks re-render/fire
190
+ At build time the integration scans the code for `wormholes.<name>` reads: server modules are attributed to the routes whose page or endpoint reaches them, client modules to the emitted chunks. It also generates the types for the `wormholes` proxy from your registry. At runtime the middleware runs, in parallel, the handlers of everything the request's route can reach — frontmatter and endpoint reads, the islands the page hydrates (via `@astroscope/node`'s route map), its `<script>` blocks — and each island's data is embedded in the HTML right before it, so it is always there before the island's code runs, even while the page is still streaming. Routes the build could not attribute (dev, astro's own routes) load everything. On the client, `set()` updates the shared data and notifies subscribers, keeping islands and scripts in sync.
191
+
192
+ Each handler runs under its own `wormhole <name>` span beneath the request span — they run in parallel, and the trace shows which one holds the page's first byte — and records `astro.wormhole.handler.duration` {`astro.wormhole.name`}. A throwing handler is counted on `astro.wormhole.handler.failures` {`astro.wormhole.name`, `error.type`}, logged with its name, and fails the request. All through the adapter's telemetry, so nothing runs without its SDK.
249
193
 
250
194
  ## License
251
195
 
@@ -0,0 +1,7 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/als.ts
3
+ const als = globalThis[Symbol.for("@astroscope/wormhole/als")] ??= new AsyncLocalStorage();
4
+ //#endregion
5
+ export { als as t };
6
+
7
+ //# sourceMappingURL=als-CntRqWdO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"als-CntRqWdO.js","names":[],"sources":["../src/als.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\n\n// a single ALS holds an immutable map of all open wormholes (keyed by wormhole key);\n// keyed on globalThis so the vite-runner and native module instances share one context\nexport const als: AsyncLocalStorage<ReadonlyMap<string, unknown>> = ((globalThis as any)[\n Symbol.for('@astroscope/wormhole/als')\n] ??= new AsyncLocalStorage());\n"],"mappings":";;AAIA,MAAa,MAAwD,WACnE,OAAO,IAAI,0BAA0B,OACjC,IAAI,kBAAkB"}
@@ -0,0 +1,83 @@
1
+ import { t as als } from "./als-CntRqWdO.js";
2
+ import { t as wormholeKey } from "./key-BqTy1gM3.js";
3
+ //#region src/define.ts
4
+ const ASSIGN = Symbol.for("@astroscope/wormhole.assign");
5
+ const SOURCE = Symbol.for("@astroscope/wormhole.source");
6
+ /**
7
+ * Define a wormhole for the `src/wormholes.ts` registry, actions-style. The
8
+ * handler resolves the value per request; the middleware calls it only for routes
9
+ * the build found a reader on (frontmatter, islands, scripts) — or on every
10
+ * request with `eager: true`. The wormhole's name is the registry key, assigned
11
+ * when the registry is passed to `createWormholeMiddleware()` (or
12
+ * `assignWormholeNames()`).
13
+ *
14
+ * **Security:** wormhole data is serialized into the HTML and sent to the browser.
15
+ * Never store secrets (tokens, API keys, credentials) in a wormhole.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * export const wormholes = {
20
+ * cart: defineWormhole({ handler: (ctx) => ctx.locals.cart }),
21
+ * config: defineWormhole({ handler: () => loadConfig(), eager: true }),
22
+ * };
23
+ * ```
24
+ */
25
+ function defineWormhole(definition) {
26
+ const wormhole = createWormhole();
27
+ wormhole[SOURCE] = {
28
+ handler: definition.handler,
29
+ eager: definition.eager ?? false
30
+ };
31
+ return wormhole;
32
+ }
33
+ /** the server-side wormhole object without a handler — the `wormholes` proxy stubs (internal) */
34
+ function createWormhole() {
35
+ let name;
36
+ const requireName = () => {
37
+ if (name === void 0) throw new Error("wormhole is not registered — pass its registry to createWormholeMiddleware()");
38
+ return name;
39
+ };
40
+ const key = () => wormholeKey(requireName());
41
+ return {
42
+ get name() {
43
+ return requireName();
44
+ },
45
+ get key() {
46
+ return key();
47
+ },
48
+ get() {
49
+ const value = als.getStore()?.get(key());
50
+ if (value === void 0) throw new Error(`wormhole "${requireName()}" is not open for this request — is it provided by the wormhole middleware?`);
51
+ return value;
52
+ },
53
+ set() {
54
+ throw new Error(`wormhole "${requireName()}" set() cannot be called on the server — values are request-scoped and provided by the middleware`);
55
+ },
56
+ subscribe() {
57
+ return () => {};
58
+ },
59
+ [ASSIGN](next) {
60
+ if (name !== void 0 && name !== next) throw new Error(`wormhole is registered under two names: "${name}" and "${next}"`);
61
+ name = next;
62
+ }
63
+ };
64
+ }
65
+ /** the definition a registry entry was created with — undefined for proxy stubs */
66
+ function getWormholeSource(wormhole) {
67
+ return wormhole[SOURCE];
68
+ }
69
+ /**
70
+ * Assign each wormhole its registry key as name. Called by `createWormholeMiddleware()`;
71
+ * exposed for setups that read wormholes on the server without the middleware.
72
+ */
73
+ function assignWormholeNames(registry) {
74
+ for (const [name, wormhole] of Object.entries(registry)) {
75
+ const assign = wormhole[ASSIGN];
76
+ if (typeof assign !== "function") throw new Error(`registry entry "${name}" is not a wormhole — the registry must contain defineWormhole() values only`);
77
+ assign(name);
78
+ }
79
+ }
80
+ //#endregion
81
+ export { getWormholeSource as i, createWormhole as n, defineWormhole as r, assignWormholeNames as t };
82
+
83
+ //# sourceMappingURL=define-1UkPnsXV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-1UkPnsXV.js","names":[],"sources":["../src/define.ts"],"sourcesContent":["import type { ReadonlyDeep } from '@entwico/dash';\nimport { als } from './als.js';\nimport { wormholeKey } from './key.js';\nimport type { Wormhole, WormholeDefinition, WormholeHandler } from './types.js';\n\n// name assignment and the handler must survive dual module instances (vite runner +\n// native), so both ride the wormhole object itself under Symbol.for keys\nconst ASSIGN = Symbol.for('@astroscope/wormhole.assign');\nconst SOURCE = Symbol.for('@astroscope/wormhole.source');\n\n/** how the middleware opens a wormhole: its handler and whether every route loads it */\nexport type WormholeSource = { handler: WormholeHandler<unknown>; eager: boolean };\n\ntype Internal = Wormhole<unknown> & { [ASSIGN](next: string): void; [SOURCE]?: WormholeSource | undefined };\n\n/**\n * Define a wormhole for the `src/wormholes.ts` registry, actions-style. The\n * handler resolves the value per request; the middleware calls it only for routes\n * the build found a reader on (frontmatter, islands, scripts) — or on every\n * request with `eager: true`. The wormhole's name is the registry key, assigned\n * when the registry is passed to `createWormholeMiddleware()` (or\n * `assignWormholeNames()`).\n *\n * **Security:** wormhole data is serialized into the HTML and sent to the browser.\n * Never store secrets (tokens, API keys, credentials) in a wormhole.\n *\n * @example\n * ```typescript\n * export const wormholes = {\n * cart: defineWormhole({ handler: (ctx) => ctx.locals.cart }),\n * config: defineWormhole({ handler: () => loadConfig(), eager: true }),\n * };\n * ```\n */\nexport function defineWormhole<T>(definition: WormholeDefinition<T>): Wormhole<T> {\n const wormhole = createWormhole() as Internal;\n\n wormhole[SOURCE] = { handler: definition.handler as WormholeHandler<unknown>, eager: definition.eager ?? false };\n\n return wormhole as Wormhole<T>;\n}\n\n/** the server-side wormhole object without a handler — the `wormholes` proxy stubs (internal) */\nexport function createWormhole<T>(): Wormhole<T> {\n let name: string | undefined;\n\n const requireName = (): string => {\n if (name === undefined) {\n throw new Error('wormhole is not registered — pass its registry to createWormholeMiddleware()');\n }\n\n return name;\n };\n\n const key = (): string => wormholeKey(requireName());\n\n // methods must not rely on `this` — consumers pass them around detached\n // (e.g. useSyncExternalStore(wormhole.subscribe, wormhole.get, wormhole.get))\n const wormhole: Wormhole<T> & { [ASSIGN](next: string): void } = {\n get name() {\n return requireName();\n },\n\n get key() {\n return key();\n },\n\n get(): ReadonlyDeep<T> {\n const value = als.getStore()?.get(key());\n\n if (value === undefined) {\n throw new Error(\n `wormhole \"${requireName()}\" is not open for this request — is it provided by the wormhole middleware?`,\n );\n }\n\n return value as ReadonlyDeep<T>;\n },\n\n set(): void {\n throw new Error(\n `wormhole \"${requireName()}\" set() cannot be called on the server — values are request-scoped and provided by the middleware`,\n );\n },\n\n subscribe(): () => void {\n // server values never change within a request — nothing to notify\n return () => {};\n },\n\n [ASSIGN](next: string): void {\n if (name !== undefined && name !== next) {\n throw new Error(`wormhole is registered under two names: \"${name}\" and \"${next}\"`);\n }\n\n name = next;\n },\n };\n\n return wormhole;\n}\n\n/** the definition a registry entry was created with — undefined for proxy stubs */\nexport function getWormholeSource<T>(wormhole: Wormhole<T>): WormholeSource | undefined {\n return (wormhole as unknown as Internal)[SOURCE];\n}\n\n/**\n * Assign each wormhole its registry key as name. Called by `createWormholeMiddleware()`;\n * exposed for setups that read wormholes on the server without the middleware.\n */\nexport function assignWormholeNames(registry: Record<string, object>): void {\n for (const [name, wormhole] of Object.entries(registry)) {\n const assign = (wormhole as { [ASSIGN]?: (next: string) => void })[ASSIGN];\n\n if (typeof assign !== 'function') {\n throw new Error(\n `registry entry \"${name}\" is not a wormhole — the registry must contain defineWormhole() values only`,\n );\n }\n\n assign(name);\n }\n}\n"],"mappings":";;;AAOA,MAAM,SAAS,OAAO,IAAI,6BAA6B;AACvD,MAAM,SAAS,OAAO,IAAI,6BAA6B;;;;;;;;;;;;;;;;;;;;AA0BvD,SAAgB,eAAkB,YAAgD;CAChF,MAAM,WAAW,eAAe;CAEhC,SAAS,UAAU;EAAE,SAAS,WAAW;EAAqC,OAAO,WAAW,SAAS;CAAM;CAE/G,OAAO;AACT;;AAGA,SAAgB,iBAAiC;CAC/C,IAAI;CAEJ,MAAM,oBAA4B;EAChC,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,MAAM,8EAA8E;EAGhG,OAAO;CACT;CAEA,MAAM,YAAoB,YAAY,YAAY,CAAC;CA6CnD,OAAO;EAxCL,IAAI,OAAO;GACT,OAAO,YAAY;EACrB;EAEA,IAAI,MAAM;GACR,OAAO,IAAI;EACb;EAEA,MAAuB;GACrB,MAAM,QAAQ,IAAI,SAAS,CAAC,EAAE,IAAI,IAAI,CAAC;GAEvC,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MACR,aAAa,YAAY,EAAE,4EAC7B;GAGF,OAAO;EACT;EAEA,MAAY;GACV,MAAM,IAAI,MACR,aAAa,YAAY,EAAE,kGAC7B;EACF;EAEA,YAAwB;GAEtB,aAAa,CAAC;EAChB;EAEA,CAAC,QAAQ,MAAoB;GAC3B,IAAI,SAAS,KAAA,KAAa,SAAS,MACjC,MAAM,IAAI,MAAM,4CAA4C,KAAK,SAAS,KAAK,EAAE;GAGnF,OAAO;EACT;CAGY;AAChB;;AAGA,SAAgB,kBAAqB,UAAmD;CACtF,OAAQ,SAAiC;AAC3C;;;;;AAMA,SAAgB,oBAAoB,UAAwC;CAC1E,KAAK,MAAM,CAAC,MAAM,aAAa,OAAO,QAAQ,QAAQ,GAAG;EACvD,MAAM,SAAU,SAAmD;EAEnE,IAAI,OAAO,WAAW,YACpB,MAAM,IAAI,MACR,mBAAmB,KAAK,6EAC1B;EAGF,OAAO,IAAI;CACb;AACF"}
@@ -0,0 +1,9 @@
1
+ import { a as WormholeMap, i as WormholeHandler, n as Wormhole, o as WormholeRegistry, r as WormholeDefinition, t as UnwrapWormholes } from "./types-DiWa8aQg.js";
2
+ //#region src/index.browser.d.ts
3
+ export declare const wormholes: WormholeMap;
4
+ export declare function defineWormhole(): never;
5
+ export declare function assignWormholeNames(): never;
6
+ export default function wormholeIntegration(): never;
7
+ //#endregion
8
+ export type { UnwrapWormholes, Wormhole, WormholeDefinition, WormholeHandler, WormholeMap, WormholeRegistry };
9
+ //# sourceMappingURL=index.browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.browser.d.ts","names":[],"sources":["../src/index.browser.ts"],"mappings":";;qBAyEa,WAAW;wBAMR;wBAMA;wBAIQ"}
@@ -0,0 +1,48 @@
1
+ import { t as wormholeKey } from "./key-BqTy1gM3.js";
2
+ //#region src/index.browser.ts
3
+ const store = globalThis.__wormholes__ ??= {};
4
+ const stubs = /* @__PURE__ */ new Map();
5
+ function stub(name) {
6
+ let wormhole = stubs.get(name);
7
+ if (wormhole) return wormhole;
8
+ const entry = () => store[name] ??= {};
9
+ wormhole = {
10
+ name,
11
+ key: wormholeKey(name),
12
+ get() {
13
+ const value = entry().v;
14
+ if (value === void 0) throw new Error(`wormhole "${name}" has no data — is it provided by the wormhole middleware?`);
15
+ return value;
16
+ },
17
+ set(data) {
18
+ const current = entry();
19
+ current.v = data;
20
+ current.l?.forEach((fn) => fn(data));
21
+ },
22
+ subscribe(fn) {
23
+ const current = entry();
24
+ (current.l ??= []).push(fn);
25
+ return () => {
26
+ current.l = current.l?.filter((listener) => listener !== fn);
27
+ };
28
+ }
29
+ };
30
+ stubs.set(name, wormhole);
31
+ return wormhole;
32
+ }
33
+ const wormholes = new Proxy({}, { get(_target, prop) {
34
+ return typeof prop === "string" ? stub(prop) : void 0;
35
+ } });
36
+ function defineWormhole() {
37
+ throw new Error("defineWormhole() is server-only — do not import the wormhole registry in client code; access wormholes via the `wormholes` proxy instead");
38
+ }
39
+ function assignWormholeNames() {
40
+ throw new Error("assignWormholeNames() is server-only");
41
+ }
42
+ function wormholeIntegration() {
43
+ throw new Error("the @astroscope/wormhole integration is server-only");
44
+ }
45
+ //#endregion
46
+ export { assignWormholeNames, wormholeIntegration as default, defineWormhole, wormholes };
47
+
48
+ //# sourceMappingURL=index.browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.browser.js","names":[],"sources":["../src/index.browser.ts"],"sourcesContent":["import type { ReadonlyDeep } from '@entwico/dash';\nimport { wormholeKey } from './key.js';\nimport type { Wormhole, WormholeMap } from './types.js';\n\nexport type {\n UnwrapWormholes,\n Wormhole,\n WormholeDefinition,\n WormholeHandler,\n WormholeMap,\n WormholeRegistry,\n} from './types.js';\n\n/**\n * Browser side of the `wormholes` proxy. Values live on `self.__wormholes__` — written\n * by the inline merge scripts the server emits before each island (and at stream end\n * for script consumers), or by client-side `set()` calls. The entry protocol is\n * `{ v: value, l: listeners }`, shared with the emitted scripts, so writes land in\n * either order.\n */\n\ntype Entry = { v?: unknown; l?: ((value: unknown) => void)[] | undefined };\n\nconst store: Record<string, Entry> = ((globalThis as any).__wormholes__ ??= {});\n\nconst stubs = new Map<string, Wormhole<unknown>>();\n\nfunction stub(name: string): Wormhole<unknown> {\n let wormhole = stubs.get(name);\n\n if (wormhole) {\n return wormhole;\n }\n\n const entry = (): Entry => (store[name] ??= {});\n\n wormhole = {\n name,\n key: wormholeKey(name),\n\n get(): ReadonlyDeep<unknown> {\n const value = entry().v;\n\n if (value === undefined) {\n throw new Error(`wormhole \"${name}\" has no data — is it provided by the wormhole middleware?`);\n }\n\n return value;\n },\n\n set(data): void {\n const current = entry();\n\n current.v = data;\n current.l?.forEach((fn) => fn(data));\n },\n\n subscribe(fn): () => void {\n const current = entry();\n\n (current.l ??= []).push(fn);\n\n return () => {\n current.l = current.l?.filter((listener) => listener !== fn);\n };\n },\n };\n\n stubs.set(name, wormhole);\n\n return wormhole;\n}\n\nexport const wormholes: WormholeMap = new Proxy({} as WormholeMap, {\n get(_target, prop) {\n return typeof prop === 'string' ? stub(prop) : undefined;\n },\n});\n\nexport function defineWormhole(): never {\n throw new Error(\n 'defineWormhole() is server-only — do not import the wormhole registry in client code; access wormholes via the `wormholes` proxy instead',\n );\n}\n\nexport function assignWormholeNames(): never {\n throw new Error('assignWormholeNames() is server-only');\n}\n\nexport default function wormholeIntegration(): never {\n throw new Error('the @astroscope/wormhole integration is server-only');\n}\n"],"mappings":";;AAuBA,MAAM,QAAgC,WAAoB,kBAAkB,CAAC;AAE7E,MAAM,wBAAQ,IAAI,IAA+B;AAEjD,SAAS,KAAK,MAAiC;CAC7C,IAAI,WAAW,MAAM,IAAI,IAAI;CAE7B,IAAI,UACF,OAAO;CAGT,MAAM,cAAsB,MAAM,UAAU,CAAC;CAE7C,WAAW;EACT;EACA,KAAK,YAAY,IAAI;EAErB,MAA6B;GAC3B,MAAM,QAAQ,MAAM,CAAC,CAAC;GAEtB,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,aAAa,KAAK,2DAA2D;GAG/F,OAAO;EACT;EAEA,IAAI,MAAY;GACd,MAAM,UAAU,MAAM;GAEtB,QAAQ,IAAI;GACZ,QAAQ,GAAG,SAAS,OAAO,GAAG,IAAI,CAAC;EACrC;EAEA,UAAU,IAAgB;GACxB,MAAM,UAAU,MAAM;GAEtB,CAAC,QAAQ,MAAM,CAAC,EAAA,CAAG,KAAK,EAAE;GAE1B,aAAa;IACX,QAAQ,IAAI,QAAQ,GAAG,QAAQ,aAAa,aAAa,EAAE;GAC7D;EACF;CACF;CAEA,MAAM,IAAI,MAAM,QAAQ;CAExB,OAAO;AACT;AAEA,MAAa,YAAyB,IAAI,MAAM,CAAC,GAAkB,EACjE,IAAI,SAAS,MAAM;CACjB,OAAO,OAAO,SAAS,WAAW,KAAK,IAAI,IAAI,KAAA;AACjD,EACF,CAAC;AAED,SAAgB,iBAAwB;CACtC,MAAM,IAAI,MACR,0IACF;AACF;AAEA,SAAgB,sBAA6B;CAC3C,MAAM,IAAI,MAAM,sCAAsC;AACxD;AAEA,SAAwB,sBAA6B;CACnD,MAAM,IAAI,MAAM,qDAAqD;AACvE"}
package/dist/index.d.ts CHANGED
@@ -1,13 +1,61 @@
1
- import { n as Wormhole, t as DeepReadonly } from "./types-Q2gaO_N_.js";
2
-
1
+ import { a as WormholeMap, i as WormholeHandler, n as Wormhole, o as WormholeRegistry, r as WormholeDefinition, t as UnwrapWormholes } from "./types-DiWa8aQg.js";
2
+ import { ExcludePattern } from "@astroscope/node/excludes";
3
+ import { AstroIntegration } from "astro";
4
+ //#region src/integration.d.ts
5
+ type WormholeIntegrationOptions = {
6
+ /** patterns the middleware skips — no handler runs, nothing is delivered; defaults to RECOMMENDED_EXCLUDES */
7
+ exclude?: ExcludePattern[] | undefined;
8
+ };
9
+ /**
10
+ * Astro integration for wormholes with per-route loading and per-island delivery.
11
+ *
12
+ * Resolves the `src/wormholes.ts` registry and exposes it to the middleware as a
13
+ * virtual module, injects the middleware after the project's own (so handlers see
14
+ * everything earlier middleware put on `locals`), registers the vite plugin that
15
+ * scans server and client code for `wormholes.<name>` accesses and maps them onto
16
+ * routes and chunks, and generates the registry type stub that types the
17
+ * `wormholes` proxy. Delivery happens at runtime: the middleware runs the handlers
18
+ * the route needs, and the islands emitter (via `@astroscope/node`) writes each
19
+ * island's slice before its tag.
20
+ */
21
+ export default function wormholeIntegration(options?: WormholeIntegrationOptions): AstroIntegration;
22
+ //#endregion
3
23
  //#region src/define.d.ts
4
24
  /**
5
- * Define a wormhole that transfers state from server middleware to client components.
25
+ * Define a wormhole for the `src/wormholes.ts` registry, actions-style. The
26
+ * handler resolves the value per request; the middleware calls it only for routes
27
+ * the build found a reader on (frontmatter, islands, scripts) — or on every
28
+ * request with `eager: true`. The wormhole's name is the registry key, assigned
29
+ * when the registry is passed to `createWormholeMiddleware()` (or
30
+ * `assignWormholeNames()`).
6
31
  *
7
32
  * **Security:** wormhole data is serialized into the HTML and sent to the browser.
8
33
  * Never store secrets (tokens, API keys, credentials) in a wormhole.
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * export const wormholes = {
38
+ * cart: defineWormhole({ handler: (ctx) => ctx.locals.cart }),
39
+ * config: defineWormhole({ handler: () => loadConfig(), eager: true }),
40
+ * };
41
+ * ```
42
+ */
43
+ export declare function defineWormhole<T>(definition: WormholeDefinition<T>): Wormhole<T>;
44
+ /**
45
+ * Assign each wormhole its registry key as name. Called by `createWormholeMiddleware()`;
46
+ * exposed for setups that read wormholes on the server without the middleware.
47
+ */
48
+ export declare function assignWormholeNames(registry: Record<string, object>): void;
49
+ //#endregion
50
+ //#region src/index.d.ts
51
+ /**
52
+ * Access wormholes by name — `wormholes.cart.get()`. This is the server side of the
53
+ * proxy (the browser build ships its own): values come from the request's
54
+ * wormhole middleware scope, so it works in frontmatter, endpoints, and
55
+ * server-rendered islands alike. Typed via the registry type stub the integration
56
+ * generates from `src/wormholes.ts`.
9
57
  */
10
- declare function defineWormhole<T>(name: string): Wormhole<T>;
58
+ export declare const wormholes: WormholeMap;
11
59
  //#endregion
12
- export { type DeepReadonly, type Wormhole, defineWormhole };
60
+ export type { UnwrapWormholes, Wormhole, WormholeDefinition, WormholeHandler, WormholeIntegrationOptions, WormholeMap, WormholeRegistry };
13
61
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/define.ts"],"mappings":";;;;;AAQA;;;;iBAAgB,cAAA,IAAkB,IAAA,WAAe,QAAQ,CAAC,CAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/integration.ts","../src/define.ts","../src/index.ts"],"mappings":";;;;KAQY;;EAEV,UAAU;;;;;;;;;;;;;;wBAeY,oBAAoB,UAAS,6BAAkC;;;;;;;;;;;;;;;;;;;;;;wBCSvE,eAAe,GAAG,YAAY,mBAAmB,KAAK,SAAS;;;;;wBA6E/D,oBAAoB,UAAU;;;;;;;;;;qBC1EjC,WAAW"}