create-stitchkit 0.2.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 +77 -0
- package/dist/cli.js +7 -16
- package/examples/repository/_env.example.append +2 -0
- package/examples/repository/e2e/repository.spec.ts +76 -2
- package/examples/repository/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- 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/config/src/features.ts +1 -0
- package/examples/repository/packages/frontend/src/components/repository-summary.tsx +7 -3
- 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 +100 -19
- 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 +21 -13
- package/template/ecosystem.dev.config.cjs +0 -12
- package/template/package.json +5 -3
- package/template/packages/backend/src/cli.ts +1 -1
- package/template/packages/backend/src/index.ts +27 -24
- 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 +5 -2
- 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 +14 -4
- package/template/scripts/surface-conformance.ts +77 -16
- package/template/scripts/surface-snapshot.ts +21 -0
- package/template/scripts/tooling-env.ts +16 -3
- package/template/scripts/web-surface-smoke.ts +28 -0
- 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,83 @@ 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
|
+
|
|
26
|
+
## [0.3.0] — 2026-08-10
|
|
27
|
+
|
|
28
|
+
### ⚠️ Breaking changes
|
|
29
|
+
|
|
30
|
+
- **Generated projects ship no `.env`.** The single environment source is
|
|
31
|
+
`.env.example`; `bun run env:ensure` (and every tooling entry point,
|
|
32
|
+
self-healing before validation) renders `.env` with the application-derived
|
|
33
|
+
database name. A clone and a post-generation rename now produce the same
|
|
34
|
+
database, and the second-developer path (`runtime:smoke`, `e2e`) works
|
|
35
|
+
unaided.
|
|
36
|
+
`// before: scaffold writes .env with a neutral database name` →
|
|
37
|
+
`// after: .env is rendered on first run from .env.example`
|
|
38
|
+
|
|
39
|
+
- **Starter-owned LAN HTTPS is removed.** Generated applications no longer ship
|
|
40
|
+
`dev:lan`, certificate generation, onboarding routes or `DEV_HTTPS_*` settings.
|
|
41
|
+
Applications that need a trusted device-testing origin should own certificate
|
|
42
|
+
creation and pass TLS files through Stitchkit's documented `bun.tls` boundary.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **Surface conformance is anchored and total.** The declared surface is
|
|
47
|
+
compared against a committed `surface.snapshot.json` (with schema-shape
|
|
48
|
+
digests, regenerated deliberately via `bun run surface:snapshot`), the CLI is
|
|
49
|
+
observed by spawning the real process, missing `x-stitchkit-*` metadata is an
|
|
50
|
+
error unless a standard-document mode is declared explicitly, and the starter
|
|
51
|
+
lane sweeps generated trees for neutral-identity leaks against a committed
|
|
52
|
+
allowlist.
|
|
53
|
+
- **The template targets Stitchkit 0.46.** The error-code map covers
|
|
54
|
+
`REALTIME_CONTRACT_VIOLATION` and conformance validates the operation
|
|
55
|
+
metadata 0.46 emits.
|
|
56
|
+
- **Scaffold identity is rendered from one config.** The generated
|
|
57
|
+
`app.config.json` drives runtime identity and database naming; only the root
|
|
58
|
+
package manifest is structurally projected, with no global text search or
|
|
59
|
+
inert lockfile rewrite.
|
|
60
|
+
- **Development is explicit and portable.** `bun run dev` validates PM2 and
|
|
61
|
+
external PostgreSQL requirements before side effects. The removed LAN HTTPS
|
|
62
|
+
mode is documented as application-owned framework configuration instead of a
|
|
63
|
+
starter subsystem.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **Fresh clones bootstrap in the right order.** The starter materializes its
|
|
68
|
+
local environment before Prisma/type gates and reports a missing or placeholder
|
|
69
|
+
`DATABASE_URL` directly.
|
|
70
|
+
- **Every executable source is checked.** Root TypeScript coverage includes
|
|
71
|
+
scripts and browser E2E; the authored-source guard scans CJS and reports the
|
|
72
|
+
real offending line.
|
|
73
|
+
- **Runtime and browser gates prove the backend.** Surface conformance compares
|
|
74
|
+
HTTP/OpenAPI, MCP, Agent and CLI identities and schemas; browser E2E performs
|
|
75
|
+
a contract call and realtime cache update.
|
|
76
|
+
- **Repository example uses the canonical realtime contract.** Shared Zod event
|
|
77
|
+
definitions drive backend emission, browser subscriptions and cache bridging
|
|
78
|
+
without handwritten event maps.
|
|
79
|
+
|
|
80
|
+
### Removed
|
|
81
|
+
|
|
82
|
+
- **Starter-owned LAN HTTPS.** `dev:lan`, certificate generation, onboarding
|
|
83
|
+
transport and `DEV_HTTPS_*` settings are gone; trusted local TLS remains an
|
|
84
|
+
application-owned adapter choice documented by Stitchkit.
|
|
85
|
+
|
|
9
86
|
## [0.2.0] — 2026-08-10
|
|
10
87
|
|
|
11
88
|
### ⚠️ 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,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,78 @@
|
|
|
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
|
+
}
|
|
23
|
+
|
|
24
|
+
test('prefetched data hydrates without a loading flash or a client refetch', async ({
|
|
25
|
+
page,
|
|
26
|
+
request,
|
|
27
|
+
}) => {
|
|
28
|
+
// The SSR document itself carries the prefetched repository data — the
|
|
29
|
+
// dehydration envelope is not empty.
|
|
30
|
+
const document = await request.get('/en');
|
|
31
|
+
expect(await document.text()).toContain('max-listov/stitchkit');
|
|
32
|
+
|
|
33
|
+
// Block the browser-side read entirely: the page must still render the data
|
|
34
|
+
// from the hydration envelope — never refetching, never flashing a loader.
|
|
35
|
+
let clientReads = 0;
|
|
36
|
+
await page.route('**/api/repository', async (route) => {
|
|
37
|
+
if (route.request().method() === 'GET') {
|
|
38
|
+
clientReads += 1;
|
|
39
|
+
await route.abort();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await route.continue();
|
|
43
|
+
});
|
|
44
|
+
await page.goto('/en');
|
|
45
|
+
await expectRepositorySummary(page);
|
|
46
|
+
expect(clientReads).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a server realtime event updates the TanStack cache without a client refetch', async ({
|
|
50
|
+
context,
|
|
51
|
+
page,
|
|
52
|
+
}) => {
|
|
53
|
+
await gotoWithRealtime(page);
|
|
54
|
+
const summary = page.getByTestId('repository-summary');
|
|
55
|
+
await expect(summary).toBeVisible();
|
|
56
|
+
const before = await summary.getAttribute('data-fetched-at');
|
|
57
|
+
expect(before).not.toBeNull();
|
|
58
|
+
|
|
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.
|
|
62
|
+
await page.route('**/api/repository', async (route) => {
|
|
63
|
+
if (route.request().method() === 'GET') {
|
|
64
|
+
await route.abort();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await route.continue();
|
|
68
|
+
});
|
|
69
|
+
const trigger = await context.newPage();
|
|
70
|
+
await trigger.goto('/en');
|
|
71
|
+
await trigger.getByRole('button', { name: 'Refresh repository data' }).click();
|
|
72
|
+
await expect(summary).not.toHaveAttribute('data-fetched-at', before ?? '', {
|
|
73
|
+
timeout: 10_000,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
2
76
|
|
|
3
77
|
test('renders and refreshes the repository example', async ({ page }) => {
|
|
4
78
|
await page.route('**/api/repository/refresh', async (route) => {
|
|
@@ -6,7 +80,7 @@ test('renders and refreshes the repository example', async ({ page }) => {
|
|
|
6
80
|
await route.continue();
|
|
7
81
|
});
|
|
8
82
|
await page.goto('/en');
|
|
9
|
-
await
|
|
83
|
+
await expectRepositorySummary(page);
|
|
10
84
|
|
|
11
85
|
const refresh = page.getByRole('button', { name: 'Refresh repository data' });
|
|
12
86
|
await expect(refresh).toHaveCSS('height', '32px');
|
|
@@ -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) => {
|
|
@@ -155,12 +155,12 @@ export class GitHubRepositoryCache {
|
|
|
155
155
|
|
|
156
156
|
try {
|
|
157
157
|
const [repositoryResponse, commitsResponse] = await Promise.all([
|
|
158
|
-
this.fetcher(new URL(`/repos/${repositoryName}`,
|
|
158
|
+
this.fetcher(new URL(`/repos/${repositoryName}`, env.GITHUB_API_URL), {
|
|
159
159
|
headers: githubHeaders(),
|
|
160
160
|
signal: AbortSignal.timeout(8_000),
|
|
161
161
|
}),
|
|
162
162
|
this.fetcher(
|
|
163
|
-
new URL(`/repos/${repositoryName}/commits?per_page=1`,
|
|
163
|
+
new URL(`/repos/${repositoryName}/commits?per_page=1`, env.GITHUB_API_URL),
|
|
164
164
|
{ headers: githubHeaders(), signal: AbortSignal.timeout(8_000) },
|
|
165
165
|
),
|
|
166
166
|
]);
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
export const featureServerSchema = {
|
|
4
|
+
GITHUB_API_URL: z.url().default('https://api.github.com'),
|
|
4
5
|
GITHUB_REPOSITORY: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
|
|
5
6
|
GITHUB_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(900),
|
|
6
7
|
GITHUB_TOKEN: z.string().min(1).optional(),
|
|
@@ -30,8 +30,8 @@ export function RepositorySummary() {
|
|
|
30
30
|
const queryClient = useQueryClient();
|
|
31
31
|
const repository = useRepository();
|
|
32
32
|
const refresh = useRefreshRepository({
|
|
33
|
-
onSuccess:
|
|
34
|
-
|
|
33
|
+
onSuccess: (snapshot) => {
|
|
34
|
+
queryClient.setQueryData(useRepository.getKey(), snapshot);
|
|
35
35
|
},
|
|
36
36
|
});
|
|
37
37
|
|
|
@@ -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,11 @@
|
|
|
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
|
+
import { assertPublicWebSurface } from './web-surface-smoke';
|
|
6
7
|
|
|
8
|
+
const toolingEnv = loadToolingEnv();
|
|
7
9
|
const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
|
|
8
10
|
|
|
9
11
|
async function json(path: string, init?: RequestInit): Promise<unknown> {
|
|
@@ -24,33 +26,39 @@ await runSurfaceConformance({
|
|
|
24
26
|
fixture: { path: '/api/repository/refresh' },
|
|
25
27
|
output: RepositorySnapshotSchema,
|
|
26
28
|
run: async ({ path }) => {
|
|
27
|
-
const
|
|
29
|
+
const rejected: string[] = [];
|
|
30
|
+
const socket = createRealtimeClient(repositoryRealtimeContract, {
|
|
31
|
+
url: apiOrigin,
|
|
32
|
+
transports: ['websocket'],
|
|
33
|
+
onRejected: ({ event, direction, phase }) => {
|
|
34
|
+
rejected.push(`${event}:${direction}:${phase}`);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
28
37
|
try {
|
|
29
38
|
await new Promise<void>((resolve, reject) => {
|
|
30
39
|
const timeout = setTimeout(
|
|
31
40
|
() => reject(new Error('Socket.IO connection timed out')),
|
|
32
41
|
5_000,
|
|
33
42
|
);
|
|
34
|
-
const
|
|
43
|
+
const unsubscribe = socket.onConnectionChange((connected, reason) => {
|
|
44
|
+
if (!connected) {
|
|
45
|
+
if (reason) reject(new Error(`Socket.IO disconnected: ${reason}`));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
35
48
|
clearTimeout(timeout);
|
|
36
|
-
|
|
49
|
+
unsubscribe();
|
|
37
50
|
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);
|
|
51
|
+
});
|
|
52
|
+
socket.connect();
|
|
46
53
|
});
|
|
47
54
|
const refreshedEvent = new Promise<string>((resolve, reject) => {
|
|
48
55
|
const timeout = setTimeout(
|
|
49
56
|
() => reject(new Error('Socket.IO repository refresh event timed out')),
|
|
50
57
|
5_000,
|
|
51
58
|
);
|
|
52
|
-
socket.
|
|
59
|
+
const unsubscribe = socket.on('repository:refreshed', (snapshot) => {
|
|
53
60
|
clearTimeout(timeout);
|
|
61
|
+
unsubscribe();
|
|
54
62
|
resolve(snapshot.fullName);
|
|
55
63
|
});
|
|
56
64
|
});
|
|
@@ -64,9 +72,77 @@ await runSurfaceConformance({
|
|
|
64
72
|
if (cached.fullName !== refreshed.fullName) {
|
|
65
73
|
throw new Error('Repository cache read differs');
|
|
66
74
|
}
|
|
75
|
+
if (rejected.length > 0) {
|
|
76
|
+
throw new Error(`Realtime contract rejected ${rejected.join(', ')}`);
|
|
77
|
+
}
|
|
67
78
|
return refreshed;
|
|
68
79
|
} finally {
|
|
69
|
-
socket.
|
|
80
|
+
socket.disconnect();
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
defineSurfaceProbe({
|
|
85
|
+
name: 'realtime rejection path fires on a contract mismatch',
|
|
86
|
+
input: z.object({ path: z.literal('/api/repository/refresh') }),
|
|
87
|
+
fixture: { path: '/api/repository/refresh' },
|
|
88
|
+
run: async ({ path }) => {
|
|
89
|
+
// NEGATIVE probe: a client whose local contract disagrees with the
|
|
90
|
+
// server must see the real payload REJECTED — this executes the
|
|
91
|
+
// rejection path instead of merely asserting its absence. Matched by
|
|
92
|
+
// event/direction/phase, never by message text.
|
|
93
|
+
const divergentContract = defineRealtimeContract({
|
|
94
|
+
serverToClient: {
|
|
95
|
+
'repository:refreshed': { args: z.tuple([z.object({ bogus: z.string() })]) },
|
|
96
|
+
},
|
|
97
|
+
clientToServer: {},
|
|
98
|
+
});
|
|
99
|
+
const rejections: Array<{ event: string; direction: string; phase: string }> = [];
|
|
100
|
+
const socket = createRealtimeClient(divergentContract, {
|
|
101
|
+
url: apiOrigin,
|
|
102
|
+
transports: ['websocket'],
|
|
103
|
+
onRejected: ({ event, direction, phase }) => {
|
|
104
|
+
rejections.push({ event, direction, phase });
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
await new Promise<void>((resolve, reject) => {
|
|
109
|
+
const timeout = setTimeout(
|
|
110
|
+
() => reject(new Error('Socket.IO connection timed out')),
|
|
111
|
+
5_000,
|
|
112
|
+
);
|
|
113
|
+
const unsubscribe = socket.onConnectionChange((connected) => {
|
|
114
|
+
if (!connected) return;
|
|
115
|
+
clearTimeout(timeout);
|
|
116
|
+
unsubscribe();
|
|
117
|
+
resolve();
|
|
118
|
+
});
|
|
119
|
+
socket.connect();
|
|
120
|
+
});
|
|
121
|
+
// A handler must be attached for the inbound frame to be validated.
|
|
122
|
+
const unsubscribe = socket.on('repository:refreshed', () => {
|
|
123
|
+
throw new Error('a payload outside the local contract reached the handler');
|
|
124
|
+
});
|
|
125
|
+
await json(path, { method: 'POST' });
|
|
126
|
+
const deadline = Date.now() + 5_000;
|
|
127
|
+
while (rejections.length === 0 && Date.now() < deadline) {
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
129
|
+
}
|
|
130
|
+
unsubscribe();
|
|
131
|
+
const rejection = rejections[0];
|
|
132
|
+
if (!rejection) {
|
|
133
|
+
throw new Error('the contract mismatch was never rejected');
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
rejection.event !== 'repository:refreshed' ||
|
|
137
|
+
rejection.direction !== 'client-inbound' ||
|
|
138
|
+
rejection.phase !== 'arguments'
|
|
139
|
+
) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`unexpected rejection identity: ${rejection.event}:${rejection.direction}:${rejection.phase}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
socket.disconnect();
|
|
70
146
|
}
|
|
71
147
|
},
|
|
72
148
|
}),
|
|
@@ -77,8 +153,11 @@ await runSurfaceConformance({
|
|
|
77
153
|
const corsResponse = await fetch(`${apiOrigin}/api/repository/`, {
|
|
78
154
|
headers: { Origin: 'http://localhost:58302' },
|
|
79
155
|
});
|
|
80
|
-
if (
|
|
81
|
-
|
|
156
|
+
if (
|
|
157
|
+
corsResponse.headers.get('access-control-allow-origin') !==
|
|
158
|
+
new URL(toolingEnv.NEXT_PUBLIC_WEB_URL).origin
|
|
159
|
+
) {
|
|
160
|
+
throw new Error('API CORS origin differs from the configured web origin');
|
|
82
161
|
}
|
|
83
162
|
const openApi = z
|
|
84
163
|
.object({ paths: z.record(z.string(), z.unknown()) })
|
|
@@ -88,4 +167,6 @@ await runSurfaceConformance({
|
|
|
88
167
|
}
|
|
89
168
|
}
|
|
90
169
|
|
|
91
|
-
|
|
170
|
+
await assertPublicWebSurface(toolingEnv.NEXT_PUBLIC_WEB_URL);
|
|
171
|
+
|
|
172
|
+
console.log('Runtime HTTP, OpenAPI, Socket.IO, MCP and public web smoke passed');
|
package/package.json
CHANGED
package/template/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:
|