diff --git a/packages/browser/package.json b/packages/browser/package.json index 81af32b2b..ab70f5374 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -42,7 +42,8 @@ "build": "rollup --config ./rollup.config.js" }, "dependencies": { - "@quilted/signals": "^0.2.1", + "@quilted/assets": "workspace:^0.1.1", + "@quilted/signals": "workspace:^0.2.1", "js-cookie": "^3.0.0" }, "peerDependencies": {}, diff --git a/packages/browser/source/browser.ts b/packages/browser/source/browser.ts index 62901c6b3..2a58549f6 100644 --- a/packages/browser/source/browser.ts +++ b/packages/browser/source/browser.ts @@ -180,6 +180,10 @@ export class BrowserSerializations { this.serializations.set(id, data); } } + + *[Symbol.iterator]() { + yield* this.serializations; + } } function getSerializedFromNode(node: Element): T | undefined { diff --git a/packages/browser/source/server.ts b/packages/browser/source/server.ts index 716a75bc4..1e34c0516 100644 --- a/packages/browser/source/server.ts +++ b/packages/browser/source/server.ts @@ -1,6 +1,11 @@ import {resolveSignalOrValue, type ReadonlySignal} from '@quilted/signals'; import type {BrowserDetails, CookieOptions, Cookies} from './types.ts'; +import type { + AssetLoadTiming, + AssetsCacheKey, + BrowserAssetModuleSelector, +} from '@quilted/assets'; export * from './types.ts'; @@ -13,14 +18,17 @@ export class BrowserResponse implements BrowserDetails { readonly serializations: BrowserResponseSerializations; readonly headers: Headers; readonly initialURL: URL; + readonly assets: BrowserResponseAssets; constructor({ request, headers = new Headers(), + cacheKey, serializations, }: { request: Request; headers?: Headers; + cacheKey?: Partial; serializations?: Iterable<[string, unknown]>; }) { this.initialURL = new URL(request.url); @@ -29,6 +37,7 @@ export class BrowserResponse implements BrowserDetails { headers, request.headers.get('Cookie') ?? undefined, ); + this.assets = new BrowserResponseAssets({cacheKey}); this.serializations = new BrowserResponseSerializations( new Map(serializations), ); @@ -136,6 +145,91 @@ export class BrowserResponseSerializations { this.serializations.set(id, data); } } + + *[Symbol.iterator]() { + yield* this.serializations; + } +} + +const ASSET_TIMING_PRIORITY: AssetLoadTiming[] = ['never', 'preload', 'load']; + +const PRIORITY_BY_TIMING = new Map( + ASSET_TIMING_PRIORITY.map((value, index) => [value, index]), +); + +export class BrowserResponseAssets { + readonly cacheKey: Partial; + private usedModulesWithTiming = new Map< + string, + { + styles: AssetLoadTiming; + scripts: AssetLoadTiming; + } + >(); + + constructor({cacheKey}: {cacheKey?: Partial} = {}) { + this.cacheKey = {...cacheKey}; + } + + updateCacheKey(cacheKey: Partial) { + Object.assign(this.cacheKey, cacheKey); + } + + use( + id: string, + { + timing = 'load', + scripts = timing, + styles = timing, + }: { + timing?: AssetLoadTiming; + scripts?: AssetLoadTiming; + styles?: AssetLoadTiming; + } = {}, + ) { + const current = this.usedModulesWithTiming.get(id); + + if (current == null) { + this.usedModulesWithTiming.set(id, { + scripts, + styles, + }); + } else { + this.usedModulesWithTiming.set(id, { + scripts: + scripts == null + ? current.scripts + : highestPriorityAssetLoadTiming(scripts, current.scripts), + styles: + styles == null + ? current.styles + : highestPriorityAssetLoadTiming(styles, current.styles), + }); + } + } + + get({timing = 'load'}: {timing?: AssetLoadTiming | AssetLoadTiming[]} = {}) { + const allowedTiming = Array.isArray(timing) ? timing : [timing]; + + const assets: BrowserAssetModuleSelector[] = []; + + for (const [asset, {scripts, styles}] of this.usedModulesWithTiming) { + const stylesMatch = allowedTiming.includes(styles); + const scriptsMatch = allowedTiming.includes(scripts); + + if (stylesMatch || scriptsMatch) { + assets.push({id: asset, styles: stylesMatch, scripts: scriptsMatch}); + } + } + + return assets; + } +} + +function highestPriorityAssetLoadTiming(...timings: AssetLoadTiming[]) { + return ASSET_TIMING_PRIORITY[ + Math.max(...timings.map((timing) => PRIORITY_BY_TIMING.get(timing)!)) + ]!; } // What follows is a basic re-implementation of https://www.npmjs.com/package/cookie. diff --git a/packages/browser/source/types.ts b/packages/browser/source/types.ts index 2381b51ca..c85b871ef 100644 --- a/packages/browser/source/types.ts +++ b/packages/browser/source/types.ts @@ -17,6 +17,7 @@ export interface BrowserDetails { readonly serializations: { get(id: string): T; set(id: string, data: unknown): void; + [Symbol.iterator](): IterableIterator<[string, unknown]>; }; readonly cookies: Cookies; readonly initialURL: URL; diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json index ae366c887..27b90069e 100644 --- a/packages/browser/tsconfig.json +++ b/packages/browser/tsconfig.json @@ -6,5 +6,5 @@ }, "include": ["source"], "exclude": [], - "references": [{"path": "../signals"}] + "references": [{"path": "../assets"}, {"path": "../signals"}] } diff --git a/packages/quilt/package.json b/packages/quilt/package.json index 41b70dc44..d09d0e74d 100644 --- a/packages/quilt/package.json +++ b/packages/quilt/package.json @@ -234,7 +234,6 @@ "@quilted/events": "workspace:^2.0.0", "@quilted/graphql": "workspace:^3.0.2", "@quilted/react": "workspace:^18.2.0", - "@quilted/react-assets": "workspace:^0.1.1", "@quilted/react-async": "workspace:^0.4.1", "@quilted/react-browser": "workspace:^0.0.0", "@quilted/react-dom": "workspace:^18.2.0", diff --git a/packages/quilt/source/assets.ts b/packages/quilt/source/assets.ts index e7e50b687..e2be76db5 100644 --- a/packages/quilt/source/assets.ts +++ b/packages/quilt/source/assets.ts @@ -19,7 +19,6 @@ export type { AssetsBuildManifest, AssetsBuildManifestEntry, } from '@quilted/assets'; -export {useAssetsCacheKey, useModuleAssets} from '@quilted/react-assets'; declare module '@quilted/assets' { interface AssetsCacheKey { diff --git a/packages/quilt/source/server.ts b/packages/quilt/source/server.ts index f4b3c4530..958c4f6b3 100644 --- a/packages/quilt/source/server.ts +++ b/packages/quilt/source/server.ts @@ -29,14 +29,6 @@ export type { AssetsBuildManifest, AssetsBuildManifestEntry, } from '@quilted/assets'; -export { - useAssetsCacheKey, - useModuleAssets, - AssetsContext, - AssetsManager, - SERVER_ACTION_ID as ASSETS_SERVER_ACTION_ID, -} from '@quilted/react-assets/server'; -export type {HttpState} from '@quilted/react-http/server'; export {parseAcceptLanguageHeader} from '@quilted/react-localize'; export {createRequestRouterLocalization} from '@quilted/react-localize/request-router'; export { @@ -52,5 +44,4 @@ export type { ServerRenderRequestContext, } from '@quilted/react-server-render'; -export {ServerContext} from './server/ServerContext.tsx'; export {renderToResponse} from './server/request-router.tsx'; diff --git a/packages/quilt/source/server/ServerContext.tsx b/packages/quilt/source/server/ServerContext.tsx deleted file mode 100644 index 0c80e863d..000000000 --- a/packages/quilt/source/server/ServerContext.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type {PropsWithChildren} from 'react'; - -import {AssetsContext, type AssetsManager} from '@quilted/react-assets/server'; -import {InitialUrlContext} from '@quilted/react-router'; -import {HTMLContext, type HTMLManager} from '@quilted/react-html/server'; -import {HttpServerContext, type HttpManager} from '@quilted/react-http/server'; - -interface Props { - url?: string | URL; - html?: HTMLManager; - http?: HttpManager; - assets?: AssetsManager; -} - -export function ServerContext({ - url, - html, - http, - assets, - children, -}: PropsWithChildren) { - const normalizedUrl = typeof url === 'string' ? new URL(url) : url; - - const withInitialURL = normalizedUrl ? ( - - {children} - - ) : ( - children - ); - - const withHTML = html ? ( - {withInitialURL} - ) : ( - withInitialURL - ); - - const withHTTPServer = http ? ( - - {withHTML} - - ) : ( - withHTML - ); - - const withAssets = assets ? ( - - {withHTTPServer} - - ) : ( - withHTTPServer - ); - - return withAssets; -} diff --git a/packages/quilt/source/server/request-router.tsx b/packages/quilt/source/server/request-router.tsx index 2a9bb86ef..4b39a234c 100644 --- a/packages/quilt/source/server/request-router.tsx +++ b/packages/quilt/source/server/request-router.tsx @@ -8,57 +8,55 @@ import { type BrowserAssets, type BrowserAssetsEntry, } from '@quilted/assets'; -import {AssetsManager} from '@quilted/react-assets/server'; -import {HttpManager} from '@quilted/react-http/server'; +import { + BrowserResponse, + BrowserDetailsContext, +} from '@quilted/react-browser/server'; import { Head, Script, ScriptPreload, Style, StylePreload, - HTMLManager, } from '@quilted/react-html/server'; import {extract} from '@quilted/react-server-render/server'; import {HTMLResponse, RedirectResponse} from '@quilted/request-router'; -import {ServerContext} from './ServerContext.tsx'; - export interface RenderHTMLFunction { ( content: ReadableStream, context: { - readonly manager: HTMLManager; - readonly headers: Headers; + readonly response: BrowserResponse; readonly assets?: BrowserAssetsEntry; readonly preloadAssets?: BrowserAssetsEntry; }, ): ReadableStream | string | Promise | string>; } -export interface RenderOptions { +export interface RenderOptions { readonly request: Request; readonly stream?: 'headers' | false; readonly headers?: HeadersInit; - readonly assets?: BrowserAssets; - readonly cacheKey?: CacheKey; + readonly assets?: BrowserAssets; + readonly cacheKey?: Partial; readonly renderHTML?: boolean | 'fragment' | 'document' | RenderHTMLFunction; waitUntil?(promise: Promise): void; } -export async function renderToResponse( +export async function renderToResponse( element: ReactElement, - options: RenderOptions, + options: RenderOptions, ): Promise; -export async function renderToResponse( - options: RenderOptions, +export async function renderToResponse( + options: RenderOptions, ): Promise; -export async function renderToResponse( - optionsOrElement: ReactElement | RenderOptions, - definitelyOptions?: RenderOptions, +export async function renderToResponse( + optionsOrElement: ReactElement | RenderOptions, + definitelyOptions?: RenderOptions, ) { let element: ReactElement | undefined; - let options: RenderOptions; + let options: RenderOptions; if (isValidElement(optionsOrElement)) { element = optionsOrElement; @@ -81,57 +79,26 @@ export async function renderToResponse( const cacheKey = explicitCacheKey ?? - (((await assets?.cacheKey?.(request)) ?? {}) as CacheKey); + (((await assets?.cacheKey?.(request)) ?? {}) as AssetsCacheKey); - const html = new HTMLManager(); - const http = new HttpManager({headers: request.headers}); - const assetsManager = new AssetsManager({cacheKey}); + const browserResponse = new BrowserResponse({ + request, + headers: new Headers(explicitHeaders), + }); - let responseStatus = 200; let appStream: ReadableStream | undefined; - const headers = new Headers(explicitHeaders); if (shouldStream === false && element != null) { const rendered = await extract(element, { decorate(element) { return ( - + {element} - + ); }, }); - const {headers: appHeaders, statusCode = 200, redirectUrl} = http.state; - - const hasSetCookieHeader = typeof appHeaders.getSetCookie === 'function'; - - if (hasSetCookieHeader) { - for (const cookie of appHeaders.getSetCookie()) { - headers.append('Set-Cookie', cookie); - } - } - - for (const [header, value] of appHeaders.entries()) { - if (hasSetCookieHeader && header.toLowerCase() === 'set-cookie') continue; - headers.set(header, value); - } - - if (redirectUrl) { - return new RedirectResponse(redirectUrl, { - status: statusCode as 301, - headers: headers, - request, - }); - } - - responseStatus = statusCode; - const appTransformStream = new TransformStream(); const appWriter = appTransformStream.writable.getWriter(); appStream = appTransformStream.readable; @@ -151,14 +118,9 @@ export async function renderToResponse( const rendered = await extract(element, { decorate(element) { return ( - + {element} - + ); }, }); @@ -175,8 +137,8 @@ export async function renderToResponse( const body = await renderToHTMLBody(appStream); return new HTMLResponse(body, { - status: responseStatus, - headers, + status: browserResponse.status.value, + headers: browserResponse.headers, }); async function renderToHTMLBody( @@ -185,23 +147,23 @@ export async function renderToResponse( const [synchronousAssets, preloadAssets] = await Promise.all([ assets?.entry({ cacheKey, - modules: assetsManager.usedModules({timing: 'load'}), + modules: browserResponse.assets.get({timing: 'load'}), }), - assets?.modules(assetsManager.usedModules({timing: 'preload'}), { + assets?.modules(browserResponse.assets.get({timing: 'preload'}), { cacheKey, }), ]); if (synchronousAssets) { for (const style of synchronousAssets.styles) { - headers.append( + browserResponse.headers.append( 'Link', preloadHeader(styleAssetPreloadAttributes(style)), ); } for (const script of synchronousAssets.scripts) { - headers.append( + browserResponse.headers.append( 'Link', preloadHeader(scriptAssetPreloadAttributes(script)), ); @@ -210,8 +172,7 @@ export async function renderToResponse( if (typeof renderHTML === 'function') { const body = await renderHTML(content, { - manager: html, - headers, + response: browserResponse, assets: synchronousAssets, preloadAssets, }); @@ -229,11 +190,19 @@ export async function renderToResponse( writer.write(``); - const {htmlAttributes, bodyAttributes, ...headProps} = html.state; + // TODO + // const {htmlAttributes, bodyAttributes, ...headProps} = html.state; const htmlContent = renderToStaticMarkup( - + {synchronousAssets?.scripts.map((script) => (