diff options
author | 2021-11-02 13:45:07 -0400 | |
---|---|---|
committer | 2021-11-02 13:45:07 -0400 | |
commit | 94f35dbfbc3280c3ebde900d25cd573299b883a0 (patch) | |
tree | 5708b348198ddd248c6056f08d25179ef640ef42 | |
parent | b4dbb90b5f7b2b5a707fca13e5f87d8ba1eb2a1e (diff) | |
download | astro-94f35dbfbc3280c3ebde900d25cd573299b883a0.tar.gz astro-94f35dbfbc3280c3ebde900d25cd573299b883a0.tar.zst astro-94f35dbfbc3280c3ebde900d25cd573299b883a0.zip |
Small refactor to the server runtime (#1699) (#1727)
* Small refactor to the runtime
* Update packages/astro/src/runtime/server/index.ts
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
* Update packages/astro/src/runtime/server/index.ts
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
* Update packages/astro/src/runtime/server/index.ts
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
* Update packages/astro/src/runtime/server/index.ts
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
* Update packages/astro/src/runtime/server/hydration.ts
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
* Improve based on review comments
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
-rw-r--r-- | packages/astro/src/@types/astro-runtime.ts | 1 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/hydration.ts | 125 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/index.ts | 191 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/util.ts | 30 |
4 files changed, 197 insertions, 150 deletions
diff --git a/packages/astro/src/@types/astro-runtime.ts b/packages/astro/src/@types/astro-runtime.ts index f600fa4e0..fa312f840 100644 --- a/packages/astro/src/@types/astro-runtime.ts +++ b/packages/astro/src/@types/astro-runtime.ts @@ -41,7 +41,6 @@ export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: str export type Params = Record<string, string | undefined>; export interface TopLevelAstro { - isPage: boolean; fetchContent<T = any>(globStr: string): Promise<FetchContentResult<T>[]>; resolve: (path: string) => string; site: URL; diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts new file mode 100644 index 000000000..3b52efd74 --- /dev/null +++ b/packages/astro/src/runtime/server/hydration.ts @@ -0,0 +1,125 @@ +import type { AstroComponentMetadata } from '../../@types/astro-core'; +import type { SSRElement } from '../../@types/astro-runtime'; +import { valueToEstree } from 'estree-util-value-to-estree'; +import * as astring from 'astring'; +import { serializeListValue } from './util.js'; + +const { generate, GENERATOR } = astring; + +// INVESTIGATE: What features are we getting from this that we need? +// JSON.stringify has a "replacer" argument. +// A more robust version alternative to `JSON.stringify` that can handle most values +// see https://github.com/remcohaszing/estree-util-value-to-estree#readme +const customGenerator: astring.Generator = { + ...GENERATOR, + Literal(node, state) { + if (node.raw != null) { + // escape closing script tags in strings so browsers wouldn't interpret them as + // closing the actual end tag in HTML + state.write(node.raw.replace('</script>', '<\\/script>')); + } else { + GENERATOR.Literal(node, state); + } + }, +}; + +// Serializes props passed into a component so that they can be reused during hydration. +// The value is any +export function serializeProps(value: any) { + return generate(valueToEstree(value), { + generator: customGenerator, + }); +} + +interface ExtractedProps { + hydration: { + directive: string; + value: string; + componentUrl: string; + componentExport: { value: string }; + } | null; + props: Record<string | number, any>; +} + +// Used to extract the directives, aka `client:load` information about a component. +// Finds these special props and removes them from what gets passed into the component. +export function extractDirectives(inputProps: Record<string | number, any>): ExtractedProps { + let extracted: ExtractedProps = { + hydration: null, + props: {}, + }; + for (const [key, value] of Object.entries(inputProps)) { + if (key.startsWith('client:')) { + if (!extracted.hydration) { + extracted.hydration = { + directive: '', + value: '', + componentUrl: '', + componentExport: { value: '' }, + }; + } + switch (key) { + case 'client:component-path': { + extracted.hydration.componentUrl = value; + break; + } + case 'client:component-export': { + extracted.hydration.componentExport.value = value; + break; + } + default: { + extracted.hydration.directive = key.split(':')[1]; + extracted.hydration.value = value; + break; + } + } + } else if (key === 'class:list') { + // support "class" from an expression passed into a component (#782) + extracted.props[key.slice(0, -5)] = serializeListValue(value); + } else { + extracted.props[key] = value; + } + } + return extracted; +} + + +interface HydrateScriptOptions { + renderer: any; + astroId: string; + props: Record<string | number, any>; +} + +/** For hydrated components, generate a <script type="module"> to load the component */ +export async function generateHydrateScript(scriptOptions: HydrateScriptOptions, metadata: Required<AstroComponentMetadata>): Promise<SSRElement> { + const { renderer, astroId, props } = scriptOptions; + const { hydrate, componentUrl, componentExport } = metadata; + + if (!componentExport) { + throw new Error(`Unable to resolve a componentExport for "${metadata.displayName}"! Please open an issue.`); + } + + let hydrationSource = ''; + if (renderer.hydrationPolyfills) { + hydrationSource += `await Promise.all([${renderer.hydrationPolyfills.map((src: string) => `\n import("${src}")`).join(', ')}]);\n`; + } + + hydrationSource += renderer.source + ? `const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${renderer.source}")]); + return (el, children) => hydrate(el)(Component, ${serializeProps(props)}, children); +` + : `await import("${componentUrl}"); + return () => {}; +`; + + const hydrationScript = { + props: { type: 'module' }, + children: `import setup from 'astro/client/${hydrate}.js'; +setup("${astroId}", {${metadata.hydrateArgs ? `value: ${JSON.stringify(metadata.hydrateArgs)}` : ''}}, async () => { + ${hydrationSource} +}); +`, + }; + + return hydrationScript; +}
\ No newline at end of file diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 6e56a336e..03ac314c3 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -2,33 +2,18 @@ import type { AstroComponentMetadata, Renderer } from '../../@types/astro-core'; import type { SSRResult, SSRElement } from '../../@types/astro-runtime'; import type { TopLevelAstro } from '../../@types/astro-runtime'; -import { valueToEstree } from 'estree-util-value-to-estree'; -import * as astring from 'astring'; import shorthash from 'shorthash'; +import { extractDirectives, generateHydrateScript } from './hydration.js'; +import { serializeListValue } from './util.js'; export { createMetadata } from './metadata.js'; -const { generate, GENERATOR } = astring; - -// A more robust version alternative to `JSON.stringify` that can handle most values -// see https://github.com/remcohaszing/estree-util-value-to-estree#readme -const customGenerator: astring.Generator = { - ...GENERATOR, - Literal(node, state) { - if (node.raw != null) { - // escape closing script tags in strings so browsers wouldn't interpret them as - // closing the actual end tag in HTML - state.write(node.raw.replace('</script>', '<\\/script>')); - } else { - GENERATOR.Literal(node, state); - } - }, -}; - -const serialize = (value: any) => - generate(valueToEstree(value), { - generator: customGenerator, - }); +// INVESTIGATE: +// 2. Less anys when possible and make it well known when they are needed. +// Used to render slots and expressions +// INVESTIGATE: Can we have more specific types both for the argument and output? +// If these are intentional, add comments that these are intention and why. +// Or maybe type UserValue = any; ? async function _render(child: any): Promise<any> { child = await child; if (Array.isArray(child)) { @@ -42,13 +27,18 @@ async function _render(child: any): Promise<any> { return child; } else if (!child && child !== 0) { // do nothing, safe to ignore falsey values. - } else if (child instanceof AstroComponent || child.toString() === '[object AstroComponent]') { + } + // Add a comment explaining why each of these are needed. + // Maybe create clearly named function for what this is doing. + else if (child instanceof AstroComponent || child.toString() === '[object AstroComponent]') { return await renderAstroComponent(child); } else { return child; } } +// The return value when rendering a component. +// This is the result of calling render(), should this be named to RenderResult or...? export class AstroComponent { private htmlParts: TemplateStringsArray; private expressions: any[]; @@ -79,108 +69,21 @@ export async function render(htmlParts: TemplateStringsArray, ...expressions: an return new AstroComponent(htmlParts, expressions); } +// The callback passed to to $$createComponent export interface AstroComponentFactory { (result: any, props: any, slots: any): ReturnType<typeof render>; isAstroComponentFactory?: boolean; } +// Used in creating the component. aka the main export. export function createComponent(cb: AstroComponentFactory) { // Add a flag to this callback to mark it as an Astro component + // INVESTIGATE does this need to cast (cb as any).isAstroComponentFactory = true; return cb; } -interface ExtractedProps { - hydration: { - directive: string; - value: string; - componentUrl: string; - componentExport: { value: string }; - } | null; - props: Record<string | number, any>; -} - -function extractDirectives(inputProps: Record<string | number, any>): ExtractedProps { - let extracted: ExtractedProps = { - hydration: null, - props: {}, - }; - for (const [key, value] of Object.entries(inputProps)) { - if (key.startsWith('client:')) { - if (!extracted.hydration) { - extracted.hydration = { - directive: '', - value: '', - componentUrl: '', - componentExport: { value: '' }, - }; - } - switch (key) { - case 'client:component-path': { - extracted.hydration.componentUrl = value; - break; - } - case 'client:component-export': { - extracted.hydration.componentExport.value = value; - break; - } - default: { - extracted.hydration.directive = key.split(':')[1]; - extracted.hydration.value = value; - break; - } - } - } else if (key === 'class:list') { - // support "class" from an expression passed into a component (#782) - extracted.props[key.slice(0, -5)] = serializeListValue(value); - } else { - extracted.props[key] = value; - } - } - return extracted; -} - -interface HydrateScriptOptions { - renderer: any; - astroId: string; - props: any; -} - -/** For hydrated components, generate a <script type="module"> to load the component */ -async function generateHydrateScript(scriptOptions: HydrateScriptOptions, metadata: Required<AstroComponentMetadata>): Promise<SSRElement> { - const { renderer, astroId, props } = scriptOptions; - const { hydrate, componentUrl, componentExport } = metadata; - - if (!componentExport) { - throw new Error(`Unable to resolve a componentExport for "${metadata.displayName}"! Please open an issue.`); - } - - let hydrationSource = ''; - if (renderer.hydrationPolyfills) { - hydrationSource += `await Promise.all([${renderer.hydrationPolyfills.map((src: string) => `\n import("${src}")`).join(', ')}]);\n`; - } - - hydrationSource += renderer.source - ? `const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${renderer.source}")]); - return (el, children) => hydrate(el)(Component, ${serialize(props)}, children); -` - : `await import("${componentUrl}"); - return () => {}; -`; - - const hydrationScript = { - props: { type: 'module' }, - children: `import setup from 'astro/client/${hydrate}.js'; -setup("${astroId}", {${metadata.hydrateArgs ? `value: ${JSON.stringify(metadata.hydrateArgs)}` : ''}}, async () => { - ${hydrationSource} -}); -`, - }; - - return hydrationScript; -} - -export async function renderSlot(result: any, slotted: string, fallback?: any) { +export async function renderSlot(_result: any, slotted: string, fallback?: any) { if (slotted) { return _render(slotted); } @@ -213,6 +116,7 @@ export async function renderComponent(result: SSRResult, displayName: string, Co metadata.componentUrl = hydration.componentUrl; } + // Call the renderers `check` hook to see if any claim this component. let renderer: Renderer | undefined; for (const r of renderers) { if (await r.ssr.check(Component, props, children)) { @@ -221,7 +125,10 @@ export async function renderComponent(result: SSRResult, displayName: string, Co } } + // If no one claimed the renderer if (!renderer) { + // This is a custom element without a renderer. Because of that, render it + // as a string and the user is responsible for adding a script tag for the component definition. if (typeof Component === 'string') { html = await renderAstroComponent(await render`<${Component}${spreadAttributes(props)}>${children}</${Component}>`); } else { @@ -231,6 +138,7 @@ export async function renderComponent(result: SSRResult, displayName: string, Co ({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children)); } + // This is used to add polyfill scripts to the page, if the renderer needs them. if (renderer?.polyfills?.length) { let polyfillScripts = renderer.polyfills.map((src) => `<script type="module">import "${src}";</script>`).join(''); html = html + polyfillScripts; @@ -243,6 +151,8 @@ export async function renderComponent(result: SSRResult, displayName: string, Co // Include componentExport name and componentUrl in hash to dedupe identical islands const astroId = shorthash.unique(`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}`); + // Rather than appending this inline in the page, puts this into the `result.scripts` set that will be appended to the head. + // INVESTIGATE: This will likely be a problem in streaming because the `<head>` will be gone at this point. result.scripts.add(await generateHydrateScript({ renderer, astroId, props }, metadata as Required<AstroComponentMetadata>)); return `<astro-root uid="${astroId}">${html}</astro-root>`; @@ -271,23 +181,28 @@ function createFetchContentFn(url: URL) { }) .filter(Boolean); }; - return fetchContent; + // This has to be cast because the type of fetchContent is the type of the function + // that receives the import.meta.glob result, but the user is using it as + // another type. + return fetchContent as unknown as TopLevelAstro['fetchContent']; } +// This is used to create the top-level Astro global; the one that you can use +// Inside of getStaticPaths. export function createAstro(fileURLStr: string, site: string): TopLevelAstro { const url = new URL(fileURLStr); - const fetchContent = createFetchContentFn(url) as unknown as TopLevelAstro['fetchContent']; + const fetchContent = createFetchContentFn(url); return { - // TODO I think this is no longer needed. - isPage: false, site: new URL(site), fetchContent, - resolve(...segments) { + // INVESTIGATE is there a use-case for multi args? + resolve(...segments: string[]) { return segments.reduce((u, segment) => new URL(segment, u), url).pathname; }, }; } +// A helper used to turn expressions into attribute key/value export function addAttribute(value: any, key: string) { if (value == null || value === false) { return ''; @@ -301,6 +216,7 @@ export function addAttribute(value: any, key: string) { return ` ${key}="${value}"`; } +// Adds support for `<Component {...value} /> export function spreadAttributes(values: Record<any, any>) { let output = ''; for (const [key, value] of Object.entries(values)) { @@ -309,36 +225,9 @@ export function spreadAttributes(values: Record<any, any>) { return output; } -function serializeListValue(value: any) { - const hash: Record<string, any> = {}; - - push(value); - - return Object.keys(hash).join(' '); - - function push(item: any) { - // push individual iteratables - if (item && typeof item.forEach === 'function') item.forEach(push); - // otherwise, push object value keys by truthiness - else if (item === Object(item)) - Object.keys(item).forEach((name) => { - if (item[name]) push(name); - }); - // otherwise, push any other values as a string - else { - // get the item as a string - item = item == null ? '' : String(item).trim(); - - // add the item if it is filled - if (item) { - item.split(/\s+/).forEach((name: string) => { - hash[name] = true; - }); - } - } - } -} + +// Adds CSS variables to an inline style tag export function defineStyleVars(selector: string, vars: Record<any, any>) { let output = '\n'; for (const [key, value] of Object.entries(vars)) { @@ -347,6 +236,7 @@ export function defineStyleVars(selector: string, vars: Record<any, any>) { return `${selector} {${output}}`; } +// Adds variables to an inline script. export function defineScriptVars(vars: Record<any, any>) { let output = ''; for (const [key, value] of Object.entries(vars)) { @@ -355,6 +245,7 @@ export function defineScriptVars(vars: Record<any, any>) { return output; } +// Calls a component and renders it into a string of HTML export async function renderToString(result: SSRResult, componentFactory: AstroComponentFactory, props: any, children: any) { const Component = await componentFactory(result, props, children); let template = await renderAstroComponent(Component); @@ -368,6 +259,8 @@ const uniqueElements = (item: any, index: number, all: any[]) => { return index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children); }; +// Renders a page to completion by first calling the factory callback, waiting for its result, and then appending +// styles and scripts into the head. export async function renderPage(result: SSRResult, Component: AstroComponentFactory, props: any, children: any) { const template = await renderToString(result, Component, props, children); const styles = Array.from(result.styles) diff --git a/packages/astro/src/runtime/server/util.ts b/packages/astro/src/runtime/server/util.ts new file mode 100644 index 000000000..ff1dd55d3 --- /dev/null +++ b/packages/astro/src/runtime/server/util.ts @@ -0,0 +1,30 @@ + +export function serializeListValue(value: any) { + const hash: Record<string, any> = {}; + + push(value); + + return Object.keys(hash).join(' '); + + function push(item: any) { + // push individual iteratables + if (item && typeof item.forEach === 'function') item.forEach(push); + // otherwise, push object value keys by truthiness + else if (item === Object(item)) + Object.keys(item).forEach((name) => { + if (item[name]) push(name); + }); + // otherwise, push any other values as a string + else { + // get the item as a string + item = item == null ? '' : String(item).trim(); + + // add the item if it is filled + if (item) { + item.split(/\s+/).forEach((name: string) => { + hash[name] = true; + }); + } + } + } +}
\ No newline at end of file |