@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,316 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
3
|
+
import { resolveRequestHooks, } from './request-hooks.js';
|
|
4
|
+
import { compileViews, } from './views.js';
|
|
5
|
+
export class RequestBodyTooLargeError extends Error {
|
|
6
|
+
statusCode = 413;
|
|
7
|
+
code = 'payload_too_large';
|
|
8
|
+
constructor(maxBytes) {
|
|
9
|
+
super(`Request body exceeded ${maxBytes} bytes.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createProcessEnvAccessor() {
|
|
13
|
+
return {
|
|
14
|
+
get: (name) => process.env[name],
|
|
15
|
+
require: (name) => {
|
|
16
|
+
const value = process.env[name];
|
|
17
|
+
if (value === undefined) {
|
|
18
|
+
throw new Error(`Missing required env var ${name}`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
},
|
|
22
|
+
entries: () => ({ ...process.env }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function createReadinessTracker() {
|
|
26
|
+
let status = 'booting';
|
|
27
|
+
let message;
|
|
28
|
+
return {
|
|
29
|
+
booting() {
|
|
30
|
+
status = 'booting';
|
|
31
|
+
message = undefined;
|
|
32
|
+
},
|
|
33
|
+
ready() {
|
|
34
|
+
status = 'ready';
|
|
35
|
+
message = undefined;
|
|
36
|
+
},
|
|
37
|
+
error(reason) {
|
|
38
|
+
status = 'error';
|
|
39
|
+
message = reason;
|
|
40
|
+
},
|
|
41
|
+
snapshot() {
|
|
42
|
+
return { status, message };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function normalizePath(value) {
|
|
47
|
+
if (!value || value === '/') {
|
|
48
|
+
return '/';
|
|
49
|
+
}
|
|
50
|
+
const trimmed = value.endsWith('/') ? value.slice(0, -1) : value;
|
|
51
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
52
|
+
}
|
|
53
|
+
export function matchRoute(routes, method, pathname) {
|
|
54
|
+
const normalizedMethod = (method ?? 'GET').toUpperCase();
|
|
55
|
+
for (const route of routes) {
|
|
56
|
+
if (route.method !== normalizedMethod) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const { matched, params } = route.match(pathname);
|
|
60
|
+
if (matched) {
|
|
61
|
+
return { route, params };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
export async function loadModuleRuntime(options) {
|
|
67
|
+
const loaded = await tryLoadModuleDefinition(options);
|
|
68
|
+
if (!loaded) {
|
|
69
|
+
return { routes: [], views: [] };
|
|
70
|
+
}
|
|
71
|
+
const manifest = sanitizeManifest(loaded.definition.manifest);
|
|
72
|
+
const compiled = compileRoutes(loaded.definition.routes ?? [], {
|
|
73
|
+
manifestRequestHooks: manifest?.requestHooks,
|
|
74
|
+
requestHookImplementations: loaded.definition.requestHooks,
|
|
75
|
+
});
|
|
76
|
+
const views = compileViews(resolveModuleViews(loaded.definition, manifest));
|
|
77
|
+
return {
|
|
78
|
+
definition: loaded.definition,
|
|
79
|
+
manifest,
|
|
80
|
+
routes: compiled.routes,
|
|
81
|
+
views,
|
|
82
|
+
source: loaded.source,
|
|
83
|
+
warnings: compiled.warnings,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function summarizeManifest(manifest) {
|
|
87
|
+
if (!manifest) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
name: manifest.name,
|
|
92
|
+
version: manifest.version,
|
|
93
|
+
routes: Array.isArray(manifest.routes) ? manifest.routes.length : 0,
|
|
94
|
+
views: Array.isArray(manifest.views) ? manifest.views.length : 0,
|
|
95
|
+
capabilities: manifest.capabilities && manifest.capabilities.length > 0 ? manifest.capabilities : undefined,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function logManifestSummary(logger, manifest, routeCount, viewCount) {
|
|
99
|
+
if (!manifest) {
|
|
100
|
+
logger.info(`[webstir-backend] manifest routes=${routeCount} views=${viewCount} (no manifest metadata found)`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const caps = manifest.capabilities?.length ? ` [${manifest.capabilities.join(', ')}]` : '';
|
|
104
|
+
const routes = Array.isArray(manifest.routes) ? manifest.routes.length : routeCount;
|
|
105
|
+
const views = Array.isArray(manifest.views) ? manifest.views.length : viewCount;
|
|
106
|
+
logger.info(`[webstir-backend] manifest name=${manifest.name ?? 'unknown'} routes=${routes} views=${views}${caps}`);
|
|
107
|
+
}
|
|
108
|
+
export function normalizeRouteHandlerResult(result) {
|
|
109
|
+
const validatedFragment = validateFragmentResult(result.fragment);
|
|
110
|
+
if (!validatedFragment.valid) {
|
|
111
|
+
return {
|
|
112
|
+
status: result.status && result.status >= 400 ? result.status : 500,
|
|
113
|
+
headers: result.headers,
|
|
114
|
+
errors: [
|
|
115
|
+
{
|
|
116
|
+
code: 'invalid_fragment_response',
|
|
117
|
+
message: 'Fragment responses require a non-empty target, supported mode, and body.',
|
|
118
|
+
details: validatedFragment.issues,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (!validatedFragment.fragment) {
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...result,
|
|
128
|
+
fragment: validatedFragment.fragment,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function resolveResponseHeaders(result) {
|
|
132
|
+
const headers = { ...(result.headers ?? {}) };
|
|
133
|
+
const lowerCaseHeaders = lowerCaseHeaderMap(headers);
|
|
134
|
+
if (result.redirect) {
|
|
135
|
+
headers.location = result.redirect.location;
|
|
136
|
+
}
|
|
137
|
+
if (result.fragment) {
|
|
138
|
+
headers['x-webstir-fragment-cache'] = 'bypass';
|
|
139
|
+
headers['x-webstir-fragment-target'] = result.fragment.target;
|
|
140
|
+
if (result.fragment.selector) {
|
|
141
|
+
headers['x-webstir-fragment-selector'] = result.fragment.selector;
|
|
142
|
+
}
|
|
143
|
+
if (result.fragment.mode) {
|
|
144
|
+
headers['x-webstir-fragment-mode'] = result.fragment.mode;
|
|
145
|
+
}
|
|
146
|
+
if (!('cache-control' in lowerCaseHeaders)) {
|
|
147
|
+
headers['cache-control'] = 'no-store';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!('content-type' in lowerCaseHeaders)) {
|
|
151
|
+
const payload = result.fragment ? result.fragment.body : result.body;
|
|
152
|
+
if (payload !== undefined && payload !== null) {
|
|
153
|
+
headers['content-type'] = resolveContentType(payload);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return headers;
|
|
157
|
+
}
|
|
158
|
+
function lowerCaseHeaderMap(headers) {
|
|
159
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
160
|
+
}
|
|
161
|
+
function resolveContentType(payload) {
|
|
162
|
+
if (typeof payload === 'string') {
|
|
163
|
+
return 'text/html; charset=utf-8';
|
|
164
|
+
}
|
|
165
|
+
if (Buffer.isBuffer(payload)) {
|
|
166
|
+
return 'application/octet-stream';
|
|
167
|
+
}
|
|
168
|
+
return 'application/json';
|
|
169
|
+
}
|
|
170
|
+
function validateFragmentResult(fragment) {
|
|
171
|
+
if (!fragment) {
|
|
172
|
+
return { valid: true };
|
|
173
|
+
}
|
|
174
|
+
const issues = [];
|
|
175
|
+
const target = typeof fragment.target === 'string' ? fragment.target.trim() : '';
|
|
176
|
+
if (!target) {
|
|
177
|
+
issues.push('target');
|
|
178
|
+
}
|
|
179
|
+
let selector;
|
|
180
|
+
if (fragment.selector !== undefined) {
|
|
181
|
+
if (typeof fragment.selector !== 'string' || !fragment.selector.trim()) {
|
|
182
|
+
issues.push('selector');
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
selector = fragment.selector.trim();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
let mode;
|
|
189
|
+
if (fragment.mode !== undefined) {
|
|
190
|
+
if (fragment.mode === 'replace' || fragment.mode === 'append' || fragment.mode === 'prepend') {
|
|
191
|
+
mode = fragment.mode;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
issues.push('mode');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (fragment.body === undefined || fragment.body === null) {
|
|
198
|
+
issues.push('body');
|
|
199
|
+
}
|
|
200
|
+
if (issues.length > 0) {
|
|
201
|
+
return { valid: false, issues };
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
valid: true,
|
|
205
|
+
fragment: {
|
|
206
|
+
target,
|
|
207
|
+
selector,
|
|
208
|
+
mode,
|
|
209
|
+
body: fragment.body,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function sanitizeManifest(manifest) {
|
|
214
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
...manifest,
|
|
219
|
+
routes: Array.isArray(manifest.routes) ? manifest.routes : [],
|
|
220
|
+
views: Array.isArray(manifest.views) ? manifest.views : [],
|
|
221
|
+
requestHooks: Array.isArray(manifest.requestHooks) ? manifest.requestHooks : [],
|
|
222
|
+
capabilities: Array.isArray(manifest.capabilities) ? manifest.capabilities : undefined,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function compileRoutes(routes, options) {
|
|
226
|
+
const compiled = [];
|
|
227
|
+
const warnings = [];
|
|
228
|
+
for (const route of routes) {
|
|
229
|
+
if (typeof route.handler !== 'function') {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const method = (route.definition?.method ?? 'GET').toUpperCase();
|
|
233
|
+
const pathPattern = normalizePath(route.definition?.path ?? '/');
|
|
234
|
+
const routeName = route.definition?.name ?? pathPattern;
|
|
235
|
+
const resolvedHooks = resolveRequestHooks({
|
|
236
|
+
routeName,
|
|
237
|
+
routeReferences: route.definition?.requestHooks,
|
|
238
|
+
manifestDefinitions: options.manifestRequestHooks,
|
|
239
|
+
registrations: options.requestHookImplementations,
|
|
240
|
+
});
|
|
241
|
+
compiled.push({
|
|
242
|
+
method,
|
|
243
|
+
name: routeName,
|
|
244
|
+
match: createPathMatcher(pathPattern),
|
|
245
|
+
handler: route.handler,
|
|
246
|
+
requestHooks: resolvedHooks.hooks,
|
|
247
|
+
definition: route.definition,
|
|
248
|
+
});
|
|
249
|
+
warnings.push(...resolvedHooks.warnings);
|
|
250
|
+
}
|
|
251
|
+
return { routes: compiled, warnings };
|
|
252
|
+
}
|
|
253
|
+
function resolveModuleViews(definition, manifest) {
|
|
254
|
+
if (Array.isArray(definition.views) && definition.views.length > 0) {
|
|
255
|
+
return definition.views;
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(manifest?.views) && manifest.views.length > 0) {
|
|
258
|
+
return manifest.views.map((view) => ({ definition: view }));
|
|
259
|
+
}
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
function createPathMatcher(pattern) {
|
|
263
|
+
const normalized = normalizePath(pattern);
|
|
264
|
+
const paramRegex = /:([A-Za-z0-9_]+)/g;
|
|
265
|
+
const regex = new RegExp('^' +
|
|
266
|
+
normalized
|
|
267
|
+
.replace(/\//g, '\\/')
|
|
268
|
+
.replace(paramRegex, (_segment, name) => `(?<${name}>[^/]+)`) +
|
|
269
|
+
'$');
|
|
270
|
+
return (pathname) => {
|
|
271
|
+
const pathToTest = normalizePath(pathname);
|
|
272
|
+
const match = regex.exec(pathToTest);
|
|
273
|
+
if (!match) {
|
|
274
|
+
return { matched: false, params: {} };
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
matched: true,
|
|
278
|
+
params: (match.groups ?? {}),
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async function tryLoadModuleDefinition(options) {
|
|
283
|
+
const here = path.dirname(fileURLToPath(options.importMetaUrl));
|
|
284
|
+
const candidates = options.candidates ?? [
|
|
285
|
+
'module.js',
|
|
286
|
+
'module.mjs',
|
|
287
|
+
'module/index.js',
|
|
288
|
+
'module/index.mjs',
|
|
289
|
+
];
|
|
290
|
+
for (const rel of candidates) {
|
|
291
|
+
const full = path.join(here, rel);
|
|
292
|
+
try {
|
|
293
|
+
const imported = await import(`${pathToFileURL(full).href}?t=${Date.now()}`);
|
|
294
|
+
const definition = extractModuleDefinition(imported);
|
|
295
|
+
if (definition) {
|
|
296
|
+
return { definition, source: rel };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// ignore and continue
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
function extractModuleDefinition(exports) {
|
|
306
|
+
const keys = ['module', 'moduleDefinition', 'default', 'backendModule'];
|
|
307
|
+
for (const key of keys) {
|
|
308
|
+
if (key in exports) {
|
|
309
|
+
const value = exports[key];
|
|
310
|
+
if (value && typeof value === 'object') {
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ChildProcessByStdio } from 'node:child_process';
|
|
2
|
+
import type { Readable } from 'node:stream';
|
|
3
|
+
import type { DeploymentIo, PublishedWorkspaceMode } from './deploy-shared.js';
|
|
4
|
+
interface RuntimeProcessRecord {
|
|
5
|
+
readonly child: ChildProcessByStdio<null, Readable, Readable>;
|
|
6
|
+
readonly exitPromise: Promise<number | null>;
|
|
7
|
+
expectedExit: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function startBackendProcess(options: {
|
|
10
|
+
readonly workspaceRoot: string;
|
|
11
|
+
readonly backendEntry: string;
|
|
12
|
+
readonly port: number;
|
|
13
|
+
readonly env?: Record<string, string | undefined>;
|
|
14
|
+
readonly io: DeploymentIo;
|
|
15
|
+
}): RuntimeProcessRecord;
|
|
16
|
+
export declare function waitForRuntimeReady(port: number, exitPromise: Promise<number | null>): Promise<void>;
|
|
17
|
+
export declare function proxyRequest(request: Request, requestUrl: URL, proxyPath: string, backendOrigin: string, mode: PublishedWorkspaceMode): Promise<Response>;
|
|
18
|
+
export declare function shouldProxyToBackend(request: Request, pathname: string): boolean;
|
|
19
|
+
export declare function getFullWorkspaceProxyPath(pathname: string): string;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { resolveRuntimeCommand, textResponse } from './deploy-shared.js';
|
|
5
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
6
|
+
const READY_POLL_MS = 100;
|
|
7
|
+
const SOCKET_TIMEOUT_MS = 200;
|
|
8
|
+
export function startBackendProcess(options) {
|
|
9
|
+
const child = spawn(resolveRuntimeCommand(), [options.backendEntry], {
|
|
10
|
+
cwd: options.workspaceRoot,
|
|
11
|
+
env: {
|
|
12
|
+
...process.env,
|
|
13
|
+
...options.env,
|
|
14
|
+
PORT: String(options.port),
|
|
15
|
+
NODE_ENV: options.env?.NODE_ENV ?? process.env.NODE_ENV ?? 'production',
|
|
16
|
+
},
|
|
17
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
18
|
+
});
|
|
19
|
+
child.stdout.setEncoding('utf8');
|
|
20
|
+
child.stderr.setEncoding('utf8');
|
|
21
|
+
child.stdout.on('data', (chunk) => {
|
|
22
|
+
options.io.stdout.write(`[backend] ${chunk}`);
|
|
23
|
+
});
|
|
24
|
+
child.stderr.on('data', (chunk) => {
|
|
25
|
+
options.io.stderr.write(`[backend] ${chunk}`);
|
|
26
|
+
});
|
|
27
|
+
const exitPromise = new Promise((resolve, reject) => {
|
|
28
|
+
child.once('error', reject);
|
|
29
|
+
child.once('close', (code) => resolve(code));
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
child,
|
|
33
|
+
exitPromise,
|
|
34
|
+
expectedExit: false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function waitForRuntimeReady(port, exitPromise) {
|
|
38
|
+
const abortController = new AbortController();
|
|
39
|
+
try {
|
|
40
|
+
await Promise.race([
|
|
41
|
+
waitForPortOpen(port, abortController.signal),
|
|
42
|
+
exitPromise.then((code) => {
|
|
43
|
+
throw new Error(`Backend runtime exited before it became ready (code ${code ?? 'null'}).`);
|
|
44
|
+
}),
|
|
45
|
+
delay(READY_TIMEOUT_MS).then(() => {
|
|
46
|
+
throw new Error(`Backend runtime did not become ready within ${READY_TIMEOUT_MS}ms.`);
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
abortController.abort();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function proxyRequest(request, requestUrl, proxyPath, backendOrigin, mode) {
|
|
55
|
+
const targetUrl = new URL(proxyPath + requestUrl.search, backendOrigin);
|
|
56
|
+
try {
|
|
57
|
+
const requestInit = createProxyRequestInit(request, targetUrl);
|
|
58
|
+
const proxyResponse = await fetch(targetUrl, requestInit);
|
|
59
|
+
const headers = rewriteProxyResponseHeaders(proxyResponse.headers, targetUrl, mode);
|
|
60
|
+
return new Response(request.method !== 'HEAD' ? proxyResponse.body : null, {
|
|
61
|
+
status: proxyResponse.status,
|
|
62
|
+
headers,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return textResponse(502, 'Backend proxy failed.');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function shouldProxyToBackend(request, pathname) {
|
|
70
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
71
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return (pathname === '/api' ||
|
|
75
|
+
pathname === '/api/health' ||
|
|
76
|
+
pathname.startsWith('/api/') ||
|
|
77
|
+
pathname === '/healthz' ||
|
|
78
|
+
pathname === '/readyz' ||
|
|
79
|
+
pathname === '/metrics');
|
|
80
|
+
}
|
|
81
|
+
export function getFullWorkspaceProxyPath(pathname) {
|
|
82
|
+
if (pathname === '/healthz' || pathname === '/readyz' || pathname === '/metrics') {
|
|
83
|
+
return pathname;
|
|
84
|
+
}
|
|
85
|
+
if (pathname === '/api') {
|
|
86
|
+
return '/';
|
|
87
|
+
}
|
|
88
|
+
if (pathname === '/api/health') {
|
|
89
|
+
return '/api/health';
|
|
90
|
+
}
|
|
91
|
+
if (pathname.startsWith('/api/')) {
|
|
92
|
+
const normalizedPath = pathname.slice('/api'.length);
|
|
93
|
+
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
94
|
+
}
|
|
95
|
+
return pathname;
|
|
96
|
+
}
|
|
97
|
+
async function waitForPortOpen(port, signal) {
|
|
98
|
+
while (!signal.aborted) {
|
|
99
|
+
if (await canConnectToPort(port)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await delay(READY_POLL_MS, undefined, { signal }).catch(() => undefined);
|
|
103
|
+
}
|
|
104
|
+
throw new Error('Backend readiness check was aborted.');
|
|
105
|
+
}
|
|
106
|
+
async function canConnectToPort(port) {
|
|
107
|
+
return await new Promise((resolve) => {
|
|
108
|
+
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
109
|
+
let settled = false;
|
|
110
|
+
const finish = (value) => {
|
|
111
|
+
if (settled) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
settled = true;
|
|
115
|
+
socket.destroy();
|
|
116
|
+
resolve(value);
|
|
117
|
+
};
|
|
118
|
+
socket.setTimeout(SOCKET_TIMEOUT_MS);
|
|
119
|
+
socket.once('connect', () => finish(true));
|
|
120
|
+
socket.once('timeout', () => finish(false));
|
|
121
|
+
socket.once('error', () => finish(false));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function rewriteProxyResponseHeaders(headers, targetUrl, mode) {
|
|
125
|
+
const nextHeaders = new Headers(headers);
|
|
126
|
+
const location = headers.get('location');
|
|
127
|
+
if (!location) {
|
|
128
|
+
return nextHeaders;
|
|
129
|
+
}
|
|
130
|
+
nextHeaders.set('location', rewriteProxyLocation(location, targetUrl, mode));
|
|
131
|
+
return nextHeaders;
|
|
132
|
+
}
|
|
133
|
+
function rewriteProxyLocation(value, targetUrl, mode) {
|
|
134
|
+
const trimmed = value.trim();
|
|
135
|
+
if (!trimmed || mode === 'api') {
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
if (trimmed.startsWith('/')) {
|
|
139
|
+
return prefixApiMount(trimmed);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const resolved = new URL(trimmed, targetUrl.origin);
|
|
143
|
+
if (resolved.origin !== targetUrl.origin) {
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
return prefixApiMount(`${resolved.pathname}${resolved.search}${resolved.hash}`);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function prefixApiMount(pathname) {
|
|
153
|
+
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
154
|
+
return pathname;
|
|
155
|
+
}
|
|
156
|
+
return pathname === '/' ? '/api' : `/api${pathname}`;
|
|
157
|
+
}
|
|
158
|
+
function createProxyRequestInit(request, targetUrl) {
|
|
159
|
+
const headers = new Headers(request.headers);
|
|
160
|
+
headers.set('host', targetUrl.host);
|
|
161
|
+
headers.set('connection', 'close');
|
|
162
|
+
const requestInit = {
|
|
163
|
+
method: request.method,
|
|
164
|
+
headers,
|
|
165
|
+
redirect: 'manual',
|
|
166
|
+
signal: request.signal,
|
|
167
|
+
};
|
|
168
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
169
|
+
requestInit.body = request.body;
|
|
170
|
+
if (request.body) {
|
|
171
|
+
requestInit.duplex = 'half';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return requestInit;
|
|
175
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface PublishedWorkspaceServerOptions {
|
|
2
|
+
readonly workspaceRoot: string;
|
|
3
|
+
readonly host?: string;
|
|
4
|
+
readonly port?: number;
|
|
5
|
+
readonly env?: Record<string, string | undefined>;
|
|
6
|
+
readonly io?: DeploymentIo;
|
|
7
|
+
}
|
|
8
|
+
export interface PublishedWorkspaceServer {
|
|
9
|
+
readonly origin: string;
|
|
10
|
+
readonly mode: PublishedWorkspaceMode;
|
|
11
|
+
stop(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface DeploymentIo {
|
|
14
|
+
readonly stdout: {
|
|
15
|
+
write(message: string): void;
|
|
16
|
+
};
|
|
17
|
+
readonly stderr: {
|
|
18
|
+
write(message: string): void;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export type PublishedWorkspaceMode = 'api' | 'full';
|
|
22
|
+
export interface BunServerLike {
|
|
23
|
+
readonly port: number;
|
|
24
|
+
stop(closeActiveConnections?: boolean): void;
|
|
25
|
+
}
|
|
26
|
+
export interface BunLike {
|
|
27
|
+
serve(options: {
|
|
28
|
+
readonly port: number;
|
|
29
|
+
readonly hostname?: string;
|
|
30
|
+
readonly idleTimeout?: number;
|
|
31
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
32
|
+
error?(error: Error): Response | Promise<Response>;
|
|
33
|
+
}): BunServerLike;
|
|
34
|
+
file(pathname: string): Blob;
|
|
35
|
+
}
|
|
36
|
+
export declare const DEFAULT_PUBLIC_PORT = 8080;
|
|
37
|
+
export declare function readPublishedWorkspaceMode(workspaceRoot: string): Promise<PublishedWorkspaceMode>;
|
|
38
|
+
export declare function assertExists(targetPath: string, label: string): Promise<void>;
|
|
39
|
+
export declare function getOpenPort(): Promise<number>;
|
|
40
|
+
export declare function textResponse(statusCode: number, body: string): Response;
|
|
41
|
+
export declare function resolveRuntimeCommand(): string;
|
|
42
|
+
export declare function requireBunRuntime(): BunLike;
|
|
43
|
+
export declare const defaultIo: DeploymentIo;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { access, readFile } from 'node:fs/promises';
|
|
4
|
+
export const DEFAULT_PUBLIC_PORT = 8080;
|
|
5
|
+
export async function readPublishedWorkspaceMode(workspaceRoot) {
|
|
6
|
+
const packageJsonPath = path.join(workspaceRoot, 'package.json');
|
|
7
|
+
const source = await readFile(packageJsonPath, 'utf8');
|
|
8
|
+
const packageJson = JSON.parse(source);
|
|
9
|
+
const mode = packageJson.webstir?.mode;
|
|
10
|
+
if (mode === 'api' || mode === 'full') {
|
|
11
|
+
return mode;
|
|
12
|
+
}
|
|
13
|
+
throw new Error(`Published deploy only supports api and full workspaces. Received ${JSON.stringify(mode)} in ${packageJsonPath}.`);
|
|
14
|
+
}
|
|
15
|
+
export async function assertExists(targetPath, label) {
|
|
16
|
+
try {
|
|
17
|
+
await access(targetPath);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error(`Expected ${label} at ${targetPath}.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function getOpenPort() {
|
|
24
|
+
return await new Promise((resolve, reject) => {
|
|
25
|
+
const server = net.createServer();
|
|
26
|
+
server.once('error', reject);
|
|
27
|
+
server.listen(0, '127.0.0.1', () => {
|
|
28
|
+
const address = server.address();
|
|
29
|
+
if (!address || typeof address === 'string') {
|
|
30
|
+
reject(new Error('Failed to allocate an open port.'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
server.close((error) => {
|
|
34
|
+
if (error) {
|
|
35
|
+
reject(error);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
resolve(address.port);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function textResponse(statusCode, body) {
|
|
44
|
+
return new Response(body, {
|
|
45
|
+
status: statusCode,
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function resolveRuntimeCommand() {
|
|
52
|
+
if (typeof process.versions.bun === 'string') {
|
|
53
|
+
return process.execPath;
|
|
54
|
+
}
|
|
55
|
+
return 'bun';
|
|
56
|
+
}
|
|
57
|
+
export function requireBunRuntime() {
|
|
58
|
+
const bun = globalThis.Bun;
|
|
59
|
+
if (!bun?.serve || !bun.file) {
|
|
60
|
+
throw new Error('Published Webstir deploy requires Bun at runtime.');
|
|
61
|
+
}
|
|
62
|
+
return bun;
|
|
63
|
+
}
|
|
64
|
+
export const defaultIo = {
|
|
65
|
+
stdout: {
|
|
66
|
+
write(message) {
|
|
67
|
+
process.stdout.write(message);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
stderr: {
|
|
71
|
+
write(message) {
|
|
72
|
+
process.stderr.write(message);
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|