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,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();
@@ -1,34 +1,57 @@
1
+ // GENERATED FILE — do not edit.
2
+ //
3
+ // Rendered from `project.json` by `scripts/declaration.ts`; run
4
+ // `bun run gen:declaration` after changing a role. Roles, commands and the
5
+ // drain floor come from the declaration because they are true of the code;
6
+ // restart policy and the kill timeout are this machine's, and the generator
7
+ // refuses a timeout shorter than any role's full shutdown budget.
1
8
  const path = require('node:path');
2
9
  const { config } = require('dotenv');
3
- const identity = require('./app.config.json');
10
+ const declaration = require('./project.json');
4
11
 
5
- config({ path: path.join(__dirname, '.env'), quiet: true, override: true });
12
+ // NOT `override`: an environment a deployment injected into this process must
13
+ // win over a file in the repository. The file fills gaps; it does not overrule
14
+ // the place.
15
+ config({ path: path.join(__dirname, '.env'), quiet: true });
6
16
 
7
17
  module.exports = {
8
18
  apps: [
9
19
  {
10
- name: `${identity.slug}-backend`,
11
- cwd: path.join(__dirname, 'packages/backend'),
12
- script: 'dist/index.js',
13
- interpreter: 'bun',
20
+ name: `${declaration.identity.slug}-api`,
21
+ // The role's OWN process, in its OWN directory — no launcher in between.
22
+ // Measured: a launcher makes the role see the stop signal twice (once from
23
+ // the supervisor, once forwarded), the second press forces the shutdown,
24
+ // and a declared drain of seconds collapses to milliseconds. A workspace
25
+ // filter is worse: the signal never arrives at all.
26
+ cwd: path.join(__dirname, "packages/backend"),
27
+ script: "bun",
28
+ // No argv invented here: the deployment injects `API_PORT` and the command
29
+ // reads it. Serialised rather than concatenated — an argument with a space
30
+ // or a quote has to survive this file intact.
31
+ args: ["dist/index.js"],
32
+ interpreter: 'none',
14
33
  autorestart: true,
15
- kill_timeout: 15000,
34
+ // >= this role's full shutdown budget of 25000ms.
35
+ kill_timeout: 30000,
16
36
  env: { NODE_ENV: 'production' },
17
37
  },
18
38
  {
19
- name: `${identity.slug}-frontend`,
20
- cwd: path.join(__dirname, 'packages/frontend'),
21
- script: 'node_modules/.bin/next',
22
- args: [
23
- 'start',
24
- '--port',
25
- process.env.WEB_PORT,
26
- '--hostname',
27
- process.env.BIND_HOST ?? '127.0.0.1',
28
- ],
29
- interpreter: 'bun',
39
+ name: `${declaration.identity.slug}-web`,
40
+ // The role's OWN process, in its OWN directory — no launcher in between.
41
+ // Measured: a launcher makes the role see the stop signal twice (once from
42
+ // the supervisor, once forwarded), the second press forces the shutdown,
43
+ // and a declared drain of seconds collapses to milliseconds. A workspace
44
+ // filter is worse: the signal never arrives at all.
45
+ cwd: path.join(__dirname, "packages/frontend"),
46
+ script: "bun",
47
+ // No argv invented here: the deployment injects `WEB_PORT` and the command
48
+ // reads it. Serialised rather than concatenated — an argument with a space
49
+ // or a quote has to survive this file intact.
50
+ args: ["scripts/serve.ts","production"],
51
+ interpreter: 'none',
30
52
  autorestart: true,
31
- kill_timeout: 15000,
53
+ // >= this role's full shutdown budget of 15000ms.
54
+ kill_timeout: 30000,
32
55
  env: { NODE_ENV: 'production' },
33
56
  },
34
57
  ],
@@ -1,37 +1,57 @@
1
+ // GENERATED FILE — do not edit.
2
+ //
3
+ // Rendered from `project.json` by `scripts/declaration.ts`; run
4
+ // `bun run gen:declaration` after changing a role. Roles, commands and the
5
+ // drain floor come from the declaration because they are true of the code;
6
+ // restart policy and the kill timeout are this machine's, and the generator
7
+ // refuses a timeout shorter than any role's full shutdown budget.
1
8
  const path = require('node:path');
2
9
  const { config } = require('dotenv');
3
- const identity = require('./app.config.json');
10
+ const declaration = require('./project.json');
4
11
 
12
+ // NOT `override`: an environment a deployment injected into this process must
13
+ // win over a file in the repository. The file fills gaps; it does not overrule
14
+ // the place.
5
15
  config({ path: path.join(__dirname, '.env'), quiet: true });
6
16
 
7
- const frontendArgs = [
8
- 'dev',
9
- '--port',
10
- process.env.WEB_PORT,
11
- '--hostname',
12
- process.env.BIND_HOST ?? '127.0.0.1',
13
- ];
14
-
15
17
  module.exports = {
16
18
  apps: [
17
19
  {
18
- name: `${identity.slug}-backend-dev`,
19
- cwd: path.join(__dirname, 'packages/backend'),
20
- script: 'src/index.ts',
21
- interpreter: 'bun',
22
- interpreter_args: '--watch',
20
+ name: `${declaration.identity.slug}-api-dev`,
21
+ // The role's OWN process, in its OWN directory — no launcher in between.
22
+ // Measured: a launcher makes the role see the stop signal twice (once from
23
+ // the supervisor, once forwarded), the second press forces the shutdown,
24
+ // and a declared drain of seconds collapses to milliseconds. A workspace
25
+ // filter is worse: the signal never arrives at all.
26
+ cwd: path.join(__dirname, "packages/backend"),
27
+ script: "bun",
28
+ // No argv invented here: the deployment injects `API_PORT` and the command
29
+ // reads it. Serialised rather than concatenated — an argument with a space
30
+ // or a quote has to survive this file intact.
31
+ args: ["--watch","src/index.ts"],
32
+ interpreter: 'none',
23
33
  autorestart: true,
24
- kill_timeout: 10000,
34
+ // >= this role's full shutdown budget of 25000ms.
35
+ kill_timeout: 30000,
25
36
  env: { NODE_ENV: 'development' },
26
37
  },
27
38
  {
28
- name: `${identity.slug}-frontend-dev`,
29
- cwd: path.join(__dirname, 'packages/frontend'),
30
- script: 'node_modules/.bin/next',
31
- args: frontendArgs,
32
- interpreter: 'bun',
39
+ name: `${declaration.identity.slug}-web-dev`,
40
+ // The role's OWN process, in its OWN directory — no launcher in between.
41
+ // Measured: a launcher makes the role see the stop signal twice (once from
42
+ // the supervisor, once forwarded), the second press forces the shutdown,
43
+ // and a declared drain of seconds collapses to milliseconds. A workspace
44
+ // filter is worse: the signal never arrives at all.
45
+ cwd: path.join(__dirname, "packages/frontend"),
46
+ script: "bun",
47
+ // No argv invented here: the deployment injects `WEB_PORT` and the command
48
+ // reads it. Serialised rather than concatenated — an argument with a space
49
+ // or a quote has to survive this file intact.
50
+ args: ["scripts/serve.ts","development"],
51
+ interpreter: 'none',
33
52
  autorestart: true,
34
- kill_timeout: 10000,
53
+ // >= this role's full shutdown budget of 15000ms.
54
+ kill_timeout: 30000,
35
55
  env: { NODE_ENV: 'development' },
36
56
  },
37
57
  ],
@@ -7,14 +7,14 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.52.0"
10
+ "stitchkit": "^0.60.0"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "bun scripts/dev.ts",
14
14
  "check": "bun run db:generate && bun run check:authored && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
15
15
  "check:authored": "bun scripts/check-authored.ts",
16
- "test": "bun run --filter '*' test",
17
- "build": "bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build",
16
+ "test": "bun test scripts && bun run --filter '*' test",
17
+ "build": "bun scripts/build-inputs.ts && bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build && bun scripts/build-stamp.ts",
18
18
  "start:api": "bun --filter @app/backend start",
19
19
  "start:web": "bun --filter @app/frontend start",
20
20
  "env:ensure": "bun scripts/local-env.ts",
@@ -28,10 +28,12 @@
28
28
  "runtime:smoke": "bun scripts/runtime-smoke.ts",
29
29
  "surface:snapshot": "bun scripts/surface-snapshot.ts",
30
30
  "e2e": "playwright test",
31
+ "acceptance:local": "bun scripts/acceptance-local.ts",
31
32
  "lint": "biome check --error-on-warnings .",
32
33
  "lint:fix": "biome check --write .",
33
34
  "pm2:dev": "bun scripts/dev.ts",
34
- "pm2:prod": "bun run db:deploy && bun packages/backend/scripts/ensure-built.ts && pm2 startOrReload ecosystem.config.cjs --update-env"
35
+ "pm2:prod": "bun scripts/release.ts",
36
+ "gen:declaration": "bun scripts/declaration.ts"
35
37
  },
36
38
  "dependencies": {
37
39
  "dotenv": "^17.4.2"
@@ -39,13 +41,13 @@
39
41
  "devDependencies": {
40
42
  "@app/config": "workspace:*",
41
43
  "@app/shared": "workspace:*",
42
- "@axe-core/playwright": "^4.11.0",
43
- "@biomejs/biome": "^2.5.7",
44
+ "@axe-core/playwright": "^4.13.0",
45
+ "@biomejs/biome": "^2.5.10",
44
46
  "@modelcontextprotocol/client": "^2.0.0",
45
- "@playwright/test": "^1.55.0",
46
- "@types/bun": "^1.3.14",
47
- "@types/node": "^26.2.0",
48
- "oxc-parser": "^0.143.0",
47
+ "@playwright/test": "^1.62.1",
48
+ "@types/bun": "^1.4.0",
49
+ "@types/node": "^26.3.0",
50
+ "oxc-parser": "^0.147.0",
49
51
  "socket.io-client": "^4.8.3",
50
52
  "stitchkit": "catalog:",
51
53
  "typescript": "^7.0.2",
@@ -19,13 +19,13 @@
19
19
  "@modelcontextprotocol/server": "^2.0.0",
20
20
  "@prisma/adapter-pg": "^7.9.1",
21
21
  "@socket.io/bun-engine": "^0.1.1",
22
- "ai": "^7.0.58",
22
+ "ai": "^7.0.78",
23
23
  "socket.io": "^4.8.3",
24
24
  "stitchkit": "catalog:",
25
25
  "zod": "^4.4.3"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/bun": "^1.3.14",
28
+ "@types/bun": "^1.4.0",
29
29
  "typescript": "^7.0.2"
30
30
  }
31
31
  }
@@ -0,0 +1,121 @@
1
+ import { CLEANUP_BUDGET_MS } from '@app/config/shutdown';
2
+
3
+ export interface CleanupStep {
4
+ name: string;
5
+ close: () => Promise<unknown>;
6
+ }
7
+
8
+ export interface CleanupFailure {
9
+ name: string;
10
+ cause: unknown;
11
+ }
12
+
13
+ export interface CleanupResult {
14
+ /** Steps that did not finish inside the budget, in order. */
15
+ unfinished: string[];
16
+ /** Steps that finished by throwing, with what they threw. */
17
+ failed: CleanupFailure[];
18
+ durationMs: number;
19
+ }
20
+
21
+ /** How one step ended: in time, in time but throwing, or not in time. */
22
+ type StepOutcome =
23
+ | { kind: 'finished' }
24
+ | { kind: 'threw'; cause: unknown }
25
+ | { kind: 'expired' };
26
+
27
+ /**
28
+ * Close what the role owns, and stop waiting when the budget is spent.
29
+ *
30
+ * Waiting forever is not the safe option it looks like: the supervisor's kill
31
+ * timeout is derived from this budget, so a close that hangs past it turns an
32
+ * orderly shutdown into a SIGKILL — which is the one ending that runs no
33
+ * cleanup at all. Better to leave one connection to the operating system and
34
+ * exit, saying which.
35
+ *
36
+ * The steps run in order and share one deadline, because they are one budget:
37
+ * a slow first close must not hand the second one a full budget of its own.
38
+ *
39
+ * The two ways a step can end are kept apart. Running out of time and throwing
40
+ * are different facts about a shutdown — one says a resource is still held, the
41
+ * other says closing it is broken — and collapsing them (this used to discard
42
+ * the rejection entirely) turned a failed shutdown into a clean exit with the
43
+ * reason gone.
44
+ *
45
+ * The clock is `performance.now()` and cannot be replaced. It used to be
46
+ * `Date.now()` behind an injectable parameter, and both halves of that were
47
+ * wrong for a deadline: a wall clock stepped backwards widens the very upper
48
+ * bound a supervisor's kill timeout was derived from, and an injected clock
49
+ * that does not advance hands every step a full budget — a shutdown budget that
50
+ * a test could switch off. Nothing needs to fake it: the regression that
51
+ * matters runs a real process.
52
+ */
53
+ export async function closeWithinBudget(
54
+ steps: readonly CleanupStep[],
55
+ budgetMs: number = CLEANUP_BUDGET_MS,
56
+ ): Promise<CleanupResult> {
57
+ const now = (): number => performance.now();
58
+ const startedAt = now();
59
+ const unfinished: string[] = [];
60
+ const failed: CleanupFailure[] = [];
61
+ const finished = (): StepOutcome => ({ kind: 'finished' });
62
+ const threw = (cause: unknown): StepOutcome => ({ kind: 'threw', cause });
63
+ for (const step of steps) {
64
+ const remaining = budgetMs - (now() - startedAt);
65
+ if (remaining <= 0) {
66
+ unfinished.push(step.name);
67
+ continue;
68
+ }
69
+ let timer: ReturnType<typeof setTimeout> | undefined;
70
+ const outcome = await Promise.race<StepOutcome>([
71
+ step.close().then(finished, threw),
72
+ new Promise<StepOutcome>((resolve) => {
73
+ timer = setTimeout(() => resolve({ kind: 'expired' }), remaining);
74
+ }),
75
+ ]);
76
+ if (timer !== undefined) clearTimeout(timer);
77
+ if (outcome.kind === 'expired') unfinished.push(step.name);
78
+ else if (outcome.kind === 'threw') failed.push({ name: step.name, cause: outcome.cause });
79
+ }
80
+ return { unfinished, failed, durationMs: Math.round(now() - startedAt) };
81
+ }
82
+
83
+ /**
84
+ * Turn a bounded cleanup into an ending: say what happened, and make sure it
85
+ * actually ends.
86
+ *
87
+ * `process.exitCode` only decides the code the process reports *when it exits*.
88
+ * A step that ran out of time may still be holding a handle — that is what
89
+ * running out of time usually means — and a process holding one waits for the
90
+ * event loop to drain, which is precisely the wait the supervisor answers with
91
+ * SIGKILL. So an unfinished step is not a note to log on the way out; it is the
92
+ * reason to leave now.
93
+ *
94
+ * `exit` is a parameter so this decision can be exercised without ending the
95
+ * test runner — but the regression that matters runs a real process
96
+ * (`scripts/shutdown-budget.fixture.ts`), because "the promise resolved" was
97
+ * never the property in question.
98
+ */
99
+ export function concludeShutdown(
100
+ result: CleanupResult,
101
+ drainWasClean: boolean,
102
+ exit: (code: number) => void = (code) => process.exit(code),
103
+ ): void {
104
+ for (const failure of result.failed) {
105
+ console.error(`Shutdown could not close ${failure.name}:`, failure.cause);
106
+ }
107
+ if (result.unfinished.length > 0) {
108
+ console.error(
109
+ `Shutdown left ${result.unfinished.join(' and ')} unclosed after ${result.durationMs}ms — the cleanup budget is spent, exiting anyway.`,
110
+ );
111
+ }
112
+ const cleanupCompleted = result.unfinished.length === 0 && result.failed.length === 0;
113
+ const code = drainWasClean && cleanupCompleted ? 0 : 1;
114
+ process.exitCode = code;
115
+ // A cleanup that did not complete does not get to wait. Either a step ran out
116
+ // of time — so something is still held — or closing it threw, which says the
117
+ // same thing with less certainty about what. A shutdown that completed exits
118
+ // on its own, and letting it do so keeps the ordinary path ordinary.
119
+ // Last on purpose: nothing after this line runs.
120
+ if (!cleanupCompleted) exit(code);
121
+ }
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { appIdentity } from '@app/config/identity';
3
+ import { appDeclaration } from '@app/config/declaration';
4
4
  import { createCli } from 'stitchkit/cli';
5
5
  import { createSurface } from './surface';
6
6
 
7
7
  const { services, socket } = await createSurface();
8
8
 
9
9
  try {
10
- await createCli({ name: appIdentity.slug, version: appIdentity.version, services });
10
+ await createCli({
11
+ name: appDeclaration.identity.slug,
12
+ version: appDeclaration.identity.version,
13
+ services,
14
+ });
11
15
  } finally {
12
16
  await socket.close();
13
17
  }
@@ -1,5 +1,5 @@
1
1
  import { env } from '@app/config';
2
- import { appIdentity } from '@app/config/identity';
2
+ import { apiRole, appDeclaration } from '@app/config/declaration';
3
3
  import { wrapInRequestContext } from 'stitchkit/observability';
4
4
  import {
5
5
  bindProcessSignals,
@@ -8,6 +8,7 @@ import {
8
8
  openApiRoute,
9
9
  } from 'stitchkit/server';
10
10
  import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools';
11
+ import { closeWithinBudget, concludeShutdown } from './cleanup';
11
12
  import { prisma } from './lib/db';
12
13
  import { createSurface } from './surface';
13
14
  import { onError } from './transport/errors';
@@ -15,12 +16,18 @@ import { onError } from './transport/errors';
15
16
  async function main(): Promise<void> {
16
17
  const { services, socket } = await createSurface();
17
18
  const mcp = createMcpHandler({
18
- serverInfo: { name: appIdentity.slug, version: appIdentity.version },
19
+ serverInfo: {
20
+ name: appDeclaration.identity.slug,
21
+ version: appDeclaration.identity.version,
22
+ },
19
23
  auth: () => ({ scope: 'public' }),
20
24
  services,
21
25
  });
22
26
  const openApi = generateOpenApiDocument({
23
- info: { title: `${appIdentity.name} API`, version: appIdentity.version },
27
+ info: {
28
+ title: `${appDeclaration.identity.name} API`,
29
+ version: appDeclaration.identity.version,
30
+ },
24
31
  groups: [{ pathPrefix: '/api', services }],
25
32
  });
26
33
 
@@ -28,7 +35,7 @@ async function main(): Promise<void> {
28
35
  groups: [{ pathPrefix: '/api', services }],
29
36
  port: env.API_PORT,
30
37
  hostname: env.BIND_HOST,
31
- cors: { origin: env.CORS_ORIGIN },
38
+ cors: env.CORS_ORIGIN ? { origin: env.CORS_ORIGIN } : undefined,
32
39
  hooks: { onError },
33
40
  logging: { format: env.LOG_FORMAT },
34
41
  socket,
@@ -48,11 +55,29 @@ async function main(): Promise<void> {
48
55
  // application's and close after the drain. A second signal forces this same
49
56
  // shutdown, a third hands the signal back to its default disposition.
50
57
  bindProcessSignals(server, {
51
- shutdown: { gracePeriodMs: 30_000 },
58
+ // The FLOOR comes from the declaration, which is where a supervisor reads
59
+ // it too — one number, not two that can disagree. It is a property of the
60
+ // code: whatever supervises this process must allow at least this much
61
+ // before sending SIGKILL, or the drain never finishes.
62
+ shutdown: { gracePeriodMs: apiRole.drainFloorMs },
52
63
  onComplete: async (result) => {
53
- await mcp.close();
54
- await prisma.$disconnect();
55
- process.exitCode = result.outcome === 'clean' ? 0 : 1;
64
+ // Bounded on purpose. The supervisor's kill timeout is derived from this
65
+ // budget, so a close that hangs past it turns an orderly shutdown into a
66
+ // SIGKILL — the one ending that runs no cleanup at all.
67
+ const cleanup = await closeWithinBudget([
68
+ { name: 'MCP', close: () => mcp.close() },
69
+ { name: 'database', close: () => prisma.$disconnect() },
70
+ ]);
71
+ // Say how the drain ended. Without this an operator sees a process that
72
+ // vanished and an exit code, and cannot tell a clean drain from one the
73
+ // deadline or a second signal cut short.
74
+ console.log(
75
+ `Shutdown ${result.outcome}${result.reason ? ` (${result.reason})` : ''} in ${result.durationMs}ms — ${result.completedRequests} requests completed, ${result.abortedRequests} aborted, ${result.forcedWebSockets} sockets forced`,
76
+ );
77
+ // Sets the code, reports any close that threw, and — if a step is still
78
+ // holding something — ends the process here rather than waiting for a
79
+ // handle that already missed its deadline. Nothing follows it.
80
+ concludeShutdown(cleanup, result.outcome === 'clean');
56
81
  },
57
82
  onError: (phase, error) => {
58
83
  console.error(`Shutdown failed during ${phase}`, error);
@@ -3,6 +3,11 @@ import { createSocketIOServer } from 'stitchkit/server';
3
3
  import { createSystemService } from './transport/system-service';
4
4
 
5
5
  export async function createSurface() {
6
- const socket = await createSocketIOServer({ cors: { origin: env.CORS_ORIGIN } });
6
+ // An EMPTY allow-list is same-origin: no origin is permitted to open a
7
+ // cross-origin socket, and no browser on this app's own origin needs one.
8
+ // `CORS_ORIGIN` is set only when the browser genuinely lives elsewhere.
9
+ // (Once the workspace targets a Stitchkit release where `cors` itself is
10
+ // optional, this becomes `undefined` and the empty array goes away.)
11
+ const socket = await createSocketIOServer({ cors: { origin: env.CORS_ORIGIN ?? [] } });
7
12
  return { socket, services: [createSystemService()] };
8
13
  }
@@ -1,7 +1,9 @@
1
- import type { StitchErrorCode } from 'stitchkit';
2
1
  import { createErrorHook } from 'stitchkit/server';
3
2
 
4
- const codeMap: Record<StitchErrorCode, string> = {
3
+ // Deliberately not annotated as an exhaustive `Record<StitchErrorCode, …>`:
4
+ // this template compiles against both its pinned Stitchkit target and HEAD, and
5
+ // the code union differs between them. Unlisted codes travel as themselves.
6
+ const codeMap = {
5
7
  BAD_REQUEST: 'bad_request',
6
8
  VALIDATION_ERROR: 'validation_error',
7
9
  UNAUTHORIZED: 'unauthorized',
@@ -5,7 +5,10 @@
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/server.ts",
8
- "./identity": "./src/identity.ts"
8
+ "./variables": "./src/variables.ts",
9
+ "./declaration": "./src/declaration.ts",
10
+ "./shutdown": "./src/shutdown.ts",
11
+ "./app-identity": "./src/app-identity.generated.ts"
9
12
  },
10
13
  "scripts": {
11
14
  "check": "bun x tsc --noEmit",
@@ -14,10 +17,11 @@
14
17
  "dependencies": {
15
18
  "@t3-oss/env-core": "^0.13.11",
16
19
  "dotenv": "^17.4.2",
20
+ "stitchkit": "catalog:",
17
21
  "zod": "^4.4.3"
18
22
  },
19
23
  "devDependencies": {
20
- "@types/bun": "^1.3.14",
24
+ "@types/bun": "^1.4.0",
21
25
  "typescript": "^7.0.2"
22
26
  }
23
27
  }
@@ -0,0 +1,20 @@
1
+ // GENERATED FILE — do not edit.
2
+ //
3
+ // Rendered from `project.json` by `scripts/declaration.ts`.
4
+ //
5
+ // Identity ONLY, inlined rather than imported, because this is the part of the
6
+ // declaration a browser may know. Importing the whole declaration from a client
7
+ // component would put role commands, working directories, build artifact paths,
8
+ // the migration lockfile and every environment variable name into the browser
9
+ // bundle — the same mistake as publishing internal topology from a status
10
+ // endpoint, made from the other side.
11
+
12
+ export const appIdentity = {
13
+ "slug": "stitchkit-starter",
14
+ "name": "Stitchkit Starter",
15
+ "version": "0.1.0",
16
+ "description": {
17
+ "en": "Stitchkit Starter is a production application built with Stitchkit.",
18
+ "ru": "Stitchkit Starter — production-приложение на Stitchkit."
19
+ }
20
+ };
@@ -0,0 +1,30 @@
1
+ import { findProjectRole, parseProjectDeclaration } from 'stitchkit/declaration';
2
+ import source from '../../../project.json' with { type: 'json' };
3
+
4
+ /**
5
+ * What this repository says about itself — the one machine-readable statement
6
+ * that is true with no machine in existence.
7
+ *
8
+ * Ports, hosts, addresses, machine paths and supervision policy are NOT here by
9
+ * construction: the schema has nowhere to put them. A deployment supplies those
10
+ * under the variable names the declaration lists.
11
+ */
12
+ export const appDeclaration = parseProjectDeclaration(source);
13
+
14
+ /**
15
+ * This application's API role.
16
+ *
17
+ * Resolved once, here, so the role's own code can read what the declaration
18
+ * says about it — the drain floor above all — instead of restating it. A
19
+ * declaration without the role is a broken declaration, and saying so at
20
+ * startup beats a silent `undefined` deep inside a shutdown path.
21
+ */
22
+ const API_ROLE_NAME = 'api';
23
+
24
+ export const apiRole = (() => {
25
+ const role = findProjectRole(appDeclaration, API_ROLE_NAME);
26
+ if (!role) {
27
+ throw new Error(`project.json declares no "${API_ROLE_NAME}" role.`);
28
+ }
29
+ return role;
30
+ })();
@@ -1,27 +1,18 @@
1
1
  import path from 'node:path';
2
2
  import { createEnv } from '@t3-oss/env-core';
3
3
  import { config } from 'dotenv';
4
- import { z } from 'zod';
5
- import { featureServerSchema } from './features';
4
+ import { applicationVariables } from './variables';
6
5
 
7
6
  config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
8
7
 
8
+ /**
9
+ * The API role's view of the environment: every declared variable.
10
+ *
11
+ * The variables themselves are declared once in `variables.ts` — this module
12
+ * only says which of them this role validates, and where the file is read from.
13
+ */
9
14
  export const env = createEnv({
10
- server: {
11
- NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
12
- DATABASE_URL: z.url(),
13
- // Loopback by default — exposing the app to the network is an explicit
14
- // opt-in (`BIND_HOST=0.0.0.0`), never something a forgotten edit causes.
15
- BIND_HOST: z.string().min(1).default('127.0.0.1'),
16
- API_PORT: z.coerce.number().int().positive(),
17
- WEB_PORT: z.coerce.number().int().positive(),
18
- NEXT_PUBLIC_API_URL: z.url(),
19
- INTERNAL_API_URL: z.url(),
20
- NEXT_PUBLIC_WEB_URL: z.url(),
21
- LOG_FORMAT: z.enum(['pretty', 'json']).default('pretty'),
22
- CORS_ORIGIN: z.url(),
23
- ...featureServerSchema,
24
- },
15
+ server: applicationVariables,
25
16
  runtimeEnv: process.env,
26
17
  emptyStringAsUndefined: true,
27
18
  });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The parts of a shutdown that are NOT the drain, as one number each.
3
+ *
4
+ * `terminationBudgetMs` adds these to a role's drain floor and refuses a
5
+ * supervision policy that allows less. That only means something if each part
6
+ * is really bounded — and cleanup was not: the drain has a deadline, but the
7
+ * closes that run after it (an MCP session, a database pool) had none, so the
8
+ * "budget" was an estimate wearing the shape of an upper bound, and a role
9
+ * could still be killed mid-shutdown by a timeout the generator had approved.
10
+ *
11
+ * They live here rather than in the generator because two readers need them and
12
+ * a number in two places is two numbers: the generator, which tells a
13
+ * supervisor how long to wait, and the role itself, which must not take longer.
14
+ */
15
+
16
+ /** After the drain deadline, how long a forced finish may take. */
17
+ export const FORCE_BUDGET_MS = 5_000;
18
+
19
+ /** After the server is done, how long the role's own closes may take. */
20
+ export const CLEANUP_BUDGET_MS = 5_000;