diff --git a/deno_dist/context.ts b/deno_dist/context.ts index 3e3d87ec9..7304d51c5 100644 --- a/deno_dist/context.ts +++ b/deno_dist/context.ts @@ -19,7 +19,7 @@ export interface ContextVariableMap {} export interface ContextRenderer {} interface DefaultRenderer { - (content: string): Response | Promise + (content: string | Promise): Response | Promise } export type Renderer = ContextRenderer extends Function ? ContextRenderer : DefaultRenderer @@ -75,8 +75,10 @@ interface JSONTRespond { } interface HTMLRespond { - (html: string, status?: StatusCode, headers?: HeaderRecord): Response - (html: string, init?: ResponseInit): Response + (html: string | Promise, status?: StatusCode, headers?: HeaderRecord): + | Response + | Promise + (html: string | Promise, init?: ResponseInit): Response | Promise } type ContextOptions = { @@ -106,7 +108,7 @@ export class Context< private _pH: Record | undefined = undefined // _preparedHeaders private _res: Response | undefined private _init = true - private _renderer: Renderer = (content: string) => this.html(content) + private _renderer: Renderer = (content: string | Promise) => this.html(content) private notFoundHandler: NotFoundHandler = () => new Response() constructor(req: HonoRequest, options?: ContextOptions) { @@ -356,15 +358,29 @@ export class Context< } html: HTMLRespond = ( - html: string, + html: string | Promise, arg?: StatusCode | ResponseInit, headers?: HeaderRecord - ): Response => { + ): Response | Promise => { this._pH ??= {} this._pH['content-type'] = 'text/html; charset=UTF-8' + + if (typeof html === 'object') { + if (!(html instanceof Promise)) { + html = (html as string).toString() // HtmlEscapedString object to string + } + if ((html as string | Promise) instanceof Promise) { + return (html as unknown as Promise).then((html) => { + return typeof arg === 'number' + ? this.newResponse(html, arg, headers) + : this.newResponse(html, arg) + }) + } + } + return typeof arg === 'number' - ? this.newResponse(html, arg, headers) - : this.newResponse(html, arg) + ? this.newResponse(html as string, arg, headers) + : this.newResponse(html as string, arg) } redirect = (location: string, status: StatusCode = 302): Response => { diff --git a/deno_dist/helper/html/index.ts b/deno_dist/helper/html/index.ts index 82ad4dd2f..27f5350ea 100644 --- a/deno_dist/helper/html/index.ts +++ b/deno_dist/helper/html/index.ts @@ -1,4 +1,4 @@ -import { escapeToBuffer } from '../../utils/html.ts' +import { escapeToBuffer, stringBufferToString } from '../../utils/html.ts' import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../../utils/html.ts' export const raw = (value: unknown): HtmlEscapedString => { @@ -8,7 +8,10 @@ export const raw = (value: unknown): HtmlEscapedString => { return escapedString } -export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlEscapedString => { +export const html = ( + strings: TemplateStringsArray, + ...values: unknown[] +): HtmlEscapedString | Promise => { const buffer: StringBuffer = [''] for (let i = 0, len = strings.length - 1; i < len; i++) { @@ -27,7 +30,12 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlE (typeof child === 'object' && (child as HtmlEscaped).isEscaped) || typeof child === 'number' ) { - buffer[0] += child + const tmp = child.toString() + if (tmp instanceof Promise) { + buffer.unshift('', tmp) + } else { + buffer[0] += tmp + } } else { escapeToBuffer(child.toString(), buffer) } @@ -35,5 +43,5 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlE } buffer[0] += strings[strings.length - 1] - return raw(buffer[0]) + return buffer.length === 1 ? raw(buffer[0]) : stringBufferToString(buffer).then((str) => raw(str)) } diff --git a/deno_dist/jsx/index.ts b/deno_dist/jsx/index.ts index 4fdaced07..8048348fe 100644 --- a/deno_dist/jsx/index.ts +++ b/deno_dist/jsx/index.ts @@ -1,4 +1,4 @@ -import { escapeToBuffer } from '../utils/html.ts' +import { escapeToBuffer, stringBufferToString } from '../utils/html.ts' import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html.ts' import type { IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements.ts' @@ -8,7 +8,7 @@ type Props = Record declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { - type Element = HtmlEscapedString + type Element = HtmlEscapedString | Promise interface ElementChildrenAttribute { children: Child } @@ -76,7 +76,9 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void typeof child === 'number' || (child as unknown as { isEscaped: boolean }).isEscaped ) { - buffer[0] += child + ;(buffer[0] as string) += child + } else if (child instanceof Promise) { + buffer.unshift('', child) } else { // `child` type is `Child[]`, so stringify recursively childrenToStringToBuffer(child, buffer) @@ -84,7 +86,7 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void } } -type Child = string | number | JSXNode | Child[] +export type Child = string | Promise | number | JSXNode | Child[] export class JSXNode implements HtmlEscaped { tag: string | Function props: Props @@ -96,10 +98,10 @@ export class JSXNode implements HtmlEscaped { this.children = children } - toString(): string { + toString(): string | Promise { const buffer: StringBuffer = [''] this.toStringToBuffer(buffer) - return buffer[0] + return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) } toStringToBuffer(buffer: StringBuffer): void { @@ -172,7 +174,9 @@ class JSXFunctionNode extends JSXNode { children: children.length <= 1 ? children[0] : children, }) - if (res instanceof JSXNode) { + if (res instanceof Promise) { + buffer.unshift('', res) + } else if (res instanceof JSXNode) { res.toStringToBuffer(buffer) } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { buffer[0] += res @@ -201,7 +205,9 @@ const jsxFn = ( } } -export type FC = (props: T & { children?: Child }) => HtmlEscapedString +export type FC = ( + props: T & { children?: Child } +) => HtmlEscapedString | Promise const shallowEqual = (a: Props, b: Props): boolean => { if (a === b) { diff --git a/deno_dist/jsx/streaming.ts b/deno_dist/jsx/streaming.ts new file mode 100644 index 000000000..f0a8d3d36 --- /dev/null +++ b/deno_dist/jsx/streaming.ts @@ -0,0 +1,100 @@ +import type { HtmlEscapedString } from '../utils/html.ts' +import type { FC, Child } from './index.ts' + +let suspenseCounter = 0 + +async function childrenToString(children: Child): Promise { + try { + return children.toString() + } catch (e) { + if (e instanceof Promise) { + await e + return childrenToString(children) + } else { + throw e + } + } +} + +/** + * @experimental + * `Suspense` is an experimental feature. + * The API might be changed. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => { + if (!children) { + return fallback.toString() + } + + let res + try { + res = children.toString() + } catch (e) { + if (e instanceof Promise) { + res = e + } else { + throw e + } + } finally { + const index = suspenseCounter++ + if (res instanceof Promise) { + const promise = res + res = new String( + `${fallback.toString()}` + ) as HtmlEscapedString + res.isEscaped = true + res.promises = [ + promise.then(async () => { + return `` + }), + ] + } + } + return res as HtmlEscapedString +} + +const textEncoder = new TextEncoder() +/** + * @experimental + * `renderToReadableStream()` is an experimental feature. + * The API might be changed. + */ +export const renderToReadableStream = ( + str: HtmlEscapedString | Promise +): ReadableStream => { + const reader = new ReadableStream({ + async start(controller) { + const resolved = await str.toString() + controller.enqueue(textEncoder.encode(resolved)) + + let unresolvedCount = (resolved as HtmlEscapedString).promises?.length || 0 + if (!unresolvedCount) { + controller.close() + return + } + + for (let i = 0; i < unresolvedCount; i++) { + ;((resolved as HtmlEscapedString).promises as Promise[])[i] + .catch((err) => { + console.trace(err) + return '' + }) + .then((res) => { + controller.enqueue(textEncoder.encode(res)) + if (!--unresolvedCount) { + controller.close() + } + }) + } + }, + }) + return reader +} diff --git a/deno_dist/utils/html.ts b/deno_dist/utils/html.ts index 51a22cb5c..f4fef4602 100644 --- a/deno_dist/utils/html.ts +++ b/deno_dist/utils/html.ts @@ -1,12 +1,33 @@ -export type HtmlEscaped = { isEscaped: true } +export type HtmlEscaped = { isEscaped: true; promises?: Promise[] } export type HtmlEscapedString = string & HtmlEscaped -export type StringBuffer = [string] +export type StringBuffer = (string | Promise)[] // The `escapeToBuffer` implementation is based on code from the MIT licensed `react-dom` package. // https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/server/escapeTextForBrowser.js const escapeRe = /[&<>'"]/ +export const stringBufferToString = async (buffer: StringBuffer): Promise => { + let str = '' + const promises: Promise[] = [] + for (let i = buffer.length - 1; i >= 0; i--) { + let r = await buffer[i] + if (typeof r === 'object') { + promises.push(...((r as HtmlEscapedString).promises || [])) + } + r = await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) + if (typeof r === 'object') { + promises.push(...((r as HtmlEscapedString).promises || [])) + } + str += r + } + + const res = new String(str) as HtmlEscapedString + res.isEscaped = true + res.promises = promises + return res +} + export const escapeToBuffer = (str: string, buffer: StringBuffer): void => { const match = str.search(escapeRe) if (match === -1) { diff --git a/package.json b/package.json index ccc136a87..8256419b8 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,11 @@ "import": "./dist/jsx/jsx-runtime.js", "require": "./dist/cjs/jsx/jsx-runtime.js" }, + "./jsx/streaming": { + "types": "./dist/types/jsx/streaming.d.ts", + "import": "./dist/jsx/streaming.js", + "require": "./dist/cjs/jsx/streaming.js" + }, "./jsx-renderer": { "types": "./dist/types/middleware/jsx-renderer/index.d.ts", "import": "./dist/middleware/jsx-renderer/index.js", @@ -304,6 +309,9 @@ "jsx/jsx-dev-runtime": [ "./dist/types/jsx/jsx-dev-runtime.d.ts" ], + "jsx/streaming": [ + "./dist/types/jsx/streaming.d.ts" + ], "jsx-renderer": [ "./dist/types/middleware/jsx-renderer" ], @@ -424,6 +432,7 @@ "@types/crypto-js": "^4.1.1", "@types/glob": "^8.0.0", "@types/jest": "^29.4.0", + "@types/jsdom": "^21.1.4", "@types/node": "^20.8.2", "@types/node-fetch": "^2.6.2", "@types/supertest": "^2.0.12", @@ -445,6 +454,7 @@ "form-data": "^4.0.0", "jest": "^29.6.4", "jest-preset-fastly-js-compute": "^1.3.0", + "jsdom": "^22.1.0", "msw": "^1.0.0", "node-fetch": "2", "np": "^7.7.0", diff --git a/src/context.test.ts b/src/context.test.ts index 77aacd64f..84956bf57 100644 --- a/src/context.test.ts +++ b/src/context.test.ts @@ -27,7 +27,7 @@ describe('Context', () => { }) it('c.html()', async () => { - const res = c.html('

Hello! Hono!

', 201, { 'X-Custom': 'Message' }) + const res = await c.html('

Hello! Hono!

', 201, { 'X-Custom': 'Message' }) expect(res.status).toBe(201) expect(res.headers.get('Content-Type')).toMatch('text/html') expect(await res.text()).toBe('

Hello! Hono!

') @@ -60,7 +60,7 @@ describe('Context', () => { it('c.header() - append, c.html()', async () => { c.header('X-Foo', 'Bar', { append: true }) - const res = c.html('

This rendered fine

') + const res = await c.html('

This rendered fine

') expect(res.headers.get('content-type')).toMatch(/^text\/html/) }) @@ -189,11 +189,11 @@ describe('Context header', () => { }) it('Should return only one content-type value', async () => { c.header('Content-Type', 'foo') - const res = c.html('foo') + const res = await c.html('foo') expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') }) it('Should rewrite header values correctly', async () => { - c.res = c.html('foo') + c.res = await c.html('foo') const res = c.text('foo') expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/) }) @@ -257,7 +257,7 @@ describe('Pass a ResponseInit to respond methods', () => { it('c.html()', async () => { const originalResponse = new Response('foo') - const res = c.html('

foo

', originalResponse) + const res = await c.html('

foo

', originalResponse) expect(res.headers.get('content-type')).toMatch(/^text\/html/) expect(await res.text()).toBe('

foo

') }) @@ -306,7 +306,7 @@ describe('Pass a ResponseInit to respond methods', () => { declare module './context' { interface ContextRenderer { - (content: string, head: { title: string }): Response + (content: string | Promise, head: { title: string }): Response | Promise } } @@ -319,7 +319,7 @@ describe('c.render', () => { it('Should return a Response from the default renderer', async () => { c.header('foo', 'bar') - const res = c.render('

content

', { title: 'dummy ' }) + const res = await c.render('

content

', { title: 'dummy ' }) expect(res.headers.get('foo')).toBe('bar') expect(await res.text()).toBe('

content

') }) @@ -329,7 +329,7 @@ describe('c.render', () => { return c.html(`${head.title}${content}`) }) c.header('foo', 'bar') - const res = c.render('

content

', { title: 'title' }) + const res = await c.render('

content

', { title: 'title' }) expect(res.headers.get('foo')).toBe('bar') expect(await res.text()).toBe('title

content

') }) diff --git a/src/context.ts b/src/context.ts index 4ca04362a..7c670a0c3 100644 --- a/src/context.ts +++ b/src/context.ts @@ -19,7 +19,7 @@ export interface ContextVariableMap {} export interface ContextRenderer {} interface DefaultRenderer { - (content: string): Response | Promise + (content: string | Promise): Response | Promise } export type Renderer = ContextRenderer extends Function ? ContextRenderer : DefaultRenderer @@ -75,8 +75,10 @@ interface JSONTRespond { } interface HTMLRespond { - (html: string, status?: StatusCode, headers?: HeaderRecord): Response - (html: string, init?: ResponseInit): Response + (html: string | Promise, status?: StatusCode, headers?: HeaderRecord): + | Response + | Promise + (html: string | Promise, init?: ResponseInit): Response | Promise } type ContextOptions = { @@ -106,7 +108,7 @@ export class Context< private _pH: Record | undefined = undefined // _preparedHeaders private _res: Response | undefined private _init = true - private _renderer: Renderer = (content: string) => this.html(content) + private _renderer: Renderer = (content: string | Promise) => this.html(content) private notFoundHandler: NotFoundHandler = () => new Response() constructor(req: HonoRequest, options?: ContextOptions) { @@ -356,15 +358,29 @@ export class Context< } html: HTMLRespond = ( - html: string, + html: string | Promise, arg?: StatusCode | ResponseInit, headers?: HeaderRecord - ): Response => { + ): Response | Promise => { this._pH ??= {} this._pH['content-type'] = 'text/html; charset=UTF-8' + + if (typeof html === 'object') { + if (!(html instanceof Promise)) { + html = (html as string).toString() // HtmlEscapedString object to string + } + if ((html as string | Promise) instanceof Promise) { + return (html as unknown as Promise).then((html) => { + return typeof arg === 'number' + ? this.newResponse(html, arg, headers) + : this.newResponse(html, arg) + }) + } + } + return typeof arg === 'number' - ? this.newResponse(html, arg, headers) - : this.newResponse(html, arg) + ? this.newResponse(html as string, arg, headers) + : this.newResponse(html as string, arg) } redirect = (location: string, status: StatusCode = 302): Response => { diff --git a/src/helper/html/index.ts b/src/helper/html/index.ts index 748b2d0a0..483621d37 100644 --- a/src/helper/html/index.ts +++ b/src/helper/html/index.ts @@ -1,4 +1,4 @@ -import { escapeToBuffer } from '../../utils/html' +import { escapeToBuffer, stringBufferToString } from '../../utils/html' import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../../utils/html' export const raw = (value: unknown): HtmlEscapedString => { @@ -8,7 +8,10 @@ export const raw = (value: unknown): HtmlEscapedString => { return escapedString } -export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlEscapedString => { +export const html = ( + strings: TemplateStringsArray, + ...values: unknown[] +): HtmlEscapedString | Promise => { const buffer: StringBuffer = [''] for (let i = 0, len = strings.length - 1; i < len; i++) { @@ -27,7 +30,12 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlE (typeof child === 'object' && (child as HtmlEscaped).isEscaped) || typeof child === 'number' ) { - buffer[0] += child + const tmp = child.toString() + if (tmp instanceof Promise) { + buffer.unshift('', tmp) + } else { + buffer[0] += tmp + } } else { escapeToBuffer(child.toString(), buffer) } @@ -35,5 +43,5 @@ export const html = (strings: TemplateStringsArray, ...values: unknown[]): HtmlE } buffer[0] += strings[strings.length - 1] - return raw(buffer[0]) + return buffer.length === 1 ? raw(buffer[0]) : stringBufferToString(buffer).then((str) => raw(str)) } diff --git a/src/hono.test.ts b/src/hono.test.ts index 3d0e7ccbe..29b15cc71 100644 --- a/src/hono.test.ts +++ b/src/hono.test.ts @@ -2409,7 +2409,7 @@ describe('HEAD method', () => { declare module './context' { interface ContextRenderer { - (content: string, head: { title: string }): Response + (content: string | Promise, head: { title: string }): Response | Promise } } diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index f1e2d2b6d..ee11e9e74 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -67,6 +67,50 @@ describe('JSX middleware', () => { `) }) + + it('Should render async component', async () => { + const ChildAsyncComponent = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return child async component + } + + const AsyncComponent = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return ( +

+ Hello from async component + +

+ ) + } + + app.get('/', (c) => { + return c.html() + }) + const res = await app.request('http://localhost/') + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') + expect(await res.text()).toBe( + '

Hello from async componentchild async component

' + ) + }) + + it('Should render async component with "html" tagged template strings', async () => { + const AsyncComponent = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return

Hello from async component

+ } + + app.get('/', (c) => { + return c.html( + html`${()}` + ) + }) + const res = await app.request('http://localhost/') + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') + expect(await res.text()).toBe('

Hello from async component

') + }) }) describe('render to string', () => { diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 5010a8c45..0e141deaa 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -1,4 +1,4 @@ -import { escapeToBuffer } from '../utils/html' +import { escapeToBuffer, stringBufferToString } from '../utils/html' import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html' import type { IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements' @@ -8,7 +8,7 @@ type Props = Record declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { - type Element = HtmlEscapedString + type Element = HtmlEscapedString | Promise interface ElementChildrenAttribute { children: Child } @@ -76,7 +76,9 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void typeof child === 'number' || (child as unknown as { isEscaped: boolean }).isEscaped ) { - buffer[0] += child + ;(buffer[0] as string) += child + } else if (child instanceof Promise) { + buffer.unshift('', child) } else { // `child` type is `Child[]`, so stringify recursively childrenToStringToBuffer(child, buffer) @@ -84,7 +86,7 @@ const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void } } -type Child = string | number | JSXNode | Child[] +export type Child = string | Promise | number | JSXNode | Child[] export class JSXNode implements HtmlEscaped { tag: string | Function props: Props @@ -96,10 +98,10 @@ export class JSXNode implements HtmlEscaped { this.children = children } - toString(): string { + toString(): string | Promise { const buffer: StringBuffer = [''] this.toStringToBuffer(buffer) - return buffer[0] + return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer) } toStringToBuffer(buffer: StringBuffer): void { @@ -172,7 +174,9 @@ class JSXFunctionNode extends JSXNode { children: children.length <= 1 ? children[0] : children, }) - if (res instanceof JSXNode) { + if (res instanceof Promise) { + buffer.unshift('', res) + } else if (res instanceof JSXNode) { res.toStringToBuffer(buffer) } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { buffer[0] += res @@ -201,7 +205,9 @@ const jsxFn = ( } } -export type FC = (props: T & { children?: Child }) => HtmlEscapedString +export type FC = ( + props: T & { children?: Child } +) => HtmlEscapedString | Promise const shallowEqual = (a: Props, b: Props): boolean => { if (a === b) { diff --git a/src/jsx/streaming.test.tsx b/src/jsx/streaming.test.tsx new file mode 100644 index 000000000..6ae1a4568 --- /dev/null +++ b/src/jsx/streaming.test.tsx @@ -0,0 +1,275 @@ +import { JSDOM } from 'jsdom' +import type { HtmlEscapedString } from '../utils/html' +import { Suspense, renderToReadableStream } from './streaming' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { jsx, Fragment } from './index' + +function replacementResult(html: string) { + const document = new JSDOM(html, { runScripts: 'dangerously' }).window.document + document.querySelectorAll('template, script').forEach((e) => e.remove()) + return document.body.innerHTML +} + +describe('Streaming', () => { + let suspenseCounter = 0 + afterEach(() => { + suspenseCounter++ + }) + + it('Suspense / renderToReadableStream', async () => { + const Content = () => { + const content = new Promise((resolve) => + setTimeout(() => resolve(

Hello

), 10) + ) + return content + } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + ``, + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Hello

' + ) + }) + + it('simple content inside Suspense', async () => { + const Content = () => { + return

Hello

+ } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual(['

Hello

']) + }) + + it('resolve(undefined)', async () => { + const Content = async () => { + const content = await Promise.resolve(undefined) + return

{content}

+ } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + ``, + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual('

') + }) + + it('resolve(null)', async () => { + const Content = async () => { + const content = await Promise.resolve(null) + return

{content}

+ } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + ``, + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual('

') + }) + + // This test should end successfully , but vitest catches the global unhandledRejection and makes an error, so it temporarily skips + it.skip('reject()', async () => { + const Content = async () => { + const content = await Promise.reject() + return

{content}

+ } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + '', + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Loading...

' + ) + }) + + it('Multiple calls to "use"', async () => { + const delayedContent = new Promise((resolve) => + setTimeout(() => resolve(

Hello

), 10) + ) + const delayedContent2 = new Promise((resolve) => + setTimeout(() => resolve(

World

), 10) + ) + const Content = async () => { + const content = await delayedContent + const content2 = await delayedContent2 + return ( + <> + {content} + {content2} + + ) + } + + const stream = renderToReadableStream( + Loading...

}> + +
+ ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `

Loading...

`, + ``, + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Hello

World

' + ) + }) + + it('Complex fallback content', async () => { + const delayedContent = new Promise((resolve) => + setTimeout(() => resolve(

Hello

), 10) + ) + + const Content = async () => { + const content = await delayedContent + return content + } + + const stream = renderToReadableStream( + + Loading... + + } + > + + + ) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([ + `Loading...`, + ``, + ]) + + expect(replacementResult(`${chunks.join('')}`)).toEqual( + '

Hello

' + ) + }) + + it('renderToReadableStream(str: string)', async () => { + const str = '

Hello

' + const stream = renderToReadableStream(str as HtmlEscapedString) + + const chunks = [] + const textDecoder = new TextDecoder() + for await (const chunk of stream as any) { + chunks.push(textDecoder.decode(chunk)) + } + + expect(chunks).toEqual([str]) + }) +}) diff --git a/src/jsx/streaming.ts b/src/jsx/streaming.ts new file mode 100644 index 000000000..307dcf5c9 --- /dev/null +++ b/src/jsx/streaming.ts @@ -0,0 +1,100 @@ +import type { HtmlEscapedString } from '../utils/html' +import type { FC, Child } from './index' + +let suspenseCounter = 0 + +async function childrenToString(children: Child): Promise { + try { + return children.toString() + } catch (e) { + if (e instanceof Promise) { + await e + return childrenToString(children) + } else { + throw e + } + } +} + +/** + * @experimental + * `Suspense` is an experimental feature. + * The API might be changed. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const Suspense: FC<{ fallback: any }> = async ({ children, fallback }) => { + if (!children) { + return fallback.toString() + } + + let res + try { + res = children.toString() + } catch (e) { + if (e instanceof Promise) { + res = e + } else { + throw e + } + } finally { + const index = suspenseCounter++ + if (res instanceof Promise) { + const promise = res + res = new String( + `${fallback.toString()}` + ) as HtmlEscapedString + res.isEscaped = true + res.promises = [ + promise.then(async () => { + return `` + }), + ] + } + } + return res as HtmlEscapedString +} + +const textEncoder = new TextEncoder() +/** + * @experimental + * `renderToReadableStream()` is an experimental feature. + * The API might be changed. + */ +export const renderToReadableStream = ( + str: HtmlEscapedString | Promise +): ReadableStream => { + const reader = new ReadableStream({ + async start(controller) { + const resolved = await str.toString() + controller.enqueue(textEncoder.encode(resolved)) + + let unresolvedCount = (resolved as HtmlEscapedString).promises?.length || 0 + if (!unresolvedCount) { + controller.close() + return + } + + for (let i = 0; i < unresolvedCount; i++) { + ;((resolved as HtmlEscapedString).promises as Promise[])[i] + .catch((err) => { + console.trace(err) + return '' + }) + .then((res) => { + controller.enqueue(textEncoder.encode(res)) + if (!--unresolvedCount) { + controller.close() + } + }) + } + }, + }) + return reader +} diff --git a/src/utils/html.ts b/src/utils/html.ts index 51a22cb5c..f4fef4602 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -1,12 +1,33 @@ -export type HtmlEscaped = { isEscaped: true } +export type HtmlEscaped = { isEscaped: true; promises?: Promise[] } export type HtmlEscapedString = string & HtmlEscaped -export type StringBuffer = [string] +export type StringBuffer = (string | Promise)[] // The `escapeToBuffer` implementation is based on code from the MIT licensed `react-dom` package. // https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/server/escapeTextForBrowser.js const escapeRe = /[&<>'"]/ +export const stringBufferToString = async (buffer: StringBuffer): Promise => { + let str = '' + const promises: Promise[] = [] + for (let i = buffer.length - 1; i >= 0; i--) { + let r = await buffer[i] + if (typeof r === 'object') { + promises.push(...((r as HtmlEscapedString).promises || [])) + } + r = await (typeof r === 'object' ? (r as HtmlEscapedString).toString() : r) + if (typeof r === 'object') { + promises.push(...((r as HtmlEscapedString).promises || [])) + } + str += r + } + + const res = new String(str) as HtmlEscapedString + res.isEscaped = true + res.promises = promises + return res +} + export const escapeToBuffer = (str: string, buffer: StringBuffer): void => { const match = str.search(escapeRe) if (match === -1) { diff --git a/yarn.lock b/yarn.lock index 90fced206..73cce7c6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1365,6 +1365,11 @@ dependencies: defer-to-connect "^2.0.0" +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== + "@types/babel__core@^7.1.14": version "7.20.1" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.1.tgz#916ecea274b0c776fec721e333e55762d3a9614b" @@ -1506,6 +1511,15 @@ resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5" integrity sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g== +"@types/jsdom@^21.1.4": + version "21.1.4" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.4.tgz#82105c8fb5a1072265dde1a180336ca74a8fbabf" + integrity sha512-NzAMLEV0KQ4cBaDx3Ls8VfJUElyDUm1xrtYRmcMK0gF8L5xYbujFVaQlJ50yinQ/d47j2rEP1XUzkiYrw4YRFA== + dependencies: + "@types/node" "*" + "@types/tough-cookie" "*" + parse5 "^7.0.0" + "@types/json-buffer@~3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/json-buffer/-/json-buffer-3.0.0.tgz#85c1ff0f0948fc159810d4b5be35bf8c20875f64" @@ -1615,6 +1629,11 @@ dependencies: "@types/superagent" "*" +"@types/tough-cookie@*": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.4.tgz#cf2f0c7c51b985b6afecea73eb2cd65421ecb717" + integrity sha512-95Sfz4nvMAb0Nl9DTxN3j64adfwfbBPEYq14VN7zT5J5O2M9V6iZMIIQU1U+pJyl9agHYHNCqhCXgyEtIRRa5A== + "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" @@ -1781,6 +1800,11 @@ resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b" integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== +abab@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1801,6 +1825,13 @@ acorn@^8.8.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -2601,12 +2632,28 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +cssstyle@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a" + integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg== + dependencies: + rrweb-cssom "^0.6.0" + +data-urls@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4" + integrity sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g== + dependencies: + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.0" + date-fns@^1.27.2: version "1.30.1" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== -debug@4.3.4, debug@^4.1.0, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2640,6 +2687,11 @@ decamelize@^1.1.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + decompress-response@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" @@ -2812,6 +2864,13 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== + dependencies: + webidl-conversions "^7.0.0" + dot-prop@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" @@ -2876,6 +2935,11 @@ enhanced-resolve@^5.12.0: graceful-fs "^4.2.4" tapable "^2.2.0" +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -4100,6 +4164,13 @@ hosted-git-info@^4.0.1: dependencies: lru-cache "^6.0.0" +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== + dependencies: + whatwg-encoding "^2.0.0" + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -4115,6 +4186,23 @@ http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0: resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -4125,6 +4213,13 @@ human-signals@^3.0.1: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -4528,6 +4623,11 @@ is-plain-object@^5.0.0: resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-promise@^2.1.0: version "2.2.2" resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" @@ -5166,6 +5266,35 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsdom@^22.1.0: + version "22.1.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-22.1.0.tgz#0fca6d1a37fbeb7f4aac93d1090d782c56b611c8" + integrity sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw== + dependencies: + abab "^2.0.6" + cssstyle "^3.0.0" + data-urls "^4.0.0" + decimal.js "^10.4.3" + domexception "^4.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.4" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.2" + w3c-xmlserializer "^4.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.1" + ws "^8.13.0" + xml-name-validator "^4.0.0" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -5931,6 +6060,11 @@ number-is-nan@^1.0.0: resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" integrity sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ== +nwsapi@^2.2.4: + version "2.2.7" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" + integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== + object-assign@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -6231,6 +6365,13 @@ parse-package-name@^1.0.0: resolved "https://registry.yarnpkg.com/parse-package-name/-/parse-package-name-1.0.0.tgz#1a108757e4ffc6889d5e78bcc4932a97c097a5a7" integrity sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg== +parse5@^7.0.0, parse5@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + path-depth@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/path-depth/-/path-depth-1.0.0.tgz#88cf881097e171b8b54d450d2167ea76063d3086" @@ -6383,6 +6524,11 @@ ps-tree@1.2.0: dependencies: event-stream "=3.3.4" +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + publint@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/publint/-/publint-0.1.8.tgz#a679fccc67578f232ecffa568acfccf3a7c983c5" @@ -6405,6 +6551,11 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +punycode@^2.1.1, punycode@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + pupa@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" @@ -6424,6 +6575,11 @@ qs@^6.11.0: dependencies: side-channel "^1.0.4" +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -6530,6 +6686,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -6660,6 +6821,11 @@ rollup@^3.27.1: optionalDependencies: fsevents "~2.3.2" +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + run-async@^2.2.0, run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -6719,11 +6885,18 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" -"safer-buffer@>= 2.1.2 < 3": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + scoped-regex@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-2.1.0.tgz#7b9be845d81fd9d21d1ec97c61a0b7cf86d2015f" @@ -7235,6 +7408,11 @@ symbol-observable@^3.0.0: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-3.0.0.tgz#eea8f6478c651018e059044268375c408c15c533" integrity sha512-6tDOXSHiVjuCaasQSWTmHUWn4PuG7qa3+1WT031yTc/swT7+rLiw3GOrFxaH1E3lLP09dH3bVuVDf2gK5rxG3Q== +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + synckit@^0.8.5: version "0.8.5" resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.5.tgz#b7f4358f9bb559437f9f167eb6bc46b3c9818fa3" @@ -7332,6 +7510,23 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tough-cookie@^4.1.2: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" + integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== + dependencies: + punycode "^2.3.0" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -7521,6 +7716,11 @@ universal-user-agent@^6.0.0: resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + update-browserslist-db@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" @@ -7568,6 +7768,14 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + urlpattern-polyfill@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-4.0.3.tgz#c1fa7a73eb4e6c6a1ffb41b24cf31974f7392d3b" @@ -7678,6 +7886,13 @@ vitest@^0.34.3: vite-node "0.34.3" why-is-node-running "^2.2.2" +w3c-xmlserializer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" + integrity sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw== + dependencies: + xml-name-validator "^4.0.0" + wait-on@6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-6.0.1.tgz#16bbc4d1e4ebdd41c5b4e63a2e16dbd1f4e5601e" @@ -7717,6 +7932,31 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + +whatwg-url@^12.0.0, whatwg-url@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-12.0.1.tgz#fd7bcc71192e7c3a2a97b9a8d6b094853ed8773c" + integrity sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ== + dependencies: + tr46 "^4.1.1" + webidl-conversions "^7.0.0" + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -7838,6 +8078,11 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" +ws@^8.13.0: + version "8.14.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" + integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== + ws@^8.2.2: version "8.4.2" resolved "https://registry.yarnpkg.com/ws/-/ws-8.4.2.tgz#18e749868d8439f2268368829042894b6907aa0b" @@ -7848,6 +8093,16 @@ xdg-basedir@^4.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + xxhash-wasm@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/xxhash-wasm/-/xxhash-wasm-1.0.2.tgz#ecc0f813219b727af4d5f3958ca6becee2f2f1ff"