@craft-ts/dev-tools 0.7.0-beta.15 → 0.7.0-beta.16
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/README.md +9 -13
- package/package.json +1 -1
- package/src/bin/craft.d.ts +0 -1
- package/src/bin/craft.js +1 -15
- package/src/bin/craft.js.map +1 -1
- package/src/scripts/create/create-project.d.ts +3 -14
- package/src/scripts/create/create-project.js +429 -292
- package/src/scripts/create/create-project.js.map +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { runArchitectureMigration } from '../architecture/migrate-architecture.js';
|
|
6
|
-
export const CRAFT_TS_STARTER_VERSION = '^0.7.0-beta.
|
|
6
|
+
export const CRAFT_TS_STARTER_VERSION = '^0.7.0-beta.15';
|
|
7
7
|
export const EFFECT_V4_VERSION = '^4.0.0-rc.110';
|
|
8
8
|
const DEFAULT_AGENTS = ['codex'];
|
|
9
9
|
const GENERATED_GITIGNORE = `node_modules/
|
|
@@ -88,23 +88,105 @@ This project deliberately uses Effect v4 (effect@${EFFECT_V4_VERSION}) and
|
|
|
88
88
|
Do not silently replace Effect v4 APIs with v3 examples. Confirm a symbol in
|
|
89
89
|
the installed package before using it.
|
|
90
90
|
`;
|
|
91
|
-
|
|
91
|
+
function referenceAgentGuidance(config) {
|
|
92
|
+
if (!config || (!config.references.craftTs && !config.references.effectTs))
|
|
93
|
+
return '';
|
|
94
|
+
const root = config.workspace.kind === 'nx' ? '../../.references' : '.references';
|
|
95
|
+
const entries = [
|
|
96
|
+
config.references.craftTs ? `- CraftTS source: \`${root}/craft-ts\`` : '',
|
|
97
|
+
config.references.effectTs ? `- EffectTS source: \`${root}/effect-ts\`` : '',
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
return `
|
|
100
|
+
## Local source references
|
|
101
|
+
|
|
102
|
+
The following repositories are cloned for agent context only:
|
|
103
|
+
${entries.join('\n')}
|
|
104
|
+
Use them to inspect implementations, types, tests and examples when the
|
|
105
|
+
installed package or project documentation is not enough. The application must
|
|
106
|
+
always import CraftTS and EffectTS from the npm dependencies declared in
|
|
107
|
+
\`package.json\`; do not add TypeScript, Vite or package \`file:\` aliases to
|
|
108
|
+
these clones.
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
function agentsMd(mode, config) {
|
|
112
|
+
const frontend = config?.frontendRuntime ?? (mode === 'effect' ? 'effect' : 'plain');
|
|
113
|
+
const backend = config?.backendRuntime ?? 'none';
|
|
114
|
+
const i18n = config?.i18n.enabled ?? true;
|
|
115
|
+
const designSystem = config?.designSystem !== 'none';
|
|
116
|
+
const typedCss = config?.typedCss ?? true;
|
|
117
|
+
const effectFrontend = frontend === 'effect';
|
|
118
|
+
const effectBackend = backend === 'effect';
|
|
119
|
+
const effect = effectFrontend || effectBackend;
|
|
120
|
+
const effectSkillPaths = (config?.agents ?? []).map((agent) => agent === 'codex'
|
|
121
|
+
? '.agents/skills/craft-ts-effect-v4/SKILL.md'
|
|
122
|
+
: agent === 'cursor'
|
|
123
|
+
? '.cursor/skills/craft-ts-project/SKILL.md'
|
|
124
|
+
: agent === 'claude-code'
|
|
125
|
+
? '.claude/skills/craft-ts-effect-v4/SKILL.md'
|
|
126
|
+
: '.gemini/skills/craft-ts-effect-v4/SKILL.md');
|
|
127
|
+
const effectSkillLine = effectSkillPaths.length
|
|
128
|
+
? `Read the Effect-specific guidance in ${effectSkillPaths.map((path) => `\`${path}\``).join(', ')}.`
|
|
129
|
+
: 'Use the Effect v4 guidance in the project documentation when adding Effect code.';
|
|
130
|
+
const verify = [
|
|
131
|
+
'npm run lint',
|
|
132
|
+
'npm run typecheck',
|
|
133
|
+
'npm run typecheck-spec',
|
|
134
|
+
...(i18n ? ['npm run i18n:check', 'npm run i18n:test'] : []),
|
|
135
|
+
...(typedCss ? ['npm run style:check'] : []),
|
|
136
|
+
...(backend !== 'none' ? ['npm run server:test'] : []),
|
|
137
|
+
...(effect ? ['npm run effect-check'] : []),
|
|
138
|
+
'npm test',
|
|
139
|
+
'npm run architecture',
|
|
140
|
+
'npm run typecheck-architecture',
|
|
141
|
+
'npm run build',
|
|
142
|
+
];
|
|
143
|
+
const effectGuidance = effect
|
|
144
|
+
? `
|
|
145
|
+
## Effect boundary
|
|
146
|
+
|
|
147
|
+
${effectFrontend ? '- Effect v4 is enabled in the browser; use `queryEffect` and the installed Craft Effect bridge.' : '- The browser runtime is plain; keep Effect imports out of browser components.'}
|
|
148
|
+
${effectBackend ? '- Effect v4 is enabled on the backend; keep server Effects in `src/server/`, provide services through `Layer`, and run `npm run effect-check` after changes.' : '- The backend does not use Effect; do not add Effect dependencies or server Effects unless the runtime choice changes.'}
|
|
149
|
+
${effectSkillLine}
|
|
150
|
+
`
|
|
151
|
+
: `
|
|
152
|
+
## Effect boundary
|
|
153
|
+
|
|
154
|
+
Effect is not selected in this starter. Keep the application on the plain
|
|
155
|
+
CraftTS runtime and do not add Effect imports or dependencies incidentally.
|
|
156
|
+
`;
|
|
157
|
+
return `# CraftTS project
|
|
92
158
|
|
|
93
|
-
This project was created with \`craft create
|
|
159
|
+
This project was created with \`craft create\`. Treat this file as the project
|
|
160
|
+
guide for coding agents: it records the selected runtime and feature surfaces.
|
|
161
|
+
|
|
162
|
+
## Selected configuration
|
|
163
|
+
|
|
164
|
+
- Frontend runtime: **${frontend}**
|
|
165
|
+
- Backend runtime: **${backend}**
|
|
166
|
+
- Type-safe i18n: **${i18n ? 'enabled' : 'disabled'}**
|
|
167
|
+
- Design system: **${designSystem ? 'enabled' : 'disabled'}**
|
|
168
|
+
- Typed CSS: **${typedCss ? 'enabled' : 'disabled'}**
|
|
94
169
|
|
|
95
170
|
Read \`.agents/skills/craft-ts-project/SKILL.md\` before changing application
|
|
96
|
-
code. ${
|
|
171
|
+
code. ${i18n ? 'Translation keys live in `src/i18n/`; run `npm run i18n:check` and `npm run i18n:test` after changes.' : 'This starter has no i18n surface; do not add translation files unless the project configuration changes.'}
|
|
172
|
+
${effectGuidance}
|
|
97
173
|
|
|
98
|
-
##
|
|
174
|
+
## Workflow
|
|
99
175
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
176
|
+
Use Craft primitives and yield every Craft reader. Keep the browser, server and
|
|
177
|
+
transport boundaries aligned with the selected runtimes. The architecture
|
|
178
|
+
suite is a graph contract: run \`npm run architecture\` after structural
|
|
179
|
+
changes, and do not add a test per feature or a rule for a smell already
|
|
180
|
+
covered by the baseline helpers.
|
|
103
181
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
182
|
+
## Verification
|
|
183
|
+
|
|
184
|
+
Run the checks relevant to this generated configuration:
|
|
185
|
+
|
|
186
|
+
${verify.map((command) => `- \`${command}\``).join('\n')}
|
|
187
|
+
${referenceAgentGuidance(config)}`;
|
|
188
|
+
}
|
|
189
|
+
const AGENTS_MD = agentsMd;
|
|
108
190
|
function json(value) {
|
|
109
191
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
110
192
|
}
|
|
@@ -115,15 +197,9 @@ function packageJson(context) {
|
|
|
115
197
|
const hasI18n = context.config.i18n.enabled;
|
|
116
198
|
const hasTypedCss = context.config.typedCss;
|
|
117
199
|
const hasServer = context.config.backendRuntime !== 'none';
|
|
118
|
-
const localReferences = context.config.references.mode === 'local';
|
|
119
|
-
const sourceReferences = context.config.references.mode === 'source';
|
|
120
200
|
const packageVersion = context.packageVersion ?? CRAFT_TS_STARTER_VERSION;
|
|
121
|
-
const craftPackage = (
|
|
122
|
-
|
|
123
|
-
: packageVersion;
|
|
124
|
-
const effectPackage = localReferences && context.config.references.effectTs
|
|
125
|
-
? 'file:.references/effect-ts/packages/effect'
|
|
126
|
-
: EFFECT_V4_VERSION;
|
|
201
|
+
const craftPackage = () => packageVersion;
|
|
202
|
+
const effectPackage = EFFECT_V4_VERSION;
|
|
127
203
|
return json({
|
|
128
204
|
name: context.projectName,
|
|
129
205
|
private: true,
|
|
@@ -170,37 +246,36 @@ function packageJson(context) {
|
|
|
170
246
|
'typecheck-architecture': 'tsc -p tsconfig.architecture.json --noEmit',
|
|
171
247
|
},
|
|
172
248
|
dependencies: {
|
|
173
|
-
'@craft-ts/component': craftPackage(
|
|
174
|
-
'@craft-ts/core': craftPackage(
|
|
175
|
-
...(hasI18n ? { '@craft-ts/i18n': craftPackage(
|
|
176
|
-
...(hasTypedCss ? { '@craft-ts/style': craftPackage(
|
|
249
|
+
'@craft-ts/component': craftPackage(),
|
|
250
|
+
'@craft-ts/core': craftPackage(),
|
|
251
|
+
...(hasI18n ? { '@craft-ts/i18n': craftPackage() } : {}),
|
|
252
|
+
...(hasTypedCss ? { '@craft-ts/style': craftPackage() } : {}),
|
|
177
253
|
...(hasEffect && hasI18n
|
|
178
|
-
? { '@craft-ts/i18n-effect': craftPackage(
|
|
254
|
+
? { '@craft-ts/i18n-effect': craftPackage() }
|
|
179
255
|
: {}),
|
|
180
256
|
...(hasEffect
|
|
181
|
-
? { '@craft-ts/effect': craftPackage(
|
|
257
|
+
? { '@craft-ts/effect': craftPackage(), effect: effectPackage }
|
|
182
258
|
: {}),
|
|
183
259
|
},
|
|
184
260
|
devDependencies: {
|
|
185
|
-
'@craft-ts/dev-tools': craftPackage(
|
|
186
|
-
'@craft-ts/mcp': craftPackage(
|
|
187
|
-
'@craft-ts/function-registry-mcp': craftPackage(
|
|
188
|
-
'@craft-ts/log-mcp': craftPackage(
|
|
189
|
-
'@craft-ts/log-server': craftPackage(
|
|
190
|
-
effect: effectPackage,
|
|
261
|
+
'@craft-ts/dev-tools': craftPackage(),
|
|
262
|
+
'@craft-ts/mcp': craftPackage(),
|
|
263
|
+
'@craft-ts/function-registry-mcp': craftPackage(),
|
|
264
|
+
'@craft-ts/log-mcp': craftPackage(),
|
|
265
|
+
'@craft-ts/log-server': craftPackage(),
|
|
266
|
+
...(hasEffect ? { effect: effectPackage } : {}),
|
|
191
267
|
...(hasTypedCss
|
|
192
|
-
? { '@craft-ts/style-testing': craftPackage(
|
|
268
|
+
? { '@craft-ts/style-testing': craftPackage() }
|
|
193
269
|
: {}),
|
|
194
270
|
'@playwright/test': '^1.52.0',
|
|
195
271
|
'@types/node': '^22.0.0',
|
|
272
|
+
'aria-query': '^5.3.2',
|
|
196
273
|
jsdom: '^27.1.0',
|
|
197
274
|
rxjs: '^7.8.0',
|
|
198
275
|
tslib: '^2.3.0',
|
|
199
276
|
...(hasEffect
|
|
200
277
|
? {
|
|
201
|
-
'@effect/tsgo':
|
|
202
|
-
? 'file:.references/effect-ts/packages/tsgo'
|
|
203
|
-
: '^0.24.3',
|
|
278
|
+
'@effect/tsgo': '^0.24.3',
|
|
204
279
|
'@typescript/native': 'npm:typescript@7.0.2',
|
|
205
280
|
}
|
|
206
281
|
: {}),
|
|
@@ -215,61 +290,10 @@ function packageJson(context) {
|
|
|
215
290
|
},
|
|
216
291
|
});
|
|
217
292
|
}
|
|
218
|
-
function craftReferencePackagePath(name) {
|
|
219
|
-
if (name === 'mcp' ||
|
|
220
|
-
name === 'function-registry-mcp' ||
|
|
221
|
-
name === 'log-mcp') {
|
|
222
|
-
return `packages/${name}`;
|
|
223
|
-
}
|
|
224
|
-
if (name === 'log-server')
|
|
225
|
-
return 'apps/log-server';
|
|
226
|
-
return `libs/${name}`;
|
|
227
|
-
}
|
|
228
|
-
export function localCraftReferencePackagePath(name) {
|
|
229
|
-
if (name === 'mcp' ||
|
|
230
|
-
name === 'function-registry-mcp' ||
|
|
231
|
-
name === 'log-mcp') {
|
|
232
|
-
return `packages/${name}`;
|
|
233
|
-
}
|
|
234
|
-
if (name === 'log-server')
|
|
235
|
-
return 'apps/log-server';
|
|
236
|
-
return `dist/libs/${name}`;
|
|
237
|
-
}
|
|
238
|
-
function sourceReferenceRoot(context) {
|
|
239
|
-
return context.config.workspace.kind === 'nx'
|
|
240
|
-
? '../../.references/craft-ts'
|
|
241
|
-
: './.references/craft-ts';
|
|
242
|
-
}
|
|
243
|
-
function sourceReferenceAliases(context) {
|
|
244
|
-
const root = sourceReferenceRoot(context);
|
|
245
|
-
const aliases = {
|
|
246
|
-
'@craft-ts/core': `${root}/libs/core/src/index.ts`,
|
|
247
|
-
'@craft-ts/component': `${root}/libs/component/src/index.ts`,
|
|
248
|
-
'@craft-ts/i18n': `${root}/libs/i18n/src/index.ts`,
|
|
249
|
-
'@craft-ts/style': `${root}/libs/style/src/index.ts`,
|
|
250
|
-
'@craft-ts/style/vite': `${root}/libs/style/src/plugin/vite.ts`,
|
|
251
|
-
'@craft-ts/style-testing': `${root}/libs/style-testing/src/index.ts`,
|
|
252
|
-
'@craft-ts/dev-tools': `${root}/libs/dev-tools/src/index.ts`,
|
|
253
|
-
};
|
|
254
|
-
if (context.config.frontendRuntime === 'effect' ||
|
|
255
|
-
context.config.backendRuntime === 'effect') {
|
|
256
|
-
aliases['@craft-ts/effect'] = `${root}/libs/effect/src/index.ts`;
|
|
257
|
-
}
|
|
258
|
-
if (context.config.i18n.enabled &&
|
|
259
|
-
(context.config.frontendRuntime === 'effect' ||
|
|
260
|
-
context.config.backendRuntime === 'effect')) {
|
|
261
|
-
aliases['@craft-ts/i18n-effect'] = `${root}/libs/i18n-effect/src/index.ts`;
|
|
262
|
-
}
|
|
263
|
-
return aliases;
|
|
264
|
-
}
|
|
265
293
|
function tsconfig(context) {
|
|
266
294
|
const hasEffect = context.config.frontendRuntime === 'effect' ||
|
|
267
295
|
context.config.backendRuntime === 'effect';
|
|
268
296
|
const hasServer = context.config.backendRuntime !== 'none';
|
|
269
|
-
const sourceAliases = context.config.references.mode === 'source' &&
|
|
270
|
-
context.config.references.craftTs
|
|
271
|
-
? Object.fromEntries(Object.entries(sourceReferenceAliases(context)).map(([name, path]) => [name, [path]]))
|
|
272
|
-
: undefined;
|
|
273
297
|
return json({
|
|
274
298
|
compilerOptions: {
|
|
275
299
|
target: 'ES2022',
|
|
@@ -282,7 +306,6 @@ function tsconfig(context) {
|
|
|
282
306
|
erasableSyntaxOnly: false,
|
|
283
307
|
skipLibCheck: true,
|
|
284
308
|
types: ['node'],
|
|
285
|
-
...(sourceAliases ? { paths: sourceAliases } : {}),
|
|
286
309
|
},
|
|
287
310
|
references: [
|
|
288
311
|
{ path: './tsconfig.app.json' },
|
|
@@ -308,39 +331,49 @@ const tsconfigSpec = `{
|
|
|
308
331
|
}\n`;
|
|
309
332
|
const tsconfigEffect = `{
|
|
310
333
|
"extends": "./tsconfig.json",
|
|
311
|
-
"compilerOptions": {
|
|
334
|
+
"compilerOptions": {
|
|
335
|
+
"noEmit": true,
|
|
336
|
+
"plugins": [{
|
|
337
|
+
"name": "@effect/language-service",
|
|
338
|
+
"diagnostics": true,
|
|
339
|
+
"diagnosticsName": true,
|
|
340
|
+
"overrides": [{
|
|
341
|
+
"include": ["src/**/*.ts"],
|
|
342
|
+
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
|
|
343
|
+
"options": {
|
|
344
|
+
"diagnosticSeverity": {
|
|
345
|
+
"floatingEffect": "error",
|
|
346
|
+
"missingEffectContext": "error",
|
|
347
|
+
"missingEffectError": "error",
|
|
348
|
+
"missingLayerContext": "error",
|
|
349
|
+
"missingReturnYieldStar": "error",
|
|
350
|
+
"missingStarInYieldEffectGen": "error",
|
|
351
|
+
"outdatedApi": "error",
|
|
352
|
+
"unsafeEffectTypeAssertion": "error",
|
|
353
|
+
"asyncFunction": "warning",
|
|
354
|
+
"newPromise": "warning",
|
|
355
|
+
"nodeBuiltinImport": "warning",
|
|
356
|
+
"preferSchemaOverJson": "warning"
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}]
|
|
360
|
+
}]
|
|
361
|
+
},
|
|
312
362
|
"include": ["src/**/*.ts"],
|
|
313
363
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
|
314
364
|
}\n`;
|
|
315
365
|
function viteConfig(context) {
|
|
316
366
|
const typedCss = context.config.typedCss;
|
|
317
367
|
const hasServer = context.config.backendRuntime !== 'none';
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
const sourceRoot = sourceReferenceRoot(context);
|
|
321
|
-
const styleImport = sourceReferences
|
|
322
|
-
? `import { craftStyle } from '${sourceRoot}/libs/style/src/plugin/vite.ts';`
|
|
323
|
-
: "import { craftStyle } from '@craft-ts/style/vite';";
|
|
324
|
-
const sourceAliasEntries = Object.entries(sourceReferenceAliases(context))
|
|
325
|
-
.map(([name, path]) => ` ${JSON.stringify(name)}: resolvePath(import.meta.dirname, ${JSON.stringify(path)})`)
|
|
326
|
-
.join(',\n');
|
|
327
|
-
const styleAliasEntries = sourceReferences
|
|
328
|
-
? sourceAliasEntries
|
|
329
|
-
: ` '@craft-ts/style': resolvePath(import.meta.dirname, 'node_modules/@craft-ts/style/src/index.js')`;
|
|
330
|
-
const sourceAliasConfig = sourceReferences
|
|
331
|
-
? `resolve: {
|
|
332
|
-
alias: {
|
|
333
|
-
${sourceAliasEntries}
|
|
334
|
-
},
|
|
335
|
-
},`
|
|
336
|
-
: '';
|
|
368
|
+
const styleImport = "import { craftStyle } from '@craft-ts/style/vite';";
|
|
369
|
+
const styleAliasEntries = ` '@craft-ts/style': resolvePath(import.meta.dirname, 'node_modules/@craft-ts/style/src/index.js')`;
|
|
337
370
|
const stylePlugin = typedCss
|
|
338
371
|
? ` craftStyle({ dumpPath: '.craft/style-graph.json', alias: {
|
|
339
372
|
${styleAliasEntries}
|
|
340
373
|
} }),`
|
|
341
374
|
: '';
|
|
342
375
|
return `import { readFileSync } from 'node:fs';
|
|
343
|
-
import { resolve as resolvePath } from 'node:path'
|
|
376
|
+
${typedCss ? "import { resolve as resolvePath } from 'node:path';\n" : ''}
|
|
344
377
|
import { defineConfig, type ViteDevServer } from 'vite';
|
|
345
378
|
${typedCss ? styleImport : ''}
|
|
346
379
|
|
|
@@ -378,9 +411,17 @@ ${hasServer
|
|
|
378
411
|
return {
|
|
379
412
|
name: 'craft-starter-server-functions',
|
|
380
413
|
async configureServer(server: ViteDevServer) {
|
|
381
|
-
const module = await server.ssrLoadModule('/src/server/
|
|
414
|
+
const module = await server.ssrLoadModule('/src/server/node-http.ts') as typeof import('./src/server/node-http');
|
|
382
415
|
server.middlewares.use('/__server-functions', (request, response) => {
|
|
383
|
-
void module.handleRequest(request, response)
|
|
416
|
+
void module.handleRequest(request, response).catch((error: unknown) => {
|
|
417
|
+
if (response.headersSent) {
|
|
418
|
+
response.destroy();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
response.statusCode = 500;
|
|
422
|
+
response.end('Internal Server Error');
|
|
423
|
+
console.error(error);
|
|
424
|
+
});
|
|
384
425
|
});
|
|
385
426
|
},
|
|
386
427
|
};
|
|
@@ -401,7 +442,6 @@ ${hasServer ? ' serverFunctionsPlugin(),' : ''}
|
|
|
401
442
|
port: starterPort,
|
|
402
443
|
forwardConsole: true,
|
|
403
444
|
},
|
|
404
|
-
${sourceAliasConfig}
|
|
405
445
|
build: { target: 'es2022' },
|
|
406
446
|
});
|
|
407
447
|
`;
|
|
@@ -553,6 +593,12 @@ ${effect ? '' : " 'craft-ts/no-effect-import-in-frontend': 'error',"}
|
|
|
553
593
|
},
|
|
554
594
|
{
|
|
555
595
|
files: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
|
|
596
|
+
languageOptions: {
|
|
597
|
+
parserOptions: {
|
|
598
|
+
project: ['./tsconfig.spec.json'],
|
|
599
|
+
tsconfigRootDir: import.meta.dirname,
|
|
600
|
+
},
|
|
601
|
+
},
|
|
556
602
|
rules: {
|
|
557
603
|
'craft-ts/no-async-await': 'off',
|
|
558
604
|
'craft-ts/no-throw': 'off',
|
|
@@ -564,6 +610,7 @@ ${effect ? '' : " 'craft-ts/no-effect-import-in-frontend': 'error',"}
|
|
|
564
610
|
${backendEffect
|
|
565
611
|
? ` {
|
|
566
612
|
files: ['src/server/**/*.ts', 'src/**/*.fn-serveur.ts', 'src/**/*.mw-serveur.ts'],
|
|
613
|
+
ignores: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
|
|
567
614
|
plugins: { 'craft-ts': craftRules },
|
|
568
615
|
languageOptions: {
|
|
569
616
|
parserOptions: {
|
|
@@ -632,7 +679,8 @@ const styles = `:root {
|
|
|
632
679
|
body { margin: 0; min-width: 320px; }
|
|
633
680
|
a { color: #2457d6; }
|
|
634
681
|
main { max-width: 860px; margin: 0 auto; padding: 3rem 1.25rem; }
|
|
635
|
-
nav { display: flex; gap: 1rem; padding: 1rem 1.25rem; background: white; border-bottom: 1px solid #e3e7ef; }
|
|
682
|
+
nav { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.25rem; background: white; border-bottom: 1px solid #e3e7ef; }
|
|
683
|
+
.starter-experimental-badge { display: inline-flex; align-items: center; margin-left: auto; padding: .25rem .55rem; border: 1px solid #f0c36d; border-radius: 999px; color: #7a4b00; background: #fff8e6; font-size: .75rem; font-weight: 600; line-height: 1.2; white-space: nowrap; }
|
|
636
684
|
button:focus-visible, a:focus-visible { outline: 3px solid #7aa2ff; outline-offset: 3px; }
|
|
637
685
|
.heading { font-size: var(--craft-font-size-heading); line-height: var(--craft-line-height-heading); font-weight: 700; }
|
|
638
686
|
body, .body { font-size: var(--craft-font-size-body); line-height: var(--craft-line-height-body); }
|
|
@@ -1032,6 +1080,7 @@ function appTs(context) {
|
|
|
1032
1080
|
div,
|
|
1033
1081
|
main,
|
|
1034
1082
|
nav,
|
|
1083
|
+
span,
|
|
1035
1084
|
} from '@craft-ts/component';
|
|
1036
1085
|
import { CraftRouterLink } from '@craft-ts/core';
|
|
1037
1086
|
${uiImport}
|
|
@@ -1046,6 +1095,7 @@ export const App = craftComponent(
|
|
|
1046
1095
|
a('home', {}, 'Home').pipe(CraftRouterLink({ to: '' })),
|
|
1047
1096
|
a('services', {}, 'Services').pipe(CraftRouterLink({ to: 'services' })),
|
|
1048
1097
|
a('about', {}, 'About').pipe(CraftRouterLink({ to: 'about' })),
|
|
1098
|
+
span({ class: 'starter-experimental-badge' }, 'Experimental · feedback welcome'),
|
|
1049
1099
|
]),
|
|
1050
1100
|
main(CraftRouterOutlet()),
|
|
1051
1101
|
${themeClose},
|
|
@@ -1065,7 +1115,15 @@ function routesTs(context) {
|
|
|
1065
1115
|
}),
|
|
1066
1116
|
`
|
|
1067
1117
|
: '';
|
|
1068
|
-
|
|
1118
|
+
const backendEffectErrorHandler = context.config.frontendRuntime === 'plain' &&
|
|
1119
|
+
context.config.backendRuntime === 'effect'
|
|
1120
|
+
? ` StarterRepositoryError: craftExceptionHandler(function* ({ globalError }) {
|
|
1121
|
+
return globalError();
|
|
1122
|
+
}),
|
|
1123
|
+
`
|
|
1124
|
+
: '';
|
|
1125
|
+
return `/* eslint-disable require-yield -- Route exception outcomes are synchronous by design. */
|
|
1126
|
+
import { loadCraftComponent } from '@craft-ts/component';
|
|
1069
1127
|
import {
|
|
1070
1128
|
assertExhaustiveRouteExceptions,
|
|
1071
1129
|
craftExceptionHandler,
|
|
@@ -1085,6 +1143,7 @@ export const { appRoutes } = craftRoutes('app', [
|
|
|
1085
1143
|
),
|
|
1086
1144
|
}, {
|
|
1087
1145
|
${httpErrorHandler}
|
|
1146
|
+
${backendEffectErrorHandler}
|
|
1088
1147
|
${welcomeErrorHandler} }),
|
|
1089
1148
|
craftRoute('about', {
|
|
1090
1149
|
...loadCraftComponent(({ withRetry }) =>
|
|
@@ -1200,23 +1259,30 @@ function serverFiles(context) {
|
|
|
1200
1259
|
return {};
|
|
1201
1260
|
const effect = context.config.backendRuntime === 'effect';
|
|
1202
1261
|
const fnServer = effect
|
|
1203
|
-
? `import { serverFunction } from '@craft-ts/core';
|
|
1262
|
+
? `import { serverFunction, type ServerFunctionSuccess } from '@craft-ts/core';
|
|
1204
1263
|
import { Effect, Schema } from 'effect';
|
|
1205
1264
|
import { StarterRepository } from './server/repository';
|
|
1265
|
+
import { starterMiddleware } from './starter.mw-serveur';
|
|
1206
1266
|
|
|
1207
1267
|
const inputSchema = Schema.toStandardSchemaV1(Schema.Struct({ filter: Schema.String }));
|
|
1208
1268
|
const outputSchema = Schema.toStandardSchemaV1(Schema.Struct({ title: Schema.String, body: Schema.String }));
|
|
1209
1269
|
|
|
1210
|
-
export type StarterResponse = { readonly title: string; readonly body: string };
|
|
1211
|
-
|
|
1212
1270
|
export const getStarterMessage = serverFunction(
|
|
1213
1271
|
'starter.welcome', inputSchema, { exposure: 'client', output: outputSchema },
|
|
1214
|
-
).handler(({ input }) => Effect.gen(function* () {
|
|
1272
|
+
).use(starterMiddleware).handler(({ input }) => Effect.gen(function* () {
|
|
1215
1273
|
const repository = yield* StarterRepository;
|
|
1216
1274
|
return yield* repository.welcome(input.filter);
|
|
1217
|
-
})).exposeErrors({
|
|
1275
|
+
})).exposeErrors({
|
|
1276
|
+
StarterRepositoryError: (errorPayload) => ({
|
|
1277
|
+
code: 'STARTER_REPOSITORY_FAILURE',
|
|
1278
|
+
status: 503,
|
|
1279
|
+
payload: { filter: errorPayload.filter, message: errorPayload.message },
|
|
1280
|
+
}),
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
export type StarterResponse = ServerFunctionSuccess<typeof getStarterMessage>;
|
|
1218
1284
|
`
|
|
1219
|
-
: `import { flatMapContext, mapContext, portableServerFunction } from '@craft-ts/core';
|
|
1285
|
+
: `import { flatMapContext, mapContext, portableServerFunction, type SchemaOutput, type ServerFunctionContractOutput, type ServerFunctionSuccess } from '@craft-ts/core';
|
|
1220
1286
|
import type { StandardSchemaV1 } from '@craft-ts/core';
|
|
1221
1287
|
import { StarterRepository } from './server/repository';
|
|
1222
1288
|
|
|
@@ -1224,141 +1290,278 @@ type Input = { readonly filter: string };
|
|
|
1224
1290
|
const inputSchema: StandardSchemaV1<Input, Input> = { '~standard': { version: 1, vendor: 'craft-starter', types: undefined,
|
|
1225
1291
|
validate(value: unknown) { return typeof value === 'object' && value !== null && typeof (value as Input).filter === 'string'
|
|
1226
1292
|
? { value: value as Input } : { issues: [{ message: 'filter must be a string' }] }; } } };
|
|
1227
|
-
|
|
1293
|
+
const outputSchema: StandardSchemaV1<{ readonly title: string; readonly body: string }, { readonly title: string; readonly body: string }> = { '~standard': { version: 1, vendor: 'craft-starter', types: undefined,
|
|
1294
|
+
validate(value: unknown) { return typeof value === 'object' && value !== null && typeof (value as { title?: unknown }).title === 'string' && typeof (value as { body?: unknown }).body === 'string'
|
|
1295
|
+
? { value: value as { readonly title: string; readonly body: string } } : { issues: [{ message: 'welcome response must contain title and body' }] }; } } };
|
|
1228
1296
|
|
|
1229
|
-
export const getStarterMessage = portableServerFunction('starter.welcome', inputSchema, { exposure: 'client' })
|
|
1297
|
+
export const getStarterMessage = portableServerFunction('starter.welcome', inputSchema, { exposure: 'client', output: outputSchema })
|
|
1230
1298
|
.pipe(
|
|
1231
1299
|
mapContext(({ input }) => ({ normalizedFilter: input.filter.trim() })),
|
|
1232
1300
|
flatMapContext(() => StarterRepository.welcome()),
|
|
1233
1301
|
)
|
|
1234
|
-
.handler(async ({ context }) => context.value)
|
|
1302
|
+
.handler(async ({ context }) => context.value as SchemaOutput<typeof outputSchema>)
|
|
1235
1303
|
.exposeErrors({});
|
|
1304
|
+
|
|
1305
|
+
export type StarterResponse = ServerFunctionContractOutput<typeof getStarterMessage['contract']>;
|
|
1236
1306
|
`;
|
|
1237
1307
|
const repository = effect
|
|
1238
|
-
? `import { Context, Effect, Layer } from 'effect';
|
|
1308
|
+
? `import { Context, Data, Effect, Layer } from 'effect';
|
|
1239
1309
|
|
|
1240
1310
|
export type StarterRepositoryShape = {
|
|
1241
|
-
readonly welcome: (filter: string) => Effect.Effect<
|
|
1311
|
+
readonly welcome: (filter: string) => Effect.Effect<
|
|
1312
|
+
{ readonly title: string; readonly body: string },
|
|
1313
|
+
StarterRepositoryError
|
|
1314
|
+
>;
|
|
1242
1315
|
};
|
|
1243
1316
|
export class StarterRepository extends Context.Service<StarterRepository, StarterRepositoryShape>()('starter/StarterRepository') {}
|
|
1317
|
+
export class StarterRepositoryError extends Data.TaggedError('StarterRepositoryError')<{
|
|
1318
|
+
readonly filter: string;
|
|
1319
|
+
readonly message: string;
|
|
1320
|
+
}> {}
|
|
1321
|
+
|
|
1244
1322
|
export const StarterRepositoryLive = Layer.succeed(StarterRepository, {
|
|
1245
|
-
welcome: (filter) => Effect.
|
|
1323
|
+
welcome: (filter) => Effect.tryPromise({
|
|
1324
|
+
try: () => filter === 'error'
|
|
1325
|
+
? Promise.reject(new Error('The starter repository failed.'))
|
|
1326
|
+
: Promise.resolve({ title: 'Hello from the server', body: 'Effect server function: ' + filter }),
|
|
1327
|
+
catch: (cause) => new StarterRepositoryError({
|
|
1328
|
+
filter,
|
|
1329
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
1330
|
+
}),
|
|
1331
|
+
}),
|
|
1246
1332
|
});
|
|
1247
1333
|
`
|
|
1248
1334
|
: `export const StarterRepository = {
|
|
1249
1335
|
welcome: async () => ({ value: { title: 'Hello from the server', body: 'Promise server function works.' } }),
|
|
1250
1336
|
};
|
|
1251
1337
|
`;
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
server = `import { createServer } from '@craft-ts/core';
|
|
1338
|
+
const application = effect
|
|
1339
|
+
? `import { createServer, type Server } from '@craft-ts/core';
|
|
1255
1340
|
import { executeEffect } from '@craft-ts/effect';
|
|
1341
|
+
${context.config.i18n.enabled ? "import { Layer } from 'effect';\n" : ''}
|
|
1256
1342
|
import { getStarterMessage } from '../starter.fn-serveur';
|
|
1257
1343
|
import { StarterRepositoryLive } from './repository';
|
|
1258
|
-
import
|
|
1344
|
+
${context.config.i18n.enabled ? "import { serverI18nLayer } from './i18n';\n" : ''}
|
|
1345
|
+
|
|
1346
|
+
export const runtimeLayer = ${context.config.i18n.enabled ? 'Layer.mergeAll(StarterRepositoryLive, serverI18nLayer)' : 'StarterRepositoryLive'};
|
|
1347
|
+
|
|
1348
|
+
export function createApplication(layer = runtimeLayer): Server {
|
|
1349
|
+
return createServer({
|
|
1350
|
+
functions: [getStarterMessage],
|
|
1351
|
+
execute: executeEffect(layer).run,
|
|
1352
|
+
runtimeOptions: {
|
|
1353
|
+
maxBodyBytes: 1_000_000,
|
|
1354
|
+
maxOutputBytes: 1_000_000,
|
|
1355
|
+
timeoutMs: 10_000,
|
|
1356
|
+
},
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1259
1359
|
|
|
1260
|
-
export const application =
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1360
|
+
export const application = createApplication();
|
|
1361
|
+
`
|
|
1362
|
+
: `import { createServer, type Server } from '@craft-ts/core';
|
|
1363
|
+
import { getStarterMessage } from '../starter.fn-serveur';
|
|
1364
|
+
|
|
1365
|
+
export function createApplication(): Server {
|
|
1366
|
+
return createServer({
|
|
1367
|
+
functions: [getStarterMessage],
|
|
1368
|
+
runtimeOptions: {
|
|
1369
|
+
maxBodyBytes: 1_000_000,
|
|
1370
|
+
maxOutputBytes: 1_000_000,
|
|
1371
|
+
timeoutMs: 10_000,
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1274
1374
|
}
|
|
1375
|
+
|
|
1376
|
+
export const application = createApplication();
|
|
1275
1377
|
`;
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
server = `import { createServer } from '@craft-ts/core';
|
|
1279
|
-
import { getStarterMessage } from '../starter.fn-serveur';
|
|
1378
|
+
const nodeHttp = `/* eslint-disable craft-ts/no-async-await -- The Node adapter is an async platform boundary. */
|
|
1379
|
+
import { application } from './application';
|
|
1280
1380
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
1281
1381
|
|
|
1282
|
-
export
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1382
|
+
export async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
1383
|
+
const abortController = new AbortController();
|
|
1384
|
+
const abort = () => abortController.abort();
|
|
1385
|
+
const close = () => {
|
|
1386
|
+
if (!request.complete) abort();
|
|
1387
|
+
};
|
|
1388
|
+
request.once('aborted', abort);
|
|
1389
|
+
request.once('close', close);
|
|
1390
|
+
try {
|
|
1391
|
+
const webResponse = await application.handle(toWebRequest(request, abortController.signal));
|
|
1392
|
+
await writeWebResponse(webResponse, response, request.method === 'HEAD');
|
|
1393
|
+
} finally {
|
|
1394
|
+
request.off('aborted', abort);
|
|
1395
|
+
request.off('close', close);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function toWebRequest(request: IncomingMessage, signal: AbortSignal): Request {
|
|
1400
|
+
const headers = new Headers();
|
|
1401
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
1402
|
+
if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value);
|
|
1403
|
+
}
|
|
1404
|
+
const method = request.method ?? 'GET';
|
|
1405
|
+
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
1406
|
+
return new Request(
|
|
1407
|
+
'http://' + (request.headers.host ?? '127.0.0.1') + (request.url ?? '/'),
|
|
1408
|
+
{
|
|
1409
|
+
method,
|
|
1410
|
+
headers,
|
|
1411
|
+
signal,
|
|
1412
|
+
...(hasBody ? { body: request as unknown as BodyInit, duplex: 'half' } : {}),
|
|
1413
|
+
} as RequestInit,
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
async function writeWebResponse(
|
|
1418
|
+
webResponse: Response,
|
|
1419
|
+
response: ServerResponse,
|
|
1420
|
+
head: boolean,
|
|
1421
|
+
): Promise<void> {
|
|
1292
1422
|
response.statusCode = webResponse.status;
|
|
1293
1423
|
webResponse.headers.forEach((value, name) => response.setHeader(name, value));
|
|
1424
|
+
if (head || webResponse.body === null) {
|
|
1425
|
+
response.end();
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1294
1428
|
response.end(Buffer.from(await webResponse.arrayBuffer()));
|
|
1295
1429
|
}
|
|
1296
1430
|
`;
|
|
1297
|
-
}
|
|
1298
|
-
const client = `import { createServerFunctionClient, craftUnique } from '@craft-ts/core';
|
|
1431
|
+
const client = `import { createServerFunctionClient, craftUnique, type ServerFunctionClient } from '@craft-ts/core';
|
|
1299
1432
|
import type { getStarterMessage as ServerGetStarterMessage, StarterResponse } from './starter.fn-serveur';
|
|
1300
1433
|
|
|
1301
1434
|
export type { StarterResponse };
|
|
1302
|
-
|
|
1435
|
+
const starterMessageTransport = createServerFunctionClient<typeof ServerGetStarterMessage>(craftUnique('starter.welcome'));
|
|
1436
|
+
export const getStarterMessage = starterMessageTransport as ServerFunctionClient<typeof ServerGetStarterMessage, StarterResponse>;
|
|
1437
|
+
`;
|
|
1438
|
+
const compatibilityServer = `export { application, createApplication } from './application';
|
|
1439
|
+
${effect ? 'export { runtimeLayer } from \'./application\';\n' : ''}export { handleRequest } from './node-http';
|
|
1303
1440
|
`;
|
|
1304
1441
|
return {
|
|
1305
1442
|
'src/server/repository.ts': repository,
|
|
1306
|
-
'src/server/
|
|
1443
|
+
'src/server/application.ts': application,
|
|
1444
|
+
'src/server/node-http.ts': nodeHttp,
|
|
1445
|
+
'src/server/server.ts': compatibilityServer,
|
|
1307
1446
|
'src/starter.fn-serveur.ts': fnServer,
|
|
1308
1447
|
'src/starter.fn-client.ts': client,
|
|
1309
1448
|
...(effect && context.config.i18n.enabled
|
|
1310
1449
|
? {
|
|
1311
|
-
'src/server/i18n.ts': "import { provideI18nRuntime } from '@craft-ts/i18n-effect';\nimport { i18n } from '../i18n/runtime';\nexport const serverI18nLayer = provideI18nRuntime(i18n);\
|
|
1450
|
+
'src/server/i18n.ts': "import { provideI18nRuntime } from '@craft-ts/i18n-effect';\nimport { i18n } from '../i18n/runtime';\n\nexport const serverI18nLayer = provideI18nRuntime(i18n);\n",
|
|
1312
1451
|
}
|
|
1313
1452
|
: {}),
|
|
1314
1453
|
...(effect
|
|
1315
1454
|
? {
|
|
1316
|
-
'src/starter.mw-serveur.ts': "import { Effect } from 'effect';\nimport { effectServerMiddleware } from '@craft-ts/effect';\nexport const starterMiddleware = effectServerMiddleware('starter.middleware', ()
|
|
1455
|
+
'src/starter.mw-serveur.ts': "import { Effect } from 'effect';\nimport { effectServerMiddleware } from '@craft-ts/effect';\n\nexport const starterMiddleware = effectServerMiddleware('starter.middleware', () =>\n Effect.gen(function* () {\n yield* Effect.log('starter middleware executed');\n return { value: undefined };\n }),\n);\n",
|
|
1317
1456
|
}
|
|
1318
1457
|
: {}),
|
|
1319
1458
|
'vitest.server.config.ts': `import { defineConfig } from 'vitest/config';
|
|
1320
1459
|
export default defineConfig({ test: { name: 'craft-starter-server', globals: true, environment: 'node', include: ['src/server/**/*.spec.ts'] } });
|
|
1321
1460
|
`,
|
|
1322
|
-
'src/server/server.spec.ts':
|
|
1323
|
-
|
|
1461
|
+
'src/server/server.spec.ts': serverSpec(context),
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
function serverSpec(context) {
|
|
1465
|
+
const effectFailure = context.config.backendRuntime === 'effect'
|
|
1466
|
+
? `
|
|
1467
|
+
it('exposes the typed Effect repository failure', async () => {
|
|
1468
|
+
const response = await application.handle(new Request('http://127.0.0.1/__server-functions', {
|
|
1469
|
+
method: 'POST',
|
|
1470
|
+
headers: { 'content-type': 'application/json' },
|
|
1471
|
+
body: JSON.stringify({ id: 'starter.welcome', input: { filter: 'error' } }),
|
|
1472
|
+
}));
|
|
1473
|
+
expect(response.status).toBe(503);
|
|
1474
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
1475
|
+
error: { _tag: 'StarterRepositoryError', code: 'STARTER_REPOSITORY_FAILURE', filter: 'error' },
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
`
|
|
1479
|
+
: '';
|
|
1480
|
+
return `import { createServer as createNodeServer } from 'node:http';
|
|
1481
|
+
import { describe, expect, it } from 'vitest';
|
|
1482
|
+
import { createServer, serverFunction, type StandardSchemaV1 } from '@craft-ts/core';
|
|
1483
|
+
import { application, handleRequest } from './server';
|
|
1484
|
+
|
|
1485
|
+
const invalidInputSchema: StandardSchemaV1<unknown, unknown> = {
|
|
1486
|
+
'~standard': { version: 1, vendor: 'craft-starter-test', types: undefined,
|
|
1487
|
+
validate(value: unknown) { return { value }; } },
|
|
1488
|
+
};
|
|
1489
|
+
const invalidOutputSchema: StandardSchemaV1<{ readonly required: string }, { readonly required: string }> = {
|
|
1490
|
+
'~standard': { version: 1, vendor: 'craft-starter-test', types: undefined,
|
|
1491
|
+
validate(value: unknown) {
|
|
1492
|
+
return typeof value === 'object' && value !== null && typeof (value as { required?: unknown }).required === 'string'
|
|
1493
|
+
? { value: value as { readonly required: string } }
|
|
1494
|
+
: { issues: [{ message: 'required must be a string' }] };
|
|
1495
|
+
} },
|
|
1496
|
+
};
|
|
1497
|
+
const invalidOutput = serverFunction(
|
|
1498
|
+
'starter.invalid-output',
|
|
1499
|
+
invalidInputSchema,
|
|
1500
|
+
{ exposure: 'server', output: invalidOutputSchema },
|
|
1501
|
+
).handler(() => ({ required: 123 })).exposeErrors({});
|
|
1324
1502
|
|
|
1325
1503
|
describe('server function registry', () => {
|
|
1326
|
-
it('
|
|
1504
|
+
it('invokes the starter function through the registry', async () => {
|
|
1505
|
+
await expect(application.invoke('starter.welcome', { filter: 'Ada' })).resolves.toMatchObject({
|
|
1506
|
+
title: 'Hello from the server',
|
|
1507
|
+
});
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
it('rejects invalid input', async () => {
|
|
1511
|
+
await expect(application.invoke('starter.welcome', { filter: 123 })).rejects.toThrow(
|
|
1512
|
+
'CRAFT_SERVER_FUNCTION_INPUT_INVALID',
|
|
1513
|
+
);
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
it('rejects invalid output', async () => {
|
|
1517
|
+
const server = createServer({ functions: [invalidOutput] });
|
|
1518
|
+
await expect(server.invoke('starter.invalid-output', undefined)).rejects.toThrow(
|
|
1519
|
+
'CRAFT_SERVER_FUNCTION_OUTPUT_INVALID',
|
|
1520
|
+
);
|
|
1521
|
+
});
|
|
1522
|
+
${effectFailure}
|
|
1523
|
+
|
|
1524
|
+
it('serves a real HTTP request through the Node adapter', async () => {
|
|
1525
|
+
const nodeServer = createNodeServer((request, response) => {
|
|
1526
|
+
void handleRequest(request, response).catch((error: unknown) => {
|
|
1527
|
+
if (!response.headersSent) response.statusCode = 500;
|
|
1528
|
+
response.end('Internal Server Error');
|
|
1529
|
+
throw error;
|
|
1530
|
+
});
|
|
1531
|
+
});
|
|
1532
|
+
await new Promise<void>((resolve) => nodeServer.listen(0, '127.0.0.1', resolve));
|
|
1533
|
+
const address = nodeServer.address();
|
|
1534
|
+
if (!address || typeof address === 'string') throw new Error('Server did not start.');
|
|
1535
|
+
try {
|
|
1536
|
+
const response = await fetch('http://127.0.0.1:' + address.port + '/__server-functions', {
|
|
1537
|
+
method: 'POST',
|
|
1538
|
+
headers: { 'content-type': 'application/json' },
|
|
1539
|
+
body: JSON.stringify({ id: 'starter.welcome', input: { filter: 'Ada' } }),
|
|
1540
|
+
});
|
|
1541
|
+
expect(response.status).toBe(200);
|
|
1542
|
+
await expect(response.json()).resolves.toMatchObject({ title: 'Hello from the server' });
|
|
1543
|
+
} finally {
|
|
1544
|
+
await new Promise<void>((resolve, reject) => nodeServer.close((error) => error ? reject(error) : resolve()));
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
it('rejects unsupported method and content type', async () => {
|
|
1549
|
+
await expect(application.handle(new Request('http://127.0.0.1/__server-functions'))).resolves.toHaveProperty('status', 405);
|
|
1550
|
+
await expect(application.handle(new Request('http://127.0.0.1/__server-functions', {
|
|
1551
|
+
method: 'POST',
|
|
1552
|
+
headers: { 'content-type': 'text/plain' },
|
|
1553
|
+
body: '{}',
|
|
1554
|
+
}))).resolves.toHaveProperty('status', 415);
|
|
1555
|
+
});
|
|
1327
1556
|
});
|
|
1328
|
-
|
|
1329
|
-
};
|
|
1557
|
+
`;
|
|
1330
1558
|
}
|
|
1331
|
-
export const EFFECT_REFERENCE_PATHS = [
|
|
1332
|
-
'apps/demo-effect',
|
|
1333
|
-
'apps/quickstart-effect',
|
|
1334
|
-
'apps/demo-with-server-function',
|
|
1335
|
-
'apps/docs/learn-effect',
|
|
1336
|
-
'apps/docs/tsconfig.learn-effect.json',
|
|
1337
|
-
'apps/docs/tests/snippets/learn-effect',
|
|
1338
|
-
'apps/docs/guide/advanced/effect.md',
|
|
1339
|
-
'apps/docs/guide/i18n/effect.md',
|
|
1340
|
-
'apps/docs/guide/reactivity/craft-effect.md',
|
|
1341
|
-
'apps/docs/guide/testing/architecture/craft-effect-imperative-sync.md',
|
|
1342
|
-
'apps/docs/guide/testing/architecture/craft-effect-network.md',
|
|
1343
|
-
'apps/docs/resources/effect-adoption.md',
|
|
1344
|
-
'apps/docs/resources/effect-compatibility.md',
|
|
1345
|
-
'apps/docs/public/assets/effect-logo-black.png',
|
|
1346
|
-
'apps/docs/public/assets/effect-craft-ts-hover.png',
|
|
1347
|
-
'apps/docs/public/assets/effect-craft-mark-hover.png',
|
|
1348
|
-
'libs/effect',
|
|
1349
|
-
'libs/i18n-effect',
|
|
1350
|
-
'packages/mcp/skills/craft-ts-effect-v4',
|
|
1351
|
-
'tools/effect-diagnostics',
|
|
1352
|
-
'tools/compile-learn-effect-examples.mjs',
|
|
1353
|
-
'tools/run-effect-tsgo.mjs',
|
|
1354
|
-
'tools/effect-typecost',
|
|
1355
|
-
];
|
|
1356
1559
|
function referenceFiles(context) {
|
|
1357
1560
|
if (!context.config.references.craftTs && !context.config.references.effectTs)
|
|
1358
1561
|
return {};
|
|
1359
1562
|
const manifest = {
|
|
1360
1563
|
schemaVersion: 1,
|
|
1361
|
-
mode: context
|
|
1564
|
+
mode: 'context',
|
|
1362
1565
|
effectEnabled: context.config.frontendRuntime === 'effect' ||
|
|
1363
1566
|
context.config.backendRuntime === 'effect',
|
|
1364
1567
|
};
|
|
@@ -1376,14 +1579,19 @@ function referenceFiles(context) {
|
|
|
1376
1579
|
resolvedSha: '',
|
|
1377
1580
|
path: '.references/effect-ts',
|
|
1378
1581
|
};
|
|
1379
|
-
const resolver = "
|
|
1380
|
-
const updater = `import { execFileSync } from 'node:child_process'; import {
|
|
1381
|
-
const root = resolve(import.meta.dirname, '..');
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1384
|
-
const
|
|
1385
|
-
|
|
1386
|
-
|
|
1582
|
+
const resolver = "import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path';\nexport function resolveReferenceManifest(root) { return JSON.parse(readFileSync(join(root, '.references/manifest.json'), 'utf8')); }\nexport function resolveReferencePath(root, name) { const manifest = resolveReferenceManifest(root); return manifest[name] ? resolve(root, manifest[name].path) : undefined; }\n";
|
|
1583
|
+
const updater = `import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path';
|
|
1584
|
+
const root = resolve(import.meta.dirname, '..');
|
|
1585
|
+
const manifestPath = join(root, '.references/manifest.json');
|
|
1586
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
1587
|
+
for (const [name, entry] of Object.entries(manifest).filter(([key, value]) => !['schemaVersion', 'mode', 'effectEnabled'].includes(key) && value && value.path)) {
|
|
1588
|
+
const path = resolve(root, entry.path);
|
|
1589
|
+
if (!existsSync(join(path, '.git'))) throw new Error('Missing reference clone: ' + path);
|
|
1590
|
+
if (execFileSync('git', ['status', '--short'], { cwd: path, encoding: 'utf8' }).trim()) throw new Error('Modified reference: ' + path);
|
|
1591
|
+
execFileSync('git', ['fetch', '--depth', '1', 'origin', entry.requestedRef], { cwd: path, stdio: 'inherit' });
|
|
1592
|
+
execFileSync('git', ['checkout', '--detach', 'FETCH_HEAD'], { cwd: path, stdio: 'inherit' });
|
|
1593
|
+
entry.resolvedSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: path, encoding: 'utf8' }).trim();
|
|
1594
|
+
}
|
|
1387
1595
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\\n');
|
|
1388
1596
|
`;
|
|
1389
1597
|
return {
|
|
@@ -1422,22 +1630,6 @@ function cloneReferenceIfRequested(root, config) {
|
|
|
1422
1630
|
mkdirSync(dirname(target), { recursive: true });
|
|
1423
1631
|
execFileSync('git', ['clone', '--depth', '1', '--branch', ref, url, target], { cwd: root, stdio: 'inherit' });
|
|
1424
1632
|
}
|
|
1425
|
-
if (relativePath === '.references/craft-ts' &&
|
|
1426
|
-
config.frontendRuntime !== 'effect' &&
|
|
1427
|
-
config.backendRuntime !== 'effect') {
|
|
1428
|
-
pruneEffectReference(target);
|
|
1429
|
-
}
|
|
1430
|
-
if (config.references.mode === 'local' ||
|
|
1431
|
-
(config.references.mode === 'source' &&
|
|
1432
|
-
relativePath === '.references/effect-ts')) {
|
|
1433
|
-
execFileSync('npm', ['install', '--no-audit', '--no-fund'], {
|
|
1434
|
-
cwd: target,
|
|
1435
|
-
stdio: 'inherit',
|
|
1436
|
-
});
|
|
1437
|
-
for (const [command, args] of getReferenceBuildSteps(relativePath, config)) {
|
|
1438
|
-
execFileSync(command, [...args], { cwd: target, stdio: 'inherit' });
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
1633
|
}
|
|
1442
1634
|
const manifestPath = join(root, '.references', 'manifest.json');
|
|
1443
1635
|
if (existsSync(manifestPath)) {
|
|
@@ -1454,51 +1646,6 @@ function cloneReferenceIfRequested(root, config) {
|
|
|
1454
1646
|
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
1455
1647
|
}
|
|
1456
1648
|
}
|
|
1457
|
-
/**
|
|
1458
|
-
* CraftTS has no root `build` script: its published packages are Nx targets,
|
|
1459
|
-
* while the MCP and log workspaces keep their own package build scripts.
|
|
1460
|
-
*/
|
|
1461
|
-
export function getReferenceBuildSteps(relativePath, config) {
|
|
1462
|
-
if (relativePath === '.references/effect-ts') {
|
|
1463
|
-
return [['npm', ['run', 'build']]];
|
|
1464
|
-
}
|
|
1465
|
-
if (relativePath !== '.references/craft-ts')
|
|
1466
|
-
return [];
|
|
1467
|
-
const hasEffect = config.frontendRuntime === 'effect' || config.backendRuntime === 'effect';
|
|
1468
|
-
const projects = [
|
|
1469
|
-
'craft-ts-core',
|
|
1470
|
-
'craft-ts-component',
|
|
1471
|
-
'dev-tools',
|
|
1472
|
-
'mcp',
|
|
1473
|
-
...(config.i18n.enabled ? ['craft-ts-i18n'] : []),
|
|
1474
|
-
...(config.i18n.enabled && hasEffect ? ['craft-ts-i18n-effect'] : []),
|
|
1475
|
-
...(config.typedCss
|
|
1476
|
-
? ['craft-ts-style', 'craft-ts-style-testing']
|
|
1477
|
-
: []),
|
|
1478
|
-
...(hasEffect ? ['craft-ts-effect'] : []),
|
|
1479
|
-
];
|
|
1480
|
-
return [
|
|
1481
|
-
[
|
|
1482
|
-
'npx',
|
|
1483
|
-
[
|
|
1484
|
-
'--no-install',
|
|
1485
|
-
'nx',
|
|
1486
|
-
'run-many',
|
|
1487
|
-
'--target=build',
|
|
1488
|
-
'--projects',
|
|
1489
|
-
projects.join(','),
|
|
1490
|
-
'--skipSync',
|
|
1491
|
-
'--outputStyle=stream',
|
|
1492
|
-
],
|
|
1493
|
-
],
|
|
1494
|
-
['npm', ['run', 'build', '--workspace', '@craft-ts/log-server']],
|
|
1495
|
-
['npm', ['run', 'build', '--workspace', '@craft-ts/log-mcp']],
|
|
1496
|
-
[
|
|
1497
|
-
'npm',
|
|
1498
|
-
['run', 'build', '--workspace', '@craft-ts/function-registry-mcp'],
|
|
1499
|
-
],
|
|
1500
|
-
];
|
|
1501
|
-
}
|
|
1502
1649
|
function ensureGitignore(root) {
|
|
1503
1650
|
const gitignorePath = join(root, '.gitignore');
|
|
1504
1651
|
if (!existsSync(gitignorePath)) {
|
|
@@ -1531,11 +1678,6 @@ function initialiseGitRepository(root) {
|
|
|
1531
1678
|
}
|
|
1532
1679
|
execFileSync('git', ['init', '--quiet'], { cwd: root, stdio: 'ignore' });
|
|
1533
1680
|
}
|
|
1534
|
-
export function pruneEffectReference(root) {
|
|
1535
|
-
for (const relativePath of EFFECT_REFERENCE_PATHS) {
|
|
1536
|
-
rmSync(join(root, relativePath), { recursive: true, force: true });
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
1681
|
function aboutPageTs(context) {
|
|
1540
1682
|
const designSystem = context.config.designSystem !== 'none';
|
|
1541
1683
|
const surfaceImport = designSystem
|
|
@@ -1593,7 +1735,8 @@ const { StarterService } = craftService({ name: 'StarterService', providedIn: 'g
|
|
|
1593
1735
|
return { label: 'resolved through Craft DI' };
|
|
1594
1736
|
});
|
|
1595
1737
|
`;
|
|
1596
|
-
|
|
1738
|
+
const lintComment = context.config.frontendRuntime === 'effect' ? '' : '/* eslint-disable require-yield -- Synchronous DI factory is intentional in this starter. */\n';
|
|
1739
|
+
return `${lintComment}import { craftComponent, div, heading, p } from '@craft-ts/component';
|
|
1597
1740
|
${uiImport}${i18n}${effectI18n}${service}
|
|
1598
1741
|
export const ServicesPage = craftComponent(
|
|
1599
1742
|
'ServicesPage',
|
|
@@ -1624,9 +1767,9 @@ function plainHomePageTs(context) {
|
|
|
1624
1767
|
const summary = context.config.i18n.enabled
|
|
1625
1768
|
? "p('i18n: ' + i18n.t('order.summary', { amount: 1234.5, count: 2, date: Date.UTC(2026, 0, 15) })),"
|
|
1626
1769
|
: "p('A framework-independent starter with a typed API boundary.'),";
|
|
1627
|
-
const
|
|
1628
|
-
? 'return yield* loadWelcome();'
|
|
1629
|
-
: '
|
|
1770
|
+
const loadLoader = context.config.backendRuntime === 'none'
|
|
1771
|
+
? 'function* () { return yield* loadWelcome(); }'
|
|
1772
|
+
: '() => loadWelcome()';
|
|
1630
1773
|
return `import {
|
|
1631
1774
|
craftComponent,
|
|
1632
1775
|
div,
|
|
@@ -1646,9 +1789,7 @@ export const HomePage = craftComponent(
|
|
|
1646
1789
|
'welcomeQuery',
|
|
1647
1790
|
{
|
|
1648
1791
|
params: () => true,
|
|
1649
|
-
loader:
|
|
1650
|
-
${loadExpression}
|
|
1651
|
-
},
|
|
1792
|
+
loader: ${loadLoader},
|
|
1652
1793
|
},
|
|
1653
1794
|
({ resource }) => ({
|
|
1654
1795
|
hasWelcome: craftComputed('hasWelcome', () => resource.hasValue()),
|
|
@@ -1664,12 +1805,10 @@ export const HomePage = craftComponent(
|
|
|
1664
1805
|
ifNode(welcomeQuery.hasWelcome, () =>
|
|
1665
1806
|
div([
|
|
1666
1807
|
p(function* () {
|
|
1667
|
-
|
|
1668
|
-
return 'API title: ' + (welcome?.title ?? '');
|
|
1808
|
+
return 'API title: ' + String((yield* welcomeQuery.value())?.title);
|
|
1669
1809
|
}),
|
|
1670
1810
|
p(function* () {
|
|
1671
|
-
|
|
1672
|
-
return 'API body: ' + (welcome?.body ?? '');
|
|
1811
|
+
return 'API body: ' + String((yield* welcomeQuery.value())?.body);
|
|
1673
1812
|
}),
|
|
1674
1813
|
]),
|
|
1675
1814
|
),
|
|
@@ -2051,7 +2190,7 @@ function readme(context) {
|
|
|
2051
2190
|
: []),
|
|
2052
2191
|
...(references
|
|
2053
2192
|
? [
|
|
2054
|
-
`-
|
|
2193
|
+
`- cloned CraftTS/EffectTS source references for coding agents under \`${referencePath}/\`; run \`npm run update:references\` to refresh them.`,
|
|
2055
2194
|
]
|
|
2056
2195
|
: []),
|
|
2057
2196
|
'',
|
|
@@ -2105,7 +2244,8 @@ function readme(context) {
|
|
|
2105
2244
|
'## References',
|
|
2106
2245
|
'',
|
|
2107
2246
|
`The \`${referencePath}/manifest.json\` file records the requested refs and resolved SHAs.`,
|
|
2108
|
-
'The
|
|
2247
|
+
'The cloned repositories are read-only context for coding agents; the application always uses the npm dependencies declared in package.json.',
|
|
2248
|
+
'Do not add file: dependencies or TypeScript/Vite aliases to the clones.',
|
|
2109
2249
|
'Run `npm run update:references` after reviewing local changes in a clone.',
|
|
2110
2250
|
]
|
|
2111
2251
|
: []),
|
|
@@ -2131,10 +2271,9 @@ function agentFiles(mode, agent, config) {
|
|
|
2131
2271
|
const baseSkill = config?.i18n.enabled === false
|
|
2132
2272
|
? BASE_AGENT_SKILL.replace(/4\. Keep translations[\s\S]*?5\. Keep visual/, '5. Keep visual')
|
|
2133
2273
|
: BASE_AGENT_SKILL;
|
|
2134
|
-
const skill = `${baseSkill}${effectEnabled ? `\n${EFFECT_AGENT_SKILL}` : ''}`;
|
|
2274
|
+
const skill = `${baseSkill}${effectEnabled ? `\n${EFFECT_AGENT_SKILL}` : ''}${referenceAgentGuidance(config)}`;
|
|
2135
2275
|
if (agent === 'codex') {
|
|
2136
2276
|
return {
|
|
2137
|
-
'AGENTS.md': AGENTS_MD(mode, config),
|
|
2138
2277
|
'.agents/skills/craft-ts-project/SKILL.md': skill,
|
|
2139
2278
|
...(effectEnabled
|
|
2140
2279
|
? { '.agents/skills/craft-ts-effect-v4/SKILL.md': EFFECT_AGENT_SKILL }
|
|
@@ -2179,6 +2318,7 @@ function templates(context) {
|
|
|
2179
2318
|
: []);
|
|
2180
2319
|
const files = {
|
|
2181
2320
|
'package.json': packageJson(context),
|
|
2321
|
+
'AGENTS.md': AGENTS_MD(context.mode, context.config),
|
|
2182
2322
|
'.gitignore': GENERATED_GITIGNORE,
|
|
2183
2323
|
'tsconfig.json': tsconfig(context),
|
|
2184
2324
|
'tsconfig.app.json': tsconfigApp,
|
|
@@ -2214,7 +2354,7 @@ function templates(context) {
|
|
|
2214
2354
|
}
|
|
2215
2355
|
: {}),
|
|
2216
2356
|
...localeFiles,
|
|
2217
|
-
...(
|
|
2357
|
+
...(effect && hasI18n
|
|
2218
2358
|
? {
|
|
2219
2359
|
'src/i18n/effect.ts': effectI18nTs,
|
|
2220
2360
|
...(effect ? { 'src/i18n/effect-layer.ts': effectLayerTs } : {}),
|
|
@@ -2382,17 +2522,14 @@ export function normalizeCreateOptions(options) {
|
|
|
2382
2522
|
throw new Error(`Unknown references selection "${references}".`);
|
|
2383
2523
|
}
|
|
2384
2524
|
if (options.referenceMode !== undefined &&
|
|
2385
|
-
|
|
2386
|
-
throw new Error(`
|
|
2525
|
+
options.referenceMode !== 'context') {
|
|
2526
|
+
throw new Error(`Reference mode "${options.referenceMode}" is no longer supported; cloned references are context only and npm packages remain the runtime dependencies.`);
|
|
2387
2527
|
}
|
|
2388
2528
|
const craftTs = options.cloneCraftTs ?? (references === 'craft-ts' || references === 'all');
|
|
2389
2529
|
const effectTs = options.cloneEffectTs ?? references === 'all';
|
|
2390
2530
|
if (effectTs && frontendRuntime !== 'effect' && backendRuntime !== 'effect') {
|
|
2391
2531
|
throw new Error('EffectTS references require an Effect frontend or backend runtime.');
|
|
2392
2532
|
}
|
|
2393
|
-
if (options.referenceMode === 'local' && !craftTs && !effectTs) {
|
|
2394
|
-
throw new Error('--reference-mode=local requires at least one cloned reference.');
|
|
2395
|
-
}
|
|
2396
2533
|
const directory = resolve(rootDir, options.directory);
|
|
2397
2534
|
const workspaceKind = options.workspace ??
|
|
2398
2535
|
(existsSync(join(rootDir, 'nx.json')) ? 'nx' : 'standalone');
|
|
@@ -2413,7 +2550,7 @@ export function normalizeCreateOptions(options) {
|
|
|
2413
2550
|
references: {
|
|
2414
2551
|
craftTs,
|
|
2415
2552
|
effectTs,
|
|
2416
|
-
mode:
|
|
2553
|
+
mode: 'context',
|
|
2417
2554
|
craftTsRef: options.craftTsRef ?? 'main',
|
|
2418
2555
|
effectTsRef: options.effectTsRef ?? 'main',
|
|
2419
2556
|
},
|