create-stitchkit 0.3.3 → 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 (80) hide show
  1. package/CHANGELOG.md +218 -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 +12 -2
  25. package/template/README.md +43 -6
  26. package/template/_env.example +8 -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 -19
  31. package/template/ecosystem.dev.config.cjs +41 -21
  32. package/template/package.json +5 -4
  33. package/template/packages/backend/src/cli.ts +6 -2
  34. package/template/packages/backend/src/index.ts +21 -5
  35. package/template/packages/backend/src/surface.ts +6 -1
  36. package/template/packages/backend/src/transport/errors.ts +4 -2
  37. package/template/packages/config/package.json +3 -1
  38. package/template/packages/config/src/app-identity.generated.ts +20 -0
  39. package/template/packages/config/src/declaration.ts +30 -0
  40. package/template/packages/config/src/project-declaration.generated.ts +611 -0
  41. package/template/packages/config/src/server.ts +8 -17
  42. package/template/packages/config/src/variables.ts +89 -0
  43. package/template/packages/frontend/next.config.ts +3 -2
  44. package/template/packages/frontend/package.json +2 -2
  45. package/template/packages/frontend/scripts/serve.ts +70 -0
  46. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  47. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  48. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  49. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  50. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  51. package/template/packages/frontend/src/app/robots.ts +4 -2
  52. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  53. package/template/packages/frontend/src/env.ts +27 -8
  54. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  55. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  56. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  57. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  58. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  59. package/template/packages/frontend/src/theme/config.ts +1 -1
  60. package/template/packages/frontend/tsconfig.json +10 -3
  61. package/template/playwright.config.ts +1 -1
  62. package/template/project.json +169 -0
  63. package/template/scripts/build-inputs.test.ts +69 -0
  64. package/template/scripts/build-inputs.ts +57 -0
  65. package/template/scripts/check-authored.ts +18 -2
  66. package/template/scripts/declaration.test.ts +206 -0
  67. package/template/scripts/declaration.ts +268 -0
  68. package/template/scripts/dev.ts +41 -20
  69. package/template/scripts/local-env.test.ts +2 -2
  70. package/template/scripts/local-env.ts +3 -3
  71. package/template/scripts/release-steps.test.ts +87 -0
  72. package/template/scripts/release-steps.ts +108 -0
  73. package/template/scripts/release.ts +30 -0
  74. package/template/scripts/runtime-smoke.ts +7 -4
  75. package/template/scripts/serve-mode.test.ts +36 -0
  76. package/template/scripts/supervision-signal.test.ts +94 -0
  77. package/template/scripts/tooling-env.ts +5 -2
  78. package/template/scripts/web-surface-smoke.ts +70 -0
  79. package/template/app.config.json +0 -9
  80. package/template/packages/config/src/identity.ts +0 -18
@@ -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",
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",
@@ -31,7 +31,8 @@
31
31
  "lint": "biome check --error-on-warnings .",
32
32
  "lint:fix": "biome check --write .",
33
33
  "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"
34
+ "pm2:prod": "bun scripts/release.ts",
35
+ "gen:declaration": "bun scripts/declaration.ts"
35
36
  },
36
37
  "dependencies": {
37
38
  "dotenv": "^17.4.2"
@@ -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,
@@ -15,12 +15,18 @@ import { onError } from './transport/errors';
15
15
  async function main(): Promise<void> {
16
16
  const { services, socket } = await createSurface();
17
17
  const mcp = createMcpHandler({
18
- serverInfo: { name: appIdentity.slug, version: appIdentity.version },
18
+ serverInfo: {
19
+ name: appDeclaration.identity.slug,
20
+ version: appDeclaration.identity.version,
21
+ },
19
22
  auth: () => ({ scope: 'public' }),
20
23
  services,
21
24
  });
22
25
  const openApi = generateOpenApiDocument({
23
- info: { title: `${appIdentity.name} API`, version: appIdentity.version },
26
+ info: {
27
+ title: `${appDeclaration.identity.name} API`,
28
+ version: appDeclaration.identity.version,
29
+ },
24
30
  groups: [{ pathPrefix: '/api', services }],
25
31
  });
26
32
 
@@ -28,7 +34,7 @@ async function main(): Promise<void> {
28
34
  groups: [{ pathPrefix: '/api', services }],
29
35
  port: env.API_PORT,
30
36
  hostname: env.BIND_HOST,
31
- cors: { origin: env.CORS_ORIGIN },
37
+ cors: env.CORS_ORIGIN ? { origin: env.CORS_ORIGIN } : undefined,
32
38
  hooks: { onError },
33
39
  logging: { format: env.LOG_FORMAT },
34
40
  socket,
@@ -48,10 +54,20 @@ async function main(): Promise<void> {
48
54
  // application's and close after the drain. A second signal forces this same
49
55
  // shutdown, a third hands the signal back to its default disposition.
50
56
  bindProcessSignals(server, {
51
- shutdown: { gracePeriodMs: 30_000 },
57
+ // The FLOOR comes from the declaration, which is where a supervisor reads
58
+ // it too — one number, not two that can disagree. It is a property of the
59
+ // code: whatever supervises this process must allow at least this much
60
+ // before sending SIGKILL, or the drain never finishes.
61
+ shutdown: { gracePeriodMs: apiRole.drainFloorMs },
52
62
  onComplete: async (result) => {
53
63
  await mcp.close();
54
64
  await prisma.$disconnect();
65
+ // Say how the drain ended. Without this an operator sees a process that
66
+ // vanished and an exit code, and cannot tell a clean drain from one the
67
+ // deadline or a second signal cut short.
68
+ console.log(
69
+ `Shutdown ${result.outcome}${result.reason ? ` (${result.reason})` : ''} in ${result.durationMs}ms — ${result.completedRequests} requests completed, ${result.abortedRequests} aborted, ${result.forcedWebSockets} sockets forced`,
70
+ );
55
71
  process.exitCode = result.outcome === 'clean' ? 0 : 1;
56
72
  },
57
73
  onError: (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,9 @@
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
+ "./app-identity": "./src/app-identity.generated.ts"
9
11
  },
10
12
  "scripts": {
11
13
  "check": "bun x tsc --noEmit",
@@ -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 source from '../../../project.json' with { type: 'json' };
2
+ import { findProjectRole, parseProjectDeclaration } from './project-declaration.generated';
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
+ })();