create-stitchkit 0.3.0 → 0.3.1

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,23 @@ is declared in the template root catalog.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1] — 2026-08-15
10
+
11
+ ### Changed
12
+
13
+ - **The template targets Stitchkit 0.49.2 and owns one managed server lifecycle.**
14
+ Generated backends pass the full Socket.IO handle to `createServer()`, close
15
+ through `server.shutdown()`, and use a repeated process signal to force the
16
+ same shutdown chain instead of creating a competing close path.
17
+
18
+ ### Fixed
19
+
20
+ - **Repository refresh and realtime verification are deterministic.** A successful
21
+ refresh updates the initiating browser from its typed mutation result, remote
22
+ browsers still update through the Socket.IO cache bridge, and the release lane
23
+ exercises the complete backend path against a local GitHub-compatible upstream
24
+ instead of depending on public API availability.
25
+
9
26
  ## [0.3.0] — 2026-08-10
10
27
 
11
28
  ### ⚠️ 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');
@@ -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
  ]);
@@ -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.1",
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.49.2",
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.49.2", "", { "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-QP0I//fs/khC+bTqYungjpZskAseQAB/Lm8UOx3mZDx+rmYtdsBhlAn0I0hBYE/du9jag5rE032s9HrZgT2VXg=="],
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.49.2"
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
  }
@@ -26,9 +26,8 @@ async function main(): Promise<void> {
26
26
  cors: { origin: env.CORS_ORIGIN },
27
27
  hooks: { onError },
28
28
  logging: { format: env.LOG_FORMAT },
29
- websocket: socket.websocket,
29
+ socket,
30
30
  rawRoutes: [
31
- socket.route,
32
31
  openApiRoute('/openapi.json', openApi),
33
32
  createMcpHttpRoute({ path: '/mcp', handler: mcp }),
34
33
  {
@@ -40,15 +39,33 @@ async function main(): Promise<void> {
40
39
  wrapFetch: (fetch) => wrapInRequestContext(fetch),
41
40
  });
42
41
 
43
- async function shutdown(): Promise<void> {
44
- server.stop();
45
- await mcp.close();
46
- await socket.io.close();
47
- await prisma.$disconnect();
42
+ const shutdownController = new AbortController();
43
+ let shutdownPromise: Promise<void> | undefined;
44
+
45
+ function shutdown(): Promise<void> {
46
+ if (shutdownPromise) {
47
+ shutdownController.abort();
48
+ return shutdownPromise;
49
+ }
50
+
51
+ shutdownPromise = (async () => {
52
+ await server.shutdown({
53
+ gracePeriodMs: 30_000,
54
+ signal: shutdownController.signal,
55
+ });
56
+ await mcp.close();
57
+ await prisma.$disconnect();
58
+ })();
59
+ return shutdownPromise;
48
60
  }
49
61
 
50
- process.once('SIGTERM', shutdown);
51
- process.once('SIGINT', shutdown);
62
+ const onSignal = () =>
63
+ void shutdown().catch((error: unknown) => {
64
+ console.error('Shutdown failed', error);
65
+ });
66
+
67
+ process.on('SIGTERM', onSignal);
68
+ process.on('SIGINT', onSignal);
52
69
  console.log(`API listening on http://127.0.0.1:${env.API_PORT}`);
53
70
  }
54
71
 
@@ -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
+ }