Skip to content

Commit 66a78c2

Browse files
committed
feat(next): evolve client surface + adopt it in next-runtime-snapshot
- Drop the experimental npm dist-tag (publishConfig); the package stays flagged experimental in its description and docs only, matching the other packages. - RpcProvider now renders children eagerly and exposes live connection state: useRpc() returns the client or null, useRpcStatus() returns { status, error } (subscribed to the client's connection:status / connection:error events). - Apply @devframes/next/client to examples/next-runtime-snapshot: its connect provider is now a thin adapter over the package (the host/handler adapter stays N/A there since devframe owns the server via createCac). Wire the turbo build edge and regenerate the client API snapshot.
1 parent 9bbb582 commit 66a78c2

10 files changed

Lines changed: 126 additions & 85 deletions

File tree

docs/helpers/next.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ outline: deep
55
# Next Helper
66

77
> [!WARNING]
8-
> Experimental. `@devframes/next` is published under the `experimental` npm tag (`npm i @devframes/next@experimental`) while its API settles. Expect changes before a stable release.
8+
> Experimental. `@devframes/next`'s API is still settling — expect changes before a stable release.
99
1010
`@devframes/next` hosts devframes from a Next.js App Router app. Next runs on webpack/Turbopack rather than Vite, so it hosts through a route handler instead of the [Vite Bridge](./vite-bridge): the package serves each devframe's SPA and its `__connection.json` from a single `fetch` handler your catch-all route delegates to, reusing devframe's own [`serveStaticHandler`](/adapters/dev) for SPA fallback, content types, and path-traversal guarding.
1111

@@ -95,7 +95,7 @@ export async function GET(request: Request): Promise<Response> {
9595

9696
## React client
9797

98-
`@devframes/next/client` connects to the RPC backend and provides the client to your component tree — the React counterpart to `@devframes/nuxt`'s `$rpc` plugin.
98+
`@devframes/next/client` connects to the RPC backend and provides the client to your component tree — the React counterpart to `@devframes/nuxt`'s `$rpc` plugin. Children render immediately, so your shell and a connection indicator stay visible while the client connects.
9999

100100
```tsx [app/providers.tsx]
101101
'use client'
@@ -106,17 +106,22 @@ export function Providers({ children }: { children: React.ReactNode }) {
106106
}
107107
```
108108

109+
`useRpc()` returns the connected `DevframeRpcClient`, or `null` while connecting; scope it to your tool's namespace. `useRpcStatus()` returns the live `{ status, error }` for a connection indicator.
110+
109111
```tsx [app/panel.tsx]
110112
'use client'
111-
import { useRpc } from '@devframes/next/client'
113+
import { useRpc, useRpcStatus } from '@devframes/next/client'
112114

113115
export function Panel() {
114-
const rpc = useRpc().scope('my-tool:')
115-
// rpc.call('get-payload'), rpc.sharedState, …
116+
const rpc = useRpc()?.scope('my-tool:')
117+
const { status, error } = useRpcStatus()
118+
if (!rpc)
119+
return <p>{error ? `connection failed — ${error.message}` : 'connecting…'}</p>
120+
// rpc.rpc.call('get-payload'), rpc.sharedState, …
116121
}
117122
```
118123

119-
`RpcProvider` renders its `fallback` (default `null`) until the client connects, so `useRpc()` always returns a live client. Theming and layout stay app-owned.
124+
Both hooks throw outside a `<RpcProvider>`. Theming and layout stay app-owned.
120125

121126
## Runtime
122127

examples/next-runtime-snapshot/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@antfu/design": "catalog:frontend",
22+
"@devframes/next": "workspace:*",
2223
"cac": "catalog:deps",
2324
"colorjs.io": "catalog:frontend",
2425
"devframe": "workspace:*",

examples/next-runtime-snapshot/src/client/app/components/connect.tsx

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import type { DevframeScopedClientContext } from 'devframe/client'
44
import type { ReactNode } from 'react'
5-
import { connectDevframe } from 'devframe/client'
6-
import { createContext, useContext, useEffect, useState } from 'react'
5+
import { RpcProvider as DevframeRpcProvider, useRpc as useDevframeRpc, useRpcStatus } from '@devframes/next/client'
6+
import { useMemo } from 'react'
77

88
// Inlined (not imported from the server `rpc/index.ts`) so the client
99
// bundle stays free of node-only server code.
@@ -16,33 +16,20 @@ interface ConnectionState {
1616
error: string | null
1717
}
1818

19-
const RpcContext = createContext<ConnectionState>({ ctx: null, error: null })
20-
21-
export function useRpc(): ConnectionState {
22-
return useContext(RpcContext)
23-
}
24-
19+
/**
20+
* Connect to the RPC backend via `@devframes/next/client` — the connect +
21+
* status machinery lives in the package now; this file only scopes the client
22+
* to this tool's namespace and reshapes the status for the local UI.
23+
*/
2524
export function RpcProvider({ children }: { children: ReactNode }) {
26-
const [state, setState] = useState<ConnectionState>({ ctx: null, error: null })
27-
28-
useEffect(() => {
29-
let cancelled = false
30-
connectDevframe().then(
31-
(rpc) => {
32-
if (!cancelled)
33-
setState({ ctx: rpc.scope(NAMESPACE), error: null })
34-
},
35-
(err: unknown) => {
36-
if (cancelled)
37-
return
38-
const message = err instanceof Error ? err.message : String(err)
39-
setState({ ctx: null, error: message })
40-
},
41-
)
42-
return () => {
43-
cancelled = true
44-
}
45-
}, [])
25+
return <DevframeRpcProvider>{children}</DevframeRpcProvider>
26+
}
4627

47-
return <RpcContext.Provider value={state}>{children}</RpcContext.Provider>
28+
export function useRpc(): ConnectionState {
29+
const rpc = useDevframeRpc()
30+
const { error } = useRpcStatus()
31+
// `rpc` is stable once resolved, so memoizing on it keeps `ctx` referentially
32+
// stable across renders (the snapshot components depend on `ctx` identity).
33+
const ctx = useMemo(() => (rpc ? rpc.scope(NAMESPACE) : null), [rpc])
34+
return { ctx, error: error ? error.message : null }
4835
}

packages/next/package.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@
3434
"files": [
3535
"dist"
3636
],
37-
"publishConfig": {
38-
"access": "public",
39-
"tag": "experimental"
40-
},
4137
"scripts": {
4238
"build": "tsdown",
4339
"watch": "tsdown --watch",

packages/next/src/client.tsx

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
'use client'
22

3-
import type { DevframeRpcClient } from 'devframe/client'
3+
import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client'
44
import type { ConnectionMeta } from 'devframe/types'
55
import type { ReactNode } from 'react'
66
import { connectDevframe } from 'devframe/client'
77
import { createContext, useContext, useEffect, useState } from 'react'
88

9-
const RpcContext = createContext<DevframeRpcClient | null>(null)
9+
export interface DevframeRpcState {
10+
/** The connected client, or `null` while the initial connect is in flight. */
11+
rpc: DevframeRpcClient | null
12+
/** Live connection status — `'connecting'` until the client resolves. */
13+
status: DevframeConnectionStatus
14+
/** The latest connection error, or `null`. */
15+
error: Error | null
16+
}
17+
18+
const RpcContext = createContext<DevframeRpcState | null>(null)
19+
20+
const INITIAL: DevframeRpcState = { rpc: null, status: 'connecting', error: null }
1021

1122
export interface RpcProviderProps {
1223
children: ReactNode
@@ -21,21 +32,16 @@ export interface RpcProviderProps {
2132
* fetch entirely (e.g. when the host already knows the WS endpoint).
2233
*/
2334
connectionMeta?: ConnectionMeta
24-
/**
25-
* Rendered while the client is connecting. Children mount only once the RPC
26-
* client is ready, so {@link useRpc} always returns a live client. Defaults
27-
* to `null`.
28-
*/
29-
fallback?: ReactNode
3035
}
3136

3237
/**
33-
* Connect to the devframe RPC backend once and provide the client to the tree.
38+
* Connect to the devframe RPC backend once and provide the client — plus live
39+
* connection status — to the tree. The React counterpart to `@devframes/nuxt`'s
40+
* client plugin.
3441
*
35-
* The React counterpart to `@devframes/nuxt`'s client plugin: it calls
36-
* `connectDevframe()` on mount and exposes the result through {@link useRpc}.
37-
* Being a client component, drop it into a Next layout or page and read the
38-
* client from any descendant.
42+
* Children render immediately (before the connection resolves), so your shell
43+
* and a connection indicator stay visible throughout; read the client with
44+
* {@link useRpc} and the status with {@link useRpcStatus}.
3945
*
4046
* ```tsx
4147
* 'use client'
@@ -50,37 +56,66 @@ export function RpcProvider({
5056
children,
5157
baseURL = './',
5258
connectionMeta,
53-
fallback = null,
5459
}: RpcProviderProps): ReactNode {
55-
const [rpc, setRpc] = useState<DevframeRpcClient | null>(null)
60+
const [state, setState] = useState<DevframeRpcState>(INITIAL)
5661

5762
useEffect(() => {
5863
let active = true
59-
void connectDevframe({ baseURL, connectionMeta }).then((client) => {
60-
if (active)
61-
setRpc(client)
62-
})
64+
let client: DevframeRpcClient | undefined
65+
const sync = (): void => {
66+
if (active && client)
67+
setState({ rpc: client, status: client.status, error: client.connectionError })
68+
}
69+
70+
const offs: Array<() => void> = []
71+
void connectDevframe({ baseURL, connectionMeta }).then(
72+
(c) => {
73+
if (!active)
74+
return
75+
client = c
76+
sync()
77+
offs.push(c.events.on('connection:status', sync))
78+
offs.push(c.events.on('connection:error', sync))
79+
},
80+
(err: unknown) => {
81+
if (active)
82+
setState({ rpc: null, status: 'error', error: err instanceof Error ? err : new Error(String(err)) })
83+
},
84+
)
85+
6386
return () => {
6487
active = false
88+
for (const off of offs)
89+
off()
6590
}
6691
// `connectionMeta` is a plain descriptor; re-connect only when the base changes.
6792
}, [baseURL])
6893

69-
if (!rpc)
70-
return fallback
94+
return <RpcContext.Provider value={state}>{children}</RpcContext.Provider>
95+
}
7196

72-
return <RpcContext.Provider value={rpc}>{children}</RpcContext.Provider>
97+
function useDevframeState(): DevframeRpcState {
98+
const state = useContext(RpcContext)
99+
if (!state)
100+
throw new Error('[@devframes/next] useRpc()/useRpcStatus() must be called inside a <RpcProvider>.')
101+
return state
73102
}
74103

75104
/**
76-
* Read the connected {@link DevframeRpcClient} provided by {@link RpcProvider}.
77-
* Scope it to your tool's RPC namespace with `useRpc().scope('my-tool:')`.
105+
* Read the connected {@link DevframeRpcClient}, or `null` while connecting.
106+
* Scope it to your tool's RPC namespace with `useRpc()?.scope('my-tool:')`.
78107
*
79108
* Throws when called outside a `<RpcProvider>`.
80109
*/
81-
export function useRpc(): DevframeRpcClient {
82-
const rpc = useContext(RpcContext)
83-
if (!rpc)
84-
throw new Error('[@devframes/next] useRpc() must be called inside a <RpcProvider>.')
85-
return rpc
110+
export function useRpc(): DevframeRpcClient | null {
111+
return useDevframeState().rpc
112+
}
113+
114+
/**
115+
* Read the live connection `status` and latest `error`, for a connection
116+
* indicator. Throws when called outside a `<RpcProvider>`.
117+
*/
118+
export function useRpcStatus(): { status: DevframeConnectionStatus, error: Error | null } {
119+
const { status, error } = useDevframeState()
120+
return { status, error }
86121
}

plans/notes/devframes-next-proposal.md

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -133,23 +133,28 @@ STOP condition (contract divergence) does not trip.
133133
side-car against a temp `distDir` and asserts SPA serving, SPA fallback, the
134134
WS-port meta, and a bare 404).
135135
2. **Client surface — implemented** at `@devframes/next/client` (React peer,
136-
optional). `RpcProvider` calls `connectDevframe()` once and provides the
137-
client; `useRpc()` reads it (throwing outside a provider). The `'use client'`
138-
directive is preserved through the browser build. A theme/layout helper was
139-
deliberately left out — theming is design-system-specific and stays
140-
app-owned.
141-
3. **Publishing shape — done.** `private` dropped; `publishConfig.tag:
142-
"experimental"` publishes under the `experimental` tag, not `latest`. `next`
143-
and `react` are optional peers; exports are `.` (Node) + `./client`
144-
(browser). tsnapi snapshots generated under
136+
optional). `RpcProvider` calls `connectDevframe()` once and renders children
137+
eagerly (so a shell + connection indicator stay visible while connecting);
138+
`useRpc()` returns the client or `null`, and `useRpcStatus()` exposes the live
139+
`{ status, error }` (subscribed to the client's `connection:status` /
140+
`connection:error` events). The `'use client'` directive is preserved through
141+
the browser build. A theme/layout helper was deliberately left out — theming
142+
is design-system-specific and stays app-owned.
143+
3. **Publishing shape — done.** `private` dropped so the package publishes with
144+
the rest of the workspace; it's flagged experimental in its description and
145+
the docs (no npm dist-tag). `next` and `react` are optional peers; exports are
146+
`.` (Node) + `./client` (browser). tsnapi snapshots generated under
145147
`tests/__snapshots__/tsnapi/@devframes/next/` (`index` + `client`).
146148
4. **404 body — parity added.** The host `fetch` normalizes any miss to a
147149
body-less `404`, matching a plain static server (h3's default JSON error body
148150
is dropped).
149-
5. **`next-runtime-snapshot` needs no bridge.** It uses Next only as a static-SPA
150-
builder while devframe owns the server via `createCac`/`createBuild`; the
151-
bridge doesn't apply. Left as-is (recorded so a future reader doesn't try to
152-
"unify" the two Next examples).
151+
5. **`next-runtime-snapshot` — client surface adopted; host part N/A.** It uses
152+
Next only as a static-SPA builder while devframe owns the server via
153+
`createCac`/`createBuild`, so the host/handler adapter (route handlers) still
154+
doesn't apply. Its hand-rolled connect provider, however, was migrated onto
155+
`@devframes/next/client`: `connect.tsx` is now a thin adapter that delegates
156+
connect + status to the package and only scopes the client to the example's
157+
namespace — a second consumer that validated the eager-render + status API.
153158

154159
## Files
155160

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.d.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@
22
* Generated by tsnapi — public API snapshot of `@devframes/next/client`
33
*/
44
// #region Interfaces
5+
export interface DevframeRpcState {
6+
rpc: DevframeRpcClient | null;
7+
status: DevframeConnectionStatus;
8+
error: Error | null;
9+
}
510
export interface RpcProviderProps {
611
children: ReactNode;
712
baseURL?: string;
813
connectionMeta?: ConnectionMeta;
9-
fallback?: ReactNode;
1014
}
1115
// #endregion
1216

1317
// #region Functions
14-
export declare function RpcProvider({ children, baseURL, connectionMeta, fallback }: RpcProviderProps): ReactNode;
15-
export declare function useRpc(): DevframeRpcClient;
18+
export declare function RpcProvider({ children, baseURL, connectionMeta }: RpcProviderProps): ReactNode;
19+
export declare function useRpc(): DevframeRpcClient | null;
20+
export declare function useRpcStatus(): {
21+
status: DevframeConnectionStatus;
22+
error: Error | null;
23+
};
1624
// #endregion

tests/__snapshots__/tsnapi/@devframes/next/client.snapshot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
// #region Functions
55
export function RpcProvider(_) {}
66
export function useRpc() {}
7+
export function useRpcStatus() {}
78
// #endregion

turbo.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
},
111111
"next-runtime-snapshot-example#build": {
112112
"outputLogs": "new-only",
113-
"dependsOn": ["devframe#build"],
113+
"dependsOn": ["devframe#build", "@devframes/next#build"],
114114
"outputs": ["dist/**"]
115115
},
116116
"@devframes/plugin-git#build": {

0 commit comments

Comments
 (0)