create-stitchkit 0.3.0 → 0.3.2
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 +32 -0
- package/examples/repository/_env.example.append +2 -0
- package/examples/repository/e2e/repository.spec.ts +32 -8
- package/examples/repository/packages/backend/src/domain/errors.ts +2 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -3
- package/examples/repository/packages/config/src/features.ts +1 -0
- package/examples/repository/packages/frontend/src/components/repository-summary.tsx +2 -2
- package/examples/repository/scripts/runtime-smoke.ts +4 -1
- package/package.json +1 -1
- package/template/bun.lock +2 -2
- package/template/e2e/starter.spec.ts +4 -13
- package/template/package.json +1 -1
- package/template/packages/backend/src/cli.ts +1 -1
- package/template/packages/backend/src/index.ts +22 -11
- package/template/packages/backend/src/tools.ts +1 -1
- package/template/scripts/runtime-smoke.ts +5 -2
- package/template/scripts/surface-conformance.ts +1 -1
- package/template/scripts/surface-snapshot.ts +1 -1
- package/template/scripts/web-surface-smoke.ts +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ is declared in the template root catalog.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.2] — 2026-08-17
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **The template targets Stitchkit 0.50.0 and binds process signals through the
|
|
14
|
+
framework.** Generated backends replace the hand-written shutdown coordinator
|
|
15
|
+
with `bindProcessSignals(server, …)`: MCP and Prisma close in `onComplete`, the
|
|
16
|
+
exit code is set there, and failures are reported by phase. The manual version
|
|
17
|
+
the template used to ship reported a failing `mcp.close()` as a failed
|
|
18
|
+
shutdown, did nothing on a third signal, and collapsed the grace period when a
|
|
19
|
+
supervisor delivered two signals at once.
|
|
20
|
+
- **The repository example declares its domain error message once.**
|
|
21
|
+
`GITHUB_UNAVAILABLE` carries its text in the `defineErrors` definition instead
|
|
22
|
+
of repeating it at the throw site.
|
|
23
|
+
|
|
24
|
+
## [0.3.1] — 2026-08-15
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **The template targets Stitchkit 0.49.2 and owns one managed server lifecycle.**
|
|
29
|
+
Generated backends pass the full Socket.IO handle to `createServer()`, close
|
|
30
|
+
through `server.shutdown()`, and use a repeated process signal to force the
|
|
31
|
+
same shutdown chain instead of creating a competing close path.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Repository refresh and realtime verification are deterministic.** A successful
|
|
36
|
+
refresh updates the initiating browser from its typed mutation result, remote
|
|
37
|
+
browsers still update through the Socket.IO cache bridge, and the release lane
|
|
38
|
+
exercises the complete backend path against a local GitHub-compatible upstream
|
|
39
|
+
instead of depending on public API availability.
|
|
40
|
+
|
|
9
41
|
## [0.3.0] — 2026-08-10
|
|
10
42
|
|
|
11
43
|
### ⚠️ Breaking changes
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
GITHUB_REPOSITORY=max-listov/stitchkit
|
|
2
2
|
GITHUB_CACHE_TTL_SECONDS=900
|
|
3
|
+
# Optional. Defaults to the public GitHub API and supports GitHub Enterprise.
|
|
4
|
+
# GITHUB_API_URL=https://api.github.com
|
|
3
5
|
# Optional. Authenticated conditional requests have a higher GitHub rate limit.
|
|
4
6
|
# GITHUB_TOKEN=github_pat_...
|
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
import { expect, test } from '@playwright/test';
|
|
1
|
+
import { expect, type Page, test } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
async function gotoWithRealtime(page: Page): Promise<void> {
|
|
4
|
+
const connected = page
|
|
5
|
+
.waitForEvent('websocket', {
|
|
6
|
+
predicate: (socket) => socket.url().includes('/socket.io/'),
|
|
7
|
+
})
|
|
8
|
+
.then((socket) =>
|
|
9
|
+
socket.waitForEvent('framereceived', {
|
|
10
|
+
predicate: ({ payload }) => typeof payload === 'string' && payload.startsWith('40'),
|
|
11
|
+
}),
|
|
12
|
+
);
|
|
13
|
+
await page.goto('/en');
|
|
14
|
+
await connected;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function expectRepositorySummary(page: Page): Promise<void> {
|
|
18
|
+
const summary = page.getByTestId('repository-summary');
|
|
19
|
+
await expect(summary).toHaveCount(1);
|
|
20
|
+
await expect(summary).toBeVisible();
|
|
21
|
+
await expect(summary.getByText('max-listov/stitchkit', { exact: true })).toBeVisible();
|
|
22
|
+
}
|
|
2
23
|
|
|
3
24
|
test('prefetched data hydrates without a loading flash or a client refetch', async ({
|
|
4
25
|
page,
|
|
@@ -21,22 +42,23 @@ test('prefetched data hydrates without a loading flash or a client refetch', asy
|
|
|
21
42
|
await route.continue();
|
|
22
43
|
});
|
|
23
44
|
await page.goto('/en');
|
|
24
|
-
await
|
|
45
|
+
await expectRepositorySummary(page);
|
|
25
46
|
expect(clientReads).toBe(0);
|
|
26
47
|
});
|
|
27
48
|
|
|
28
49
|
test('a server realtime event updates the TanStack cache without a client refetch', async ({
|
|
50
|
+
context,
|
|
29
51
|
page,
|
|
30
52
|
}) => {
|
|
31
|
-
await page
|
|
53
|
+
await gotoWithRealtime(page);
|
|
32
54
|
const summary = page.getByTestId('repository-summary');
|
|
33
55
|
await expect(summary).toBeVisible();
|
|
34
56
|
const before = await summary.getAttribute('data-fetched-at');
|
|
35
57
|
expect(before).not.toBeNull();
|
|
36
58
|
|
|
37
|
-
// Sever the
|
|
38
|
-
//
|
|
39
|
-
//
|
|
59
|
+
// Sever the observer's HTTP read path completely, then refresh from a second
|
|
60
|
+
// tab. The observer never invokes the mutation and cannot consume its HTTP
|
|
61
|
+
// result, so only the Socket.IO event can update its TanStack cache.
|
|
40
62
|
await page.route('**/api/repository', async (route) => {
|
|
41
63
|
if (route.request().method() === 'GET') {
|
|
42
64
|
await route.abort();
|
|
@@ -44,7 +66,9 @@ test('a server realtime event updates the TanStack cache without a client refetc
|
|
|
44
66
|
}
|
|
45
67
|
await route.continue();
|
|
46
68
|
});
|
|
47
|
-
|
|
69
|
+
const trigger = await context.newPage();
|
|
70
|
+
await trigger.goto('/en');
|
|
71
|
+
await trigger.getByRole('button', { name: 'Refresh repository data' }).click();
|
|
48
72
|
await expect(summary).not.toHaveAttribute('data-fetched-at', before ?? '', {
|
|
49
73
|
timeout: 10_000,
|
|
50
74
|
});
|
|
@@ -56,7 +80,7 @@ test('renders and refreshes the repository example', async ({ page }) => {
|
|
|
56
80
|
await route.continue();
|
|
57
81
|
});
|
|
58
82
|
await page.goto('/en');
|
|
59
|
-
await
|
|
83
|
+
await expectRepositorySummary(page);
|
|
60
84
|
|
|
61
85
|
const refresh = page.getByRole('button', { name: 'Refresh repository data' });
|
|
62
86
|
await expect(refresh).toHaveCSS('height', '32px');
|
|
@@ -4,6 +4,8 @@ import { z } from 'zod';
|
|
|
4
4
|
export const { errors: domainErrors, codes: domainErrorCodes } = defineErrors({
|
|
5
5
|
GITHUB_UNAVAILABLE: {
|
|
6
6
|
status: 503,
|
|
7
|
+
// Declared once here instead of at every throw site.
|
|
8
|
+
message: 'GitHub repository data is temporarily unavailable',
|
|
7
9
|
details: z.object({ cause: z.string() }),
|
|
8
10
|
},
|
|
9
11
|
});
|
|
@@ -155,12 +155,12 @@ export class GitHubRepositoryCache {
|
|
|
155
155
|
|
|
156
156
|
try {
|
|
157
157
|
const [repositoryResponse, commitsResponse] = await Promise.all([
|
|
158
|
-
this.fetcher(new URL(`/repos/${repositoryName}`,
|
|
158
|
+
this.fetcher(new URL(`/repos/${repositoryName}`, env.GITHUB_API_URL), {
|
|
159
159
|
headers: githubHeaders(),
|
|
160
160
|
signal: AbortSignal.timeout(8_000),
|
|
161
161
|
}),
|
|
162
162
|
this.fetcher(
|
|
163
|
-
new URL(`/repos/${repositoryName}/commits?per_page=1`,
|
|
163
|
+
new URL(`/repos/${repositoryName}/commits?per_page=1`, env.GITHUB_API_URL),
|
|
164
164
|
{ headers: githubHeaders(), signal: AbortSignal.timeout(8_000) },
|
|
165
165
|
),
|
|
166
166
|
]);
|
|
@@ -197,7 +197,6 @@ export class GitHubRepositoryCache {
|
|
|
197
197
|
} catch (error) {
|
|
198
198
|
if (previous) return serialize(previous, 'stale');
|
|
199
199
|
throw domainErrors.GITHUB_UNAVAILABLE({
|
|
200
|
-
message: 'GitHub repository data is temporarily unavailable',
|
|
201
200
|
details: { cause: error instanceof Error ? error.message : 'Unknown upstream error' },
|
|
202
201
|
hint: 'Retry after the upstream service recovers',
|
|
203
202
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
export const featureServerSchema = {
|
|
4
|
+
GITHUB_API_URL: z.url().default('https://api.github.com'),
|
|
4
5
|
GITHUB_REPOSITORY: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
|
|
5
6
|
GITHUB_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(900),
|
|
6
7
|
GITHUB_TOKEN: z.string().min(1).optional(),
|
|
@@ -30,8 +30,8 @@ export function RepositorySummary() {
|
|
|
30
30
|
const queryClient = useQueryClient();
|
|
31
31
|
const repository = useRepository();
|
|
32
32
|
const refresh = useRefreshRepository({
|
|
33
|
-
onSuccess:
|
|
34
|
-
|
|
33
|
+
onSuccess: (snapshot) => {
|
|
34
|
+
queryClient.setQueryData(useRepository.getKey(), snapshot);
|
|
35
35
|
},
|
|
36
36
|
});
|
|
37
37
|
|
|
@@ -3,6 +3,7 @@ import { createRealtimeClient, defineRealtimeContract } from 'stitchkit';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { defineSurfaceProbe, runSurfaceConformance } from './surface-conformance';
|
|
5
5
|
import { loadToolingEnv } from './tooling-env';
|
|
6
|
+
import { assertPublicWebSurface } from './web-surface-smoke';
|
|
6
7
|
|
|
7
8
|
const toolingEnv = loadToolingEnv();
|
|
8
9
|
const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
|
|
@@ -166,4 +167,6 @@ await runSurfaceConformance({
|
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
|
|
170
|
+
await assertPublicWebSurface(toolingEnv.NEXT_PUBLIC_WEB_URL);
|
|
171
|
+
|
|
172
|
+
console.log('Runtime HTTP, OpenAPI, Socket.IO, MCP and public web smoke passed');
|
package/package.json
CHANGED
package/template/bun.lock
CHANGED
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
},
|
|
140
140
|
"catalog": {
|
|
141
|
-
"stitchkit": "^0.
|
|
141
|
+
"stitchkit": "^0.50.0",
|
|
142
142
|
},
|
|
143
143
|
"packages": {
|
|
144
144
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="],
|
|
@@ -1117,7 +1117,7 @@
|
|
|
1117
1117
|
|
|
1118
1118
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
|
1119
1119
|
|
|
1120
|
-
"stitchkit": ["stitchkit@0.
|
|
1120
|
+
"stitchkit": ["stitchkit@0.50.0", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-lMadqgQ9aryAhULyA0biVjx9bn86HOUhcUj809TyHy2MuUZSnL9qI4fiByQbVAqlxW6i1a1RLAeguEJeu6jwWg=="],
|
|
1121
1121
|
|
|
1122
1122
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
|
1123
1123
|
|
|
@@ -29,10 +29,7 @@ test('calls the live backend through the typed contract client', async () => {
|
|
|
29
29
|
await expect(client.status()).resolves.toEqual({ status: 'ok' });
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test('publishes complete page metadata
|
|
33
|
-
page,
|
|
34
|
-
request,
|
|
35
|
-
}) => {
|
|
32
|
+
test('publishes complete page metadata', async ({ page }) => {
|
|
36
33
|
await page.goto('/en/ui/themes');
|
|
37
34
|
await expect(page).toHaveTitle(`Theme system · ${appIdentity.name}`);
|
|
38
35
|
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
|
@@ -45,15 +42,9 @@ test('publishes complete page metadata and a reachable Open Graph card', async (
|
|
|
45
42
|
);
|
|
46
43
|
|
|
47
44
|
const imageUrl = await page.locator('meta[property="og:image"]').getAttribute('content');
|
|
48
|
-
expect(imageUrl).
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
expect(imageResponse.status()).toBe(200);
|
|
52
|
-
expect(imageResponse.headers()['content-type']).toContain('image/png');
|
|
53
|
-
|
|
54
|
-
const sitemapResponse = await request.get('/sitemap.xml');
|
|
55
|
-
expect(sitemapResponse.status()).toBe(200);
|
|
56
|
-
expect(await sitemapResponse.text()).toContain('/ru/ui/themes');
|
|
45
|
+
expect(imageUrl).toBe(
|
|
46
|
+
new URL('/api/og/en/themes', toolingEnv.NEXT_PUBLIC_WEB_URL).toString(),
|
|
47
|
+
);
|
|
57
48
|
});
|
|
58
49
|
|
|
59
50
|
test('switches catalogue sections and component tabs', async ({ page }) => {
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { env } from '@app/config';
|
|
2
2
|
import { appIdentity } from '@app/config/identity';
|
|
3
3
|
import { wrapInRequestContext } from 'stitchkit/observability';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
bindProcessSignals,
|
|
6
|
+
createServer,
|
|
7
|
+
generateOpenApiDocument,
|
|
8
|
+
openApiRoute,
|
|
9
|
+
} from 'stitchkit/server';
|
|
5
10
|
import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools';
|
|
6
11
|
import { prisma } from './lib/db';
|
|
7
12
|
import { createSurface } from './surface';
|
|
@@ -26,9 +31,8 @@ async function main(): Promise<void> {
|
|
|
26
31
|
cors: { origin: env.CORS_ORIGIN },
|
|
27
32
|
hooks: { onError },
|
|
28
33
|
logging: { format: env.LOG_FORMAT },
|
|
29
|
-
|
|
34
|
+
socket,
|
|
30
35
|
rawRoutes: [
|
|
31
|
-
socket.route,
|
|
32
36
|
openApiRoute('/openapi.json', openApi),
|
|
33
37
|
createMcpHttpRoute({ path: '/mcp', handler: mcp }),
|
|
34
38
|
{
|
|
@@ -40,15 +44,22 @@ async function main(): Promise<void> {
|
|
|
40
44
|
wrapFetch: (fetch) => wrapInRequestContext(fetch),
|
|
41
45
|
});
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// The server owns HTTP and Socket.IO; MCP, Prisma and the exit code are the
|
|
48
|
+
// application's and close after the drain. A second signal forces this same
|
|
49
|
+
// shutdown, a third hands the signal back to its default disposition.
|
|
50
|
+
bindProcessSignals(server, {
|
|
51
|
+
shutdown: { gracePeriodMs: 30_000 },
|
|
52
|
+
onComplete: async (result) => {
|
|
53
|
+
await mcp.close();
|
|
54
|
+
await prisma.$disconnect();
|
|
55
|
+
process.exitCode = result.outcome === 'clean' ? 0 : 1;
|
|
56
|
+
},
|
|
57
|
+
onError: (phase, error) => {
|
|
58
|
+
console.error(`Shutdown failed during ${phase}`, error);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
49
62
|
|
|
50
|
-
process.once('SIGTERM', shutdown);
|
|
51
|
-
process.once('SIGINT', shutdown);
|
|
52
63
|
console.log(`API listening on http://127.0.0.1:${env.API_PORT}`);
|
|
53
64
|
}
|
|
54
65
|
|
|
@@ -3,8 +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
7
|
|
|
7
|
-
const
|
|
8
|
+
const toolingEnv = loadToolingEnv();
|
|
9
|
+
const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
|
|
8
10
|
|
|
9
11
|
async function json(path: string): Promise<unknown> {
|
|
10
12
|
const response = await fetch(`${apiOrigin}${path}`);
|
|
@@ -26,5 +28,6 @@ if (!Object.keys(openApi.paths).includes('/api/system/status')) {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
await runSurfaceConformance({ apiOrigin });
|
|
31
|
+
await assertPublicWebSurface(toolingEnv.NEXT_PUBLIC_WEB_URL);
|
|
29
32
|
|
|
30
|
-
console.log('Runtime HTTP, typed client, OpenAPI and
|
|
33
|
+
console.log('Runtime HTTP, typed client, OpenAPI, MCP and public web smoke passed');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const OG_IMAGE_PATH = '/api/og/en/themes';
|
|
2
|
+
const SITEMAP_PATH = '/sitemap.xml';
|
|
3
|
+
|
|
4
|
+
function publicUrl(origin: string, path: string): URL {
|
|
5
|
+
return new URL(path, new URL(origin).origin);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
|
|
9
|
+
const image = await fetch(publicUrl(webOrigin, OG_IMAGE_PATH));
|
|
10
|
+
if (image.status !== 200) {
|
|
11
|
+
throw new Error(`GET ${OG_IMAGE_PATH} returned ${image.status}`);
|
|
12
|
+
}
|
|
13
|
+
const imageType = image.headers.get('content-type');
|
|
14
|
+
if (!imageType?.toLowerCase().startsWith('image/png')) {
|
|
15
|
+
throw new Error(`GET ${OG_IMAGE_PATH} returned ${imageType ?? 'no content type'}`);
|
|
16
|
+
}
|
|
17
|
+
if ((await image.arrayBuffer()).byteLength === 0) {
|
|
18
|
+
throw new Error(`GET ${OG_IMAGE_PATH} returned an empty image`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sitemap = await fetch(publicUrl(webOrigin, SITEMAP_PATH));
|
|
22
|
+
if (sitemap.status !== 200) {
|
|
23
|
+
throw new Error(`GET ${SITEMAP_PATH} returned ${sitemap.status}`);
|
|
24
|
+
}
|
|
25
|
+
if (!(await sitemap.text()).includes('/ru/ui/themes')) {
|
|
26
|
+
throw new Error(`GET ${SITEMAP_PATH} omitted the localized theme-system URL`);
|
|
27
|
+
}
|
|
28
|
+
}
|