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 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 expect(page.getByText('max-listov/stitchkit')).toBeVisible();
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.goto('/en');
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 refetch path completely: the refresh mutation invalidates the
38
- // query, but its refetch is aborted here — so the ONLY way the summary can
39
- // carry a new snapshot is the Socket.IO event through the cache bridge.
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
- await page.getByRole('button', { name: 'Refresh repository data' }).click();
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 expect(page.getByText('max-listov/stitchkit')).toBeVisible();
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}`, 'https://api.github.com'), {
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`, 'https://api.github.com'),
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: async () => {
34
- await queryClient.invalidateQueries({ queryKey: useRepository.getKey() });
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
- console.log('Runtime HTTP, OpenAPI, Socket.IO and MCP smoke passed');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
package/template/bun.lock CHANGED
@@ -138,7 +138,7 @@
138
138
  },
139
139
  },
140
140
  "catalog": {
141
- "stitchkit": "^0.46.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.46.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-vHFaXHp5Ws4sFSRptqPqwpc7CGEEbmHY+duvvGkNGwuyp51aY4De3bXVh1xiZSGiUN5IS0jhj1P59GG3VjP/Ag=="],
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 and a reachable Open Graph card', async ({
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).not.toBeNull();
49
- if (!imageUrl) throw new Error('Open Graph image URL is missing');
50
- const imageResponse = await request.get(imageUrl);
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 }) => {
@@ -7,7 +7,7 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.46.0"
10
+ "stitchkit": "^0.50.0"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "bun scripts/dev.ts",
@@ -9,5 +9,5 @@ const { services, socket } = await createSurface();
9
9
  try {
10
10
  await createCli({ name: appIdentity.slug, version: appIdentity.version, services });
11
11
  } finally {
12
- await socket.io.close();
12
+ await socket.close();
13
13
  }
@@ -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 { createServer, generateOpenApiDocument, openApiRoute } from 'stitchkit/server';
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
- websocket: socket.websocket,
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
- async function shutdown(): Promise<void> {
44
- server.stop();
45
- await mcp.close();
46
- await socket.io.close();
47
- await prisma.$disconnect();
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
 
@@ -20,5 +20,5 @@ try {
20
20
  ),
21
21
  );
22
22
  } finally {
23
- await socket.io.close();
23
+ await socket.close();
24
24
  }
@@ -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 apiOrigin = loadToolingEnv().NEXT_PUBLIC_API_URL;
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 MCP smoke passed');
33
+ console.log('Runtime HTTP, typed client, OpenAPI, MCP and public web smoke passed');
@@ -134,7 +134,7 @@ export async function runSurfaceConformance({
134
134
  }
135
135
  }
136
136
  } finally {
137
- await socket.io.close();
137
+ await socket.close();
138
138
  }
139
139
  }
140
140
 
@@ -17,5 +17,5 @@ try {
17
17
  await writeFile(target, `${JSON.stringify(manifest, null, 2)}\n`);
18
18
  console.log(`Wrote ${manifest.length} operation(s) to ${SURFACE_SNAPSHOT_PATH}`);
19
19
  } finally {
20
- await socket.io.close();
20
+ await socket.close();
21
21
  }
@@ -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
+ }