create-stitchkit 0.3.3 → 0.4.1

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 (99) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +342 -0
  4. package/dist/cli.js +238 -42
  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 +38 -6
  23. package/package.json +12 -2
  24. package/template/AGENTS.md +23 -3
  25. package/template/README.md +83 -8
  26. package/template/_env.example +16 -4
  27. package/template/_gitignore +1 -0
  28. package/template/biome.json +6 -2
  29. package/template/bun.lock +115 -98
  30. package/template/e2e/starter.spec.ts +5 -7
  31. package/template/ecosystem.config.cjs +42 -19
  32. package/template/ecosystem.dev.config.cjs +41 -21
  33. package/template/package.json +12 -10
  34. package/template/packages/backend/package.json +2 -2
  35. package/template/packages/backend/src/cleanup.ts +121 -0
  36. package/template/packages/backend/src/cli.ts +6 -2
  37. package/template/packages/backend/src/index.ts +33 -8
  38. package/template/packages/backend/src/surface.ts +6 -1
  39. package/template/packages/backend/src/transport/errors.ts +4 -2
  40. package/template/packages/config/package.json +6 -2
  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/server.ts +8 -17
  44. package/template/packages/config/src/shutdown.ts +20 -0
  45. package/template/packages/config/src/variables.ts +89 -0
  46. package/template/packages/db/package.json +2 -2
  47. package/template/packages/frontend/next.config.ts +3 -2
  48. package/template/packages/frontend/package.json +13 -13
  49. package/template/packages/frontend/scripts/serve.ts +70 -0
  50. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  51. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  52. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  53. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  54. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  55. package/template/packages/frontend/src/app/robots.ts +4 -2
  56. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  57. package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
  58. package/template/packages/frontend/src/env.ts +27 -8
  59. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  60. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  61. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  62. package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
  63. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  64. package/template/packages/frontend/src/theme/config.ts +1 -1
  65. package/template/packages/frontend/tsconfig.json +10 -3
  66. package/template/packages/shared/package.json +1 -1
  67. package/template/playwright.config.ts +1 -1
  68. package/template/project.json +169 -0
  69. package/template/scripts/acceptance-database.test.ts +73 -0
  70. package/template/scripts/acceptance-database.ts +92 -0
  71. package/template/scripts/acceptance-local.ts +144 -0
  72. package/template/scripts/build-inputs.test.ts +69 -0
  73. package/template/scripts/build-inputs.ts +58 -0
  74. package/template/scripts/build-stamp.test.ts +151 -0
  75. package/template/scripts/build-stamp.ts +169 -0
  76. package/template/scripts/check-authored.ts +18 -2
  77. package/template/scripts/client-boundary.test.ts +117 -0
  78. package/template/scripts/client-boundary.ts +148 -0
  79. package/template/scripts/declaration.test.ts +206 -0
  80. package/template/scripts/declaration.ts +271 -0
  81. package/template/scripts/deployment-preflight.ts +41 -0
  82. package/template/scripts/dev.ts +43 -20
  83. package/template/scripts/local-env.test.ts +2 -2
  84. package/template/scripts/local-env.ts +9 -3
  85. package/template/scripts/readiness.ts +92 -0
  86. package/template/scripts/release-steps.test.ts +87 -0
  87. package/template/scripts/release-steps.ts +112 -0
  88. package/template/scripts/release.ts +38 -0
  89. package/template/scripts/runtime-smoke.test.ts +178 -0
  90. package/template/scripts/runtime-smoke.ts +21 -5
  91. package/template/scripts/serve-mode.test.ts +36 -0
  92. package/template/scripts/shutdown-budget.fixture.ts +29 -0
  93. package/template/scripts/shutdown-budget.test.ts +164 -0
  94. package/template/scripts/supervision-signal.test.ts +94 -0
  95. package/template/scripts/surface-conformance.ts +8 -1
  96. package/template/scripts/tooling-env.ts +35 -3
  97. package/template/scripts/web-surface-smoke.ts +183 -2
  98. package/template/app.config.json +0 -9
  99. package/template/packages/config/src/identity.ts +0 -18
@@ -1,3 +1,24 @@
1
+ # The web role reaches the API role internally, and forwards the browser's
2
+ # same-origin `/api/…` calls to it. This one is required.
3
+ INTERNAL_API_URL=http://127.0.0.1:3211
4
+
5
+ # The realtime socket, and ONLY it. A WebSocket upgrade does not survive the
6
+ # route handler that forwards `/api`, so two roles on two loopback ports must
7
+ # name the socket's origin even though their HTTP is already same-origin.
8
+ # Behind one routing layer that forwards `/socket.io`, leave this unset.
9
+ PUBLIC_REALTIME_ORIGIN=http://127.0.0.1:3211
10
+
11
+ # The browser origin the API role admits — for HTTP and for the realtime
12
+ # handshake alike. Needed here because the socket above is cross-origin.
13
+ CORS_ORIGIN=http://127.0.0.1:3210
14
+
15
+ # THE CROSS-ORIGIN HTTP VARIANT — unset, and unnecessary for this example.
16
+ # Set it only for a frontend that dials the API role itself instead of calling
17
+ # its own `/api`: separate hostnames with nothing in front of them. Setting it
18
+ # changes nothing on its own; switching is one import in
19
+ # packages/frontend/src/lib/api/queries.ts. See lib/api/cross-origin.ts.
20
+ # PUBLIC_API_ORIGIN=https://api.example
21
+
1
22
  GITHUB_REPOSITORY=max-listov/stitchkit
2
23
  GITHUB_CACHE_TTL_SECONDS=900
3
24
  # Optional. Defaults to the public GitHub API and supports GitHub Enterprise.
@@ -1,5 +1,5 @@
1
1
  import { env } from '@app/config';
2
- import { appIdentity } from '@app/config/identity';
2
+ import { appDeclaration } from '@app/config/declaration';
3
3
  import { RepositoryVisibility } from '@app/db';
4
4
  import type { RepositorySnapshot } from '@app/shared';
5
5
  import { z } from 'zod';
@@ -81,7 +81,7 @@ const snapshotStore: RepositorySnapshotStore = {
81
81
  function githubHeaders(): Headers {
82
82
  const headers = new Headers({
83
83
  Accept: 'application/vnd.github+json',
84
- 'User-Agent': appIdentity.slug,
84
+ 'User-Agent': appDeclaration.identity.slug,
85
85
  'X-GitHub-Api-Version': '2026-03-10',
86
86
  });
87
87
  if (env.GITHUB_TOKEN) headers.set('Authorization', `Bearer ${env.GITHUB_TOKEN}`);
@@ -6,7 +6,7 @@ import { createSystemService } from './transport/system-service';
6
6
 
7
7
  export async function createSurface() {
8
8
  const socket = await createSocketIOServer({
9
- cors: { origin: env.CORS_ORIGIN },
9
+ cors: { origin: env.CORS_ORIGIN ?? [] },
10
10
  });
11
11
  const realtime = bindRealtimeServer(repositoryRealtimeContract, socket);
12
12
  const repositoryService = createRepositoryService((snapshot) =>
@@ -1,6 +1,23 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  export const featureServerSchema = {
4
+ // The web role dereferences this on every proxied request and on every server
5
+ // render, so it TIGHTENS from optional to required. Declared optional, a
6
+ // deployment reading project.json would supply nothing and every request would
7
+ // throw — the declaration would be derived and still wrong, which is the one
8
+ // failure the derivation exists to prevent.
9
+ INTERNAL_API_URL: z.url(),
10
+ // Deliberately NOT tightened. The browser talks to its own origin by default
11
+ // (`frontend/src/lib/api/client.ts`), so a single-origin deployment supplies
12
+ // none of these. They are the price of a browser that leaves that origin, and
13
+ // only a deployment that has that case should be made to pay it.
14
+ // PUBLIC_REALTIME_ORIGIN — where the socket connects, when no routing layer
15
+ // forwards `/socket.io`. A WebSocket upgrade cannot be proxied by the
16
+ // route handler that forwards `/api`, so this one is separate on purpose.
17
+ // PUBLIC_API_ORIGIN — where the browser dials the API role over HTTP, for
18
+ // the cross-origin variant. Inert until the import in `queries.ts` moves.
19
+ // CORS_ORIGIN — the API role's allow-list, needed once the browser is
20
+ // genuinely cross-origin for either of the two.
4
21
  GITHUB_API_URL: z.url().default('https://api.github.com'),
5
22
  GITHUB_REPOSITORY: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
6
23
  GITHUB_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(900),
@@ -1,10 +1,10 @@
1
- import { appIdentity } from '@app/config/identity';
1
+ import { appDeclaration } from '@app/config/declaration';
2
2
  import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
3
3
  import type { Metadata } from 'next';
4
4
  import { getTranslations } from 'next-intl/server';
5
5
  import { LocaleSchema } from '@/i18n/locales';
6
- import { createServerRepositoryApi } from '@/lib/api/client';
7
6
  import { useRepository } from '@/lib/api/queries';
7
+ import { createServerRepositoryApi } from '@/lib/api/server-client';
8
8
  import { getQueryClient } from '@/lib/query-client';
9
9
  import { createPageMetadata } from '@/lib/seo/metadata';
10
10
  import { StarterPage } from './starter-page';
@@ -34,8 +34,8 @@ export default async function Page({ params }: { params: Promise<{ locale: strin
34
34
  return (
35
35
  <HydrationBoundary state={dehydrate(queryClient)}>
36
36
  <StarterPage
37
- applicationName={appIdentity.name}
38
- applicationDescription={appIdentity.description[appLocale]}
37
+ applicationName={appDeclaration.identity.name}
38
+ applicationDescription={appDeclaration.identity.description[appLocale]}
39
39
  heroTitle={t('heroTitle')}
40
40
  catalogueLabel={t('ui')}
41
41
  locale={appLocale}
@@ -57,7 +57,7 @@ const architecture = [
57
57
  },
58
58
  ];
59
59
 
60
- export function StarterPage({
60
+ export async function StarterPage({
61
61
  applicationName,
62
62
  applicationDescription,
63
63
  heroTitle,
@@ -71,7 +71,7 @@ export function StarterPage({
71
71
  name: SITE_NAME,
72
72
  applicationCategory: 'DeveloperApplication',
73
73
  operatingSystem: 'Web',
74
- url: absoluteSiteUrl(`/${locale}`),
74
+ url: await absoluteSiteUrl(`/${locale}`),
75
75
  description: homeSeo.description,
76
76
  };
77
77
 
@@ -0,0 +1,66 @@
1
+ import { internalApiUrl } from '@/lib/api/place';
2
+
3
+ /**
4
+ * The default shape: the browser talks to its OWN origin, and the web role
5
+ * forwards to the API role.
6
+ *
7
+ * This is what makes the example's client a plain module constant. A browser
8
+ * that dials the API role directly needs that role's public address, which is a
9
+ * property of the place — so the address has to arrive from the server at
10
+ * runtime, the client cannot exist until it does, and every call site pays for
11
+ * that with a lazy accessor. A same-origin request needs no address at all:
12
+ * `/api/…` is complete before any machine exists.
13
+ *
14
+ * What it costs: one extra hop through the web role, and no WebSocket — a
15
+ * route handler cannot proxy an upgrade. The realtime socket is therefore the
16
+ * one place this example still needs the API role's address, or a routing layer
17
+ * in front of both roles that serves them on one origin (see
18
+ * `lib/api/cross-origin.ts`).
19
+ */
20
+ export const dynamic = 'force-dynamic';
21
+
22
+ const FORWARDED_REQUEST_HEADERS = [
23
+ 'accept',
24
+ 'accept-language',
25
+ 'content-type',
26
+ 'authorization',
27
+ ];
28
+ const FORWARDED_RESPONSE_HEADERS = ['content-type', 'cache-control', 'etag'];
29
+
30
+ async function forward(request: Request): Promise<Response> {
31
+ const incoming = new URL(request.url);
32
+ // Rebuilt from the incoming pathname rather than from the matched segments,
33
+ // so an encoded segment reaches the API role exactly as it arrived.
34
+ const target = new URL(`${incoming.pathname}${incoming.search}`, internalApiUrl());
35
+
36
+ const headers = new Headers();
37
+ for (const name of FORWARDED_REQUEST_HEADERS) {
38
+ const value = request.headers.get(name);
39
+ if (value !== null) headers.set(name, value);
40
+ }
41
+
42
+ // Buffered rather than streamed: forwarding a stream needs the non-standard
43
+ // `duplex` init that this project's types do not carry, and every payload
44
+ // this contract accepts is a small JSON document.
45
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
46
+ const response = await fetch(target, {
47
+ method: request.method,
48
+ headers,
49
+ body: hasBody ? await request.arrayBuffer() : undefined,
50
+ // A redirect is the API role's answer, not something to resolve here.
51
+ redirect: 'manual',
52
+ });
53
+
54
+ const responseHeaders = new Headers();
55
+ for (const name of FORWARDED_RESPONSE_HEADERS) {
56
+ const value = response.headers.get(name);
57
+ if (value !== null) responseHeaders.set(name, value);
58
+ }
59
+ return new Response(response.body, { status: response.status, headers: responseHeaders });
60
+ }
61
+
62
+ export const GET = forward;
63
+ export const POST = forward;
64
+ export const PUT = forward;
65
+ export const PATCH = forward;
66
+ export const DELETE = forward;
@@ -1,20 +1,25 @@
1
1
  import { repositoryContract } from '@app/shared';
2
2
  import { createClient, createHttpClient, createUrlBuilder } from 'stitchkit';
3
- import { env } from '@/env';
4
3
 
5
- function apiOrigin(): string {
6
- return typeof window === 'undefined' ? env.INTERNAL_API_URL : env.NEXT_PUBLIC_API_URL;
7
- }
4
+ /**
5
+ * The browser's API client — a module CONSTANT, because it needs no address.
6
+ *
7
+ * `/api` is complete when no machine exists: it names a path on whatever origin
8
+ * served the page. That is the whole reason this file has no factory, no lazy
9
+ * accessor and no parentheses at its call sites — see `queries.ts`. The web
10
+ * role forwards these requests to the API role (`app/api/[...path]/route.ts`).
11
+ *
12
+ * A browser that genuinely must reach a DIFFERENT origin cannot do this, and
13
+ * pays a real price for it. That variant lives in `cross-origin.ts`, named and
14
+ * explained, rather than in the default path everybody copies.
15
+ */
16
+ const browserHttp = createHttpClient({ baseUrl: '/api', credentials: 'same-origin' });
17
+
18
+ export const repositoryApi = createClient(repositoryContract, browserHttp);
19
+ export const repositoryUrls = createUrlBuilder(repositoryContract, browserHttp);
8
20
 
21
+ /** The server-side client needs an address, and reads it from the place. */
9
22
  export function createRepositoryApi(baseUrl: string) {
10
23
  const http = createHttpClient({ baseUrl: `${baseUrl}/api`, credentials: 'omit' });
11
24
  return createClient(repositoryContract, http);
12
25
  }
13
-
14
- const http = createHttpClient({ baseUrl: `${apiOrigin()}/api`, credentials: 'omit' });
15
- export const repositoryApi = createRepositoryApi(apiOrigin());
16
- export const repositoryUrls = createUrlBuilder(repositoryContract, http);
17
-
18
- export function createServerRepositoryApi() {
19
- return createRepositoryApi(env.INTERNAL_API_URL);
20
- }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * THE VARIANT: a browser that must reach the API role at a different origin.
3
+ *
4
+ * The default in this example is same-origin (`lib/api/client.ts`): the browser
5
+ * calls `/api/…` and the web role forwards. Two things can pull a deployment
6
+ * out of that, and they are separate, so they have separate variables:
7
+ *
8
+ * - **`PUBLIC_REALTIME_ORIGIN`** — the socket. A WebSocket upgrade does not
9
+ * survive a proxying route handler, so a deployment running the two roles on
10
+ * two ports with no routing layer in front of them must name the socket's
11
+ * origin even though its HTTP is already same-origin. This one is read by the
12
+ * default path, and unset means the page's own origin.
13
+ * - **`PUBLIC_API_ORIGIN`** — HTTP. Only for a frontend that genuinely dials
14
+ * the API role itself: separate hostnames with nothing in front of them.
15
+ * Setting it changes nothing on its own; switching is an import, below.
16
+ *
17
+ * Switching HTTP to this variant is one line in `queries.ts`:
18
+ *
19
+ * ```ts
20
+ * // before
21
+ * import { repositoryApi } from './client'
22
+ * fetcher: () => repositoryApi.read()
23
+ * // after
24
+ * import { repositoryApiCrossOrigin } from './cross-origin'
25
+ * fetcher: () => repositoryApiCrossOrigin().read()
26
+ * ```
27
+ *
28
+ * What that costs, in order:
29
+ *
30
+ * 1. **The address is a property of the place**, so it can never be compiled
31
+ * in. The server reads it per request and hands it to the browser
32
+ * (`providers/index.tsx` → `providers/client-providers.tsx`).
33
+ * 2. **Nothing can be built at import time.** The client has to be constructed
34
+ * on FIRST USE — hence the parentheses, which the default path does not pay.
35
+ * 3. **Order matters.** Anything reading the origin must render inside
36
+ * `<Providers>`; outside it, the value is not there yet.
37
+ * 4. **The API role needs `CORS_ORIGIN`**, for HTTP and for the realtime
38
+ * handshake alike.
39
+ *
40
+ * None of that is wrong — it is the correct shape for the case. It is simply
41
+ * not the case most projects have, which is why it is not the body of the
42
+ * example.
43
+ */
44
+ import { createRepositoryApi } from './client';
45
+
46
+ export interface PublicOrigins {
47
+ /** Where the browser dials the API role over HTTP, if not this origin. */
48
+ readonly api: string | undefined;
49
+ /** Where the browser opens the realtime socket, if not this origin. */
50
+ readonly realtime: string | undefined;
51
+ }
52
+
53
+ let origins: PublicOrigins = { api: undefined, realtime: undefined };
54
+
55
+ /** Supplied by the server, once, above every consumer. */
56
+ export function setPublicOrigins(supplied: PublicOrigins): void {
57
+ origins = supplied;
58
+ }
59
+
60
+ /**
61
+ * The socket's origin, or `undefined` when this deployment serves both roles on
62
+ * one origin.
63
+ *
64
+ * `undefined` is an answer, not a missing value: the socket then connects to
65
+ * the page's own origin, where a routing layer forwards `/socket.io`.
66
+ */
67
+ export function optionalRealtimeOrigin(): string | undefined {
68
+ return origins.realtime;
69
+ }
70
+
71
+ export function requirePublicApiOrigin(): string {
72
+ const { api } = origins;
73
+ if (!api) {
74
+ throw new Error(
75
+ 'The public API origin has not been provided — set PUBLIC_API_ORIGIN and render this inside <Providers>, which supplies it from the server.',
76
+ );
77
+ }
78
+ return api;
79
+ }
80
+
81
+ let crossOriginApi: ReturnType<typeof createRepositoryApi> | undefined;
82
+
83
+ /** Built on FIRST USE: the origin arrives at runtime, not at import. */
84
+ export function repositoryApiCrossOrigin(): ReturnType<typeof createRepositoryApi> {
85
+ crossOriginApi ??= createRepositoryApi(requirePublicApiOrigin());
86
+ return crossOriginApi;
87
+ }
@@ -0,0 +1,26 @@
1
+ import { env } from '@/env';
2
+
3
+ /**
4
+ * The addresses this example reads from the place.
5
+ *
6
+ * `INTERNAL_API_URL` is REQUIRED — see `packages/config/src/features.ts` — and
7
+ * the declaration a deployment reads says so, because the web role dereferences
8
+ * it on every proxied request and on every server render.
9
+ *
10
+ * The two public ones are optional and independent, because the questions they
11
+ * answer are independent: HTTP can be forwarded by the web role, and a
12
+ * WebSocket upgrade cannot. A deployment behind one routing layer sets neither.
13
+ */
14
+ export function internalApiUrl(): string {
15
+ return env.INTERNAL_API_URL;
16
+ }
17
+
18
+ /** Where the browser dials the API role over HTTP — the cross-origin variant. */
19
+ export function publicApiOrigin(): string | undefined {
20
+ return env.PUBLIC_API_ORIGIN;
21
+ }
22
+
23
+ /** Where the browser opens the realtime socket, when it is not this origin. */
24
+ export function publicRealtimeOrigin(): string | undefined {
25
+ return env.PUBLIC_REALTIME_ORIGIN;
26
+ }
@@ -1,6 +1,9 @@
1
1
  import { createMutation, createQuery } from 'react-query-kit';
2
2
  import { repositoryApi } from './client';
3
3
 
4
+ // No parentheses: the client is a module constant, because a same-origin path
5
+ // needs no address. The cross-origin variant pays for its address with a lazy
6
+ // accessor — see `cross-origin.ts`.
4
7
  export const useRepository = createQuery({
5
8
  queryKey: ['repository'],
6
9
  fetcher: () => repositoryApi.read(),
@@ -0,0 +1,11 @@
1
+ import { createRepositoryApi } from './client';
2
+ import { internalApiUrl } from './place';
3
+
4
+ /**
5
+ * Server-side API client. Separate module on purpose: it reads the SERVER
6
+ * environment, and importing that from a module the browser bundle also pulls
7
+ * in would drag server configuration into the client graph.
8
+ */
9
+ export function createServerRepositoryApi() {
10
+ return createRepositoryApi(internalApiUrl());
11
+ }
@@ -1,20 +1,52 @@
1
1
  import { repositoryRealtimeContract } from '@app/shared';
2
2
  import { createRealtimeClient } from 'stitchkit';
3
3
  import { createCacheBridge } from 'stitchkit/react';
4
- import { env } from '@/env';
4
+ import { optionalRealtimeOrigin } from '@/lib/api/cross-origin';
5
5
  import { useRepository } from '@/lib/api/queries';
6
6
  import { getQueryClient } from '@/lib/query-client';
7
7
 
8
- export const repositorySocket = createRealtimeClient(repositoryRealtimeContract, {
9
- url: env.NEXT_PUBLIC_API_URL,
10
- });
8
+ /**
9
+ * The one thing a route handler cannot proxy.
10
+ *
11
+ * A WebSocket upgrade does not survive a Next route handler, which is why this
12
+ * has a variable of its own — `PUBLIC_REALTIME_ORIGIN` — rather than sharing
13
+ * the HTTP one. A deployment can be same-origin for HTTP and still have to name
14
+ * the socket's address: two roles on two ports with no routing layer in front
15
+ * of them is exactly that. Unset picks the page's own origin, where a routing
16
+ * layer forwards `/socket.io`.
17
+ */
18
+ function buildSocket() {
19
+ // `window.location.origin` is not a value baked in anywhere: it is what this
20
+ // browser actually dialled, read at connect time, exactly like the server
21
+ // reads the request's origin. This function only ever runs in an effect.
22
+ const url = optionalRealtimeOrigin() ?? window.location.origin;
23
+ return createRealtimeClient(repositoryRealtimeContract, { url });
24
+ }
11
25
 
12
- export const repositoryBridge = createCacheBridge({
13
- socket: repositorySocket,
14
- queryClient: getQueryClient,
15
- handlers: {
16
- 'repository:refreshed': (snapshot, { queryClient }) => {
17
- queryClient.setQueryData(useRepository.getKey(), snapshot);
26
+ function buildBridge() {
27
+ return createCacheBridge({
28
+ socket: repositorySocket(),
29
+ queryClient: getQueryClient,
30
+ handlers: {
31
+ 'repository:refreshed': (snapshot, { queryClient }) => {
32
+ queryClient.setQueryData(useRepository.getKey(), snapshot);
33
+ },
18
34
  },
19
- },
20
- });
35
+ });
36
+ }
37
+
38
+ // Built on FIRST USE: the socket needs whatever origin the server supplied,
39
+ // and that arrives at runtime rather than from the build. See
40
+ // `lib/api/cross-origin.ts`.
41
+ let socket: ReturnType<typeof buildSocket> | undefined;
42
+ let bridge: ReturnType<typeof buildBridge> | undefined;
43
+
44
+ export function repositorySocket(): ReturnType<typeof buildSocket> {
45
+ socket ??= buildSocket();
46
+ return socket;
47
+ }
48
+
49
+ export function repositoryBridge(): ReturnType<typeof buildBridge> {
50
+ bridge ??= buildBridge();
51
+ return bridge;
52
+ }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { TooltipProvider } from '@radix-ui/react-tooltip';
4
+ import type { ReactNode } from 'react';
5
+ import { Toaster } from '@/components/ui/toaster';
6
+ import { type PublicOrigins, setPublicOrigins } from '@/lib/api/cross-origin';
7
+ import { ReactQueryProvider } from './react-query';
8
+ import { RealtimeProvider } from './realtime';
9
+
10
+ export function ClientProviders({
11
+ origins,
12
+ children,
13
+ }: {
14
+ origins: PublicOrigins;
15
+ children: ReactNode;
16
+ }) {
17
+ // Recorded before any child renders, so the realtime socket can be built
18
+ // lazily on first use. Idempotent: the same values every render. `undefined`
19
+ // means this deployment serves both roles on one origin.
20
+ setPublicOrigins(origins);
21
+
22
+ return (
23
+ <ReactQueryProvider>
24
+ <RealtimeProvider>
25
+ <TooltipProvider delayDuration={250}>{children}</TooltipProvider>
26
+ </RealtimeProvider>
27
+ <Toaster />
28
+ </ReactQueryProvider>
29
+ );
30
+ }
@@ -1,18 +1,23 @@
1
- 'use client';
2
-
3
- import { TooltipProvider } from '@radix-ui/react-tooltip';
4
1
  import type { ReactNode } from 'react';
5
- import { Toaster } from '@/components/ui/toaster';
6
- import { ReactQueryProvider } from './react-query';
7
- import { RealtimeProvider } from './realtime';
2
+ import { publicApiOrigin, publicRealtimeOrigin } from '@/lib/api/place';
3
+ import { ClientProviders } from './client-providers';
8
4
 
5
+ /**
6
+ * A SERVER component on purpose.
7
+ *
8
+ * The browser's data calls are same-origin and need no address at all. What
9
+ * still can is the realtime socket, which no route handler can proxy: when this
10
+ * deployment runs the two roles on two origins, the socket's address is read
11
+ * HERE, per request, and handed down. Reading it at request time rather than at
12
+ * build time is what lets one artifact serve every address it is routed to; a
13
+ * `NEXT_PUBLIC_` variable would have frozen one into the bundle.
14
+ *
15
+ * `undefined` for both is the normal answer for a single-origin deployment.
16
+ */
9
17
  export function Providers({ children }: { children: ReactNode }) {
10
18
  return (
11
- <ReactQueryProvider>
12
- <RealtimeProvider>
13
- <TooltipProvider delayDuration={250}>{children}</TooltipProvider>
14
- </RealtimeProvider>
15
- <Toaster />
16
- </ReactQueryProvider>
19
+ <ClientProviders origins={{ api: publicApiOrigin(), realtime: publicRealtimeOrigin() }}>
20
+ {children}
21
+ </ClientProviders>
17
22
  );
18
23
  }
@@ -5,11 +5,13 @@ import { repositoryBridge, repositorySocket } from '@/lib/realtime/repository';
5
5
 
6
6
  export function RealtimeProvider({ children }: { children: ReactNode }) {
7
7
  useEffect(() => {
8
- repositorySocket.connect();
9
- repositoryBridge.connect();
8
+ const socket = repositorySocket();
9
+ const bridge = repositoryBridge();
10
+ socket.connect();
11
+ bridge.connect();
10
12
  return () => {
11
- repositoryBridge.disconnect();
12
- repositorySocket.disconnect();
13
+ bridge.disconnect();
14
+ socket.disconnect();
13
15
  };
14
16
  }, []);
15
17
  return children;