@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,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,252 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } 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
|
+
|
|
8
|
+
import type { ModuleManifest } from '@webstir-io/module-contract';
|
|
9
|
+
|
|
10
|
+
import { getBackendTestContext, setBackendTestContext } from './context.js';
|
|
11
|
+
import type {
|
|
12
|
+
BackendTestCallback,
|
|
13
|
+
BackendTestContext,
|
|
14
|
+
BackendTestHarness,
|
|
15
|
+
BackendTestHarnessOptions
|
|
16
|
+
} from './types.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PORT = 4100;
|
|
19
|
+
const DEFAULT_READY_TEXT = 'API server running';
|
|
20
|
+
const DEFAULT_READY_TIMEOUT_MS = 15_000;
|
|
21
|
+
|
|
22
|
+
export type { BackendTestCallback, BackendTestContext, BackendTestHarness, BackendTestHarnessOptions } from './types.js';
|
|
23
|
+
export { getBackendTestContext, setBackendTestContext };
|
|
24
|
+
|
|
25
|
+
export async function createBackendTestHarness(options: BackendTestHarnessOptions = {}): Promise<BackendTestHarness> {
|
|
26
|
+
const workspaceRoot = options.workspaceRoot ?? process.env.WEBSTIR_WORKSPACE_ROOT ?? process.cwd();
|
|
27
|
+
const buildRoot = options.buildRoot ?? process.env.WEBSTIR_BACKEND_BUILD_ROOT ?? path.join(workspaceRoot, 'build', 'backend');
|
|
28
|
+
const entry = options.entry ?? process.env.WEBSTIR_BACKEND_TEST_ENTRY ?? path.join(buildRoot, 'index.js');
|
|
29
|
+
const manifestPath =
|
|
30
|
+
options.manifestPath ??
|
|
31
|
+
process.env.WEBSTIR_BACKEND_TEST_MANIFEST ??
|
|
32
|
+
path.join(workspaceRoot, '.webstir', 'backend-manifest.json');
|
|
33
|
+
const readyText = options.readyText ?? process.env.WEBSTIR_BACKEND_TEST_READY ?? DEFAULT_READY_TEXT;
|
|
34
|
+
const readyTimeoutMs =
|
|
35
|
+
options.readyTimeoutMs ?? readInt(process.env.WEBSTIR_BACKEND_TEST_READY_TIMEOUT, DEFAULT_READY_TIMEOUT_MS);
|
|
36
|
+
|
|
37
|
+
if (!existsSync(entry)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Backend test entry not found at ${entry}. Run the backend build before executing backend tests.`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const requestedPort = options.port ?? readInt(process.env.WEBSTIR_BACKEND_TEST_PORT, DEFAULT_PORT);
|
|
44
|
+
const port = await findOpenPort(requestedPort);
|
|
45
|
+
const env = createRuntimeEnv({
|
|
46
|
+
workspaceRoot,
|
|
47
|
+
port,
|
|
48
|
+
overrides: options.env
|
|
49
|
+
});
|
|
50
|
+
const manifest = await loadManifest(manifestPath);
|
|
51
|
+
const child = spawn(process.execPath, [entry], {
|
|
52
|
+
cwd: workspaceRoot,
|
|
53
|
+
env,
|
|
54
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await waitForReady(child, readyText, readyTimeoutMs);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
await stopProcess(child);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const baseUrl = new URL(env.API_BASE_URL ?? `http://127.0.0.1:${port}`);
|
|
65
|
+
const context: BackendTestContext = {
|
|
66
|
+
baseUrl: baseUrl.toString(),
|
|
67
|
+
url: baseUrl,
|
|
68
|
+
port,
|
|
69
|
+
manifest,
|
|
70
|
+
routes: Array.isArray(manifest?.routes) ? (manifest.routes as NonNullable<ModuleManifest['routes']>) : [],
|
|
71
|
+
env,
|
|
72
|
+
request: async (pathOrUrl = '/', init) => {
|
|
73
|
+
const target = toUrl(baseUrl, pathOrUrl);
|
|
74
|
+
return await fetch(target, init);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
context,
|
|
80
|
+
async stop() {
|
|
81
|
+
await stopProcess(child);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function backendTest(name: string, callback: BackendTestCallback): void {
|
|
87
|
+
const globalTest = (globalThis as { test?: (id: string, fn: () => Promise<void> | void) => void }).test;
|
|
88
|
+
if (typeof globalTest !== 'function') {
|
|
89
|
+
throw new Error('backendTest() requires the @webstir-io/webstir-testing runtime.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
globalTest(name, async () => {
|
|
93
|
+
const context = getBackendTestContext();
|
|
94
|
+
if (!context) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
'Backend test context not available. Ensure backend tests run via the Webstir CLI (`webstir test`).'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
await callback(context);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toUrl(base: URL, pathOrUrl: string | URL): string {
|
|
104
|
+
if (pathOrUrl instanceof URL) {
|
|
105
|
+
return pathOrUrl.toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (/^https?:/i.test(pathOrUrl)) {
|
|
109
|
+
return pathOrUrl;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return new URL(pathOrUrl, base).toString();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readInt(value: string | undefined, fallback: number): number {
|
|
116
|
+
if (!value) return fallback;
|
|
117
|
+
const parsed = Number.parseInt(value, 10);
|
|
118
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function findOpenPort(start: number, attempts = 10): Promise<number> {
|
|
122
|
+
let candidate = start;
|
|
123
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
124
|
+
// eslint-disable-next-line no-await-in-loop
|
|
125
|
+
if (await isPortAvailable(candidate)) {
|
|
126
|
+
return candidate;
|
|
127
|
+
}
|
|
128
|
+
candidate += 1;
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`Unable to find an open port for backend tests (tried starting at ${start}).`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isPortAvailable(port: number): Promise<boolean> {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const server = net.createServer();
|
|
136
|
+
server.once('error', () => {
|
|
137
|
+
server.close(() => resolve(false));
|
|
138
|
+
});
|
|
139
|
+
server.once('listening', () => {
|
|
140
|
+
server.close(() => resolve(true));
|
|
141
|
+
});
|
|
142
|
+
server.listen(port, '127.0.0.1');
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface RuntimeEnvOptions {
|
|
147
|
+
workspaceRoot: string;
|
|
148
|
+
port: number;
|
|
149
|
+
overrides?: Record<string, string | undefined>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createRuntimeEnv(options: RuntimeEnvOptions): Record<string, string> {
|
|
153
|
+
const overrides: Record<string, string> = {};
|
|
154
|
+
for (const [key, value] of Object.entries(options.overrides ?? {})) {
|
|
155
|
+
if (value !== undefined) {
|
|
156
|
+
overrides[key] = value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const baseUrl = overrides.API_BASE_URL ?? process.env.API_BASE_URL ?? `http://127.0.0.1:${options.port}`;
|
|
161
|
+
return {
|
|
162
|
+
...process.env,
|
|
163
|
+
...overrides,
|
|
164
|
+
PORT: String(options.port),
|
|
165
|
+
API_BASE_URL: baseUrl,
|
|
166
|
+
NODE_ENV: overrides.NODE_ENV ?? process.env.NODE_ENV ?? 'test',
|
|
167
|
+
WORKSPACE_ROOT: options.workspaceRoot,
|
|
168
|
+
WEBSTIR_BACKEND_TEST_RUN: '1'
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function loadManifest(manifestPath: string): Promise<ModuleManifest | null> {
|
|
173
|
+
try {
|
|
174
|
+
const raw = await readFile(manifestPath, 'utf8');
|
|
175
|
+
return JSON.parse(raw) as ModuleManifest;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function emitModuleEvent(level: 'info' | 'warn' | 'error', message: string): void {
|
|
182
|
+
const payload = JSON.stringify({ type: level, message });
|
|
183
|
+
process.stdout.write(`WEBSTIR_MODULE_EVENT ${payload}\n`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function waitForReady(child: ChildProcess, readyText: string, timeoutMs: number): Promise<void> {
|
|
187
|
+
const normalized = readyText
|
|
188
|
+
.split('|')
|
|
189
|
+
.map((token) => token.trim())
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
const readinessMatches = (line: string) => (normalized.length === 0 ? line.length > 0 : normalized.some((token) => line.includes(token)));
|
|
192
|
+
|
|
193
|
+
await new Promise<void>((resolve, reject) => {
|
|
194
|
+
const cleanup = () => {
|
|
195
|
+
child.stdout?.off('data', onStdout);
|
|
196
|
+
child.stderr?.off('data', onStderr);
|
|
197
|
+
child.off('exit', onExit);
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const onStdout = (chunk: Buffer | string) => {
|
|
202
|
+
const text = chunk.toString();
|
|
203
|
+
for (const line of text.split(/\r?\n/)) {
|
|
204
|
+
if (!line) continue;
|
|
205
|
+
emitModuleEvent('info', line);
|
|
206
|
+
if (readinessMatches(line)) {
|
|
207
|
+
cleanup();
|
|
208
|
+
resolve();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const onStderr = (chunk: Buffer | string) => {
|
|
214
|
+
const text = chunk.toString();
|
|
215
|
+
for (const line of text.split(/\r?\n/)) {
|
|
216
|
+
if (!line) continue;
|
|
217
|
+
emitModuleEvent('error', line);
|
|
218
|
+
if (readinessMatches(line)) {
|
|
219
|
+
cleanup();
|
|
220
|
+
resolve();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const onExit = (code: number | null) => {
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new Error(`Backend test server exited before it became ready (code ${code ?? 'null'}).`));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
cleanup();
|
|
232
|
+
emitModuleEvent('error', 'Backend test server readiness timed out.');
|
|
233
|
+
reject(new Error(`Backend test server did not become ready within ${timeoutMs}ms. Check server logs for details.`));
|
|
234
|
+
}, timeoutMs);
|
|
235
|
+
|
|
236
|
+
child.stdout?.on('data', onStdout);
|
|
237
|
+
child.stderr?.on('data', onStderr);
|
|
238
|
+
child.once('exit', onExit);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
243
|
+
if (!child || child.killed || child.exitCode !== null) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
child.kill('SIGTERM');
|
|
247
|
+
try {
|
|
248
|
+
await once(child, 'exit');
|
|
249
|
+
} catch {
|
|
250
|
+
// ignore
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -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 {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ModuleManifest } from '@webstir-io/module-contract';
|
|
2
|
+
|
|
3
|
+
type ModuleRoute = NonNullable<ModuleManifest['routes']>[number];
|
|
4
|
+
|
|
5
|
+
export interface BackendTestContext {
|
|
6
|
+
readonly baseUrl: string;
|
|
7
|
+
readonly url: URL;
|
|
8
|
+
readonly port: number;
|
|
9
|
+
readonly manifest: ModuleManifest | null;
|
|
10
|
+
readonly routes: readonly ModuleRoute[];
|
|
11
|
+
readonly env: Readonly<Record<string, string>>;
|
|
12
|
+
request(pathOrUrl?: string | URL, init?: RequestInit): Promise<Response>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BackendTestHarness {
|
|
16
|
+
readonly context: BackendTestContext;
|
|
17
|
+
stop(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BackendTestHarnessOptions {
|
|
21
|
+
workspaceRoot?: string;
|
|
22
|
+
buildRoot?: string;
|
|
23
|
+
entry?: string;
|
|
24
|
+
manifestPath?: string;
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
port?: number;
|
|
27
|
+
readyText?: string;
|
|
28
|
+
readyTimeoutMs?: number;
|
|
29
|
+
reuseExistingServer?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type BackendTestCallback = (context: BackendTestContext) => Promise<void> | void;
|
package/src/watch.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { context as createEsbuildContext, type BuildContext, type BuildResult, type Plugin } from 'esbuild';
|
|
6
|
+
|
|
7
|
+
import type { ModuleDiagnostic } from '@webstir-io/module-contract';
|
|
8
|
+
|
|
9
|
+
import { collectOutputSizes, formatEsbuildMessage, shouldTypeCheck } from './build/pipeline.js';
|
|
10
|
+
import { discoverEntryPoints } from './build/entries.js';
|
|
11
|
+
import { loadBackendModuleManifest } from './manifest/pipeline.js';
|
|
12
|
+
import { createCacheReporter } from './cache/reporters.js';
|
|
13
|
+
import { normalizeMode, resolveWorkspacePaths } from './workspace.js';
|
|
14
|
+
|
|
15
|
+
export interface WatchHandle {
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StartWatchOptions {
|
|
20
|
+
readonly workspaceRoot: string;
|
|
21
|
+
readonly env?: Record<string, string | undefined>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function startBackendWatch(options: StartWatchOptions): Promise<WatchHandle> {
|
|
25
|
+
const { workspaceRoot } = options;
|
|
26
|
+
const env = options.env ?? {};
|
|
27
|
+
const paths = resolveWorkspacePaths(workspaceRoot);
|
|
28
|
+
const tsconfigPath = path.join(paths.sourceRoot, 'tsconfig.json');
|
|
29
|
+
const mode = normalizeMode(env.WEBSTIR_MODULE_MODE);
|
|
30
|
+
|
|
31
|
+
const entryPoints = await discoverEntryPoints(paths.sourceRoot);
|
|
32
|
+
if (entryPoints.length === 0) {
|
|
33
|
+
console.warn(`[webstir-backend] watch: no entry found under ${paths.sourceRoot} (index.ts/js)`);
|
|
34
|
+
throw new Error('No backend entry point found.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const nodeEnv = env.NODE_ENV ?? (mode === 'publish' ? 'production' : 'development');
|
|
38
|
+
const diagMax = (() => {
|
|
39
|
+
const raw = env.WEBSTIR_BACKEND_DIAG_MAX;
|
|
40
|
+
const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
|
|
41
|
+
return Number.isFinite(n) && n > 0 ? n : 20;
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
console.info(`[webstir-backend] watch:start (${mode})`);
|
|
45
|
+
|
|
46
|
+
// Start type-checker in watch mode (no emit) unless explicitly skipped for DX.
|
|
47
|
+
const shouldRunTypecheck = shouldTypeCheck(mode, env);
|
|
48
|
+
let tscProc: ChildProcess | undefined;
|
|
49
|
+
if (shouldRunTypecheck) {
|
|
50
|
+
const tscArgs = ['-p', tsconfigPath, '--noEmit', '--watch'];
|
|
51
|
+
tscProc = spawn('tsc', tscArgs, {
|
|
52
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
53
|
+
env: { ...process.env, ...env, NODE_ENV: nodeEnv },
|
|
54
|
+
cwd: workspaceRoot,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
tscProc.stdout?.on('data', (chunk) => {
|
|
58
|
+
const text = chunk.toString();
|
|
59
|
+
for (const line of text.split(/\r?\n/)) {
|
|
60
|
+
if (line) console.info(`[webstir-backend][tsc] ${line}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
tscProc.stderr?.on('data', (chunk) => {
|
|
64
|
+
const text = chunk.toString();
|
|
65
|
+
for (const line of text.split(/\r?\n/)) {
|
|
66
|
+
if (line) console.warn(`[webstir-backend][tsc] ${line}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
console.info('[webstir-backend] watch: type-check skipped by WEBSTIR_BACKEND_TYPECHECK');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const timingPlugin: Plugin = {
|
|
74
|
+
name: 'webstir-watch-logger',
|
|
75
|
+
setup(build) {
|
|
76
|
+
let start = 0;
|
|
77
|
+
build.onStart(() => {
|
|
78
|
+
start = performance.now();
|
|
79
|
+
});
|
|
80
|
+
build.onEnd(async (result: BuildResult) => {
|
|
81
|
+
const end = performance.now();
|
|
82
|
+
const warnCount = result.warnings?.length ?? 0;
|
|
83
|
+
// errors is not in the typed result, but present at runtime
|
|
84
|
+
const errorList = (result as any).errors ?? [];
|
|
85
|
+
const errorCount = Array.isArray(errorList) ? errorList.length : 0;
|
|
86
|
+
// Print detailed diagnostics with file:line when available (capped for readability)
|
|
87
|
+
if (errorCount > 0) {
|
|
88
|
+
for (const msg of errorList.slice(0, diagMax)) {
|
|
89
|
+
const text = formatEsbuildMessage(msg);
|
|
90
|
+
console.error(`[webstir-backend][esbuild] ${text}`);
|
|
91
|
+
}
|
|
92
|
+
if (errorCount > diagMax) {
|
|
93
|
+
console.error(`[webstir-backend][esbuild] ... ${errorCount - diagMax} more error(s) omitted`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (warnCount > 0) {
|
|
97
|
+
for (const msg of result.warnings.slice(0, diagMax)) {
|
|
98
|
+
const text = formatEsbuildMessage(msg as any);
|
|
99
|
+
console.warn(`[webstir-backend][esbuild] ${text}`);
|
|
100
|
+
}
|
|
101
|
+
if (warnCount > diagMax) {
|
|
102
|
+
console.warn(`[webstir-backend][esbuild] ... ${warnCount - diagMax} more warning(s) omitted`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.info(`[webstir-backend] watch:esbuild ${errorCount} error(s), ${warnCount} warning(s) in ${(end - start).toFixed(1)}ms`);
|
|
106
|
+
|
|
107
|
+
if (errorCount === 0) {
|
|
108
|
+
const diagBuffer: ModuleDiagnostic[] = [];
|
|
109
|
+
const cacheReporter = createCacheReporter({
|
|
110
|
+
workspaceRoot,
|
|
111
|
+
buildRoot: paths.buildRoot,
|
|
112
|
+
env,
|
|
113
|
+
diagnostics: diagBuffer
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
const metafile: any = (result as any).metafile;
|
|
117
|
+
if (metafile && metafile.outputs) {
|
|
118
|
+
const outputs = collectOutputSizes(metafile, paths.buildRoot);
|
|
119
|
+
await cacheReporter.diffOutputs(outputs, mode);
|
|
120
|
+
}
|
|
121
|
+
const manifest = await loadBackendModuleManifest({
|
|
122
|
+
workspaceRoot,
|
|
123
|
+
buildRoot: paths.buildRoot,
|
|
124
|
+
entryPoints,
|
|
125
|
+
diagnostics: diagBuffer
|
|
126
|
+
});
|
|
127
|
+
await cacheReporter.diffManifest(manifest);
|
|
128
|
+
} catch {
|
|
129
|
+
// cache or manifest diff failure should not break watch
|
|
130
|
+
} finally {
|
|
131
|
+
for (const diag of diagBuffer) {
|
|
132
|
+
const logger =
|
|
133
|
+
diag.severity === 'error' ? console.error : diag.severity === 'warn' ? console.warn : console.info;
|
|
134
|
+
logger(diag.message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const ctx: BuildContext = await createEsbuildContext({
|
|
143
|
+
entryPoints,
|
|
144
|
+
bundle: false,
|
|
145
|
+
platform: 'node',
|
|
146
|
+
target: 'node20',
|
|
147
|
+
format: 'esm',
|
|
148
|
+
sourcemap: true,
|
|
149
|
+
outdir: paths.buildRoot,
|
|
150
|
+
outbase: paths.sourceRoot,
|
|
151
|
+
metafile: true,
|
|
152
|
+
tsconfig: existsSync(tsconfigPath) ? tsconfigPath : undefined,
|
|
153
|
+
define: { 'process.env.NODE_ENV': JSON.stringify(nodeEnv) },
|
|
154
|
+
logLevel: 'silent',
|
|
155
|
+
plugins: [timingPlugin],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await ctx.watch();
|
|
159
|
+
|
|
160
|
+
console.info('[webstir-backend] watch:ready');
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
async stop() {
|
|
164
|
+
try {
|
|
165
|
+
await ctx.dispose();
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
tscProc?.kill('SIGINT');
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore
|
|
173
|
+
}
|
|
174
|
+
console.info('[webstir-backend] watch:stopped');
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|