create-stitchkit 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +225 -0
  4. package/dist/cli.js +233 -41
  5. package/examples/repository/_env.example.append +21 -0
  6. package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
  7. package/examples/repository/packages/backend/src/surface.ts +1 -1
  8. package/examples/repository/packages/config/src/features.ts +17 -0
  9. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
  10. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  11. package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
  12. package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
  13. package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
  14. package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
  15. package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
  16. package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
  17. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
  18. package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
  19. package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
  20. package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
  21. package/examples/repository/project.json +189 -0
  22. package/examples/repository/scripts/runtime-smoke.ts +25 -5
  23. package/package.json +9 -1
  24. package/template/AGENTS.md +15 -2
  25. package/template/README.md +51 -6
  26. package/template/_env.example +10 -4
  27. package/template/biome.json +5 -1
  28. package/template/bun.lock +2 -2
  29. package/template/e2e/starter.spec.ts +5 -7
  30. package/template/ecosystem.config.cjs +42 -13
  31. package/template/ecosystem.dev.config.cjs +41 -15
  32. package/template/package.json +5 -4
  33. package/template/packages/backend/package.json +1 -1
  34. package/template/packages/backend/scripts/ensure-built.ts +7 -0
  35. package/template/packages/backend/src/cli.ts +6 -2
  36. package/template/packages/backend/src/index.ts +23 -7
  37. package/template/packages/backend/src/surface.ts +6 -1
  38. package/template/packages/backend/src/transport/errors.ts +4 -2
  39. package/template/packages/backend/tsconfig.json +1 -1
  40. package/template/packages/config/package.json +3 -1
  41. package/template/packages/config/src/app-identity.generated.ts +20 -0
  42. package/template/packages/config/src/declaration.ts +30 -0
  43. package/template/packages/config/src/project-declaration.generated.ts +611 -0
  44. package/template/packages/config/src/server.ts +8 -14
  45. package/template/packages/config/src/variables.ts +89 -0
  46. package/template/packages/frontend/next.config.ts +3 -2
  47. package/template/packages/frontend/package.json +2 -2
  48. package/template/packages/frontend/scripts/serve.ts +70 -0
  49. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  50. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  51. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  52. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  53. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  54. package/template/packages/frontend/src/app/robots.ts +4 -2
  55. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  56. package/template/packages/frontend/src/env.ts +27 -8
  57. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  58. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  59. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  60. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  61. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  62. package/template/packages/frontend/src/theme/config.ts +1 -1
  63. package/template/packages/frontend/tsconfig.json +10 -3
  64. package/template/playwright.config.ts +1 -1
  65. package/template/project.json +169 -0
  66. package/template/scripts/build-inputs.test.ts +69 -0
  67. package/template/scripts/build-inputs.ts +57 -0
  68. package/template/scripts/check-authored.ts +18 -2
  69. package/template/scripts/declaration.test.ts +206 -0
  70. package/template/scripts/declaration.ts +268 -0
  71. package/template/scripts/dev.ts +84 -15
  72. package/template/scripts/local-env.test.ts +2 -2
  73. package/template/scripts/local-env.ts +3 -3
  74. package/template/scripts/release-steps.test.ts +87 -0
  75. package/template/scripts/release-steps.ts +108 -0
  76. package/template/scripts/release.ts +30 -0
  77. package/template/scripts/runtime-smoke.ts +7 -4
  78. package/template/scripts/serve-mode.test.ts +36 -0
  79. package/template/scripts/supervision-signal.test.ts +94 -0
  80. package/template/scripts/tooling-env.ts +5 -2
  81. package/template/scripts/web-surface-smoke.ts +70 -0
  82. package/template/app.config.json +0 -9
  83. package/template/packages/config/src/identity.ts +0 -18
@@ -0,0 +1,89 @@
1
+ import { z } from 'zod';
2
+ import { featureServerSchema } from './features';
3
+
4
+ /**
5
+ * Every environment variable this application reads, declared **once**.
6
+ *
7
+ * There used to be three overlapping copies — the server schema, the frontend
8
+ * schema and the tooling schema — and they had already drifted in composition.
9
+ * A fourth copy in the project declaration would have been worse still, so this
10
+ * module is the source and everything else is a projection of it:
11
+ *
12
+ * - `server.ts` validates all of them for the API role;
13
+ * - `frontend/src/env.ts` projects the handful the web role reads;
14
+ * - `scripts/declaration.ts` DERIVES `env.required` in `project.json` from
15
+ * here, so the declaration a deployment reads can never fall behind.
16
+ *
17
+ * Defaults, coercion and error messages live here and only here. The
18
+ * declaration carries names and shapes; it deliberately carries no values.
19
+ */
20
+ const baseVariables = {
21
+ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
22
+ DATABASE_URL: z.url(),
23
+ // Loopback by default — exposing the app to the network is an explicit
24
+ // opt-in (`BIND_HOST=0.0.0.0`), never something a forgotten edit causes.
25
+ BIND_HOST: z.string().min(1).default('127.0.0.1'),
26
+ API_PORT: z.coerce.number().int().positive(),
27
+ WEB_PORT: z.coerce.number().int().positive(),
28
+ /**
29
+ * Override for the public origin of the web role, for a proxy that forwards
30
+ * neither `x-forwarded-host` nor a usable `Host`. Normally unset: the origin
31
+ * comes from the request, which is what lets one artifact serve many
32
+ * addresses.
33
+ */
34
+ PUBLIC_WEB_ORIGIN: z.url().optional(),
35
+ /**
36
+ * Which hosts this deployment answers for, comma-separated, when more than
37
+ * one address reaches the same artifact. Unset means only PUBLIC_WEB_ORIGIN;
38
+ * a forwarded host outside the list is refused rather than believed.
39
+ */
40
+ PUBLIC_WEB_HOSTS: z.string().min(1).optional(),
41
+ /**
42
+ * Where the web role reaches the API role, server side. Never seen by a
43
+ * browser. The blank starter needs it only if it calls the API at all.
44
+ */
45
+ INTERNAL_API_URL: z.url().optional(),
46
+ /**
47
+ * Where the BROWSER dials the API role over HTTP, when it must dial it
48
+ * directly instead of reaching it through its own origin.
49
+ *
50
+ * Set it only for a genuinely cross-origin frontend. It affects HTTP and
51
+ * nothing else — the realtime socket has its own variable below, because the
52
+ * two answers differ: HTTP can be forwarded by the web role, and a WebSocket
53
+ * upgrade cannot.
54
+ */
55
+ PUBLIC_API_ORIGIN: z.url().optional(),
56
+ /**
57
+ * Where the browser opens the realtime socket, when the two roles do not
58
+ * share an origin.
59
+ *
60
+ * Separate from `PUBLIC_API_ORIGIN` on purpose. A WebSocket upgrade does not
61
+ * survive a proxying route handler, so a deployment can serve HTTP from one
62
+ * origin and still need to name the socket's — which is exactly the shape of
63
+ * running the two roles on two ports with no routing layer in front of them.
64
+ * Unset means the page's own origin, where a routing layer forwards
65
+ * `/socket.io` to the API role.
66
+ */
67
+ PUBLIC_REALTIME_ORIGIN: z.url().optional(),
68
+ LOG_FORMAT: z.enum(['pretty', 'json']).default('pretty'),
69
+ /**
70
+ * The browser origin the API role admits, for HTTP and for the realtime
71
+ * handshake alike. Only a genuinely cross-origin browser needs it: a frontend
72
+ * reaching the API through its own origin makes same-origin requests, and
73
+ * requiring an origin there would be requiring knowledge of the place.
74
+ */
75
+ CORS_ORIGIN: z.url().optional(),
76
+ };
77
+
78
+ /**
79
+ * The base declaration, then whatever this project's own features say.
80
+ *
81
+ * A merge rather than a spread inside one literal, so an overlay can **tighten**
82
+ * a variable the base declares optional — not only add new ones. Without that,
83
+ * a project whose code dereferences a variable without a fallback had no way to
84
+ * say so, and its declaration told a deployment the variable was optional while
85
+ * the first render threw.
86
+ */
87
+ export const applicationVariables = { ...baseVariables, ...featureServerSchema };
88
+
89
+ export type ApplicationVariable = keyof typeof applicationVariables;
@@ -1,7 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import type { NextConfig } from 'next';
3
3
  import createNextIntlPlugin from 'next-intl/plugin';
4
- import { env } from './src/env';
5
4
 
6
5
  const config: NextConfig = {
7
6
  agentRules: false,
@@ -11,7 +10,9 @@ const config: NextConfig = {
11
10
  turbopack: {
12
11
  root: path.resolve(import.meta.dirname, '../..'),
13
12
  },
14
- allowedDevOrigins: [new URL(env.NEXT_PUBLIC_WEB_URL).hostname],
13
+ // Development only, and deliberately not derived from a public address:
14
+ // reading one here would pull a value of the place into the build.
15
+ allowedDevOrigins: ['127.0.0.1', 'localhost'],
15
16
  images: { remotePatterns: [] },
16
17
  };
17
18
 
@@ -4,9 +4,9 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "next dev --port ${WEB_PORT:-3210}",
7
+ "dev": "bun scripts/serve.ts development",
8
8
  "build": "next build",
9
- "start": "next start --port ${WEB_PORT:-3210}",
9
+ "start": "bun scripts/serve.ts production",
10
10
  "check": "next typegen && bun x tsc --noEmit",
11
11
  "test": "bun test --pass-with-no-tests"
12
12
  },
@@ -0,0 +1,70 @@
1
+ import { env } from '../src/env';
2
+
3
+ /**
4
+ * Run the web role.
5
+ *
6
+ * Next takes its port and interface as command-line arguments; the ROLE builds
7
+ * that argv from its own bindings, so a deployment only ever has to set
8
+ * variables. That is the whole contract of the declaration: one injection form,
9
+ * named by the role, and no supervisor guessing how a process wants its port.
10
+ *
11
+ * There is no fallback port here on purpose. A default in the repository is a
12
+ * value of the place living in the code, and a forgotten variable would start
13
+ * the role on the wrong port in silence instead of failing by name — the
14
+ * environment schema in `@app/config` is the one place a default may live, and
15
+ * `WEB_PORT` deliberately has none.
16
+ */
17
+ /**
18
+ * The run mode, fail-closed.
19
+ *
20
+ * `argv[2] === 'development' ? 'dev' : 'start'` meant a typo, an empty string
21
+ * and a missing argument all silently became production. A wrong mode is not a
22
+ * detail — it decides whether the role serves a build or compiles on demand —
23
+ * and the supervision files pass it explicitly, so anything else is a mistake
24
+ * worth seeing.
25
+ */
26
+ const MODES: Record<string, 'dev' | 'start'> = {
27
+ development: 'dev',
28
+ production: 'start',
29
+ };
30
+
31
+ const requested = process.argv[2];
32
+ const mode = requested === undefined ? undefined : MODES[requested];
33
+ if (mode === undefined) {
34
+ throw new Error(
35
+ `Run mode must be "development" or "production", received ${requested === undefined ? 'nothing' : `"${requested}"`}. The supervision files pass it explicitly; run \`bun run gen:declaration\` if they have fallen behind.`,
36
+ );
37
+ }
38
+ const next = new URL('../node_modules/.bin/next', import.meta.url).pathname;
39
+
40
+ const child = Bun.spawn(
41
+ [next, mode, '--port', String(env.WEB_PORT), '--hostname', env.BIND_HOST],
42
+ { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' },
43
+ );
44
+
45
+ // Forward the shutdown signal rather than dying and orphaning the server: the
46
+ // supervisor's kill timeout is measured against the role, not against this
47
+ // three-line wrapper.
48
+ let stopping = false;
49
+
50
+ function forward(signal: 'SIGINT' | 'SIGTERM'): void {
51
+ stopping = true;
52
+ child.kill(signal);
53
+ }
54
+
55
+ process.on('SIGINT', () => forward('SIGINT'));
56
+ process.on('SIGTERM', () => forward('SIGTERM'));
57
+
58
+ const code = await child.exited;
59
+ /**
60
+ * A stop that was ASKED for is a success — but only for the codes a stop
61
+ * actually produces.
62
+ *
63
+ * `stopping ? 0 : code` reported success for ANY exit during shutdown, so a
64
+ * role that crashed while draining looked identical to one that drained. Next
65
+ * exits 130 on SIGINT and 143 on SIGTERM, and reporting those upward would make
66
+ * every ordinary supervised stop look like a failure; anything else during a
67
+ * shutdown is a real failure and keeps its code.
68
+ */
69
+ const SIGNAL_EXIT_CODES = new Set([0, 130, 143]);
70
+ process.exitCode = stopping && SIGNAL_EXIT_CODES.has(code) ? 0 : code;
@@ -5,10 +5,9 @@ import { notFound } from 'next/navigation';
5
5
  import { NextIntlClientProvider } from 'next-intl';
6
6
  import { getMessages } from 'next-intl/server';
7
7
  import type { ReactNode } from 'react';
8
- import { env } from '@/env';
9
8
  import { LocaleSchema } from '@/i18n/locales';
10
9
  import { routing } from '@/i18n/routing';
11
- import { createPageMetadata } from '@/lib/seo/metadata';
10
+ import { createPageMetadata, siteMetadataBase } from '@/lib/seo/metadata';
12
11
  import { SITE_NAME } from '@/lib/seo/pages';
13
12
  import { Providers } from '@/providers';
14
13
  import { themeProviderConfig } from '@/theme/config';
@@ -21,12 +20,14 @@ const montserrat = Montserrat({
21
20
  display: 'swap',
22
21
  });
23
22
 
24
- export const metadata: Metadata = {
25
- metadataBase: new URL(env.NEXT_PUBLIC_WEB_URL),
26
- ...createPageMetadata('home', 'en'),
27
- title: SITE_NAME,
28
- icons: { icon: [{ url: '/favicon/mascot-stitch.png', type: 'image/png' }] },
29
- };
23
+ export async function generateMetadata(): Promise<Metadata> {
24
+ return {
25
+ metadataBase: await siteMetadataBase(),
26
+ ...(await createPageMetadata('home', 'en')),
27
+ title: SITE_NAME,
28
+ icons: { icon: [{ url: '/favicon/mascot-stitch.png', type: 'image/png' }] },
29
+ };
30
+ }
30
31
 
31
32
  export function generateStaticParams() {
32
33
  return routing.locales.map((locale) => ({ locale }));
@@ -1,4 +1,4 @@
1
- import { appIdentity } from '@app/config/identity';
1
+ import { appIdentity } from '@app/config/app-identity';
2
2
  import type { Metadata } from 'next';
3
3
  import { getTranslations } from 'next-intl/server';
4
4
  import { LocaleSchema } from '@/i18n/locales';
@@ -13,7 +13,7 @@ export async function generateMetadata({
13
13
  params: Promise<{ locale: string }>;
14
14
  }): Promise<Metadata> {
15
15
  const { locale } = await params;
16
- return createPageMetadata('home', LocaleSchema.parse(locale));
16
+ return await createPageMetadata('home', LocaleSchema.parse(locale));
17
17
  }
18
18
 
19
19
  export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
@@ -56,7 +56,7 @@ const architecture = [
56
56
  },
57
57
  ];
58
58
 
59
- export function StarterPage({
59
+ export async function StarterPage({
60
60
  applicationName,
61
61
  applicationDescription,
62
62
  heroTitle,
@@ -70,7 +70,7 @@ export function StarterPage({
70
70
  name: SITE_NAME,
71
71
  applicationCategory: 'DeveloperApplication',
72
72
  operatingSystem: 'Web',
73
- url: absoluteSiteUrl(`/${locale}`),
73
+ url: await absoluteSiteUrl(`/${locale}`),
74
74
  description: homeSeo.description,
75
75
  };
76
76
 
@@ -16,7 +16,7 @@ export async function generateMetadata({
16
16
  }): Promise<Metadata> {
17
17
  const { locale, story } = await params;
18
18
  if (!isStoryId(story)) notFound();
19
- return createPageMetadata(story, LocaleSchema.parse(locale));
19
+ return await createPageMetadata(story, LocaleSchema.parse(locale));
20
20
  }
21
21
 
22
22
  export default async function UiStoryPage({
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { appIdentity } from '@app/config/identity';
3
+ import { appIdentity } from '@app/config/app-identity';
4
4
  import { IconArrowRight, IconBraces, IconDatabase, IconWorld } from '@tabler/icons-react';
5
5
  import type { ReactNode } from 'react';
6
6
  import { BrandMark } from '@/components/brand-mark';
@@ -1,9 +1,11 @@
1
1
  import type { MetadataRoute } from 'next';
2
2
  import { absoluteSiteUrl } from '@/lib/seo/metadata';
3
3
 
4
- export default function robots(): MetadataRoute.Robots {
4
+ // Dynamic on purpose: a prerendered robots.txt freezes one external address
5
+ // into the artifact, and a single build then cannot serve a second one.
6
+ export default async function robots(): Promise<MetadataRoute.Robots> {
5
7
  return {
6
8
  rules: { userAgent: '*', allow: '/' },
7
- sitemap: absoluteSiteUrl('/sitemap.xml'),
9
+ sitemap: await absoluteSiteUrl('/sitemap.xml'),
8
10
  };
9
11
  }
@@ -1,22 +1,10 @@
1
1
  import type { MetadataRoute } from 'next';
2
- import { locales } from '@/i18n/locales';
3
- import { absoluteSiteUrl } from '@/lib/seo/metadata';
4
- import { localizedPagePath, publicPageIds } from '@/lib/seo/pages';
2
+ import { sitemapForOrigin } from '@/lib/seo/metadata';
3
+ import { requestOrigin } from '@/lib/seo/request-origin';
5
4
 
6
- export default function sitemap(): MetadataRoute.Sitemap {
7
- return publicPageIds.flatMap((pageId) =>
8
- locales.map((locale) => ({
9
- url: absoluteSiteUrl(localizedPagePath(pageId, locale)),
10
- changeFrequency: pageId === 'home' ? 'weekly' : 'monthly',
11
- priority: pageId === 'home' ? 1 : 0.7,
12
- alternates: {
13
- languages: Object.fromEntries(
14
- locales.map((availableLocale) => [
15
- availableLocale,
16
- absoluteSiteUrl(localizedPagePath(pageId, availableLocale)),
17
- ]),
18
- ),
19
- },
20
- })),
21
- );
5
+ // Dynamic on purpose (see robots.ts): the URLs are a function of the origin
6
+ // this response is served on, and that is not known until the request arrives.
7
+ // The entries themselves are built once per origin, not once per request.
8
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
9
+ return sitemapForOrigin(await requestOrigin());
22
10
  }
@@ -1,21 +1,40 @@
1
1
  import path from 'node:path';
2
+ import { applicationVariables } from '@app/config/variables';
2
3
  import { createEnv } from '@t3-oss/env-nextjs';
3
4
  import { config } from 'dotenv';
4
- import { z } from 'zod';
5
5
 
6
6
  config({ path: path.resolve(process.cwd(), '../../.env'), quiet: true });
7
7
 
8
+ /**
9
+ * The web role's view of the environment — a PROJECTION of the one declaration
10
+ * in `@app/config/variables`, never a second copy of it.
11
+ *
12
+ * There is deliberately NO `client` block. A `NEXT_PUBLIC_` variable is
13
+ * substituted at BUILD time, so declaring one freezes a value of the place into
14
+ * the artifact — the built server chunk once carried
15
+ * `NEXT_PUBLIC_API_URL:"http://…"` as a literal while a plain `WEB_PORT` stayed
16
+ * a runtime read. Anything the browser needs is either relative (so it needs no
17
+ * address at all), derived from the request, or read on the server and handed
18
+ * down per request — never compiled in.
19
+ */
8
20
  export const env = createEnv({
9
- server: { INTERNAL_API_URL: z.url(), WEB_PORT: z.coerce.number().int().positive() },
10
- client: {
11
- NEXT_PUBLIC_API_URL: z.url(),
12
- NEXT_PUBLIC_WEB_URL: z.url(),
21
+ server: {
22
+ BIND_HOST: applicationVariables.BIND_HOST,
23
+ WEB_PORT: applicationVariables.WEB_PORT,
24
+ PUBLIC_WEB_ORIGIN: applicationVariables.PUBLIC_WEB_ORIGIN,
25
+ PUBLIC_WEB_HOSTS: applicationVariables.PUBLIC_WEB_HOSTS,
26
+ INTERNAL_API_URL: applicationVariables.INTERNAL_API_URL,
27
+ PUBLIC_API_ORIGIN: applicationVariables.PUBLIC_API_ORIGIN,
28
+ PUBLIC_REALTIME_ORIGIN: applicationVariables.PUBLIC_REALTIME_ORIGIN,
13
29
  },
14
30
  runtimeEnv: {
15
- INTERNAL_API_URL: process.env.INTERNAL_API_URL,
31
+ BIND_HOST: process.env.BIND_HOST,
16
32
  WEB_PORT: process.env.WEB_PORT,
17
- NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
18
- NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL,
33
+ PUBLIC_WEB_ORIGIN: process.env.PUBLIC_WEB_ORIGIN,
34
+ PUBLIC_WEB_HOSTS: process.env.PUBLIC_WEB_HOSTS,
35
+ INTERNAL_API_URL: process.env.INTERNAL_API_URL,
36
+ PUBLIC_API_ORIGIN: process.env.PUBLIC_API_ORIGIN,
37
+ PUBLIC_REALTIME_ORIGIN: process.env.PUBLIC_REALTIME_ORIGIN,
19
38
  },
20
39
  emptyStringAsUndefined: true,
21
40
  });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { cacheByOrigin } from './cache-by-origin';
3
+
4
+ describe('cacheByOrigin', () => {
5
+ test('builds once per address, not once per request', () => {
6
+ let builds = 0;
7
+ const render = cacheByOrigin(
8
+ (origin: string) => origin,
9
+ (origin: string) => {
10
+ builds += 1;
11
+ return `${origin}/sitemap.xml`;
12
+ },
13
+ );
14
+
15
+ expect(render('https://alpha.example')).toBe('https://alpha.example/sitemap.xml');
16
+ expect(render('https://alpha.example')).toBe('https://alpha.example/sitemap.xml');
17
+ expect(render('https://alpha.example')).toBe('https://alpha.example/sitemap.xml');
18
+ expect(builds).toBe(1);
19
+
20
+ expect(render('https://beta.example')).toBe('https://beta.example/sitemap.xml');
21
+ expect(builds).toBe(2);
22
+ });
23
+
24
+ test('a forged Host cannot grow the cache without limit', () => {
25
+ let builds = 0;
26
+ const render = cacheByOrigin(
27
+ (origin: string) => origin,
28
+ (origin: string) => {
29
+ builds += 1;
30
+ return origin;
31
+ },
32
+ 2,
33
+ );
34
+
35
+ render('a');
36
+ render('b');
37
+ render('c'); // evicts the least recently used, 'a'
38
+ expect(builds).toBe(3);
39
+
40
+ render('c');
41
+ render('b');
42
+ expect(builds).toBe(3);
43
+
44
+ render('a'); // evicted, so rebuilt — the cache stayed bounded
45
+ expect(builds).toBe(4);
46
+ });
47
+
48
+ test('recency is refreshed on a hit, so a hot address is not evicted', () => {
49
+ let builds = 0;
50
+ const render = cacheByOrigin(
51
+ (origin: string) => origin,
52
+ (origin: string) => {
53
+ builds += 1;
54
+ return origin;
55
+ },
56
+ 2,
57
+ );
58
+
59
+ render('hot');
60
+ render('cold');
61
+ render('hot'); // refreshes 'hot', leaving 'cold' as least recent
62
+ render('new'); // evicts 'cold', not 'hot'
63
+ expect(builds).toBe(3);
64
+
65
+ render('hot');
66
+ expect(builds).toBe(3);
67
+ });
68
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Memoise a pure function of its input, so a deployment serving N addresses builds
3
+ * each answer once rather than once per request.
4
+ *
5
+ * This is the price of deriving the public origin from the request instead of
6
+ * the build: `robots.txt`, `sitemap.xml` and page metadata stopped being
7
+ * prerendered constants. They are still constants *per address*, and this is
8
+ * where that is spent — one render per address, not one per hit.
9
+ *
10
+ * Bounded and least-recently-used on purpose: part of every key comes from a
11
+ * request header, and a forged `Host` must not be able to grow the cache
12
+ * without limit. The key is derived by `keyOf` so the builder keeps its real
13
+ * types — nothing is stringified and parsed back.
14
+ */
15
+ export function cacheByOrigin<Input, Value>(
16
+ keyOf: (input: Input) => string,
17
+ build: (input: Input) => Value,
18
+ limit = 64,
19
+ ): (input: Input) => Value {
20
+ // The value is boxed so that a builder returning `undefined` is still a hit.
21
+ // Testing the stored value for `undefined` would rebuild it on every request
22
+ // while the cache kept growing an entry per key.
23
+ const entries = new Map<string, { value: Value }>();
24
+ return (input) => {
25
+ const key = keyOf(input);
26
+ const hit = entries.get(key);
27
+ if (hit) {
28
+ entries.delete(key);
29
+ entries.set(key, hit);
30
+ return hit.value;
31
+ }
32
+ const built = { value: build(input) };
33
+ entries.set(key, built);
34
+ if (entries.size > limit) {
35
+ const oldest = entries.keys().next();
36
+ if (!oldest.done) entries.delete(oldest.value);
37
+ }
38
+ return built.value;
39
+ };
40
+ }
@@ -1,25 +1,57 @@
1
- import type { Metadata } from 'next';
2
- import { env } from '@/env';
1
+ import type { Metadata, MetadataRoute } from 'next';
3
2
  import type { AppLocale } from '@/i18n/locales';
4
3
  import { locales } from '@/i18n/locales';
4
+ import { cacheByOrigin } from './cache-by-origin';
5
5
  import type { SeoPageId } from './pages';
6
- import { getSeoPage, localizedPagePath, SITE_NAME } from './pages';
6
+ import { getSeoPage, localizedPagePath, publicPageIds, SITE_NAME } from './pages';
7
+ import { requestOrigin } from './request-origin';
7
8
 
8
- export const siteOrigin = new URL(env.NEXT_PUBLIC_WEB_URL).origin;
9
+ /** Absolute URL for `path` on the origin this response is being served from. */
10
+ export async function absoluteSiteUrl(path: string): Promise<string> {
11
+ return siteUrl(await requestOrigin(), path);
12
+ }
13
+
14
+ function siteUrl(origin: string, path: string): string {
15
+ return new URL(path, origin).toString();
16
+ }
9
17
 
10
- export function absoluteSiteUrl(path: string): string {
11
- return new URL(path, siteOrigin).toString();
18
+ interface PageMetadataInput {
19
+ origin: string;
20
+ pageId: SeoPageId;
21
+ locale: AppLocale;
12
22
  }
13
23
 
14
- export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metadata {
24
+ /**
25
+ * Page metadata for one origin. Pure in its inputs, so it is built once per
26
+ * (origin, page, locale) instead of once per request — the cost of trading a
27
+ * build-time constant for a request-time value, paid once per address.
28
+ */
29
+ const metadataFor = cacheByOrigin(
30
+ ({ origin, pageId, locale }: PageMetadataInput) => `${origin}|${pageId}|${locale}`,
31
+ ({ origin, pageId, locale }: PageMetadataInput) => buildPageMetadata(origin, pageId, locale),
32
+ );
33
+
34
+ export async function createPageMetadata(
35
+ pageId: SeoPageId,
36
+ locale: AppLocale,
37
+ ): Promise<Metadata> {
38
+ return metadataFor({ origin: await requestOrigin(), pageId, locale });
39
+ }
40
+
41
+ /** `metadataBase` for the origin in hand — relative metadata resolves against it. */
42
+ export async function siteMetadataBase(): Promise<URL> {
43
+ return new URL(await requestOrigin());
44
+ }
45
+
46
+ function buildPageMetadata(origin: string, pageId: SeoPageId, locale: AppLocale): Metadata {
15
47
  const page = getSeoPage(pageId, locale);
16
48
  const title = pageId === 'home' ? SITE_NAME : `${page.title} · ${SITE_NAME}`;
17
49
  const canonicalPath = localizedPagePath(pageId, locale);
18
- const image = absoluteSiteUrl(`/api/og/${locale}/${pageId}`);
50
+ const image = siteUrl(origin, `/api/og/${locale}/${pageId}`);
19
51
  const languageAlternates = Object.fromEntries(
20
52
  locales.map((availableLocale) => [
21
53
  availableLocale,
22
- absoluteSiteUrl(localizedPagePath(pageId, availableLocale)),
54
+ siteUrl(origin, localizedPagePath(pageId, availableLocale)),
23
55
  ]),
24
56
  );
25
57
 
@@ -27,7 +59,7 @@ export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metada
27
59
  title,
28
60
  description: page.description,
29
61
  alternates: {
30
- canonical: absoluteSiteUrl(canonicalPath),
62
+ canonical: siteUrl(origin, canonicalPath),
31
63
  languages: languageAlternates,
32
64
  },
33
65
  openGraph: {
@@ -35,7 +67,7 @@ export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metada
35
67
  siteName: SITE_NAME,
36
68
  title,
37
69
  description: page.description,
38
- url: absoluteSiteUrl(canonicalPath),
70
+ url: siteUrl(origin, canonicalPath),
39
71
  locale: locale === 'ru' ? 'ru_RU' : 'en_US',
40
72
  alternateLocale: locale === 'ru' ? ['en_US'] : ['ru_RU'],
41
73
  images: [{ url: image, width: 1200, height: 630, alt: `${title} — ${page.eyebrow}` }],
@@ -48,3 +80,28 @@ export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metada
48
80
  },
49
81
  };
50
82
  }
83
+
84
+ /**
85
+ * Sitemap entries for one origin — the same value for every request that
86
+ * arrives on that address, so it is built once per address.
87
+ */
88
+ export const sitemapForOrigin = cacheByOrigin(
89
+ (origin: string) => origin,
90
+ (origin: string): MetadataRoute.Sitemap =>
91
+ publicPageIds.flatMap(
92
+ (pageId): MetadataRoute.Sitemap =>
93
+ locales.map((locale) => ({
94
+ url: siteUrl(origin, localizedPagePath(pageId, locale)),
95
+ changeFrequency: pageId === 'home' ? 'weekly' : 'monthly',
96
+ priority: pageId === 'home' ? 1 : 0.7,
97
+ alternates: {
98
+ languages: Object.fromEntries(
99
+ locales.map((availableLocale) => [
100
+ availableLocale,
101
+ siteUrl(origin, localizedPagePath(pageId, availableLocale)),
102
+ ]),
103
+ ),
104
+ },
105
+ })),
106
+ ),
107
+ );
@@ -1,8 +1,8 @@
1
- import { appIdentity } from '@app/config/identity';
1
+ import { appDeclaration } from '@app/config/declaration';
2
2
  import { z } from 'zod';
3
3
  import type { AppLocale } from '@/i18n/locales';
4
4
 
5
- export const SITE_NAME = appIdentity.name;
5
+ export const SITE_NAME = appDeclaration.identity.name;
6
6
  export const StoryIdSchema = z.enum(['components', 'themes', 'blocks']);
7
7
  export type StoryId = z.infer<typeof StoryIdSchema>;
8
8