From d8608bb9478ceaa6968417bd5d2d5c10da306e66 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 14 Apr 2023 05:56:27 -0500 Subject: [PATCH] spike: client-side routing --- examples/minimal/astro.config.mjs | 8 +- examples/minimal/package.json | 1 + examples/minimal/src/component/Deep.astro | 3 + examples/minimal/src/component/Tabs.astro | 10 ++ examples/minimal/src/layouts/Default.astro | 132 ++++++++++++++++++ examples/minimal/src/pages/a/[tab].astro | 22 +++ examples/minimal/src/pages/a/index.astro | 21 +++ examples/minimal/src/pages/b.astro | 20 +++ examples/minimal/src/pages/c.astro | 22 +++ examples/minimal/src/pages/index.astro | 28 ++-- packages/astro/components/Outlet.astro | 14 ++ packages/astro/components/index.ts | 1 + packages/astro/src/@types/astro.ts | 3 + packages/astro/src/core/render/result.ts | 3 + .../src/runtime/server/astro-component.ts | 10 +- .../runtime/server/render/astro/factory.ts | 1 + .../runtime/server/render/astro/instance.ts | 6 + .../astro/src/runtime/server/render/page.ts | 78 ++++++++++- pnpm-lock.yaml | 3 + 19 files changed, 369 insertions(+), 17 deletions(-) create mode 100644 examples/minimal/src/component/Deep.astro create mode 100644 examples/minimal/src/component/Tabs.astro create mode 100644 examples/minimal/src/layouts/Default.astro create mode 100644 examples/minimal/src/pages/a/[tab].astro create mode 100644 examples/minimal/src/pages/a/index.astro create mode 100644 examples/minimal/src/pages/b.astro create mode 100644 examples/minimal/src/pages/c.astro create mode 100644 packages/astro/components/Outlet.astro diff --git a/examples/minimal/astro.config.mjs b/examples/minimal/astro.config.mjs index 882e6515a..4537da20a 100644 --- a/examples/minimal/astro.config.mjs +++ b/examples/minimal/astro.config.mjs @@ -1,4 +1,10 @@ import { defineConfig } from 'astro/config'; +import node from "@astrojs/node"; // https://astro.build/config -export default defineConfig({}); +export default defineConfig({ + output: "server", + adapter: node({ + mode: "standalone" + }) +}); diff --git a/examples/minimal/package.json b/examples/minimal/package.json index 642dfd2b0..51b6607b9 100644 --- a/examples/minimal/package.json +++ b/examples/minimal/package.json @@ -11,6 +11,7 @@ "astro": "astro" }, "dependencies": { + "@astrojs/node": "^5.1.1", "astro": "^2.4.1" } } diff --git a/examples/minimal/src/component/Deep.astro b/examples/minimal/src/component/Deep.astro new file mode 100644 index 000000000..940b69e68 --- /dev/null +++ b/examples/minimal/src/component/Deep.astro @@ -0,0 +1,3 @@ +
+ +
diff --git a/examples/minimal/src/component/Tabs.astro b/examples/minimal/src/component/Tabs.astro new file mode 100644 index 000000000..c01894bb4 --- /dev/null +++ b/examples/minimal/src/component/Tabs.astro @@ -0,0 +1,10 @@ +--- +const { tab } = Astro.props; +--- + + +
Tab {tab}
diff --git a/examples/minimal/src/layouts/Default.astro b/examples/minimal/src/layouts/Default.astro new file mode 100644 index 000000000..ad57a8b31 --- /dev/null +++ b/examples/minimal/src/layouts/Default.astro @@ -0,0 +1,132 @@ +--- +import { Outlet } from 'astro/components'; +--- + + + + + + + + Astro + + +
+

My Website

+
+ + + +
+ {() => new Promise((resolve) => setTimeout(resolve, 1000))} + + {() => new Promise((resolve) => setTimeout(resolve, 1000))} + + +
+ + + + + + diff --git a/examples/minimal/src/pages/a/[tab].astro b/examples/minimal/src/pages/a/[tab].astro new file mode 100644 index 000000000..1b53d85ce --- /dev/null +++ b/examples/minimal/src/pages/a/[tab].astro @@ -0,0 +1,22 @@ +--- +import Layout from '../../layouts/Default.astro'; +import Tab from '../../component/Tabs.astro'; +const { tab = '' } = Astro.params; +--- + + +
+

Page A

+
+ + + + +
diff --git a/examples/minimal/src/pages/a/index.astro b/examples/minimal/src/pages/a/index.astro new file mode 100644 index 000000000..dfbed4c69 --- /dev/null +++ b/examples/minimal/src/pages/a/index.astro @@ -0,0 +1,21 @@ +--- +import Layout from '../../layouts/Default.astro'; +import Tab from '../../component/Tabs.astro'; +--- + + +
+

Page A

+
+ + + + +
diff --git a/examples/minimal/src/pages/b.astro b/examples/minimal/src/pages/b.astro new file mode 100644 index 000000000..9a935f9a4 --- /dev/null +++ b/examples/minimal/src/pages/b.astro @@ -0,0 +1,20 @@ +--- +import Layout from '../layouts/Default.astro'; +import Deep from '../component/Deep.astro'; +--- + + + +

Page B

+

No delay

+
+ + +
diff --git a/examples/minimal/src/pages/c.astro b/examples/minimal/src/pages/c.astro new file mode 100644 index 000000000..45c5f9c91 --- /dev/null +++ b/examples/minimal/src/pages/c.astro @@ -0,0 +1,22 @@ +--- +import Layout from '../layouts/Default.astro'; +import { setTimeout as sleep } from 'node:timers/promises'; +--- + + +
+

Page C

+

This is fast

+ {sleep(1000)} +

This is slow

+
+ + +
diff --git a/examples/minimal/src/pages/index.astro b/examples/minimal/src/pages/index.astro index 7264ff502..5b0032ab8 100644 --- a/examples/minimal/src/pages/index.astro +++ b/examples/minimal/src/pages/index.astro @@ -1,15 +1,19 @@ --- +import Layout from '../layouts/Default.astro'; --- - - - - - - - Astro - - -

Astro

- - + +
+

Home

+

Streaming partials!

+
+ + +
diff --git a/packages/astro/components/Outlet.astro b/packages/astro/components/Outlet.astro new file mode 100644 index 000000000..347e372b4 --- /dev/null +++ b/packages/astro/components/Outlet.astro @@ -0,0 +1,14 @@ +--- +interface Props { + id: string; +} + +const { id } = Astro.props; + +// @ts-ignore untyped internals +// $$result.outlets.set(id, $$slots.default); +--- + +`} />`} +/> diff --git a/packages/astro/components/index.ts b/packages/astro/components/index.ts index 864c7cc3b..b2b0068cf 100644 --- a/packages/astro/components/index.ts +++ b/packages/astro/components/index.ts @@ -1,2 +1,3 @@ export { default as Code } from './Code.astro'; export { default as Debug } from './Debug.astro'; +export { default as Outlet } from './Outlet.astro'; diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 0f8cf4240..70ec81d9f 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1750,6 +1750,7 @@ export interface SSRMetadata { hasDirectives: Set; hasRenderedHead: boolean; headInTree: boolean; + request: Request; } /** @@ -1775,7 +1776,9 @@ export interface SSRResult { links: Set; componentMetadata: Map; propagators: Map; + outletPropagators: Map; extraHead: Array; + outlets: Map; cookies: AstroCookies | undefined; createAstro( Astro: AstroGlobalPartial, diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 598ec116f..63fd2c4d1 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -163,7 +163,9 @@ export function createResult(args: CreateResultArgs): SSRResult { links: args.links ?? new Set(), componentMetadata, propagators: new Map(), + outletPropagators: new Map(), extraHead: [], + outlets: new Map(), scope: 0, cookies, /** This function returns the `Astro` faux-global */ @@ -260,6 +262,7 @@ export function createResult(args: CreateResultArgs): SSRResult { hasRenderedHead: false, hasDirectives: new Set(), headInTree: false, + request }, response, }; diff --git a/packages/astro/src/runtime/server/astro-component.ts b/packages/astro/src/runtime/server/astro-component.ts index 44428b929..3ddc2d8f4 100644 --- a/packages/astro/src/runtime/server/astro-component.ts +++ b/packages/astro/src/runtime/server/astro-component.ts @@ -7,7 +7,7 @@ function validateArgs(args: unknown[]): args is Parameters) => { if (!validateArgs(args)) { @@ -22,6 +22,7 @@ function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string): Astr // Add a flag to this callback to mark it as an Astro component fn.isAstroComponentFactory = true; fn.moduleId = moduleId; + fn.hash = hash; return fn; } @@ -29,20 +30,23 @@ interface CreateComponentOptions { factory: AstroComponentFactory; moduleId?: string; propagation?: PropagationHint; + hash?: string; } function createComponentWithOptions(opts: CreateComponentOptions) { const cb = baseCreateComponent(opts.factory, opts.moduleId); cb.propagation = opts.propagation; + cb.hash = opts.hash; return cb; } // Used in creating the component. aka the main export. export function createComponent( arg1: AstroComponentFactory | CreateComponentOptions, - moduleId?: string + moduleId?: string, + hash?: string ) { if (typeof arg1 === 'function') { - return baseCreateComponent(arg1, moduleId); + return baseCreateComponent(arg1, moduleId, hash); } else { return createComponentWithOptions(arg1); } diff --git a/packages/astro/src/runtime/server/render/astro/factory.ts b/packages/astro/src/runtime/server/render/astro/factory.ts index 6d1b08563..c61b35407 100644 --- a/packages/astro/src/runtime/server/render/astro/factory.ts +++ b/packages/astro/src/runtime/server/render/astro/factory.ts @@ -14,6 +14,7 @@ export interface AstroComponentFactory { isAstroComponentFactory?: boolean; moduleId?: string | undefined; propagation?: PropagationHint; + hash?: string; } export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory { diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts index 47ce7f495..1a9f87f18 100644 --- a/packages/astro/src/runtime/server/render/astro/instance.ts +++ b/packages/astro/src/runtime/server/render/astro/instance.ts @@ -81,6 +81,12 @@ export function createAstroComponentInstance( ) { validateComponentProps(props, displayName); const instance = new AstroComponentInstance(result, props, slots, factory); + if (result._metadata.request.headers.has('x-astro-outlet')) { + for (const [name, slot] of Object.entries(slots)) { + result.outletPropagators.set(`${factory.hash} ${name}`, slot); + } + return instance; + } if (isAPropagatingComponent(result, factory) && !result.propagators.has(factory)) { result.propagators.set(factory, instance); } diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index ce2d165a7..df80c86d1 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -67,6 +67,23 @@ async function bufferHeadContent(result: SSRResult) { } } +// Recursively calls component instances that might have slots to be propagated up. +async function bufferSlottedContent(result: SSRResult, outlet: string) { + const iterator = result.outletPropagators.entries(); + let promises = []; + while (true) { + const { value: [name, slot] = [], done } = iterator.next(); + if (done) { + break; + } + const returnValue = await slot(result); + result.outlets.set(name, returnValue); + if (name === outlet) { + break; + } + } +} + export async function renderPage( result: SSRResult, componentFactory: AstroComponentFactory | NonAstroPageComponent, @@ -131,7 +148,66 @@ export async function renderPage( result.componentMetadata.get(componentFactory.moduleId!)?.containsHead ?? false; const factoryReturnValue = await componentFactory(result, props, children); const factoryIsHeadAndContent = isHeadAndContent(factoryReturnValue); - if (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent) { + if (result._metadata.request.headers.get('x-astro-outlet') && (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent)) { + const outlet = result._metadata.request.headers.get('x-astro-outlet')!; + result.scripts.clear(); + await bufferSlottedContent(result, outlet); + + if (!result.outlets.get(outlet)) { + let init = result.response; + let headers = new Headers(init.headers); + let response = createResponse(null, { ...init, headers, status: 404 }); + return response; + } + + let init = result.response; + let headers = new Headers(init.headers); + let body: BodyInit; + + if (streaming) { + body = new ReadableStream({ + start(controller) { + async function read() { + try { + const template = result.outlets.get(outlet); + for await (const chunk of renderAstroTemplateResult(template)) { + const bytes = chunkToByteArray(result, chunk); + controller.enqueue(bytes); + } + controller.close(); + } catch (e) { + // We don't have a lot of information downstream, and upstream we can't catch the error properly + // So let's add the location here + if (AstroError.is(e) && !e.loc) { + e.setLocation({ + file: route?.component, + }); + } + + controller.error(e); + } + } + read(); + }, + }); + } else { + let iterable = (async function* () { + for (const [key, slot] of result.outlets) { + yield `\n`; + for await (const chunk of renderAstroTemplateResult(slot)) { + const bytes = chunkToByteArray(result, chunk); + yield bytes; + } + yield '\n\n\n'; + } + })() + body = await iterableToHTMLBytes(result, iterable); + headers.set('Content-Length', body.byteLength.toString()); + } + + let response = createResponse(body, { ...init, headers }); + return response; + } else if (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent) { // Wait for head content to be buffered up await bufferHeadContent(result); const templateResult = factoryIsHeadAndContent diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a39fb6cbe..652780c80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,6 +375,9 @@ importers: examples/minimal: dependencies: + '@astrojs/node': + specifier: ^5.1.1 + version: link:../../packages/integrations/node astro: specifier: ^2.4.1 version: link:../../packages/astro