@webstir-io/webstir-backend 0.1.15 → 0.1.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 +106 -79
- package/dist/add.d.ts +59 -0
- package/dist/add.js +626 -0
- package/dist/build/artifacts.d.ts +115 -1
- package/dist/build/artifacts.js +4 -4
- package/dist/build/entries.js +1 -1
- package/dist/build/pipeline.d.ts +33 -1
- package/dist/build/pipeline.js +307 -65
- package/dist/cache/diff.js +9 -8
- package/dist/cache/reporters.js +1 -1
- package/dist/deploy-cli.d.ts +2 -0
- package/dist/deploy-cli.js +86 -0
- package/dist/diagnostics/summary.js +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/manifest/pipeline.js +103 -32
- package/dist/provider.js +35 -17
- package/dist/runtime/bun.d.ts +51 -0
- package/dist/runtime/bun.js +499 -0
- package/dist/runtime/core.d.ts +141 -0
- package/dist/runtime/core.js +316 -0
- package/dist/runtime/deploy-backend.d.ts +20 -0
- package/dist/runtime/deploy-backend.js +175 -0
- package/dist/runtime/deploy-shared.d.ts +43 -0
- package/dist/runtime/deploy-shared.js +75 -0
- package/dist/runtime/deploy-static.d.ts +2 -0
- package/dist/runtime/deploy-static.js +161 -0
- package/dist/runtime/deploy.d.ts +3 -0
- package/dist/runtime/deploy.js +91 -0
- package/dist/runtime/forms.d.ts +73 -0
- package/dist/runtime/forms.js +236 -0
- package/dist/runtime/request-hooks.d.ts +47 -0
- package/dist/runtime/request-hooks.js +102 -0
- package/dist/runtime/session-metadata.d.ts +13 -0
- package/dist/runtime/session-metadata.js +98 -0
- package/dist/runtime/session-runtime.d.ts +28 -0
- package/dist/runtime/session-runtime.js +180 -0
- package/dist/runtime/session.d.ts +83 -0
- package/dist/runtime/session.js +396 -0
- package/dist/runtime/views.d.ts +74 -0
- package/dist/runtime/views.js +221 -0
- package/dist/scaffold/assets.js +25 -21
- package/dist/testing/context.js +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +100 -56
- package/dist/utils/bun.d.ts +2 -0
- package/dist/utils/bun.js +13 -0
- package/dist/watch.d.ts +13 -1
- package/dist/watch.js +345 -97
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.js +44 -3
- package/package.json +49 -14
- package/scripts/publish.sh +2 -92
- package/scripts/smoke.mjs +282 -107
- package/scripts/update-contract.sh +12 -10
- package/src/add.ts +964 -0
- package/src/build/artifacts.ts +49 -46
- package/src/build/entries.ts +12 -12
- package/src/build/pipeline.ts +779 -403
- package/src/cache/diff.ts +111 -105
- package/src/cache/reporters.ts +26 -26
- package/src/deploy-cli.ts +111 -0
- package/src/diagnostics/summary.ts +28 -22
- package/src/index.ts +11 -0
- package/src/manifest/pipeline.ts +328 -215
- package/src/provider.ts +115 -98
- package/src/runtime/bun.ts +793 -0
- package/src/runtime/core.ts +598 -0
- package/src/runtime/deploy-backend.ts +239 -0
- package/src/runtime/deploy-shared.ts +136 -0
- package/src/runtime/deploy-static.ts +191 -0
- package/src/runtime/deploy.ts +143 -0
- package/src/runtime/forms.ts +364 -0
- package/src/runtime/request-hooks.ts +165 -0
- package/src/runtime/session-metadata.ts +135 -0
- package/src/runtime/session-runtime.ts +267 -0
- package/src/runtime/session.ts +642 -0
- package/src/runtime/views.ts +385 -0
- package/src/scaffold/assets.ts +77 -73
- package/src/testing/context.js +8 -9
- package/src/testing/context.ts +9 -9
- package/src/testing/index.d.ts +14 -3
- package/src/testing/index.js +254 -175
- package/src/testing/index.ts +298 -195
- package/src/testing/types.d.ts +18 -19
- package/src/testing/types.ts +18 -18
- package/src/utils/bun.ts +26 -0
- package/src/watch.ts +503 -99
- package/src/workspace.ts +59 -3
- package/templates/backend/.env.example +15 -0
- package/templates/backend/auth/adapter.ts +335 -36
- package/templates/backend/db/connection.ts +190 -65
- package/templates/backend/db/migrate.ts +149 -43
- package/templates/backend/db/types.d.ts +1 -1
- package/templates/backend/env.ts +132 -20
- package/templates/backend/functions/hello/index.ts +1 -2
- package/templates/backend/index.ts +15 -508
- package/templates/backend/jobs/nightly/index.ts +1 -1
- package/templates/backend/jobs/runtime.ts +24 -11
- package/templates/backend/jobs/scheduler.ts +208 -46
- package/templates/backend/module.ts +227 -13
- package/templates/backend/observability/logger.ts +2 -12
- package/templates/backend/observability/metrics.ts +8 -5
- package/templates/backend/session/sqlite.ts +152 -0
- package/templates/backend/session/store.ts +45 -0
- package/templates/backend/tsconfig.json +1 -1
- package/tests/add.test.js +327 -0
- package/tests/authAdapter.test.js +315 -0
- package/tests/bundlerParity.test.js +217 -0
- package/tests/cacheReporter.test.js +10 -10
- package/tests/dbConnection.test.js +209 -0
- package/tests/deploy.test.js +357 -0
- package/tests/envLoader.test.js +271 -17
- package/tests/integration.test.js +2432 -3
- package/tests/jobsScheduler.test.js +253 -0
- package/tests/manifest.test.js +287 -12
- package/tests/migrationRunner.test.js +249 -0
- package/tests/sessionScaffoldStore.test.js +752 -0
- package/tests/sessionStore.test.js +490 -0
- package/tests/testing.test.js +252 -0
- package/tests/watch.test.js +192 -32
- package/tsconfig.json +3 -10
- package/templates/backend/server/fastify.ts +0 -288
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { build as esbuild } from 'esbuild';
|
|
9
|
+
|
|
10
|
+
import { backendProvider } from '../dist/index.js';
|
|
11
|
+
|
|
12
|
+
async function createTempWorkspace(prefix = 'webstir-backend-db-') {
|
|
13
|
+
return await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function copyFile(src, dest) {
|
|
17
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
18
|
+
await fs.copyFile(src, dest);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function seedBackendWorkspace(workspace, name) {
|
|
22
|
+
const assets = await backendProvider.getScaffoldAssets();
|
|
23
|
+
for (const asset of assets) {
|
|
24
|
+
await copyFile(asset.sourcePath, path.join(workspace, asset.targetPath));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await fs.writeFile(
|
|
28
|
+
path.join(workspace, 'package.json'),
|
|
29
|
+
JSON.stringify(
|
|
30
|
+
{
|
|
31
|
+
name,
|
|
32
|
+
version: '0.0.0',
|
|
33
|
+
type: 'module',
|
|
34
|
+
},
|
|
35
|
+
null,
|
|
36
|
+
2,
|
|
37
|
+
),
|
|
38
|
+
'utf8',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function compileTemplateDbFiles(workspace) {
|
|
43
|
+
await esbuild({
|
|
44
|
+
entryPoints: [
|
|
45
|
+
path.join(workspace, 'src', 'backend', 'env.ts'),
|
|
46
|
+
path.join(workspace, 'src', 'backend', 'db', 'connection.ts'),
|
|
47
|
+
],
|
|
48
|
+
bundle: false,
|
|
49
|
+
format: 'esm',
|
|
50
|
+
platform: 'node',
|
|
51
|
+
target: 'node20',
|
|
52
|
+
outdir: path.join(workspace, 'build', 'backend'),
|
|
53
|
+
outbase: path.join(workspace, 'src', 'backend'),
|
|
54
|
+
logLevel: 'silent',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function snapshotEnv(keys) {
|
|
59
|
+
return Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function restoreEnv(snapshot) {
|
|
63
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
delete process.env[key];
|
|
66
|
+
} else {
|
|
67
|
+
process.env[key] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function assertSameResolvedPath(actual, expected) {
|
|
73
|
+
const [resolvedActual, resolvedExpected] = await Promise.all([
|
|
74
|
+
resolveComparablePath(actual),
|
|
75
|
+
resolveComparablePath(expected),
|
|
76
|
+
]);
|
|
77
|
+
assert.equal(resolvedActual, resolvedExpected);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resolveComparablePath(targetPath) {
|
|
81
|
+
const directory = await fs.realpath(path.dirname(targetPath));
|
|
82
|
+
return path.join(directory, path.basename(targetPath));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function runConnectionProbe(workspace, { cwd = workspace, env = {} } = {}) {
|
|
86
|
+
const entryUrl = pathToFileURL(
|
|
87
|
+
path.join(workspace, 'build', 'backend', 'db', 'connection.js'),
|
|
88
|
+
).href;
|
|
89
|
+
const script = `
|
|
90
|
+
import(${JSON.stringify(entryUrl)}).then(async ({ createDatabaseClient }) => {
|
|
91
|
+
const db = await createDatabaseClient();
|
|
92
|
+
await db.execute('CREATE TABLE IF NOT EXISTS probe_items (id TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
|
93
|
+
await db.execute('DELETE FROM probe_items');
|
|
94
|
+
await db.execute('INSERT INTO probe_items (id, value) VALUES (?, ?)', ['item-1', 'Ada']);
|
|
95
|
+
const rows = await db.query('SELECT value FROM probe_items WHERE id = ?', ['item-1']);
|
|
96
|
+
const databases = await db.query('PRAGMA database_list');
|
|
97
|
+
const main = databases.find((row) => row.name === 'main');
|
|
98
|
+
console.log(JSON.stringify({ target: main?.file ?? null, value: rows[0]?.value ?? null }));
|
|
99
|
+
await db.close();
|
|
100
|
+
});
|
|
101
|
+
`;
|
|
102
|
+
const child = spawn('bun', ['--eval', script], {
|
|
103
|
+
cwd,
|
|
104
|
+
env: {
|
|
105
|
+
...process.env,
|
|
106
|
+
...env,
|
|
107
|
+
},
|
|
108
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let stdout = '';
|
|
112
|
+
let stderr = '';
|
|
113
|
+
child.stdout.on('data', (chunk) => {
|
|
114
|
+
stdout += chunk.toString();
|
|
115
|
+
});
|
|
116
|
+
child.stderr.on('data', (chunk) => {
|
|
117
|
+
stderr += chunk.toString();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const exitCode = await new Promise((resolve) => {
|
|
121
|
+
child.once('close', resolve);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (exitCode !== 0) {
|
|
125
|
+
throw new Error(`DB probe failed (exit ${exitCode}).\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const line = stdout
|
|
129
|
+
.split(/\r?\n/)
|
|
130
|
+
.map((value) => value.trim())
|
|
131
|
+
.find((value) => value.startsWith('{'));
|
|
132
|
+
if (!line) {
|
|
133
|
+
throw new Error(`DB probe did not emit JSON.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return JSON.parse(line);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
test('createDatabaseClient resolves file: DATABASE_URL from WEBSTIR_WORKSPACE_ROOT outside the workspace cwd', async () => {
|
|
140
|
+
const workspace = await createTempWorkspace('webstir-backend-db-env-root-');
|
|
141
|
+
const alternateCwd = await createTempWorkspace('webstir-backend-db-env-root-cwd-');
|
|
142
|
+
await seedBackendWorkspace(workspace, '@demo/db-env-root');
|
|
143
|
+
await compileTemplateDbFiles(workspace);
|
|
144
|
+
|
|
145
|
+
const previousEnv = snapshotEnv(['WORKSPACE_ROOT', 'WEBSTIR_WORKSPACE_ROOT', 'DATABASE_URL']);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const result = await runConnectionProbe(workspace, {
|
|
149
|
+
cwd: alternateCwd,
|
|
150
|
+
env: {
|
|
151
|
+
WORKSPACE_ROOT: ' ',
|
|
152
|
+
WEBSTIR_WORKSPACE_ROOT: workspace,
|
|
153
|
+
DATABASE_URL: 'file:./data/env-root.sqlite',
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await assertSameResolvedPath(result.target, path.join(workspace, 'data', 'env-root.sqlite'));
|
|
158
|
+
assert.equal(result.value, 'Ada');
|
|
159
|
+
} finally {
|
|
160
|
+
restoreEnv(previousEnv);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('createDatabaseClient resolves plain relative sqlite DATABASE_URL from module path outside the workspace cwd', async () => {
|
|
165
|
+
const workspace = await createTempWorkspace('webstir-backend-db-infer-');
|
|
166
|
+
const alternateCwd = await createTempWorkspace('webstir-backend-db-infer-cwd-');
|
|
167
|
+
await seedBackendWorkspace(workspace, '@demo/db-infer');
|
|
168
|
+
await compileTemplateDbFiles(workspace);
|
|
169
|
+
|
|
170
|
+
const previousEnv = snapshotEnv(['WORKSPACE_ROOT', 'WEBSTIR_WORKSPACE_ROOT', 'DATABASE_URL']);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const result = await runConnectionProbe(workspace, {
|
|
174
|
+
cwd: alternateCwd,
|
|
175
|
+
env: {
|
|
176
|
+
DATABASE_URL: './data/infer.sqlite',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await assertSameResolvedPath(result.target, path.join(workspace, 'data', 'infer.sqlite'));
|
|
181
|
+
assert.equal(result.value, 'Ada');
|
|
182
|
+
} finally {
|
|
183
|
+
restoreEnv(previousEnv);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('createDatabaseClient preserves absolute sqlite paths outside the workspace cwd', async () => {
|
|
188
|
+
const workspace = await createTempWorkspace('webstir-backend-db-absolute-');
|
|
189
|
+
const alternateCwd = await createTempWorkspace('webstir-backend-db-absolute-cwd-');
|
|
190
|
+
const absoluteTarget = path.join(workspace, 'data', 'absolute.sqlite');
|
|
191
|
+
await seedBackendWorkspace(workspace, '@demo/db-absolute');
|
|
192
|
+
await compileTemplateDbFiles(workspace);
|
|
193
|
+
|
|
194
|
+
const previousEnv = snapshotEnv(['WORKSPACE_ROOT', 'WEBSTIR_WORKSPACE_ROOT', 'DATABASE_URL']);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const result = await runConnectionProbe(workspace, {
|
|
198
|
+
cwd: alternateCwd,
|
|
199
|
+
env: {
|
|
200
|
+
DATABASE_URL: `file:${absoluteTarget}`,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await assertSameResolvedPath(result.target, absoluteTarget);
|
|
205
|
+
assert.equal(result.value, 'Ada');
|
|
206
|
+
} finally {
|
|
207
|
+
restoreEnv(previousEnv);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { backendProvider, startPublishedWorkspaceServer } from '../dist/index.js';
|
|
10
|
+
|
|
11
|
+
test('deploy cli is emitted with a Bun shebang', async () => {
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const cliPath = path.join(here, '..', 'dist', 'deploy-cli.js');
|
|
14
|
+
const source = await fs.readFile(cliPath, 'utf8');
|
|
15
|
+
|
|
16
|
+
assert.match(source, /^#!\/usr\/bin\/env bun/m);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('deploy cli prints usage', () => {
|
|
20
|
+
const cliPath = path.join(getPackageRoot(), 'dist', 'deploy-cli.js');
|
|
21
|
+
const result = Bun.spawnSync({
|
|
22
|
+
cmd: ['bun', cliPath, '--help'],
|
|
23
|
+
cwd: getPackageRoot(),
|
|
24
|
+
stdout: 'pipe',
|
|
25
|
+
stderr: 'pipe',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
assert.equal(result.exitCode, 0);
|
|
29
|
+
assert.match(new TextDecoder().decode(result.stdout), /Usage: webstir-backend-deploy/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('published deploy serves frontend assets and proxies backend routes for full workspaces', async (t) => {
|
|
33
|
+
if (!(await canListenOnTcp())) {
|
|
34
|
+
t.skip('TCP listen is not permitted in this environment.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const workspace = await createTempWorkspace('webstir-backend-deploy-full-');
|
|
39
|
+
await buildRuntimeWorkspace(workspace, 'full');
|
|
40
|
+
await writePublishedFrontendDocument(
|
|
41
|
+
workspace,
|
|
42
|
+
'home',
|
|
43
|
+
'<!DOCTYPE html><html><body>Home</body></html>',
|
|
44
|
+
);
|
|
45
|
+
await writePublishedFrontendDocument(
|
|
46
|
+
workspace,
|
|
47
|
+
'about',
|
|
48
|
+
'<!DOCTYPE html><html><body>About</body></html>',
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const port = await getOpenPort();
|
|
52
|
+
const server = await startPublishedWorkspaceServer({
|
|
53
|
+
workspaceRoot: workspace,
|
|
54
|
+
port,
|
|
55
|
+
io: quietIo,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const homeResponse = await fetch(`${server.origin}/`);
|
|
60
|
+
assert.equal(homeResponse.status, 200);
|
|
61
|
+
assert.match(await homeResponse.text(), /Home/);
|
|
62
|
+
assert.match(String(homeResponse.headers.get('cache-control')), /no-store/);
|
|
63
|
+
|
|
64
|
+
const aboutResponse = await fetch(`${server.origin}/about`);
|
|
65
|
+
assert.equal(aboutResponse.status, 200);
|
|
66
|
+
assert.match(await aboutResponse.text(), /About/);
|
|
67
|
+
|
|
68
|
+
const apiResponse = await fetch(`${server.origin}/api/deploy/check`);
|
|
69
|
+
assert.equal(apiResponse.status, 200);
|
|
70
|
+
assert.deepEqual(await apiResponse.json(), { ok: true, mode: 'full' });
|
|
71
|
+
|
|
72
|
+
const readyResponse = await fetch(`${server.origin}/readyz`);
|
|
73
|
+
assert.equal(readyResponse.status, 200);
|
|
74
|
+
const readyPayload = await readyResponse.json();
|
|
75
|
+
assert.equal(readyPayload.status, 'ready');
|
|
76
|
+
assert.equal(readyPayload.manifest?.routes, 2);
|
|
77
|
+
|
|
78
|
+
const healthResponse = await fetch(`${server.origin}/healthz`);
|
|
79
|
+
assert.equal(healthResponse.status, 200);
|
|
80
|
+
assert.equal((await healthResponse.json()).ok, true);
|
|
81
|
+
|
|
82
|
+
const metricsResponse = await fetch(`${server.origin}/metrics`);
|
|
83
|
+
assert.equal(metricsResponse.status, 200);
|
|
84
|
+
const metricsPayload = await metricsResponse.json();
|
|
85
|
+
assert.equal(metricsPayload.enabled, true);
|
|
86
|
+
assert.ok(metricsPayload.totalRequests >= 1);
|
|
87
|
+
assert.ok((metricsPayload.byStatus?.['200'] ?? 0) >= 1);
|
|
88
|
+
|
|
89
|
+
const redirectResponse = await fetch(`${server.origin}/api/deploy/redirect`, {
|
|
90
|
+
redirect: 'manual',
|
|
91
|
+
});
|
|
92
|
+
assert.equal(redirectResponse.status, 303);
|
|
93
|
+
assert.equal(redirectResponse.headers.get('location'), '/api/deploy/check');
|
|
94
|
+
} finally {
|
|
95
|
+
await server.stop();
|
|
96
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('published deploy proxies api workspaces without a frontend host', async (t) => {
|
|
101
|
+
if (!(await canListenOnTcp())) {
|
|
102
|
+
t.skip('TCP listen is not permitted in this environment.');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const workspace = await createTempWorkspace('webstir-backend-deploy-api-');
|
|
107
|
+
await buildRuntimeWorkspace(workspace, 'api');
|
|
108
|
+
|
|
109
|
+
const port = await getOpenPort();
|
|
110
|
+
const server = await startPublishedWorkspaceServer({
|
|
111
|
+
workspaceRoot: workspace,
|
|
112
|
+
port,
|
|
113
|
+
io: quietIo,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const apiResponse = await fetch(`${server.origin}/deploy/check`);
|
|
118
|
+
assert.equal(apiResponse.status, 200);
|
|
119
|
+
assert.deepEqual(await apiResponse.json(), { ok: true, mode: 'api' });
|
|
120
|
+
|
|
121
|
+
const healthResponse = await fetch(`${server.origin}/healthz`);
|
|
122
|
+
assert.equal(healthResponse.status, 200);
|
|
123
|
+
assert.equal((await healthResponse.json()).ok, true);
|
|
124
|
+
|
|
125
|
+
const metricsResponse = await fetch(`${server.origin}/metrics`);
|
|
126
|
+
assert.equal(metricsResponse.status, 200);
|
|
127
|
+
assert.equal((await metricsResponse.json()).enabled, true);
|
|
128
|
+
|
|
129
|
+
const redirectResponse = await fetch(`${server.origin}/deploy/redirect`, {
|
|
130
|
+
redirect: 'manual',
|
|
131
|
+
});
|
|
132
|
+
assert.equal(redirectResponse.status, 303);
|
|
133
|
+
assert.equal(redirectResponse.headers.get('location'), '/deploy/check');
|
|
134
|
+
|
|
135
|
+
const missingResponse = await fetch(`${server.origin}/missing`);
|
|
136
|
+
assert.equal(missingResponse.status, 404);
|
|
137
|
+
} finally {
|
|
138
|
+
await server.stop();
|
|
139
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
async function createTempWorkspace(prefix) {
|
|
144
|
+
return await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function ensureDir(dir) {
|
|
148
|
+
await fs.mkdir(dir, { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function copyFile(src, dest) {
|
|
152
|
+
await ensureDir(path.dirname(dest));
|
|
153
|
+
await fs.copyFile(src, dest);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function hydrateBackendScaffold(workspace) {
|
|
157
|
+
const assets = await backendProvider.getScaffoldAssets();
|
|
158
|
+
|
|
159
|
+
for (const asset of assets) {
|
|
160
|
+
const normalized = asset.targetPath.replace(/\\/g, '/');
|
|
161
|
+
if (!normalized.includes('src/backend/')) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const target = path.join(workspace, asset.targetPath);
|
|
166
|
+
await copyFile(asset.sourcePath, target);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getLocalBinPath() {
|
|
171
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
172
|
+
const pkgRoot = path.resolve(here, '..');
|
|
173
|
+
return path.join(pkgRoot, 'node_modules', '.bin');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getPackageRoot() {
|
|
177
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
178
|
+
return path.resolve(here, '..');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function linkWorkspaceNodeModules(workspace) {
|
|
182
|
+
const packageRoot = getPackageRoot();
|
|
183
|
+
const source = path.join(packageRoot, 'node_modules');
|
|
184
|
+
const target = path.join(workspace, 'node_modules');
|
|
185
|
+
await fs.mkdir(target, { recursive: true });
|
|
186
|
+
|
|
187
|
+
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
if (entry.name === '@webstir-io') {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await createSymlinkIfMissing(
|
|
194
|
+
path.join(source, entry.name),
|
|
195
|
+
path.join(target, entry.name),
|
|
196
|
+
entry.isDirectory() ? 'dir' : 'file',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const scopeSource = path.join(source, '@webstir-io');
|
|
201
|
+
const scopeTarget = path.join(target, '@webstir-io');
|
|
202
|
+
await fs.mkdir(scopeTarget, { recursive: true });
|
|
203
|
+
const scopeEntries = await fs.readdir(scopeSource, { withFileTypes: true });
|
|
204
|
+
for (const entry of scopeEntries) {
|
|
205
|
+
await createSymlinkIfMissing(
|
|
206
|
+
path.join(scopeSource, entry.name),
|
|
207
|
+
path.join(scopeTarget, entry.name),
|
|
208
|
+
entry.isDirectory() ? 'dir' : 'file',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await createSymlinkIfMissing(packageRoot, path.join(scopeTarget, 'webstir-backend'), 'dir');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function createSymlinkIfMissing(source, target, type) {
|
|
216
|
+
try {
|
|
217
|
+
await fs.symlink(source, target, type);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function buildRuntimeWorkspace(workspace, mode) {
|
|
228
|
+
await hydrateBackendScaffold(workspace);
|
|
229
|
+
await linkWorkspaceNodeModules(workspace);
|
|
230
|
+
await fs.writeFile(
|
|
231
|
+
path.join(workspace, 'package.json'),
|
|
232
|
+
JSON.stringify(
|
|
233
|
+
{
|
|
234
|
+
name: `@demo/${mode}-deploy`,
|
|
235
|
+
version: '0.1.0',
|
|
236
|
+
type: 'module',
|
|
237
|
+
webstir: {
|
|
238
|
+
mode,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
null,
|
|
242
|
+
2,
|
|
243
|
+
),
|
|
244
|
+
'utf8',
|
|
245
|
+
);
|
|
246
|
+
await fs.writeFile(
|
|
247
|
+
path.join(workspace, 'src', 'backend', 'module.ts'),
|
|
248
|
+
createModuleSource(mode),
|
|
249
|
+
'utf8',
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
await backendProvider.build({
|
|
253
|
+
workspaceRoot: workspace,
|
|
254
|
+
env: {
|
|
255
|
+
WEBSTIR_MODULE_MODE: 'publish',
|
|
256
|
+
WEBSTIR_BACKEND_TYPECHECK: 'skip',
|
|
257
|
+
PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
258
|
+
},
|
|
259
|
+
incremental: false,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function createModuleSource(mode) {
|
|
264
|
+
return `const routes = [
|
|
265
|
+
{
|
|
266
|
+
definition: {
|
|
267
|
+
name: 'deployCheck',
|
|
268
|
+
method: 'GET',
|
|
269
|
+
path: '/deploy/check'
|
|
270
|
+
},
|
|
271
|
+
handler: async () => ({
|
|
272
|
+
status: 200,
|
|
273
|
+
body: {
|
|
274
|
+
ok: true,
|
|
275
|
+
mode: ${JSON.stringify(mode)}
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
definition: {
|
|
281
|
+
name: 'deployRedirect',
|
|
282
|
+
method: 'GET',
|
|
283
|
+
path: '/deploy/redirect'
|
|
284
|
+
},
|
|
285
|
+
handler: async () => ({
|
|
286
|
+
status: 303,
|
|
287
|
+
redirect: {
|
|
288
|
+
location: '/deploy/check'
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
export const module = {
|
|
295
|
+
manifest: {
|
|
296
|
+
contractVersion: '1.0.0',
|
|
297
|
+
name: '@demo/${mode}-deploy',
|
|
298
|
+
version: '0.1.0',
|
|
299
|
+
kind: 'backend',
|
|
300
|
+
capabilities: ['http'],
|
|
301
|
+
routes: routes.map((route) => route.definition)
|
|
302
|
+
},
|
|
303
|
+
routes
|
|
304
|
+
};
|
|
305
|
+
`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function writePublishedFrontendDocument(workspace, pageName, html) {
|
|
309
|
+
const targetPath = path.join(workspace, 'dist', 'frontend', 'pages', pageName, 'index.html');
|
|
310
|
+
await ensureDir(path.dirname(targetPath));
|
|
311
|
+
await fs.writeFile(targetPath, html, 'utf8');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function getOpenPort() {
|
|
315
|
+
return await new Promise((resolve, reject) => {
|
|
316
|
+
const server = net.createServer();
|
|
317
|
+
server.once('error', reject);
|
|
318
|
+
server.listen(0, '127.0.0.1', () => {
|
|
319
|
+
const address = server.address();
|
|
320
|
+
if (!address || typeof address === 'string') {
|
|
321
|
+
reject(new Error('Failed to allocate an open port.'));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
server.close((error) => {
|
|
326
|
+
if (error) {
|
|
327
|
+
reject(error);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
resolve(address.port);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function canListenOnTcp() {
|
|
338
|
+
return await new Promise((resolve) => {
|
|
339
|
+
const server = net.createServer();
|
|
340
|
+
const settle = (value) => {
|
|
341
|
+
server.removeAllListeners();
|
|
342
|
+
server.close(() => resolve(value));
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
server.once('error', () => settle(false));
|
|
346
|
+
server.listen(0, '127.0.0.1', () => settle(true));
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const quietIo = {
|
|
351
|
+
stdout: {
|
|
352
|
+
write() {},
|
|
353
|
+
},
|
|
354
|
+
stderr: {
|
|
355
|
+
write() {},
|
|
356
|
+
},
|
|
357
|
+
};
|