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,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;
@@ -0,0 +1,189 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "application",
4
+ "identity": {
5
+ "slug": "stitchkit-starter",
6
+ "name": "Stitchkit Starter",
7
+ "version": "0.1.0",
8
+ "description": {
9
+ "en": "Stitchkit Starter is a production application built with Stitchkit.",
10
+ "ru": "Stitchkit Starter — production-приложение на Stitchkit."
11
+ }
12
+ },
13
+ "roles": [
14
+ {
15
+ "name": "api",
16
+ "workingDirectory": "packages/backend",
17
+ "commands": {
18
+ "development": {
19
+ "executable": "bun",
20
+ "args": [
21
+ "--watch",
22
+ "src/index.ts"
23
+ ]
24
+ },
25
+ "production": {
26
+ "executable": "bun",
27
+ "args": [
28
+ "dist/index.js"
29
+ ]
30
+ }
31
+ },
32
+ "listener": {
33
+ "portVariable": "API_PORT",
34
+ "bindVariable": "BIND_HOST",
35
+ "readinessPath": "/health"
36
+ },
37
+ "drainFloorMs": 15000
38
+ },
39
+ {
40
+ "name": "web",
41
+ "workingDirectory": "packages/frontend",
42
+ "commands": {
43
+ "development": {
44
+ "executable": "bun",
45
+ "args": [
46
+ "scripts/serve.ts",
47
+ "development"
48
+ ]
49
+ },
50
+ "production": {
51
+ "executable": "bun",
52
+ "args": [
53
+ "scripts/serve.ts",
54
+ "production"
55
+ ]
56
+ }
57
+ },
58
+ "listener": {
59
+ "portVariable": "WEB_PORT",
60
+ "bindVariable": "BIND_HOST",
61
+ "readinessPath": "/"
62
+ },
63
+ "drainFloorMs": 5000
64
+ }
65
+ ],
66
+ "build": {
67
+ "command": {
68
+ "executable": "bun",
69
+ "args": [
70
+ "run",
71
+ "build"
72
+ ]
73
+ },
74
+ "artifacts": [
75
+ "packages/backend/dist",
76
+ "packages/frontend/.next",
77
+ "packages/db/src/generated"
78
+ ]
79
+ },
80
+ "requires": [
81
+ {
82
+ "name": "postgres",
83
+ "phases": [
84
+ "release",
85
+ "start"
86
+ ]
87
+ }
88
+ ],
89
+ "release": {
90
+ "migrations": {
91
+ "engine": "prisma",
92
+ "root": "packages/db/migrations",
93
+ "lockfile": "packages/db/migrations/migration_lock.toml"
94
+ }
95
+ },
96
+ "env": {
97
+ "variables": [
98
+ {
99
+ "name": "API_PORT",
100
+ "shape": "integer",
101
+ "required": true
102
+ },
103
+ {
104
+ "name": "BIND_HOST",
105
+ "shape": "string",
106
+ "required": false
107
+ },
108
+ {
109
+ "name": "CORS_ORIGIN",
110
+ "shape": "url",
111
+ "required": false
112
+ },
113
+ {
114
+ "name": "DATABASE_URL",
115
+ "shape": "url",
116
+ "required": true
117
+ },
118
+ {
119
+ "name": "GITHUB_API_URL",
120
+ "shape": "url",
121
+ "required": false
122
+ },
123
+ {
124
+ "name": "GITHUB_CACHE_TTL_SECONDS",
125
+ "shape": "integer",
126
+ "required": false
127
+ },
128
+ {
129
+ "name": "GITHUB_REPOSITORY",
130
+ "shape": "string",
131
+ "required": true
132
+ },
133
+ {
134
+ "name": "GITHUB_TOKEN",
135
+ "shape": "string",
136
+ "required": false
137
+ },
138
+ {
139
+ "name": "INTERNAL_API_URL",
140
+ "shape": "url",
141
+ "required": true
142
+ },
143
+ {
144
+ "name": "LOG_FORMAT",
145
+ "shape": "enum",
146
+ "required": false,
147
+ "members": [
148
+ "pretty",
149
+ "json"
150
+ ]
151
+ },
152
+ {
153
+ "name": "NODE_ENV",
154
+ "shape": "enum",
155
+ "required": false,
156
+ "members": [
157
+ "development",
158
+ "test",
159
+ "production"
160
+ ]
161
+ },
162
+ {
163
+ "name": "PUBLIC_API_ORIGIN",
164
+ "shape": "url",
165
+ "required": false
166
+ },
167
+ {
168
+ "name": "PUBLIC_REALTIME_ORIGIN",
169
+ "shape": "url",
170
+ "required": false
171
+ },
172
+ {
173
+ "name": "PUBLIC_WEB_HOSTS",
174
+ "shape": "string",
175
+ "required": false
176
+ },
177
+ {
178
+ "name": "PUBLIC_WEB_ORIGIN",
179
+ "shape": "url",
180
+ "required": false
181
+ },
182
+ {
183
+ "name": "WEB_PORT",
184
+ "shape": "integer",
185
+ "required": true
186
+ }
187
+ ]
188
+ }
189
+ }
@@ -3,10 +3,10 @@ import { createRealtimeClient, defineRealtimeContract } from 'stitchkit';
3
3
  import { z } from 'zod';
4
4
  import { defineSurfaceProbe, runSurfaceConformance } from './surface-conformance';
5
5
  import { loadToolingEnv } from './tooling-env';
6
- import { assertPublicWebSurface } from './web-surface-smoke';
6
+ import { assertArtifactIsPlacementFree, assertPublicWebSurface } from './web-surface-smoke';
7
7
 
8
8
  const toolingEnv = loadToolingEnv();
9
- const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
9
+ const apiOrigin = toolingEnv.SMOKE_API_ORIGIN;
10
10
 
11
11
  async function json(path: string, init?: RequestInit): Promise<unknown> {
12
12
  const response = await fetch(`${apiOrigin}${path}`, init);
@@ -155,7 +155,7 @@ await runSurfaceConformance({
155
155
  });
156
156
  if (
157
157
  corsResponse.headers.get('access-control-allow-origin') !==
158
- new URL(toolingEnv.NEXT_PUBLIC_WEB_URL).origin
158
+ new URL(toolingEnv.SMOKE_WEB_ORIGIN).origin
159
159
  ) {
160
160
  throw new Error('API CORS origin differs from the configured web origin');
161
161
  }
@@ -167,6 +167,26 @@ await runSurfaceConformance({
167
167
  }
168
168
  }
169
169
 
170
- await assertPublicWebSurface(toolingEnv.NEXT_PUBLIC_WEB_URL);
170
+ {
171
+ // THE DEFAULT PATH, end to end: the browser's own origin answers `/api/…`
172
+ // because the web role forwards it. This is what makes the example's client a
173
+ // module constant, so it is checked rather than described.
174
+ const proxied = await fetch(`${toolingEnv.SMOKE_WEB_ORIGIN}/api/repository`);
175
+ if (!proxied.ok) {
176
+ throw new Error(
177
+ `The web role did not forward /api/repository (${proxied.status}) — the same-origin default is broken`,
178
+ );
179
+ }
180
+ const sameOrigin = RepositorySnapshotSchema.parse(await proxied.json());
181
+ const direct = RepositorySnapshotSchema.parse(await json('/api/repository/'));
182
+ if (sameOrigin.fullName !== direct.fullName) {
183
+ throw new Error("the forwarded answer differs from the API role's own");
184
+ }
185
+ }
186
+
187
+ await assertPublicWebSurface(toolingEnv.SMOKE_WEB_ORIGIN);
188
+ await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN);
171
189
 
172
- console.log('Runtime HTTP, OpenAPI, Socket.IO, MCP and public web smoke passed');
190
+ console.log(
191
+ 'Runtime HTTP (same-origin and direct), OpenAPI, Socket.IO, MCP and public web smoke passed',
192
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
@@ -27,13 +27,21 @@
27
27
  "!template/**/src/generated/**",
28
28
  "!template/**/*.log",
29
29
  "!template/**/*.tsbuildinfo",
30
+ "!template/**/coverage/**",
31
+ "!examples/**/.env",
30
32
  "!examples/**/node_modules/**",
31
33
  "!examples/**/.next/**",
32
34
  "!examples/**/dist/**",
35
+ "!examples/**/coverage/**",
36
+ "!examples/**/playwright-report/**",
37
+ "!examples/**/test-results/**",
38
+ "!examples/**/next-env.d.ts",
39
+ "!examples/**/src/generated/**",
33
40
  "!examples/**/*.log",
34
41
  "!examples/**/*.tsbuildinfo",
35
42
  "README.md",
36
43
  "CHANGELOG.md",
44
+ "UPGRADING.md",
37
45
  "LICENSE"
38
46
  ],
39
47
  "publishConfig": {
@@ -12,8 +12,18 @@ framework source repository.
12
12
  owns application policy. Routes remain thin transport boundaries.
13
13
  - `packages/frontend` owns Next.js pages, typed clients, query/mutation hooks and
14
14
  cache reactions.
15
- - `packages/config/src/server.ts` is the only server environment boundary.
16
- `app.config.json` is the only application identity boundary.
15
+ - `packages/config/src/variables.ts` is the only declaration of an environment
16
+ variable. `server.ts` and `frontend/src/env.ts` are projections of it, and
17
+ the declaration's `env.variables` is derived from it — never restated.
18
+ - `project.json` is the only place this project describes itself. It is written
19
+ by machine — the scaffolder stamps the identity, `bun run gen:declaration`
20
+ derives `env.variables` — so the formatter leaves it alone and
21
+ `scripts/declaration.test.ts` is what checks it.
22
+ - Three files are generated from it and must not be hand-edited:
23
+ `ecosystem.config.cjs`, `ecosystem.dev.config.cjs` and the `env.variables`
24
+ block of `project.json`. Run `bun run gen:declaration` after changing a role. It holds
25
+ nothing that differs between two deployments; those are named there by
26
+ variable and supplied by the place.
17
27
 
18
28
  Dependencies point inward: frontend/backend → shared; backend → db/config.
19
29
  Shared never imports an application runtime package.
@@ -29,6 +39,9 @@ Shared never imports an application runtime package.
29
39
  endpoint URLs, query keys or error envelopes by hand.
30
40
  - Use Socket.IO through the shared realtime contract and Stitchkit wrappers.
31
41
  Authentication, authorization and room membership remain application policy.
42
+ - Drive Prisma only through the root `bun run db:*` scripts; the `prisma` CLI
43
+ invoked directly has no datasource URL. Keep `BIND_HOST` at its loopback
44
+ default unless network exposure is an explicit requirement.
32
45
  - Extend runtime smoke with an explicit typed probe for operations whose handler
33
46
  behavior matters. Generic OpenAPI/MCP discovery checks are already derived.
34
47
 
@@ -2,9 +2,21 @@
2
2
 
3
3
  Production-shaped application generated by `create-stitchkit`.
4
4
 
5
- Application identity lives in [`app.config.json`](app.config.json). Change the
6
- slug, display name, version and localized description there; package names,
7
- process names, MCP/OpenAPI identity, UI copy and SEO derive from it.
5
+ This project describes itself in [`project.json`](project.json) — its
6
+ **declaration**: identity, the roles it runs, what it builds, what it needs
7
+ before it starts, and the environment variables a deployment must supply.
8
+
9
+ The declaration is true **with no machine in existence**. A field you cannot
10
+ fill in without knowing where the code will run is a *binding*, not a
11
+ declaration: ports, hosts, addresses, machine paths and supervision policy are
12
+ named there by variable and never by value, and the schema has nowhere to put
13
+ them. Change the slug, display name, version or description there and package
14
+ names, process names, MCP/OpenAPI identity, UI copy and SEO follow.
15
+
16
+ Three files are **generated** from it — `ecosystem.config.cjs`,
17
+ `ecosystem.dev.config.cjs` and the `env.variables` block of the declaration
18
+ itself. Run `bun run gen:declaration` after changing a role; the test suite
19
+ refuses a stale copy.
8
20
 
9
21
  ## Start
10
22
 
@@ -35,6 +47,27 @@ Configure it with `GITHUB_REPOSITORY`; `GITHUB_TOKEN` is optional.
35
47
  Repository visibility then demonstrates the canonical Prisma enum → shared Zod schema
36
48
  → HTTP/UI path without duplicating its allowed values.
37
49
 
50
+ **How the browser reaches the API, and what each shape costs.** By default it
51
+ does not reach it at all: the browser calls its own origin (`/api/…`), the web
52
+ role forwards to the API role (`packages/frontend/src/app/api/[...path]/route.ts`),
53
+ and the client is a plain module constant — no address to wait for, no lazy
54
+ accessor, nothing to order relative to `<Providers>`. A single-origin deployment
55
+ supplies only `INTERNAL_API_URL`.
56
+
57
+ Two things can pull a deployment out of that, and they are separate questions,
58
+ so they have separate variables. **`PUBLIC_REALTIME_ORIGIN`** is the socket: a
59
+ WebSocket upgrade does not survive the route handler that forwards `/api`, so
60
+ two roles on two ports with nothing in front of them must name the socket's
61
+ origin even though their HTTP is already same-origin. Behind one routing layer
62
+ that forwards `/socket.io`, leave it unset. **`PUBLIC_API_ORIGIN`** is HTTP, for
63
+ a frontend that genuinely dials the API role itself — and setting it changes
64
+ nothing on its own: switching is one import in
65
+ `packages/frontend/src/lib/api/queries.ts`, documented in
66
+ `packages/frontend/src/lib/api/cross-origin.ts`. That variant costs a
67
+ server-delivered address, a client built on first use (hence the parentheses),
68
+ an ordering requirement relative to `<Providers>`, and `CORS_ORIGIN` on the API
69
+ role.
70
+
38
71
  The `/en/ui` catalogue is isolated under
39
72
  `packages/frontend/src/app/[locale]/ui` and can be removed as one directory;
40
73
  reusable primitives remain in `components/ui`. It renders the complete
@@ -63,16 +96,28 @@ bun run e2e
63
96
  Provide a production `.env`, then:
64
97
 
65
98
  ```bash
66
- bun run db:deploy
67
99
  bun run build
68
100
  bun run pm2:prod
69
101
  ```
70
102
 
71
- The Next.js frontend and Stitchkit API are separate processes and can also be
72
- deployed independently.
103
+ `pm2:prod` is the release: it checks every artifact `build.artifacts` declares,
104
+ applies the migrations `release.migrations` declares, and only then starts the
105
+ roles from the generated supervision file. There is no separate `db:deploy`
106
+ step to remember — the declaration already says the migrations exist, and one
107
+ place saying it is the point.
108
+
109
+ The roles are separate processes and can also be deployed independently.
73
110
 
74
111
  PostgreSQL is external infrastructure in development and production. The
75
112
  application owns its schema and migrations; the environment owns the database
76
113
  process and supplies its connection through `DATABASE_URL`.
77
114
 
115
+ Both processes bind `BIND_HOST` (default `127.0.0.1`, loopback only). Set
116
+ `BIND_HOST=0.0.0.0` in `.env` to expose the app on every network interface —
117
+ that is a conscious opt-in, typically behind a reverse proxy or firewall.
118
+
119
+ Always drive Prisma through the root `bun run db:*` scripts — invoking the
120
+ `prisma` CLI directly fails because the datasource URL is wired through the
121
+ `@app/db` package environment, not a static config.
122
+
78
123
  `bun run dev` and `bun run pm2:dev` use the same direct PM2 development path.
@@ -1,9 +1,15 @@
1
1
  NODE_ENV=development
2
2
  DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter
3
+ # 0.0.0.0 exposes the app to every network interface — opt in consciously.
4
+ BIND_HOST=127.0.0.1
3
5
  API_PORT=3211
4
6
  WEB_PORT=3210
5
- NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
6
- INTERNAL_API_URL=http://127.0.0.1:3211
7
- NEXT_PUBLIC_WEB_URL=http://127.0.0.1:3210
7
+ SMOKE_API_ORIGIN=http://127.0.0.1:3211
8
+ SMOKE_WEB_ORIGIN=http://127.0.0.1:3210
9
+ # Hosts this deployment answers for, comma-separated. One built artifact can
10
+ # serve several addresses; a forwarded host outside this list is refused rather
11
+ # than believed. Leave unset and set PUBLIC_WEB_ORIGIN instead for a single one.
12
+ PUBLIC_WEB_HOSTS=127.0.0.1:3210
8
13
  LOG_FORMAT=pretty
9
- CORS_ORIGIN=http://127.0.0.1:3210
14
+ # CORS_ORIGIN is only needed for a genuinely cross-origin browser.
15
+ # CORS_ORIGIN=http://127.0.0.1:3210
@@ -9,7 +9,11 @@
9
9
  "!!playwright-report",
10
10
  "!!test-results",
11
11
  "!!packages/frontend/next-env.d.ts",
12
- "!!packages/frontend/public"
12
+ "!!packages/frontend/public",
13
+ "!!project.json",
14
+ "!!ecosystem.config.cjs",
15
+ "!!ecosystem.dev.config.cjs",
16
+ "!!packages/config/src/app-identity.generated.ts"
13
17
  ]
14
18
  },
15
19
  "formatter": {
package/template/bun.lock CHANGED
@@ -138,7 +138,7 @@
138
138
  },
139
139
  },
140
140
  "catalog": {
141
- "stitchkit": "^0.50.0",
141
+ "stitchkit": "^0.60.0",
142
142
  },
143
143
  "packages": {
144
144
  "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="],
@@ -1117,7 +1117,7 @@
1117
1117
 
1118
1118
  "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
1119
1119
 
1120
- "stitchkit": ["stitchkit@0.50.0", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-lMadqgQ9aryAhULyA0biVjx9bn86HOUhcUj809TyHy2MuUZSnL9qI4fiByQbVAqlxW6i1a1RLAeguEJeu6jwWg=="],
1120
+ "stitchkit": ["stitchkit@0.60.0", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.0.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "grammy": "^1.45.1", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "grammy", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-bP4HUHYG4/0WfF1oFGsRddvu+jHpCNyvL/lPNhWHofb6QrcL0KY1nna72lqg9g59xZKhNMLd8mzRCJkLKvghFg=="],
1121
1121
 
1122
1122
  "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
1123
1123
 
@@ -1,4 +1,4 @@
1
- import { appIdentity } from '@app/config/identity';
1
+ import { appDeclaration } from '@app/config/declaration';
2
2
  import { systemContract } from '@app/shared';
3
3
  import AxeBuilder from '@axe-core/playwright';
4
4
  import { expect, test } from '@playwright/test';
@@ -21,7 +21,7 @@ test('calls the live backend through the typed contract client', async () => {
21
21
  const client = createClient(
22
22
  systemContract,
23
23
  createHttpClient({
24
- baseUrl: `${toolingEnv.NEXT_PUBLIC_API_URL}/api`,
24
+ baseUrl: `${toolingEnv.SMOKE_API_ORIGIN}/api`,
25
25
  credentials: 'omit',
26
26
  }),
27
27
  );
@@ -31,7 +31,7 @@ test('calls the live backend through the typed contract client', async () => {
31
31
 
32
32
  test('publishes complete page metadata', async ({ page }) => {
33
33
  await page.goto('/en/ui/themes');
34
- await expect(page).toHaveTitle(`Theme system · ${appIdentity.name}`);
34
+ await expect(page).toHaveTitle(`Theme system · ${appDeclaration.identity.name}`);
35
35
  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
36
36
  'href',
37
37
  /\/en\/ui\/themes$/,
@@ -42,9 +42,7 @@ test('publishes complete page metadata', async ({ page }) => {
42
42
  );
43
43
 
44
44
  const imageUrl = await page.locator('meta[property="og:image"]').getAttribute('content');
45
- expect(imageUrl).toBe(
46
- new URL('/api/og/en/themes', toolingEnv.NEXT_PUBLIC_WEB_URL).toString(),
47
- );
45
+ expect(imageUrl).toBe(new URL('/api/og/en/themes', toolingEnv.SMOKE_WEB_ORIGIN).toString());
48
46
  });
49
47
 
50
48
  test('switches catalogue sections and component tabs', async ({ page }) => {
@@ -217,7 +215,7 @@ test('provides a server-first synchronized theme system', async ({
217
215
  await page.getByRole('button', { name: 'System', exact: true }).click();
218
216
  await expect(page.getByTestId('theme-state-selected')).toContainText('system');
219
217
  const themeCookie = (await context.cookies()).find(
220
- (cookie) => cookie.name === `${appIdentity.slug}-theme`,
218
+ (cookie) => cookie.name === `${appDeclaration.identity.slug}-theme`,
221
219
  );
222
220
  expect(themeCookie?.value).toBe('system');
223
221
  await page.reload();