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.
- package/CHANGELOG.md +218 -0
- package/README.md +3 -1
- package/UPGRADING.md +225 -0
- package/dist/cli.js +233 -41
- 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 +25 -5
- package/package.json +9 -1
- package/template/AGENTS.md +12 -2
- package/template/README.md +43 -6
- package/template/_env.example +8 -4
- package/template/biome.json +5 -1
- package/template/bun.lock +2 -2
- 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 +5 -4
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +21 -5
- 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 +3 -1
- 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/project-declaration.generated.ts +611 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +2 -2
- 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/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 +2 -2
- 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/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- package/template/scripts/build-inputs.test.ts +69 -0
- package/template/scripts/build-inputs.ts +57 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +268 -0
- package/template/scripts/dev.ts +41 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +3 -3
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +108 -0
- package/template/scripts/release.ts +30 -0
- package/template/scripts/runtime-smoke.ts +7 -4
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/tooling-env.ts +5 -2
- package/template/scripts/web-surface-smoke.ts +70 -0
- package/template/app.config.json +0 -9
- package/template/packages/config/src/identity.ts +0 -18
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { headers } from 'next/headers';
|
|
2
|
+
import { env } from '@/env';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The public origin this response is being served on.
|
|
6
|
+
*
|
|
7
|
+
* Derived from the REQUEST, never from the build. An absolute address read at
|
|
8
|
+
* build time is frozen into the artifact — `robots.txt` and `sitemap.xml` were
|
|
9
|
+
* prerendered with one origin inside their bytes, so a single build could not
|
|
10
|
+
* serve a second address. Reading the request instead makes one artifact
|
|
11
|
+
* correct at every address it is ever routed to.
|
|
12
|
+
*
|
|
13
|
+
* **The request is not trusted on its own.** `x-forwarded-host` is set by
|
|
14
|
+
* whoever is in front of this process, and a request that reaches the role
|
|
15
|
+
* directly can set it too. An unchecked value would put an attacker's host into
|
|
16
|
+
* canonical URLs, the sitemap and OG metadata — the artifact would be portable
|
|
17
|
+
* and also forgeable. So a forwarded host is honoured only when the deployment
|
|
18
|
+
* has said which hosts it serves, and `PUBLIC_WEB_ORIGIN` remains the way to
|
|
19
|
+
* state a single one.
|
|
20
|
+
*
|
|
21
|
+
* Both are read at RUNTIME (no `NEXT_PUBLIC_` prefix, so nothing is substituted
|
|
22
|
+
* at build time).
|
|
23
|
+
*/
|
|
24
|
+
export async function requestOrigin(): Promise<string> {
|
|
25
|
+
const configured = env.PUBLIC_WEB_ORIGIN;
|
|
26
|
+
if (configured) return new URL(configured).origin;
|
|
27
|
+
|
|
28
|
+
const incoming = await headers();
|
|
29
|
+
const host = firstValue(incoming.get('x-forwarded-host') ?? incoming.get('host'));
|
|
30
|
+
if (!host) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'Cannot determine the public origin: the request carried no Host header. Set PUBLIC_WEB_ORIGIN.',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (!isAllowedHost(host)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Refusing to answer for host "${host}": it is not in PUBLIC_WEB_HOSTS. Set PUBLIC_WEB_ORIGIN for a single address, or PUBLIC_WEB_HOSTS for several.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return new URL(`${protocolOf(incoming.get('x-forwarded-proto'))}://${host}`).origin;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Which hosts this deployment serves.
|
|
45
|
+
*
|
|
46
|
+
* Empty means "only what `PUBLIC_WEB_ORIGIN` says", and since that short-circuits
|
|
47
|
+
* above, an empty list with no origin set is a deployment that has not been told
|
|
48
|
+
* where it lives — which is an error, not a licence to believe the caller.
|
|
49
|
+
*
|
|
50
|
+
* There is deliberately no wildcard: serving any host is the same as having no
|
|
51
|
+
* canonical origin, and every answer this module gives is a canonical origin.
|
|
52
|
+
*/
|
|
53
|
+
function isAllowedHost(host: string): boolean {
|
|
54
|
+
const allowed = env.PUBLIC_WEB_HOSTS?.split(',')
|
|
55
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
56
|
+
.filter((entry) => entry.length > 0);
|
|
57
|
+
if (!allowed || allowed.length === 0) return false;
|
|
58
|
+
// No wildcard. `*` read as "serve any host" — which is the same as having no
|
|
59
|
+
// canonical origin at all, and therefore no meaningful answer to give for a
|
|
60
|
+
// canonical URL, a sitemap or an OG card. A deployment that genuinely serves
|
|
61
|
+
// many addresses lists them; one that does not know which it serves has a
|
|
62
|
+
// configuration problem, not a licence to believe the caller.
|
|
63
|
+
return allowed.includes(host.toLowerCase());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The scheme this response is being served over.
|
|
68
|
+
*
|
|
69
|
+
* Only two are possible, and anything else is a misconfigured proxy or a
|
|
70
|
+
* forgery — both of which must be seen, not normalised. Mapping every unknown
|
|
71
|
+
* value to `http` meant `ftp`, `javascript` and a truncated header all produced
|
|
72
|
+
* a plausible-looking origin, and the answer went into canonical URLs.
|
|
73
|
+
*
|
|
74
|
+
* A missing header is not a failure: a deployment reached directly has no
|
|
75
|
+
* forwarding layer, and `http` is then the truth.
|
|
76
|
+
*/
|
|
77
|
+
function protocolOf(header: string | null): 'http' | 'https' {
|
|
78
|
+
const forwarded = firstValue(header)?.toLowerCase();
|
|
79
|
+
if (forwarded === undefined) return 'http';
|
|
80
|
+
if (forwarded === 'http' || forwarded === 'https') return forwarded;
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Refusing to answer for forwarded protocol "${forwarded}": x-forwarded-proto must be http or https. Fix the proxy, or set PUBLIC_WEB_ORIGIN to state the public origin outright.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A forwarded header may carry the whole proxy chain — the first hop is ours. */
|
|
87
|
+
function firstValue(header: string | null): string | undefined {
|
|
88
|
+
return header?.split(',')[0]?.trim() || undefined;
|
|
89
|
+
}
|
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
"jsx": "react-jsx",
|
|
6
6
|
"incremental": true,
|
|
7
7
|
"types": ["bun"],
|
|
8
|
-
"plugins": [
|
|
9
|
-
|
|
8
|
+
"plugins": [
|
|
9
|
+
{
|
|
10
|
+
"name": "next"
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"paths": {
|
|
14
|
+
"@/*": ["./src/*"]
|
|
15
|
+
}
|
|
10
16
|
},
|
|
11
17
|
"include": [
|
|
12
18
|
"next-env.d.ts",
|
|
13
19
|
"next.config.ts",
|
|
14
20
|
"src/**/*.ts",
|
|
15
21
|
"src/**/*.tsx",
|
|
16
|
-
".next/types/**/*.ts"
|
|
22
|
+
".next/types/**/*.ts",
|
|
23
|
+
"scripts/**/*.ts"
|
|
17
24
|
],
|
|
18
25
|
"exclude": ["node_modules"]
|
|
19
26
|
}
|
|
@@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|
|
2
2
|
import { loadToolingEnv } from './scripts/tooling-env';
|
|
3
3
|
|
|
4
4
|
const toolingEnv = loadToolingEnv();
|
|
5
|
-
const baseURL = toolingEnv.PLAYWRIGHT_BASE_URL ?? toolingEnv.
|
|
5
|
+
const baseURL = toolingEnv.PLAYWRIGHT_BASE_URL ?? toolingEnv.SMOKE_WEB_ORIGIN;
|
|
6
6
|
|
|
7
7
|
export default defineConfig({
|
|
8
8
|
testDir: './e2e',
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"kind": "application",
|
|
4
|
+
"identity": {
|
|
5
|
+
"slug": "stitchkit-starter",
|
|
6
|
+
"name": "Stitchkit Starter",
|
|
7
|
+
"version": "0.1.0",
|
|
8
|
+
"description": {
|
|
9
|
+
"en": "Stitchkit Starter is a production application built with Stitchkit.",
|
|
10
|
+
"ru": "Stitchkit Starter — production-приложение на Stitchkit."
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"roles": [
|
|
14
|
+
{
|
|
15
|
+
"name": "api",
|
|
16
|
+
"workingDirectory": "packages/backend",
|
|
17
|
+
"commands": {
|
|
18
|
+
"development": {
|
|
19
|
+
"executable": "bun",
|
|
20
|
+
"args": [
|
|
21
|
+
"--watch",
|
|
22
|
+
"src/index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"production": {
|
|
26
|
+
"executable": "bun",
|
|
27
|
+
"args": [
|
|
28
|
+
"dist/index.js"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"listener": {
|
|
33
|
+
"portVariable": "API_PORT",
|
|
34
|
+
"bindVariable": "BIND_HOST",
|
|
35
|
+
"readinessPath": "/health"
|
|
36
|
+
},
|
|
37
|
+
"drainFloorMs": 15000
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "web",
|
|
41
|
+
"workingDirectory": "packages/frontend",
|
|
42
|
+
"commands": {
|
|
43
|
+
"development": {
|
|
44
|
+
"executable": "bun",
|
|
45
|
+
"args": [
|
|
46
|
+
"scripts/serve.ts",
|
|
47
|
+
"development"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"production": {
|
|
51
|
+
"executable": "bun",
|
|
52
|
+
"args": [
|
|
53
|
+
"scripts/serve.ts",
|
|
54
|
+
"production"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"listener": {
|
|
59
|
+
"portVariable": "WEB_PORT",
|
|
60
|
+
"bindVariable": "BIND_HOST",
|
|
61
|
+
"readinessPath": "/"
|
|
62
|
+
},
|
|
63
|
+
"drainFloorMs": 5000
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"build": {
|
|
67
|
+
"command": {
|
|
68
|
+
"executable": "bun",
|
|
69
|
+
"args": [
|
|
70
|
+
"run",
|
|
71
|
+
"build"
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"artifacts": [
|
|
75
|
+
"packages/backend/dist",
|
|
76
|
+
"packages/frontend/.next",
|
|
77
|
+
"packages/db/src/generated"
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
"requires": [
|
|
81
|
+
{
|
|
82
|
+
"name": "postgres",
|
|
83
|
+
"phases": [
|
|
84
|
+
"release",
|
|
85
|
+
"start"
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"release": {
|
|
90
|
+
"migrations": {
|
|
91
|
+
"engine": "prisma",
|
|
92
|
+
"root": "packages/db/migrations",
|
|
93
|
+
"lockfile": "packages/db/migrations/migration_lock.toml"
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"env": {
|
|
97
|
+
"variables": [
|
|
98
|
+
{
|
|
99
|
+
"name": "API_PORT",
|
|
100
|
+
"shape": "integer",
|
|
101
|
+
"required": true
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"name": "BIND_HOST",
|
|
105
|
+
"shape": "string",
|
|
106
|
+
"required": false
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "CORS_ORIGIN",
|
|
110
|
+
"shape": "url",
|
|
111
|
+
"required": false
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "DATABASE_URL",
|
|
115
|
+
"shape": "url",
|
|
116
|
+
"required": true
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"name": "INTERNAL_API_URL",
|
|
120
|
+
"shape": "url",
|
|
121
|
+
"required": false
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"name": "LOG_FORMAT",
|
|
125
|
+
"shape": "enum",
|
|
126
|
+
"required": false,
|
|
127
|
+
"members": [
|
|
128
|
+
"pretty",
|
|
129
|
+
"json"
|
|
130
|
+
]
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"name": "NODE_ENV",
|
|
134
|
+
"shape": "enum",
|
|
135
|
+
"required": false,
|
|
136
|
+
"members": [
|
|
137
|
+
"development",
|
|
138
|
+
"test",
|
|
139
|
+
"production"
|
|
140
|
+
]
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"name": "PUBLIC_API_ORIGIN",
|
|
144
|
+
"shape": "url",
|
|
145
|
+
"required": false
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"name": "PUBLIC_REALTIME_ORIGIN",
|
|
149
|
+
"shape": "url",
|
|
150
|
+
"required": false
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"name": "PUBLIC_WEB_HOSTS",
|
|
154
|
+
"shape": "string",
|
|
155
|
+
"required": false
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"name": "PUBLIC_WEB_ORIGIN",
|
|
159
|
+
"shape": "url",
|
|
160
|
+
"required": false
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"name": "WEB_PORT",
|
|
164
|
+
"shape": "integer",
|
|
165
|
+
"required": true
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { appDeclaration } from '../packages/config/src/declaration';
|
|
7
|
+
import type { ProjectDeclaration } from '../packages/config/src/project-declaration.generated';
|
|
8
|
+
import { assertDeclaredBuildInputs } from './build-inputs';
|
|
9
|
+
|
|
10
|
+
function digestOf(text: string): string {
|
|
11
|
+
return `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withInput(path: string, digest: string): ProjectDeclaration {
|
|
15
|
+
const build = appDeclaration.build;
|
|
16
|
+
if (!build) throw new Error('the template declares a build');
|
|
17
|
+
return {
|
|
18
|
+
...appDeclaration,
|
|
19
|
+
build: { ...build, inputs: [{ name: 'catalogue', path, digest }] },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('this template declares no build inputs, and that is the answer', () => {
|
|
24
|
+
// Not "we forgot to say": the frontend cannot reach a data source at all
|
|
25
|
+
// (`check-authored` refuses the import), so the build is a function of the
|
|
26
|
+
// source alone. If a route ever needs data at build time, it declares it.
|
|
27
|
+
expect(appDeclaration.build?.inputs).toBeUndefined();
|
|
28
|
+
expect(() => assertDeclaredBuildInputs()).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a declared input whose bytes changed is refused by name', async () => {
|
|
32
|
+
const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
|
|
33
|
+
try {
|
|
34
|
+
await writeFile(join(directory, 'catalogue.json'), '{"items":2}');
|
|
35
|
+
// The digest of what the author froze — not of what is on disk now. This is
|
|
36
|
+
// the whole failure mode: the filename still resolves, so nothing else in
|
|
37
|
+
// the build notices that two builds of one source now differ.
|
|
38
|
+
const stale = digestOf('{"items":1}');
|
|
39
|
+
expect(() =>
|
|
40
|
+
assertDeclaredBuildInputs(withInput('catalogue.json', stale), directory),
|
|
41
|
+
).toThrow(/"catalogue".*no longer matches its digest/s);
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(directory, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a declared input that matches its digest passes', async () => {
|
|
48
|
+
const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
|
|
49
|
+
try {
|
|
50
|
+
const frozen = '{"items":2}';
|
|
51
|
+
await writeFile(join(directory, 'catalogue.json'), frozen);
|
|
52
|
+
expect(() =>
|
|
53
|
+
assertDeclaredBuildInputs(withInput('catalogue.json', digestOf(frozen)), directory),
|
|
54
|
+
).not.toThrow();
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(directory, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a declared input that is not there names itself', async () => {
|
|
61
|
+
const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
|
|
62
|
+
try {
|
|
63
|
+
expect(() =>
|
|
64
|
+
assertDeclaredBuildInputs(withInput('catalogue.json', digestOf('{}')), directory),
|
|
65
|
+
).toThrow(/Declared build input "catalogue" is missing/);
|
|
66
|
+
} finally {
|
|
67
|
+
await rm(directory, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { appDeclaration } from '../packages/config/src/declaration';
|
|
5
|
+
import type { ProjectDeclaration } from '../packages/config/src/project-declaration.generated';
|
|
6
|
+
|
|
7
|
+
const root = resolve(import.meta.dir, '..');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The third kind of input, checked before the build reads it.
|
|
11
|
+
*
|
|
12
|
+
* The boundary rule separates code from the values of a place. Data read while
|
|
13
|
+
* building is neither: it is not in the source and it is not a binding, so a
|
|
14
|
+
* build that reads it is a function of something nobody declared. That is the
|
|
15
|
+
* dependency that never announces itself — it works on the machine that happens
|
|
16
|
+
* to have the database, and the artifact quietly stops being a function of the
|
|
17
|
+
* source.
|
|
18
|
+
*
|
|
19
|
+
* Three answers are legitimate, per route rather than per project: render at
|
|
20
|
+
* runtime (the default, and what this template does), read a frozen export
|
|
21
|
+
* whose digest is declared here, or generate the bytes as a release step. This
|
|
22
|
+
* file owns the second one. It is deliberately a no-op for a project that
|
|
23
|
+
* declares no inputs — absent means "this build reads no data", which is an
|
|
24
|
+
* answer, not a gap.
|
|
25
|
+
*/
|
|
26
|
+
export function assertDeclaredBuildInputs(
|
|
27
|
+
declaration: ProjectDeclaration = appDeclaration,
|
|
28
|
+
from: string = root,
|
|
29
|
+
): void {
|
|
30
|
+
for (const input of declaration.build?.inputs ?? []) {
|
|
31
|
+
const path = resolve(from, input.path);
|
|
32
|
+
let bytes: Buffer;
|
|
33
|
+
try {
|
|
34
|
+
bytes = readFileSync(path);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Declared build input "${input.name}" is missing: ${input.path}. A build input is a frozen export inside the source — restore it, or stop declaring it and read the data at runtime.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const actual = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
41
|
+
if (actual !== input.digest) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Declared build input "${input.name}" (${input.path}) no longer matches its digest.\n declared: ${input.digest}\n actual: ${actual}\nTwo builds of one source would produce different bytes. Re-freeze the export and update the digest in project.json, or drop the input and render the data at runtime.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (import.meta.main) {
|
|
50
|
+
assertDeclaredBuildInputs();
|
|
51
|
+
const declared = appDeclaration.build?.inputs ?? [];
|
|
52
|
+
console.log(
|
|
53
|
+
declared.length === 0
|
|
54
|
+
? 'This build reads no declared data.'
|
|
55
|
+
: `${declared.length} declared build input(s) match their digests.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -7,12 +7,13 @@ const rootFiles = ['playwright.config.ts', 'ecosystem.config.cjs', 'ecosystem.de
|
|
|
7
7
|
const processEnvMarker = ['process', 'env'].join('.');
|
|
8
8
|
const replacedThemePackage = ['next', 'themes'].join('-');
|
|
9
9
|
const generatedDirectories = new Set(['.git', '.next', 'dist', 'node_modules']);
|
|
10
|
+
// The supervision files used to read the environment directly to build an argv
|
|
11
|
+
// for the web role. They no longer do — a role reads its own bindings — so they
|
|
12
|
+
// are no longer environment boundaries, and this list is smaller by two.
|
|
10
13
|
const processEnvBoundaries = new Set([
|
|
11
14
|
'packages/frontend/src/env.ts',
|
|
12
15
|
'packages/config/src/server.ts',
|
|
13
16
|
'scripts/tooling-env.ts',
|
|
14
|
-
'ecosystem.config.cjs',
|
|
15
|
-
'ecosystem.dev.config.cjs',
|
|
16
17
|
]);
|
|
17
18
|
|
|
18
19
|
function lineAt(source: string, offset: number): number {
|
|
@@ -52,6 +53,21 @@ function inspect(path: string, source: string): string[] {
|
|
|
52
53
|
failures.push(
|
|
53
54
|
`${path}: server-only dependency ${dependency} crossed the browser boundary`,
|
|
54
55
|
);
|
|
56
|
+
// Two separate reasons meet on this line, and the second one is the
|
|
57
|
+
// quiet one. Shipping a database client to the browser is the obvious
|
|
58
|
+
// failure; the other is that a route reaching a data source makes the
|
|
59
|
+
// BUILD depend on data — bytes that are neither code nor a binding, so
|
|
60
|
+
// the artifact stops being a function of the source and starts being a
|
|
61
|
+
// function of whichever machine had the database. Three answers are
|
|
62
|
+
// legitimate, chosen per route: render at runtime (what this template
|
|
63
|
+
// does), declare a frozen export as `build.inputs` in `project.json`
|
|
64
|
+
// and let `scripts/build-inputs.ts` pin its digest, or generate the
|
|
65
|
+
// bytes as a release step.
|
|
66
|
+
if (path.startsWith('packages/frontend/src/app/')) {
|
|
67
|
+
failures.push(
|
|
68
|
+
`${path}: a route reading data makes the build depend on it — render at runtime, declare the export in build.inputs, or generate it as a release step`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { parse } from 'dotenv';
|
|
5
|
+
import { appDeclaration } from '../packages/config/src/declaration';
|
|
6
|
+
import {
|
|
7
|
+
assertSupervisionAllowsShutdown,
|
|
8
|
+
LOCAL_SUPERVISION,
|
|
9
|
+
renderAppIdentity,
|
|
10
|
+
renderEcosystem,
|
|
11
|
+
renderEnvVariables,
|
|
12
|
+
terminationBudgetMs,
|
|
13
|
+
} from './declaration';
|
|
14
|
+
import { ensureLocalEnvironment } from './local-env';
|
|
15
|
+
|
|
16
|
+
const root = resolve(import.meta.dir, '..');
|
|
17
|
+
const read = (name: string) => readFileSync(resolve(root, name), 'utf8');
|
|
18
|
+
|
|
19
|
+
describe('the declaration is the source of what is derived from it', () => {
|
|
20
|
+
test('the checked-in supervision files are exactly what the generator renders', () => {
|
|
21
|
+
// Byte-for-byte: nothing in these files is authored, so a difference is
|
|
22
|
+
// always a hand edit or a stale file — never a formatting opinion.
|
|
23
|
+
expect(read('ecosystem.config.cjs')).toBe(renderEcosystem(appDeclaration, 'production'));
|
|
24
|
+
expect(read('ecosystem.dev.config.cjs')).toBe(
|
|
25
|
+
renderEcosystem(appDeclaration, 'development'),
|
|
26
|
+
);
|
|
27
|
+
expect(read('packages/config/src/app-identity.generated.ts')).toBe(renderAppIdentity());
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('env.variables in the declaration matches the one environment schema', () => {
|
|
31
|
+
// Content, not bytes: roles are authored in this file and the formatter
|
|
32
|
+
// owns its shape — only the derived block has to agree.
|
|
33
|
+
expect(appDeclaration.env.variables).toEqual(renderEnvVariables());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('every declared variable appears once, with a shape a reader can act on', () => {
|
|
37
|
+
const derived = renderEnvVariables();
|
|
38
|
+
expect(new Set(derived.map((entry) => entry.name)).size).toBe(derived.length);
|
|
39
|
+
expect(derived).toContainEqual({ name: 'DATABASE_URL', shape: 'url', required: true });
|
|
40
|
+
// A variable with a default is NOT required — the schema decides, not this list.
|
|
41
|
+
expect(derived).toContainEqual({ name: 'BIND_HOST', shape: 'string', required: false });
|
|
42
|
+
// An enum names its members: "one of an unnamed set" is useless to the
|
|
43
|
+
// reader this list exists for.
|
|
44
|
+
expect(derived).toContainEqual({
|
|
45
|
+
name: 'LOG_FORMAT',
|
|
46
|
+
shape: 'enum',
|
|
47
|
+
required: false,
|
|
48
|
+
members: ['pretty', 'json'],
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('supervision may not be shorter than the code needs', () => {
|
|
54
|
+
test('the budget is the whole shutdown, not just the drain', () => {
|
|
55
|
+
for (const role of appDeclaration.roles) {
|
|
56
|
+
// Drain, then the force that follows it, then cleanup. Comparing against
|
|
57
|
+
// the drain alone is what let 15s + 5s meet a 20s kill timeout exactly.
|
|
58
|
+
expect(terminationBudgetMs(role)).toBeGreaterThan(role.drainFloorMs);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('the local policy allows every role its full shutdown', () => {
|
|
63
|
+
expect(() =>
|
|
64
|
+
assertSupervisionAllowsShutdown(appDeclaration, LOCAL_SUPERVISION.killTimeoutMs),
|
|
65
|
+
).not.toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a timeout that only covers the drain is refused', () => {
|
|
69
|
+
const floor = Math.max(...appDeclaration.roles.map((role) => role.drainFloorMs));
|
|
70
|
+
expect(() => assertSupervisionAllowsShutdown(appDeclaration, floor)).toThrow(
|
|
71
|
+
/killed mid-shutdown/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a timeout one millisecond under the budget is refused', () => {
|
|
76
|
+
const budget = Math.max(...appDeclaration.roles.map(terminationBudgetMs));
|
|
77
|
+
expect(() => assertSupervisionAllowsShutdown(appDeclaration, budget - 1)).toThrow();
|
|
78
|
+
expect(() => assertSupervisionAllowsShutdown(appDeclaration, budget)).not.toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('rendering a supervision file cannot bypass the rule', () => {
|
|
82
|
+
const impatient = {
|
|
83
|
+
...appDeclaration,
|
|
84
|
+
roles: appDeclaration.roles.map((role) => ({ ...role, drainFloorMs: 10 ** 9 })),
|
|
85
|
+
};
|
|
86
|
+
expect(() => renderEcosystem(impatient, 'production')).toThrow(/mid-shutdown/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('no repository file carries a value of the place', () => {
|
|
91
|
+
// The real property, checked the same way the frontend build is checked: no
|
|
92
|
+
// value that differs between two deployments may appear in a repository file.
|
|
93
|
+
// A kill timeout may — supervision policy is the place's, and for the manual
|
|
94
|
+
// path this repository IS the place, which is why it is one visible constant.
|
|
95
|
+
ensureLocalEnvironment(root);
|
|
96
|
+
const environment = parse(read('.env'));
|
|
97
|
+
const placementValues = Object.values(environment).filter(
|
|
98
|
+
(value) => /^\d+$/.test(value) || value.includes('://') || /\d+\.\d+\.\d+\.\d+/.test(value),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
test('the fixture actually contains ports and addresses to look for', () => {
|
|
102
|
+
expect(placementValues.length).toBeGreaterThan(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const repositoryFiles = [
|
|
106
|
+
'ecosystem.config.cjs',
|
|
107
|
+
'ecosystem.dev.config.cjs',
|
|
108
|
+
'project.json',
|
|
109
|
+
'packages/config/src/app-identity.generated.ts',
|
|
110
|
+
];
|
|
111
|
+
for (const name of repositoryFiles) {
|
|
112
|
+
test(`${name} names no port and no address`, () => {
|
|
113
|
+
const rendered = read(name);
|
|
114
|
+
for (const value of placementValues) expect(rendered).not.toInclude(value);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const name of ['ecosystem.config.cjs', 'ecosystem.dev.config.cjs']) {
|
|
119
|
+
test(`${name} says it is generated`, () => {
|
|
120
|
+
expect(read(name)).toStartWith('// GENERATED FILE — do not edit.');
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('a role starts its own process, never a launcher', () => {
|
|
126
|
+
// Measured, not assumed: under PM2 a `bun run <script>` command made the API
|
|
127
|
+
// role receive the stop signal twice — once from the supervisor, once
|
|
128
|
+
// forwarded by the launcher — and the second press forced the shutdown, which
|
|
129
|
+
// turned a declared 15s drain into 1.3ms. Direct exec: `Shutdown clean`.
|
|
130
|
+
// The schema refuses the shape; this checks what actually reaches PM2.
|
|
131
|
+
const modes: Array<'production' | 'development'> = ['production', 'development'];
|
|
132
|
+
|
|
133
|
+
for (const mode of modes) {
|
|
134
|
+
test(`the ${mode} supervision file execs the role directly`, () => {
|
|
135
|
+
const rendered = renderEcosystem(appDeclaration, mode)
|
|
136
|
+
.split('\n')
|
|
137
|
+
.filter((line) => !line.trimStart().startsWith('//'))
|
|
138
|
+
.join('\n');
|
|
139
|
+
expect(rendered).not.toMatch(/args: \["run"/);
|
|
140
|
+
expect(rendered).not.toContain('--filter');
|
|
141
|
+
// And the deployment's environment is not overruled by a repository file.
|
|
142
|
+
expect(rendered).not.toContain('override');
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('the drain floor has one home', () => {
|
|
148
|
+
test('the API role reads its grace period from the declaration', () => {
|
|
149
|
+
const source = read('packages/backend/src/index.ts');
|
|
150
|
+
// A literal here would be a second number able to disagree with the one a
|
|
151
|
+
// supervisor reads, which is exactly how a 30s floor met a 15s kill timeout.
|
|
152
|
+
expect(source).toContain('gracePeriodMs: apiRole.drainFloorMs');
|
|
153
|
+
expect(source).not.toMatch(/gracePeriodMs:\s*\d/);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('an argument survives the generator intact', () => {
|
|
158
|
+
test('a space and a quote reach the supervision file unmangled', () => {
|
|
159
|
+
// The reason commands are argv and the generator serialises rather than
|
|
160
|
+
// concatenates: `split(' ')` destroyed quoted arguments, and building
|
|
161
|
+
// `'${part}'` by hand emitted invalid JavaScript for an argument
|
|
162
|
+
// containing a quote.
|
|
163
|
+
const awkward = {
|
|
164
|
+
...appDeclaration,
|
|
165
|
+
roles: appDeclaration.roles.map((role) => ({
|
|
166
|
+
...role,
|
|
167
|
+
commands: {
|
|
168
|
+
...role.commands,
|
|
169
|
+
production: { executable: 'bun', args: ['run me.ts', "it's fine"] },
|
|
170
|
+
},
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const rendered = renderEcosystem(awkward, 'production');
|
|
175
|
+
expect(rendered).toContain('["run me.ts","it\'s fine"]');
|
|
176
|
+
|
|
177
|
+
// And it is still valid JavaScript: the file is `require`d by PM2.
|
|
178
|
+
expect(
|
|
179
|
+
() =>
|
|
180
|
+
new Function(
|
|
181
|
+
`return (${rendered.slice(rendered.indexOf('args: [')).slice(6).split(']')[0]}])`,
|
|
182
|
+
),
|
|
183
|
+
).not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('the guidance a next agent reads names the keys that exist', () => {
|
|
188
|
+
// `AGENTS.md` is not documentation about the past — it is the instruction the
|
|
189
|
+
// next agent follows. It kept pointing at `env.required` for a whole release
|
|
190
|
+
// after the key became `env.variables`, which is worse than a stale comment:
|
|
191
|
+
// the agent goes looking for something that is not there.
|
|
192
|
+
const guidance = ['AGENTS.md', 'README.md'];
|
|
193
|
+
|
|
194
|
+
for (const file of guidance) {
|
|
195
|
+
test(`${file} names no key the declaration does not have`, () => {
|
|
196
|
+
const text = readFileSync(resolve(import.meta.dir, '..', file), 'utf8');
|
|
197
|
+
const referenced = [...text.matchAll(/`(env|build|release|roles|identity)\.(\w+)`/g)];
|
|
198
|
+
const unknown = referenced.filter(([, root, key]) => {
|
|
199
|
+
const branch: unknown = Reflect.get(appDeclaration, root ?? '');
|
|
200
|
+
if (typeof branch !== 'object' || branch === null) return true;
|
|
201
|
+
return !Object.hasOwn(branch, key ?? '');
|
|
202
|
+
});
|
|
203
|
+
expect(unknown.map(([match]) => match)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|