@ripple-ts/vite-plugin 0.3.90 → 0.3.92
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.
- package/CHANGELOG.md +31 -0
- package/package.json +4 -4
- package/src/load-config.js +3 -0
- package/src/server/production.js +44 -23
- package/src/server/render-route.js +41 -25
- package/src/server/stream-response.js +78 -0
- package/src/server/virtual-entry.js +3 -1
- package/tests/render-route-props.test.js +7 -4
- package/tests/stream-response.test.js +96 -0
- package/types/index.d.ts +17 -0
- package/types/production.d.ts +44 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @ripple-ts/vite-plugin
|
|
2
2
|
|
|
3
|
+
## 0.3.92
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#1326](https://github.com/Ripple-TS/ripple/pull/1326)
|
|
8
|
+
[`fea49bf`](https://github.com/Ripple-TS/ripple/commit/fea49bfb4410a05e0c915dfa39acdaba7f542737)
|
|
9
|
+
Thanks [@leonidaz](https://github.com/leonidaz)! - Streaming SSR:
|
|
10
|
+
`render(App, { stream })` now streams progressively. The synchronous shell (with
|
|
11
|
+
pending fallbacks for suspended `@try` boundaries and all CSS registered so far)
|
|
12
|
+
is flushed immediately; each boundary's content streams out of order as a framed
|
|
13
|
+
chunk once its async work settles, including per-chunk CSS, trackAsync envelopes
|
|
14
|
+
and `<head>` content. A tiny inline runtime swaps chunks into their slots before
|
|
15
|
+
hydration, and hydrated boundaries activate streamed chunks in place afterwards
|
|
16
|
+
— claiming the streamed DOM without re-rendering. Catch-only async boundaries
|
|
17
|
+
stream an empty slot and resolve to their body or server-rendered catch; errors
|
|
18
|
+
whose catch region is already on the wire hand off to the client boundary via an
|
|
19
|
+
error envelope. `render` also gains a `streamTemplate` option for document
|
|
20
|
+
scaffolding, and the vite plugin streams render-route responses when
|
|
21
|
+
`ssr.streaming` is enabled in ripple.config.ts (falling back to buffered SSR
|
|
22
|
+
when index.html lacks the `<!--ssr-head-->`/`<!--ssr-body-->` markers).
|
|
23
|
+
- Updated dependencies []:
|
|
24
|
+
- @ripple-ts/adapter@0.3.92
|
|
25
|
+
|
|
26
|
+
## 0.3.91
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- Updated dependencies []:
|
|
31
|
+
- @tsrx/ripple@0.1.38
|
|
32
|
+
- @ripple-ts/adapter@0.3.91
|
|
33
|
+
|
|
3
34
|
## 0.3.90
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Vite plugin for Ripple",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Dominic Gannaway",
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.92",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"module": "src/index.js",
|
|
9
9
|
"main": "src/index.js",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
"url": "https://github.com/Ripple-TS/ripple/issues"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ripple-ts/adapter": "0.3.
|
|
36
|
-
"@tsrx/ripple": "0.1.
|
|
35
|
+
"@ripple-ts/adapter": "0.3.92",
|
|
36
|
+
"@tsrx/ripple": "0.1.38"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "^24.3.0",
|
|
40
40
|
"type-fest": "^5.6.0",
|
|
41
41
|
"vite": "^8.0.12",
|
|
42
|
-
"ripple": "0.3.
|
|
42
|
+
"ripple": "0.3.92"
|
|
43
43
|
},
|
|
44
44
|
"publishConfig": {
|
|
45
45
|
"access": "public"
|
package/src/load-config.js
CHANGED
|
@@ -140,6 +140,9 @@ export function resolveRippleConfig(raw, options = {}) {
|
|
|
140
140
|
routes: raw.router?.routes ?? [],
|
|
141
141
|
},
|
|
142
142
|
rootBoundary: raw.rootBoundary ?? {},
|
|
143
|
+
ssr: {
|
|
144
|
+
streaming: raw.ssr?.streaming ?? false,
|
|
145
|
+
},
|
|
143
146
|
middlewares: raw.middlewares ?? [],
|
|
144
147
|
platform: {
|
|
145
148
|
env: raw.platform?.env ?? {},
|
package/src/server/production.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { createRouter } from './router.js';
|
|
13
13
|
import { createContext, runMiddlewareChain } from './middleware.js';
|
|
14
|
+
import { buildStreamTemplate, createStreamingResponse } from './stream-response.js';
|
|
14
15
|
import { createLayoutWrapper, createPropsWrapper } from './component-wrappers.js';
|
|
15
16
|
import { get_route_entry_id, get_route_entry_path } from '../routes.js';
|
|
16
17
|
import {
|
|
@@ -32,7 +33,6 @@ export { resolveRippleConfig } from '../load-config.js';
|
|
|
32
33
|
/**
|
|
33
34
|
@import {
|
|
34
35
|
ServerManifest,
|
|
35
|
-
RenderResult,
|
|
36
36
|
HandlerOptions,
|
|
37
37
|
ClientAssetEntry,
|
|
38
38
|
} from '../../types/production.d.ts';
|
|
@@ -49,7 +49,7 @@ export { resolveRippleConfig } from '../load-config.js';
|
|
|
49
49
|
* @returns {(request: Request) => Promise<Response>}
|
|
50
50
|
*/
|
|
51
51
|
export function createHandler(manifest, options) {
|
|
52
|
-
const { render, getCss, htmlTemplate, executeServerFunction } = options;
|
|
52
|
+
const { render, getCss, htmlTemplate, executeServerFunction, createSsrStream } = options;
|
|
53
53
|
const router = createRouter(manifest.routes);
|
|
54
54
|
const globalMiddlewares = manifest.middlewares;
|
|
55
55
|
const trustProxy = manifest.trustProxy ?? false;
|
|
@@ -107,6 +107,7 @@ export function createHandler(manifest, options) {
|
|
|
107
107
|
getCss,
|
|
108
108
|
htmlTemplate,
|
|
109
109
|
clientAssets,
|
|
110
|
+
createSsrStream,
|
|
110
111
|
);
|
|
111
112
|
} else {
|
|
112
113
|
return await handleServerRoute(match.route, context, globalMiddlewares);
|
|
@@ -136,10 +137,11 @@ export function createHandler(manifest, options) {
|
|
|
136
137
|
* @param {import('@ripple-ts/vite-plugin').Context} context
|
|
137
138
|
* @param {ServerManifest} manifest
|
|
138
139
|
* @param {Middleware[]} globalMiddlewares
|
|
139
|
-
* @param {
|
|
140
|
+
* @param {import('../../types/production.d.ts').RenderFunction} render
|
|
140
141
|
* @param {(css: Set<string>) => string} getCss
|
|
141
142
|
* @param {string} htmlTemplate
|
|
142
143
|
* @param {Record<string, ClientAssetEntry>} clientAssets
|
|
144
|
+
* @param {import('../../types/production.d.ts').HandlerOptions['createSsrStream']} [createSsrStream]
|
|
143
145
|
* @returns {Promise<Response>}
|
|
144
146
|
*/
|
|
145
147
|
async function handleRenderRoute(
|
|
@@ -151,6 +153,7 @@ async function handleRenderRoute(
|
|
|
151
153
|
getCss,
|
|
152
154
|
htmlTemplate,
|
|
153
155
|
clientAssets,
|
|
156
|
+
createSsrStream,
|
|
154
157
|
) {
|
|
155
158
|
const renderHandler = async () => {
|
|
156
159
|
// Get the page component
|
|
@@ -172,20 +175,6 @@ async function handleRenderRoute(
|
|
|
172
175
|
RootComponent = createPropsWrapper(PageComponent, pageProps);
|
|
173
176
|
}
|
|
174
177
|
|
|
175
|
-
// Render to HTML
|
|
176
|
-
const { head, body, css } = await render(RootComponent, {
|
|
177
|
-
rootBoundary: manifest.rootBoundary,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
// Generate inline scoped CSS (from SSR-rendered component hashes)
|
|
181
|
-
let cssContent = '';
|
|
182
|
-
if (css.size > 0) {
|
|
183
|
-
const cssString = getCss(css);
|
|
184
|
-
if (cssString) {
|
|
185
|
-
cssContent = `<style data-ripple-ssr>${cssString}</style>`;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
178
|
// Build asset preload tags from the client manifest.
|
|
190
179
|
// These ensure the browser starts downloading page-specific JS/CSS
|
|
191
180
|
// immediately, before the hydration script executes.
|
|
@@ -214,12 +203,44 @@ async function handleRenderRoute(
|
|
|
214
203
|
routeIndex: getRenderRouteIndex(manifest.routes, route),
|
|
215
204
|
params: context.params,
|
|
216
205
|
});
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
206
|
+
const routeDataScript = `<script id="__ripple_data" type="application/json">${escapeScript(routeData)}</script>`;
|
|
207
|
+
|
|
208
|
+
if (manifest.streaming && createSsrStream) {
|
|
209
|
+
// SSR head content and CSS travel in the stream itself; only the
|
|
210
|
+
// static per-request head additions go into the scaffold
|
|
211
|
+
const streamTemplate = buildStreamTemplate(
|
|
212
|
+
htmlTemplate,
|
|
213
|
+
[...preloadTags, routeDataScript].join('\n'),
|
|
214
|
+
);
|
|
215
|
+
if (streamTemplate) {
|
|
216
|
+
return createStreamingResponse({
|
|
217
|
+
render,
|
|
218
|
+
createSsrStream,
|
|
219
|
+
component: RootComponent,
|
|
220
|
+
rootBoundary: manifest.rootBoundary,
|
|
221
|
+
streamTemplate,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
console.warn(
|
|
225
|
+
'[ripple] ssr.streaming is enabled but the HTML template is missing the <!--ssr-head--> or <!--ssr-body--> marker — falling back to buffered SSR.',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Render to HTML
|
|
230
|
+
const { head, body, css } = await render(RootComponent, {
|
|
231
|
+
rootBoundary: manifest.rootBoundary,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Generate inline scoped CSS (from SSR-rendered component hashes)
|
|
235
|
+
let cssContent = '';
|
|
236
|
+
if (css.size > 0) {
|
|
237
|
+
const cssString = getCss(css);
|
|
238
|
+
if (cssString) {
|
|
239
|
+
cssContent = `<style data-ripple-ssr>${cssString}</style>`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const headContent = [head, cssContent, ...preloadTags, routeDataScript]
|
|
223
244
|
.filter(Boolean)
|
|
224
245
|
.join('\n');
|
|
225
246
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { createLayoutWrapper, createPropsWrapper } from './component-wrappers.js';
|
|
5
|
+
import { buildStreamTemplate, createStreamingResponse } from './stream-response.js';
|
|
5
6
|
import {
|
|
6
7
|
get_component_export,
|
|
7
8
|
get_route_entry_export_name,
|
|
@@ -40,7 +41,8 @@ export async function handleRenderRoute(route, context, vite, rippleConfig) {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
// Load ripple server utilities
|
|
43
|
-
const { render, get_css_for_hashes } =
|
|
44
|
+
const { render, get_css_for_hashes, create_ssr_stream } =
|
|
45
|
+
await vite.ssrLoadModule('ripple/server');
|
|
44
46
|
|
|
45
47
|
// Load the page component
|
|
46
48
|
const entryPath = get_route_entry_path(route.entry);
|
|
@@ -75,6 +77,42 @@ export async function handleRenderRoute(route, context, vite, rippleConfig) {
|
|
|
75
77
|
RootComponent = createPropsWrapper(PageComponent, pageProps);
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
// Load and process index.html template
|
|
81
|
+
const templatePath = join(vite.config.root, 'index.html');
|
|
82
|
+
let template = await readFile(templatePath, 'utf-8');
|
|
83
|
+
|
|
84
|
+
// Apply Vite's HTML transforms (HMR client, module resolution, etc.)
|
|
85
|
+
template = await vite.transformIndexHtml(context.url.pathname, template);
|
|
86
|
+
|
|
87
|
+
// Inject hydration script before </body>
|
|
88
|
+
const hydrationScript = `<script type="module" src="/@id/virtual:ripple-hydrate"></script>`;
|
|
89
|
+
template = template.replace('</body>', `${hydrationScript}\n</body>`);
|
|
90
|
+
|
|
91
|
+
const routeData = JSON.stringify({
|
|
92
|
+
entry: entryPath,
|
|
93
|
+
routeIndex: getRenderRouteIndex(rippleConfig, route),
|
|
94
|
+
params: context.params,
|
|
95
|
+
});
|
|
96
|
+
const routeDataScript = `<script id="__ripple_data" type="application/json">${escapeScript(routeData)}</script>`;
|
|
97
|
+
|
|
98
|
+
if (rippleConfig?.ssr?.streaming) {
|
|
99
|
+
// SSR head content and CSS travel in the stream itself; only the
|
|
100
|
+
// static per-request head additions go into the scaffold
|
|
101
|
+
const streamTemplate = buildStreamTemplate(template, routeDataScript);
|
|
102
|
+
if (streamTemplate) {
|
|
103
|
+
return createStreamingResponse({
|
|
104
|
+
render,
|
|
105
|
+
createSsrStream: create_ssr_stream,
|
|
106
|
+
component: RootComponent,
|
|
107
|
+
rootBoundary: rippleConfig?.rootBoundary,
|
|
108
|
+
streamTemplate,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
console.warn(
|
|
112
|
+
'[ripple] ssr.streaming is enabled but index.html is missing the <!--ssr-head--> or <!--ssr-body--> marker — falling back to buffered SSR.',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
78
116
|
// Render to HTML
|
|
79
117
|
/** @type {RenderResult} */
|
|
80
118
|
const { head, body, css } = await render(RootComponent, {
|
|
@@ -91,32 +129,10 @@ export async function handleRenderRoute(route, context, vite, rippleConfig) {
|
|
|
91
129
|
}
|
|
92
130
|
|
|
93
131
|
// Build head content with hydration data
|
|
94
|
-
const
|
|
95
|
-
entry: entryPath,
|
|
96
|
-
routeIndex: getRenderRouteIndex(rippleConfig, route),
|
|
97
|
-
params: context.params,
|
|
98
|
-
});
|
|
99
|
-
const headContent = [
|
|
100
|
-
head,
|
|
101
|
-
cssContent,
|
|
102
|
-
`<script id="__ripple_data" type="application/json">${escapeScript(routeData)}</script>`,
|
|
103
|
-
]
|
|
104
|
-
.filter(Boolean)
|
|
105
|
-
.join('\n');
|
|
106
|
-
|
|
107
|
-
// Load and process index.html template
|
|
108
|
-
const templatePath = join(vite.config.root, 'index.html');
|
|
109
|
-
let template = await readFile(templatePath, 'utf-8');
|
|
110
|
-
|
|
111
|
-
// Apply Vite's HTML transforms (HMR client, module resolution, etc.)
|
|
112
|
-
template = await vite.transformIndexHtml(context.url.pathname, template);
|
|
132
|
+
const headContent = [head, cssContent, routeDataScript].filter(Boolean).join('\n');
|
|
113
133
|
|
|
114
134
|
// Replace placeholders
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// Inject hydration script before </body>
|
|
118
|
-
const hydrationScript = `<script type="module" src="/@id/virtual:ripple-hydrate"></script>`;
|
|
119
|
-
html = html.replace('</body>', `${hydrationScript}\n</body>`);
|
|
135
|
+
const html = template.replace('<!--ssr-head-->', headContent).replace('<!--ssr-body-->', body);
|
|
120
136
|
|
|
121
137
|
return new Response(html, {
|
|
122
138
|
status: 200,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for streaming SSR responses, used by both the dev server
|
|
3
|
+
* (render-route.js) and the production runtime (production.js).
|
|
4
|
+
*
|
|
5
|
+
* Platform-agnostic: relies only on the Web `Response`/`ReadableStream`
|
|
6
|
+
* globals and the `render`/`create_ssr_stream` functions injected by the
|
|
7
|
+
* caller.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SSR_HEAD_MARKER = '<!--ssr-head-->';
|
|
11
|
+
const SSR_BODY_MARKER = '<!--ssr-body-->';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Splits an HTML document template into the streaming scaffold around the
|
|
15
|
+
* `<!--ssr-head-->` and `<!--ssr-body-->` markers. The renderer emits
|
|
16
|
+
* `before` + SSR head content + `between` + shell body, streams boundary
|
|
17
|
+
* chunks, and pushes `after` when the stream closes.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} template - full HTML document template
|
|
20
|
+
* @param {string} headContent - static per-request head additions (route
|
|
21
|
+
* data script, preload tags); SSR-derived head content and CSS come from
|
|
22
|
+
* the stream itself
|
|
23
|
+
* @returns {{ before: string, between: string, after: string } | null}
|
|
24
|
+
* null when the template is missing the markers (caller should fall back
|
|
25
|
+
* to buffered rendering)
|
|
26
|
+
*/
|
|
27
|
+
export function buildStreamTemplate(template, headContent) {
|
|
28
|
+
const head_index = template.indexOf(SSR_HEAD_MARKER);
|
|
29
|
+
const body_index = template.indexOf(SSR_BODY_MARKER);
|
|
30
|
+
if (head_index === -1 || body_index === -1 || body_index < head_index) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
before: template.slice(0, head_index) + headContent,
|
|
35
|
+
between: template.slice(head_index + SSR_HEAD_MARKER.length, body_index),
|
|
36
|
+
after: template.slice(body_index + SSR_BODY_MARKER.length),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Kicks off a streaming render and returns the HTML Response immediately —
|
|
42
|
+
* the shell is flushed synchronously by the renderer and boundary chunks
|
|
43
|
+
* follow as their async work settles.
|
|
44
|
+
*
|
|
45
|
+
* @param {{
|
|
46
|
+
* render: Function,
|
|
47
|
+
* createSsrStream: () => { stream: ReadableStream<Uint8Array>, sink: { push(chunk: string): void, close(): void, error(reason: unknown): void } },
|
|
48
|
+
* component: Function,
|
|
49
|
+
* rootBoundary: object | undefined,
|
|
50
|
+
* streamTemplate: { before: string, between: string, after: string },
|
|
51
|
+
* }} options
|
|
52
|
+
* @returns {Response}
|
|
53
|
+
*/
|
|
54
|
+
export function createStreamingResponse({
|
|
55
|
+
render,
|
|
56
|
+
createSsrStream,
|
|
57
|
+
component,
|
|
58
|
+
rootBoundary,
|
|
59
|
+
streamTemplate,
|
|
60
|
+
}) {
|
|
61
|
+
const { stream, sink } = createSsrStream();
|
|
62
|
+
|
|
63
|
+
Promise.resolve(
|
|
64
|
+
render(component, {
|
|
65
|
+
stream: sink,
|
|
66
|
+
rootBoundary,
|
|
67
|
+
streamTemplate,
|
|
68
|
+
}),
|
|
69
|
+
).catch((/** @type {unknown} */ error) => {
|
|
70
|
+
console.error('[ripple] SSR streaming error:', error);
|
|
71
|
+
sink.error(error);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return new Response(stream, {
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -144,7 +144,7 @@ export function generateServerEntry(options) {
|
|
|
144
144
|
// Auto-generated server entry for production build
|
|
145
145
|
// Do not edit — regenerated on each build
|
|
146
146
|
|
|
147
|
-
import { render, get_css_for_hashes, executeServerFunction } from 'ripple/server';
|
|
147
|
+
import { render, get_css_for_hashes, create_ssr_stream, executeServerFunction } from 'ripple/server';
|
|
148
148
|
import { createHandler, resolveRippleConfig } from '@ripple-ts/vite-plugin/production';
|
|
149
149
|
import { readFileSync, existsSync } from 'node:fs';
|
|
150
150
|
import { fileURLToPath } from 'node:url';
|
|
@@ -200,6 +200,7 @@ const handler = createHandler(
|
|
|
200
200
|
rpcModules,
|
|
201
201
|
trustProxy: rippleConfig.server.trustProxy,
|
|
202
202
|
rootBoundary: rippleConfig.rootBoundary,
|
|
203
|
+
streaming: rippleConfig.ssr.streaming,
|
|
203
204
|
runtime: rippleConfig.adapter.runtime,
|
|
204
205
|
clientAssets,
|
|
205
206
|
},
|
|
@@ -208,6 +209,7 @@ const handler = createHandler(
|
|
|
208
209
|
getCss: get_css_for_hashes,
|
|
209
210
|
htmlTemplate,
|
|
210
211
|
executeServerFunction,
|
|
212
|
+
createSsrStream: create_ssr_stream,
|
|
211
213
|
},
|
|
212
214
|
);
|
|
213
215
|
|
|
@@ -31,10 +31,13 @@ function createRuntime() {
|
|
|
31
31
|
|
|
32
32
|
function createHandlerOptions() {
|
|
33
33
|
return {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
// test double for the buffered overload only — streaming stays off here
|
|
35
|
+
render: /** @type {import('../types/production.d.ts').RenderFunction} */ (
|
|
36
|
+
async (/** @type {Function} */ Component) => {
|
|
37
|
+
Component({ fromRender: true });
|
|
38
|
+
return { head: '', body: '<div>ok</div>', css: new Set() };
|
|
39
|
+
}
|
|
40
|
+
),
|
|
38
41
|
getCss: () => '',
|
|
39
42
|
htmlTemplate: '<html><head><!--ssr-head--></head><body><!--ssr-body--></body></html>',
|
|
40
43
|
executeServerFunction: async () => '',
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildStreamTemplate, createStreamingResponse } from '../src/server/stream-response.js';
|
|
3
|
+
|
|
4
|
+
describe('buildStreamTemplate', () => {
|
|
5
|
+
const template =
|
|
6
|
+
'<html><head><!--ssr-head--></head><body><div id="root"><!--ssr-body--></div><script src="/x.js"></script></body></html>';
|
|
7
|
+
|
|
8
|
+
it('splits the template around the ssr markers and injects head content', () => {
|
|
9
|
+
const result = buildStreamTemplate(template, '<meta name="x">');
|
|
10
|
+
expect(result).toEqual({
|
|
11
|
+
before: '<html><head><meta name="x">',
|
|
12
|
+
between: '</head><body><div id="root">',
|
|
13
|
+
after: '</div><script src="/x.js"></script></body></html>',
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns null when a marker is missing', () => {
|
|
18
|
+
expect(buildStreamTemplate('<html><body><!--ssr-body--></body></html>', '')).toBeNull();
|
|
19
|
+
expect(buildStreamTemplate('<html><head><!--ssr-head--></head></html>', '')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null when the markers are out of order', () => {
|
|
23
|
+
expect(buildStreamTemplate('<!--ssr-body--><!--ssr-head-->', '')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('createStreamingResponse', () => {
|
|
28
|
+
it('returns an HTML response immediately and streams render output', async () => {
|
|
29
|
+
/** @type {string[]} */
|
|
30
|
+
const pushed = [];
|
|
31
|
+
let closed = false;
|
|
32
|
+
|
|
33
|
+
const fakeSink = {
|
|
34
|
+
push: (/** @type {string} */ chunk) => pushed.push(chunk),
|
|
35
|
+
close: () => {
|
|
36
|
+
closed = true;
|
|
37
|
+
},
|
|
38
|
+
error: () => {},
|
|
39
|
+
};
|
|
40
|
+
const fakeStream = new ReadableStream({
|
|
41
|
+
start(controller) {
|
|
42
|
+
controller.enqueue(new TextEncoder().encode('streamed'));
|
|
43
|
+
controller.close();
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const streamTemplate = { before: '<b>', between: '<m>', after: '<a>' };
|
|
48
|
+
/** @type {any} */
|
|
49
|
+
let seen_options;
|
|
50
|
+
|
|
51
|
+
const response = createStreamingResponse({
|
|
52
|
+
render: (/** @type {Function} */ _component, /** @type {any} */ options) => {
|
|
53
|
+
seen_options = options;
|
|
54
|
+
options.stream.push('shell');
|
|
55
|
+
options.stream.close();
|
|
56
|
+
return Promise.resolve({ stream: options.stream });
|
|
57
|
+
},
|
|
58
|
+
createSsrStream: () => ({ stream: fakeStream, sink: fakeSink }),
|
|
59
|
+
component: () => {},
|
|
60
|
+
rootBoundary: undefined,
|
|
61
|
+
streamTemplate,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(response).toBeInstanceOf(Response);
|
|
65
|
+
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8');
|
|
66
|
+
expect(seen_options.streamTemplate).toBe(streamTemplate);
|
|
67
|
+
expect(pushed).toEqual(['shell']);
|
|
68
|
+
expect(closed).toBe(true);
|
|
69
|
+
expect(await response.text()).toBe('streamed');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('propagates render failures to the sink', async () => {
|
|
73
|
+
/** @type {unknown} */
|
|
74
|
+
let errored = null;
|
|
75
|
+
const response = createStreamingResponse({
|
|
76
|
+
render: () => Promise.reject(new Error('render exploded')),
|
|
77
|
+
createSsrStream: () => ({
|
|
78
|
+
stream: new ReadableStream(),
|
|
79
|
+
sink: {
|
|
80
|
+
push: () => {},
|
|
81
|
+
close: () => {},
|
|
82
|
+
error: (/** @type {unknown} */ reason) => {
|
|
83
|
+
errored = reason;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
component: () => {},
|
|
88
|
+
rootBoundary: undefined,
|
|
89
|
+
streamTemplate: { before: '', between: '', after: '' },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(response).toBeInstanceOf(Response);
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
94
|
+
expect(errored).toBeInstanceOf(Error);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/types/index.d.ts
CHANGED
|
@@ -141,6 +141,19 @@ export interface RippleConfigOptions {
|
|
|
141
141
|
};
|
|
142
142
|
/** Global root pending/catch UI used by client and SSR render roots */
|
|
143
143
|
rootBoundary?: RootBoundaryOptions;
|
|
144
|
+
ssr?: {
|
|
145
|
+
/**
|
|
146
|
+
* Stream render-route responses: the synchronous shell (with pending
|
|
147
|
+
* fallbacks for suspended `@try` boundaries) is sent immediately and
|
|
148
|
+
* each boundary's content streams as it settles.
|
|
149
|
+
*
|
|
150
|
+
* Requires `<!--ssr-head-->` and `<!--ssr-body-->` markers in
|
|
151
|
+
* index.html; falls back to buffered rendering when they are missing.
|
|
152
|
+
*
|
|
153
|
+
* @default false
|
|
154
|
+
*/
|
|
155
|
+
streaming?: boolean;
|
|
156
|
+
};
|
|
144
157
|
/** Global middlewares applied to all routes */
|
|
145
158
|
middlewares?: Middleware[];
|
|
146
159
|
platform?: {
|
|
@@ -181,6 +194,10 @@ export interface ResolvedRippleConfig {
|
|
|
181
194
|
routes: Route[];
|
|
182
195
|
};
|
|
183
196
|
rootBoundary: RootBoundaryOptions;
|
|
197
|
+
ssr: {
|
|
198
|
+
/** @default false */
|
|
199
|
+
streaming: boolean;
|
|
200
|
+
};
|
|
184
201
|
/** @default [] */
|
|
185
202
|
middlewares: Middleware[];
|
|
186
203
|
platform: {
|
package/types/production.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface ServerManifest {
|
|
|
29
29
|
/** Trust X-Forwarded-* headers when deriving origin for RPC fetch */
|
|
30
30
|
trustProxy?: boolean;
|
|
31
31
|
rootBoundary?: RootBoundaryOptions;
|
|
32
|
+
/** Stream render-route responses (progressive SSR of async boundaries) */
|
|
33
|
+
streaming?: boolean;
|
|
32
34
|
/** Platform-specific runtime primitives from the adapter */
|
|
33
35
|
runtime: RuntimePrimitives;
|
|
34
36
|
/**
|
|
@@ -47,14 +49,52 @@ export interface RenderResult {
|
|
|
47
49
|
css: Set<string>;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
export interface
|
|
51
|
-
|
|
52
|
+
export interface StreamTemplate {
|
|
53
|
+
before: string;
|
|
54
|
+
between: string;
|
|
55
|
+
after: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface StreamSink {
|
|
59
|
+
push(chunk: string): void;
|
|
60
|
+
close(): void;
|
|
61
|
+
error(reason: unknown): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Overloaded like ripple/server's `render`: without a `stream` sink it
|
|
66
|
+
* resolves to the buffered {@link RenderResult}, with one it streams into the
|
|
67
|
+
* sink and resolves to the stream handle.
|
|
68
|
+
*/
|
|
69
|
+
export interface RenderFunction {
|
|
70
|
+
(
|
|
52
71
|
component: Function,
|
|
53
|
-
options?: {
|
|
54
|
-
|
|
72
|
+
options?: {
|
|
73
|
+
rootBoundary?: RootBoundaryOptions;
|
|
74
|
+
stream?: undefined;
|
|
75
|
+
streamTemplate?: StreamTemplate;
|
|
76
|
+
},
|
|
77
|
+
): Promise<RenderResult>;
|
|
78
|
+
(
|
|
79
|
+
component: Function,
|
|
80
|
+
options: {
|
|
81
|
+
rootBoundary?: RootBoundaryOptions;
|
|
82
|
+
stream: StreamSink;
|
|
83
|
+
streamTemplate?: StreamTemplate;
|
|
84
|
+
},
|
|
85
|
+
): Promise<{ stream: StreamSink }>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface HandlerOptions {
|
|
89
|
+
render: RenderFunction;
|
|
55
90
|
getCss: (css: Set<string>) => string;
|
|
56
91
|
htmlTemplate: string;
|
|
57
92
|
executeServerFunction: (fn: Function, body: string) => Promise<string>;
|
|
93
|
+
/** `create_ssr_stream` from 'ripple/server' — required when streaming is enabled */
|
|
94
|
+
createSsrStream?: () => {
|
|
95
|
+
stream: ReadableStream<Uint8Array>;
|
|
96
|
+
sink: StreamSink;
|
|
97
|
+
};
|
|
58
98
|
}
|
|
59
99
|
|
|
60
100
|
export function createHandler(
|