@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 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.90",
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.90",
36
- "@tsrx/ripple": "0.1.37"
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.90"
42
+ "ripple": "0.3.92"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"
@@ -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 ?? {},
@@ -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 {(component: Function, options?: { rootBoundary?: import('@ripple-ts/vite-plugin').RootBoundaryOptions }) => Promise<RenderResult>} render
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 headContent = [
218
- head,
219
- cssContent,
220
- ...preloadTags,
221
- `<script id="__ripple_data" type="application/json">${escapeScript(routeData)}</script>`,
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 } = await vite.ssrLoadModule('ripple/server');
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 routeData = JSON.stringify({
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
- let html = template.replace('<!--ssr-head-->', headContent).replace('<!--ssr-body-->', body);
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
- render: async (Component) => {
35
- Component({ fromRender: true });
36
- return { head: '', body: '<div>ok</div>', css: new Set() };
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: {
@@ -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 HandlerOptions {
51
- render: (
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?: { rootBoundary?: RootBoundaryOptions },
54
- ) => Promise<RenderResult>;
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(