react-bun-ssr 0.3.2 → 0.4.1

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.
@@ -0,0 +1,486 @@
1
+ import {
2
+ consumeTransitionChunkText,
3
+ createTransitionChunkParserState,
4
+ flushTransitionChunkText,
5
+ shouldHardNavigateForRedirectDepth,
6
+ } from "./client-transition-core";
7
+ import type { RouteActionStateHandler } from "./action-stub";
8
+ import { markRouteActionStub } from "./action-stub";
9
+ import { isDeferredToken } from "./deferred";
10
+ import type {
11
+ ActionResponseEnvelope,
12
+ RenderPayload,
13
+ RouteErrorResponse,
14
+ TransitionDeferredChunk,
15
+ TransitionDocumentChunk,
16
+ TransitionInitialChunk,
17
+ TransitionRedirectChunk,
18
+ } from "./types";
19
+
20
+ export interface RouteWireNavigationPlan {
21
+ kind: "soft" | "hard";
22
+ location: string;
23
+ replace: boolean;
24
+ }
25
+
26
+ export interface RouteWireActionDataOutcome {
27
+ type: "data";
28
+ status: number;
29
+ data: unknown;
30
+ }
31
+
32
+ export interface RouteWireActionRedirectOutcome {
33
+ type: "redirect";
34
+ status: number;
35
+ navigation: RouteWireNavigationPlan;
36
+ }
37
+
38
+ export interface RouteWireActionCatchOutcome {
39
+ type: "catch";
40
+ status: number;
41
+ error: RouteErrorResponse;
42
+ }
43
+
44
+ export interface RouteWireActionErrorOutcome {
45
+ type: "error";
46
+ status: number;
47
+ message: string;
48
+ }
49
+
50
+ export type RouteWireActionOutcome =
51
+ | RouteWireActionDataOutcome
52
+ | RouteWireActionRedirectOutcome
53
+ | RouteWireActionCatchOutcome
54
+ | RouteWireActionErrorOutcome;
55
+
56
+ export interface RouteWireProtocol {
57
+ submitAction(input: {
58
+ to: string;
59
+ formData: FormData;
60
+ }): Promise<RouteWireActionOutcome>;
61
+
62
+ startTransition(input: {
63
+ to: string | URL;
64
+ onDeferredChunk?: (chunk: TransitionDeferredChunk) => void;
65
+ signal?: AbortSignal;
66
+ }): RouteWireTransitionHandle;
67
+ }
68
+
69
+ export interface RouteWire {
70
+ action<TState = unknown>(target?: string | URL): RouteActionStateHandler<TState>;
71
+ }
72
+
73
+ export interface RouteWireDeps {
74
+ fetchImpl?: typeof fetch;
75
+ getCurrentUrl: () => URL | null;
76
+ hardNavigate?: (location: string) => void;
77
+ softNavigate?: (location: string, options: { replace?: boolean }) => Promise<void>;
78
+ }
79
+
80
+ export interface RouteWireTransitionHandle {
81
+ initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null>;
82
+ donePromise: Promise<void>;
83
+ }
84
+
85
+ export interface RouteWireTransitionRenderOutcome {
86
+ type: "render";
87
+ chunk: TransitionInitialChunk;
88
+ }
89
+
90
+ export interface RouteWireTransitionNavigateOutcome {
91
+ type: "navigate";
92
+ navigation: RouteWireNavigationPlan;
93
+ redirected: boolean;
94
+ redirectDepth: number;
95
+ }
96
+
97
+ export type RouteWireTransitionInitialOutcome =
98
+ | RouteWireTransitionRenderOutcome
99
+ | RouteWireTransitionNavigateOutcome;
100
+
101
+ export interface RouteWireDeferredRuntime {
102
+ get(id: string): Promise<unknown>;
103
+ resolve(id: string, value: unknown): void;
104
+ reject(id: string, message: string): void;
105
+ }
106
+
107
+ function isActionResponseEnvelope(value: unknown): value is ActionResponseEnvelope {
108
+ if (!value || typeof value !== "object") {
109
+ return false;
110
+ }
111
+
112
+ const candidate = value as {
113
+ type?: unknown;
114
+ status?: unknown;
115
+ };
116
+ return typeof candidate.type === "string" && typeof candidate.status === "number";
117
+ }
118
+
119
+ function resolveRequiredCurrentUrl(getCurrentUrl: () => URL | null): URL {
120
+ const currentUrl = getCurrentUrl();
121
+ if (!currentUrl) {
122
+ throw new Error("Route wire protocol requires a current URL.");
123
+ }
124
+ return currentUrl;
125
+ }
126
+
127
+ function resolveActionRedirect(
128
+ location: string,
129
+ currentUrl: URL,
130
+ ): RouteWireActionRedirectOutcome {
131
+ const redirectUrl = new URL(location, currentUrl.toString());
132
+ return {
133
+ type: "redirect",
134
+ status: 302,
135
+ navigation: {
136
+ kind: redirectUrl.origin === currentUrl.origin ? "soft" : "hard",
137
+ location: redirectUrl.toString(),
138
+ replace: true,
139
+ },
140
+ };
141
+ }
142
+
143
+ function createTransitionUrl(
144
+ to: string | URL,
145
+ currentUrl: URL,
146
+ ): URL {
147
+ const transitionUrl = new URL("/__rbssr/transition", currentUrl.origin);
148
+ const toUrl = typeof to === "string"
149
+ ? new URL(to, currentUrl)
150
+ : new URL(to.toString(), currentUrl);
151
+ transitionUrl.searchParams.set("to", toUrl.pathname + toUrl.search + toUrl.hash);
152
+ return transitionUrl;
153
+ }
154
+
155
+ async function defaultSoftNavigate(
156
+ location: string,
157
+ options: { replace?: boolean },
158
+ ): Promise<void> {
159
+ const runtime = await import("./client-runtime");
160
+ await runtime.navigateWithNavigationApiOrFallback(location, options);
161
+ }
162
+
163
+ function resolveActionTarget(target: string | URL | undefined, currentUrl: URL): string {
164
+ if (typeof target === "undefined") {
165
+ return currentUrl.pathname + currentUrl.search + currentUrl.hash;
166
+ }
167
+
168
+ const targetUrl = typeof target === "string"
169
+ ? new URL(target, currentUrl)
170
+ : new URL(target.toString(), currentUrl);
171
+
172
+ return targetUrl.pathname + targetUrl.search + targetUrl.hash;
173
+ }
174
+
175
+ async function executeActionNavigationPlan(
176
+ plan: RouteWireNavigationPlan,
177
+ deps: RouteWireDeps,
178
+ ): Promise<void> {
179
+ if (plan.kind === "hard") {
180
+ deps.hardNavigate?.(plan.location);
181
+ return;
182
+ }
183
+
184
+ try {
185
+ if (deps.softNavigate) {
186
+ await deps.softNavigate(plan.location, { replace: plan.replace });
187
+ return;
188
+ }
189
+
190
+ await defaultSoftNavigate(plan.location, { replace: plan.replace });
191
+ } catch {
192
+ deps.hardNavigate?.(plan.location);
193
+ }
194
+ }
195
+
196
+ export function reviveRouteWirePayload(
197
+ payload: RenderPayload,
198
+ runtime?: Pick<RouteWireDeferredRuntime, "get">,
199
+ ): RenderPayload {
200
+ const sourceData = payload.loaderData;
201
+ if (!sourceData || Array.isArray(sourceData) || typeof sourceData !== "object") {
202
+ return payload;
203
+ }
204
+
205
+ if (!runtime) {
206
+ return payload;
207
+ }
208
+
209
+ const revivedData = { ...(sourceData as Record<string, unknown>) };
210
+ for (const [key, value] of Object.entries(revivedData)) {
211
+ if (!isDeferredToken(value)) {
212
+ continue;
213
+ }
214
+
215
+ revivedData[key] = runtime.get(value.__rbssrDeferred);
216
+ }
217
+
218
+ return {
219
+ ...payload,
220
+ loaderData: revivedData,
221
+ };
222
+ }
223
+
224
+ export function applyRouteWireDeferredChunk(
225
+ chunk: TransitionDeferredChunk,
226
+ runtime?: Pick<RouteWireDeferredRuntime, "resolve" | "reject">,
227
+ ): void {
228
+ if (!runtime) {
229
+ return;
230
+ }
231
+
232
+ if (chunk.ok) {
233
+ runtime.resolve(chunk.id, chunk.value);
234
+ return;
235
+ }
236
+
237
+ runtime.reject(chunk.id, chunk.error ?? "Deferred value rejected");
238
+ }
239
+
240
+ export function resolveRouteWireTransitionInitial(
241
+ chunk: TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk,
242
+ options: {
243
+ currentUrl: URL;
244
+ redirectDepth?: number;
245
+ maxRedirectDepth?: number;
246
+ },
247
+ ): RouteWireTransitionInitialOutcome {
248
+ if (chunk.type === "initial") {
249
+ return {
250
+ type: "render",
251
+ chunk,
252
+ };
253
+ }
254
+
255
+ if (chunk.type === "document") {
256
+ return {
257
+ type: "navigate",
258
+ navigation: {
259
+ kind: "hard",
260
+ location: new URL(chunk.location, options.currentUrl).toString(),
261
+ replace: true,
262
+ },
263
+ redirected: false,
264
+ redirectDepth: options.redirectDepth ?? 0,
265
+ };
266
+ }
267
+
268
+ const redirectUrl = new URL(chunk.location, options.currentUrl);
269
+ const redirectDepth = (options.redirectDepth ?? 0) + 1;
270
+ const hardNavigate = (
271
+ redirectUrl.origin !== options.currentUrl.origin
272
+ || shouldHardNavigateForRedirectDepth(redirectDepth, options.maxRedirectDepth)
273
+ );
274
+
275
+ return {
276
+ type: "navigate",
277
+ navigation: {
278
+ kind: hardNavigate ? "hard" : "soft",
279
+ location: redirectUrl.toString(),
280
+ replace: true,
281
+ },
282
+ redirected: true,
283
+ redirectDepth,
284
+ };
285
+ }
286
+
287
+ export async function completeRouteWireTransition<TResult>(
288
+ chunk: TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk,
289
+ options: {
290
+ currentUrl: URL;
291
+ redirectDepth?: number;
292
+ maxRedirectDepth?: number;
293
+ render: (chunk: TransitionInitialChunk) => Promise<TResult>;
294
+ softNavigate: (
295
+ location: string,
296
+ info: {
297
+ replace: boolean;
298
+ redirected: boolean;
299
+ redirectDepth: number;
300
+ },
301
+ ) => Promise<TResult | null>;
302
+ hardNavigate: (location: string) => void;
303
+ },
304
+ ): Promise<TResult | null> {
305
+ const outcome = resolveRouteWireTransitionInitial(chunk, {
306
+ currentUrl: options.currentUrl,
307
+ redirectDepth: options.redirectDepth,
308
+ maxRedirectDepth: options.maxRedirectDepth,
309
+ });
310
+
311
+ if (outcome.type === "render") {
312
+ return options.render(outcome.chunk);
313
+ }
314
+
315
+ if (outcome.navigation.kind === "hard") {
316
+ options.hardNavigate(outcome.navigation.location);
317
+ return null;
318
+ }
319
+
320
+ return options.softNavigate(outcome.navigation.location, {
321
+ replace: outcome.navigation.replace,
322
+ redirected: outcome.redirected,
323
+ redirectDepth: outcome.redirectDepth,
324
+ });
325
+ }
326
+
327
+ export function createRouteWireProtocol(deps: Pick<RouteWireDeps, "fetchImpl" | "getCurrentUrl">): RouteWireProtocol {
328
+ return {
329
+ async submitAction(input) {
330
+ const currentUrl = resolveRequiredCurrentUrl(deps.getCurrentUrl);
331
+ const endpoint = new URL("/__rbssr/action", currentUrl.origin);
332
+ endpoint.searchParams.set("to", input.to);
333
+
334
+ const response = await (deps.fetchImpl ?? fetch)(endpoint.toString(), {
335
+ method: "POST",
336
+ body: input.formData,
337
+ credentials: "same-origin",
338
+ headers: {
339
+ accept: "application/json",
340
+ },
341
+ });
342
+
343
+ let payload: unknown;
344
+ try {
345
+ payload = await response.json();
346
+ } catch {
347
+ throw new Error("Action endpoint returned a non-JSON response.");
348
+ }
349
+
350
+ if (!isActionResponseEnvelope(payload)) {
351
+ throw new Error("Action endpoint returned an invalid envelope.");
352
+ }
353
+
354
+ if (payload.type === "data") {
355
+ return payload;
356
+ }
357
+
358
+ if (payload.type === "redirect") {
359
+ return {
360
+ ...resolveActionRedirect(payload.location, currentUrl),
361
+ status: payload.status,
362
+ };
363
+ }
364
+
365
+ if (payload.type === "catch") {
366
+ return payload;
367
+ }
368
+
369
+ return payload;
370
+ },
371
+
372
+ startTransition(input) {
373
+ let resolveInitial: (
374
+ value: TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null,
375
+ ) => void = () => undefined;
376
+ let rejectInitial: (reason?: unknown) => void = () => undefined;
377
+
378
+ const initialPromise = new Promise<
379
+ TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null
380
+ >((resolve, reject) => {
381
+ resolveInitial = resolve;
382
+ rejectInitial = reject;
383
+ });
384
+
385
+ const donePromise = (async () => {
386
+ const currentUrl = resolveRequiredCurrentUrl(deps.getCurrentUrl);
387
+ const endpoint = createTransitionUrl(input.to, currentUrl);
388
+ const response = await (deps.fetchImpl ?? fetch)(endpoint.toString(), {
389
+ method: "GET",
390
+ credentials: "same-origin",
391
+ signal: input.signal,
392
+ });
393
+
394
+ if (!response.ok || !response.body) {
395
+ throw new Error(`Transition request failed with status ${response.status}`);
396
+ }
397
+
398
+ const reader = response.body.getReader();
399
+ const decoder = new TextDecoder();
400
+ let parserState = createTransitionChunkParserState();
401
+
402
+ while (true) {
403
+ const { done, value } = await reader.read();
404
+ if (done) {
405
+ break;
406
+ }
407
+
408
+ const previousInitialChunk = parserState.initialChunk;
409
+ const previousDeferredCount = parserState.deferredChunks.length;
410
+ parserState = consumeTransitionChunkText(
411
+ parserState,
412
+ decoder.decode(value, { stream: true }),
413
+ );
414
+
415
+ if (!previousInitialChunk && parserState.initialChunk) {
416
+ resolveInitial(parserState.initialChunk);
417
+ }
418
+
419
+ for (const chunk of parserState.deferredChunks.slice(previousDeferredCount)) {
420
+ input.onDeferredChunk?.(chunk);
421
+ }
422
+ }
423
+
424
+ const previousInitialChunk = parserState.initialChunk;
425
+ const previousDeferredCount = parserState.deferredChunks.length;
426
+ parserState = flushTransitionChunkText(parserState);
427
+
428
+ if (!previousInitialChunk && parserState.initialChunk) {
429
+ resolveInitial(parserState.initialChunk);
430
+ }
431
+
432
+ for (const chunk of parserState.deferredChunks.slice(previousDeferredCount)) {
433
+ input.onDeferredChunk?.(chunk);
434
+ }
435
+
436
+ if (!parserState.initialChunk) {
437
+ resolveInitial(null);
438
+ }
439
+ })();
440
+
441
+ donePromise.catch(error => {
442
+ rejectInitial(error);
443
+ });
444
+
445
+ return {
446
+ initialPromise,
447
+ donePromise,
448
+ };
449
+ },
450
+ };
451
+ }
452
+
453
+ export function createRouteWire(deps: RouteWireDeps): RouteWire {
454
+ const protocol = createRouteWireProtocol(deps);
455
+
456
+ return {
457
+ action<TState = unknown>(target?: string | URL): RouteActionStateHandler<TState> {
458
+ return markRouteActionStub(async (previousState: TState, formData: FormData) => {
459
+ const currentUrl = deps.getCurrentUrl();
460
+ if (!currentUrl) {
461
+ return previousState;
462
+ }
463
+
464
+ const outcome = await protocol.submitAction({
465
+ to: resolveActionTarget(target, currentUrl),
466
+ formData,
467
+ });
468
+
469
+ if (outcome.type === "data") {
470
+ return outcome.data as TState;
471
+ }
472
+
473
+ if (outcome.type === "redirect") {
474
+ await executeActionNavigationPlan(outcome.navigation, deps);
475
+ return previousState;
476
+ }
477
+
478
+ if (outcome.type === "catch") {
479
+ throw outcome.error;
480
+ }
481
+
482
+ throw new Error(outcome.message);
483
+ });
484
+ },
485
+ };
486
+ }