create-stitchkit 0.2.0 → 0.3.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 +60 -0
- package/dist/cli.js +7 -16
- package/examples/repository/e2e/repository.spec.ts +50 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
- package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
- package/examples/repository/packages/backend/src/surface.ts +7 -5
- package/examples/repository/packages/frontend/src/components/repository-summary.tsx +5 -1
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +5 -6
- package/examples/repository/packages/shared/src/index.ts +3 -1
- package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
- package/examples/repository/packages/shared/src/schemas/repository.ts +4 -2
- package/examples/repository/scripts/runtime-smoke.ts +96 -18
- package/package.json +1 -1
- package/template/README.md +6 -10
- package/template/_env.example +1 -1
- package/template/bun.lock +4 -4
- package/template/docs/ADDING_A_FEATURE.md +3 -2
- package/template/e2e/starter.spec.ts +17 -0
- package/template/ecosystem.dev.config.cjs +0 -12
- package/template/package.json +5 -3
- package/template/packages/backend/src/index.ts +1 -15
- package/template/packages/backend/src/surface-manifest.test.ts +183 -8
- package/template/packages/backend/src/surface-manifest.ts +108 -5
- package/template/packages/backend/src/surface.snapshot.json +18 -0
- package/template/packages/backend/src/surface.ts +2 -1
- package/template/packages/backend/src/tools.ts +4 -1
- package/template/packages/backend/src/transport/errors.ts +1 -0
- package/template/packages/backend/src/transport/system-service.ts +8 -0
- package/template/packages/config/src/server.ts +1 -4
- package/template/packages/frontend/package.json +0 -1
- package/template/packages/frontend/src/lib/query-client.test.ts +22 -0
- package/template/packages/frontend/src/lib/query-client.ts +10 -2
- package/template/packages/frontend/tsconfig.json +7 -1
- package/template/packages/shared/package.json +0 -1
- package/template/packages/shared/src/contracts/system.ts +19 -0
- package/template/packages/shared/src/index.ts +2 -1
- package/template/packages/shared/src/schemas/system.ts +4 -0
- package/template/playwright.config.ts +2 -1
- package/template/scripts/check-authored.ts +20 -6
- package/template/scripts/dev.ts +19 -10
- package/template/scripts/local-env.test.ts +33 -0
- package/template/scripts/local-env.ts +16 -5
- package/template/scripts/runtime-smoke.ts +12 -5
- package/template/scripts/surface-conformance.ts +76 -15
- package/template/scripts/surface-snapshot.ts +21 -0
- package/template/scripts/tooling-env.ts +16 -3
- package/template/tsconfig.json +3 -1
- package/examples/repository/_env.append +0 -3
- package/examples/repository/packages/shared/src/events/repository.ts +0 -9
- package/template/_env +0 -9
- package/template/docs/LAN_HTTPS.md +0 -28
- package/template/packages/backend/src/transport/lan-onboarding.ts +0 -29
- package/template/scripts/dev-lan.test.ts +0 -58
- package/template/scripts/dev-lan.ts +0 -176
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,66 @@ is declared in the template root catalog.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] — 2026-08-10
|
|
10
|
+
|
|
11
|
+
### ⚠️ Breaking changes
|
|
12
|
+
|
|
13
|
+
- **Generated projects ship no `.env`.** The single environment source is
|
|
14
|
+
`.env.example`; `bun run env:ensure` (and every tooling entry point,
|
|
15
|
+
self-healing before validation) renders `.env` with the application-derived
|
|
16
|
+
database name. A clone and a post-generation rename now produce the same
|
|
17
|
+
database, and the second-developer path (`runtime:smoke`, `e2e`) works
|
|
18
|
+
unaided.
|
|
19
|
+
`// before: scaffold writes .env with a neutral database name` →
|
|
20
|
+
`// after: .env is rendered on first run from .env.example`
|
|
21
|
+
|
|
22
|
+
- **Starter-owned LAN HTTPS is removed.** Generated applications no longer ship
|
|
23
|
+
`dev:lan`, certificate generation, onboarding routes or `DEV_HTTPS_*` settings.
|
|
24
|
+
Applications that need a trusted device-testing origin should own certificate
|
|
25
|
+
creation and pass TLS files through Stitchkit's documented `bun.tls` boundary.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Surface conformance is anchored and total.** The declared surface is
|
|
30
|
+
compared against a committed `surface.snapshot.json` (with schema-shape
|
|
31
|
+
digests, regenerated deliberately via `bun run surface:snapshot`), the CLI is
|
|
32
|
+
observed by spawning the real process, missing `x-stitchkit-*` metadata is an
|
|
33
|
+
error unless a standard-document mode is declared explicitly, and the starter
|
|
34
|
+
lane sweeps generated trees for neutral-identity leaks against a committed
|
|
35
|
+
allowlist.
|
|
36
|
+
- **The template targets Stitchkit 0.46.** The error-code map covers
|
|
37
|
+
`REALTIME_CONTRACT_VIOLATION` and conformance validates the operation
|
|
38
|
+
metadata 0.46 emits.
|
|
39
|
+
- **Scaffold identity is rendered from one config.** The generated
|
|
40
|
+
`app.config.json` drives runtime identity and database naming; only the root
|
|
41
|
+
package manifest is structurally projected, with no global text search or
|
|
42
|
+
inert lockfile rewrite.
|
|
43
|
+
- **Development is explicit and portable.** `bun run dev` validates PM2 and
|
|
44
|
+
external PostgreSQL requirements before side effects. The removed LAN HTTPS
|
|
45
|
+
mode is documented as application-owned framework configuration instead of a
|
|
46
|
+
starter subsystem.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **Fresh clones bootstrap in the right order.** The starter materializes its
|
|
51
|
+
local environment before Prisma/type gates and reports a missing or placeholder
|
|
52
|
+
`DATABASE_URL` directly.
|
|
53
|
+
- **Every executable source is checked.** Root TypeScript coverage includes
|
|
54
|
+
scripts and browser E2E; the authored-source guard scans CJS and reports the
|
|
55
|
+
real offending line.
|
|
56
|
+
- **Runtime and browser gates prove the backend.** Surface conformance compares
|
|
57
|
+
HTTP/OpenAPI, MCP, Agent and CLI identities and schemas; browser E2E performs
|
|
58
|
+
a contract call and realtime cache update.
|
|
59
|
+
- **Repository example uses the canonical realtime contract.** Shared Zod event
|
|
60
|
+
definitions drive backend emission, browser subscriptions and cache bridging
|
|
61
|
+
without handwritten event maps.
|
|
62
|
+
|
|
63
|
+
### Removed
|
|
64
|
+
|
|
65
|
+
- **Starter-owned LAN HTTPS.** `dev:lan`, certificate generation, onboarding
|
|
66
|
+
transport and `DEV_HTTPS_*` settings are gone; trusted local TLS remains an
|
|
67
|
+
application-owned adapter choice documented by Stitchkit.
|
|
68
|
+
|
|
9
69
|
## [0.2.0] — 2026-08-10
|
|
10
70
|
|
|
11
71
|
### ⚠️ Breaking changes
|
package/dist/cli.js
CHANGED
|
@@ -60,7 +60,8 @@ function parseOptions(args) {
|
|
|
60
60
|
// src/scaffold.ts
|
|
61
61
|
import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
62
62
|
import { homedir } from "os";
|
|
63
|
-
import { basename as basename2, dirname, join, parse, relative, resolve, sep } from "path";
|
|
63
|
+
import { basename as basename2, dirname, extname, join, parse, relative, resolve, sep } from "path";
|
|
64
|
+
import { z as z2 } from "zod";
|
|
64
65
|
|
|
65
66
|
// src/identity.ts
|
|
66
67
|
import { basename } from "path";
|
|
@@ -112,12 +113,11 @@ var TEXT_EXTENSIONS = new Set([
|
|
|
112
113
|
".yml"
|
|
113
114
|
]);
|
|
114
115
|
var TEMPLATE_RENAMES = new Map([
|
|
115
|
-
["_env", ".env"],
|
|
116
|
-
["_env.append", ".env"],
|
|
117
116
|
["_env.example", ".env.example"],
|
|
118
117
|
["_env.example.append", ".env.example"],
|
|
119
118
|
["_gitignore", ".gitignore"]
|
|
120
119
|
]);
|
|
120
|
+
var RootManifestSchema = z2.looseObject({ name: z2.string().min(1) });
|
|
121
121
|
var IGNORED_DIRECTORIES = new Set([
|
|
122
122
|
".next",
|
|
123
123
|
"coverage",
|
|
@@ -189,8 +189,8 @@ async function collectMaterialisedFiles(templateDirectory, directory, files) {
|
|
|
189
189
|
const targetName = TEMPLATE_RENAMES.get(entry.name);
|
|
190
190
|
const sourceRelativePath = relative(templateDirectory, sourcePath);
|
|
191
191
|
const outputRelativePath = targetName ? join(dirname(sourceRelativePath), targetName) : sourceRelativePath;
|
|
192
|
-
const
|
|
193
|
-
const fileExtension =
|
|
192
|
+
const materialisedName = outputRelativePath.endsWith(".append") ? outputRelativePath.slice(0, -".append".length) : outputRelativePath;
|
|
193
|
+
const fileExtension = extname(materialisedName);
|
|
194
194
|
const content = TEXT_EXTENSIONS.has(fileExtension) || targetName ? await readFile(sourcePath, "utf8") : await readFile(sourcePath);
|
|
195
195
|
files.push({
|
|
196
196
|
sourcePath: portablePath(sourceRelativePath),
|
|
@@ -234,18 +234,9 @@ async function scaffoldProject(templateDirectory, destination, options = {}) {
|
|
|
234
234
|
await writeFile(join(resolvedDestination, "app.config.json"), `${JSON.stringify(identity, undefined, 2)}
|
|
235
235
|
`);
|
|
236
236
|
const manifestPath = join(resolvedDestination, "package.json");
|
|
237
|
-
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
238
|
-
manifest
|
|
239
|
-
await writeFile(manifestPath, `${JSON.stringify(manifest, undefined, 2)}
|
|
237
|
+
const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
|
|
238
|
+
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: identity.slug }, undefined, 2)}
|
|
240
239
|
`);
|
|
241
|
-
const lockPath = join(resolvedDestination, "bun.lock");
|
|
242
|
-
const lock = await readFile(lockPath, "utf8");
|
|
243
|
-
await writeFile(lockPath, lock.replace(/"name"\s*:\s*"stitchkit-starter"/, `"name": "${identity.slug}"`));
|
|
244
|
-
for (const environmentPath of [".env", ".env.example"]) {
|
|
245
|
-
const fullPath = join(resolvedDestination, environmentPath);
|
|
246
|
-
const environment = await readFile(fullPath, "utf8");
|
|
247
|
-
await writeFile(fullPath, environment.replaceAll("stitchkit_starter", identity.slug.replaceAll("-", "_")));
|
|
248
|
-
}
|
|
249
240
|
} catch (error) {
|
|
250
241
|
if (!destinationExisted) {
|
|
251
242
|
await rm(resolvedDestination, { recursive: true, force: true });
|
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
import { expect, test } from '@playwright/test';
|
|
2
2
|
|
|
3
|
+
test('prefetched data hydrates without a loading flash or a client refetch', async ({
|
|
4
|
+
page,
|
|
5
|
+
request,
|
|
6
|
+
}) => {
|
|
7
|
+
// The SSR document itself carries the prefetched repository data — the
|
|
8
|
+
// dehydration envelope is not empty.
|
|
9
|
+
const document = await request.get('/en');
|
|
10
|
+
expect(await document.text()).toContain('max-listov/stitchkit');
|
|
11
|
+
|
|
12
|
+
// Block the browser-side read entirely: the page must still render the data
|
|
13
|
+
// from the hydration envelope — never refetching, never flashing a loader.
|
|
14
|
+
let clientReads = 0;
|
|
15
|
+
await page.route('**/api/repository', async (route) => {
|
|
16
|
+
if (route.request().method() === 'GET') {
|
|
17
|
+
clientReads += 1;
|
|
18
|
+
await route.abort();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await route.continue();
|
|
22
|
+
});
|
|
23
|
+
await page.goto('/en');
|
|
24
|
+
await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
|
|
25
|
+
expect(clientReads).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a server realtime event updates the TanStack cache without a client refetch', async ({
|
|
29
|
+
page,
|
|
30
|
+
}) => {
|
|
31
|
+
await page.goto('/en');
|
|
32
|
+
const summary = page.getByTestId('repository-summary');
|
|
33
|
+
await expect(summary).toBeVisible();
|
|
34
|
+
const before = await summary.getAttribute('data-fetched-at');
|
|
35
|
+
expect(before).not.toBeNull();
|
|
36
|
+
|
|
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.
|
|
40
|
+
await page.route('**/api/repository', async (route) => {
|
|
41
|
+
if (route.request().method() === 'GET') {
|
|
42
|
+
await route.abort();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await route.continue();
|
|
46
|
+
});
|
|
47
|
+
await page.getByRole('button', { name: 'Refresh repository data' }).click();
|
|
48
|
+
await expect(summary).not.toHaveAttribute('data-fetched-at', before ?? '', {
|
|
49
|
+
timeout: 10_000,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
3
53
|
test('renders and refreshes the repository example', async ({ page }) => {
|
|
4
54
|
await page.route('**/api/repository/refresh', async (route) => {
|
|
5
55
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { RepositoryVisibility } from '@app/db';
|
|
3
|
+
import { RepositoryVisibilitySchema } from '@app/shared';
|
|
2
4
|
import {
|
|
3
5
|
GitHubRepositoryCache,
|
|
4
6
|
type RepositorySnapshotStore,
|
|
@@ -39,6 +41,12 @@ function commitsResponse(): Response {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
describe('GitHubRepositoryCache', () => {
|
|
44
|
+
test('keeps the shared wire enum aligned with the database enum', () => {
|
|
45
|
+
expect([...RepositoryVisibilitySchema.options].sort()).toEqual(
|
|
46
|
+
Object.values(RepositoryVisibility).sort(),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
42
50
|
test('deduplicates refreshes and persists an exact repository snapshot', async () => {
|
|
43
51
|
const requests: URL[] = [];
|
|
44
52
|
const fetcher = async (url: URL) => {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"service": "repository",
|
|
4
|
+
"action": "read",
|
|
5
|
+
"scope": "public",
|
|
6
|
+
"hasInput": false,
|
|
7
|
+
"hasOutput": true,
|
|
8
|
+
"inputShape": null,
|
|
9
|
+
"outputShape": "49c1f81a53ca82ad",
|
|
10
|
+
"http": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/api/repository"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"tools": {
|
|
17
|
+
"MCP": "repository_read",
|
|
18
|
+
"AGENT": "repository_read",
|
|
19
|
+
"CLI": "repository_read"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"service": "repository",
|
|
24
|
+
"action": "refresh",
|
|
25
|
+
"scope": "public",
|
|
26
|
+
"hasInput": false,
|
|
27
|
+
"hasOutput": true,
|
|
28
|
+
"inputShape": null,
|
|
29
|
+
"outputShape": "49c1f81a53ca82ad",
|
|
30
|
+
"http": [
|
|
31
|
+
{
|
|
32
|
+
"method": "POST",
|
|
33
|
+
"path": "/api/repository/refresh"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"tools": {
|
|
37
|
+
"MCP": "repository_refresh",
|
|
38
|
+
"AGENT": "repository_refresh",
|
|
39
|
+
"CLI": "repository_refresh"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"service": "system",
|
|
44
|
+
"action": "status",
|
|
45
|
+
"scope": "public",
|
|
46
|
+
"hasInput": false,
|
|
47
|
+
"hasOutput": true,
|
|
48
|
+
"inputShape": null,
|
|
49
|
+
"outputShape": "58b078ade3ee6167",
|
|
50
|
+
"http": [
|
|
51
|
+
{
|
|
52
|
+
"method": "GET",
|
|
53
|
+
"path": "/api/system/status"
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"tools": {}
|
|
57
|
+
}
|
|
58
|
+
]
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { env } from '@app/config';
|
|
2
|
-
import
|
|
3
|
-
import { createSocketIOServer } from 'stitchkit/server';
|
|
2
|
+
import { repositoryRealtimeContract } from '@app/shared';
|
|
3
|
+
import { bindRealtimeServer, createSocketIOServer } from 'stitchkit/server';
|
|
4
4
|
import { createRepositoryService } from './transport/repository-service';
|
|
5
|
+
import { createSystemService } from './transport/system-service';
|
|
5
6
|
|
|
6
7
|
export async function createSurface() {
|
|
7
|
-
const socket = await createSocketIOServer
|
|
8
|
+
const socket = await createSocketIOServer({
|
|
8
9
|
cors: { origin: env.CORS_ORIGIN },
|
|
9
10
|
});
|
|
11
|
+
const realtime = bindRealtimeServer(repositoryRealtimeContract, socket);
|
|
10
12
|
const repositoryService = createRepositoryService((snapshot) =>
|
|
11
|
-
|
|
13
|
+
realtime.emit('repository:refreshed', snapshot),
|
|
12
14
|
);
|
|
13
|
-
return { socket, services: [repositoryService] };
|
|
15
|
+
return { socket, services: [createSystemService(), repositoryService] };
|
|
14
16
|
}
|
|
@@ -52,7 +52,11 @@ export function RepositorySummary() {
|
|
|
52
52
|
: '—';
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
|
-
<div
|
|
55
|
+
<div
|
|
56
|
+
className='mx-auto flex w-full max-w-4xl flex-col gap-4 rounded-xl border border-border bg-card/80 px-4 py-4 text-left backdrop-blur sm:flex-row sm:items-center sm:px-5'
|
|
57
|
+
data-fetched-at={snapshot.cache.fetchedAt}
|
|
58
|
+
data-testid='repository-summary'
|
|
59
|
+
>
|
|
56
60
|
<a
|
|
57
61
|
className='group flex min-w-0 flex-1 items-center gap-3'
|
|
58
62
|
href={snapshot.htmlUrl}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { repositoryRealtimeContract } from '@app/shared';
|
|
2
|
+
import { createRealtimeClient } from 'stitchkit';
|
|
3
3
|
import { createCacheBridge } from 'stitchkit/react';
|
|
4
4
|
import { env } from '@/env';
|
|
5
5
|
import { useRepository } from '@/lib/api/queries';
|
|
6
6
|
import { getQueryClient } from '@/lib/query-client';
|
|
7
7
|
|
|
8
|
-
export const repositorySocket =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
>({ url: env.NEXT_PUBLIC_API_URL });
|
|
8
|
+
export const repositorySocket = createRealtimeClient(repositoryRealtimeContract, {
|
|
9
|
+
url: env.NEXT_PUBLIC_API_URL,
|
|
10
|
+
});
|
|
12
11
|
|
|
13
12
|
export const repositoryBridge = createCacheBridge({
|
|
14
13
|
socket: repositorySocket,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineRealtimeContract } from 'stitchkit';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { RepositorySnapshotSchema } from '../schemas/repository';
|
|
4
|
+
|
|
5
|
+
export const repositoryRealtimeContract = defineRealtimeContract({
|
|
6
|
+
serverToClient: {
|
|
7
|
+
'repository:refreshed': { args: z.tuple([RepositorySnapshotSchema]) },
|
|
8
|
+
},
|
|
9
|
+
clientToServer: {},
|
|
10
|
+
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { RepositoryVisibility } from '@app/db/enums';
|
|
2
1
|
import { z } from 'zod';
|
|
3
2
|
|
|
3
|
+
export const RepositoryVisibilitySchema = z.enum(['PUBLIC', 'PRIVATE', 'INTERNAL']);
|
|
4
|
+
export type RepositoryVisibility = z.infer<typeof RepositoryVisibilitySchema>;
|
|
5
|
+
|
|
4
6
|
export const RepositorySnapshotSchema = z.object({
|
|
5
7
|
fullName: z.string().min(1),
|
|
6
8
|
description: z.string().nullable(),
|
|
7
9
|
htmlUrl: z.url(),
|
|
8
10
|
language: z.string().nullable(),
|
|
9
|
-
visibility:
|
|
11
|
+
visibility: RepositoryVisibilitySchema,
|
|
10
12
|
stars: z.number().int().nonnegative(),
|
|
11
13
|
forks: z.number().int().nonnegative(),
|
|
12
14
|
openIssues: z.number().int().nonnegative(),
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { RepositorySnapshotSchema } from '@app/shared';
|
|
2
|
-
import {
|
|
1
|
+
import { RepositorySnapshotSchema, repositoryRealtimeContract } from '@app/shared';
|
|
2
|
+
import { createRealtimeClient, defineRealtimeContract } from 'stitchkit';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { defineSurfaceProbe, runSurfaceConformance } from './surface-conformance';
|
|
5
|
-
import {
|
|
5
|
+
import { loadToolingEnv } from './tooling-env';
|
|
6
6
|
|
|
7
|
+
const toolingEnv = loadToolingEnv();
|
|
7
8
|
const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
|
|
8
9
|
|
|
9
10
|
async function json(path: string, init?: RequestInit): Promise<unknown> {
|
|
@@ -24,33 +25,39 @@ await runSurfaceConformance({
|
|
|
24
25
|
fixture: { path: '/api/repository/refresh' },
|
|
25
26
|
output: RepositorySnapshotSchema,
|
|
26
27
|
run: async ({ path }) => {
|
|
27
|
-
const
|
|
28
|
+
const rejected: string[] = [];
|
|
29
|
+
const socket = createRealtimeClient(repositoryRealtimeContract, {
|
|
30
|
+
url: apiOrigin,
|
|
31
|
+
transports: ['websocket'],
|
|
32
|
+
onRejected: ({ event, direction, phase }) => {
|
|
33
|
+
rejected.push(`${event}:${direction}:${phase}`);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
28
36
|
try {
|
|
29
37
|
await new Promise<void>((resolve, reject) => {
|
|
30
38
|
const timeout = setTimeout(
|
|
31
39
|
() => reject(new Error('Socket.IO connection timed out')),
|
|
32
40
|
5_000,
|
|
33
41
|
);
|
|
34
|
-
const
|
|
42
|
+
const unsubscribe = socket.onConnectionChange((connected, reason) => {
|
|
43
|
+
if (!connected) {
|
|
44
|
+
if (reason) reject(new Error(`Socket.IO disconnected: ${reason}`));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
35
47
|
clearTimeout(timeout);
|
|
36
|
-
|
|
48
|
+
unsubscribe();
|
|
37
49
|
resolve();
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
clearTimeout(timeout);
|
|
41
|
-
socket.off('connect', onConnect);
|
|
42
|
-
reject(error);
|
|
43
|
-
};
|
|
44
|
-
socket.once('connect', onConnect);
|
|
45
|
-
socket.once('connect_error', onConnectError);
|
|
50
|
+
});
|
|
51
|
+
socket.connect();
|
|
46
52
|
});
|
|
47
53
|
const refreshedEvent = new Promise<string>((resolve, reject) => {
|
|
48
54
|
const timeout = setTimeout(
|
|
49
55
|
() => reject(new Error('Socket.IO repository refresh event timed out')),
|
|
50
56
|
5_000,
|
|
51
57
|
);
|
|
52
|
-
socket.
|
|
58
|
+
const unsubscribe = socket.on('repository:refreshed', (snapshot) => {
|
|
53
59
|
clearTimeout(timeout);
|
|
60
|
+
unsubscribe();
|
|
54
61
|
resolve(snapshot.fullName);
|
|
55
62
|
});
|
|
56
63
|
});
|
|
@@ -64,9 +71,77 @@ await runSurfaceConformance({
|
|
|
64
71
|
if (cached.fullName !== refreshed.fullName) {
|
|
65
72
|
throw new Error('Repository cache read differs');
|
|
66
73
|
}
|
|
74
|
+
if (rejected.length > 0) {
|
|
75
|
+
throw new Error(`Realtime contract rejected ${rejected.join(', ')}`);
|
|
76
|
+
}
|
|
67
77
|
return refreshed;
|
|
68
78
|
} finally {
|
|
69
|
-
socket.
|
|
79
|
+
socket.disconnect();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
defineSurfaceProbe({
|
|
84
|
+
name: 'realtime rejection path fires on a contract mismatch',
|
|
85
|
+
input: z.object({ path: z.literal('/api/repository/refresh') }),
|
|
86
|
+
fixture: { path: '/api/repository/refresh' },
|
|
87
|
+
run: async ({ path }) => {
|
|
88
|
+
// NEGATIVE probe: a client whose local contract disagrees with the
|
|
89
|
+
// server must see the real payload REJECTED — this executes the
|
|
90
|
+
// rejection path instead of merely asserting its absence. Matched by
|
|
91
|
+
// event/direction/phase, never by message text.
|
|
92
|
+
const divergentContract = defineRealtimeContract({
|
|
93
|
+
serverToClient: {
|
|
94
|
+
'repository:refreshed': { args: z.tuple([z.object({ bogus: z.string() })]) },
|
|
95
|
+
},
|
|
96
|
+
clientToServer: {},
|
|
97
|
+
});
|
|
98
|
+
const rejections: Array<{ event: string; direction: string; phase: string }> = [];
|
|
99
|
+
const socket = createRealtimeClient(divergentContract, {
|
|
100
|
+
url: apiOrigin,
|
|
101
|
+
transports: ['websocket'],
|
|
102
|
+
onRejected: ({ event, direction, phase }) => {
|
|
103
|
+
rejections.push({ event, direction, phase });
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
await new Promise<void>((resolve, reject) => {
|
|
108
|
+
const timeout = setTimeout(
|
|
109
|
+
() => reject(new Error('Socket.IO connection timed out')),
|
|
110
|
+
5_000,
|
|
111
|
+
);
|
|
112
|
+
const unsubscribe = socket.onConnectionChange((connected) => {
|
|
113
|
+
if (!connected) return;
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
unsubscribe();
|
|
116
|
+
resolve();
|
|
117
|
+
});
|
|
118
|
+
socket.connect();
|
|
119
|
+
});
|
|
120
|
+
// A handler must be attached for the inbound frame to be validated.
|
|
121
|
+
const unsubscribe = socket.on('repository:refreshed', () => {
|
|
122
|
+
throw new Error('a payload outside the local contract reached the handler');
|
|
123
|
+
});
|
|
124
|
+
await json(path, { method: 'POST' });
|
|
125
|
+
const deadline = Date.now() + 5_000;
|
|
126
|
+
while (rejections.length === 0 && Date.now() < deadline) {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
128
|
+
}
|
|
129
|
+
unsubscribe();
|
|
130
|
+
const rejection = rejections[0];
|
|
131
|
+
if (!rejection) {
|
|
132
|
+
throw new Error('the contract mismatch was never rejected');
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
rejection.event !== 'repository:refreshed' ||
|
|
136
|
+
rejection.direction !== 'client-inbound' ||
|
|
137
|
+
rejection.phase !== 'arguments'
|
|
138
|
+
) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`unexpected rejection identity: ${rejection.event}:${rejection.direction}:${rejection.phase}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
socket.disconnect();
|
|
70
145
|
}
|
|
71
146
|
},
|
|
72
147
|
}),
|
|
@@ -77,8 +152,11 @@ await runSurfaceConformance({
|
|
|
77
152
|
const corsResponse = await fetch(`${apiOrigin}/api/repository/`, {
|
|
78
153
|
headers: { Origin: 'http://localhost:58302' },
|
|
79
154
|
});
|
|
80
|
-
if (
|
|
81
|
-
|
|
155
|
+
if (
|
|
156
|
+
corsResponse.headers.get('access-control-allow-origin') !==
|
|
157
|
+
new URL(toolingEnv.NEXT_PUBLIC_WEB_URL).origin
|
|
158
|
+
) {
|
|
159
|
+
throw new Error('API CORS origin differs from the configured web origin');
|
|
82
160
|
}
|
|
83
161
|
const openApi = z
|
|
84
162
|
.object({ paths: z.record(z.string(), z.unknown()) })
|
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Application starter
|
|
2
2
|
|
|
3
3
|
Production-shaped application generated by `create-stitchkit`.
|
|
4
4
|
|
|
@@ -14,8 +14,8 @@ Point `DATABASE_URL` in `.env` at an existing PostgreSQL database, then run:
|
|
|
14
14
|
bun run dev
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
The command validates the environment, generates the Prisma client, applies
|
|
18
|
-
|
|
17
|
+
The command validates the environment, generates the Prisma client, applies any
|
|
18
|
+
database migrations you add and launches:
|
|
19
19
|
|
|
20
20
|
- Web: <http://localhost:3210>
|
|
21
21
|
- UI catalogue: <http://localhost:3210/en/ui>
|
|
@@ -23,7 +23,7 @@ checked-in migrations and launches:
|
|
|
23
23
|
- OpenAPI: <http://localhost:3211/openapi.json>
|
|
24
24
|
- MCP: <http://localhost:3211/mcp>
|
|
25
25
|
|
|
26
|
-
The home page is a compact, domain-free
|
|
26
|
+
The home page is a compact, domain-free application reference surface.
|
|
27
27
|
Add application features as vertical schema → contract → service → client slices.
|
|
28
28
|
The exact workflow is in [`docs/ADDING_A_FEATURE.md`](docs/ADDING_A_FEATURE.md),
|
|
29
29
|
and root [`AGENTS.md`](AGENTS.md) gives coding agents the application boundaries.
|
|
@@ -54,14 +54,10 @@ Unsupported browsers and users requesting reduced motion switch immediately.
|
|
|
54
54
|
bun run check
|
|
55
55
|
bun run test
|
|
56
56
|
bun run build
|
|
57
|
+
bun run runtime:smoke
|
|
58
|
+
bun run e2e
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
## Physical-device HTTPS
|
|
60
|
-
|
|
61
|
-
`bun run dev:lan` is an explicit trusted-LAN mode powered by mkcert. It leaves
|
|
62
|
-
normal development and production unchanged. Setup and device trust steps are
|
|
63
|
-
in [`docs/LAN_HTTPS.md`](docs/LAN_HTTPS.md).
|
|
64
|
-
|
|
65
61
|
## Production
|
|
66
62
|
|
|
67
63
|
Provide a production `.env`, then:
|
package/template/_env.example
CHANGED
package/template/bun.lock
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
16
16
|
"@playwright/test": "^1.55.0",
|
|
17
17
|
"@types/bun": "^1.3.14",
|
|
18
|
+
"@types/node": "^26.2.0",
|
|
18
19
|
"oxc-parser": "^0.143.0",
|
|
19
20
|
"socket.io-client": "^4.8.3",
|
|
21
|
+
"stitchkit": "catalog:",
|
|
20
22
|
"typescript": "^7.0.2",
|
|
21
23
|
"zod": "^4.4.3",
|
|
22
24
|
},
|
|
@@ -74,7 +76,6 @@
|
|
|
74
76
|
"version": "0.1.0",
|
|
75
77
|
"dependencies": {
|
|
76
78
|
"@app/config": "workspace:*",
|
|
77
|
-
"@app/db": "workspace:*",
|
|
78
79
|
"@app/shared": "workspace:*",
|
|
79
80
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
|
80
81
|
"@radix-ui/react-avatar": "^1.2.6",
|
|
@@ -127,7 +128,6 @@
|
|
|
127
128
|
"name": "@app/shared",
|
|
128
129
|
"version": "0.1.0",
|
|
129
130
|
"dependencies": {
|
|
130
|
-
"@app/db": "workspace:*",
|
|
131
131
|
"stitchkit": "catalog:",
|
|
132
132
|
"zod": "^4.4.3",
|
|
133
133
|
},
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
},
|
|
140
140
|
"catalog": {
|
|
141
|
-
"stitchkit": "^0.
|
|
141
|
+
"stitchkit": "^0.46.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.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=="],
|
|
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
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Adding a vertical feature
|
|
2
2
|
|
|
3
3
|
This guide uses a small `status` resource to show the canonical path. The blank
|
|
4
|
-
scaffold starts with
|
|
5
|
-
keep the repository slice and add the
|
|
4
|
+
scaffold starts with only its HTTP readiness endpoint and no application or tool
|
|
5
|
+
surface. In `--example repository` mode, keep the repository slice and add the
|
|
6
|
+
same files beside it.
|
|
6
7
|
|
|
7
8
|
## 1. Define the wire data
|
|
8
9
|
|