@webstir-io/webstir-backend 0.1.15
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 +427 -0
- package/dist/build/artifacts.d.ts +113 -0
- package/dist/build/artifacts.js +53 -0
- package/dist/build/entries.d.ts +1 -0
- package/dist/build/entries.js +17 -0
- package/dist/build/pipeline.d.ts +31 -0
- package/dist/build/pipeline.js +424 -0
- package/dist/cache/diff.d.ts +4 -0
- package/dist/cache/diff.js +114 -0
- package/dist/cache/reporters.d.ts +12 -0
- package/dist/cache/reporters.js +23 -0
- package/dist/diagnostics/summary.d.ts +6 -0
- package/dist/diagnostics/summary.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest/pipeline.d.ts +13 -0
- package/dist/manifest/pipeline.js +224 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +101 -0
- package/dist/scaffold/assets.d.ts +2 -0
- package/dist/scaffold/assets.js +77 -0
- package/dist/testing/context.d.ts +3 -0
- package/dist/testing/context.js +14 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +208 -0
- package/dist/testing/types.d.ts +28 -0
- package/dist/testing/types.js +1 -0
- package/dist/watch.d.ts +8 -0
- package/dist/watch.js +159 -0
- package/dist/workspace.d.ts +4 -0
- package/dist/workspace.js +15 -0
- package/package.json +74 -0
- package/scripts/publish.sh +99 -0
- package/scripts/smoke.mjs +241 -0
- package/scripts/update-contract.sh +122 -0
- package/src/build/artifacts.ts +67 -0
- package/src/build/entries.ts +19 -0
- package/src/build/pipeline.ts +507 -0
- package/src/cache/diff.ts +128 -0
- package/src/cache/reporters.ts +41 -0
- package/src/diagnostics/summary.ts +32 -0
- package/src/index.ts +2 -0
- package/src/manifest/pipeline.ts +270 -0
- package/src/provider.ts +124 -0
- package/src/scaffold/assets.ts +81 -0
- package/src/testing/context.d.ts +3 -0
- package/src/testing/context.js +14 -0
- package/src/testing/context.ts +17 -0
- package/src/testing/index.d.ts +6 -0
- package/src/testing/index.js +208 -0
- package/src/testing/index.ts +252 -0
- package/src/testing/types.d.ts +28 -0
- package/src/testing/types.js +1 -0
- package/src/testing/types.ts +32 -0
- package/src/watch.ts +177 -0
- package/src/workspace.ts +22 -0
- package/templates/backend/.env.example +13 -0
- package/templates/backend/auth/adapter.ts +160 -0
- package/templates/backend/db/connection.ts +99 -0
- package/templates/backend/db/migrate.ts +231 -0
- package/templates/backend/db/migrations/0001-example.ts +17 -0
- package/templates/backend/db/types.d.ts +2 -0
- package/templates/backend/env.ts +174 -0
- package/templates/backend/functions/hello/index.ts +29 -0
- package/templates/backend/index.ts +532 -0
- package/templates/backend/jobs/nightly/index.ts +28 -0
- package/templates/backend/jobs/runtime.ts +103 -0
- package/templates/backend/jobs/scheduler.ts +193 -0
- package/templates/backend/module.ts +87 -0
- package/templates/backend/observability/logger.ts +24 -0
- package/templates/backend/observability/metrics.ts +78 -0
- package/templates/backend/server/fastify.ts +288 -0
- package/templates/backend/tsconfig.json +19 -0
- package/tests/cacheReporter.test.js +89 -0
- package/tests/envLoader.test.js +64 -0
- package/tests/integration.test.js +108 -0
- package/tests/manifest.test.js +159 -0
- package/tests/watch.test.js +100 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { moduleManifestSchema, CONTRACT_VERSION } from '@webstir-io/module-contract';
|
|
6
|
+
export async function loadBackendModuleManifest(options) {
|
|
7
|
+
const { workspaceRoot, buildRoot, entryPoints, diagnostics } = options;
|
|
8
|
+
const pkgPath = path.join(workspaceRoot, 'package.json');
|
|
9
|
+
let workspacePackage;
|
|
10
|
+
try {
|
|
11
|
+
const raw = await readFile(pkgPath, 'utf8');
|
|
12
|
+
workspacePackage = JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
const err = error;
|
|
16
|
+
// Missing package.json is expected in some temporary workspaces; avoid noisy warnings.
|
|
17
|
+
if (err.code !== 'ENOENT') {
|
|
18
|
+
diagnostics.push({
|
|
19
|
+
severity: 'warn',
|
|
20
|
+
message: `[webstir-backend] unable to read ${pkgPath}: ${err.message}. Using defaults.`
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const moduleConfig = workspacePackage?.webstir?.moduleManifest ?? {};
|
|
25
|
+
let manifestCandidate = {
|
|
26
|
+
contractVersion: typeof moduleConfig.contractVersion === 'string' ? moduleConfig.contractVersion : CONTRACT_VERSION,
|
|
27
|
+
name: typeof moduleConfig.name === 'string' ? moduleConfig.name : deriveModuleName(workspacePackage, workspaceRoot),
|
|
28
|
+
version: typeof moduleConfig.version === 'string' ? moduleConfig.version : deriveModuleVersion(workspacePackage),
|
|
29
|
+
kind: 'backend',
|
|
30
|
+
capabilities: Array.isArray(moduleConfig.capabilities) ? moduleConfig.capabilities : [],
|
|
31
|
+
routes: moduleConfig.routes ?? [],
|
|
32
|
+
views: moduleConfig.views ?? [],
|
|
33
|
+
jobs: moduleConfig.jobs ?? [],
|
|
34
|
+
events: moduleConfig.events ?? [],
|
|
35
|
+
services: moduleConfig.services ?? [],
|
|
36
|
+
init: moduleConfig.init,
|
|
37
|
+
dispose: moduleConfig.dispose
|
|
38
|
+
};
|
|
39
|
+
const definition = await loadModuleDefinition(buildRoot, diagnostics);
|
|
40
|
+
if (definition) {
|
|
41
|
+
const definitionManifest = definition.manifest ?? {};
|
|
42
|
+
const routesFromDefinition = definition.routes?.map((route) => route.definition);
|
|
43
|
+
const viewsFromDefinition = definition.views?.map((view) => view.definition);
|
|
44
|
+
const mergedCapabilities = Array.from(new Set([...(manifestCandidate.capabilities ?? []), ...(definitionManifest.capabilities ?? [])]));
|
|
45
|
+
manifestCandidate = {
|
|
46
|
+
...manifestCandidate,
|
|
47
|
+
...definitionManifest,
|
|
48
|
+
capabilities: mergedCapabilities,
|
|
49
|
+
routes: routesFromDefinition ?? definitionManifest.routes ?? manifestCandidate.routes ?? [],
|
|
50
|
+
views: viewsFromDefinition ?? definitionManifest.views ?? manifestCandidate.views ?? [],
|
|
51
|
+
jobs: definitionManifest.jobs ?? manifestCandidate.jobs ?? [],
|
|
52
|
+
events: definitionManifest.events ?? manifestCandidate.events ?? [],
|
|
53
|
+
services: definitionManifest.services ?? manifestCandidate.services ?? [],
|
|
54
|
+
init: definitionManifest.init ?? manifestCandidate.init,
|
|
55
|
+
dispose: definitionManifest.dispose ?? manifestCandidate.dispose
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const validation = moduleManifestSchema.safeParse(manifestCandidate);
|
|
59
|
+
if (!validation.success) {
|
|
60
|
+
const problems = validation.error.issues
|
|
61
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
62
|
+
.join('; ');
|
|
63
|
+
diagnostics.push({
|
|
64
|
+
severity: 'error',
|
|
65
|
+
message: `[webstir-backend] module manifest validation failed (${problems}). Falling back to defaults.`
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
contractVersion: CONTRACT_VERSION,
|
|
69
|
+
name: deriveModuleName(workspacePackage, workspaceRoot),
|
|
70
|
+
version: deriveModuleVersion(workspacePackage),
|
|
71
|
+
kind: 'backend',
|
|
72
|
+
capabilities: [],
|
|
73
|
+
routes: [],
|
|
74
|
+
views: [],
|
|
75
|
+
jobs: [],
|
|
76
|
+
events: [],
|
|
77
|
+
services: []
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const manifest = validation.data;
|
|
81
|
+
try {
|
|
82
|
+
const normalizePath = (p) => {
|
|
83
|
+
let s = typeof p === 'string' ? p : '';
|
|
84
|
+
if (!s.startsWith('/'))
|
|
85
|
+
s = '/' + s;
|
|
86
|
+
s = s.replace(/\/+/, '/');
|
|
87
|
+
if (s.length > 1 && s.endsWith('/'))
|
|
88
|
+
s = s.slice(0, -1);
|
|
89
|
+
return s;
|
|
90
|
+
};
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
for (const r of manifest.routes ?? []) {
|
|
93
|
+
const method = typeof r.method === 'string' ? r.method.toUpperCase() : '';
|
|
94
|
+
const pathKey = normalizePath(r.path);
|
|
95
|
+
const key = `${method} ${pathKey}`;
|
|
96
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
const dups = Array.from(seen.entries()).filter(([, count]) => count > 1);
|
|
99
|
+
if (dups.length > 0) {
|
|
100
|
+
const list = dups.map(([k, c]) => `${k} (${c}x)`).join(', ');
|
|
101
|
+
diagnostics.push({ severity: 'warn', message: `[webstir-backend] duplicate route definitions: ${list}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// best-effort only
|
|
106
|
+
}
|
|
107
|
+
if (manifest.routes?.length && entryPoints.length === 0) {
|
|
108
|
+
diagnostics.push({
|
|
109
|
+
severity: 'warn',
|
|
110
|
+
message: '[webstir-backend] module manifest defines routes but no entry points were built. Ensure backend compilation produced handlers.'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const jobs = Array.isArray(manifest.jobs) ? manifest.jobs : [];
|
|
115
|
+
const events = Array.isArray(manifest.events) ? manifest.events : [];
|
|
116
|
+
const services = Array.isArray(manifest.services) ? manifest.services : [];
|
|
117
|
+
if (jobs.length + events.length + services.length > 0) {
|
|
118
|
+
diagnostics.push({
|
|
119
|
+
severity: 'info',
|
|
120
|
+
message: `[webstir-backend] manifest jobs=${jobs.length} events=${events.length} services=${services.length}`
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const noSchedule = jobs.filter((j) => j && typeof j.name === 'string' && (j.schedule === undefined || j.schedule === null));
|
|
124
|
+
if (noSchedule.length > 0) {
|
|
125
|
+
const MAX_LIST = 10;
|
|
126
|
+
const names = noSchedule.map((j) => j.name).slice(0, MAX_LIST).join(', ');
|
|
127
|
+
const omitted = noSchedule.length > MAX_LIST ? ` (+${noSchedule.length - MAX_LIST} more)` : '';
|
|
128
|
+
diagnostics.push({
|
|
129
|
+
severity: 'warn',
|
|
130
|
+
message: `[webstir-backend] jobs without schedules: ${names}${omitted}`
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// best-effort only
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const routes = Array.isArray(manifest.routes) ? manifest.routes.length : 0;
|
|
139
|
+
const views = Array.isArray(manifest.views) ? manifest.views.length : 0;
|
|
140
|
+
const caps = Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0 ? ` [${manifest.capabilities.join(', ')}]` : '';
|
|
141
|
+
diagnostics.push({ severity: 'info', message: `[webstir-backend] manifest routes=${routes} views=${views}${caps}` });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
return manifest;
|
|
147
|
+
}
|
|
148
|
+
async function loadModuleDefinition(buildRoot, diagnostics) {
|
|
149
|
+
const candidates = [
|
|
150
|
+
path.join(buildRoot, 'module.js'),
|
|
151
|
+
path.join(buildRoot, 'module.mjs'),
|
|
152
|
+
path.join(buildRoot, 'module/index.js'),
|
|
153
|
+
path.join(buildRoot, 'module/index.mjs')
|
|
154
|
+
];
|
|
155
|
+
for (const fullPath of candidates) {
|
|
156
|
+
if (!existsSync(fullPath)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const moduleUrl = `${pathToFileURL(fullPath).href}?t=${Date.now()}`;
|
|
161
|
+
const imported = (await import(moduleUrl));
|
|
162
|
+
const definitionCandidate = extractModuleDefinition(imported);
|
|
163
|
+
if (isModuleDefinition(definitionCandidate)) {
|
|
164
|
+
return definitionCandidate;
|
|
165
|
+
}
|
|
166
|
+
diagnostics.push({
|
|
167
|
+
severity: 'warn',
|
|
168
|
+
message: `[webstir-backend] module definition at ${fullPath} does not export a createModule() definition.`
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
diagnostics.push({
|
|
173
|
+
severity: 'warn',
|
|
174
|
+
message: `[webstir-backend] failed to load module definition from ${fullPath}: ${error.message}`
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
function extractModuleDefinition(exports) {
|
|
181
|
+
const keys = ['module', 'moduleDefinition', 'default', 'backendModule'];
|
|
182
|
+
for (const key of keys) {
|
|
183
|
+
if (key in exports) {
|
|
184
|
+
const value = exports[key];
|
|
185
|
+
if (value !== null && value !== undefined) {
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
function isModuleDefinition(value) {
|
|
193
|
+
return typeof value === 'object' && value !== null && 'manifest' in value;
|
|
194
|
+
}
|
|
195
|
+
function deriveModuleName(pkg, workspaceRoot) {
|
|
196
|
+
if (typeof pkg?.webstir?.moduleManifest?.name === 'string' && pkg.webstir.moduleManifest.name.length > 0) {
|
|
197
|
+
return pkg.webstir.moduleManifest.name;
|
|
198
|
+
}
|
|
199
|
+
if (typeof pkg?.name === 'string' && pkg.name.length > 0) {
|
|
200
|
+
return pkg.name;
|
|
201
|
+
}
|
|
202
|
+
return `backend-module-${path.basename(workspaceRoot)}`;
|
|
203
|
+
}
|
|
204
|
+
function deriveModuleVersion(pkg) {
|
|
205
|
+
if (typeof pkg?.webstir?.moduleManifest?.version === 'string' && pkg.webstir.moduleManifest.version.length > 0) {
|
|
206
|
+
return pkg.webstir.moduleManifest.version;
|
|
207
|
+
}
|
|
208
|
+
if (typeof pkg?.version === 'string' && pkg.version.length > 0) {
|
|
209
|
+
return pkg.version;
|
|
210
|
+
}
|
|
211
|
+
return '0.0.0';
|
|
212
|
+
}
|
|
213
|
+
export async function summarizeBuiltManifest(buildRoot) {
|
|
214
|
+
const definition = await loadModuleDefinition(buildRoot, []);
|
|
215
|
+
if (!definition || !definition.manifest) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
const manifest = definition.manifest;
|
|
219
|
+
return {
|
|
220
|
+
routes: Array.isArray(manifest.routes) ? manifest.routes.length : 0,
|
|
221
|
+
views: Array.isArray(manifest.views) ? manifest.views.length : 0,
|
|
222
|
+
capabilities: manifest.capabilities
|
|
223
|
+
};
|
|
224
|
+
}
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { collectArtifacts, createBuildManifest } from './build/artifacts.js';
|
|
4
|
+
import { buildSupportFile, runBackendBuildPipeline } from './build/pipeline.js';
|
|
5
|
+
import { loadBackendModuleManifest } from './manifest/pipeline.js';
|
|
6
|
+
import { createCacheReporter } from './cache/reporters.js';
|
|
7
|
+
import { normalizeLogLevel, filterDiagnostics } from './diagnostics/summary.js';
|
|
8
|
+
import { getBackendScaffoldAssets } from './scaffold/assets.js';
|
|
9
|
+
import { normalizeMode, resolveWorkspacePaths } from './workspace.js';
|
|
10
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
11
|
+
const pkg = packageJson;
|
|
12
|
+
export const backendProvider = {
|
|
13
|
+
metadata: {
|
|
14
|
+
id: pkg.name ?? '@webstir-io/webstir-backend',
|
|
15
|
+
kind: 'backend',
|
|
16
|
+
version: pkg.version ?? '0.0.0',
|
|
17
|
+
compatibility: {
|
|
18
|
+
minCliVersion: '0.1.0',
|
|
19
|
+
nodeRange: pkg.engines?.node ?? '>=20.18.1'
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
resolveWorkspace(options) {
|
|
23
|
+
return resolveWorkspacePaths(options.workspaceRoot);
|
|
24
|
+
},
|
|
25
|
+
async build(options) {
|
|
26
|
+
const paths = resolveWorkspacePaths(options.workspaceRoot);
|
|
27
|
+
const tsconfigPath = path.join(paths.sourceRoot, 'tsconfig.json');
|
|
28
|
+
const diagnostics = [];
|
|
29
|
+
const incremental = options.incremental === true;
|
|
30
|
+
const env = options.env ?? {};
|
|
31
|
+
const mode = normalizeMode(env.WEBSTIR_MODULE_MODE);
|
|
32
|
+
console.info(`[webstir-backend] ${mode}:start`);
|
|
33
|
+
const { entryPoints, outputs, includePublishSourcemaps } = await runBackendBuildPipeline({
|
|
34
|
+
sourceRoot: paths.sourceRoot,
|
|
35
|
+
buildRoot: paths.buildRoot,
|
|
36
|
+
tsconfigPath,
|
|
37
|
+
mode,
|
|
38
|
+
env,
|
|
39
|
+
incremental,
|
|
40
|
+
diagnostics
|
|
41
|
+
});
|
|
42
|
+
const artifacts = await collectArtifacts(paths.buildRoot, includePublishSourcemaps);
|
|
43
|
+
const envSource = path.join(paths.sourceRoot, 'env.ts');
|
|
44
|
+
if (existsSync(envSource)) {
|
|
45
|
+
try {
|
|
46
|
+
await buildSupportFile({
|
|
47
|
+
sourceFile: envSource,
|
|
48
|
+
sourceRoot: paths.sourceRoot,
|
|
49
|
+
buildRoot: paths.buildRoot,
|
|
50
|
+
tsconfigPath,
|
|
51
|
+
mode,
|
|
52
|
+
env,
|
|
53
|
+
diagnostics
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// env compilation errors are already captured in diagnostics
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const moduleManifest = await loadBackendModuleManifest({
|
|
61
|
+
workspaceRoot: options.workspaceRoot,
|
|
62
|
+
buildRoot: paths.buildRoot,
|
|
63
|
+
entryPoints,
|
|
64
|
+
diagnostics
|
|
65
|
+
});
|
|
66
|
+
const manifest = createBuildManifest(paths.buildRoot, artifacts, diagnostics, moduleManifest);
|
|
67
|
+
console.info(`[webstir-backend] ${mode}:complete (entries=${manifest.entryPoints.length})`);
|
|
68
|
+
diagnostics.push({ severity: 'info', message: `[webstir-backend] ${mode}:built entries=${manifest.entryPoints.length}` });
|
|
69
|
+
const cacheReporter = createCacheReporter({
|
|
70
|
+
workspaceRoot: options.workspaceRoot,
|
|
71
|
+
buildRoot: paths.buildRoot,
|
|
72
|
+
env,
|
|
73
|
+
diagnostics
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
await cacheReporter.diffOutputs(outputs, mode);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// ignore cache errors
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await cacheReporter.diffManifest(moduleManifest);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// ignore cache errors
|
|
86
|
+
}
|
|
87
|
+
// Optionally filter diagnostics by severity for orchestrator consumption
|
|
88
|
+
const minLevel = normalizeLogLevel(env.WEBSTIR_BACKEND_LOG_LEVEL);
|
|
89
|
+
const filteredDiagnostics = filterDiagnostics(manifest.diagnostics, minLevel);
|
|
90
|
+
return {
|
|
91
|
+
artifacts,
|
|
92
|
+
manifest: {
|
|
93
|
+
...manifest,
|
|
94
|
+
diagnostics: filteredDiagnostics
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async getScaffoldAssets() {
|
|
99
|
+
return await getBackendScaffoldAssets();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
export async function getBackendScaffoldAssets() {
|
|
4
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const packageRoot = path.resolve(here, '..', '..');
|
|
6
|
+
const templatesRoot = path.join(packageRoot, 'templates', 'backend');
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
sourcePath: path.join(templatesRoot, 'tsconfig.json'),
|
|
10
|
+
targetPath: path.join('src', 'backend', 'tsconfig.json')
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
sourcePath: path.join(templatesRoot, 'index.ts'),
|
|
14
|
+
targetPath: path.join('src', 'backend', 'index.ts')
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
sourcePath: path.join(templatesRoot, 'server', 'fastify.ts'),
|
|
18
|
+
targetPath: path.join('src', 'backend', 'server', 'fastify.ts')
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
sourcePath: path.join(templatesRoot, 'module.ts'),
|
|
22
|
+
targetPath: path.join('src', 'backend', 'module.ts')
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
sourcePath: path.join(templatesRoot, 'auth', 'adapter.ts'),
|
|
26
|
+
targetPath: path.join('src', 'backend', 'auth', 'adapter.ts')
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
sourcePath: path.join(templatesRoot, 'observability', 'logger.ts'),
|
|
30
|
+
targetPath: path.join('src', 'backend', 'observability', 'logger.ts')
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
sourcePath: path.join(templatesRoot, 'observability', 'metrics.ts'),
|
|
34
|
+
targetPath: path.join('src', 'backend', 'observability', 'metrics.ts')
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
sourcePath: path.join(templatesRoot, 'env.ts'),
|
|
38
|
+
targetPath: path.join('src', 'backend', 'env.ts')
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
sourcePath: path.join(templatesRoot, 'functions', 'hello', 'index.ts'),
|
|
42
|
+
targetPath: path.join('src', 'backend', 'functions', 'hello', 'index.ts')
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
sourcePath: path.join(templatesRoot, 'jobs', 'nightly', 'index.ts'),
|
|
46
|
+
targetPath: path.join('src', 'backend', 'jobs', 'nightly', 'index.ts')
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
sourcePath: path.join(templatesRoot, 'jobs', 'runtime.ts'),
|
|
50
|
+
targetPath: path.join('src', 'backend', 'jobs', 'runtime.ts')
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
sourcePath: path.join(templatesRoot, 'jobs', 'scheduler.ts'),
|
|
54
|
+
targetPath: path.join('src', 'backend', 'jobs', 'scheduler.ts')
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
sourcePath: path.join(templatesRoot, 'db', 'connection.ts'),
|
|
58
|
+
targetPath: path.join('src', 'backend', 'db', 'connection.ts')
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
sourcePath: path.join(templatesRoot, 'db', 'migrate.ts'),
|
|
62
|
+
targetPath: path.join('src', 'backend', 'db', 'migrate.ts')
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
sourcePath: path.join(templatesRoot, 'db', 'migrations', '0001-example.ts'),
|
|
66
|
+
targetPath: path.join('src', 'backend', 'db', 'migrations', '0001-example.ts')
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
sourcePath: path.join(templatesRoot, 'db', 'types.d.ts'),
|
|
70
|
+
targetPath: path.join('src', 'backend', 'db', 'types.d.ts')
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
sourcePath: path.join(templatesRoot, '.env.example'),
|
|
74
|
+
targetPath: path.join('.env.example')
|
|
75
|
+
}
|
|
76
|
+
];
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const GLOBAL_KEY = '__webstirBackendTestContext__';
|
|
2
|
+
export function setBackendTestContext(context) {
|
|
3
|
+
const target = globalThis;
|
|
4
|
+
if (context) {
|
|
5
|
+
target[GLOBAL_KEY] = context;
|
|
6
|
+
}
|
|
7
|
+
else {
|
|
8
|
+
delete target[GLOBAL_KEY];
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function getBackendTestContext() {
|
|
12
|
+
const target = globalThis;
|
|
13
|
+
return (target[GLOBAL_KEY] ?? null);
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { getBackendTestContext, setBackendTestContext } from './context.js';
|
|
2
|
+
import type { BackendTestCallback, BackendTestHarness, BackendTestHarnessOptions } from './types.js';
|
|
3
|
+
export type { BackendTestCallback, BackendTestContext, BackendTestHarness, BackendTestHarnessOptions } from './types.js';
|
|
4
|
+
export { getBackendTestContext, setBackendTestContext };
|
|
5
|
+
export declare function createBackendTestHarness(options?: BackendTestHarnessOptions): Promise<BackendTestHarness>;
|
|
6
|
+
export declare function backendTest(name: string, callback: BackendTestCallback): void;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { getBackendTestContext, setBackendTestContext } from './context.js';
|
|
8
|
+
const DEFAULT_PORT = 4100;
|
|
9
|
+
const DEFAULT_READY_TEXT = 'API server running';
|
|
10
|
+
const DEFAULT_READY_TIMEOUT_MS = 15_000;
|
|
11
|
+
export { getBackendTestContext, setBackendTestContext };
|
|
12
|
+
export async function createBackendTestHarness(options = {}) {
|
|
13
|
+
const workspaceRoot = options.workspaceRoot ?? process.env.WEBSTIR_WORKSPACE_ROOT ?? process.cwd();
|
|
14
|
+
const buildRoot = options.buildRoot ?? process.env.WEBSTIR_BACKEND_BUILD_ROOT ?? path.join(workspaceRoot, 'build', 'backend');
|
|
15
|
+
const entry = options.entry ?? process.env.WEBSTIR_BACKEND_TEST_ENTRY ?? path.join(buildRoot, 'index.js');
|
|
16
|
+
const manifestPath = options.manifestPath ??
|
|
17
|
+
process.env.WEBSTIR_BACKEND_TEST_MANIFEST ??
|
|
18
|
+
path.join(workspaceRoot, '.webstir', 'backend-manifest.json');
|
|
19
|
+
const readyText = options.readyText ?? process.env.WEBSTIR_BACKEND_TEST_READY ?? DEFAULT_READY_TEXT;
|
|
20
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? readInt(process.env.WEBSTIR_BACKEND_TEST_READY_TIMEOUT, DEFAULT_READY_TIMEOUT_MS);
|
|
21
|
+
if (!existsSync(entry)) {
|
|
22
|
+
throw new Error(`Backend test entry not found at ${entry}. Run the backend build before executing backend tests.`);
|
|
23
|
+
}
|
|
24
|
+
const requestedPort = options.port ?? readInt(process.env.WEBSTIR_BACKEND_TEST_PORT, DEFAULT_PORT);
|
|
25
|
+
const port = await findOpenPort(requestedPort);
|
|
26
|
+
const env = createRuntimeEnv({
|
|
27
|
+
workspaceRoot,
|
|
28
|
+
port,
|
|
29
|
+
overrides: options.env
|
|
30
|
+
});
|
|
31
|
+
const manifest = await loadManifest(manifestPath);
|
|
32
|
+
const child = spawn(process.execPath, [entry], {
|
|
33
|
+
cwd: workspaceRoot,
|
|
34
|
+
env,
|
|
35
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
await waitForReady(child, readyText, readyTimeoutMs);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
await stopProcess(child);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
const baseUrl = new URL(env.API_BASE_URL ?? `http://127.0.0.1:${port}`);
|
|
45
|
+
const context = {
|
|
46
|
+
baseUrl: baseUrl.toString(),
|
|
47
|
+
url: baseUrl,
|
|
48
|
+
port,
|
|
49
|
+
manifest,
|
|
50
|
+
routes: Array.isArray(manifest?.routes) ? manifest.routes : [],
|
|
51
|
+
env,
|
|
52
|
+
request: async (pathOrUrl = '/', init) => {
|
|
53
|
+
const target = toUrl(baseUrl, pathOrUrl);
|
|
54
|
+
return await fetch(target, init);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
context,
|
|
59
|
+
async stop() {
|
|
60
|
+
await stopProcess(child);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function backendTest(name, callback) {
|
|
65
|
+
const globalTest = globalThis.test;
|
|
66
|
+
if (typeof globalTest !== 'function') {
|
|
67
|
+
throw new Error('backendTest() requires the @webstir-io/webstir-testing runtime.');
|
|
68
|
+
}
|
|
69
|
+
globalTest(name, async () => {
|
|
70
|
+
const context = getBackendTestContext();
|
|
71
|
+
if (!context) {
|
|
72
|
+
throw new Error('Backend test context not available. Ensure backend tests run via the Webstir CLI (`webstir test`).');
|
|
73
|
+
}
|
|
74
|
+
await callback(context);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function toUrl(base, pathOrUrl) {
|
|
78
|
+
if (pathOrUrl instanceof URL) {
|
|
79
|
+
return pathOrUrl.toString();
|
|
80
|
+
}
|
|
81
|
+
if (/^https?:/i.test(pathOrUrl)) {
|
|
82
|
+
return pathOrUrl;
|
|
83
|
+
}
|
|
84
|
+
return new URL(pathOrUrl, base).toString();
|
|
85
|
+
}
|
|
86
|
+
function readInt(value, fallback) {
|
|
87
|
+
if (!value)
|
|
88
|
+
return fallback;
|
|
89
|
+
const parsed = Number.parseInt(value, 10);
|
|
90
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
91
|
+
}
|
|
92
|
+
async function findOpenPort(start, attempts = 10) {
|
|
93
|
+
let candidate = start;
|
|
94
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
95
|
+
// eslint-disable-next-line no-await-in-loop
|
|
96
|
+
if (await isPortAvailable(candidate)) {
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
candidate += 1;
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`Unable to find an open port for backend tests (tried starting at ${start}).`);
|
|
102
|
+
}
|
|
103
|
+
function isPortAvailable(port) {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
const server = net.createServer();
|
|
106
|
+
server.once('error', () => {
|
|
107
|
+
server.close(() => resolve(false));
|
|
108
|
+
});
|
|
109
|
+
server.once('listening', () => {
|
|
110
|
+
server.close(() => resolve(true));
|
|
111
|
+
});
|
|
112
|
+
server.listen(port, '127.0.0.1');
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function createRuntimeEnv(options) {
|
|
116
|
+
const overrides = {};
|
|
117
|
+
for (const [key, value] of Object.entries(options.overrides ?? {})) {
|
|
118
|
+
if (value !== undefined) {
|
|
119
|
+
overrides[key] = value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const baseUrl = overrides.API_BASE_URL ?? process.env.API_BASE_URL ?? `http://127.0.0.1:${options.port}`;
|
|
123
|
+
return {
|
|
124
|
+
...process.env,
|
|
125
|
+
...overrides,
|
|
126
|
+
PORT: String(options.port),
|
|
127
|
+
API_BASE_URL: baseUrl,
|
|
128
|
+
NODE_ENV: overrides.NODE_ENV ?? process.env.NODE_ENV ?? 'test',
|
|
129
|
+
WORKSPACE_ROOT: options.workspaceRoot,
|
|
130
|
+
WEBSTIR_BACKEND_TEST_RUN: '1'
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function loadManifest(manifestPath) {
|
|
134
|
+
try {
|
|
135
|
+
const raw = await readFile(manifestPath, 'utf8');
|
|
136
|
+
return JSON.parse(raw);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function emitModuleEvent(level, message) {
|
|
143
|
+
const payload = JSON.stringify({ type: level, message });
|
|
144
|
+
process.stdout.write(`WEBSTIR_MODULE_EVENT ${payload}\n`);
|
|
145
|
+
}
|
|
146
|
+
async function waitForReady(child, readyText, timeoutMs) {
|
|
147
|
+
const normalized = readyText
|
|
148
|
+
.split('|')
|
|
149
|
+
.map((token) => token.trim())
|
|
150
|
+
.filter(Boolean);
|
|
151
|
+
const readinessMatches = (line) => (normalized.length === 0 ? line.length > 0 : normalized.some((token) => line.includes(token)));
|
|
152
|
+
await new Promise((resolve, reject) => {
|
|
153
|
+
const cleanup = () => {
|
|
154
|
+
child.stdout?.off('data', onStdout);
|
|
155
|
+
child.stderr?.off('data', onStderr);
|
|
156
|
+
child.off('exit', onExit);
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
};
|
|
159
|
+
const onStdout = (chunk) => {
|
|
160
|
+
const text = chunk.toString();
|
|
161
|
+
for (const line of text.split(/\r?\n/)) {
|
|
162
|
+
if (!line)
|
|
163
|
+
continue;
|
|
164
|
+
emitModuleEvent('info', line);
|
|
165
|
+
if (readinessMatches(line)) {
|
|
166
|
+
cleanup();
|
|
167
|
+
resolve();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const onStderr = (chunk) => {
|
|
172
|
+
const text = chunk.toString();
|
|
173
|
+
for (const line of text.split(/\r?\n/)) {
|
|
174
|
+
if (!line)
|
|
175
|
+
continue;
|
|
176
|
+
emitModuleEvent('error', line);
|
|
177
|
+
if (readinessMatches(line)) {
|
|
178
|
+
cleanup();
|
|
179
|
+
resolve();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const onExit = (code) => {
|
|
184
|
+
cleanup();
|
|
185
|
+
reject(new Error(`Backend test server exited before it became ready (code ${code ?? 'null'}).`));
|
|
186
|
+
};
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
cleanup();
|
|
189
|
+
emitModuleEvent('error', 'Backend test server readiness timed out.');
|
|
190
|
+
reject(new Error(`Backend test server did not become ready within ${timeoutMs}ms. Check server logs for details.`));
|
|
191
|
+
}, timeoutMs);
|
|
192
|
+
child.stdout?.on('data', onStdout);
|
|
193
|
+
child.stderr?.on('data', onStderr);
|
|
194
|
+
child.once('exit', onExit);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async function stopProcess(child) {
|
|
198
|
+
if (!child || child.killed || child.exitCode !== null) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
child.kill('SIGTERM');
|
|
202
|
+
try {
|
|
203
|
+
await once(child, 'exit');
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// ignore
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ModuleManifest } from '@webstir-io/module-contract';
|
|
2
|
+
type ModuleRoute = NonNullable<ModuleManifest['routes']>[number];
|
|
3
|
+
export interface BackendTestContext {
|
|
4
|
+
readonly baseUrl: string;
|
|
5
|
+
readonly url: URL;
|
|
6
|
+
readonly port: number;
|
|
7
|
+
readonly manifest: ModuleManifest | null;
|
|
8
|
+
readonly routes: readonly ModuleRoute[];
|
|
9
|
+
readonly env: Readonly<Record<string, string>>;
|
|
10
|
+
request(pathOrUrl?: string | URL, init?: RequestInit): Promise<Response>;
|
|
11
|
+
}
|
|
12
|
+
export interface BackendTestHarness {
|
|
13
|
+
readonly context: BackendTestContext;
|
|
14
|
+
stop(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface BackendTestHarnessOptions {
|
|
17
|
+
workspaceRoot?: string;
|
|
18
|
+
buildRoot?: string;
|
|
19
|
+
entry?: string;
|
|
20
|
+
manifestPath?: string;
|
|
21
|
+
env?: Record<string, string | undefined>;
|
|
22
|
+
port?: number;
|
|
23
|
+
readyText?: string;
|
|
24
|
+
readyTimeoutMs?: number;
|
|
25
|
+
reuseExistingServer?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export type BackendTestCallback = (context: BackendTestContext) => Promise<void> | void;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|