Skip to content

Commit 2736500

Browse files
committed
feat: added dumb implementation of tanstack server components because why not
1 parent 117d671 commit 2736500

File tree

11 files changed

+1022
-127
lines changed

11 files changed

+1022
-127
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React, { ReactNode } from 'react';
2+
3+
/**
4+
* Serializable representation of a React element (matches worker definition)
5+
*/
6+
export interface SerializableElement {
7+
type: string;
8+
props: Record<string, any>;
9+
children?: SerializableNode[];
10+
}
11+
12+
export type SerializableNode = SerializableElement | string | number | boolean | null | undefined;
13+
14+
/**
15+
* Converts a serializable element back to a React element
16+
*/
17+
export function deserializeJSX(node: SerializableNode): ReactNode {
18+
// Handle primitives
19+
if (node === null || node === undefined || typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') {
20+
return node;
21+
}
22+
23+
// Handle arrays
24+
if (Array.isArray(node)) {
25+
return node.map((child, index) => React.createElement(React.Fragment, { key: index }, deserializeJSX(child)));
26+
}
27+
28+
// Handle serialized elements
29+
if (typeof node === 'object' && 'type' in node) {
30+
const element = node as SerializableElement;
31+
const children = element.children ? element.children.map((child) => deserializeJSX(child)) : [];
32+
33+
return React.createElement(element.type as string, element.props, ...children);
34+
}
35+
36+
return null;
37+
}

packages/web/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Route as RpcIndexRouteImport } from './routes/rpc/index'
1414
import { Route as RpcSayHelloRouteImport } from './routes/rpc/say-hello'
1515
import { Route as RpcProcessBatchRouteImport } from './routes/rpc/process-batch'
1616
import { Route as RpcGetDataRouteImport } from './routes/rpc/get-data'
17+
import { Route as RpcGetComponentRouteImport } from './routes/rpc/get-component'
1718
import { Route as RpcCalculateRouteImport } from './routes/rpc/calculate'
1819
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs'
1920
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request'
@@ -48,6 +49,11 @@ const RpcGetDataRoute = RpcGetDataRouteImport.update({
4849
path: '/rpc/get-data',
4950
getParentRoute: () => rootRouteImport,
5051
} as any)
52+
const RpcGetComponentRoute = RpcGetComponentRouteImport.update({
53+
id: '/rpc/get-component',
54+
path: '/rpc/get-component',
55+
getParentRoute: () => rootRouteImport,
56+
} as any)
5157
const RpcCalculateRoute = RpcCalculateRouteImport.update({
5258
id: '/rpc/calculate',
5359
path: '/rpc/calculate',
@@ -92,6 +98,7 @@ const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
9298
export interface FileRoutesByFullPath {
9399
'/': typeof IndexRoute
94100
'/rpc/calculate': typeof RpcCalculateRoute
101+
'/rpc/get-component': typeof RpcGetComponentRoute
95102
'/rpc/get-data': typeof RpcGetDataRoute
96103
'/rpc/process-batch': typeof RpcProcessBatchRoute
97104
'/rpc/say-hello': typeof RpcSayHelloRoute
@@ -107,6 +114,7 @@ export interface FileRoutesByFullPath {
107114
export interface FileRoutesByTo {
108115
'/': typeof IndexRoute
109116
'/rpc/calculate': typeof RpcCalculateRoute
117+
'/rpc/get-component': typeof RpcGetComponentRoute
110118
'/rpc/get-data': typeof RpcGetDataRoute
111119
'/rpc/process-batch': typeof RpcProcessBatchRoute
112120
'/rpc/say-hello': typeof RpcSayHelloRoute
@@ -123,6 +131,7 @@ export interface FileRoutesById {
123131
__root__: typeof rootRouteImport
124132
'/': typeof IndexRoute
125133
'/rpc/calculate': typeof RpcCalculateRoute
134+
'/rpc/get-component': typeof RpcGetComponentRoute
126135
'/rpc/get-data': typeof RpcGetDataRoute
127136
'/rpc/process-batch': typeof RpcProcessBatchRoute
128137
'/rpc/say-hello': typeof RpcSayHelloRoute
@@ -140,6 +149,7 @@ export interface FileRouteTypes {
140149
fullPaths:
141150
| '/'
142151
| '/rpc/calculate'
152+
| '/rpc/get-component'
143153
| '/rpc/get-data'
144154
| '/rpc/process-batch'
145155
| '/rpc/say-hello'
@@ -155,6 +165,7 @@ export interface FileRouteTypes {
155165
to:
156166
| '/'
157167
| '/rpc/calculate'
168+
| '/rpc/get-component'
158169
| '/rpc/get-data'
159170
| '/rpc/process-batch'
160171
| '/rpc/say-hello'
@@ -170,6 +181,7 @@ export interface FileRouteTypes {
170181
| '__root__'
171182
| '/'
172183
| '/rpc/calculate'
184+
| '/rpc/get-component'
173185
| '/rpc/get-data'
174186
| '/rpc/process-batch'
175187
| '/rpc/say-hello'
@@ -186,6 +198,7 @@ export interface FileRouteTypes {
186198
export interface RootRouteChildren {
187199
IndexRoute: typeof IndexRoute
188200
RpcCalculateRoute: typeof RpcCalculateRoute
201+
RpcGetComponentRoute: typeof RpcGetComponentRoute
189202
RpcGetDataRoute: typeof RpcGetDataRoute
190203
RpcProcessBatchRoute: typeof RpcProcessBatchRoute
191204
RpcSayHelloRoute: typeof RpcSayHelloRoute
@@ -236,6 +249,13 @@ declare module '@tanstack/react-router' {
236249
preLoaderRoute: typeof RpcGetDataRouteImport
237250
parentRoute: typeof rootRouteImport
238251
}
252+
'/rpc/get-component': {
253+
id: '/rpc/get-component'
254+
path: '/rpc/get-component'
255+
fullPath: '/rpc/get-component'
256+
preLoaderRoute: typeof RpcGetComponentRouteImport
257+
parentRoute: typeof rootRouteImport
258+
}
239259
'/rpc/calculate': {
240260
id: '/rpc/calculate'
241261
path: '/rpc/calculate'
@@ -298,6 +318,7 @@ declare module '@tanstack/react-router' {
298318
const rootRouteChildren: RootRouteChildren = {
299319
IndexRoute: IndexRoute,
300320
RpcCalculateRoute: RpcCalculateRoute,
321+
RpcGetComponentRoute: RpcGetComponentRoute,
301322
RpcGetDataRoute: RpcGetDataRoute,
302323
RpcProcessBatchRoute: RpcProcessBatchRoute,
303324
RpcSayHelloRoute: RpcSayHelloRoute,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
import { createServerFn } from '@tanstack/react-start';
3+
import { getWorkerRpc } from '@/lib/rpc';
4+
import { deserializeJSX, type SerializableNode } from '@/lib/jsx-deserializer';
5+
6+
export const getComponent = createServerFn({ method: 'GET' }).handler(async () => {
7+
const workerRpc = getWorkerRpc();
8+
9+
// Get the serialized JSX payload from the worker
10+
const serializedPayload = await workerRpc.getComponent();
11+
12+
// Return it to the client
13+
return serializedPayload;
14+
});
15+
16+
export const Route = createFileRoute('/rpc/get-component')({
17+
loader: async () => getComponent(),
18+
component: RouteComponent,
19+
});
20+
21+
function RouteComponent() {
22+
const serializedPayload = Route.useLoaderData() as SerializableNode;
23+
24+
// Deserialize the JSX - reconstructs the React element from the serialized format
25+
const component = deserializeJSX(serializedPayload);
26+
27+
return (
28+
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 p-8">
29+
<div className="max-w-4xl mx-auto">
30+
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-xl p-8">
31+
<h1 className="text-4xl font-bold text-white mb-4">Get Component RPC Method</h1>
32+
<p className="text-gray-400 mb-6">Returns JSX from the Worker with server-side fetched data (like React Server Components)</p>
33+
34+
<div className="bg-slate-900/50 rounded-lg p-6 mb-6">
35+
<h2 className="text-xl font-semibold text-cyan-400 mb-3">Rendered Component</h2>
36+
<div className="text-gray-300">
37+
{component}
38+
</div>
39+
</div>
40+
41+
<div className="bg-slate-900/50 rounded-lg p-6 mb-6">
42+
<h2 className="text-xl font-semibold text-cyan-400 mb-3">How It Works</h2>
43+
<div className="space-y-3 text-gray-300">
44+
<p>
45+
✨ The component above was returned as <strong className="text-white">pure JSX</strong> from the Cloudflare Worker with <strong className="text-white">server-side data</strong>!
46+
</p>
47+
<p>
48+
The worker fetches data from external APIs (ipify.org) <strong className="text-white">server-side</strong> and reads environment variables — the browser never makes these requests! This demonstrates true worker-only functionality that cannot happen in the client.
49+
</p>
50+
<div className="mt-4 space-y-2">
51+
<p className="font-semibold text-white">Worker-Only Features Demonstrated:</p>
52+
<ul className="list-disc list-inside space-y-1 pl-4">
53+
<li>Server-side fetch() calls without CORS restrictions</li>
54+
<li>Access to environment variables (worker context only)</li>
55+
<li>Worker execution context and metadata</li>
56+
<li>JSX serialization & deserialization</li>
57+
</ul>
58+
</div>
59+
<div className="mt-4 p-3 bg-orange-500/10 border border-orange-500/30 rounded">
60+
<p className="text-sm text-orange-200">
61+
<strong>🔥 Pro Tip:</strong> Open your browser's Network tab and refresh. You won't see any requests to ipify.org — it happens entirely in the worker!
62+
</p>
63+
</div>
64+
</div>
65+
</div>
66+
67+
<a
68+
href="/rpc"
69+
className="inline-flex items-center gap-2 px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white font-semibold rounded-lg transition-colors"
70+
>
71+
← Back to RPC Methods
72+
</a>
73+
</div>
74+
</div>
75+
</div>
76+
);
77+
}

packages/web/src/routes/rpc/index.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createFileRoute, Link } from '@tanstack/react-router';
2-
import { Network, Calculator, Database, Package2, ArrowRight } from 'lucide-react';
2+
import { Network, Calculator, Database, Package2, ArrowRight, Component } from 'lucide-react';
33

44
export const Route = createFileRoute('/rpc/')({
55
component: RouteComponent,
@@ -39,6 +39,14 @@ function RouteComponent() {
3939
signature: 'processBatch(items: string[]): Promise<{ processed: number; items: string[] }>',
4040
example: '/rpc/process-batch',
4141
},
42+
{
43+
name: 'getComponent',
44+
path: '/rpc/get-component',
45+
icon: <Component className="w-8 h-8 text-orange-400" />,
46+
description: 'Returns JSX with server-side fetched data (external APIs, env vars) - browser never sees these requests!',
47+
signature: 'getComponent(): Promise<SerializableNode>',
48+
example: '/rpc/get-component',
49+
},
4250
];
4351

4452
return (
@@ -55,7 +63,7 @@ function RouteComponent() {
5563
</p>
5664
<p className="text-gray-500">
5765
All methods are defined in{' '}
58-
<code className="px-2 py-1 bg-slate-700 rounded text-orange-400">packages/worker/src/rpc.ts</code>
66+
<code className="px-2 py-1 bg-slate-700 rounded text-orange-400">packages/worker/src/rpc.tsx</code>
5967
</p>
6068
</div>
6169

@@ -92,7 +100,7 @@ function RouteComponent() {
92100
<ol className="list-decimal list-inside space-y-2 text-gray-400">
93101
<li>
94102
Add your method to the <code className="text-orange-400">WorkerRpc</code> class in{' '}
95-
<code className="text-orange-400">packages/worker/src/rpc.ts</code>
103+
<code className="text-orange-400">packages/worker/src/rpc.tsx</code>
96104
</li>
97105
<li>
98106
TypeScript will automatically provide types in the web package via{' '}

packages/worker/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
"@cloudflare/vitest-pool-workers": "^0.10.3",
1010
"@cloudflare/workers-types": "^4.20250922.0",
1111
"@types/node": "^18.0.0",
12+
"@types/react": "^19.2.2",
1213
"vitest": "~3.0.9",
1314
"wrangler": "^4.45.3"
1415
},
1516
"dependencies": {
16-
"hono": "^4.10.4"
17+
"hono": "^4.10.4",
18+
"react": "^19.2.0"
1719
},
1820
"scripts": {
1921
"dev": "wrangler dev",
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { ReactElement, ReactNode } from 'react';
2+
3+
/**
4+
* Serializable representation of a React element
5+
*/
6+
export interface SerializableElement {
7+
type: string;
8+
props: Record<string, any>;
9+
children?: SerializableNode[];
10+
}
11+
12+
export type SerializableNode = SerializableElement | string | number | boolean | null | undefined;
13+
14+
/**
15+
* Converts a React element to a serializable format
16+
*/
17+
export function serializeJSX(node: ReactNode): SerializableNode {
18+
// Handle primitives
19+
if (
20+
node === null ||
21+
node === undefined ||
22+
typeof node === 'string' ||
23+
typeof node === 'number' ||
24+
typeof node === 'boolean'
25+
) {
26+
return node;
27+
}
28+
29+
// Handle arrays - flatten them (React handles this the same way)
30+
if (Array.isArray(node)) {
31+
// For arrays, we serialize each child and return the first element if single, or null
32+
// In practice, React fragments are rendered as arrays, so we handle the children individually
33+
const serialized = node.map(serializeJSX).filter((child): child is NonNullable<SerializableNode> =>
34+
child !== null && child !== undefined
35+
);
36+
// If there's only one element, return it directly
37+
if (serialized.length === 1) {
38+
return serialized[0];
39+
}
40+
// For multiple children, wrap in a fragment-like div
41+
if (serialized.length > 1) {
42+
return {
43+
type: 'div',
44+
props: {},
45+
children: serialized,
46+
};
47+
}
48+
return null;
49+
}
50+
51+
// Handle React elements
52+
if (typeof node === 'object' && 'type' in node && 'props' in node) {
53+
const element = node as ReactElement;
54+
const { children, ...restProps } = element.props as { children?: ReactNode; [key: string]: any };
55+
56+
const serialized: SerializableElement = {
57+
type: typeof element.type === 'string' ? element.type : 'div', // Only support HTML elements for now
58+
props: restProps || {},
59+
};
60+
61+
// Serialize children
62+
if (children !== undefined && children !== null) {
63+
const childArray = Array.isArray(children) ? children : [children];
64+
const serializedChildren = childArray
65+
.map(serializeJSX)
66+
.filter((child): child is NonNullable<SerializableNode> => child !== null && child !== undefined);
67+
68+
if (serializedChildren.length > 0) {
69+
serialized.children = serializedChildren;
70+
}
71+
}
72+
73+
return serialized;
74+
}
75+
76+
// Fallback
77+
return null;
78+
}

0 commit comments

Comments
 (0)