@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,161 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import { requireBunRuntime, textResponse } from './deploy-shared.js';
|
|
4
|
+
const MIME_TYPES = {
|
|
5
|
+
'.html': 'text/html; charset=utf-8',
|
|
6
|
+
'.css': 'text/css; charset=utf-8',
|
|
7
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
8
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
9
|
+
'.json': 'application/json; charset=utf-8',
|
|
10
|
+
'.svg': 'image/svg+xml',
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.jpeg': 'image/jpeg',
|
|
14
|
+
'.webp': 'image/webp',
|
|
15
|
+
'.gif': 'image/gif',
|
|
16
|
+
'.ico': 'image/x-icon',
|
|
17
|
+
'.woff': 'font/woff',
|
|
18
|
+
'.woff2': 'font/woff2',
|
|
19
|
+
'.ttf': 'font/ttf',
|
|
20
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
21
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
22
|
+
'.map': 'application/json; charset=utf-8',
|
|
23
|
+
};
|
|
24
|
+
const RESERVED_PREFIXES = ['__webstir', 'api', 'fonts', 'images', 'media', 'pages', 'sse'];
|
|
25
|
+
const STATIC_EXTENSIONS = new Set([
|
|
26
|
+
'.css',
|
|
27
|
+
'.js',
|
|
28
|
+
'.mjs',
|
|
29
|
+
'.png',
|
|
30
|
+
'.jpg',
|
|
31
|
+
'.jpeg',
|
|
32
|
+
'.gif',
|
|
33
|
+
'.svg',
|
|
34
|
+
'.webp',
|
|
35
|
+
'.ico',
|
|
36
|
+
'.woff',
|
|
37
|
+
'.woff2',
|
|
38
|
+
'.ttf',
|
|
39
|
+
'.otf',
|
|
40
|
+
'.eot',
|
|
41
|
+
'.mp3',
|
|
42
|
+
'.m4a',
|
|
43
|
+
'.wav',
|
|
44
|
+
'.ogg',
|
|
45
|
+
'.mp4',
|
|
46
|
+
'.webm',
|
|
47
|
+
'.mov',
|
|
48
|
+
'.json',
|
|
49
|
+
'.txt',
|
|
50
|
+
'.xml',
|
|
51
|
+
'.map',
|
|
52
|
+
]);
|
|
53
|
+
const CONTENT_HASH_PATTERN = /\.[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;
|
|
54
|
+
export async function servePublishedStaticFile(request, frontendRoot) {
|
|
55
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
56
|
+
return textResponse(405, 'Method not allowed.');
|
|
57
|
+
}
|
|
58
|
+
const requestUrl = new URL(request.url);
|
|
59
|
+
const candidates = getStaticCandidatePaths(requestUrl.pathname);
|
|
60
|
+
const resolved = await resolveStaticFile(frontendRoot, candidates);
|
|
61
|
+
if (!resolved) {
|
|
62
|
+
return textResponse(404, 'Not found.');
|
|
63
|
+
}
|
|
64
|
+
const lowerRelativePath = resolved.relativePath.toLowerCase();
|
|
65
|
+
const extension = path.extname(lowerRelativePath).toLowerCase();
|
|
66
|
+
const headers = new Headers({
|
|
67
|
+
'Content-Type': MIME_TYPES[extension] ?? 'application/octet-stream',
|
|
68
|
+
});
|
|
69
|
+
setCacheHeaders(headers, lowerRelativePath);
|
|
70
|
+
if (request.method === 'HEAD') {
|
|
71
|
+
return new Response(null, {
|
|
72
|
+
status: 200,
|
|
73
|
+
headers,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return new Response(requireBunRuntime().file(resolved.absolutePath), {
|
|
77
|
+
status: 200,
|
|
78
|
+
headers,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function getStaticCandidatePaths(pathname) {
|
|
82
|
+
const relativePath = normalizeRequestPath(pathname);
|
|
83
|
+
const candidates = [];
|
|
84
|
+
if (relativePath) {
|
|
85
|
+
candidates.push(...getGenericFileCandidates(relativePath));
|
|
86
|
+
}
|
|
87
|
+
if (relativePath === '') {
|
|
88
|
+
candidates.push('pages/home/index.html');
|
|
89
|
+
}
|
|
90
|
+
else if (/^index\.(?!html$)[^/]+$/i.test(relativePath)) {
|
|
91
|
+
candidates.push(path.posix.join('pages', 'home', relativePath));
|
|
92
|
+
}
|
|
93
|
+
else if (/^[^/]+\/index\.(js|css)$/i.test(relativePath)) {
|
|
94
|
+
const [pageName, fileName] = relativePath.split('/');
|
|
95
|
+
candidates.push(path.posix.join('pages', pageName, fileName));
|
|
96
|
+
}
|
|
97
|
+
else if (!path.posix.extname(relativePath) && !hasReservedPrefix(relativePath)) {
|
|
98
|
+
candidates.push(path.posix.join('pages', relativePath, 'index.html'));
|
|
99
|
+
}
|
|
100
|
+
return Array.from(new Set(candidates.map((candidate) => candidate.replace(/^\/+/, ''))));
|
|
101
|
+
}
|
|
102
|
+
async function resolveStaticFile(buildRoot, relativePaths) {
|
|
103
|
+
for (const relativePath of relativePaths) {
|
|
104
|
+
const absolutePath = path.resolve(buildRoot, relativePath);
|
|
105
|
+
if (!absolutePath.startsWith(buildRoot + path.sep) && absolutePath !== buildRoot) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await access(absolutePath);
|
|
110
|
+
return { absolutePath, relativePath };
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (isMissingStaticCandidate(error)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function isMissingStaticCandidate(error) {
|
|
122
|
+
if (!error || typeof error !== 'object' || !('code' in error)) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return error.code === 'ENOENT' || error.code === 'ENOTDIR';
|
|
126
|
+
}
|
|
127
|
+
function normalizeRequestPath(pathname) {
|
|
128
|
+
const decoded = decodeURIComponent(pathname);
|
|
129
|
+
const normalized = path.posix.normalize(decoded);
|
|
130
|
+
const stripped = normalized.replace(/^(\.\.(\/|\\|$))+/, '').replace(/^\/+/, '');
|
|
131
|
+
return stripped.replace(/\/+$/, '');
|
|
132
|
+
}
|
|
133
|
+
function getGenericFileCandidates(relativePath) {
|
|
134
|
+
const hasExtension = path.posix.extname(relativePath) !== '';
|
|
135
|
+
const candidates = hasExtension
|
|
136
|
+
? [relativePath]
|
|
137
|
+
: [relativePath, `${relativePath}.html`, path.posix.join(relativePath, 'index.html')];
|
|
138
|
+
return candidates;
|
|
139
|
+
}
|
|
140
|
+
function hasReservedPrefix(relativePath) {
|
|
141
|
+
return RESERVED_PREFIXES.some((prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`));
|
|
142
|
+
}
|
|
143
|
+
function setCacheHeaders(headers, relativePath) {
|
|
144
|
+
if (CONTENT_HASH_PATTERN.test(relativePath)) {
|
|
145
|
+
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const extension = path.extname(relativePath).toLowerCase();
|
|
149
|
+
if (extension === '.html' || extension === '') {
|
|
150
|
+
setNoCacheHeaders(headers);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (STATIC_EXTENSIONS.has(extension)) {
|
|
154
|
+
headers.set('Cache-Control', 'no-cache, must-revalidate');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function setNoCacheHeaders(headers) {
|
|
158
|
+
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
159
|
+
headers.set('Pragma', 'no-cache');
|
|
160
|
+
headers.set('Expires', '0');
|
|
161
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type DeploymentIo, type PublishedWorkspaceServer, type PublishedWorkspaceServerOptions } from './deploy-shared.js';
|
|
2
|
+
export type { DeploymentIo, PublishedWorkspaceServer, PublishedWorkspaceServerOptions };
|
|
3
|
+
export declare function startPublishedWorkspaceServer(options: PublishedWorkspaceServerOptions): Promise<PublishedWorkspaceServer>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getFullWorkspaceProxyPath, proxyRequest, shouldProxyToBackend, startBackendProcess, waitForRuntimeReady, } from './deploy-backend.js';
|
|
3
|
+
import { assertExists, DEFAULT_PUBLIC_PORT, defaultIo, getOpenPort, readPublishedWorkspaceMode, requireBunRuntime, textResponse, } from './deploy-shared.js';
|
|
4
|
+
import { servePublishedStaticFile } from './deploy-static.js';
|
|
5
|
+
export async function startPublishedWorkspaceServer(options) {
|
|
6
|
+
const bun = requireBunRuntime();
|
|
7
|
+
const workspaceRoot = path.resolve(options.workspaceRoot);
|
|
8
|
+
const io = options.io ?? defaultIo;
|
|
9
|
+
const mode = await readPublishedWorkspaceMode(workspaceRoot);
|
|
10
|
+
const frontendRoot = mode === 'full' ? path.join(workspaceRoot, 'dist', 'frontend') : undefined;
|
|
11
|
+
const backendEntry = path.join(workspaceRoot, 'build', 'backend', 'index.js');
|
|
12
|
+
await assertExists(backendEntry, 'published backend entry');
|
|
13
|
+
if (frontendRoot) {
|
|
14
|
+
await assertExists(frontendRoot, 'published frontend output');
|
|
15
|
+
}
|
|
16
|
+
const internalPort = await getOpenPort();
|
|
17
|
+
const processRecord = startBackendProcess({
|
|
18
|
+
workspaceRoot,
|
|
19
|
+
backendEntry,
|
|
20
|
+
port: internalPort,
|
|
21
|
+
env: options.env,
|
|
22
|
+
io,
|
|
23
|
+
});
|
|
24
|
+
const backendOrigin = `http://127.0.0.1:${internalPort}`;
|
|
25
|
+
let stopping = false;
|
|
26
|
+
try {
|
|
27
|
+
await waitForRuntimeReady(internalPort, processRecord.exitPromise);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
processRecord.expectedExit = true;
|
|
31
|
+
processRecord.child.kill('SIGTERM');
|
|
32
|
+
await processRecord.exitPromise.catch(() => undefined);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
const host = options.host ?? '0.0.0.0';
|
|
36
|
+
const requestedPort = options.port ?? DEFAULT_PUBLIC_PORT;
|
|
37
|
+
const server = bun.serve({
|
|
38
|
+
hostname: host,
|
|
39
|
+
idleTimeout: 0,
|
|
40
|
+
port: requestedPort,
|
|
41
|
+
fetch: async (request) => await handlePublishedWorkspaceRequest({
|
|
42
|
+
request,
|
|
43
|
+
mode,
|
|
44
|
+
frontendRoot,
|
|
45
|
+
backendOrigin,
|
|
46
|
+
}),
|
|
47
|
+
error: (error) => textResponse(500, error.message),
|
|
48
|
+
});
|
|
49
|
+
processRecord.exitPromise
|
|
50
|
+
.then((code) => {
|
|
51
|
+
if (stopping || processRecord.expectedExit) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
io.stderr.write(`[webstir-backend-deploy] backend runtime exited unexpectedly with code ${code ?? 'null'}.\n`);
|
|
55
|
+
server.stop(true);
|
|
56
|
+
})
|
|
57
|
+
.catch((error) => {
|
|
58
|
+
if (stopping) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
io.stderr.write(`[webstir-backend-deploy] backend runtime failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
62
|
+
server.stop(true);
|
|
63
|
+
});
|
|
64
|
+
const displayHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
|
65
|
+
return {
|
|
66
|
+
origin: `http://${displayHost}:${server.port}`,
|
|
67
|
+
mode,
|
|
68
|
+
async stop() {
|
|
69
|
+
stopping = true;
|
|
70
|
+
server.stop(true);
|
|
71
|
+
processRecord.expectedExit = true;
|
|
72
|
+
processRecord.child.kill('SIGTERM');
|
|
73
|
+
await processRecord.exitPromise.catch(() => undefined);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function handlePublishedWorkspaceRequest(options) {
|
|
78
|
+
const requestUrl = new URL(options.request.url);
|
|
79
|
+
const pathname = requestUrl.pathname;
|
|
80
|
+
if (options.mode === 'api') {
|
|
81
|
+
return await proxyRequest(options.request, requestUrl, pathname, options.backendOrigin, 'api');
|
|
82
|
+
}
|
|
83
|
+
if (shouldProxyToBackend(options.request, pathname)) {
|
|
84
|
+
const proxyPath = getFullWorkspaceProxyPath(pathname);
|
|
85
|
+
return await proxyRequest(options.request, requestUrl, proxyPath, options.backendOrigin, 'full');
|
|
86
|
+
}
|
|
87
|
+
if (!options.frontendRoot) {
|
|
88
|
+
return textResponse(500, 'Published frontend output is not available.');
|
|
89
|
+
}
|
|
90
|
+
return await servePublishedStaticFile(options.request, options.frontendRoot);
|
|
91
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type FormIssueCode = 'validation' | 'auth' | 'csrf';
|
|
2
|
+
export type FormValue = string | string[];
|
|
3
|
+
export type FormValues = Record<string, FormValue>;
|
|
4
|
+
export interface FormIssue {
|
|
5
|
+
code?: FormIssueCode;
|
|
6
|
+
field?: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
export interface FormRouteDefinitionLike {
|
|
10
|
+
path?: string;
|
|
11
|
+
form?: {
|
|
12
|
+
csrf?: boolean;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface PreparedFormState<TSession extends Record<string, unknown>> {
|
|
16
|
+
session: TSession;
|
|
17
|
+
csrfToken?: string;
|
|
18
|
+
values: FormValues;
|
|
19
|
+
issues: FormIssue[];
|
|
20
|
+
}
|
|
21
|
+
interface RouteHandlerResultLike {
|
|
22
|
+
status?: number;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
body?: unknown;
|
|
25
|
+
redirect?: {
|
|
26
|
+
location: string;
|
|
27
|
+
};
|
|
28
|
+
errors?: {
|
|
29
|
+
code: string;
|
|
30
|
+
message: string;
|
|
31
|
+
details?: unknown;
|
|
32
|
+
}[];
|
|
33
|
+
}
|
|
34
|
+
export type FormSubmissionResult<TSession extends Record<string, unknown>, TAuth> = {
|
|
35
|
+
ok: true;
|
|
36
|
+
session: TSession;
|
|
37
|
+
values: FormValues;
|
|
38
|
+
auth: TAuth | undefined;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
session: TSession;
|
|
42
|
+
values: FormValues;
|
|
43
|
+
issues: FormIssue[];
|
|
44
|
+
result: RouteHandlerResultLike;
|
|
45
|
+
};
|
|
46
|
+
export declare function prepareFormState<TSession extends Record<string, unknown>>(options: {
|
|
47
|
+
session: TSession | null;
|
|
48
|
+
formId: string;
|
|
49
|
+
route?: FormRouteDefinitionLike;
|
|
50
|
+
csrf?: boolean;
|
|
51
|
+
now?: () => Date;
|
|
52
|
+
}): PreparedFormState<TSession>;
|
|
53
|
+
export declare function processFormSubmission<TSession extends Record<string, unknown>, TAuth>(options: {
|
|
54
|
+
session: TSession | null;
|
|
55
|
+
body: unknown;
|
|
56
|
+
auth?: TAuth;
|
|
57
|
+
formId: string;
|
|
58
|
+
route?: FormRouteDefinitionLike;
|
|
59
|
+
csrf?: boolean;
|
|
60
|
+
csrfFieldName?: string;
|
|
61
|
+
redirectTo?: string;
|
|
62
|
+
requireAuth?: boolean | {
|
|
63
|
+
redirectTo?: string;
|
|
64
|
+
message?: string;
|
|
65
|
+
};
|
|
66
|
+
validate?: (values: FormValues) => readonly FormIssue[] | FormIssue[] | undefined;
|
|
67
|
+
now?: () => Date;
|
|
68
|
+
}): FormSubmissionResult<TSession, TAuth>;
|
|
69
|
+
export declare function groupFormIssuesByField(issues: readonly FormIssue[] | undefined): {
|
|
70
|
+
form: string[];
|
|
71
|
+
fields: Record<string, string[]>;
|
|
72
|
+
};
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { getFormSessionRuntimeState, pruneSessionRuntimeState, } from './session-runtime.js';
|
|
3
|
+
const DEFAULT_CSRF_FIELD_NAME = '_csrf';
|
|
4
|
+
export function prepareFormState(options) {
|
|
5
|
+
const session = ensureSession(options.session);
|
|
6
|
+
const store = getFormRuntimeStore(session);
|
|
7
|
+
const stored = store.states[options.formId];
|
|
8
|
+
if (stored) {
|
|
9
|
+
delete store.states[options.formId];
|
|
10
|
+
}
|
|
11
|
+
let csrfToken;
|
|
12
|
+
if (isCsrfEnabled(options)) {
|
|
13
|
+
csrfToken = ensureCsrfToken(store, options.formId);
|
|
14
|
+
}
|
|
15
|
+
cleanupFormRuntimeStore(session);
|
|
16
|
+
return {
|
|
17
|
+
session,
|
|
18
|
+
csrfToken,
|
|
19
|
+
values: cloneFormValues(stored?.values),
|
|
20
|
+
issues: cloneIssues(stored?.issues),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function processFormSubmission(options) {
|
|
24
|
+
const now = options.now ?? (() => new Date());
|
|
25
|
+
const session = ensureSession(options.session);
|
|
26
|
+
const store = getFormRuntimeStore(session);
|
|
27
|
+
const csrfFieldName = options.csrfFieldName ?? DEFAULT_CSRF_FIELD_NAME;
|
|
28
|
+
const values = normalizeFormValues(options.body, csrfFieldName);
|
|
29
|
+
const redirectTo = options.redirectTo;
|
|
30
|
+
if (isCsrfEnabled(options)) {
|
|
31
|
+
const expectedToken = ensureCsrfToken(store, options.formId);
|
|
32
|
+
const providedToken = readCsrfToken(options.body, csrfFieldName);
|
|
33
|
+
if (!providedToken || !tokensMatch(providedToken, expectedToken)) {
|
|
34
|
+
return failSubmission({
|
|
35
|
+
session,
|
|
36
|
+
store,
|
|
37
|
+
formId: options.formId,
|
|
38
|
+
values,
|
|
39
|
+
redirectTo,
|
|
40
|
+
now,
|
|
41
|
+
issues: [
|
|
42
|
+
{
|
|
43
|
+
code: 'csrf',
|
|
44
|
+
message: 'Form session expired. Reload the page and try again.',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
delete store.csrf[options.formId];
|
|
50
|
+
}
|
|
51
|
+
if (requiresAuth(options.requireAuth) && options.auth === undefined) {
|
|
52
|
+
return failSubmission({
|
|
53
|
+
session,
|
|
54
|
+
store,
|
|
55
|
+
formId: options.formId,
|
|
56
|
+
values,
|
|
57
|
+
redirectTo: resolveAuthRedirect(options.requireAuth, redirectTo),
|
|
58
|
+
now,
|
|
59
|
+
issues: [
|
|
60
|
+
{
|
|
61
|
+
code: 'auth',
|
|
62
|
+
message: typeof options.requireAuth === 'object' && options.requireAuth.message
|
|
63
|
+
? options.requireAuth.message
|
|
64
|
+
: 'Sign-in required to submit this form.',
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const validationResult = options.validate?.(values);
|
|
70
|
+
const validationIssues = cloneIssues(Array.isArray(validationResult) ? validationResult : undefined);
|
|
71
|
+
if (validationIssues.length > 0) {
|
|
72
|
+
return failSubmission({
|
|
73
|
+
session,
|
|
74
|
+
store,
|
|
75
|
+
formId: options.formId,
|
|
76
|
+
values,
|
|
77
|
+
redirectTo,
|
|
78
|
+
now,
|
|
79
|
+
issues: validationIssues.map((issue) => ({
|
|
80
|
+
...issue,
|
|
81
|
+
code: issue.code ?? 'validation',
|
|
82
|
+
})),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
delete store.states[options.formId];
|
|
86
|
+
cleanupFormRuntimeStore(session);
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
session,
|
|
90
|
+
values,
|
|
91
|
+
auth: options.auth,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function groupFormIssuesByField(issues) {
|
|
95
|
+
const grouped = {
|
|
96
|
+
form: [],
|
|
97
|
+
fields: {},
|
|
98
|
+
};
|
|
99
|
+
for (const issue of issues ?? []) {
|
|
100
|
+
if (!issue?.message) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!issue.field) {
|
|
104
|
+
grouped.form.push(issue.message);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
grouped.fields[issue.field] ??= [];
|
|
108
|
+
grouped.fields[issue.field].push(issue.message);
|
|
109
|
+
}
|
|
110
|
+
return grouped;
|
|
111
|
+
}
|
|
112
|
+
function failSubmission(options) {
|
|
113
|
+
options.store.states[options.formId] = {
|
|
114
|
+
values: cloneFormValues(options.values),
|
|
115
|
+
issues: cloneIssues(options.issues),
|
|
116
|
+
createdAt: options.now().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
cleanupFormRuntimeStore(options.session);
|
|
119
|
+
if (options.redirectTo) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
session: options.session,
|
|
123
|
+
values: options.values,
|
|
124
|
+
issues: options.issues,
|
|
125
|
+
result: {
|
|
126
|
+
status: 303,
|
|
127
|
+
redirect: {
|
|
128
|
+
location: options.redirectTo,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const status = options.issues.some((issue) => issue.code === 'auth')
|
|
134
|
+
? 401
|
|
135
|
+
: options.issues.some((issue) => issue.code === 'csrf')
|
|
136
|
+
? 403
|
|
137
|
+
: 422;
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
session: options.session,
|
|
141
|
+
values: options.values,
|
|
142
|
+
issues: options.issues,
|
|
143
|
+
result: {
|
|
144
|
+
status,
|
|
145
|
+
errors: options.issues.map((issue) => ({
|
|
146
|
+
code: issue.code === 'auth' ? 'auth' : 'validation',
|
|
147
|
+
message: issue.message,
|
|
148
|
+
details: issue.field
|
|
149
|
+
? { field: issue.field, reason: issue.code ?? 'validation' }
|
|
150
|
+
: { reason: issue.code ?? 'validation' },
|
|
151
|
+
})),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function ensureSession(session) {
|
|
156
|
+
if (session && typeof session === 'object' && !Array.isArray(session)) {
|
|
157
|
+
return session;
|
|
158
|
+
}
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
function getFormRuntimeStore(session) {
|
|
162
|
+
return getFormSessionRuntimeState(session);
|
|
163
|
+
}
|
|
164
|
+
function cleanupFormRuntimeStore(session) {
|
|
165
|
+
pruneSessionRuntimeState(session);
|
|
166
|
+
}
|
|
167
|
+
function ensureCsrfToken(store, formId) {
|
|
168
|
+
const existing = store.csrf[formId];
|
|
169
|
+
if (existing) {
|
|
170
|
+
return existing;
|
|
171
|
+
}
|
|
172
|
+
const generated = randomUUID();
|
|
173
|
+
store.csrf[formId] = generated;
|
|
174
|
+
return generated;
|
|
175
|
+
}
|
|
176
|
+
function readCsrfToken(body, fieldName) {
|
|
177
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const value = body[fieldName];
|
|
181
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(value) && typeof value[0] === 'string' && value[0].length > 0) {
|
|
185
|
+
return value[0];
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
function normalizeFormValues(body, csrfFieldName) {
|
|
190
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
const values = {};
|
|
194
|
+
for (const [key, raw] of Object.entries(body)) {
|
|
195
|
+
if (key === csrfFieldName) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (typeof raw === 'string') {
|
|
199
|
+
values[key] = raw;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(raw) && raw.every((value) => typeof value === 'string')) {
|
|
203
|
+
values[key] = [...raw];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return values;
|
|
207
|
+
}
|
|
208
|
+
function cloneFormValues(values) {
|
|
209
|
+
if (!values) {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]));
|
|
213
|
+
}
|
|
214
|
+
function cloneIssues(issues) {
|
|
215
|
+
return (issues ?? []).map((issue) => ({ ...issue }));
|
|
216
|
+
}
|
|
217
|
+
function tokensMatch(left, right) {
|
|
218
|
+
const leftBuffer = Buffer.from(left);
|
|
219
|
+
const rightBuffer = Buffer.from(right);
|
|
220
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
224
|
+
}
|
|
225
|
+
function isCsrfEnabled(options) {
|
|
226
|
+
return options.csrf ?? options.route?.form?.csrf ?? false;
|
|
227
|
+
}
|
|
228
|
+
function requiresAuth(option) {
|
|
229
|
+
return option === true || typeof option === 'object';
|
|
230
|
+
}
|
|
231
|
+
function resolveAuthRedirect(option, fallback) {
|
|
232
|
+
if (typeof option === 'object' && option.redirectTo) {
|
|
233
|
+
return option.redirectTo;
|
|
234
|
+
}
|
|
235
|
+
return fallback;
|
|
236
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type RequestHookPhase = 'beforeAuth' | 'beforeHandler' | 'afterHandler';
|
|
2
|
+
export interface RequestHookDefinitionLike {
|
|
3
|
+
id?: string;
|
|
4
|
+
phase?: RequestHookPhase;
|
|
5
|
+
order?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface RequestHookReferenceLike {
|
|
8
|
+
id?: string;
|
|
9
|
+
}
|
|
10
|
+
export type RequestHookHandler<TContext, TResult, TRoute> = (context: TContext, input: {
|
|
11
|
+
phase: RequestHookPhase;
|
|
12
|
+
route: TRoute;
|
|
13
|
+
result?: TResult;
|
|
14
|
+
}) => Promise<TResult | undefined> | TResult | undefined;
|
|
15
|
+
export interface RegisteredRequestHook<TContext, TResult, TRoute> {
|
|
16
|
+
id?: string;
|
|
17
|
+
handler?: RequestHookHandler<TContext, TResult, TRoute>;
|
|
18
|
+
}
|
|
19
|
+
export interface CompiledRequestHook<TContext, TResult, TRoute> {
|
|
20
|
+
id: string;
|
|
21
|
+
phase: RequestHookPhase;
|
|
22
|
+
order: number;
|
|
23
|
+
handler: RequestHookHandler<TContext, TResult, TRoute>;
|
|
24
|
+
}
|
|
25
|
+
export declare function resolveRequestHooks<TContext, TResult, TRoute>(options: {
|
|
26
|
+
routeName: string;
|
|
27
|
+
routeReferences?: readonly RequestHookReferenceLike[];
|
|
28
|
+
manifestDefinitions?: readonly RequestHookDefinitionLike[];
|
|
29
|
+
registrations?: readonly RegisteredRequestHook<TContext, TResult, TRoute>[];
|
|
30
|
+
}): {
|
|
31
|
+
hooks: CompiledRequestHook<TContext, TResult, TRoute>[];
|
|
32
|
+
warnings: string[];
|
|
33
|
+
};
|
|
34
|
+
export declare function executeRequestHookPhase<TContext, TResult, TRoute>(options: {
|
|
35
|
+
hooks: readonly CompiledRequestHook<TContext, TResult, TRoute>[];
|
|
36
|
+
phase: RequestHookPhase;
|
|
37
|
+
context: TContext;
|
|
38
|
+
route: TRoute;
|
|
39
|
+
logger?: {
|
|
40
|
+
info?(message: string, metadata?: Record<string, unknown>): void;
|
|
41
|
+
error?(message: string, metadata?: Record<string, unknown>): void;
|
|
42
|
+
};
|
|
43
|
+
result?: TResult;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
result?: TResult;
|
|
46
|
+
shortCircuited: boolean;
|
|
47
|
+
}>;
|