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.
- package/CHANGELOG.md +218 -0
- package/README.md +3 -1
- package/UPGRADING.md +225 -0
- package/dist/cli.js +233 -41
- package/examples/repository/_env.example.append +21 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- package/examples/repository/packages/backend/src/surface.ts +1 -1
- package/examples/repository/packages/config/src/features.ts +17 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
- package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
- package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
- package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
- package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
- package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
- package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
- package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
- package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
- package/examples/repository/project.json +189 -0
- package/examples/repository/scripts/runtime-smoke.ts +25 -5
- package/package.json +9 -1
- package/template/AGENTS.md +12 -2
- package/template/README.md +43 -6
- package/template/_env.example +8 -4
- package/template/biome.json +5 -1
- package/template/bun.lock +2 -2
- package/template/e2e/starter.spec.ts +5 -7
- package/template/ecosystem.config.cjs +42 -19
- package/template/ecosystem.dev.config.cjs +41 -21
- package/template/package.json +5 -4
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +21 -5
- package/template/packages/backend/src/surface.ts +6 -1
- package/template/packages/backend/src/transport/errors.ts +4 -2
- package/template/packages/config/package.json +3 -1
- package/template/packages/config/src/app-identity.generated.ts +20 -0
- package/template/packages/config/src/declaration.ts +30 -0
- package/template/packages/config/src/project-declaration.generated.ts +611 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +2 -2
- package/template/packages/frontend/scripts/serve.ts +70 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
- package/template/packages/frontend/src/app/robots.ts +4 -2
- package/template/packages/frontend/src/app/sitemap.ts +7 -19
- package/template/packages/frontend/src/env.ts +27 -8
- package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
- package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
- package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
- package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
- package/template/packages/frontend/src/theme/config.ts +1 -1
- package/template/packages/frontend/tsconfig.json +10 -3
- package/template/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- package/template/scripts/build-inputs.test.ts +69 -0
- package/template/scripts/build-inputs.ts +57 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +268 -0
- package/template/scripts/dev.ts +41 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +3 -3
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +108 -0
- package/template/scripts/release.ts +30 -0
- package/template/scripts/runtime-smoke.ts +7 -4
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/tooling-env.ts +5 -2
- package/template/scripts/web-surface-smoke.ts +70 -0
- package/template/app.config.json +0 -9
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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':
|
|
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 {
|
|
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={
|
|
38
|
-
applicationDescription={
|
|
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
|
-
|
|
6
|
-
|
|
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(),
|