Skip to content

Commit f0783af

Browse files
committed
feat: add @lazarv/rsc package and migrate RSC serialization to bundler-agnostic API
1 parent 61b3f3a commit f0783af

45 files changed

Lines changed: 25185 additions & 347 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
4949
test-dev-base:
5050
needs: changed
51-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
51+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
5252
timeout-minutes: 30
5353
runs-on: ${{ matrix.os }}
5454
strategy:
@@ -81,7 +81,7 @@ jobs:
8181

8282
test-build-start-base:
8383
needs: changed
84-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
84+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
8585
timeout-minutes: 30
8686
runs-on: ${{ matrix.os }}
8787
strategy:
@@ -114,7 +114,7 @@ jobs:
114114

115115
test-build-start-base-edge:
116116
needs: changed
117-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
117+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
118118
timeout-minutes: 30
119119
runs-on: ${{ matrix.os }}
120120
strategy:
@@ -149,7 +149,7 @@ jobs:
149149

150150
test-build-start-base-edge-entry:
151151
needs: changed
152-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
152+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
153153
timeout-minutes: 30
154154
runs-on: ${{ matrix.os }}
155155
strategy:
@@ -184,7 +184,7 @@ jobs:
184184

185185
test-dev-apps:
186186
needs: changed
187-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
187+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
188188
timeout-minutes: 30
189189
runs-on: ${{ matrix.os }}
190190
strategy:
@@ -217,7 +217,7 @@ jobs:
217217

218218
test-build-start-apps:
219219
needs: changed
220-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
220+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
221221
timeout-minutes: 30
222222
runs-on: ${{ matrix.os }}
223223
strategy:
@@ -250,7 +250,7 @@ jobs:
250250

251251
test-build-start-apps-edge:
252252
needs: changed
253-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
253+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
254254
timeout-minutes: 30
255255
runs-on: ${{ matrix.os }}
256256
strategy:
@@ -285,7 +285,7 @@ jobs:
285285

286286
test-build-start-apps-edge-entry:
287287
needs: changed
288-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
288+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, 'test/') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
289289
timeout-minutes: 30
290290
runs-on: ${{ matrix.os }}
291291
strategy:
@@ -318,6 +318,43 @@ jobs:
318318
env:
319319
EDGE_ENTRY: "1"
320320

321+
test-rsc:
322+
needs: changed
323+
if: contains(needs.changed.outputs.all_changed_files, 'packages/rsc') || contains(needs.changed.outputs.all_changed_files, '.github/workflows/ci.yml')
324+
timeout-minutes: 10
325+
runs-on: ${{ matrix.os }}
326+
strategy:
327+
matrix:
328+
os: [ubuntu-latest]
329+
node_version: [20, 22, 24]
330+
include:
331+
- os: macos-latest
332+
node_version: 24
333+
- os: windows-latest
334+
node_version: 24
335+
fail-fast: false
336+
337+
name: "Test rsc 🧪 node.js v${{ matrix.node_version }} on ${{ matrix.os }}"
338+
steps:
339+
- name: Checkout
340+
uses: actions/checkout@v4
341+
342+
- uses: ./.github/workflows/actions/common-setup
343+
with:
344+
node_version: ${{ matrix.node_version }}
345+
346+
- name: Test @lazarv/rsc with coverage
347+
working-directory: ./packages/rsc
348+
run: pnpm test:coverage
349+
350+
- name: Upload coverage report
351+
if: always() && matrix.os == 'ubuntu-latest' && matrix.node_version == 24
352+
uses: actions/upload-artifact@v4
353+
with:
354+
name: rsc-coverage-report
355+
path: packages/rsc/coverage/
356+
retention-days: 30
357+
321358
lint:
322359
timeout-minutes: 10
323360
runs-on: ubuntu-latest

.github/workflows/publish-commit.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,4 @@ jobs:
9696
run: pnpm install
9797

9898
- name: Publish packages
99-
run: pnpm dlx pkg-pr-new@0.0 publish --compact --pnpm ./packages/react-server ./packages/create-react-server
99+
run: pnpm dlx pkg-pr-new@0.0 publish --compact --pnpm ./packages/rsc ./packages/react-server ./packages/create-react-server

.github/workflows/release-experimental.yml

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,37 @@ jobs:
6666
VERSION="0.0.0-experimental-$(git rev-parse --short HEAD)-$(date +'%Y%m%d')-$(openssl rand -hex 4)"
6767
echo "VERSION=$VERSION" >> $GITHUB_ENV
6868
69+
- name: Prepare @lazarv/rsc
70+
id: prepare-rsc
71+
if: contains(needs.changed.outputs.all_changed_files, 'packages/rsc')
72+
working-directory: ./packages/rsc
73+
run: |
74+
jq --arg new_version "${{ env.VERSION }}" '.version = $new_version' package.json > tmp.json && mv tmp.json package.json
75+
76+
- name: Publish @lazarv/rsc
77+
id: publish-rsc
78+
if: steps.prepare-rsc.outcome == 'success'
79+
working-directory: ./packages/rsc
80+
run: pnpm publish --provenance --access=public --tag=latest
81+
82+
- name: Update @lazarv/react-server dependency on @lazarv/rsc
83+
if: steps.publish-rsc.outcome == 'success'
84+
working-directory: ./packages/react-server
85+
run: |
86+
jq --arg new_version "${{ env.VERSION }}" '.dependencies["@lazarv/rsc"] = $new_version' package.json > tmp.json && mv tmp.json package.json
87+
88+
- name: Get latest @lazarv/rsc version for @lazarv/react-server
89+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') && steps.publish-rsc.outcome != 'success'
90+
working-directory: ./packages/react-server
91+
run: |
92+
RSC_VERSION=$(npm view @lazarv/rsc version 2>/dev/null || echo "")
93+
if [ -n "$RSC_VERSION" ]; then
94+
jq --arg new_version "$RSC_VERSION" '.dependencies["@lazarv/rsc"] = $new_version' package.json > tmp.json && mv tmp.json package.json
95+
fi
96+
6997
- name: Prepare @lazarv/react-server
7098
id: prepare-react-server
71-
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server')
99+
if: contains(needs.changed.outputs.all_changed_files, 'packages/react-server') || steps.publish-rsc.outcome == 'success'
72100
working-directory: ./packages/react-server
73101
run: |
74102
jq --arg new_version "${{ env.VERSION }}" '.version = $new_version' package.json > tmp.json && mv tmp.json package.json
@@ -80,7 +108,7 @@ jobs:
80108
run: pnpm publish --provenance --access=public --tag=latest
81109

82110
- name: Create release
83-
if: steps.publish-react-server.outcome == 'success'
111+
if: steps.publish-react-server.outcome == 'success' || steps.publish-rsc.outcome == 'success'
84112
env:
85113
GH_TOKEN: ${{ github.token }}
86114
run: |

packages/react-server/cache/client.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export async function useCache(
2424
cacheDrivers.set(provider.name, provider.driver);
2525
cacheInstances.set(
2626
provider.name,
27-
new StorageCache(provider.driver, provider.options)
27+
new StorageCache(provider.driver, provider.options, provider.serializer)
2828
);
2929
}
3030
const cache = cacheInstances.get(provider.name);
Lines changed: 28 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
1-
import { createFromReadableStream } from "react-server-dom-webpack/client.edge";
2-
import { renderToReadableStream } from "react-server-dom-webpack/server.edge";
1+
import { createFromReadableStream } from "@lazarv/rsc/client";
2+
import { renderToReadableStream } from "@lazarv/rsc/server";
33

44
import { concat, copyBytesFrom } from "../lib/sys.mjs";
55

6-
export function toBuffer(model, options = {}) {
7-
return new Promise(async (resolve, reject) => {
6+
async function getModuleResolver() {
7+
try {
88
const { clientReferenceMap } =
99
await import("@lazarv/react-server/dist/server/client-reference-map");
1010
const map = clientReferenceMap();
11-
const stream = renderToReadableStream(model, map, {
11+
return {
12+
resolveClientReference(value) {
13+
return map[value.$$id];
14+
},
15+
};
16+
} catch {
17+
// Browser environment — no client reference map available
18+
return undefined;
19+
}
20+
}
21+
22+
export function toBuffer(model, options = {}) {
23+
return new Promise(async (resolve, reject) => {
24+
const moduleResolver = await getModuleResolver();
25+
const stream = renderToReadableStream(model, {
1226
...options,
27+
...(moduleResolver ? { moduleResolver } : {}),
1328
onError(error) {
1429
reject(error);
1530
},
@@ -25,82 +40,26 @@ export function toBuffer(model, options = {}) {
2540
}
2641

2742
export async function toStream(model, options = {}) {
28-
const { clientReferenceMap } =
29-
await import("@lazarv/react-server/dist/server/client-reference-map");
30-
const map = clientReferenceMap();
31-
return renderToReadableStream(model, map, options);
32-
}
33-
34-
function createManifest() {
35-
return {
36-
serverConsumerManifest: {
37-
serverModuleMap: new Proxy(
38-
{},
39-
{
40-
get(target, prop) {
41-
if (!target[prop]) {
42-
const [id, name] = prop.split("#");
43-
target[prop] = {
44-
id: `react-server-reference:${id}#${name}`,
45-
name,
46-
chunks: [],
47-
};
48-
}
49-
return target[prop];
50-
},
51-
}
52-
),
53-
moduleMap: new Proxy(
54-
{},
55-
{
56-
get(target, id) {
57-
if (!target[id]) {
58-
target[id] = new Proxy(
59-
{},
60-
{
61-
get(target, name) {
62-
if (!target[name]) {
63-
target[name] = {
64-
id: `react-client-reference:${id}::${name}`,
65-
name,
66-
chunks: [],
67-
async: true,
68-
};
69-
}
70-
return target[name];
71-
},
72-
}
73-
);
74-
}
75-
return target[id];
76-
},
77-
}
78-
),
79-
},
80-
};
43+
const moduleResolver = await getModuleResolver();
44+
return renderToReadableStream(model, {
45+
...options,
46+
...(moduleResolver ? { moduleResolver } : {}),
47+
});
8148
}
8249

8350
export function fromBuffer(payload, options = {}) {
84-
const Component = createFromReadableStream(
51+
return createFromReadableStream(
8552
new ReadableStream({
8653
type: "bytes",
8754
start(controller) {
8855
controller.enqueue(new Uint8Array(payload));
8956
controller.close();
9057
},
9158
}),
92-
{
93-
...createManifest(),
94-
...options,
95-
}
59+
options
9660
);
97-
98-
return Component;
9961
}
10062

10163
export function fromStream(stream, options = {}) {
102-
return createFromReadableStream(stream, {
103-
...createManifest(),
104-
...options,
105-
});
64+
return createFromReadableStream(stream, options);
10665
}

packages/react-server/cache/storage-cache.mjs

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,50 @@ import { createStorage } from "unstorage";
66

77
import { CACHE_MISS } from "../server/symbols.mjs";
88

9+
const textEncoder = new TextEncoder();
10+
const textDecoder = new TextDecoder();
11+
12+
function encodeBytes(bytes, encoding = "base64") {
13+
if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
14+
return Buffer.from(bytes).toString(encoding);
15+
}
16+
if (encoding === "base64") {
17+
let binary = "";
18+
for (let i = 0; i < bytes.byteLength; i++) {
19+
binary += String.fromCharCode(bytes[i]);
20+
}
21+
return btoa(binary);
22+
}
23+
if (encoding === "hex") {
24+
return Array.from(bytes)
25+
.map((b) => b.toString(16).padStart(2, "0"))
26+
.join("");
27+
}
28+
return textDecoder.decode(bytes);
29+
}
30+
31+
function decodeBytes(str, encoding = "base64") {
32+
if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
33+
return new Uint8Array(Buffer.from(str, encoding));
34+
}
35+
if (encoding === "base64") {
36+
const binary = atob(str);
37+
const bytes = new Uint8Array(binary.length);
38+
for (let i = 0; i < binary.length; i++) {
39+
bytes[i] = binary.charCodeAt(i);
40+
}
41+
return bytes;
42+
}
43+
if (encoding === "hex") {
44+
const bytes = new Uint8Array(str.length / 2);
45+
for (let i = 0; i < str.length; i += 2) {
46+
bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);
47+
}
48+
return bytes;
49+
}
50+
return textEncoder.encode(str);
51+
}
52+
953
export default class StorageCache {
1054
constructor(storageDriver, options, serializer) {
1155
this.index = new Map();
@@ -100,9 +144,10 @@ export default class StorageCache {
100144

101145
const timestamp = Date.now();
102146
const [type, encoding] = this.type?.split(";")?.map((s) => s.trim()) ?? [];
147+
const resolvedEncoding = encoding ?? this.encoding ?? "base64";
103148
const data =
104149
type === "rsc" && this.serializer
105-
? `data:text/x-component;${encoding ?? this.encoding ?? "base64"},${Buffer.from(await this.serializer.toBuffer(value)).toString(encoding ?? this.encoding ?? "base64")}`
150+
? `data:text/x-component;${resolvedEncoding},${encodeBytes(new Uint8Array(await this.serializer.toBuffer(value)), resolvedEncoding)}`
106151
: await value;
107152
const payload = {
108153
data,
@@ -210,14 +255,12 @@ export default class StorageCache {
210255

211256
async deserializeValue(data) {
212257
const [type, encoding] = this.type?.split(";")?.map((s) => s.trim()) ?? [];
258+
const resolvedEncoding = encoding ?? this.encoding ?? "base64";
213259
return type === "rsc" && this.serializer
214260
? await this.serializer.fromBuffer(
215-
Buffer.from(
216-
data.replace(
217-
`data:text/x-component;${encoding ?? this.encoding ?? "base64"},`,
218-
""
219-
),
220-
encoding ?? this.encoding ?? "base64"
261+
decodeBytes(
262+
data.replace(`data:text/x-component;${resolvedEncoding},`, ""),
263+
resolvedEncoding
221264
)
222265
)
223266
: data;

0 commit comments

Comments
 (0)