@ -2,7 +2,7 @@
/** @import { UnevalReplacer } from 'devalue' */
/** @import { UnevalReplacer } from 'devalue' */
/** @import { RenderContext, SSRContext, WarpStore } from './types.js' */
/** @import { RenderContext, SSRContext, WarpStore } from './types.js' */
/** @import { WarpKey } from '#shared' */
/** @import { WarpKey } from '#shared' */
/** @import { Csp, RenderOutput, Sync RenderOutput, Sha256Source } from '../../server/public.js' */
/** @import { AsyncRenderOutput, Csp, RenderOutput, Sha256Source } from '../../server/public.js' */
/** @import { MaybePromise } from '#shared' */
/** @import { MaybePromise } from '#shared' */
import { async _mode _flag } from '../flags/index.js' ;
import { async _mode _flag } from '../flags/index.js' ;
import { STALE _REACTION } from '../client/constants.js' ;
import { STALE _REACTION } from '../client/constants.js' ;
@ -16,7 +16,7 @@ import { with_render_context } from './render-context.js';
import { get _stack , is _promise } from './warp.js' ;
import { get _stack , is _promise } from './warp.js' ;
import { sha256 } from './crypto.js' ;
import { sha256 } from './crypto.js' ;
import * as devalue from 'devalue' ;
import * as devalue from 'devalue' ;
import { has_own _property , is _array , noop } from '../shared/utils.js' ;
import { deferred, has_own _property , is _array , noop } from '../shared/utils.js' ;
import { DEV } from 'esm-env' ;
import { DEV } from 'esm-env' ;
import { escape _html } from '../../escaping.js' ;
import { escape _html } from '../../escaping.js' ;
@ -31,7 +31,7 @@ class RenderResult {
/** @type {() => AccumulatedContent} */
/** @type {() => AccumulatedContent} */
# render ;
# render ;
/** @type {() => Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>} */
/** @type {() => Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } , tail: AsyncIterable<string> }>} */
# render _async ;
# render _async ;
/** @type {AccumulatedContent | undefined} */
/** @type {AccumulatedContent | undefined} */
@ -40,12 +40,12 @@ class RenderResult {
/** @type {{ script: '' }} */
/** @type {{ script: '' }} */
# hashes = { script : '' } ;
# hashes = { script : '' } ;
/** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | undefined} */
/** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } , tail: AsyncIterable<string> }> | undefined} */
# promise ;
# promise ;
/ * *
/ * *
* @ param { ( ) => AccumulatedContent } render
* @ param { ( ) => AccumulatedContent } render
* @ param { ( ) => Promise < AccumulatedContent & { hashes : { script : Sha256Source [ ] } } > } render _async
* @ param { ( ) => Promise < AccumulatedContent & { hashes : { script : Sha256Source [ ] } , tail : AsyncIterable < string > } > } render _async
* /
* /
constructor ( render , render _async ) {
constructor ( render , render _async ) {
this . # render = render ;
this . # render = render ;
@ -77,7 +77,7 @@ class RenderResult {
*
*
* @ template TResult1
* @ template TResult1
* @ template [ TResult2 = never ]
* @ template [ TResult2 = never ]
* @ param { ( value : S yncRenderOutput) => TResult1 } onfulfilled
* @ param { ( value : As yncRenderOutput) => TResult1 } onfulfilled
* @ param { ( reason : unknown ) => TResult2 } onrejected
* @ param { ( reason : unknown ) => TResult2 } onrejected
* /
* /
then ( onfulfilled , onrejected ) {
then ( onfulfilled , onrejected ) {
@ -87,7 +87,8 @@ class RenderResult {
head : result . head ,
head : result . head ,
body : result . body ,
body : result . body ,
html : result . body ,
html : result . body ,
hashes : { script : [ ] }
hashes : { script : [ ] } ,
tail : empty ( )
} ) ;
} ) ;
return Promise . resolve ( user _result ) ;
return Promise . resolve ( user _result ) ;
}
}
@ -102,7 +103,7 @@ class RenderResult {
return result ;
return result ;
} ) ;
} ) ;
return this . # promise . then (
return this . # promise . then (
( result ) => onfulfilled ( /** @type { S yncRenderOutput} */ ( result ) ) ,
( result ) => onfulfilled ( /** @type { As yncRenderOutput} */ ( result ) ) ,
onrejected
onrejected
) ;
) ;
}
}
@ -402,6 +403,57 @@ export class Renderer {
}
}
}
}
/ * *
* Runs the children of a pending boundary in the background , discarding their output , so that the
* data they need starts loading on the server . Only used with ` experimental.streaming ` .
* @ param { ( renderer : Renderer ) => MaybePromise < void > } fn
* /
background ( fn ) {
if ( this . global . mode === 'sync' ) return ;
const state = this . global . get _background ( ) ;
const renderer = new Renderer ( state , this ) ;
state . background _renderers . push ( renderer ) ;
const parent = ssr _context ;
set _ssr _context ( {
... ssr _context ,
p : parent ,
c : null ,
r : renderer ,
i : ssr _context ? . i ? ? false
} ) ;
try {
const result = fn ( renderer ) ;
if ( result instanceof Promise ) {
result . catch ( noop ) ;
result . finally ( ( ) => set _ssr _context ( null ) ) . catch ( noop ) ;
renderer . promise = state . track ( result ) ;
}
} catch {
// the client will render the children itself, and handle the error then
} finally {
set _ssr _context ( parent ) ;
}
}
/ * *
* Called once the background work has settled , or is no longer needed
* @ param { SSRState } state
* /
static finish _background ( state ) {
state . abort ( ) ;
for ( const renderer of state . background _renderers ) {
renderer . # run _on _destroy ( true ) ;
}
state . background _renderers . length = 0 ;
}
/ * *
/ * *
* Create a component renderer . The component renderer inherits the state from the parent ,
* Create a component renderer . The component renderer inherits the state from the parent ,
* but has its own content . It is treated as an ordering boundary for ondestroy callbacks .
* but has its own content . It is treated as an ordering boundary for ondestroy callbacks .
@ -760,12 +812,12 @@ export class Renderer {
* @ param { Component < Props > } component
* @ param { Component < Props > } component
* @ param { { props ? : Omit < Props , '$$slots' | '$$events' > ; context ? : Map < any , any > ; idPrefix ? : string ; csp ? : Csp ; replacer ? : UnevalReplacer } } options
* @ param { { props ? : Omit < Props , '$$slots' | '$$events' > ; context ? : Map < any , any > ; idPrefix ? : string ; csp ? : Csp ; replacer ? : UnevalReplacer } } options
* @ param { RenderContext } context
* @ param { RenderContext } context
* @ returns { Promise < AccumulatedContent & { hashes : { script : Sha256Source [ ] } } > }
* @ returns { Promise < AccumulatedContent & { hashes : { script : Sha256Source [ ] } , tail : AsyncIterable < string > } > }
* /
* /
static async # render _async ( component , options , context ) {
static async # render _async ( component , options , context ) {
const previous _context = ssr _context ;
const previous _context = ssr _context ;
const renderer = Renderer . # create ( 'async' , options ) ;
const renderer = Renderer . # create ( 'async' , options ) ;
/** @type {(AccumulatedContent & { hashes: { script: Sha256Source[] } }) | undefined} */
/** @type {(AccumulatedContent & { hashes: { script: Sha256Source[] } , tail: AsyncIterable<string> }) | undefined} */
let result ;
let result ;
let render _error ;
let render _error ;
let failed = false ;
let failed = false ;
@ -774,25 +826,39 @@ export class Renderer {
try {
try {
Renderer . # open _render ( renderer , component , options ) ;
Renderer . # open _render ( renderer , component , options ) ;
const content = await renderer . # collect _content _async ( ) ;
const content = await renderer . # collect _content _async ( ) ;
const background = renderer . global . background ;
if ( background !== null ) {
context . background = background . settle ( ) ;
}
const warp = await renderer . # collect _warp (
const warp = await renderer . # collect _warp (
context . warp ,
context . warp ,
compose _replacers ( options . replacer , context . replacer )
compose _replacers ( options . replacer , context . replacer ) ,
background
) ;
) ;
if ( warp !== null ) {
if ( warp . head !== null ) {
content . head = warp + content . head ;
content . head = warp . head + content . head ;
}
}
result = Renderer . # close _render ( content , renderer ) ;
result = { ... Renderer . # close _render ( content , renderer ) , tail : warp . tail } ;
} catch ( error ) {
} catch ( error ) {
render _error = error ;
render _error = error ;
failed = true ;
failed = true ;
renderer . global . abort ( ) ;
renderer . global . abort ( ) ;
await renderer . global . settle ( ) ;
await renderer . global . settle ( ) ;
if ( renderer . global . background !== null ) {
context . warp . late = null ;
Renderer . finish _background ( renderer . global . background ) ;
}
}
}
renderer . # run _on _destroy ( failed ) ;
renderer . # run _on _destroy ( failed ) ;
if ( failed ) throw render _error ;
if ( failed ) throw render _error ;
return /** @type {AccumulatedContent & { hashes: { script: Sha256Source[] } }} */ ( result ) ;
return /** @type {AccumulatedContent & { hashes: { script: Sha256Source[] }, tail: AsyncIterable<string> }} */ (
result
) ;
} finally {
} finally {
set _ssr _context ( previous _context ) ;
set _ssr _context ( previous _context ) ;
renderer . global . abort ( ) ;
renderer . global . abort ( ) ;
@ -869,18 +935,27 @@ export class Renderer {
}
}
/ * *
/ * *
* Waits for the values added to ` Warp ` instances to resolve , then serializes them
* Serializes the values added to ` Warp ` instances into a ` <script> ` that recreates them on the client .
* into a ` <script> ` that recreates them on the client .
*
* If there ' s no background work , this waits for every promise to settle first . Otherwise , the work inside
* pending boundaries may still be going on , and the client is waiting for it — so only the promises that
* have already settled are included in the ` <script> ` , and the rest ( along with any values added later )
* are streamed to the client via the ` tail ` .
* @ param { WarpStore } store
* @ param { WarpStore } store
* @ param { UnevalReplacer | undefined } replacer
* @ param { UnevalReplacer | undefined } replacer
* @ returns { Promise < string | null > }
* @ param { SSRState | null } background
* @ returns { Promise < { head : string | null , tail : AsyncIterable < string > } > }
* /
* /
async # collect _warp ( store , replacer ) {
async # collect _warp ( store , replacer , background ) {
// these reject if there's a mismatch. a loop, as more can be added while we're awaiting
// these reject if there's a mismatch. a loop, as more can be added while we're awaiting
for ( let i = 0 ; i < store . comparisons . length ; i += 1 ) {
for ( let i = 0 ; i < store . comparisons . length ; i += 1 ) {
await store . comparisons [ i ] ;
await store . comparisons [ i ] ;
}
}
if ( background !== null && this . global . csp . hash ) {
e . invalid _csp _streaming ( ) ;
}
/** @type {Map<Promise<unknown>, unknown>} */
/** @type {Map<Promise<unknown>, unknown>} */
const resolved = new Map ( ) ;
const resolved = new Map ( ) ;
/** @type {Set<Promise<unknown>>} */
/** @type {Set<Promise<unknown>>} */
@ -889,30 +964,56 @@ export class Renderer {
// values can be added while we're awaiting, and Map iteration includes them
// values can be added while we're awaiting, and Map iteration includes them
for ( const [ id , values ] of store . values ) {
for ( const [ id , values ] of store . values ) {
for ( const [ key , value ] of values ) {
for ( const [ key , value ] of values ) {
await resolve _warp _value ( store , id , key , value , replacer , resolved , visited ) ;
await resolve _warp _value (
store ,
id ,
key ,
value ,
replacer ,
resolved ,
visited ,
background === null
) ;
}
}
}
}
store . emitted = true ;
store . emitted = true ;
/** @type {Map<string, Map<WarpKey, unknown>>} */
/** @type {Map<string, Map<WarpKey, unknown>>} */
const payload = new Map ( ) ;
const values = new Map ( ) ;
for ( const [ id , values ] of store . values ) {
for ( const [ id , v ] of store . values ) {
if ( v alues . size > 0 ) payload . set ( id , v alues ) ;
if ( v . size > 0 ) values . set ( id , v ) ;
}
}
if ( payload. size === 0 ) {
if ( values. size === 0 && background === null ) {
return null ;
return { head : null , tail : empty ( ) } ;
}
}
const late = background === null ? null : stream _late _values ( store , background ) ;
const { head , tail } = devalue . unevalStream (
const { head , tail } = devalue . unevalStream (
payload ,
late === null ? values : [ values , late . values ] ,
( thing , js ) => {
( thing , js ) => {
if ( is _promise ( thing ) && resolved . has ( thing ) ) {
if ( is _promise ( thing ) && resolved . has ( thing ) ) {
return js ` Promise.resolve( ${ resolved . get ( thing ) } ) ` ;
return js ` Promise.resolve( ${ resolved . get ( thing ) } ) ` ;
}
}
if ( thing instanceof LateValues ) {
// add the values to the client's `Warp`s as soon as this is evaluated, rather than
// when the promise resolves, so that they're there before anything awaiting the
// promises resolved in the same block runs
return js ` ((e, n) => {
const w = window . _ _svelte . w ;
for ( const [ id , k , v ] of e ) {
let m = w . get ( id ) ;
if ( ! m ) w . set ( id , ( m = new Map ( ) ) ) ;
m . set ( k , v ) ;
}
return n ;
} ) ( $ { thing . entries } , $ { thing . next } ) ` ;
}
return replacer ? . ( thing , js ) ;
return replacer ? . ( thing , js ) ;
} ,
} ,
{
{
@ -923,17 +1024,25 @@ export class Renderer {
}
}
) ;
) ;
// every promise has settled, so the tail is only rejections and finishes right away
let csp _attr = '' ;
if ( this . global . csp . nonce ) {
csp _attr = ` nonce=" ${ this . global . csp . nonce } " ` ;
}
let blocks = '' ;
let blocks = '' ;
if ( late === null ) {
// every promise has settled, so the tail is only rejections and finishes right away
for await ( const block of tail ) {
for await ( const block of tail ) {
blocks += ` \n \t \t \t ${ block } ` ;
blocks += ` \n \t \t \t \t ${ block } ` ;
}
}
}
const body = `
const body = `
{
{
const w = ( window . _ _svelte ? ? = { } ) . w ? ? = new Map ( ) ;
const w = ( window . _ _svelte ? ? = { } ) . w ? ? = new Map ( ) ;
for ( const [ id , values ] of $ { head} ) {
for ( const [ id , values ] of $ { late === null ? head : ` ( ${ head } )[0] ` } ) {
const existing = w . get ( id ) ;
const existing = w . get ( id ) ;
if ( existing ) {
if ( existing ) {
@ -945,17 +1054,17 @@ export class Renderer {
}
}
` ;
` ;
let csp _attr = '' ;
if ( this . global . csp . hash ) {
if ( this . global . csp . nonce ) {
csp _attr = ` nonce=" ${ this . global . csp . nonce } " ` ;
} else if ( this . global . csp . hash ) {
// note to future selves: this doesn't need to be optimized with a Map<body, hash>
// note to future selves: this doesn't need to be optimized with a Map<body, hash>
// because the it's impossible for identical data to occur multiple times in a single render
// because the it's impossible for identical data to occur multiple times in a single render
const hash = await sha256 ( body ) ;
const hash = await sha256 ( body ) ;
this . global . csp . script _hashes . push ( ` sha256- ${ hash } ` ) ;
this . global . csp . script _hashes . push ( ` sha256- ${ hash } ` ) ;
}
}
return ` \n \t \t <script ${ csp _attr } > ${ body } </script> ` ;
return {
head : ` \n \t \t <script ${ csp _attr } > ${ body } </script> ` ,
tail : late === null ? empty ( ) : stream _scripts ( tail , csp _attr , late . close )
} ;
}
}
/ * *
/ * *
@ -1007,6 +1116,136 @@ export class Renderer {
const PENDING = Symbol ( 'pending' ) ;
const PENDING = Symbol ( 'pending' ) ;
/** Values that were added to `Warp` instances after the `head` was generated */
class LateValues {
/ * *
* @ param { Array < [ string , WarpKey , unknown ] > } entries
* @ param { Promise < LateValues | null > | null } next
* /
constructor ( entries , next ) {
this . entries = entries ;
this . next = next ;
}
}
/** @returns {AsyncIterable<string>} */
async function * empty ( ) { }
/ * *
* Streams the values that are added to ` Warp ` instances after the ` head ` was generated ,
* as a chain of promises that each resolve to a batch of values and the next promise .
* The chain ends once the background work is done .
* @ param { WarpStore } store
* @ param { SSRState } background
* /
function stream _late _values ( store , background ) {
/** @type {Array<[string, WarpKey, unknown]>} */
let entries = [ ] ;
let link = /** @type {ReturnType<typeof deferred<LateValues | null>>} */ ( deferred ( ) ) ;
let scheduled = false ;
let closed = false ;
const values = link . promise ;
function flush ( ) {
if ( ! scheduled || closed ) return ;
scheduled = false ;
const current = link ;
link = deferred ( ) ;
current . resolve ( new LateValues ( entries , link . promise ) ) ;
entries = [ ] ;
}
function close ( ) {
if ( closed ) return ;
closed = true ;
store . late = null ;
link . resolve ( entries . length > 0 ? new LateValues ( entries , null ) : null ) ;
entries = [ ] ;
Renderer . finish _background ( background ) ;
}
store . late = ( id , key , value ) => {
entries . push ( [ id , key , value ] ) ;
if ( ! scheduled ) {
scheduled = true ;
queueMicrotask ( flush ) ;
}
} ;
background . settle ( ) . then ( close , close ) ;
return { values , close } ;
}
/ * *
* @ param { AsyncIterator < string > } tail
* @ param { string } csp _attr
* @ param { ( ) => void } close
* @ returns { AsyncIterable < string > }
* /
function stream _scripts ( tail , csp _attr , close ) {
const generator = generate _scripts ( tail , csp _attr ) ;
/** @type {AsyncIterableIterator<string>} */
const iterator = {
next : ( ) => generator . next ( ) ,
// if the consumer stops early, stop the background work. this is
// done here because `return` doesn't run a generator that hasn't started
return : async ( ) => {
close ( ) ;
await tail . return ? . ( ) ;
return generator . return ( undefined ) ;
} ,
[ Symbol . asyncIterator ] : ( ) => iterator
} ;
return iterator ;
}
/ * *
* Turns the blocks from ` unevalStream ` into ` <script> ` tags . Blocks that arrive at around the same time are
* combined , so that server - side work which was waiting on a promise that just resolved has a chance to add
* the values that depend on it — otherwise , the client might not receive them before it needs them .
* @ param { AsyncIterator < string > } tail
* @ param { string } csp _attr
* @ returns { AsyncGenerator < string > }
* /
async function * generate _scripts ( tail , csp _attr ) {
const MACROTASK = Symbol ( 'macrotask' ) ;
let next = tail . next ( ) ;
while ( true ) {
const result = await next ;
if ( result . done ) return ;
let code = result . value ;
next = tail . next ( ) ;
while ( true ) {
const more = await Promise . race ( [
next ,
new Promise ( ( fulfil ) => setTimeout ( ( ) => fulfil ( MACROTASK ) , 0 ) )
] ) ;
if ( more === MACROTASK ) break ;
const { done , value } = /** @type {IteratorResult<string>} */ ( more ) ;
if ( done ) break ;
code += value ;
next = tail . next ( ) ;
}
yield ` <script ${ csp _attr } > ${ code } </script> ` ;
}
}
/ * *
/ * *
* Returns the outcome of ` promise ` if it has already settled , or ` PENDING ` otherwise
* Returns the outcome of ` promise ` if it has already settled , or ` PENDING ` otherwise
* @ param { Promise < unknown > } promise
* @ param { Promise < unknown > } promise
@ -1030,8 +1269,9 @@ function peek(promise) {
* @ param { UnevalReplacer | undefined } replacer
* @ param { UnevalReplacer | undefined } replacer
* @ param { Map < Promise < unknown > , unknown > } resolved
* @ param { Map < Promise < unknown > , unknown > } resolved
* @ param { Set < Promise < unknown >> } visited
* @ param { Set < Promise < unknown >> } visited
* @ param { boolean } wait Whether to wait for promises that haven ' t settled yet
* /
* /
async function resolve _warp _value ( store , id , key , value , replacer , resolved , visited ) {
async function resolve _warp _value ( store , id , key , value , replacer , resolved , visited , wait ) {
let warned = false ;
let warned = false ;
const queue = [ value ] ;
const queue = [ value ] ;
@ -1068,6 +1308,9 @@ async function resolve_warp_value(store, id, key, value, replacer, resolved, vis
let outcome = await peek ( promise ) ;
let outcome = await peek ( promise ) ;
if ( outcome === PENDING ) {
if ( outcome === PENDING ) {
// this will be streamed to the client once it settles
if ( ! wait ) continue ;
if ( ! warned ) {
if ( ! warned ) {
// this is a problem -- it means we've finished the render but we're still waiting on a promise
// this is a problem -- it means we've finished the render but we're still waiting on a promise
// to resolve so we can serialize it, so we're blocking the response on useless content.
// to resolve so we can serialize it, so we're blocking the response on useless content.
@ -1128,6 +1371,18 @@ export class SSRState {
/** @readonly @type {string} */
/** @readonly @type {string} */
id _prefix ;
id _prefix ;
/ * *
* The state for work started in the background by pending boundaries , if there is any
* @ type { SSRState | null }
* /
background = null ;
/ * *
* The renderers for work started in the background , if this is the state for that work
* @ type { Renderer [ ] }
* /
background _renderers = [ ] ;
/** @readonly @type {Set<{ hash: string; code: string }>} */
/** @readonly @type {Set<{ hash: string; code: string }>} */
css = new Set ( ) ;
css = new Set ( ) ;
@ -1203,6 +1458,17 @@ export class SSRState {
return controller . signal ;
return controller . signal ;
}
}
/** @returns {SSRState} */
get _background ( ) {
if ( this . background === null ) {
// background work gets its own state, so that it can't affect the rendered output
this . background = new SSRState ( this . mode , this . id _prefix , this . csp , this . transformError ) ;
this . background . background = this . background ;
}
return this . background ;
}
get _title ( ) {
get _title ( ) {
return this . # title . value ;
return this . # title . value ;
}
}