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,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
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 · ${
|
|
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 === `${
|
|
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
|
|
10
|
+
const declaration = require('./project.json');
|
|
4
11
|
|
|
5
|
-
|
|
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}-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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}-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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}-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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}-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
53
|
+
// >= this role's full shutdown budget of 15000ms.
|
|
54
|
+
kill_timeout: 30000,
|
|
35
55
|
env: { NODE_ENV: 'development' },
|
|
36
56
|
},
|
|
37
57
|
],
|
package/template/package.json
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
"packages/*"
|
|
8
8
|
],
|
|
9
9
|
"catalog": {
|
|
10
|
-
"stitchkit": "^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
|
|
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.
|
|
43
|
-
"@biomejs/biome": "^2.5.
|
|
44
|
+
"@axe-core/playwright": "^4.13.0",
|
|
45
|
+
"@biomejs/biome": "^2.5.10",
|
|
44
46
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
45
|
-
"@playwright/test": "^1.
|
|
46
|
-
"@types/bun": "^1.
|
|
47
|
-
"@types/node": "^26.
|
|
48
|
-
"oxc-parser": "^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.
|
|
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.
|
|
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 {
|
|
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({
|
|
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 {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"./
|
|
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.
|
|
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 {
|
|
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;
|