@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,239 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { spawn, type ChildProcessByStdio } from 'node:child_process';
|
|
3
|
+
import type { Readable } from 'node:stream';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
|
|
6
|
+
import type { DeploymentIo, PublishedWorkspaceMode } from './deploy-shared.js';
|
|
7
|
+
import { resolveRuntimeCommand, textResponse } from './deploy-shared.js';
|
|
8
|
+
|
|
9
|
+
interface RuntimeProcessRecord {
|
|
10
|
+
readonly child: ChildProcessByStdio<null, Readable, Readable>;
|
|
11
|
+
readonly exitPromise: Promise<number | null>;
|
|
12
|
+
expectedExit: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
16
|
+
const READY_POLL_MS = 100;
|
|
17
|
+
const SOCKET_TIMEOUT_MS = 200;
|
|
18
|
+
|
|
19
|
+
export function startBackendProcess(options: {
|
|
20
|
+
readonly workspaceRoot: string;
|
|
21
|
+
readonly backendEntry: string;
|
|
22
|
+
readonly port: number;
|
|
23
|
+
readonly env?: Record<string, string | undefined>;
|
|
24
|
+
readonly io: DeploymentIo;
|
|
25
|
+
}): RuntimeProcessRecord {
|
|
26
|
+
const child = spawn(resolveRuntimeCommand(), [options.backendEntry], {
|
|
27
|
+
cwd: options.workspaceRoot,
|
|
28
|
+
env: {
|
|
29
|
+
...process.env,
|
|
30
|
+
...options.env,
|
|
31
|
+
PORT: String(options.port),
|
|
32
|
+
NODE_ENV: options.env?.NODE_ENV ?? process.env.NODE_ENV ?? 'production',
|
|
33
|
+
},
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
child.stdout.setEncoding('utf8');
|
|
38
|
+
child.stderr.setEncoding('utf8');
|
|
39
|
+
child.stdout.on('data', (chunk: string) => {
|
|
40
|
+
options.io.stdout.write(`[backend] ${chunk}`);
|
|
41
|
+
});
|
|
42
|
+
child.stderr.on('data', (chunk: string) => {
|
|
43
|
+
options.io.stderr.write(`[backend] ${chunk}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const exitPromise = new Promise<number | null>((resolve, reject) => {
|
|
47
|
+
child.once('error', reject);
|
|
48
|
+
child.once('close', (code) => resolve(code));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
child,
|
|
53
|
+
exitPromise,
|
|
54
|
+
expectedExit: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function waitForRuntimeReady(
|
|
59
|
+
port: number,
|
|
60
|
+
exitPromise: Promise<number | null>,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const abortController = new AbortController();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await Promise.race([
|
|
66
|
+
waitForPortOpen(port, abortController.signal),
|
|
67
|
+
exitPromise.then((code) => {
|
|
68
|
+
throw new Error(`Backend runtime exited before it became ready (code ${code ?? 'null'}).`);
|
|
69
|
+
}),
|
|
70
|
+
delay(READY_TIMEOUT_MS).then(() => {
|
|
71
|
+
throw new Error(`Backend runtime did not become ready within ${READY_TIMEOUT_MS}ms.`);
|
|
72
|
+
}),
|
|
73
|
+
]);
|
|
74
|
+
} finally {
|
|
75
|
+
abortController.abort();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function proxyRequest(
|
|
80
|
+
request: Request,
|
|
81
|
+
requestUrl: URL,
|
|
82
|
+
proxyPath: string,
|
|
83
|
+
backendOrigin: string,
|
|
84
|
+
mode: PublishedWorkspaceMode,
|
|
85
|
+
): Promise<Response> {
|
|
86
|
+
const targetUrl = new URL(proxyPath + requestUrl.search, backendOrigin);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const requestInit = createProxyRequestInit(request, targetUrl);
|
|
90
|
+
const proxyResponse = await fetch(targetUrl, requestInit);
|
|
91
|
+
const headers = rewriteProxyResponseHeaders(proxyResponse.headers, targetUrl, mode);
|
|
92
|
+
|
|
93
|
+
return new Response(request.method !== 'HEAD' ? proxyResponse.body : null, {
|
|
94
|
+
status: proxyResponse.status,
|
|
95
|
+
headers,
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
98
|
+
return textResponse(502, 'Backend proxy failed.');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function shouldProxyToBackend(request: Request, pathname: string): boolean {
|
|
103
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
104
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
pathname === '/api' ||
|
|
110
|
+
pathname === '/api/health' ||
|
|
111
|
+
pathname.startsWith('/api/') ||
|
|
112
|
+
pathname === '/healthz' ||
|
|
113
|
+
pathname === '/readyz' ||
|
|
114
|
+
pathname === '/metrics'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getFullWorkspaceProxyPath(pathname: string): string {
|
|
119
|
+
if (pathname === '/healthz' || pathname === '/readyz' || pathname === '/metrics') {
|
|
120
|
+
return pathname;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (pathname === '/api') {
|
|
124
|
+
return '/';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (pathname === '/api/health') {
|
|
128
|
+
return '/api/health';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (pathname.startsWith('/api/')) {
|
|
132
|
+
const normalizedPath = pathname.slice('/api'.length);
|
|
133
|
+
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return pathname;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function waitForPortOpen(port: number, signal: AbortSignal): Promise<void> {
|
|
140
|
+
while (!signal.aborted) {
|
|
141
|
+
if (await canConnectToPort(port)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await delay(READY_POLL_MS, undefined, { signal }).catch(() => undefined);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw new Error('Backend readiness check was aborted.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function canConnectToPort(port: number): Promise<boolean> {
|
|
152
|
+
return await new Promise((resolve) => {
|
|
153
|
+
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
154
|
+
let settled = false;
|
|
155
|
+
|
|
156
|
+
const finish = (value: boolean) => {
|
|
157
|
+
if (settled) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
settled = true;
|
|
161
|
+
socket.destroy();
|
|
162
|
+
resolve(value);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
socket.setTimeout(SOCKET_TIMEOUT_MS);
|
|
166
|
+
socket.once('connect', () => finish(true));
|
|
167
|
+
socket.once('timeout', () => finish(false));
|
|
168
|
+
socket.once('error', () => finish(false));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function rewriteProxyResponseHeaders(
|
|
173
|
+
headers: Headers,
|
|
174
|
+
targetUrl: URL,
|
|
175
|
+
mode: PublishedWorkspaceMode,
|
|
176
|
+
): Headers {
|
|
177
|
+
const nextHeaders = new Headers(headers);
|
|
178
|
+
const location = headers.get('location');
|
|
179
|
+
if (!location) {
|
|
180
|
+
return nextHeaders;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
nextHeaders.set('location', rewriteProxyLocation(location, targetUrl, mode));
|
|
184
|
+
return nextHeaders;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function rewriteProxyLocation(value: string, targetUrl: URL, mode: PublishedWorkspaceMode): string {
|
|
188
|
+
const trimmed = value.trim();
|
|
189
|
+
if (!trimmed || mode === 'api') {
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (trimmed.startsWith('/')) {
|
|
194
|
+
return prefixApiMount(trimmed);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const resolved = new URL(trimmed, targetUrl.origin);
|
|
199
|
+
if (resolved.origin !== targetUrl.origin) {
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
return prefixApiMount(`${resolved.pathname}${resolved.search}${resolved.hash}`);
|
|
203
|
+
} catch {
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function prefixApiMount(pathname: string): string {
|
|
209
|
+
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
210
|
+
return pathname;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return pathname === '/' ? '/api' : `/api${pathname}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function createProxyRequestInit(
|
|
217
|
+
request: Request,
|
|
218
|
+
targetUrl: URL,
|
|
219
|
+
): RequestInit & { duplex?: 'half' } {
|
|
220
|
+
const headers = new Headers(request.headers);
|
|
221
|
+
headers.set('host', targetUrl.host);
|
|
222
|
+
headers.set('connection', 'close');
|
|
223
|
+
|
|
224
|
+
const requestInit: RequestInit & { duplex?: 'half' } = {
|
|
225
|
+
method: request.method,
|
|
226
|
+
headers,
|
|
227
|
+
redirect: 'manual',
|
|
228
|
+
signal: request.signal,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
232
|
+
requestInit.body = request.body;
|
|
233
|
+
if (request.body) {
|
|
234
|
+
requestInit.duplex = 'half';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return requestInit;
|
|
239
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { access, readFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
export interface PublishedWorkspaceServerOptions {
|
|
6
|
+
readonly workspaceRoot: string;
|
|
7
|
+
readonly host?: string;
|
|
8
|
+
readonly port?: number;
|
|
9
|
+
readonly env?: Record<string, string | undefined>;
|
|
10
|
+
readonly io?: DeploymentIo;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PublishedWorkspaceServer {
|
|
14
|
+
readonly origin: string;
|
|
15
|
+
readonly mode: PublishedWorkspaceMode;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DeploymentIo {
|
|
20
|
+
readonly stdout: {
|
|
21
|
+
write(message: string): void;
|
|
22
|
+
};
|
|
23
|
+
readonly stderr: {
|
|
24
|
+
write(message: string): void;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PublishedWorkspaceMode = 'api' | 'full';
|
|
29
|
+
|
|
30
|
+
export interface BunServerLike {
|
|
31
|
+
readonly port: number;
|
|
32
|
+
stop(closeActiveConnections?: boolean): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BunLike {
|
|
36
|
+
serve(options: {
|
|
37
|
+
readonly port: number;
|
|
38
|
+
readonly hostname?: string;
|
|
39
|
+
readonly idleTimeout?: number;
|
|
40
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
41
|
+
error?(error: Error): Response | Promise<Response>;
|
|
42
|
+
}): BunServerLike;
|
|
43
|
+
file(pathname: string): Blob;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_PUBLIC_PORT = 8080;
|
|
47
|
+
|
|
48
|
+
export async function readPublishedWorkspaceMode(
|
|
49
|
+
workspaceRoot: string,
|
|
50
|
+
): Promise<PublishedWorkspaceMode> {
|
|
51
|
+
const packageJsonPath = path.join(workspaceRoot, 'package.json');
|
|
52
|
+
const source = await readFile(packageJsonPath, 'utf8');
|
|
53
|
+
const packageJson = JSON.parse(source) as {
|
|
54
|
+
webstir?: {
|
|
55
|
+
mode?: string;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const mode = packageJson.webstir?.mode;
|
|
59
|
+
if (mode === 'api' || mode === 'full') {
|
|
60
|
+
return mode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Published deploy only supports api and full workspaces. Received ${JSON.stringify(mode)} in ${packageJsonPath}.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function assertExists(targetPath: string, label: string): Promise<void> {
|
|
69
|
+
try {
|
|
70
|
+
await access(targetPath);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error(`Expected ${label} at ${targetPath}.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function getOpenPort(): Promise<number> {
|
|
77
|
+
return await new Promise((resolve, reject) => {
|
|
78
|
+
const server = net.createServer();
|
|
79
|
+
server.once('error', reject);
|
|
80
|
+
server.listen(0, '127.0.0.1', () => {
|
|
81
|
+
const address = server.address();
|
|
82
|
+
if (!address || typeof address === 'string') {
|
|
83
|
+
reject(new Error('Failed to allocate an open port.'));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
server.close((error) => {
|
|
88
|
+
if (error) {
|
|
89
|
+
reject(error);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
resolve(address.port);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function textResponse(statusCode: number, body: string): Response {
|
|
100
|
+
return new Response(body, {
|
|
101
|
+
status: statusCode,
|
|
102
|
+
headers: {
|
|
103
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveRuntimeCommand(): string {
|
|
109
|
+
if (typeof process.versions.bun === 'string') {
|
|
110
|
+
return process.execPath;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return 'bun';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function requireBunRuntime(): BunLike {
|
|
117
|
+
const bun = (globalThis as typeof globalThis & { Bun?: BunLike }).Bun;
|
|
118
|
+
if (!bun?.serve || !bun.file) {
|
|
119
|
+
throw new Error('Published Webstir deploy requires Bun at runtime.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return bun;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const defaultIo: DeploymentIo = {
|
|
126
|
+
stdout: {
|
|
127
|
+
write(message) {
|
|
128
|
+
process.stdout.write(message);
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
stderr: {
|
|
132
|
+
write(message) {
|
|
133
|
+
process.stderr.write(message);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import { requireBunRuntime, textResponse } from './deploy-shared.js';
|
|
5
|
+
|
|
6
|
+
const MIME_TYPES: Record<string, string> = {
|
|
7
|
+
'.html': 'text/html; charset=utf-8',
|
|
8
|
+
'.css': 'text/css; charset=utf-8',
|
|
9
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
11
|
+
'.json': 'application/json; charset=utf-8',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
'.png': 'image/png',
|
|
14
|
+
'.jpg': 'image/jpeg',
|
|
15
|
+
'.jpeg': 'image/jpeg',
|
|
16
|
+
'.webp': 'image/webp',
|
|
17
|
+
'.gif': 'image/gif',
|
|
18
|
+
'.ico': 'image/x-icon',
|
|
19
|
+
'.woff': 'font/woff',
|
|
20
|
+
'.woff2': 'font/woff2',
|
|
21
|
+
'.ttf': 'font/ttf',
|
|
22
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
23
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
24
|
+
'.map': 'application/json; charset=utf-8',
|
|
25
|
+
};
|
|
26
|
+
const RESERVED_PREFIXES = ['__webstir', 'api', 'fonts', 'images', 'media', 'pages', 'sse'];
|
|
27
|
+
const STATIC_EXTENSIONS = new Set([
|
|
28
|
+
'.css',
|
|
29
|
+
'.js',
|
|
30
|
+
'.mjs',
|
|
31
|
+
'.png',
|
|
32
|
+
'.jpg',
|
|
33
|
+
'.jpeg',
|
|
34
|
+
'.gif',
|
|
35
|
+
'.svg',
|
|
36
|
+
'.webp',
|
|
37
|
+
'.ico',
|
|
38
|
+
'.woff',
|
|
39
|
+
'.woff2',
|
|
40
|
+
'.ttf',
|
|
41
|
+
'.otf',
|
|
42
|
+
'.eot',
|
|
43
|
+
'.mp3',
|
|
44
|
+
'.m4a',
|
|
45
|
+
'.wav',
|
|
46
|
+
'.ogg',
|
|
47
|
+
'.mp4',
|
|
48
|
+
'.webm',
|
|
49
|
+
'.mov',
|
|
50
|
+
'.json',
|
|
51
|
+
'.txt',
|
|
52
|
+
'.xml',
|
|
53
|
+
'.map',
|
|
54
|
+
]);
|
|
55
|
+
const CONTENT_HASH_PATTERN =
|
|
56
|
+
/\.[a-f0-9]{8,64}\.(css|js|png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp3|m4a|wav|ogg|mp4|webm|mov)$/i;
|
|
57
|
+
|
|
58
|
+
export async function servePublishedStaticFile(
|
|
59
|
+
request: Request,
|
|
60
|
+
frontendRoot: string,
|
|
61
|
+
): Promise<Response> {
|
|
62
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
63
|
+
return textResponse(405, 'Method not allowed.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const requestUrl = new URL(request.url);
|
|
67
|
+
const candidates = getStaticCandidatePaths(requestUrl.pathname);
|
|
68
|
+
const resolved = await resolveStaticFile(frontendRoot, candidates);
|
|
69
|
+
if (!resolved) {
|
|
70
|
+
return textResponse(404, 'Not found.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lowerRelativePath = resolved.relativePath.toLowerCase();
|
|
74
|
+
const extension = path.extname(lowerRelativePath).toLowerCase();
|
|
75
|
+
const headers = new Headers({
|
|
76
|
+
'Content-Type': MIME_TYPES[extension] ?? 'application/octet-stream',
|
|
77
|
+
});
|
|
78
|
+
setCacheHeaders(headers, lowerRelativePath);
|
|
79
|
+
|
|
80
|
+
if (request.method === 'HEAD') {
|
|
81
|
+
return new Response(null, {
|
|
82
|
+
status: 200,
|
|
83
|
+
headers,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Response(requireBunRuntime().file(resolved.absolutePath), {
|
|
88
|
+
status: 200,
|
|
89
|
+
headers,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getStaticCandidatePaths(pathname: string): readonly string[] {
|
|
94
|
+
const relativePath = normalizeRequestPath(pathname);
|
|
95
|
+
const candidates: string[] = [];
|
|
96
|
+
|
|
97
|
+
if (relativePath) {
|
|
98
|
+
candidates.push(...getGenericFileCandidates(relativePath));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (relativePath === '') {
|
|
102
|
+
candidates.push('pages/home/index.html');
|
|
103
|
+
} else if (/^index\.(?!html$)[^/]+$/i.test(relativePath)) {
|
|
104
|
+
candidates.push(path.posix.join('pages', 'home', relativePath));
|
|
105
|
+
} else if (/^[^/]+\/index\.(js|css)$/i.test(relativePath)) {
|
|
106
|
+
const [pageName, fileName] = relativePath.split('/');
|
|
107
|
+
candidates.push(path.posix.join('pages', pageName, fileName));
|
|
108
|
+
} else if (!path.posix.extname(relativePath) && !hasReservedPrefix(relativePath)) {
|
|
109
|
+
candidates.push(path.posix.join('pages', relativePath, 'index.html'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return Array.from(new Set(candidates.map((candidate) => candidate.replace(/^\/+/, ''))));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function resolveStaticFile(
|
|
116
|
+
buildRoot: string,
|
|
117
|
+
relativePaths: readonly string[],
|
|
118
|
+
): Promise<{ absolutePath: string; relativePath: string } | null> {
|
|
119
|
+
for (const relativePath of relativePaths) {
|
|
120
|
+
const absolutePath = path.resolve(buildRoot, relativePath);
|
|
121
|
+
if (!absolutePath.startsWith(buildRoot + path.sep) && absolutePath !== buildRoot) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await access(absolutePath);
|
|
127
|
+
return { absolutePath, relativePath };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (isMissingStaticCandidate(error)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isMissingStaticCandidate(error: unknown): boolean {
|
|
141
|
+
if (!error || typeof error !== 'object' || !('code' in error)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return error.code === 'ENOENT' || error.code === 'ENOTDIR';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeRequestPath(pathname: string): string {
|
|
149
|
+
const decoded = decodeURIComponent(pathname);
|
|
150
|
+
const normalized = path.posix.normalize(decoded);
|
|
151
|
+
const stripped = normalized.replace(/^(\.\.(\/|\\|$))+/, '').replace(/^\/+/, '');
|
|
152
|
+
return stripped.replace(/\/+$/, '');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getGenericFileCandidates(relativePath: string): readonly string[] {
|
|
156
|
+
const hasExtension = path.posix.extname(relativePath) !== '';
|
|
157
|
+
const candidates = hasExtension
|
|
158
|
+
? [relativePath]
|
|
159
|
+
: [relativePath, `${relativePath}.html`, path.posix.join(relativePath, 'index.html')];
|
|
160
|
+
|
|
161
|
+
return candidates;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hasReservedPrefix(relativePath: string): boolean {
|
|
165
|
+
return RESERVED_PREFIXES.some(
|
|
166
|
+
(prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function setCacheHeaders(headers: Headers, relativePath: string): void {
|
|
171
|
+
if (CONTENT_HASH_PATTERN.test(relativePath)) {
|
|
172
|
+
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const extension = path.extname(relativePath).toLowerCase();
|
|
177
|
+
if (extension === '.html' || extension === '') {
|
|
178
|
+
setNoCacheHeaders(headers);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (STATIC_EXTENSIONS.has(extension)) {
|
|
183
|
+
headers.set('Cache-Control', 'no-cache, must-revalidate');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function setNoCacheHeaders(headers: Headers): void {
|
|
188
|
+
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
189
|
+
headers.set('Pragma', 'no-cache');
|
|
190
|
+
headers.set('Expires', '0');
|
|
191
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
getFullWorkspaceProxyPath,
|
|
4
|
+
proxyRequest,
|
|
5
|
+
shouldProxyToBackend,
|
|
6
|
+
startBackendProcess,
|
|
7
|
+
waitForRuntimeReady,
|
|
8
|
+
} from './deploy-backend.js';
|
|
9
|
+
import {
|
|
10
|
+
assertExists,
|
|
11
|
+
DEFAULT_PUBLIC_PORT,
|
|
12
|
+
defaultIo,
|
|
13
|
+
getOpenPort,
|
|
14
|
+
readPublishedWorkspaceMode,
|
|
15
|
+
requireBunRuntime,
|
|
16
|
+
textResponse,
|
|
17
|
+
type DeploymentIo,
|
|
18
|
+
type PublishedWorkspaceMode,
|
|
19
|
+
type PublishedWorkspaceServer,
|
|
20
|
+
type PublishedWorkspaceServerOptions,
|
|
21
|
+
} from './deploy-shared.js';
|
|
22
|
+
import { servePublishedStaticFile } from './deploy-static.js';
|
|
23
|
+
|
|
24
|
+
export type { DeploymentIo, PublishedWorkspaceServer, PublishedWorkspaceServerOptions };
|
|
25
|
+
|
|
26
|
+
export async function startPublishedWorkspaceServer(
|
|
27
|
+
options: PublishedWorkspaceServerOptions,
|
|
28
|
+
): Promise<PublishedWorkspaceServer> {
|
|
29
|
+
const bun = requireBunRuntime();
|
|
30
|
+
const workspaceRoot = path.resolve(options.workspaceRoot);
|
|
31
|
+
const io = options.io ?? defaultIo;
|
|
32
|
+
const mode = await readPublishedWorkspaceMode(workspaceRoot);
|
|
33
|
+
const frontendRoot = mode === 'full' ? path.join(workspaceRoot, 'dist', 'frontend') : undefined;
|
|
34
|
+
const backendEntry = path.join(workspaceRoot, 'build', 'backend', 'index.js');
|
|
35
|
+
|
|
36
|
+
await assertExists(backendEntry, 'published backend entry');
|
|
37
|
+
if (frontendRoot) {
|
|
38
|
+
await assertExists(frontendRoot, 'published frontend output');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const internalPort = await getOpenPort();
|
|
42
|
+
const processRecord = startBackendProcess({
|
|
43
|
+
workspaceRoot,
|
|
44
|
+
backendEntry,
|
|
45
|
+
port: internalPort,
|
|
46
|
+
env: options.env,
|
|
47
|
+
io,
|
|
48
|
+
});
|
|
49
|
+
const backendOrigin = `http://127.0.0.1:${internalPort}`;
|
|
50
|
+
let stopping = false;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await waitForRuntimeReady(internalPort, processRecord.exitPromise);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
processRecord.expectedExit = true;
|
|
56
|
+
processRecord.child.kill('SIGTERM');
|
|
57
|
+
await processRecord.exitPromise.catch(() => undefined);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const host = options.host ?? '0.0.0.0';
|
|
62
|
+
const requestedPort = options.port ?? DEFAULT_PUBLIC_PORT;
|
|
63
|
+
const server = bun.serve({
|
|
64
|
+
hostname: host,
|
|
65
|
+
idleTimeout: 0,
|
|
66
|
+
port: requestedPort,
|
|
67
|
+
fetch: async (request) =>
|
|
68
|
+
await handlePublishedWorkspaceRequest({
|
|
69
|
+
request,
|
|
70
|
+
mode,
|
|
71
|
+
frontendRoot,
|
|
72
|
+
backendOrigin,
|
|
73
|
+
}),
|
|
74
|
+
error: (error) => textResponse(500, error.message),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
processRecord.exitPromise
|
|
78
|
+
.then((code) => {
|
|
79
|
+
if (stopping || processRecord.expectedExit) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
io.stderr.write(
|
|
84
|
+
`[webstir-backend-deploy] backend runtime exited unexpectedly with code ${code ?? 'null'}.\n`,
|
|
85
|
+
);
|
|
86
|
+
server.stop(true);
|
|
87
|
+
})
|
|
88
|
+
.catch((error) => {
|
|
89
|
+
if (stopping) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
io.stderr.write(
|
|
94
|
+
`[webstir-backend-deploy] backend runtime failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
95
|
+
);
|
|
96
|
+
server.stop(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const displayHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
origin: `http://${displayHost}:${server.port}`,
|
|
103
|
+
mode,
|
|
104
|
+
async stop() {
|
|
105
|
+
stopping = true;
|
|
106
|
+
server.stop(true);
|
|
107
|
+
processRecord.expectedExit = true;
|
|
108
|
+
processRecord.child.kill('SIGTERM');
|
|
109
|
+
await processRecord.exitPromise.catch(() => undefined);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function handlePublishedWorkspaceRequest(options: {
|
|
115
|
+
readonly request: Request;
|
|
116
|
+
readonly mode: PublishedWorkspaceMode;
|
|
117
|
+
readonly frontendRoot?: string;
|
|
118
|
+
readonly backendOrigin: string;
|
|
119
|
+
}): Promise<Response> {
|
|
120
|
+
const requestUrl = new URL(options.request.url);
|
|
121
|
+
const pathname = requestUrl.pathname;
|
|
122
|
+
|
|
123
|
+
if (options.mode === 'api') {
|
|
124
|
+
return await proxyRequest(options.request, requestUrl, pathname, options.backendOrigin, 'api');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (shouldProxyToBackend(options.request, pathname)) {
|
|
128
|
+
const proxyPath = getFullWorkspaceProxyPath(pathname);
|
|
129
|
+
return await proxyRequest(
|
|
130
|
+
options.request,
|
|
131
|
+
requestUrl,
|
|
132
|
+
proxyPath,
|
|
133
|
+
options.backendOrigin,
|
|
134
|
+
'full',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!options.frontendRoot) {
|
|
139
|
+
return textResponse(500, 'Published frontend output is not available.');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return await servePublishedStaticFile(options.request, options.frontendRoot);
|
|
143
|
+
}
|