diff --git a/.github/workflows/example-deploys.yml b/.github/workflows/example-deploys.yml index 26087d94..85a23cae 100644 --- a/.github/workflows/example-deploys.yml +++ b/.github/workflows/example-deploys.yml @@ -40,6 +40,8 @@ jobs: - sitemap-ext - custom-routing - astro-when + - request-state + - request-nanostores steps: - name: Checkout uses: actions/checkout@v4 @@ -73,6 +75,14 @@ jobs: - name: Discard all git changes run: git restore . + - name: Create Cloudflare Pages Project + run: |- + pnpm dlx wrangler pages project create 'inox-tools-ex-${{ matrix.example }}' --production-branch main || true + working-directory: examples/${{ matrix.example }} + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + - name: Publish to Cloudflare Pages run: pnpm dlx wrangler pages deploy dist --project-name 'inox-tools-ex-${{ matrix.example }}' --branch '${{ github.head_ref }}' working-directory: examples/${{ matrix.example }} diff --git a/examples/deploy-cloudflare.sh b/examples/deploy-cloudflare.sh index 653942f9..7b920ba6 100755 --- a/examples/deploy-cloudflare.sh +++ b/examples/deploy-cloudflare.sh @@ -17,6 +17,9 @@ if [ ! -d "$SCRIPT_DIR/$PROJECT_NAME" ]; then exit 1 fi +cp "$SCRIPT_DIR/wrangler.toml" "$SCRIPT_DIR/$PROJECT_NAME/wrangler.toml" +echo "name = \"inox-tools-ex-$PROJECT_NAME\"" >>"$SCRIPT_DIR/$PROJECT_NAME/wrangler.toml" + cd "$SCRIPT_DIR/$PROJECT_NAME" # Check that the project is an Astro project @@ -29,7 +32,7 @@ fi pnpm astro add cloudflare --yes # Build the project -pnpm build +pnpm astro build # Restore the Astro project to its original state git restore package.json astro.config.* diff --git a/examples/request-nanostores/.gitignore b/examples/request-nanostores/.gitignore new file mode 100644 index 00000000..16d54bb1 --- /dev/null +++ b/examples/request-nanostores/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/examples/request-nanostores/README.md b/examples/request-nanostores/README.md new file mode 100644 index 00000000..163c9129 --- /dev/null +++ b/examples/request-nanostores/README.md @@ -0,0 +1,11 @@ +# Astro Example: Nanostores + +```sh +npm create astro@latest -- --template with-nanostores +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-nanostores) +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/with-nanostores) +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/with-nanostores/devcontainer.json) + +This example showcases using [`nanostores`](https://github.com/nanostores/nanostores) to provide shared state between components of any framework. [**Read our documentation on sharing state**](https://docs.astro.build/en/core-concepts/sharing-state/) for a complete breakdown of this project, along with guides to use React, Vue, Svelte, or Solid! diff --git a/examples/request-nanostores/astro.config.ts b/examples/request-nanostores/astro.config.ts new file mode 100644 index 00000000..b1bfed0f --- /dev/null +++ b/examples/request-nanostores/astro.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'astro/config'; +import preact from '@astrojs/preact'; +import requestNanostores from '@inox-tools/request-nanostores'; + +import node from '@astrojs/node'; + +// https://astro.build/config +export default defineConfig({ + // Enable many frameworks to support all different kinds of components. + integrations: [preact(), requestNanostores()], + compressHTML: false, + output: 'server', + adapter: node({ + mode: 'standalone', + }), + experimental: { + actions: true, + }, +}); diff --git a/examples/request-nanostores/package.json b/examples/request-nanostores/package.json new file mode 100644 index 00000000..5f5531a1 --- /dev/null +++ b/examples/request-nanostores/package.json @@ -0,0 +1,24 @@ +{ + "name": "@example/request-nanostores", + "private": true, + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/check": "^0.9.3", + "@astrojs/node": "^8.3.3", + "@astrojs/preact": "^3.5.1", + "@inox-tools/request-nanostores": "workspace:^", + "@nanostores/preact": "^0.5.2", + "astro": "^4.14.2", + "nanostores": "^0.11.2", + "preact": "^10.23.1", + "typescript": "^5.5.4" + } +} diff --git a/examples/request-nanostores/public/favicon.svg b/examples/request-nanostores/public/favicon.svg new file mode 100644 index 00000000..f157bd1c --- /dev/null +++ b/examples/request-nanostores/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/examples/request-nanostores/public/images/astronaut-figurine.png b/examples/request-nanostores/public/images/astronaut-figurine.png new file mode 100644 index 00000000..aac9b445 Binary files /dev/null and b/examples/request-nanostores/public/images/astronaut-figurine.png differ diff --git a/examples/request-nanostores/src/actions/index.ts b/examples/request-nanostores/src/actions/index.ts new file mode 100644 index 00000000..0ce575b0 --- /dev/null +++ b/examples/request-nanostores/src/actions/index.ts @@ -0,0 +1,24 @@ +import { defineAction, z } from 'astro:actions'; +import { CART_COOKIE_NAME, cookieOptions, extractCartCookie } from '../cookies'; + +export const server = { + setCartItem: defineAction({ + input: z.object({ + id: z.string(), + quantity: z.number(), + }), + handler: async (input, ctx) => { + const currentCookie = extractCartCookie(ctx.cookies); + + const newCookie = { + ...currentCookie, + [input.id]: { + id: input.id, + quantity: input.quantity, + }, + }; + + ctx.cookies.set(CART_COOKIE_NAME, newCookie, cookieOptions(ctx.url)); + }, + }), +}; diff --git a/examples/request-nanostores/src/cartStore.ts b/examples/request-nanostores/src/cartStore.ts new file mode 100644 index 00000000..1c191089 --- /dev/null +++ b/examples/request-nanostores/src/cartStore.ts @@ -0,0 +1,40 @@ +import { shared } from '@it-astro:request-nanostores'; +import { atom, map, task } from 'nanostores'; +import { actions } from 'astro:actions'; + +export const isCartOpen = shared('isCartOpen', atom(false)); + +export type CartItem = { + id: string; + name: string; + imageSrc: string; + quantity: number; +}; + +export type CartItemDisplayInfo = Pick; + +export const cartItems = shared('cartItems', map>({})); + +export function addCartItem({ id, name, imageSrc }: CartItemDisplayInfo) { + task(async () => { + const existingEntry = cartItems.get()[id]; + const newEntry = existingEntry + ? { + ...existingEntry, + quantity: existingEntry.quantity + 1, + } + : { + id, + name, + imageSrc, + quantity: 1, + }; + + cartItems.setKey(id, newEntry); + + await actions.setCartItem({ + id: newEntry.id, + quantity: newEntry.quantity, + }); + }); +} diff --git a/examples/request-nanostores/src/components/AddToCartForm.tsx b/examples/request-nanostores/src/components/AddToCartForm.tsx new file mode 100644 index 00000000..e0571fc5 --- /dev/null +++ b/examples/request-nanostores/src/components/AddToCartForm.tsx @@ -0,0 +1,18 @@ +import { isCartOpen, addCartItem } from '../cartStore'; +import type { CartItemDisplayInfo } from '../cartStore'; +import type { ComponentChildren } from 'preact'; + +type Props = { + item: CartItemDisplayInfo; + children: ComponentChildren; +}; + +export default function AddToCartForm({ item, children }: Props) { + async function addToCart(e: SubmitEvent) { + e.preventDefault(); + isCartOpen.set(true); + await addCartItem(item); + } + + return
{children}
; +} diff --git a/examples/request-nanostores/src/components/CartFlyout.module.css b/examples/request-nanostores/src/components/CartFlyout.module.css new file mode 100644 index 00000000..cee43dd4 --- /dev/null +++ b/examples/request-nanostores/src/components/CartFlyout.module.css @@ -0,0 +1,29 @@ +.container { + position: fixed; + right: 0; + top: var(--nav-height); + height: 100vh; + background: var(--color-bg-2); + padding-inline: 2rem; + min-width: min(90vw, 300px); + border-left: 3px solid var(--color-bg-3); +} + +.list { + list-style: none; + padding: 0; +} + +.listItem { + display: flex; + gap: 1rem; + align-items: center; +} + +.listItem * { + margin-block: 0.3rem; +} + +.listItemImg { + width: 4rem; +} diff --git a/examples/request-nanostores/src/components/CartFlyout.tsx b/examples/request-nanostores/src/components/CartFlyout.tsx new file mode 100644 index 00000000..98fd8cbf --- /dev/null +++ b/examples/request-nanostores/src/components/CartFlyout.tsx @@ -0,0 +1,28 @@ +import { useStore } from '@nanostores/preact'; +import { cartItems, isCartOpen } from '../cartStore'; +import styles from './CartFlyout.module.css'; + +export default function CartFlyout() { + const $isCartOpen = useStore(isCartOpen); + const $cartItems = useStore(cartItems); + + return ( + + ); +} diff --git a/examples/request-nanostores/src/components/CartFlyoutToggle.tsx b/examples/request-nanostores/src/components/CartFlyoutToggle.tsx new file mode 100644 index 00000000..14ce1c70 --- /dev/null +++ b/examples/request-nanostores/src/components/CartFlyoutToggle.tsx @@ -0,0 +1,7 @@ +import { useStore } from '@nanostores/preact'; +import { isCartOpen } from '../cartStore'; + +export default function CartFlyoutToggle() { + const $isCartOpen = useStore(isCartOpen); + return ; +} diff --git a/examples/request-nanostores/src/components/FigurineDescription.astro b/examples/request-nanostores/src/components/FigurineDescription.astro new file mode 100644 index 00000000..1294b151 --- /dev/null +++ b/examples/request-nanostores/src/components/FigurineDescription.astro @@ -0,0 +1,44 @@ +

Astronaut Figurine

+

Limited Edition

+

+ The limited edition Astronaut Figurine is the perfect gift for any Astro contributor. This + fully-poseable action figurine comes equipped with: +

+ +

+ * Dust not actually from the lunar surface +

+ + diff --git a/examples/request-nanostores/src/cookies.ts b/examples/request-nanostores/src/cookies.ts new file mode 100644 index 00000000..d9485c21 --- /dev/null +++ b/examples/request-nanostores/src/cookies.ts @@ -0,0 +1,28 @@ +import type { AstroCookies, AstroCookieSetOptions } from 'astro'; +import type { CartItem } from './cartStore'; + +type CartCookie = Record>; + +export const CART_COOKIE_NAME = 'cart'; + +export function cookieOptions(url: URL): AstroCookieSetOptions { + return { + path: '/', + domain: url.hostname, + httpOnly: true, + secure: url.protocol === 'https:', + sameSite: 'lax', + maxAge: 3600, + }; +} + +export function extractCartCookie(cookies: AstroCookies): CartCookie { + const cookie = cookies.get(CART_COOKIE_NAME); + if (!cookie) return {}; + + try { + return cookie.json(); + } catch { + return {}; + } +} diff --git a/examples/request-nanostores/src/env.d.ts b/examples/request-nanostores/src/env.d.ts new file mode 100644 index 00000000..e16c13c6 --- /dev/null +++ b/examples/request-nanostores/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/request-nanostores/src/layouts/Layout.astro b/examples/request-nanostores/src/layouts/Layout.astro new file mode 100644 index 00000000..86ba11a6 --- /dev/null +++ b/examples/request-nanostores/src/layouts/Layout.astro @@ -0,0 +1,112 @@ +--- +import CartFlyout from '../components/CartFlyout'; +import CartFlyoutToggle from '../components/CartFlyoutToggle'; + +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + {title} + + +
+ +
+ + + + + + + + diff --git a/examples/request-nanostores/src/pages/index.astro b/examples/request-nanostores/src/pages/index.astro new file mode 100644 index 00000000..1c006a65 --- /dev/null +++ b/examples/request-nanostores/src/pages/index.astro @@ -0,0 +1,60 @@ +--- +import { cartItems, type CartItemDisplayInfo } from '../cartStore'; +import Layout from '../layouts/Layout.astro'; +import AddToCartForm from '../components/AddToCartForm'; +import FigurineDescription from '../components/FigurineDescription.astro'; +import { extractCartCookie } from '../cookies'; + +const item: CartItemDisplayInfo = { + id: 'astronaut-figurine', + name: 'Astronaut Figurine', + imageSrc: '/images/astronaut-figurine.png', +}; + +const cartCookie = extractCartCookie(Astro.cookies); +const itemQuantity = cartCookie[item.id]?.quantity || 0; + +if (itemQuantity > 0) { + cartItems.setKey(item.id, { + ...item, + quantity: itemQuantity, + }); +} +--- + + +
+
+
+ + + + +
+ {item.name} +
+
+
+ + diff --git a/examples/request-nanostores/tsconfig.json b/examples/request-nanostores/tsconfig.json new file mode 100644 index 00000000..e90c686c --- /dev/null +++ b/examples/request-nanostores/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact" + } +} \ No newline at end of file diff --git a/examples/wrangler.toml b/examples/wrangler.toml new file mode 100644 index 00000000..185f7647 --- /dev/null +++ b/examples/wrangler.toml @@ -0,0 +1,3 @@ +pages_build_output_dir = "dist" +compatibility_flags = ["nodejs_als"] +compatibility_date = "2024-08-16" diff --git a/packages/request-state/src/plugin.ts b/packages/request-state/src/plugin.ts index a645ef78..c1f369e7 100644 --- a/packages/request-state/src/plugin.ts +++ b/packages/request-state/src/plugin.ts @@ -11,6 +11,14 @@ export const plugin = (): Plugin => { return RESOLVED_MODULE_ID; } }, + config(config) { + if (config.ssr?.external === true) return; + + config.ssr = { + ...config.ssr, + external: [...(config.ssr?.external ?? []), 'node:async_hooks'], + }; + }, load(id, options) { if (id !== RESOLVED_MODULE_ID) return; diff --git a/packages/request-state/src/runtime/middleware.ts b/packages/request-state/src/runtime/middleware.ts index f03da1c9..1856eb2b 100644 --- a/packages/request-state/src/runtime/middleware.ts +++ b/packages/request-state/src/runtime/middleware.ts @@ -1,4 +1,3 @@ -import type { ReadableStream } from 'node:stream/web'; import { defineMiddleware } from 'astro/middleware'; import { collectState } from './serverState.js'; import { parse } from 'content-type'; @@ -14,18 +13,31 @@ export const onRequest = defineMiddleware(async (_, next) => { if (mediaType !== 'text/html' && !mediaType.startsWith('text/html+')) return result; - async function* render() { - for await (const chunk of result.body as ReadableStream) { - yield chunk; - } + const newBody = result.body + ?.pipeThrough(new TextDecoderStream()) + .pipeThrough(injectState(getState)) + .pipeThrough(new TextEncoderStream()); - const state = getState(); - - if (state) { - yield ``; - } - } - - // @ts-expect-error generator not assignable to ReadableStream - return new Response(render(), result); + return new Response(newBody, result); }); + +function injectState(getState: () => string | false) { + let injected = false; + return new TransformStream({ + transform(chunk, controller) { + if (!injected) { + const bodyCloseIndex = chunk.indexOf(''); + if (bodyCloseIndex > -1) { + const state = getState(); + if (state) { + const stateScript = ``; + + chunk = chunk.slice(0, bodyCloseIndex) + stateScript + chunk.slice(bodyCloseIndex); + } + injected = true; + } + } + controller.enqueue(chunk); + }, + }); +} diff --git a/packages/request-state/tsconfig.json b/packages/request-state/tsconfig.json index bfc6df51..d29b8ba9 100644 --- a/packages/request-state/tsconfig.json +++ b/packages/request-state/tsconfig.json @@ -1,4 +1,9 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "../../tsconfig.base.json" + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": [ + "dom" + ] + } }