Skip to content

Storefront (Astro)

The storefront runtime, its SDK and the Bloom and Noir themes live in storefront/, a separate npm project like docs/. The design is in Storefront runtime and themes.

The Laravel app must be running (Herd serves *.flowery.test).

Terminal window
cd storefront
npm ci
cp .env.example .env # flowery.test, plain http, API on 127.0.0.1
set -a; . ./.env; set +a
npm run dev # http://pilot-shop.flowery.test:4321/
  • API calls. The development server forwards /storefront-api to Herd on the same shop host, so islands and the cart cookie behave as in production.
  • Port. Astro may start the server in the background when there is no terminal; use npx astro dev status, npx astro dev logs and npx astro dev stop to manage it.
  • Previewing a draft. Settings → Storefront → Preview draft opens the Laravel host. Until phase D, change the port in that link to :4321.

To run the production build locally (with CSP, as served in production):

Terminal window
npm run build
STOREFRONT_PROXY_API=true PORT=4322 HOST=127.0.0.1 node dist/server/entry.mjs

STOREFRONT_PROXY_API=true makes the runtime pass /storefront-api requests to the API itself. Production never sets it: nginx routes those paths to Laravel.

php artisan db:seed makes each demo shop a realistic Ukrainian florist on the storefront, through StorefrontShowcaseSeeder:

  • Ukrainian names and real UAH prices.
  • Nine flowers and ten bouquets, with occasions and free-licence photos (resources/demo/CREDITS.md).
  • Fresh stock for the new flowers, with the hydrangea scarce so “few left” shows.
  • A filled profile, and a published Bloom storefront with a hero photo.

The demo inventory states and orders are unchanged.

To upgrade an existing local database, run php artisan db:seed --class=StorefrontShowcaseSeeder. It is idempotent, and it does not overwrite a storefront the shop has already published.

Variable Meaning
STOREFRONT_DOMAIN Shops live on {slug}.{domain}; other hosts get 404. Default flowery.com.ua
STOREFRONT_API_URL API base with a {host} placeholder. Default https://{host}/storefront-api/v1
STOREFRONT_API_ADDRESS Connect to this address instead of resolving the shop host (production: 127.0.0.1, so calls stay on the server; TLS still checks the shop’s name)
STOREFRONT_RUNTIME_KEY Same value as Laravel’s STOREFRONT_RUNTIME_KEY; lets API rate limits see each visitor
STOREFRONT_SECURE_COOKIES false only for plain-http local development
STOREFRONT_PROXY_API true only for local runs of the production build
PORT, HOST Where the Node server listens (production build)
Command What it proves
npm run sdk:check packages/sdk/src/api.d.ts matches docs/storefront-api/openapi.yaml (npm run sdk:generate rewrites it)
npm run themes:check Themes stay inside their boundary
npm run format:check, npm run lint Prettier and ESLint
npm run check astro check: types in .astro, .ts and .tsx
npm test Vitest: exact money, host and route parsing, request helpers, cache, theme boundary
npm run build The Node server build

The CI job storefront runs all of these plus npm audit. Deploys wait for it, and scripts/check-all runs the same steps.

The storefront runs beside Laravel on the Forge server. The files to apply are in deploy/storefront/. Each step is done in Forge or over SSH by the owner; the read-only checks can be run by anyone.

How it fits together

  • Routing. nginx sends only storefront pages on shop hosts (subdomains, and shops’ own domains since ADR-022) to the Node server on 127.0.0.1:4330: /, /catalogue, /bouquets/N, /flowers/N, /builder, /cart, /checkout, /orders/{token}, /create-bouquet, /__storefront/… and /_astro/….
  • Laravel keeps everything else: the root domain, www, /control, sign-in, /storefront-api, /build, /storage, /up, /release and /docs.
  • The switch. One map value decides (every host is a storefront except the root and www), so turning it off is a one-line rollback.
  • Restarts. A Forge daemon (supervisor) keeps the server running. On every deploy, the deploy script builds storefront/ in the new release and stops the old process; supervisor starts it again from the new current release. That needs no sudo, and the forge user may otherwise only restart all supervisor programs, which would bounce other sites’ daemons.
  • Release check. Once the repository variable STOREFRONT_RELEASE_URL is set, CI waits for /__storefront/release to report the deployed commit, as it does for Laravel’s /release.

The routing was tested on 4 October 2026 with the same snippets behind a local nginx:

  • Every storefront path on a shop host reaches Astro.
  • Every Laravel path, and every root-domain or www path, reaches Laravel.
  • With the storefront stopped, shop pages fall back to Laravel.

Steps

  1. Shared secret. Generate a key with openssl rand -hex 32. Add STOREFRONT_RUNTIME_KEY=<key> to the site’s environment in Forge.
  2. Storefront environment. Over SSH, copy deploy/storefront/storefront.env.example to /home/forge/flowery.com.ua/storefront.env. Put the same key in it, then chmod 600 it.
  3. Deploy script. Add the two blocks from deploy/storefront/deploy-script.sh to the site’s deploy script: the build with the other build steps, and the restart after the release is activated.
  4. Deploy. Deploy (merge, or Forge’s Deploy button). Check that ~/flowery.com.ua/current/storefront/dist/server/entry.mjs exists.
  5. Background process. In Forge → Site → Background processes, add the process from deploy/storefront/README.md (older Forge: Server → Daemons). It needs a build in current, so deploy first. Check it:
    Terminal window
    curl -s -H 'Host: batata.flowery.com.ua' http://127.0.0.1:4330/__storefront/release
    curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: batata.flowery.com.ua' http://127.0.0.1:4330/
    The first prints the deployed commit; the second prints 200.
  6. nginx. In Forge → Site → Edit Nginx Configuration:
    • Paste nginx-map.conf above the first server {.
    • Paste nginx-locations.conf inside the server block, above include forge-conf/…/site.conf;.
    • Save; Forge tests the configuration before reloading.
  7. Verify in a browser:
    • A shop’s home, catalogue, bouquet, builder and cart pages.
    • “Preview draft” from Settings → Storefront, which now opens the new storefront.
    • The control panel and sign-in on the shop host.
    • The root domain, which should be unchanged.
    • The browser’s console.
  8. Release check. Run gh variable set STOREFRONT_RELEASE_URL --body https://release.flowery.com.ua/__storefront/release.
  9. Retire the old storefront. Done 5 October 2026. The Laravel storefront’s pages, cart routes, controllers and components are gone. Laravel now answers storefront paths only through the nginx fallback (the storefront process restarting or down) with a short bilingual 503 page, “back in a moment”, carrying the shop’s published phone and Retry-After: 30. Keep the error_page 502 504 line: it is what shows that page instead of a 502.
    • The storefront route name still builds links (onboarding, previews, dev login).
    • Locally, pilot-shop.flowery.test without a port shows that page; the storefront itself runs on :4321.

Applied 5 October 2026.

  • Server files: the shared key went into Laravel’s .env (a backup was kept beside it) and storefront.env over SSH, generated on the server and never displayed.
  • Forge: the owner made the Forge changes.
  • Checks, read-only:
    • The process answered on 4330 with the live commit.
    • After nginx, the shop pages came from the storefront with its CSP, while /control, /login, /storefront-api, the root domain, /release and www stayed with Laravel.
    • An unknown shop got the storefront’s “shop not found” page.
    • No console errors.

Restarting on deploy. The deploy script’s line must be anchored:

Terminal window
pkill -f -- '^node --env-file=/home/forge/flowery.com.ua/storefront.env' || true

Forge runs the deploy as bash -c with the whole script on its command line. An unanchored pkill -f pattern therefore also matched the deploy’s own shell: the first deploy after the cutover (5 October 2026) was cut off after the restart, and the old-release purge never ran. The anchored pattern matches only a process that starts with node.

Adding storefront paths. nginx sends only the listed paths to the storefront. When a release adds a page, replace the location ~ … line in Forge with the one from deploy/storefront/nginx-locations.conf before switching a feature on, then check the path with curl -I. Laravel’s fallback route in routes/web.php lists the same paths, so the “back in a moment” page covers them too.

  • Stage 3A checkout (October 2026) added /checkout and /orders/{token}. Until the line is updated, those paths reach Laravel and get the “back in a moment” page, so a shop’s checkout must stay off.
  • Replace only that line, and check that nginx reloaded. On 5 October 2026 the site’s file in Forge’s editor (/etc/nginx/sites-available/flowery.com.ua) was replaced by its inner rules only, without the map, the server { listen 443 … } block and include forge-conf/3410669/site.conf; (which carries the PHP location). nginx’s configuration test failed on every save and restart, so nginx quietly kept the previous configuration and the new paths never went live. A reboot or hard restart would have failed to start nginx for every site on the shared server.
    • The file must keep this shape: the two include forge-conf/…/before/* lines, the map, server { … the storefront location, the @flowery_laravel location, include forge-conf/3410669/site.conf; }, and the two after/* includes.
    • After saving, confirm the reload (read-only): ssh chat-ui "ps -eo lstart,cmd | grep 'nginx: worker' | grep -v grep | head -1" must show the time of the save, and curl -sI https://<shop>.flowery.com.ua/checkout must carry the storefront’s content-security-policy header.

Rollback. In the Nginx configuration, change the map’s default 1 to 0 and save. After step 9, Laravel only shows the “back in a moment” page on storefront paths, so a rollback means a short outage of the shop pages, not the old storefront.

A shop’s own domain (ADR-022, custom domains) reaches this server through Cloudflare with the shop’s hostname unchanged in both the Host header and the TLS name. Three server-side changes make the site answer such hosts; the files are in deploy/storefront/:

  1. server_name. In the server { listen 443 ssl; … } block, server_name .flowery.com.ua ~^[a-z0-9.-]+$; so a hostname no other site on the shared server claims lands in this block (nginx-custom-domains.conf explains the order nginx tries names in). The certificate is still the site’s *.flowery.com.ua one; Cloudflare accepts it for these hosts because the zone’s encryption mode is Full, not Full (strict).
  2. The map. nginx-map.conf now defaults to 1 and lists the root and www as 0: a shop’s own domain is a storefront. Laravel’s custom-host routes answer the Storefront API and the fallback page on it; everything else there is a 404 by routing, not by nginx.
  3. Real addresses. The set_real_ip_from lines in nginx-custom-domains.conf and real_ip_header CF-Connecting-IP, inside the server block (at the top of the file they would apply to every site on the shared server), give nginx, Laravel’s rate limits and the storefront’s X-Forwarded-For the visitor’s address instead of Cloudflare’s edge. All traffic already came through Cloudflare, so until this was applied every visitor shared one Storefront API bucket.
  4. The runtime’s TLS name. STOREFRONT_API_SNI=flowery.com.ua in storefront.env: the runtime presents and verifies that name on its loopback API calls while sending the shop’s Host, so a shop on its own domain reaches the API too (runtime). Restart the storefront process after changing the file.

After saving, confirm the reload as above, then check read-only on the server: curl -sk --resolve www.example-shop.ua:443:127.0.0.1 https://www.example-shop.ua/ must return the storefront’s “shop not found” page, not another site’s.

Applied 8 October 2026 (release 8c1f118, the same evening the first part of ADR-022 was deployed). The owner pasted the full file in Forge; read-only checks afterwards: the workers restarted at the time of the save, a foreign host at the origin got the storefront’s 404 with its CSP and /login on it a plain 404, the shop subdomain still came from Astro, the root from Laravel. One wrong turn worth knowing: Forge’s editor also shows forge-conf/…/site.conf (the PHP location and headers); that is the file included by the site file, not the one to edit.

  1. Create storefront/themes/<id>/ with a theme.json manifest. Laravel validates the manifest at boot (configuration).
  2. Write Theme.astro and the seven templates, importing only @flowery/storefront-sdk, React and the theme’s own files.
  3. Add the theme to src/runtime/themes.ts and give it an entry page at src/pages/theme-entry/<id>/[...path].astro, copied from Bloom’s.
  4. Run npm run themes:check and check every template in the browser in both languages.