create-stitchkit 0.6.3 → 0.6.4
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 +29 -0
- package/examples/repository/_env.example.append +0 -14
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +2 -0
- package/package.json +2 -2
- package/template/README.md +6 -4
- package/template/_env.example +14 -2
- package/template/bun.lock +2 -2
- package/template/e2e/starter.spec.ts +18 -0
- package/template/package.json +1 -1
- package/{examples/repository → template}/packages/frontend/src/app/api/[...path]/route.ts +15 -7
- package/template/packages/frontend/src/features/board/board-live.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,35 @@ step is overwritten by the next release.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.6.4] — 2026-09-23
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **The board reaches the API on two local ports.** The base template's board
|
|
20
|
+
posts to its own origin (`/api/…`), and the README said the web role forwards
|
|
21
|
+
that — but the forwarding route lived only in the repository example, so a
|
|
22
|
+
blank scaffold answered 404. The route
|
|
23
|
+
(`packages/frontend/src/app/api/[...path]/route.ts`) is now in the base
|
|
24
|
+
template, and says in words when `INTERNAL_API_URL` is unset.
|
|
25
|
+
- **The browser build carries the socket client.** The board passes
|
|
26
|
+
`peers: { client: () => import('socket.io-client') }` to
|
|
27
|
+
`createRealtimeClient`; without the literal loader the bundle had no
|
|
28
|
+
`socket.io-client` and the page failed with "needs the socket.io-client peer".
|
|
29
|
+
- **The socket dials the API role in development.** `.env.example` now sets
|
|
30
|
+
`INTERNAL_API_URL`, `PUBLIC_REALTIME_ORIGIN` and `CORS_ORIGIN` for the two
|
|
31
|
+
local ports, and the repository example passes its realtime origin to the
|
|
32
|
+
board. A two-tab browser test proves a note posted in one tab reaches the
|
|
33
|
+
other in both variants.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **A scaffold starts on stitchkit 0.95.3**, whose watch client no longer drops
|
|
38
|
+
the values of a restarted API. The template's range moves from `^0.94.0` to
|
|
39
|
+
`^0.95.3`, over a lockfile resolving 0.95.3.
|
|
40
|
+
|
|
41
|
+
A project generated from 0.6.3 adds the route file above, the `peers` line in
|
|
42
|
+
`features/board/board-live.ts` and the three variables to its `.env`.
|
|
43
|
+
|
|
15
44
|
## [0.6.3] — 2026-09-23
|
|
16
45
|
|
|
17
46
|
### Changed
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
# The web role reaches the API role internally, and forwards the browser's
|
|
2
|
-
# same-origin `/api/…` calls to it. This one is required.
|
|
3
|
-
INTERNAL_API_URL=http://127.0.0.1:3211
|
|
4
|
-
|
|
5
|
-
# The realtime socket, and ONLY it. A WebSocket upgrade does not survive the
|
|
6
|
-
# route handler that forwards `/api`, so two roles on two loopback ports must
|
|
7
|
-
# name the socket's origin even though their HTTP is already same-origin.
|
|
8
|
-
# Behind one routing layer that forwards `/socket.io`, leave this unset.
|
|
9
|
-
PUBLIC_REALTIME_ORIGIN=http://127.0.0.1:3211
|
|
10
|
-
|
|
11
|
-
# The browser origin the API role admits — for HTTP and for the realtime
|
|
12
|
-
# handshake alike. Needed here because the socket above is cross-origin.
|
|
13
|
-
CORS_ORIGIN=http://127.0.0.1:3210
|
|
14
|
-
|
|
15
1
|
# THE CROSS-ORIGIN HTTP VARIANT — unset, and unnecessary for this example.
|
|
16
2
|
# Set it only for a frontend that dials the API role itself instead of calling
|
|
17
3
|
# its own `/api`: separate hostnames with nothing in front of them. Setting it
|
|
@@ -3,6 +3,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
|
|
3
3
|
import type { Metadata } from 'next';
|
|
4
4
|
import { getTranslations } from 'next-intl/server';
|
|
5
5
|
import { LocaleSchema } from '@/i18n/locales';
|
|
6
|
+
import { publicRealtimeOrigin } from '@/lib/api/place';
|
|
6
7
|
import { useRepository } from '@/lib/api/queries';
|
|
7
8
|
import { createServerRepositoryApi } from '@/lib/api/server-client';
|
|
8
9
|
import { getQueryClient } from '@/lib/query-client';
|
|
@@ -39,6 +40,7 @@ export default async function Page({ params }: { params: Promise<{ locale: strin
|
|
|
39
40
|
heroTitle={t('heroTitle')}
|
|
40
41
|
catalogueLabel={t('ui')}
|
|
41
42
|
locale={appLocale}
|
|
43
|
+
realtimeOrigin={publicRealtimeOrigin()}
|
|
42
44
|
/>
|
|
43
45
|
</HydrationBoundary>
|
|
44
46
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-stitchkit",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Create a production-shaped Stitchkit application",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Max Listov <maxlistov@gmail.com>",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"@types/react": "^19.3.0",
|
|
84
84
|
"ai": "^7.0.111",
|
|
85
85
|
"react": "^19.3.0",
|
|
86
|
-
"stitchkit": "0.
|
|
86
|
+
"stitchkit": "0.95.3",
|
|
87
87
|
"typescript": "^7.0.2"
|
|
88
88
|
},
|
|
89
89
|
"engines": {
|
package/template/README.md
CHANGED
|
@@ -72,10 +72,12 @@ Two things can pull a deployment out of that, and they are separate questions,
|
|
|
72
72
|
so they have separate variables. **`PUBLIC_REALTIME_ORIGIN`** is the socket: a
|
|
73
73
|
WebSocket upgrade does not survive the route handler that forwards `/api`, so
|
|
74
74
|
two roles on two ports with nothing in front of them must name the socket's
|
|
75
|
-
origin even though their HTTP is already same-origin
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
origin even though their HTTP is already same-origin — `.env.example` sets it,
|
|
76
|
+
with `CORS_ORIGIN` admitting the web origin, for the two local ports. Behind one
|
|
77
|
+
routing layer that forwards `/socket.io`, leave both unset.
|
|
78
|
+
**`PUBLIC_API_ORIGIN`** is HTTP, for a frontend that genuinely dials the API
|
|
79
|
+
role itself — and setting it changes nothing on its own: with
|
|
80
|
+
`--example repository`, switching is one import in
|
|
79
81
|
`packages/frontend/src/lib/api/queries.ts`, documented in
|
|
80
82
|
`packages/frontend/src/lib/api/cross-origin.ts`. That variant costs a
|
|
81
83
|
server-delivered address, a client built on first use (hence the parentheses),
|
package/template/_env.example
CHANGED
|
@@ -22,5 +22,17 @@ SMOKE_WEB_ORIGIN=http://127.0.0.1:3210
|
|
|
22
22
|
# carries. List here only the hosts this deployment really answers for.
|
|
23
23
|
PUBLIC_WEB_HOSTS=127.0.0.1:3210
|
|
24
24
|
LOG_FORMAT=pretty
|
|
25
|
-
|
|
26
|
-
#
|
|
25
|
+
|
|
26
|
+
# The web role reaches the API role internally, and forwards the browser's
|
|
27
|
+
# same-origin `/api/…` calls to it (packages/frontend/src/app/api/[...path]).
|
|
28
|
+
INTERNAL_API_URL=http://127.0.0.1:3211
|
|
29
|
+
|
|
30
|
+
# The realtime socket, and ONLY it. A WebSocket upgrade does not survive the
|
|
31
|
+
# route handler that forwards `/api`, so two roles on two loopback ports must
|
|
32
|
+
# name the socket's origin even though their HTTP is already same-origin.
|
|
33
|
+
# Behind one routing layer that forwards `/socket.io`, leave this unset.
|
|
34
|
+
PUBLIC_REALTIME_ORIGIN=http://127.0.0.1:3211
|
|
35
|
+
|
|
36
|
+
# The browser origin the API role admits — for HTTP and for the realtime
|
|
37
|
+
# handshake alike. Needed here because the socket above is cross-origin.
|
|
38
|
+
CORS_ORIGIN=http://127.0.0.1:3210
|
package/template/bun.lock
CHANGED
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"zod": "4.6.5",
|
|
146
146
|
},
|
|
147
147
|
"catalog": {
|
|
148
|
-
"stitchkit": "^0.
|
|
148
|
+
"stitchkit": "^0.95.3",
|
|
149
149
|
},
|
|
150
150
|
"packages": {
|
|
151
151
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.89", "", { "dependencies": { "@ai-sdk/provider": "4.0.17", "@ai-sdk/provider-utils": "5.0.45", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-n0Q88UUASYQBGK3Ogs9pCe8h3ZXeTHFSuGOLBJAyH73MVGNKUv9w8EJX2f340WUF3eDWWOV305gO7YBpSCPblA=="],
|
|
@@ -1128,7 +1128,7 @@
|
|
|
1128
1128
|
|
|
1129
1129
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
|
1130
1130
|
|
|
1131
|
-
"stitchkit": ["stitchkit@0.
|
|
1131
|
+
"stitchkit": ["stitchkit@0.95.3", "", { "dependencies": { "ky": "^2.1.0" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2 || ^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.1.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.2", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.4.2", "ai": "^7.0.107", "google-auth-library": "^11.1.0", "grammy": "^1.46.0", "maxmind": "^5.0.7", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5 || ^1.0.5", "zod": "^4.6.5" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "google-auth-library", "grammy", "maxmind", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"], "bin": { "stitchkit": "dist/entrypoints/bin/upgrade-cli.js" } }, "sha512-IVIf7CF951Q4Olfiu+wONxAmc802zvPwGYWjNo+657OHua4gzc91XNo4/zmmPPZzkvA6iyOQmzFurgrbmJ9SrQ=="],
|
|
1132
1132
|
|
|
1133
1133
|
"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=="],
|
|
1134
1134
|
|
|
@@ -35,6 +35,24 @@ test('calls the live backend through the typed contract client', async () => {
|
|
|
35
35
|
}).toPass({ timeout: 5_000 });
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test('a note posted in one tab reaches another through the web origin and the socket', async ({
|
|
39
|
+
browser,
|
|
40
|
+
}) => {
|
|
41
|
+
// The browser's own origin carries HTTP (`/api` is forwarded by the web
|
|
42
|
+
// role) and the socket dials the API role; a post in one tab must reach a
|
|
43
|
+
// second tab that never asked for it. Both halves failed silently before:
|
|
44
|
+
// `/api` was a 404 on the web port, and the browser build had no socket.
|
|
45
|
+
const writer = await browser.newPage();
|
|
46
|
+
const reader = await browser.newPage();
|
|
47
|
+
await Promise.all([writer.goto('/en'), reader.goto('/en')]);
|
|
48
|
+
const note = `note ${Date.now()}`;
|
|
49
|
+
await writer.getByRole('textbox', { name: 'Note' }).fill(note);
|
|
50
|
+
await writer.getByRole('button', { name: 'Post' }).click();
|
|
51
|
+
await expect(writer.getByText(note)).toBeVisible();
|
|
52
|
+
await expect(reader.getByText(note)).toBeVisible({ timeout: 10_000 });
|
|
53
|
+
await Promise.all([writer.close(), reader.close()]);
|
|
54
|
+
});
|
|
55
|
+
|
|
38
56
|
test('publishes complete page metadata', async ({ page }) => {
|
|
39
57
|
await page.goto('/en/ui/themes');
|
|
40
58
|
await expect(page).toHaveTitle(`Theme system · ${appDeclaration.identity.name}`);
|
package/template/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { env } from '@/env';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The default shape: the browser talks to its OWN origin, and the web role
|
|
5
|
-
* forwards to the API role.
|
|
5
|
+
* forwards `/api/…` to the API role.
|
|
6
6
|
*
|
|
7
|
-
* This is what makes the
|
|
7
|
+
* This is what makes the board's client a plain module constant. A browser
|
|
8
8
|
* that dials the API role directly needs that role's public address, which is a
|
|
9
9
|
* property of the place — so the address has to arrive from the server at
|
|
10
10
|
* runtime, the client cannot exist until it does, and every call site pays for
|
|
@@ -13,9 +13,9 @@ import { internalApiUrl } from '@/lib/api/place';
|
|
|
13
13
|
*
|
|
14
14
|
* What it costs: one extra hop through the web role, and no WebSocket — a
|
|
15
15
|
* route handler cannot proxy an upgrade. The realtime socket is therefore the
|
|
16
|
-
* one place
|
|
17
|
-
* in front of both roles that
|
|
18
|
-
*
|
|
16
|
+
* one place the browser still needs the API role's address
|
|
17
|
+
* (`PUBLIC_REALTIME_ORIGIN`), or a routing layer in front of both roles that
|
|
18
|
+
* serves them on one origin.
|
|
19
19
|
*/
|
|
20
20
|
export const dynamic = 'force-dynamic';
|
|
21
21
|
|
|
@@ -28,10 +28,18 @@ const FORWARDED_REQUEST_HEADERS = [
|
|
|
28
28
|
const FORWARDED_RESPONSE_HEADERS = ['content-type', 'cache-control', 'etag'];
|
|
29
29
|
|
|
30
30
|
async function forward(request: Request): Promise<Response> {
|
|
31
|
+
const apiUrl = env.INTERNAL_API_URL;
|
|
32
|
+
if (!apiUrl) {
|
|
33
|
+
// Said here, in words, rather than as a 404 from a route that is not there.
|
|
34
|
+
return Response.json(
|
|
35
|
+
{ error: 'INTERNAL_API_URL is not set, so the web role cannot reach the API role' },
|
|
36
|
+
{ status: 503 },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
31
39
|
const incoming = new URL(request.url);
|
|
32
40
|
// Rebuilt from the incoming pathname rather than from the matched segments,
|
|
33
41
|
// so an encoded segment reaches the API role exactly as it arrived.
|
|
34
|
-
const target = new URL(`${incoming.pathname}${incoming.search}`,
|
|
42
|
+
const target = new URL(`${incoming.pathname}${incoming.search}`, apiUrl);
|
|
35
43
|
|
|
36
44
|
const headers = new Headers();
|
|
37
45
|
for (const name of FORWARDED_REQUEST_HEADERS) {
|
|
@@ -27,6 +27,10 @@ let shared: ReturnType<typeof connect> | undefined;
|
|
|
27
27
|
function connect(realtimeOrigin?: string) {
|
|
28
28
|
const realtime = createRealtimeClient(liveContract, {
|
|
29
29
|
url: realtimeOrigin ?? window.location.origin,
|
|
30
|
+
// The literal loader is what puts the socket client into this bundle.
|
|
31
|
+
// Stitchkit's own import of the peer is left alone by bundlers on purpose,
|
|
32
|
+
// so without this line the browser build has no `socket.io-client` in it.
|
|
33
|
+
peers: { client: () => import('socket.io-client') },
|
|
30
34
|
});
|
|
31
35
|
realtime.connect();
|
|
32
36
|
return {
|