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
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { appIdentity } from '@app/config/identity';
|
|
2
|
+
import { systemContract } from '@app/shared';
|
|
2
3
|
import AxeBuilder from '@axe-core/playwright';
|
|
3
4
|
import { expect, test } from '@playwright/test';
|
|
5
|
+
import { createClient, createHttpClient } from 'stitchkit';
|
|
6
|
+
import { loadToolingEnv } from '../scripts/tooling-env';
|
|
7
|
+
|
|
8
|
+
const toolingEnv = loadToolingEnv();
|
|
4
9
|
|
|
5
10
|
test('renders the hydrated starter application and catalogue', async ({ page }) => {
|
|
6
11
|
await page.goto('/en');
|
|
@@ -12,6 +17,18 @@ test('renders the hydrated starter application and catalogue', async ({ page })
|
|
|
12
17
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('UI components');
|
|
13
18
|
});
|
|
14
19
|
|
|
20
|
+
test('calls the live backend through the typed contract client', async () => {
|
|
21
|
+
const client = createClient(
|
|
22
|
+
systemContract,
|
|
23
|
+
createHttpClient({
|
|
24
|
+
baseUrl: `${toolingEnv.NEXT_PUBLIC_API_URL}/api`,
|
|
25
|
+
credentials: 'omit',
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
await expect(client.status()).resolves.toEqual({ status: 'ok' });
|
|
30
|
+
});
|
|
31
|
+
|
|
15
32
|
test('publishes complete page metadata and a reachable Open Graph card', async ({
|
|
16
33
|
page,
|
|
17
34
|
request,
|
|
@@ -5,18 +5,6 @@ const identity = require('./app.config.json');
|
|
|
5
5
|
config({ path: path.join(__dirname, '.env'), quiet: true });
|
|
6
6
|
|
|
7
7
|
const frontendArgs = ['dev', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'];
|
|
8
|
-
if (process.env.DEV_HTTPS_CERT && process.env.DEV_HTTPS_KEY) {
|
|
9
|
-
frontendArgs.push(
|
|
10
|
-
'--experimental-https',
|
|
11
|
-
'--experimental-https-key',
|
|
12
|
-
process.env.DEV_HTTPS_KEY,
|
|
13
|
-
'--experimental-https-cert',
|
|
14
|
-
process.env.DEV_HTTPS_CERT,
|
|
15
|
-
);
|
|
16
|
-
if (process.env.DEV_HTTPS_CA) {
|
|
17
|
-
frontendArgs.push('--experimental-https-ca', process.env.DEV_HTTPS_CA);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
8
|
|
|
21
9
|
module.exports = {
|
|
22
10
|
apps: [
|
package/template/package.json
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
"packages/*"
|
|
8
8
|
],
|
|
9
9
|
"catalog": {
|
|
10
|
-
"stitchkit": "^0.
|
|
10
|
+
"stitchkit": "^0.46.0"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"dev": "bun scripts/dev.ts",
|
|
14
|
-
"
|
|
15
|
-
"check": "bun run db:generate && bun run check:authored && bun run --filter '*' check",
|
|
14
|
+
"check": "bun run db:generate && bun run check:authored && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
|
|
16
15
|
"check:authored": "bun scripts/check-authored.ts",
|
|
17
16
|
"test": "bun run --filter '*' test",
|
|
18
17
|
"build": "bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build",
|
|
@@ -27,6 +26,7 @@
|
|
|
27
26
|
"cli": "bun packages/backend/src/cli.ts",
|
|
28
27
|
"tools": "bun packages/backend/src/tools.ts",
|
|
29
28
|
"runtime:smoke": "bun scripts/runtime-smoke.ts",
|
|
29
|
+
"surface:snapshot": "bun scripts/surface-snapshot.ts",
|
|
30
30
|
"e2e": "playwright test",
|
|
31
31
|
"lint": "biome check --error-on-warnings .",
|
|
32
32
|
"lint:fix": "biome check --write .",
|
|
@@ -44,8 +44,10 @@
|
|
|
44
44
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
45
45
|
"@playwright/test": "^1.55.0",
|
|
46
46
|
"@types/bun": "^1.3.14",
|
|
47
|
+
"@types/node": "^26.2.0",
|
|
47
48
|
"oxc-parser": "^0.143.0",
|
|
48
49
|
"socket.io-client": "^4.8.3",
|
|
50
|
+
"stitchkit": "catalog:",
|
|
49
51
|
"typescript": "^7.0.2",
|
|
50
52
|
"zod": "^4.4.3"
|
|
51
53
|
},
|
|
@@ -6,7 +6,6 @@ import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools';
|
|
|
6
6
|
import { prisma } from './lib/db';
|
|
7
7
|
import { createSurface } from './surface';
|
|
8
8
|
import { onError } from './transport/errors';
|
|
9
|
-
import { createLanOnboardingRoutes } from './transport/lan-onboarding';
|
|
10
9
|
|
|
11
10
|
async function main(): Promise<void> {
|
|
12
11
|
const { services, socket } = await createSurface();
|
|
@@ -32,7 +31,6 @@ async function main(): Promise<void> {
|
|
|
32
31
|
socket.route,
|
|
33
32
|
openApiRoute('/openapi.json', openApi),
|
|
34
33
|
createMcpHttpRoute({ path: '/mcp', handler: mcp }),
|
|
35
|
-
...createLanOnboardingRoutes(),
|
|
36
34
|
{
|
|
37
35
|
method: 'GET',
|
|
38
36
|
path: '/health',
|
|
@@ -40,16 +38,6 @@ async function main(): Promise<void> {
|
|
|
40
38
|
},
|
|
41
39
|
],
|
|
42
40
|
wrapFetch: (fetch) => wrapInRequestContext(fetch),
|
|
43
|
-
bun:
|
|
44
|
-
env.DEV_HTTPS_CERT && env.DEV_HTTPS_KEY
|
|
45
|
-
? {
|
|
46
|
-
tls: {
|
|
47
|
-
cert: Bun.file(env.DEV_HTTPS_CERT),
|
|
48
|
-
key: Bun.file(env.DEV_HTTPS_KEY),
|
|
49
|
-
...(env.DEV_HTTPS_CA && { ca: Bun.file(env.DEV_HTTPS_CA) }),
|
|
50
|
-
},
|
|
51
|
-
}
|
|
52
|
-
: undefined,
|
|
53
41
|
});
|
|
54
42
|
|
|
55
43
|
async function shutdown(): Promise<void> {
|
|
@@ -61,9 +49,7 @@ async function main(): Promise<void> {
|
|
|
61
49
|
|
|
62
50
|
process.once('SIGTERM', shutdown);
|
|
63
51
|
process.once('SIGINT', shutdown);
|
|
64
|
-
console.log(
|
|
65
|
-
`API listening on ${env.DEV_HTTPS_CERT ? 'https' : 'http'}://127.0.0.1:${env.API_PORT}`,
|
|
66
|
-
);
|
|
52
|
+
console.log(`API listening on http://127.0.0.1:${env.API_PORT}`);
|
|
67
53
|
}
|
|
68
54
|
|
|
69
55
|
main().catch((error: unknown) => {
|
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createContractFactory } from 'stitchkit/contract';
|
|
3
5
|
import { generateOpenApiDocument, implement } from 'stitchkit/server';
|
|
4
6
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
7
|
+
import { discoverCliCommands, discoverMcpTools } from '../../../scripts/surface-conformance';
|
|
8
|
+
import {
|
|
9
|
+
assertManifestMatchesSnapshot,
|
|
10
|
+
assertSurfaceConformance,
|
|
11
|
+
buildSurfaceManifest,
|
|
12
|
+
} from './surface-manifest';
|
|
13
|
+
|
|
14
|
+
const NoteParamsSchema = z.object({ id: z.string() });
|
|
15
|
+
const NoteSchema = z.object({ id: z.string() });
|
|
16
|
+
// Same address and presence flags as NoteSchema — a different TYPE, for the
|
|
17
|
+
// snapshot-digest test. Named here, never inline in a contract (the template's
|
|
18
|
+
// own schema-separate-from-contract policy).
|
|
19
|
+
const RetypedNoteSchema = z.object({ id: z.number() });
|
|
20
|
+
const { defineContract } = createContractFactory<'public'>();
|
|
6
21
|
|
|
7
22
|
const service = implement(
|
|
8
23
|
defineContract(
|
|
9
|
-
{ prefix: 'notes' },
|
|
24
|
+
{ prefix: 'notes', scope: 'public' },
|
|
10
25
|
{
|
|
11
26
|
read: {
|
|
12
27
|
method: 'GET',
|
|
13
28
|
path: '/:id',
|
|
14
29
|
desc: 'Read a note',
|
|
15
|
-
params:
|
|
16
|
-
output:
|
|
30
|
+
params: NoteParamsSchema,
|
|
31
|
+
output: NoteSchema,
|
|
32
|
+
expose: ['HTTP', 'MCP', 'AGENT', 'CLI'],
|
|
17
33
|
},
|
|
18
34
|
},
|
|
19
35
|
),
|
|
@@ -31,32 +47,191 @@ describe('surface conformance', () => {
|
|
|
31
47
|
{
|
|
32
48
|
service: 'notes',
|
|
33
49
|
action: 'read',
|
|
50
|
+
scope: 'public',
|
|
51
|
+
hasInput: false,
|
|
52
|
+
hasOutput: true,
|
|
53
|
+
inputShape: null,
|
|
54
|
+
outputShape: expect.stringMatching(/^[0-9a-f]{16}$/),
|
|
34
55
|
http: [{ method: 'GET', path: '/api/notes/{id}' }],
|
|
35
|
-
tools: { AGENT: 'read_note', MCP: 'read_note' },
|
|
56
|
+
tools: { AGENT: 'read_note', CLI: 'read_note', MCP: 'read_note' },
|
|
36
57
|
},
|
|
37
58
|
]);
|
|
38
59
|
expect(() =>
|
|
39
|
-
assertSurfaceConformance({
|
|
60
|
+
assertSurfaceConformance({
|
|
61
|
+
manifest,
|
|
62
|
+
openApi,
|
|
63
|
+
mcpToolNames: ['read_note'],
|
|
64
|
+
agentToolNames: ['read_note'],
|
|
65
|
+
cliToolNames: ['read_note'],
|
|
66
|
+
}),
|
|
40
67
|
).not.toThrow();
|
|
41
68
|
});
|
|
42
69
|
|
|
43
70
|
test('fails with transport-specific expected and actual diagnostics', () => {
|
|
44
71
|
const manifest = buildSurfaceManifest([service]);
|
|
45
72
|
expect(() =>
|
|
46
|
-
assertSurfaceConformance({
|
|
73
|
+
assertSurfaceConformance({
|
|
74
|
+
manifest,
|
|
75
|
+
openApi: { paths: {} },
|
|
76
|
+
mcpToolNames: [],
|
|
77
|
+
agentToolNames: [],
|
|
78
|
+
cliToolNames: [],
|
|
79
|
+
}),
|
|
47
80
|
).toThrow('HTTP/OpenAPI surface mismatch');
|
|
48
81
|
expect(() =>
|
|
49
82
|
assertSurfaceConformance({
|
|
50
83
|
manifest: manifest.map((operation) => ({ ...operation, http: [] })),
|
|
51
84
|
openApi: { paths: {} },
|
|
52
85
|
mcpToolNames: ['unexpected'],
|
|
86
|
+
agentToolNames: ['read_note'],
|
|
87
|
+
cliToolNames: ['read_note'],
|
|
53
88
|
}),
|
|
54
89
|
).toThrow('MCP discovery surface mismatch');
|
|
55
90
|
});
|
|
56
91
|
|
|
92
|
+
test('missing Stitchkit metadata is an ERROR unless the standard-document mode is declared', () => {
|
|
93
|
+
const manifest = buildSurfaceManifest([service]);
|
|
94
|
+
const standardDocument = {
|
|
95
|
+
paths: {
|
|
96
|
+
'/api/notes/{id}': {
|
|
97
|
+
get: { responses: { 200: { description: 'Success' } } },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const names = {
|
|
102
|
+
mcpToolNames: ['read_note'],
|
|
103
|
+
agentToolNames: ['read_note'],
|
|
104
|
+
cliToolNames: ['read_note'],
|
|
105
|
+
};
|
|
106
|
+
// A silent skip was the hole: no x-stitchkit-* keys meant no comparison at
|
|
107
|
+
// all. The default mode now refuses; opting out is an explicit decision.
|
|
108
|
+
expect(() =>
|
|
109
|
+
assertSurfaceConformance({ manifest, openApi: standardDocument, ...names }),
|
|
110
|
+
).toThrow(/no x-stitchkit-\* contract metadata/);
|
|
111
|
+
expect(() =>
|
|
112
|
+
assertSurfaceConformance({
|
|
113
|
+
manifest,
|
|
114
|
+
openApi: standardDocument,
|
|
115
|
+
...names,
|
|
116
|
+
metadata: 'ignore',
|
|
117
|
+
}),
|
|
118
|
+
).not.toThrow();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a missing AGENT tool and a missing CLI command each fail conformance', () => {
|
|
122
|
+
const manifest = buildSurfaceManifest([service]);
|
|
123
|
+
const openApi = generateOpenApiDocument({
|
|
124
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
125
|
+
groups: [{ pathPrefix: '/api', services: [service] }],
|
|
126
|
+
});
|
|
127
|
+
expect(() =>
|
|
128
|
+
assertSurfaceConformance({
|
|
129
|
+
manifest,
|
|
130
|
+
openApi,
|
|
131
|
+
mcpToolNames: ['read_note'],
|
|
132
|
+
agentToolNames: [],
|
|
133
|
+
cliToolNames: ['read_note'],
|
|
134
|
+
}),
|
|
135
|
+
).toThrow('AGENT mount surface mismatch');
|
|
136
|
+
expect(() =>
|
|
137
|
+
assertSurfaceConformance({
|
|
138
|
+
manifest,
|
|
139
|
+
openApi,
|
|
140
|
+
mcpToolNames: ['read_note'],
|
|
141
|
+
agentToolNames: ['read_note'],
|
|
142
|
+
cliToolNames: [],
|
|
143
|
+
}),
|
|
144
|
+
).toThrow('CLI manifest surface mismatch');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the committed snapshot catches an expose edit that moves BOTH in-process sides together', () => {
|
|
148
|
+
// The probe that defeated the old check: removing AGENT/CLI from `expose`
|
|
149
|
+
// changes the manifest AND the in-process tool lists in lockstep. The
|
|
150
|
+
// snapshot is the anchor that does not move with the source.
|
|
151
|
+
const snapshot = buildSurfaceManifest([service]);
|
|
152
|
+
const narrowed = implement(
|
|
153
|
+
defineContract(
|
|
154
|
+
{ prefix: 'notes', scope: 'public' },
|
|
155
|
+
{
|
|
156
|
+
read: {
|
|
157
|
+
method: 'GET',
|
|
158
|
+
path: '/:id',
|
|
159
|
+
desc: 'Read a note',
|
|
160
|
+
params: NoteParamsSchema,
|
|
161
|
+
output: NoteSchema,
|
|
162
|
+
expose: ['HTTP', 'MCP'],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
),
|
|
166
|
+
{ read: ({ params }) => ({ id: params.id }) },
|
|
167
|
+
);
|
|
168
|
+
expect(() =>
|
|
169
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([narrowed]), snapshot),
|
|
170
|
+
).toThrow(/diverged from the committed snapshot/);
|
|
171
|
+
expect(() =>
|
|
172
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([service]), snapshot),
|
|
173
|
+
).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a schema TYPE change at the same method and path flips the shape digest', () => {
|
|
177
|
+
const snapshot = buildSurfaceManifest([service]);
|
|
178
|
+
const retyped = implement(
|
|
179
|
+
defineContract(
|
|
180
|
+
{ prefix: 'notes', scope: 'public' },
|
|
181
|
+
{
|
|
182
|
+
read: {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
path: '/:id',
|
|
185
|
+
desc: 'Read a note',
|
|
186
|
+
params: NoteParamsSchema,
|
|
187
|
+
// Same address, same presence flags — different output TYPE.
|
|
188
|
+
output: RetypedNoteSchema,
|
|
189
|
+
expose: ['HTTP', 'MCP', 'AGENT', 'CLI'],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
),
|
|
193
|
+
{ read: ({ params }) => ({ id: Number(params.id) }) },
|
|
194
|
+
);
|
|
195
|
+
expect(() =>
|
|
196
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([retyped]), snapshot),
|
|
197
|
+
).toThrow(/diverged from the committed snapshot/);
|
|
198
|
+
});
|
|
199
|
+
|
|
57
200
|
test('fails first on duplicate contract identity', () => {
|
|
58
201
|
expect(() => buildSurfaceManifest([service, service])).toThrow(
|
|
59
202
|
'Duplicate operation identity notes.read',
|
|
60
203
|
);
|
|
61
204
|
});
|
|
205
|
+
|
|
206
|
+
test('a broken /mcp endpoint rejects discovery — even when zero tools are expected', async () => {
|
|
207
|
+
const broken = Bun.serve({
|
|
208
|
+
port: 0,
|
|
209
|
+
fetch: () => new Response('not the MCP endpoint', { status: 404 }),
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
await expect(discoverMcpTools(`http://127.0.0.1:${broken.port}`, [])).rejects.toThrow();
|
|
213
|
+
} finally {
|
|
214
|
+
broken.stop(true);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('the CLI surface observed from the SPAWNED process matches the committed snapshot', async () => {
|
|
219
|
+
// External observation against the external anchor — neither side is the
|
|
220
|
+
// in-process `services` object, so they cannot move together.
|
|
221
|
+
const root = join(import.meta.dir, '../../..');
|
|
222
|
+
const commands = await discoverCliCommands(root);
|
|
223
|
+
const snapshot = SurfaceSnapshotCliSchema.parse(
|
|
224
|
+
JSON.parse(
|
|
225
|
+
await readFile(join(root, 'packages/backend/src/surface.snapshot.json'), 'utf8'),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
const expected = snapshot
|
|
229
|
+
.flatMap((operation) => (operation.tools.CLI ? [operation.tools.CLI] : []))
|
|
230
|
+
.sort();
|
|
231
|
+
expect([...commands].sort()).toEqual(expected);
|
|
232
|
+
});
|
|
62
233
|
});
|
|
234
|
+
|
|
235
|
+
const SurfaceSnapshotCliSchema = z.array(
|
|
236
|
+
z.object({ tools: z.object({ CLI: z.string().optional() }) }),
|
|
237
|
+
);
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import type { HttpMethod } from 'stitchkit/contract';
|
|
2
3
|
import type { OpenApiDocument, ServiceDef } from 'stitchkit/server';
|
|
3
4
|
import { listToolNames } from 'stitchkit/tools';
|
|
5
|
+
import { z } from 'zod';
|
|
4
6
|
|
|
5
7
|
export interface SurfaceManifestOperation {
|
|
6
8
|
service: string;
|
|
7
9
|
action: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
hasInput: boolean;
|
|
12
|
+
hasOutput: boolean;
|
|
13
|
+
/** Digest of the input JSON Schema — a TYPE change flips it, not just presence. */
|
|
14
|
+
inputShape: string | null;
|
|
15
|
+
/** Digest of the output JSON Schema. */
|
|
16
|
+
outputShape: string | null;
|
|
8
17
|
http: Array<{ method: HttpMethod; path: string }>;
|
|
9
18
|
tools: Partial<Record<'MCP' | 'AGENT' | 'CLI', string>>;
|
|
10
19
|
}
|
|
11
20
|
|
|
21
|
+
function schemaShape(schema: z.ZodType | undefined, io: 'input' | 'output'): string | null {
|
|
22
|
+
if (!schema) return null;
|
|
23
|
+
const document = z.toJSONSchema(schema, { io, unrepresentable: 'any' });
|
|
24
|
+
return createHash('sha256').update(JSON.stringify(document)).digest('hex').slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
function joinPath(...parts: Array<string | undefined>): string {
|
|
13
28
|
const joined = parts
|
|
14
29
|
.filter((part): part is string => Boolean(part))
|
|
@@ -58,6 +73,11 @@ export function buildSurfaceManifest(
|
|
|
58
73
|
operations.set(key, {
|
|
59
74
|
service: service.name,
|
|
60
75
|
action: method.key,
|
|
76
|
+
scope: method.scope ?? service.scope,
|
|
77
|
+
hasInput: Boolean(method.inputSchema),
|
|
78
|
+
hasOutput: Boolean(method.outputSchema),
|
|
79
|
+
inputShape: schemaShape(method.inputSchema, 'input'),
|
|
80
|
+
outputShape: schemaShape(method.outputSchema, 'output'),
|
|
61
81
|
http:
|
|
62
82
|
!method.expose || method.expose.includes('HTTP')
|
|
63
83
|
? [
|
|
@@ -89,29 +109,112 @@ export function buildSurfaceManifest(
|
|
|
89
109
|
);
|
|
90
110
|
}
|
|
91
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Compare the LIVE manifest against the committed snapshot — the external
|
|
114
|
+
* anchor that a source-level change (an `expose` edit, a schema type change, a
|
|
115
|
+
* scope change) cannot move along with itself. Regenerate deliberately with
|
|
116
|
+
* `bun run surface:snapshot` and review the diff.
|
|
117
|
+
*/
|
|
118
|
+
export function assertManifestMatchesSnapshot(
|
|
119
|
+
manifest: readonly SurfaceManifestOperation[],
|
|
120
|
+
snapshot: readonly SurfaceManifestOperation[],
|
|
121
|
+
): void {
|
|
122
|
+
const actual = JSON.stringify(manifest, null, 2);
|
|
123
|
+
const expected = JSON.stringify(snapshot, null, 2);
|
|
124
|
+
if (actual !== expected) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Declared surface diverged from the committed snapshot — if the change is intended, regenerate it with "bun run surface:snapshot" and review the diff.\nsnapshot: ${expected}\nactual: ${actual}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
92
131
|
export function assertSurfaceConformance({
|
|
93
132
|
manifest,
|
|
94
133
|
openApi,
|
|
95
134
|
mcpToolNames,
|
|
135
|
+
agentToolNames,
|
|
136
|
+
cliToolNames,
|
|
137
|
+
metadata = 'require',
|
|
96
138
|
}: {
|
|
97
139
|
manifest: readonly SurfaceManifestOperation[];
|
|
98
140
|
openApi: Pick<OpenApiDocument, 'paths'>;
|
|
99
141
|
mcpToolNames: readonly string[];
|
|
142
|
+
agentToolNames: readonly string[];
|
|
143
|
+
cliToolNames: readonly string[];
|
|
144
|
+
/**
|
|
145
|
+
* `'require'` (default) — the OpenAPI document must carry `x-stitchkit-*`
|
|
146
|
+
* metadata and it is compared; `'ignore'` — an explicitly declared mode for
|
|
147
|
+
* standard documents. A silent skip is not an option.
|
|
148
|
+
*/
|
|
149
|
+
metadata?: 'require' | 'ignore';
|
|
100
150
|
}): void {
|
|
151
|
+
const openApiOperations = Object.entries(openApi.paths).flatMap(([path, item]) =>
|
|
152
|
+
Object.entries(item)
|
|
153
|
+
.filter(([method]) => ['get', 'post', 'put', 'patch', 'delete', 'head'].includes(method))
|
|
154
|
+
.map(([method, operation]) => ({ method: method.toUpperCase(), path, operation })),
|
|
155
|
+
);
|
|
101
156
|
assertSameSet(
|
|
102
157
|
'HTTP/OpenAPI',
|
|
103
158
|
manifest.flatMap((operation) =>
|
|
104
159
|
operation.http.map((entry) => `${entry.method}:${entry.path}`),
|
|
105
160
|
),
|
|
106
|
-
|
|
107
|
-
Object.keys(item)
|
|
108
|
-
.filter((method) => ['get', 'post', 'put', 'patch', 'delete', 'head'].includes(method))
|
|
109
|
-
.map((method) => `${method.toUpperCase()}:${path}`),
|
|
110
|
-
),
|
|
161
|
+
openApiOperations.map(({ method, path }) => `${method}:${path}`),
|
|
111
162
|
);
|
|
163
|
+
const carriesContractMetadata = openApiOperations.some(({ operation }) =>
|
|
164
|
+
OpenApiOperationMetadataPresenceSchema.parse(operation),
|
|
165
|
+
);
|
|
166
|
+
if (metadata === 'require' && openApiOperations.length > 0 && !carriesContractMetadata) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'OpenAPI document carries no x-stitchkit-* contract metadata — pass metadata: "ignore" only for a deliberately standard document',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (metadata === 'require' && carriesContractMetadata) {
|
|
172
|
+
assertSameSet(
|
|
173
|
+
'HTTP/OpenAPI contract metadata',
|
|
174
|
+
manifest.flatMap((operation) =>
|
|
175
|
+
operation.http.map(
|
|
176
|
+
(entry) =>
|
|
177
|
+
`${entry.method}:${entry.path}:${operation.scope}:${operation.hasInput}:${operation.hasOutput}`,
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
openApiOperations.map(({ method, path, operation }) => {
|
|
181
|
+
const metadata = OpenApiOperationMetadataSchema.parse(operation);
|
|
182
|
+
return `${method}:${path}:${metadata['x-stitchkit-scope']}:${metadata['x-stitchkit-has-input']}:${metadata['x-stitchkit-has-output']}`;
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
112
186
|
assertSameSet(
|
|
113
187
|
'MCP discovery',
|
|
114
188
|
manifest.flatMap((operation) => (operation.tools.MCP ? [operation.tools.MCP] : [])),
|
|
115
189
|
mcpToolNames,
|
|
116
190
|
);
|
|
191
|
+
assertSameSet(
|
|
192
|
+
'AGENT mount',
|
|
193
|
+
manifest.flatMap((operation) => (operation.tools.AGENT ? [operation.tools.AGENT] : [])),
|
|
194
|
+
agentToolNames,
|
|
195
|
+
);
|
|
196
|
+
assertSameSet(
|
|
197
|
+
'CLI manifest',
|
|
198
|
+
manifest.flatMap((operation) => (operation.tools.CLI ? [operation.tools.CLI] : [])),
|
|
199
|
+
cliToolNames,
|
|
200
|
+
);
|
|
117
201
|
}
|
|
202
|
+
|
|
203
|
+
const OpenApiOperationMetadataSchema = z.object({
|
|
204
|
+
'x-stitchkit-scope': z.string(),
|
|
205
|
+
'x-stitchkit-has-input': z.boolean(),
|
|
206
|
+
'x-stitchkit-has-output': z.boolean(),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const OpenApiOperationMetadataPresenceSchema = z
|
|
210
|
+
.object({
|
|
211
|
+
'x-stitchkit-scope': z.unknown().optional(),
|
|
212
|
+
'x-stitchkit-has-input': z.unknown().optional(),
|
|
213
|
+
'x-stitchkit-has-output': z.unknown().optional(),
|
|
214
|
+
})
|
|
215
|
+
.transform(
|
|
216
|
+
(metadata) =>
|
|
217
|
+
metadata['x-stitchkit-scope'] !== undefined ||
|
|
218
|
+
metadata['x-stitchkit-has-input'] !== undefined ||
|
|
219
|
+
metadata['x-stitchkit-has-output'] !== undefined,
|
|
220
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"service": "system",
|
|
4
|
+
"action": "status",
|
|
5
|
+
"scope": "public",
|
|
6
|
+
"hasInput": false,
|
|
7
|
+
"hasOutput": true,
|
|
8
|
+
"inputShape": null,
|
|
9
|
+
"outputShape": "58b078ade3ee6167",
|
|
10
|
+
"http": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/api/system/status"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"tools": {}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { env } from '@app/config';
|
|
2
2
|
import { createSocketIOServer } from 'stitchkit/server';
|
|
3
|
+
import { createSystemService } from './transport/system-service';
|
|
3
4
|
|
|
4
5
|
export async function createSurface() {
|
|
5
6
|
const socket = await createSocketIOServer({ cors: { origin: env.CORS_ORIGIN } });
|
|
6
|
-
return { socket, services: [] };
|
|
7
|
+
return { socket, services: [createSystemService()] };
|
|
7
8
|
}
|
|
@@ -5,11 +5,14 @@ const { services, socket } = await createSurface();
|
|
|
5
5
|
|
|
6
6
|
try {
|
|
7
7
|
const surface = { services };
|
|
8
|
+
const toolNames = listToolNames(surface)
|
|
9
|
+
.filter((tool) => tool.transports.some((transport) => transport !== 'HTTP'))
|
|
10
|
+
.map((tool) => tool.name);
|
|
8
11
|
console.log(
|
|
9
12
|
JSON.stringify(
|
|
10
13
|
{
|
|
11
14
|
manifest: buildToolManifest({ services, transport: 'AGENT' }),
|
|
12
|
-
names:
|
|
15
|
+
names: toolNames,
|
|
13
16
|
transports: summarizeTransports(surface),
|
|
14
17
|
},
|
|
15
18
|
null,
|
|
@@ -16,10 +16,7 @@ export const env = createEnv({
|
|
|
16
16
|
INTERNAL_API_URL: z.url(),
|
|
17
17
|
NEXT_PUBLIC_WEB_URL: z.url(),
|
|
18
18
|
LOG_FORMAT: z.enum(['pretty', 'json']).default('pretty'),
|
|
19
|
-
CORS_ORIGIN: z.
|
|
20
|
-
DEV_HTTPS_CERT: z.string().min(1).optional(),
|
|
21
|
-
DEV_HTTPS_KEY: z.string().min(1).optional(),
|
|
22
|
-
DEV_HTTPS_CA: z.string().min(1).optional(),
|
|
19
|
+
CORS_ORIGIN: z.url(),
|
|
23
20
|
...featureServerSchema,
|
|
24
21
|
},
|
|
25
22
|
runtimeEnv: process.env,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { getQueryClient } from './query-client';
|
|
3
|
+
|
|
4
|
+
describe('query dehydration policy', () => {
|
|
5
|
+
test('includes successful prefetched data and pending streamed queries', () => {
|
|
6
|
+
const queryClient = getQueryClient();
|
|
7
|
+
queryClient.setQueryData(['prefetched-project'], { id: 'project-1' });
|
|
8
|
+
void queryClient.prefetchQuery({
|
|
9
|
+
queryKey: ['pending-project'],
|
|
10
|
+
queryFn: () => new Promise(() => undefined),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const shouldDehydrate = queryClient.getDefaultOptions().dehydrate?.shouldDehydrateQuery;
|
|
14
|
+
if (!shouldDehydrate) throw new Error('dehydration policy is required');
|
|
15
|
+
const successful = queryClient.getQueryCache().find({ queryKey: ['prefetched-project'] });
|
|
16
|
+
const pending = queryClient.getQueryCache().find({ queryKey: ['pending-project'] });
|
|
17
|
+
if (!successful || !pending) throw new Error('test queries were not created');
|
|
18
|
+
|
|
19
|
+
expect(shouldDehydrate(successful)).toBe(true);
|
|
20
|
+
expect(shouldDehydrate(pending)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import { isServer, QueryClient } from '@tanstack/react-query';
|
|
1
|
+
import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { cache } from 'react';
|
|
3
3
|
|
|
4
4
|
function createQueryClient(): QueryClient {
|
|
5
5
|
return new QueryClient({
|
|
6
6
|
defaultOptions: {
|
|
7
7
|
queries: { staleTime: 30_000, retry: 1 },
|
|
8
|
-
dehydrate: {
|
|
8
|
+
dehydrate: {
|
|
9
|
+
// `pending` queries dehydrate too: a server component may kick off a
|
|
10
|
+
// prefetch without awaiting it, and streaming SSR hands the in-flight
|
|
11
|
+
// promise to the client, which resumes it instead of refetching. The
|
|
12
|
+
// default predicate would drop exactly those queries and reintroduce
|
|
13
|
+
// the client-side loading flash.
|
|
14
|
+
shouldDehydrateQuery: (query) =>
|
|
15
|
+
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
|
|
16
|
+
},
|
|
9
17
|
},
|
|
10
18
|
});
|
|
11
19
|
}
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
"plugins": [{ "name": "next" }],
|
|
9
9
|
"paths": { "@/*": ["./src/*"] }
|
|
10
10
|
},
|
|
11
|
-
"include": [
|
|
11
|
+
"include": [
|
|
12
|
+
"next-env.d.ts",
|
|
13
|
+
"next.config.ts",
|
|
14
|
+
"src/**/*.ts",
|
|
15
|
+
"src/**/*.tsx",
|
|
16
|
+
".next/types/**/*.ts"
|
|
17
|
+
],
|
|
12
18
|
"exclude": ["node_modules"]
|
|
13
19
|
}
|