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.
- package/CHANGELOG.md +347 -0
- package/README.md +3 -1
- package/UPGRADING.md +342 -0
- package/dist/cli.js +238 -42
- package/examples/repository/_env.example.append +21 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- package/examples/repository/packages/backend/src/surface.ts +1 -1
- package/examples/repository/packages/config/src/features.ts +17 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
- package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
- package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
- package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
- package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
- package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
- package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
- package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
- package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
- package/examples/repository/project.json +189 -0
- package/examples/repository/scripts/runtime-smoke.ts +38 -6
- package/package.json +12 -2
- package/template/AGENTS.md +23 -3
- package/template/README.md +83 -8
- package/template/_env.example +16 -4
- package/template/_gitignore +1 -0
- package/template/biome.json +6 -2
- package/template/bun.lock +115 -98
- package/template/e2e/starter.spec.ts +5 -7
- package/template/ecosystem.config.cjs +42 -19
- package/template/ecosystem.dev.config.cjs +41 -21
- package/template/package.json +12 -10
- package/template/packages/backend/package.json +2 -2
- package/template/packages/backend/src/cleanup.ts +121 -0
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +33 -8
- package/template/packages/backend/src/surface.ts +6 -1
- package/template/packages/backend/src/transport/errors.ts +4 -2
- package/template/packages/config/package.json +6 -2
- package/template/packages/config/src/app-identity.generated.ts +20 -0
- package/template/packages/config/src/declaration.ts +30 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/shutdown.ts +20 -0
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/db/package.json +2 -2
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +13 -13
- package/template/packages/frontend/scripts/serve.ts +70 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
- package/template/packages/frontend/src/app/robots.ts +4 -2
- package/template/packages/frontend/src/app/sitemap.ts +7 -19
- package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
- package/template/packages/frontend/src/env.ts +27 -8
- package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
- package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
- package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
- package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
- package/template/packages/frontend/src/theme/config.ts +1 -1
- package/template/packages/frontend/tsconfig.json +10 -3
- package/template/packages/shared/package.json +1 -1
- package/template/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- package/template/scripts/acceptance-database.test.ts +73 -0
- package/template/scripts/acceptance-database.ts +92 -0
- package/template/scripts/acceptance-local.ts +144 -0
- package/template/scripts/build-inputs.test.ts +69 -0
- package/template/scripts/build-inputs.ts +58 -0
- package/template/scripts/build-stamp.test.ts +151 -0
- package/template/scripts/build-stamp.ts +169 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/client-boundary.test.ts +117 -0
- package/template/scripts/client-boundary.ts +148 -0
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +271 -0
- package/template/scripts/deployment-preflight.ts +41 -0
- package/template/scripts/dev.ts +43 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +9 -3
- package/template/scripts/readiness.ts +92 -0
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +112 -0
- package/template/scripts/release.ts +38 -0
- package/template/scripts/runtime-smoke.test.ts +178 -0
- package/template/scripts/runtime-smoke.ts +21 -5
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/shutdown-budget.fixture.ts +29 -0
- package/template/scripts/shutdown-budget.test.ts +164 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/surface-conformance.ts +8 -1
- package/template/scripts/tooling-env.ts +35 -3
- package/template/scripts/web-surface-smoke.ts +183 -2
- package/template/app.config.json +0 -9
- 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 {
|
|
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':
|
|
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 {
|
|
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={
|
|
38
|
-
applicationDescription={
|
|
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
|
-
|
|
6
|
-
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|
|
6
|
-
import {
|
|
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
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
9
|
-
repositoryBridge
|
|
8
|
+
const socket = repositorySocket();
|
|
9
|
+
const bridge = repositoryBridge();
|
|
10
|
+
socket.connect();
|
|
11
|
+
bridge.connect();
|
|
10
12
|
return () => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
bridge.disconnect();
|
|
14
|
+
socket.disconnect();
|
|
13
15
|
};
|
|
14
16
|
}, []);
|
|
15
17
|
return children;
|