create-stitchkit 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +243 -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 +15 -2
- package/template/README.md +51 -6
- package/template/_env.example +10 -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 -13
- package/template/ecosystem.dev.config.cjs +41 -15
- package/template/package.json +5 -4
- package/template/packages/backend/package.json +1 -1
- package/template/packages/backend/scripts/ensure-built.ts +7 -0
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +23 -7
- package/template/packages/backend/src/surface.ts +6 -1
- package/template/packages/backend/src/transport/errors.ts +4 -2
- package/template/packages/backend/tsconfig.json +1 -1
- 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 -14
- 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 +84 -15
- 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
|
@@ -3,10 +3,10 @@ import { createClient, createHttpClient } from 'stitchkit';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { runSurfaceConformance } from './surface-conformance';
|
|
5
5
|
import { loadToolingEnv } from './tooling-env';
|
|
6
|
-
import { assertPublicWebSurface } from './web-surface-smoke';
|
|
6
|
+
import { assertArtifactIsPlacementFree, assertPublicWebSurface } from './web-surface-smoke';
|
|
7
7
|
|
|
8
8
|
const toolingEnv = loadToolingEnv();
|
|
9
|
-
const apiOrigin = toolingEnv.
|
|
9
|
+
const apiOrigin = toolingEnv.SMOKE_API_ORIGIN;
|
|
10
10
|
|
|
11
11
|
async function json(path: string): Promise<unknown> {
|
|
12
12
|
const response = await fetch(`${apiOrigin}${path}`);
|
|
@@ -28,6 +28,9 @@ if (!Object.keys(openApi.paths).includes('/api/system/status')) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
await runSurfaceConformance({ apiOrigin });
|
|
31
|
-
await assertPublicWebSurface(toolingEnv.
|
|
31
|
+
await assertPublicWebSurface(toolingEnv.SMOKE_WEB_ORIGIN);
|
|
32
|
+
await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN);
|
|
32
33
|
|
|
33
|
-
console.log(
|
|
34
|
+
console.log(
|
|
35
|
+
'Runtime HTTP, typed client, OpenAPI, MCP, public web and placement-free artifact smoke passed',
|
|
36
|
+
);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The web role refuses a mode it does not understand.
|
|
6
|
+
*
|
|
7
|
+
* `argv[2] === 'development' ? 'dev' : 'start'` meant a typo, an empty string
|
|
8
|
+
* and a missing argument all became production in silence — the one decision
|
|
9
|
+
* that changes whether the role serves a build or compiles on demand. Run as a
|
|
10
|
+
* real process, because the failure has to happen before Next is spawned.
|
|
11
|
+
*/
|
|
12
|
+
const serve = join(import.meta.dir, '../packages/frontend/scripts/serve.ts');
|
|
13
|
+
|
|
14
|
+
async function refusal(argv: string[]): Promise<{ code: number; stderr: string }> {
|
|
15
|
+
const child = Bun.spawn(['bun', serve, ...argv], {
|
|
16
|
+
cwd: join(import.meta.dir, '../packages/frontend'),
|
|
17
|
+
env: { ...Bun.env, WEB_PORT: '3210', BIND_HOST: '127.0.0.1' },
|
|
18
|
+
stdout: 'ignore',
|
|
19
|
+
stderr: 'pipe',
|
|
20
|
+
});
|
|
21
|
+
const [stderr, code] = await Promise.all([new Response(child.stderr).text(), child.exited]);
|
|
22
|
+
return { code, stderr };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('a misspelled mode is refused, not treated as production', async () => {
|
|
26
|
+
const { code, stderr } = await refusal(['produciton']);
|
|
27
|
+
expect(code).not.toBe(0);
|
|
28
|
+
expect(stderr).toContain('Run mode must be "development" or "production"');
|
|
29
|
+
expect(stderr).toContain('produciton');
|
|
30
|
+
}, 30_000);
|
|
31
|
+
|
|
32
|
+
test('a missing mode is refused', async () => {
|
|
33
|
+
const { code, stderr } = await refusal([]);
|
|
34
|
+
expect(code).not.toBe(0);
|
|
35
|
+
expect(stderr).toContain('received nothing');
|
|
36
|
+
}, 30_000);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Why the supervision files run each role from its OWN directory.
|
|
8
|
+
*
|
|
9
|
+
* A supervisor that reaches into a workspace from the root — `bun run --filter
|
|
10
|
+
* @app/backend start` — puts a launcher process between itself and the role.
|
|
11
|
+
* `SIGTERM` sent to that launcher does not arrive at the role: the launcher
|
|
12
|
+
* dies and the role is torn down without ever running its shutdown. Every
|
|
13
|
+
* drain, every `kill_timeout`, every grace period is then decoration.
|
|
14
|
+
*
|
|
15
|
+
* This test spawns both shapes and compares what the role actually receives, so
|
|
16
|
+
* the choice in `scripts/declaration.ts` is pinned by behaviour rather than by
|
|
17
|
+
* a comment that a later refactor can talk itself out of.
|
|
18
|
+
*/
|
|
19
|
+
async function roleReceivesSignal(from: 'role directory' | 'workspace root'): Promise<boolean> {
|
|
20
|
+
const root = await mkdtemp(join(tmpdir(), 'starter-signal-'));
|
|
21
|
+
try {
|
|
22
|
+
const rolePath = join(root, 'role');
|
|
23
|
+
await Bun.$`mkdir -p ${rolePath}`.quiet();
|
|
24
|
+
await writeFile(
|
|
25
|
+
join(rolePath, 'role.ts'),
|
|
26
|
+
[
|
|
27
|
+
"process.on('SIGTERM', () => { console.log('DRAINED'); process.exit(0); });",
|
|
28
|
+
"console.log('UP');",
|
|
29
|
+
'setInterval(() => {}, 1000);',
|
|
30
|
+
].join('\n'),
|
|
31
|
+
);
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(rolePath, 'package.json'),
|
|
34
|
+
JSON.stringify({ name: '@t/role', scripts: { start: 'bun role.ts' } }),
|
|
35
|
+
);
|
|
36
|
+
await writeFile(
|
|
37
|
+
join(root, 'package.json'),
|
|
38
|
+
JSON.stringify({ name: 'root', private: true, workspaces: ['role'] }),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// `detached` makes the child a process-group LEADER, which is what lets the
|
|
42
|
+
// negative-pid signal below reach the role behind the launcher. The
|
|
43
|
+
// NEGATIVE case is the point: there the role never sees the signal, so
|
|
44
|
+
// killing the launcher alone would leave it running for the life of the
|
|
45
|
+
// machine — this test used to leak one role per run.
|
|
46
|
+
const child =
|
|
47
|
+
from === 'role directory'
|
|
48
|
+
? Bun.spawn(['bun', 'run', 'start'], {
|
|
49
|
+
cwd: rolePath,
|
|
50
|
+
detached: true,
|
|
51
|
+
stdout: 'pipe',
|
|
52
|
+
stderr: 'ignore',
|
|
53
|
+
})
|
|
54
|
+
: Bun.spawn(['bun', 'run', '--filter', '@t/role', 'start'], {
|
|
55
|
+
cwd: root,
|
|
56
|
+
detached: true,
|
|
57
|
+
stdout: 'pipe',
|
|
58
|
+
stderr: 'ignore',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const output = new Response(child.stdout).text();
|
|
62
|
+
await Bun.sleep(1500);
|
|
63
|
+
// The role first, exactly as a supervisor would; then the whole group, so
|
|
64
|
+
// nothing that ignored the request outlives the test.
|
|
65
|
+
child.kill('SIGTERM');
|
|
66
|
+
await child.exited;
|
|
67
|
+
const printed = await output;
|
|
68
|
+
try {
|
|
69
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
70
|
+
} catch {
|
|
71
|
+
// The group is already gone, which is the outcome this wants.
|
|
72
|
+
}
|
|
73
|
+
// Without this the negative case passes when the spawn simply failed —
|
|
74
|
+
// proving nothing about signal delivery, which is the only thing it exists
|
|
75
|
+
// to prove.
|
|
76
|
+
if (!printed.includes('UP')) {
|
|
77
|
+
throw new Error(`The role never started when launched from the ${from}: ${printed}`);
|
|
78
|
+
}
|
|
79
|
+
return printed.includes('DRAINED');
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(root, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('a role started in its own directory receives the shutdown signal', async () => {
|
|
86
|
+
expect(await roleReceivesSignal('role directory')).toBe(true);
|
|
87
|
+
}, 20_000);
|
|
88
|
+
|
|
89
|
+
test('a role started through a workspace filter never receives it', async () => {
|
|
90
|
+
// The reason `cwd` is per role in the generated supervision files. If this
|
|
91
|
+
// ever starts passing, the generator may be simplified — until then it may
|
|
92
|
+
// not.
|
|
93
|
+
expect(await roleReceivesSignal('workspace root')).toBe(false);
|
|
94
|
+
}, 20_000);
|
|
@@ -5,9 +5,12 @@ import { z } from 'zod';
|
|
|
5
5
|
import { ensureLocalEnvironment } from './local-env';
|
|
6
6
|
|
|
7
7
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
// Tooling addresses are LEGITIMATELY bound to a place — they name the deployment a
|
|
9
|
+
// check is dialling. They must not be named `NEXT_PUBLIC_*`, because that
|
|
10
|
+
// prefix makes Next substitute the value into the build output.
|
|
8
11
|
const ToolingEnvSchema = z.object({
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
SMOKE_API_ORIGIN: z.url(),
|
|
13
|
+
SMOKE_WEB_ORIGIN: z.url(),
|
|
11
14
|
PLAYWRIGHT_BASE_URL: z.url().optional(),
|
|
12
15
|
});
|
|
13
16
|
|
|
@@ -26,3 +26,73 @@ export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
|
|
|
26
26
|
throw new Error(`GET ${SITEMAP_PATH} omitted the localized theme-system URL`);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One artifact, many external addresses.
|
|
32
|
+
*
|
|
33
|
+
* The single check that catches a regression back into a build-time address: if
|
|
34
|
+
* anything is baked in again — a `NEXT_PUBLIC_` variable, a prerendered
|
|
35
|
+
* `sitemap.xml` — the two answers below stop differing, because the build would
|
|
36
|
+
* be answering with the address it was built with instead of the one it was
|
|
37
|
+
* asked on.
|
|
38
|
+
*/
|
|
39
|
+
export async function assertArtifactIsPlacementFree(webOrigin: string): Promise<void> {
|
|
40
|
+
const addresses = [
|
|
41
|
+
{ host: 'alpha.example', proto: 'https' },
|
|
42
|
+
{ host: 'beta.example:8443', proto: 'http' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
for (const { host, proto } of addresses) {
|
|
46
|
+
const expected = `${proto}://${host}`;
|
|
47
|
+
const headers = { 'x-forwarded-host': host, 'x-forwarded-proto': proto };
|
|
48
|
+
|
|
49
|
+
const sitemap = await fetch(publicUrl(webOrigin, SITEMAP_PATH), { headers });
|
|
50
|
+
if (sitemap.status !== 200) {
|
|
51
|
+
throw new Error(`GET ${SITEMAP_PATH} as ${expected} returned ${sitemap.status}`);
|
|
52
|
+
}
|
|
53
|
+
const body = await sitemap.text();
|
|
54
|
+
if (!body.includes(`${expected}/en`)) {
|
|
55
|
+
throw new Error(`GET ${SITEMAP_PATH} as ${expected} did not answer with that address`);
|
|
56
|
+
}
|
|
57
|
+
if (body.includes(new URL(webOrigin).host)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`GET ${SITEMAP_PATH} as ${expected} leaked the address the build was made on — something is baked into the artifact`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const robots = await fetch(publicUrl(webOrigin, '/robots.txt'), { headers });
|
|
64
|
+
if (!(await robots.text()).includes(`Sitemap: ${expected}/sitemap.xml`)) {
|
|
65
|
+
throw new Error(`GET /robots.txt as ${expected} did not answer with that address`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Portable is not the same as forgeable. A host the deployment never claimed
|
|
70
|
+
// must be refused, or the same mechanism that lets one artifact serve many
|
|
71
|
+
// addresses lets a caller choose the canonical URL, the sitemap and the OG
|
|
72
|
+
// metadata for everyone else.
|
|
73
|
+
const forged = await fetch(publicUrl(webOrigin, SITEMAP_PATH), {
|
|
74
|
+
headers: { 'x-forwarded-host': 'attacker.example', 'x-forwarded-proto': 'https' },
|
|
75
|
+
});
|
|
76
|
+
if (forged.status < 400) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`GET ${SITEMAP_PATH} answered for an unclaimed host with ${forged.status} — the forwarded host is trusted without a policy`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Same rule, one layer down. A forwarded protocol outside `http | https` is a
|
|
83
|
+
// misconfigured proxy or a forgery, and both have to be visible: mapping
|
|
84
|
+
// every unknown value to `http` produced a plausible canonical origin from
|
|
85
|
+
// `ftp`, `javascript` and a truncated header alike.
|
|
86
|
+
for (const proto of ['ftp', 'javascript', 'HTTPS://', '']) {
|
|
87
|
+
const answered = await fetch(publicUrl(webOrigin, SITEMAP_PATH), {
|
|
88
|
+
headers: { 'x-forwarded-host': 'alpha.example', 'x-forwarded-proto': proto },
|
|
89
|
+
});
|
|
90
|
+
// An empty header carries no claim at all and is treated as absent.
|
|
91
|
+
const acceptable = proto === '' ? answered.status === 200 : answered.status >= 400;
|
|
92
|
+
if (!acceptable) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`GET ${SITEMAP_PATH} with x-forwarded-proto "${proto}" returned ${answered.status} — an unknown protocol is normalised instead of refused`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
package/template/app.config.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import source from '../../../app.config.json' with { type: 'json' };
|
|
3
|
-
|
|
4
|
-
export const ApplicationIdentitySchema = z.object({
|
|
5
|
-
slug: z
|
|
6
|
-
.string()
|
|
7
|
-
.min(1)
|
|
8
|
-
.max(64)
|
|
9
|
-
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
10
|
-
name: z.string().trim().min(1).max(80),
|
|
11
|
-
version: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
12
|
-
description: z.object({
|
|
13
|
-
en: z.string().trim().min(1),
|
|
14
|
-
ru: z.string().trim().min(1),
|
|
15
|
-
}),
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
export const appIdentity = ApplicationIdentitySchema.parse(source);
|