@octanejs/tanstack-router 0.1.60 → 0.2.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.
@@ -1,38 +1,26 @@
1
1
  import { renderToReadableStream } from 'octane/server';
2
- import { prerender } from 'octane/static';
3
2
  import { isbot } from 'isbot';
4
- import { createSsrStreamResponse } from '@tanstack/router-core/ssr/server';
5
- import { finalizeBufferedHtml } from './renderRouterToString';
3
+ import {
4
+ createSsrStreamResponse,
5
+ getSsrStatus,
6
+ transformReadableStreamWithRouter,
7
+ waitForRequest,
8
+ } from '@tanstack/router-core/ssr/server';
6
9
  import type { ComponentBody } from 'octane';
7
- import type { StreamInjectionSource } from 'octane/server';
8
10
  import type { AnyRouter } from '@tanstack/router-core';
9
11
 
10
12
  type RouterApp = ComponentBody<{ router: AnyRouter }>;
11
13
  type ServerComponent = Parameters<typeof renderToReadableStream>[0];
12
14
 
13
- // The router's data stream is merged through octane's native
14
- // `StreamOptions.injection` (octane >= 0.1.11) instead of router-core's
15
- // `transformStreamWithRouter` text transform. Octane emits tag-complete
16
- // chunks and owns the document tail, so the byte-level re-parse
17
- // (closing-tag scans, leftover buffers, the held-`</body>` tail that also
18
- // buffered every post-shell suspense segment until stream end) is
19
- // unnecessary — boundary segments stream out of order for document renders,
20
- // and the transform's 64 KiB tail cap on segment volume disappears.
21
- // Octane's document mode also emits `<!DOCTYPE html>` and folds the leading
22
- // renderer-owned styles into `<head>`, replacing the `prependDoctype` and
23
- // `relocateLeadingOctaneStylesToHead` transforms this file previously piped
24
- // the stream through.
25
- //
26
- // The serialization timeout is preserved: it arms when octane reports the
27
- // render finished (`renderComplete`) and fails the stream if serialization
28
- // never completes. The script barrier lifts when octane subscribes — octane
29
- // only subscribes after the shell (which carries the barrier anchor) is on
30
- // the wire, matching the transform's lift-after-marker-flush.
31
- // (`setRenderFinished` lifts it as a backstop regardless, exactly as
32
- // before.)
33
-
34
- const SERIALIZATION_TIMEOUT_MS = 60_000;
35
-
15
+ // router-core 1.171.34 redesigned SSR serialization: the previous
16
+ // `ServerSsr` buffered-HTML / script-barrier pull API (which octane bridged to
17
+ // its native `StreamOptions.injection`) is gone. Octane now renders a plain HTML
18
+ // stream and pipes it through router-core's `transformReadableStreamWithRouter`,
19
+ // which injects the dehydrated router scripts at the `<Scripts>` boundary and
20
+ // prepends `<!DOCTYPE html>`; `createSsrStreamResponse` owns the request-lifetime
21
+ // teardown. Dehydration already ran in start-server-core's request handler. Bots
22
+ // wait for every suspense boundary (`stream.allReady`) before the response is
23
+ // produced so crawlers receive the fully-resolved document.
36
24
  export async function renderRouterToStream({
37
25
  request,
38
26
  router,
@@ -44,147 +32,129 @@ export async function renderRouterToStream({
44
32
  responseHeaders: Headers;
45
33
  App: RouterApp;
46
34
  }) {
47
- if (isbot(request.headers.get('User-Agent'))) {
48
- return renderRouterForBot({ request, router, responseHeaders, App });
49
- }
50
-
51
- const serverSsr = router.serverSsr;
52
- if (!serverSsr) {
53
- throw new Error('Invariant failed: router.serverSsr is required');
35
+ const signal = request.signal;
36
+ if (signal.aborted) {
37
+ router.serverSsr?.cleanup();
38
+ throw signal.reason;
54
39
  }
55
40
 
56
- const renderController = new AbortController();
57
- const onRequestAbort = () => renderController.abort(request.signal.reason);
58
- if (request.signal.aborted) {
59
- onRequestAbort();
60
- } else {
61
- request.signal.addEventListener('abort', onRequestAbort, { once: true });
62
- serverSsr.onCleanup(() => {
63
- request.signal.removeEventListener('abort', onRequestAbort);
64
- });
65
- }
66
-
67
- let serializationTimeout: ReturnType<typeof setTimeout> | undefined;
68
- let stopSerializationListener: (() => void) | undefined;
69
- let settleDone!: () => void;
70
- let failDone!: (reason: unknown) => void;
71
- const done = new Promise<void>((resolve, reject) => {
72
- settleDone = resolve;
73
- failDone = reject;
74
- });
75
- if (serverSsr.isSerializationFinished()) {
76
- settleDone();
77
- } else {
78
- stopSerializationListener = serverSsr.onSerializationFinished(() => settleDone());
79
- }
80
- const releaseInjection = () => {
81
- if (serializationTimeout !== undefined) {
82
- clearTimeout(serializationTimeout);
83
- serializationTimeout = undefined;
84
- }
85
- stopSerializationListener?.();
86
- stopSerializationListener = undefined;
87
- };
88
-
89
- const injection: StreamInjectionSource = {
90
- take: () => serverSsr.takeBufferedHtml() ?? '',
91
- subscribe(notify) {
92
- serverSsr.liftScriptBarrier();
93
- return serverSsr.onInjectedHtml(notify);
94
- },
95
- done,
96
- renderComplete() {
97
- serverSsr.setRenderFinished();
98
- if (!serverSsr.isSerializationFinished() && serializationTimeout === undefined) {
99
- serializationTimeout = setTimeout(() => {
100
- failDone(new Error('Serialization timeout after app render finished'));
101
- }, SERIALIZATION_TIMEOUT_MS);
102
- }
103
- },
104
- };
41
+ let rendererTeardown = false;
42
+ const bot = isbot(request.headers.get('User-Agent'));
105
43
 
106
44
  try {
107
45
  const stream = await renderToReadableStream(
108
46
  App as unknown as ServerComponent,
109
47
  { router },
110
48
  {
111
- signal: renderController.signal,
49
+ signal,
112
50
  nonce: router.options.ssr?.nonce,
113
- injection,
114
- onError(error) {
115
- if (!isAbortError(request, error)) {
51
+ onError(error: unknown) {
52
+ if (!rendererTeardown && !signal.aborted && !isAbortError(request, error)) {
116
53
  console.error('Error in renderToReadableStream:', error);
117
54
  }
118
55
  },
119
56
  },
120
57
  );
121
58
 
122
- // The renderer's stream is the response body verbatim. `allReady` settles
123
- // in every terminal state (close, abort, fatal, consumer cancel) — the
124
- // single place to release the injection wiring and the router's SSR state.
125
- stream.allReady.then(
126
- () => {
127
- releaseInjection();
128
- serverSsr.cleanup();
129
- },
130
- () => {
131
- releaseInjection();
132
- serverSsr.cleanup();
59
+ const rendererAbort = bot ? new AbortController() : undefined;
60
+ const responseStream = transformReadableStreamWithRouter(
61
+ router,
62
+ finalizeDocumentShell(stream as unknown as ReadableStream<Uint8Array>),
63
+ {
64
+ rendererSafePoint: 'script-close',
65
+ signal,
66
+ onAbort: (reason: unknown) => {
67
+ rendererTeardown = true;
68
+ rendererAbort?.abort(reason);
69
+ },
133
70
  },
134
71
  );
135
72
 
73
+ if (rendererAbort) {
74
+ await waitForRequest(stream.allReady, rendererAbort.signal);
75
+ }
76
+
136
77
  return createSsrStreamResponse(
137
78
  router,
138
- new Response(stream as unknown as BodyInit, {
139
- status: router.stores.statusCode.get(),
79
+ new Response(responseStream, {
80
+ status: getSsrStatus(router),
140
81
  headers: responseHeaders,
141
82
  }),
142
83
  );
143
84
  } catch (error) {
144
- renderController.abort(error);
145
- releaseInjection();
146
85
  router.serverSsr?.cleanup();
147
86
  throw error;
148
87
  }
149
88
  }
150
89
 
151
- async function renderRouterForBot({
152
- request,
153
- router,
154
- responseHeaders,
155
- App,
156
- }: {
157
- request: Request;
158
- router: AnyRouter;
159
- responseHeaders: Headers;
160
- App: RouterApp;
161
- }) {
162
- try {
163
- const result = await prerender(
164
- App as unknown as Parameters<typeof prerender>[0],
165
- { router },
166
- {
167
- signal: request.signal,
168
- nonce: router.options.ssr?.nonce,
169
- onError(error) {
170
- if (!isAbortError(request, error)) {
171
- console.error('Error in prerender:', error);
90
+ // Finalize octane's streamed document shell for the router-core stream path.
91
+ // octane (without its former native injection) emits the deduped scoped-style
92
+ // tags (`<style data-octane=…>`) ahead of the document shell (before `<html>`)
93
+ // and no `<!DOCTYPE html>`, and router-core's *stream* transform injects router
94
+ // scripts but neither moves those styles nor adds the doctype (only its string
95
+ // transform does). So, for a document render, fold the leading style run into the
96
+ // shell `<head>` and prepend the doctype — what octane's native-injection
97
+ // document mode (and, before it, an explicit relocate transform) used to do. Only
98
+ // the shell prefix (through `</head>`) is buffered; everything after streams
99
+ // straight through, preserving out-of-order boundary flushing. A non-document
100
+ // render (no `</head>`) passes through unchanged.
101
+ export function finalizeDocumentShell(
102
+ source: ReadableStream<Uint8Array>,
103
+ ): ReadableStream<Uint8Array> {
104
+ const reader = source.getReader();
105
+ const decoder = new TextDecoder();
106
+ const encoder = new TextEncoder();
107
+ let prefix = '';
108
+ let finalized = false;
109
+ return new ReadableStream<Uint8Array>({
110
+ async pull(controller) {
111
+ if (finalized) {
112
+ const { value, done } = await reader.read();
113
+ // Keep decoding through the same streaming decoder rather than passing
114
+ // raw bytes: the decoder may hold the leading bytes of a multi-byte
115
+ // character split across the chunk that contained `</head>`, and raw
116
+ // passthrough would drop them and corrupt the rest of the stream.
117
+ if (done) {
118
+ const tail = decoder.decode();
119
+ if (tail) controller.enqueue(encoder.encode(tail));
120
+ controller.close();
121
+ } else {
122
+ controller.enqueue(encoder.encode(decoder.decode(value, { stream: true })));
123
+ }
124
+ return;
125
+ }
126
+ for (;;) {
127
+ const { value, done } = await reader.read();
128
+ if (value) prefix += decoder.decode(value, { stream: true });
129
+ if (prefix.includes('</head>')) {
130
+ const htmlStart = prefix.search(/<html[\s/>]/i);
131
+ if (htmlStart >= 0) {
132
+ if (htmlStart > 0) {
133
+ const leading = prefix.slice(0, htmlStart);
134
+ const doc = prefix.slice(htmlStart);
135
+ const headClose = doc.indexOf('</head>');
136
+ prefix = doc.slice(0, headClose) + leading + doc.slice(headClose);
137
+ }
138
+ if (!/^\s*<!doctype/i.test(prefix)) prefix = '<!DOCTYPE html>' + prefix;
172
139
  }
173
- },
174
- },
175
- );
176
- router.serverSsr!.setRenderFinished();
177
-
178
- return new Response(
179
- finalizeBufferedHtml(result.html, result.css, router.serverSsr!.takeBufferedHtml()),
180
- {
181
- status: router.stores.statusCode.get(),
182
- headers: responseHeaders,
183
- },
184
- );
185
- } finally {
186
- router.serverSsr?.cleanup();
187
- }
140
+ controller.enqueue(encoder.encode(prefix));
141
+ prefix = '';
142
+ finalized = true;
143
+ return;
144
+ }
145
+ if (done) {
146
+ if (prefix) controller.enqueue(encoder.encode(prefix));
147
+ prefix = '';
148
+ finalized = true;
149
+ controller.close();
150
+ return;
151
+ }
152
+ }
153
+ },
154
+ cancel(reason) {
155
+ return reader.cancel(reason);
156
+ },
157
+ });
188
158
  }
189
159
 
190
160
  function isAbortError(request: Request, error: unknown) {
@@ -1,11 +1,17 @@
1
1
  import { renderToString as octaneRenderToString } from 'octane/server';
2
+ import { renderSsrHtmlResponse } from '@tanstack/router-core/ssr/server';
2
3
  import type { ComponentBody } from 'octane';
3
4
  import type { AnyRouter } from '@tanstack/router-core';
4
5
 
5
6
  type RouterApp = ComponentBody<{ router: AnyRouter }>;
6
7
  type ServerComponent = Parameters<typeof octaneRenderToString>[0];
7
8
 
8
- // eslint-disable-next-line @typescript-eslint/require-await -- framework render handlers share an async contract
9
+ // router-core 1.171.34 owns HTML serialization: `renderSsrHtmlResponse` runs the
10
+ // framework render, pipes the result through `transformHtmlStringWithRouter`
11
+ // (which prepends `<!DOCTYPE html>` and injects the dehydrated router scripts at
12
+ // the `<Scripts>` boundary), derives the HTTP status, and cleans up `serverSsr`.
13
+ // Dehydration already ran in start-server-core's request handler. Octane only
14
+ // supplies the rendered document HTML with its scoped CSS folded into `<head>`.
9
15
  export async function renderRouterToString({
10
16
  router,
11
17
  responseHeaders,
@@ -15,44 +21,26 @@ export async function renderRouterToString({
15
21
  responseHeaders: Headers;
16
22
  App: RouterApp;
17
23
  }) {
18
- try {
19
- const result = octaneRenderToString(
20
- App as unknown as ServerComponent,
21
- { router },
22
- { nonce: router.options.ssr?.nonce },
23
- );
24
- router.serverSsr!.setRenderFinished();
25
-
26
- return new Response(
27
- finalizeBufferedHtml(result.html, result.css, router.serverSsr!.takeBufferedHtml()),
28
- {
29
- status: router.stores.statusCode.get(),
30
- headers: responseHeaders,
31
- },
32
- );
33
- } catch (error) {
34
- console.error('Render to string error:', error);
35
- return new Response('Internal Server Error', {
36
- status: 500,
37
- headers: responseHeaders,
38
- });
39
- } finally {
40
- router.serverSsr?.cleanup();
41
- }
24
+ return renderSsrHtmlResponse({
25
+ router,
26
+ responseHeaders,
27
+ render: () => {
28
+ const result = octaneRenderToString(
29
+ App as unknown as ServerComponent,
30
+ { router },
31
+ { nonce: router.options.ssr?.nonce },
32
+ );
33
+ return foldCssIntoHead(result.html, result.css);
34
+ },
35
+ });
42
36
  }
43
37
 
44
- export function finalizeBufferedHtml(renderedHtml: string, css: string, injectedHtml?: string) {
45
- let html = renderedHtml;
46
-
47
- if (css) {
48
- html = html.includes('</head>') ? html.replace('</head>', `${css}</head>`) : `${css}${html}`;
49
- }
50
-
51
- if (injectedHtml) {
52
- html = html.includes('</body>')
53
- ? html.replace('</body>', `${injectedHtml}</body>`)
54
- : `${html}${injectedHtml}`;
55
- }
56
-
57
- return `<!DOCTYPE html>${html}`;
38
+ // Fold octane's scoped-style output into the document head. Unlike the previous
39
+ // implementation this adds no `<!DOCTYPE html>` and no router-script injection —
40
+ // `transformHtmlStringWithRouter` now owns both.
41
+ export function foldCssIntoHead(renderedHtml: string, css: string) {
42
+ if (!css) return renderedHtml;
43
+ return renderedHtml.includes('</head>')
44
+ ? renderedHtml.replace('</head>', `${css}</head>`)
45
+ : `${css}${renderedHtml}`;
58
46
  }
@@ -120,21 +120,23 @@ export function useBlocker(
120
120
  const blockerFnComposed = async (blockerFnArgs: BlockerFnArgs) => {
121
121
  function getLocation(location: HistoryLocation): ShouldBlockFnLocation {
122
122
  const parsedLocation = router.parseLocation(location);
123
- const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname);
124
- if (matchedRoutes.foundRoute === undefined) {
123
+ // router-core 1.171.34 getMatchedRoutes returns a tuple
124
+ // [matchedRoutes, rawParams, foundRoute] instead of an object.
125
+ const [, routeParams, foundRoute] = router.getMatchedRoutes(parsedLocation.pathname);
126
+ if (foundRoute === undefined) {
125
127
  return {
126
128
  routeId: '__notFound__',
127
129
  fullPath: parsedLocation.pathname,
128
130
  pathname: parsedLocation.pathname,
129
- params: matchedRoutes.routeParams,
131
+ params: routeParams,
130
132
  search: router.options.parseSearch(location.search),
131
133
  };
132
134
  }
133
135
  return {
134
- routeId: matchedRoutes.foundRoute.id,
135
- fullPath: matchedRoutes.foundRoute.fullPath,
136
+ routeId: foundRoute.id,
137
+ fullPath: foundRoute.fullPath,
136
138
  pathname: parsedLocation.pathname,
137
- params: matchedRoutes.routeParams,
139
+ params: routeParams,
138
140
  search: router.options.parseSearch(location.search),
139
141
  };
140
142
  }