create-stitchkit 0.3.3 → 0.4.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +225 -0
  4. package/dist/cli.js +233 -41
  5. package/examples/repository/_env.example.append +21 -0
  6. package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
  7. package/examples/repository/packages/backend/src/surface.ts +1 -1
  8. package/examples/repository/packages/config/src/features.ts +17 -0
  9. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
  10. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  11. package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
  12. package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
  13. package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
  14. package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
  15. package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
  16. package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
  17. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
  18. package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
  19. package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
  20. package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
  21. package/examples/repository/project.json +189 -0
  22. package/examples/repository/scripts/runtime-smoke.ts +25 -5
  23. package/package.json +9 -1
  24. package/template/AGENTS.md +12 -2
  25. package/template/README.md +43 -6
  26. package/template/_env.example +8 -4
  27. package/template/biome.json +5 -1
  28. package/template/bun.lock +2 -2
  29. package/template/e2e/starter.spec.ts +5 -7
  30. package/template/ecosystem.config.cjs +42 -19
  31. package/template/ecosystem.dev.config.cjs +41 -21
  32. package/template/package.json +5 -4
  33. package/template/packages/backend/src/cli.ts +6 -2
  34. package/template/packages/backend/src/index.ts +21 -5
  35. package/template/packages/backend/src/surface.ts +6 -1
  36. package/template/packages/backend/src/transport/errors.ts +4 -2
  37. package/template/packages/config/package.json +3 -1
  38. package/template/packages/config/src/app-identity.generated.ts +20 -0
  39. package/template/packages/config/src/declaration.ts +30 -0
  40. package/template/packages/config/src/project-declaration.generated.ts +611 -0
  41. package/template/packages/config/src/server.ts +8 -17
  42. package/template/packages/config/src/variables.ts +89 -0
  43. package/template/packages/frontend/next.config.ts +3 -2
  44. package/template/packages/frontend/package.json +2 -2
  45. package/template/packages/frontend/scripts/serve.ts +70 -0
  46. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  47. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  48. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  49. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  50. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  51. package/template/packages/frontend/src/app/robots.ts +4 -2
  52. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  53. package/template/packages/frontend/src/env.ts +27 -8
  54. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  55. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  56. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  57. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  58. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  59. package/template/packages/frontend/src/theme/config.ts +1 -1
  60. package/template/packages/frontend/tsconfig.json +10 -3
  61. package/template/playwright.config.ts +1 -1
  62. package/template/project.json +169 -0
  63. package/template/scripts/build-inputs.test.ts +69 -0
  64. package/template/scripts/build-inputs.ts +57 -0
  65. package/template/scripts/check-authored.ts +18 -2
  66. package/template/scripts/declaration.test.ts +206 -0
  67. package/template/scripts/declaration.ts +268 -0
  68. package/template/scripts/dev.ts +41 -20
  69. package/template/scripts/local-env.test.ts +2 -2
  70. package/template/scripts/local-env.ts +3 -3
  71. package/template/scripts/release-steps.test.ts +87 -0
  72. package/template/scripts/release-steps.ts +108 -0
  73. package/template/scripts/release.ts +30 -0
  74. package/template/scripts/runtime-smoke.ts +7 -4
  75. package/template/scripts/serve-mode.test.ts +36 -0
  76. package/template/scripts/supervision-signal.test.ts +94 -0
  77. package/template/scripts/tooling-env.ts +5 -2
  78. package/template/scripts/web-surface-smoke.ts +70 -0
  79. package/template/app.config.json +0 -9
  80. package/template/packages/config/src/identity.ts +0 -18
package/dist/cli.js CHANGED
@@ -2,9 +2,207 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { basename as basename3, resolve as resolve2 } from "path";
5
+ import { readFile as readFile2 } from "fs/promises";
6
+ import { basename as basename3, join as join2, resolve as resolve2 } from "path";
6
7
  var {spawn } = globalThis.Bun;
7
8
 
9
+ // src/identity.ts
10
+ import { basename } from "path";
11
+
12
+ // ../core/src/declaration.ts
13
+ import { z } from "zod";
14
+ var PROJECT_DECLARATION_SCHEMA_VERSION = 1;
15
+ var MACHINE_PATTERNS = [
16
+ [/:\/\//, "an absolute address"],
17
+ [/^\/\//, "a protocol-relative host"],
18
+ [/^[~]/, "a home-relative path"],
19
+ [/^[A-Za-z]:[\\/]/, "a Windows drive path"],
20
+ [/\\/, "a Windows path separator"],
21
+ [/(?:^|[\s=:])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])/, "an IP address"],
22
+ [/(?:^|[\s=])[A-Za-z][\w.-]*:\d{2,5}(?![\w.])/, "a host and port"]
23
+ ];
24
+ function namesAMachine(value) {
25
+ for (const [pattern, reason] of MACHINE_PATTERNS) {
26
+ if (pattern.test(value))
27
+ return reason;
28
+ }
29
+ return;
30
+ }
31
+ function refuseMachineNames(label) {
32
+ return (schema) => schema.refine((value) => namesAMachine(value) === undefined, {
33
+ error: (issue) => `${label} names a machine \u2014 ${namesAMachine(String(issue.input)) ?? "a value of the deployment"} is supplied by the deployment, not written in the code`
34
+ });
35
+ }
36
+ var ProjectSlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and single hyphens (for example: talk-control)");
37
+ var ProjectDescriptionSchema = z.record(refuseMachineNames("A locale tag")(z.string().min(1)), refuseMachineNames("A description")(z.string().trim().min(1))).refine((value) => Object.keys(value).length > 0, "Describe the project in at least one locale");
38
+ var ProjectIdentitySchema = z.object({
39
+ slug: ProjectSlugSchema,
40
+ name: refuseMachineNames("A project name")(z.string().trim().min(1).max(80)),
41
+ version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
42
+ description: ProjectDescriptionSchema
43
+ }).strict();
44
+ var RepositoryPathSchema = z.string().min(1).refine((value) => !value.startsWith("/"), "Use a path relative to the repository root").refine((value) => namesAMachine(value) === undefined, {
45
+ error: (issue) => `A path may not contain ${namesAMachine(String(issue.input)) ?? "a machine name"}`
46
+ }).refine((value) => !value.split("/").includes(".."), "A path may not climb out of the repository");
47
+ var BindingVariableSchema = z.string().regex(/^[A-Z][A-Z0-9_]*$/, "Name an environment variable, for example API_PORT");
48
+ var ProjectListenerSchema = z.object({
49
+ portVariable: BindingVariableSchema,
50
+ bindVariable: BindingVariableSchema,
51
+ readinessPath: refuseMachineNames("A readiness path")(z.string().startsWith("/", "Readiness is a path, for example /health"))
52
+ }).strict();
53
+ var ProjectRunModeSchema = z.enum(["development", "production"]);
54
+ var PROJECT_SCRIPT_LAUNCHERS = [
55
+ [/^(?:bun|npm|pnpm|yarn)$/, /^run$/],
56
+ [/^deno$/, /^task$/],
57
+ [/^(?:npx|bunx|pnpx)$/, null]
58
+ ];
59
+ function launchesAScript(executable, firstArgument) {
60
+ for (const [runner, verb] of PROJECT_SCRIPT_LAUNCHERS) {
61
+ if (!runner.test(executable))
62
+ continue;
63
+ if (verb === null)
64
+ return true;
65
+ if (firstArgument !== undefined && verb.test(firstArgument))
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ var PORT_FLAG = /^(?:-p|-{1,2}(?:port|listen))$/i;
71
+ var CommandPartSchema = refuseMachineNames("A command part")(z.string().min(1)).refine((value) => !value.startsWith("/"), "A command part may not be an absolute path \u2014 paths are relative to the source").refine((value) => !/^[^=\s]+=/.test(value), "A command part may not carry an inline value \u2014 write the flag and its value as separate arguments, so the value is checked like every other one");
72
+ var CommandArgumentsSchema = z.array(CommandPartSchema).refine((args) => args.every((value, index) => !(/^\d{1,5}$/.test(value) && PORT_FLAG.test(args[index - 1] ?? ""))), "A number after a port flag is a port \u2014 name the variable that carries it and let the role read it");
73
+ var ProjectCommandSchema = z.object({
74
+ executable: CommandPartSchema,
75
+ args: CommandArgumentsSchema
76
+ }).strict();
77
+ var ProjectRoleCommandSchema = ProjectCommandSchema.refine((value) => !launchesAScript(value.executable, value.args[0]), "Start the role process itself, not a script runner: a launcher between the supervisor and the role duplicates the shutdown signal and forces the drain").refine((value) => !value.args.includes("--filter"), "A workspace filter puts a launcher between the supervisor and the role, and the shutdown signal never reaches it");
78
+ var ProjectRoleSchema = z.object({
79
+ name: ProjectSlugSchema,
80
+ workingDirectory: RepositoryPathSchema.optional(),
81
+ commands: z.record(ProjectRunModeSchema, ProjectRoleCommandSchema),
82
+ listener: ProjectListenerSchema.optional(),
83
+ drainFloorMs: z.number().int().nonnegative()
84
+ }).strict();
85
+ var ProjectBuildInputSchema = z.object({
86
+ name: ProjectSlugSchema,
87
+ path: RepositoryPathSchema,
88
+ digest: z.string().regex(/^sha256:[0-9a-f]{64}$/, 'Use a lowercase sha256 digest, as "sha256:<64 hex>"')
89
+ }).strict();
90
+ var ProjectBuildSchema = z.object({
91
+ command: ProjectCommandSchema,
92
+ artifacts: z.array(RepositoryPathSchema).min(1),
93
+ inputs: z.array(ProjectBuildInputSchema).optional()
94
+ }).strict().refine((build) => {
95
+ const names = (build.inputs ?? []).map((input) => input.name);
96
+ return new Set(names).size === names.length;
97
+ }, "Two build inputs share a name \u2014 a failure could then name either of them");
98
+ var ProjectRequirementPhaseSchema = z.enum(["release", "start"]);
99
+ var ProjectRequirementSchema = z.object({
100
+ name: ProjectSlugSchema,
101
+ phases: z.array(ProjectRequirementPhaseSchema).min(1)
102
+ }).strict();
103
+ var ProjectMigrationsSchema = z.object({
104
+ engine: refuseMachineNames("A migration engine name")(z.string().min(1).max(64)),
105
+ root: RepositoryPathSchema,
106
+ lockfile: RepositoryPathSchema
107
+ }).strict();
108
+ var ProjectReleaseSchema = z.object({ migrations: ProjectMigrationsSchema.optional() }).strict();
109
+ var ProjectEnvShapeSchema = z.enum(["string", "integer", "boolean", "url", "enum"]);
110
+ var ProjectEnvVariableSchema = z.object({
111
+ name: BindingVariableSchema,
112
+ shape: ProjectEnvShapeSchema,
113
+ required: z.boolean(),
114
+ members: z.array(refuseMachineNames("An enum member")(z.string().min(1))).min(1).optional()
115
+ }).strict().refine((value) => value.shape === "enum" === (value.members !== undefined), "An enum variable lists its members; every other shape has none");
116
+ var ProjectDeclarationSchema = z.object({
117
+ schemaVersion: z.literal(PROJECT_DECLARATION_SCHEMA_VERSION),
118
+ kind: z.enum(["library", "application"]),
119
+ identity: ProjectIdentitySchema,
120
+ roles: z.array(ProjectRoleSchema),
121
+ build: ProjectBuildSchema.optional(),
122
+ requires: z.array(ProjectRequirementSchema),
123
+ release: ProjectReleaseSchema,
124
+ env: z.object({ variables: z.array(ProjectEnvVariableSchema) }).strict()
125
+ }).strict().refine((value) => value.kind === "application" ? value.roles.length > 0 : value.roles.length === 0, "An application declares at least one role; a library declares none").refine((value) => new Set(value.roles.map((role) => role.name)).size === value.roles.length, "Role names must be unique").refine((value) => new Set(value.requires.map((entry) => entry.name)).size === value.requires.length, "Name each requirement once and list its phases").refine((value) => value.requires.every((entry) => new Set(entry.phases).size === entry.phases.length), "List each phase of a requirement once").refine((value) => new Set(value.env.variables.map((entry) => entry.name)).size === value.env.variables.length, "Declare each environment variable once").refine((value) => listenerBindingProblem(value) === undefined, {
126
+ error: (issue) => listenerBindingProblem(issue.input) ?? "Listener bindings are inconsistent"
127
+ });
128
+ function isListenerBindingSubject(value) {
129
+ return typeof value === "object" && value !== null && "roles" in value && "env" in value;
130
+ }
131
+ function listenerBindingProblem(value) {
132
+ if (!isListenerBindingSubject(value))
133
+ return;
134
+ const shapes = new Map(value.env.variables.map((entry) => [entry.name, entry.shape]));
135
+ for (const role of value.roles) {
136
+ const listener = role.listener;
137
+ if (!listener)
138
+ continue;
139
+ if (listener.portVariable === listener.bindVariable) {
140
+ return `Role "${role.name}" points its port and its bind address at the same variable "${listener.portVariable}"`;
141
+ }
142
+ const expected = [
143
+ [listener.portVariable, "integer"],
144
+ [listener.bindVariable, "string"]
145
+ ];
146
+ for (const [name, shape] of expected) {
147
+ const declared = shapes.get(name);
148
+ if (declared === undefined) {
149
+ return `Role "${role.name}" listens on "${name}", which env.variables does not declare \u2014 a deployment reading this cannot know it has to supply it`;
150
+ }
151
+ if (declared !== shape) {
152
+ return `Role "${role.name}" listens on "${name}", declared as "${declared}" where a ${shape} is needed`;
153
+ }
154
+ }
155
+ }
156
+ return;
157
+ }
158
+ var VersionProbeSchema = z.object({ schemaVersion: z.unknown() }).loose();
159
+ function parseProjectDeclaration(source) {
160
+ const probe = VersionProbeSchema.safeParse(source);
161
+ const declared = probe.success ? probe.data.schemaVersion : undefined;
162
+ if (declared !== undefined && declared !== PROJECT_DECLARATION_SCHEMA_VERSION) {
163
+ throw new Error(`Project declaration schema version ${JSON.stringify(declared)} is not supported \u2014 ` + `this build understands version ${PROJECT_DECLARATION_SCHEMA_VERSION}.`);
164
+ }
165
+ return ProjectDeclarationSchema.parse(source);
166
+ }
167
+
168
+ // src/identity.ts
169
+ var APP_IDENTITY_PATH = "packages/config/src/app-identity.generated.ts";
170
+ function renderAppIdentityModule(identity) {
171
+ return `// GENERATED FILE \u2014 do not edit.
172
+ //
173
+ // Rendered from \`project.json\` by \`scripts/declaration.ts\`.
174
+ //
175
+ // Identity ONLY, inlined rather than imported, because this is the part of the
176
+ // declaration a browser may know. Importing the whole declaration from a client
177
+ // component would put role commands, working directories, build artifact paths,
178
+ // the migration lockfile and every environment variable name into the browser
179
+ // bundle \u2014 the same mistake as publishing internal topology from a status
180
+ // endpoint, made from the other side.
181
+
182
+ export const appIdentity = ${JSON.stringify(identity, undefined, 2)};
183
+ `;
184
+ }
185
+ function displayNameFromSlug(slug) {
186
+ return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
187
+ }
188
+ function createApplicationIdentity(destination, displayName) {
189
+ const slug = ProjectSlugSchema.parse(basename(destination));
190
+ const name = displayName?.trim() || displayNameFromSlug(slug);
191
+ return ProjectIdentitySchema.parse({
192
+ slug,
193
+ name,
194
+ version: "0.1.0",
195
+ description: {
196
+ en: `${name} is a production application built with Stitchkit.`,
197
+ ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
198
+ }
199
+ });
200
+ }
201
+ function withIdentity(declaration, identity) {
202
+ const copied = parseProjectDeclaration(declaration);
203
+ return parseProjectDeclaration({ ...copied, identity });
204
+ }
205
+
8
206
  // src/options.ts
9
207
  var HELP = `Create a production-shaped Stitchkit application.
10
208
 
@@ -62,38 +260,6 @@ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
62
260
  import { homedir } from "os";
63
261
  import { basename as basename2, dirname, extname, join, parse, relative, resolve, sep } from "path";
64
262
  import { z as z2 } from "zod";
65
-
66
- // src/identity.ts
67
- import { basename } from "path";
68
- import { z } from "zod";
69
- var ApplicationSlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and single hyphens (for example: talk-control)");
70
- var ApplicationIdentitySchema = z.object({
71
- slug: ApplicationSlugSchema,
72
- name: z.string().trim().min(1).max(80),
73
- version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
74
- description: z.object({
75
- en: z.string().trim().min(1),
76
- ru: z.string().trim().min(1)
77
- })
78
- });
79
- function displayNameFromSlug(slug) {
80
- return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
81
- }
82
- function createApplicationIdentity(destination, displayName) {
83
- const slug = ApplicationSlugSchema.parse(basename(destination));
84
- const name = displayName?.trim() || displayNameFromSlug(slug);
85
- return ApplicationIdentitySchema.parse({
86
- slug,
87
- name,
88
- version: "0.1.0",
89
- description: {
90
- en: `${name} is a production application built with Stitchkit.`,
91
- ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
92
- }
93
- });
94
- }
95
-
96
- // src/scaffold.ts
97
263
  var TEXT_EXTENSIONS = new Set([
98
264
  ".cjs",
99
265
  ".css",
@@ -231,8 +397,13 @@ async function scaffoldProject(templateDirectory, destination, options = {}) {
231
397
  if (options.overlayDirectory) {
232
398
  await writeMaterialisedFiles(resolvedDestination, await materialiseTemplateFiles(options.overlayDirectory));
233
399
  }
234
- await writeFile(join(resolvedDestination, "app.config.json"), `${JSON.stringify(identity, undefined, 2)}
400
+ const declarationPath = join(resolvedDestination, "project.json");
401
+ const declaration = withIdentity(JSON.parse(await readFile(declarationPath, "utf8")), identity);
402
+ await writeFile(declarationPath, `${JSON.stringify(declaration, undefined, 2)}
235
403
  `);
404
+ const identityPath = join(resolvedDestination, APP_IDENTITY_PATH);
405
+ await mkdir(dirname(identityPath), { recursive: true });
406
+ await writeFile(identityPath, renderAppIdentityModule(declaration.identity));
236
407
  const manifestPath = join(resolvedDestination, "package.json");
237
408
  const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
238
409
  await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: identity.slug }, undefined, 2)}
@@ -284,14 +455,10 @@ Created ${options.displayName ?? basename3(destination)}${mode}
284
455
  process.stdout.write(` bun run dev
285
456
 
286
457
  `);
287
- process.stdout.write(`Web: http://localhost:3210
288
- `);
289
- process.stdout.write(`API: http://localhost:3211
290
- `);
291
- process.stdout.write(`MCP: http://localhost:3211/mcp
292
- `);
293
- process.stdout.write(`OpenAPI: http://localhost:3211/openapi.json
458
+ for (const line of await roleAddresses(destination)) {
459
+ process.stdout.write(`${line}
294
460
  `);
461
+ }
295
462
  return 0;
296
463
  } catch (error) {
297
464
  const message = error instanceof Error ? error.message : String(error);
@@ -303,6 +470,31 @@ Created ${options.displayName ?? basename3(destination)}${mode}
303
470
  if (import.meta.main) {
304
471
  process.exitCode = await run(Bun.argv.slice(2));
305
472
  }
473
+ async function roleAddresses(destination) {
474
+ try {
475
+ const declaration = parseProjectDeclaration(JSON.parse(await readFile2(join2(destination, "project.json"), "utf8")));
476
+ const environment = readEnvironmentExample(await readFile2(join2(destination, ".env.example"), "utf8"));
477
+ const host = environment.BIND_HOST ?? "127.0.0.1";
478
+ return declaration.roles.flatMap((role) => {
479
+ const port = role.listener && environment[role.listener.portVariable];
480
+ if (!role.listener || !port)
481
+ return [];
482
+ return [`${role.name}: http://${host}:${port}${role.listener.readinessPath}`];
483
+ });
484
+ } catch {
485
+ return [];
486
+ }
487
+ }
488
+ function readEnvironmentExample(source) {
489
+ const values = {};
490
+ for (const line of source.split(`
491
+ `)) {
492
+ const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
493
+ if (match?.[1])
494
+ values[match[1]] = match[2] ?? "";
495
+ }
496
+ return values;
497
+ }
306
498
  export {
307
499
  run
308
500
  };
@@ -1,3 +1,24 @@
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
+ # THE CROSS-ORIGIN HTTP VARIANT — unset, and unnecessary for this example.
16
+ # Set it only for a frontend that dials the API role itself instead of calling
17
+ # its own `/api`: separate hostnames with nothing in front of them. Setting it
18
+ # changes nothing on its own; switching is one import in
19
+ # packages/frontend/src/lib/api/queries.ts. See lib/api/cross-origin.ts.
20
+ # PUBLIC_API_ORIGIN=https://api.example
21
+
1
22
  GITHUB_REPOSITORY=max-listov/stitchkit
2
23
  GITHUB_CACHE_TTL_SECONDS=900
3
24
  # Optional. Defaults to the public GitHub API and supports GitHub Enterprise.
@@ -1,5 +1,5 @@
1
1
  import { env } from '@app/config';
2
- import { appIdentity } from '@app/config/identity';
2
+ import { appDeclaration } from '@app/config/declaration';
3
3
  import { RepositoryVisibility } from '@app/db';
4
4
  import type { RepositorySnapshot } from '@app/shared';
5
5
  import { z } from 'zod';
@@ -81,7 +81,7 @@ const snapshotStore: RepositorySnapshotStore = {
81
81
  function githubHeaders(): Headers {
82
82
  const headers = new Headers({
83
83
  Accept: 'application/vnd.github+json',
84
- 'User-Agent': appIdentity.slug,
84
+ 'User-Agent': appDeclaration.identity.slug,
85
85
  'X-GitHub-Api-Version': '2026-03-10',
86
86
  });
87
87
  if (env.GITHUB_TOKEN) headers.set('Authorization', `Bearer ${env.GITHUB_TOKEN}`);
@@ -6,7 +6,7 @@ import { createSystemService } from './transport/system-service';
6
6
 
7
7
  export async function createSurface() {
8
8
  const socket = await createSocketIOServer({
9
- cors: { origin: env.CORS_ORIGIN },
9
+ cors: { origin: env.CORS_ORIGIN ?? [] },
10
10
  });
11
11
  const realtime = bindRealtimeServer(repositoryRealtimeContract, socket);
12
12
  const repositoryService = createRepositoryService((snapshot) =>
@@ -1,6 +1,23 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  export const featureServerSchema = {
4
+ // The web role dereferences this on every proxied request and on every server
5
+ // render, so it TIGHTENS from optional to required. Declared optional, a
6
+ // deployment reading project.json would supply nothing and every request would
7
+ // throw — the declaration would be derived and still wrong, which is the one
8
+ // failure the derivation exists to prevent.
9
+ INTERNAL_API_URL: z.url(),
10
+ // Deliberately NOT tightened. The browser talks to its own origin by default
11
+ // (`frontend/src/lib/api/client.ts`), so a single-origin deployment supplies
12
+ // none of these. They are the price of a browser that leaves that origin, and
13
+ // only a deployment that has that case should be made to pay it.
14
+ // PUBLIC_REALTIME_ORIGIN — where the socket connects, when no routing layer
15
+ // forwards `/socket.io`. A WebSocket upgrade cannot be proxied by the
16
+ // route handler that forwards `/api`, so this one is separate on purpose.
17
+ // PUBLIC_API_ORIGIN — where the browser dials the API role over HTTP, for
18
+ // the cross-origin variant. Inert until the import in `queries.ts` moves.
19
+ // CORS_ORIGIN — the API role's allow-list, needed once the browser is
20
+ // genuinely cross-origin for either of the two.
4
21
  GITHUB_API_URL: z.url().default('https://api.github.com'),
5
22
  GITHUB_REPOSITORY: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
6
23
  GITHUB_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(900),
@@ -1,10 +1,10 @@
1
- import { appIdentity } from '@app/config/identity';
1
+ import { appDeclaration } from '@app/config/declaration';
2
2
  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 { createServerRepositoryApi } from '@/lib/api/client';
7
6
  import { useRepository } from '@/lib/api/queries';
7
+ import { createServerRepositoryApi } from '@/lib/api/server-client';
8
8
  import { getQueryClient } from '@/lib/query-client';
9
9
  import { createPageMetadata } from '@/lib/seo/metadata';
10
10
  import { StarterPage } from './starter-page';
@@ -34,8 +34,8 @@ export default async function Page({ params }: { params: Promise<{ locale: strin
34
34
  return (
35
35
  <HydrationBoundary state={dehydrate(queryClient)}>
36
36
  <StarterPage
37
- applicationName={appIdentity.name}
38
- applicationDescription={appIdentity.description[appLocale]}
37
+ applicationName={appDeclaration.identity.name}
38
+ applicationDescription={appDeclaration.identity.description[appLocale]}
39
39
  heroTitle={t('heroTitle')}
40
40
  catalogueLabel={t('ui')}
41
41
  locale={appLocale}
@@ -57,7 +57,7 @@ const architecture = [
57
57
  },
58
58
  ];
59
59
 
60
- export function StarterPage({
60
+ export async function StarterPage({
61
61
  applicationName,
62
62
  applicationDescription,
63
63
  heroTitle,
@@ -71,7 +71,7 @@ export function StarterPage({
71
71
  name: SITE_NAME,
72
72
  applicationCategory: 'DeveloperApplication',
73
73
  operatingSystem: 'Web',
74
- url: absoluteSiteUrl(`/${locale}`),
74
+ url: await absoluteSiteUrl(`/${locale}`),
75
75
  description: homeSeo.description,
76
76
  };
77
77
 
@@ -0,0 +1,66 @@
1
+ import { internalApiUrl } from '@/lib/api/place';
2
+
3
+ /**
4
+ * The default shape: the browser talks to its OWN origin, and the web role
5
+ * forwards to the API role.
6
+ *
7
+ * This is what makes the example's client a plain module constant. A browser
8
+ * that dials the API role directly needs that role's public address, which is a
9
+ * property of the place — so the address has to arrive from the server at
10
+ * runtime, the client cannot exist until it does, and every call site pays for
11
+ * that with a lazy accessor. A same-origin request needs no address at all:
12
+ * `/api/…` is complete before any machine exists.
13
+ *
14
+ * What it costs: one extra hop through the web role, and no WebSocket — a
15
+ * route handler cannot proxy an upgrade. The realtime socket is therefore the
16
+ * one place this example still needs the API role's address, or a routing layer
17
+ * in front of both roles that serves them on one origin (see
18
+ * `lib/api/cross-origin.ts`).
19
+ */
20
+ export const dynamic = 'force-dynamic';
21
+
22
+ const FORWARDED_REQUEST_HEADERS = [
23
+ 'accept',
24
+ 'accept-language',
25
+ 'content-type',
26
+ 'authorization',
27
+ ];
28
+ const FORWARDED_RESPONSE_HEADERS = ['content-type', 'cache-control', 'etag'];
29
+
30
+ async function forward(request: Request): Promise<Response> {
31
+ const incoming = new URL(request.url);
32
+ // Rebuilt from the incoming pathname rather than from the matched segments,
33
+ // so an encoded segment reaches the API role exactly as it arrived.
34
+ const target = new URL(`${incoming.pathname}${incoming.search}`, internalApiUrl());
35
+
36
+ const headers = new Headers();
37
+ for (const name of FORWARDED_REQUEST_HEADERS) {
38
+ const value = request.headers.get(name);
39
+ if (value !== null) headers.set(name, value);
40
+ }
41
+
42
+ // Buffered rather than streamed: forwarding a stream needs the non-standard
43
+ // `duplex` init that this project's types do not carry, and every payload
44
+ // this contract accepts is a small JSON document.
45
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
46
+ const response = await fetch(target, {
47
+ method: request.method,
48
+ headers,
49
+ body: hasBody ? await request.arrayBuffer() : undefined,
50
+ // A redirect is the API role's answer, not something to resolve here.
51
+ redirect: 'manual',
52
+ });
53
+
54
+ const responseHeaders = new Headers();
55
+ for (const name of FORWARDED_RESPONSE_HEADERS) {
56
+ const value = response.headers.get(name);
57
+ if (value !== null) responseHeaders.set(name, value);
58
+ }
59
+ return new Response(response.body, { status: response.status, headers: responseHeaders });
60
+ }
61
+
62
+ export const GET = forward;
63
+ export const POST = forward;
64
+ export const PUT = forward;
65
+ export const PATCH = forward;
66
+ export const DELETE = forward;
@@ -1,20 +1,25 @@
1
1
  import { repositoryContract } from '@app/shared';
2
2
  import { createClient, createHttpClient, createUrlBuilder } from 'stitchkit';
3
- import { env } from '@/env';
4
3
 
5
- function apiOrigin(): string {
6
- return typeof window === 'undefined' ? env.INTERNAL_API_URL : env.NEXT_PUBLIC_API_URL;
7
- }
4
+ /**
5
+ * The browser's API client — a module CONSTANT, because it needs no address.
6
+ *
7
+ * `/api` is complete when no machine exists: it names a path on whatever origin
8
+ * served the page. That is the whole reason this file has no factory, no lazy
9
+ * accessor and no parentheses at its call sites — see `queries.ts`. The web
10
+ * role forwards these requests to the API role (`app/api/[...path]/route.ts`).
11
+ *
12
+ * A browser that genuinely must reach a DIFFERENT origin cannot do this, and
13
+ * pays a real price for it. That variant lives in `cross-origin.ts`, named and
14
+ * explained, rather than in the default path everybody copies.
15
+ */
16
+ const browserHttp = createHttpClient({ baseUrl: '/api', credentials: 'same-origin' });
17
+
18
+ export const repositoryApi = createClient(repositoryContract, browserHttp);
19
+ export const repositoryUrls = createUrlBuilder(repositoryContract, browserHttp);
8
20
 
21
+ /** The server-side client needs an address, and reads it from the place. */
9
22
  export function createRepositoryApi(baseUrl: string) {
10
23
  const http = createHttpClient({ baseUrl: `${baseUrl}/api`, credentials: 'omit' });
11
24
  return createClient(repositoryContract, http);
12
25
  }
13
-
14
- const http = createHttpClient({ baseUrl: `${apiOrigin()}/api`, credentials: 'omit' });
15
- export const repositoryApi = createRepositoryApi(apiOrigin());
16
- export const repositoryUrls = createUrlBuilder(repositoryContract, http);
17
-
18
- export function createServerRepositoryApi() {
19
- return createRepositoryApi(env.INTERNAL_API_URL);
20
- }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * THE VARIANT: a browser that must reach the API role at a different origin.
3
+ *
4
+ * The default in this example is same-origin (`lib/api/client.ts`): the browser
5
+ * calls `/api/…` and the web role forwards. Two things can pull a deployment
6
+ * out of that, and they are separate, so they have separate variables:
7
+ *
8
+ * - **`PUBLIC_REALTIME_ORIGIN`** — the socket. A WebSocket upgrade does not
9
+ * survive a proxying route handler, so a deployment running the two roles on
10
+ * two ports with no routing layer in front of them must name the socket's
11
+ * origin even though its HTTP is already same-origin. This one is read by the
12
+ * default path, and unset means the page's own origin.
13
+ * - **`PUBLIC_API_ORIGIN`** — HTTP. Only for a frontend that genuinely dials
14
+ * the API role itself: separate hostnames with nothing in front of them.
15
+ * Setting it changes nothing on its own; switching is an import, below.
16
+ *
17
+ * Switching HTTP to this variant is one line in `queries.ts`:
18
+ *
19
+ * ```ts
20
+ * // before
21
+ * import { repositoryApi } from './client'
22
+ * fetcher: () => repositoryApi.read()
23
+ * // after
24
+ * import { repositoryApiCrossOrigin } from './cross-origin'
25
+ * fetcher: () => repositoryApiCrossOrigin().read()
26
+ * ```
27
+ *
28
+ * What that costs, in order:
29
+ *
30
+ * 1. **The address is a property of the place**, so it can never be compiled
31
+ * in. The server reads it per request and hands it to the browser
32
+ * (`providers/index.tsx` → `providers/client-providers.tsx`).
33
+ * 2. **Nothing can be built at import time.** The client has to be constructed
34
+ * on FIRST USE — hence the parentheses, which the default path does not pay.
35
+ * 3. **Order matters.** Anything reading the origin must render inside
36
+ * `<Providers>`; outside it, the value is not there yet.
37
+ * 4. **The API role needs `CORS_ORIGIN`**, for HTTP and for the realtime
38
+ * handshake alike.
39
+ *
40
+ * None of that is wrong — it is the correct shape for the case. It is simply
41
+ * not the case most projects have, which is why it is not the body of the
42
+ * example.
43
+ */
44
+ import { createRepositoryApi } from './client';
45
+
46
+ export interface PublicOrigins {
47
+ /** Where the browser dials the API role over HTTP, if not this origin. */
48
+ readonly api: string | undefined;
49
+ /** Where the browser opens the realtime socket, if not this origin. */
50
+ readonly realtime: string | undefined;
51
+ }
52
+
53
+ let origins: PublicOrigins = { api: undefined, realtime: undefined };
54
+
55
+ /** Supplied by the server, once, above every consumer. */
56
+ export function setPublicOrigins(supplied: PublicOrigins): void {
57
+ origins = supplied;
58
+ }
59
+
60
+ /**
61
+ * The socket's origin, or `undefined` when this deployment serves both roles on
62
+ * one origin.
63
+ *
64
+ * `undefined` is an answer, not a missing value: the socket then connects to
65
+ * the page's own origin, where a routing layer forwards `/socket.io`.
66
+ */
67
+ export function optionalRealtimeOrigin(): string | undefined {
68
+ return origins.realtime;
69
+ }
70
+
71
+ export function requirePublicApiOrigin(): string {
72
+ const { api } = origins;
73
+ if (!api) {
74
+ throw new Error(
75
+ 'The public API origin has not been provided — set PUBLIC_API_ORIGIN and render this inside <Providers>, which supplies it from the server.',
76
+ );
77
+ }
78
+ return api;
79
+ }
80
+
81
+ let crossOriginApi: ReturnType<typeof createRepositoryApi> | undefined;
82
+
83
+ /** Built on FIRST USE: the origin arrives at runtime, not at import. */
84
+ export function repositoryApiCrossOrigin(): ReturnType<typeof createRepositoryApi> {
85
+ crossOriginApi ??= createRepositoryApi(requirePublicApiOrigin());
86
+ return crossOriginApi;
87
+ }
@@ -0,0 +1,26 @@
1
+ import { env } from '@/env';
2
+
3
+ /**
4
+ * The addresses this example reads from the place.
5
+ *
6
+ * `INTERNAL_API_URL` is REQUIRED — see `packages/config/src/features.ts` — and
7
+ * the declaration a deployment reads says so, because the web role dereferences
8
+ * it on every proxied request and on every server render.
9
+ *
10
+ * The two public ones are optional and independent, because the questions they
11
+ * answer are independent: HTTP can be forwarded by the web role, and a
12
+ * WebSocket upgrade cannot. A deployment behind one routing layer sets neither.
13
+ */
14
+ export function internalApiUrl(): string {
15
+ return env.INTERNAL_API_URL;
16
+ }
17
+
18
+ /** Where the browser dials the API role over HTTP — the cross-origin variant. */
19
+ export function publicApiOrigin(): string | undefined {
20
+ return env.PUBLIC_API_ORIGIN;
21
+ }
22
+
23
+ /** Where the browser opens the realtime socket, when it is not this origin. */
24
+ export function publicRealtimeOrigin(): string | undefined {
25
+ return env.PUBLIC_REALTIME_ORIGIN;
26
+ }
@@ -1,6 +1,9 @@
1
1
  import { createMutation, createQuery } from 'react-query-kit';
2
2
  import { repositoryApi } from './client';
3
3
 
4
+ // No parentheses: the client is a module constant, because a same-origin path
5
+ // needs no address. The cross-origin variant pays for its address with a lazy
6
+ // accessor — see `cross-origin.ts`.
4
7
  export const useRepository = createQuery({
5
8
  queryKey: ['repository'],
6
9
  fetcher: () => repositoryApi.read(),