Skip to content

Commit c45bfb3

Browse files
authored
Merge pull request #472 from jacob-ebey/async_nonce
feat: async nonce support
2 parents 005230b + 64e135c commit c45bfb3

11 files changed

Lines changed: 120 additions & 10 deletions

File tree

.changeset/olive-lamps-agree.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"preact-render-to-string": minor
3+
---
4+
5+
Replaces the second `context` argument in our streaming functions with an `options` object, the `context` moves to the third position.
6+
7+
Streaming is considered alpha so we're making this breaking change deliberately as part of a minor.

src/internal.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,5 @@ interface RenderToChunksOptions {
4343
onError?: (error: any) => void;
4444
onWrite: (str: string) => void;
4545
abortSignal?: AbortSignal;
46+
nonce?: string;
4647
}

src/lib/chunked.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createInitScript, createSubtree } from './client.js';
88
* @param {RenderToChunksOptions} options
99
* @returns {Promise<void>}
1010
*/
11-
export async function renderToChunks(vnode, { context, onWrite, abortSignal }) {
11+
export async function renderToChunks(vnode, { context, onWrite, abortSignal, nonce }) {
1212
context = context || {};
1313

1414
/** @type {RendererState} */
@@ -38,7 +38,7 @@ export async function renderToChunks(vnode, { context, onWrite, abortSignal }) {
3838
const prefix = hasHtmlTag ? '<!DOCTYPE html>' : '';
3939
onWrite(prefix + initialWrite);
4040
onWrite('<div hidden>');
41-
onWrite(createInitScript(len));
41+
onWrite(createInitScript(nonce));
4242
// We should keep checking all promises
4343
await forkPromises(renderer);
4444
onWrite('</div>');

src/lib/client.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { encodeEntities } from "./util.js"
2+
13
/* eslint-disable no-var, key-spacing, object-curly-spacing, prefer-arrow-callback, semi, keyword-spacing */
24

35
// function initPreactIslandElement() {
@@ -48,8 +50,12 @@
4850
// To modify the INIT_SCRIPT, uncomment the above code, modify it, and paste it into https://try.terser.org/.
4951
const INIT_SCRIPT = `class e extends HTMLElement{connectedCallback(){var e=this;if(!e.isConnected)return;let t=this.getAttribute("data-target");if(t){for(var r,a,i=document.createNodeIterator(document,128);i.nextNode();){let e=i.referenceNode;if(e.data=="$s:"+t?r=e:e.data=="/$s:"+t&&(a=e),r&&a)break}r&&a&&r.parentNode!==document&&requestAnimationFrame((()=>{for(var t=a.previousSibling;t!=r&&t&&t!=r;)a.parentNode.removeChild(t),t=a.previousSibling;for(i=r;e.firstChild;)r=e.firstChild,e.removeChild(r),i.after(r),i=r;e.parentNode.removeChild(e)}))}}}customElements.define("preact-island",e);`;
5052

51-
export function createInitScript() {
52-
return `<script>(function(){${INIT_SCRIPT}}())</script>`;
53+
/**
54+
* @param {string} nonce
55+
* @returns {string}
56+
*/
57+
export function createInitScript(nonce) {
58+
return `<script${nonce ? ` nonce="${encodeEntities(nonce)}"` : ''}>(function(){${INIT_SCRIPT}}())</script>`;
5359
}
5460

5561
/**

src/stream-node.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { VNode } from 'preact';
22
import { WritableStream } from 'node:stream';
33

44
interface RenderToPipeableStreamOptions {
5+
nonce?: string;
56
onShellReady?: () => void;
67
onAllReady?: () => void;
78
onError?: (error: any) => void;

src/stream-node.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function renderToPipeableStream(vnode, options, context) {
2929
renderToChunks(vnode, {
3030
context,
3131
abortSignal: controller.signal,
32+
nonce: options.nonce,
3233
onError: (error) => {
3334
if (options.onError) {
3435
options.onError(error);

src/stream.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ interface RenderStream extends ReadableStream<Uint8Array> {
44
allReady: Promise<void>;
55
}
66

7+
interface RenderToReadableStreamOptions {
8+
nonce?: string;
9+
}
10+
711
export function renderToReadableStream<P = {}>(
812
vnode: VNode<P>,
13+
options?: RenderToReadableStreamOptions,
914
context?: any
1015
): RenderStream;

src/stream.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { renderToChunks } from './lib/chunked.js';
55

66
/**
77
* @param {import('preact').VNode} vnode
8+
* @param {any} [options]
89
* @param {any} [context]
910
* @returns {RenderStream}
1011
*/
11-
export function renderToReadableStream(vnode, context) {
12+
export function renderToReadableStream(vnode, options, context) {
1213
/** @type {Deferred<void>} */
1314
const allReady = new Deferred();
1415
const encoder = new TextEncoder('utf-8');
@@ -18,6 +19,7 @@ export function renderToReadableStream(vnode, context) {
1819
start(controller) {
1920
renderToChunks(vnode, {
2021
context,
22+
nonce: options?.nonce,
2123
onError: (error) => {
2224
allReady.reject(error);
2325
controller.abort(error);

test/compat/render-chunked.test.jsx

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe('renderToChunks', () => {
6464
expect(result).to.deep.equal([
6565
'<div><!--$s:10-->loading...<!--/$s:10--></div>',
6666
'<div hidden>',
67-
createInitScript(1),
67+
createInitScript(),
6868
'</div>'
6969
]);
7070
});
@@ -111,7 +111,7 @@ describe('renderToChunks', () => {
111111
expect(result).to.deep.equal([
112112
'<div><!--$s:16-->loading...<!--/$s:16--></div>',
113113
'<div hidden>',
114-
createInitScript(1),
114+
createInitScript(),
115115
createSubtree('16', '<p>it works</p>'),
116116
'</div>'
117117
]);
@@ -144,7 +144,7 @@ describe('renderToChunks', () => {
144144
expect(result).to.deep.equal([
145145
'<div><p>id: P0-0</p><!--$s:24-->loading...<!--/$s:24--></div>',
146146
'<div hidden>',
147-
createInitScript(1),
147+
createInitScript(),
148148
createSubtree('24', '<p>id: P0-1</p>'),
149149
'</div>'
150150
]);
@@ -184,7 +184,7 @@ describe('renderToChunks', () => {
184184
expect(result).toEqual([
185185
'<div><p>id: P0-0</p><!--$s:33-->loading...<!--/$s:33--><!--$s:36-->loading...<!--/$s:36--></div>',
186186
'<div hidden>',
187-
createInitScript(1),
187+
createInitScript(),
188188
createSubtree('33', '<p>id: P0-1</p>'),
189189
createSubtree('36', '<p>id: P0-2</p>'),
190190
'</div>'
@@ -307,9 +307,50 @@ describe('renderToChunks', () => {
307307
expect(result).to.deep.equal([
308308
'<div><!--$s:70-->loading part 1...<!--/$s:70--></div>',
309309
'<div hidden>',
310-
createInitScript(1),
310+
createInitScript(),
311311
createSubtree('70', '<p>it works</p><p>it works</p>'),
312312
'</div>'
313313
]);
314314
});
315+
316+
it('should include the nonce attribute on the init script when a nonce is provided', async () => {
317+
const { Suspender, suspended } = createSuspender();
318+
319+
const result = [];
320+
const promise = renderToChunks(
321+
<div>
322+
<Suspense fallback="loading...">
323+
<Suspender />
324+
</Suspense>
325+
</div>,
326+
{ onWrite: (s) => result.push(s), nonce: 'r4nd0m-nonce' }
327+
);
328+
suspended.resolve();
329+
await promise;
330+
331+
const fullHtml = result.join('');
332+
expect(fullHtml).to.contain('<script nonce="r4nd0m-nonce">');
333+
334+
// The init script should be the only script emitted
335+
expect(result[2]).to.equal(createInitScript('r4nd0m-nonce'));
336+
});
337+
});
338+
339+
describe('createInitScript', () => {
340+
it('should not include a nonce attribute by default', () => {
341+
const script = createInitScript();
342+
expect(script.startsWith('<script>')).to.be.true;
343+
expect(script).to.not.contain('nonce=');
344+
});
345+
346+
it('should include a nonce attribute when a nonce is provided', () => {
347+
const script = createInitScript('r4nd0m-nonce');
348+
expect(script.startsWith('<script nonce="r4nd0m-nonce">')).to.be.true;
349+
});
350+
351+
it('should encode HTML entities in the nonce', () => {
352+
const script = createInitScript('a"b&c<d');
353+
expect(script.startsWith('<script nonce="a&quot;b&amp;c&lt;d">')).to.be
354+
.true;
355+
});
315356
});

test/compat/stream-node.test.jsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,29 @@ describe('renderToPipeableStream', () => {
8888

8989
expect(error).to.be.undefined;
9090
});
91+
92+
it('should include the nonce attribute on the init script when a nonce is provided', async () => {
93+
const { Suspender, suspended } = createSuspender();
94+
95+
const sink = createSink();
96+
const { pipe } = renderToPipeableStream(
97+
<div>
98+
<Suspense fallback="loading...">
99+
<Suspender />
100+
</Suspense>
101+
</div>,
102+
{
103+
nonce: 'r4nd0m-nonce',
104+
onShellReady: () => {
105+
pipe(sink.stream);
106+
}
107+
}
108+
);
109+
suspended.resolve();
110+
111+
const result = await sink.promise;
112+
113+
expect(result.join('')).to.contain('<script nonce="r4nd0m-nonce">');
114+
expect(result.join('')).to.not.contain('<script>');
115+
});
91116
});

0 commit comments

Comments
 (0)