@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,396 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { attachSessionRuntimeState, cloneSessionRuntimeState, coerceSessionRuntimeFormState, hasSessionRuntimeState, mergeSessionRuntimeState, readSessionRuntimeState, } from './session-runtime.js';
|
|
3
|
+
import { attachSessionMetadata, readSessionMetadata, stripSessionMetadataFields, } from './session-metadata.js';
|
|
4
|
+
const SESSION_STORE_KEY = Symbol.for('webstir.webstir-backend.session-store');
|
|
5
|
+
const LEGACY_FORM_RUNTIME_KEY = '__webstir_form_runtime';
|
|
6
|
+
export function prepareSessionState(options) {
|
|
7
|
+
const now = options.now ?? (() => new Date());
|
|
8
|
+
const cookies = normalizeCookies(options.cookies);
|
|
9
|
+
const store = options.store ?? getDefaultSessionStore();
|
|
10
|
+
const sessionCookie = cookies[options.config.cookieName];
|
|
11
|
+
const initialId = verifySignedSessionCookie(sessionCookie, options.config.secret);
|
|
12
|
+
const invalidCookie = Boolean(sessionCookie) && !initialId;
|
|
13
|
+
const initialRecord = initialId ? loadSessionRecord(store, initialId, now) : undefined;
|
|
14
|
+
const staleCookie = Boolean(initialId) && !initialRecord;
|
|
15
|
+
const delivered = resolveConsumedFlash(readStoredFlash(initialRecord), options.route);
|
|
16
|
+
const initialState = initialRecord ? restoreStoredSessionState(initialRecord) : undefined;
|
|
17
|
+
const initialSession = initialState?.session
|
|
18
|
+
? attachSessionRuntimeState(attachSessionMetadata(initialState.session, initialState.metadata), initialState.runtime)
|
|
19
|
+
: null;
|
|
20
|
+
const hasPendingConsumption = delivered.flash.length > 0;
|
|
21
|
+
return {
|
|
22
|
+
session: initialSession,
|
|
23
|
+
flash: delivered.flash,
|
|
24
|
+
commit({ session, route, result }) {
|
|
25
|
+
const publishFlash = resolvePublishedFlash(route ?? options.route, result, now);
|
|
26
|
+
const normalized = normalizeSessionValue(session);
|
|
27
|
+
if (initialRecord) {
|
|
28
|
+
store.delete(initialRecord.id);
|
|
29
|
+
}
|
|
30
|
+
const shouldPersist = normalized.session !== null ||
|
|
31
|
+
publishFlash.length > 0 ||
|
|
32
|
+
(initialRecord !== undefined && delivered.remaining.length > 0) ||
|
|
33
|
+
hasPendingConsumption ||
|
|
34
|
+
hasSessionRuntimeState(normalized.runtime);
|
|
35
|
+
if (!shouldPersist) {
|
|
36
|
+
return {
|
|
37
|
+
session: null,
|
|
38
|
+
setCookie: initialRecord || invalidCookie || staleCookie
|
|
39
|
+
? serializeExpiredCookie(options.config)
|
|
40
|
+
: undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const record = createStoredSessionRecord({
|
|
44
|
+
session: normalized.session,
|
|
45
|
+
runtime: normalized.runtime,
|
|
46
|
+
metadata: normalized.metadata,
|
|
47
|
+
fallbackId: normalized.metadata?.id ?? (publishFlash.length > 0 ? undefined : initialRecord?.id),
|
|
48
|
+
initialRecord,
|
|
49
|
+
flash: [...delivered.remaining, ...publishFlash],
|
|
50
|
+
config: options.config,
|
|
51
|
+
now,
|
|
52
|
+
});
|
|
53
|
+
store.set(record);
|
|
54
|
+
return {
|
|
55
|
+
session: attachSessionRuntimeState(attachSessionMetadata(cloneValue(record.value), {
|
|
56
|
+
id: record.id,
|
|
57
|
+
createdAt: record.createdAt,
|
|
58
|
+
expiresAt: record.expiresAt,
|
|
59
|
+
}), record.runtime),
|
|
60
|
+
setCookie: initialRecord?.id === record.id && !invalidCookie && !staleCookie
|
|
61
|
+
? undefined
|
|
62
|
+
: serializeSessionCookie(record.id, options.config),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function parseCookieHeader(header) {
|
|
68
|
+
return normalizeCookies(header);
|
|
69
|
+
}
|
|
70
|
+
export function createInMemorySessionStore() {
|
|
71
|
+
const records = new Map();
|
|
72
|
+
return {
|
|
73
|
+
get(sessionId) {
|
|
74
|
+
const record = records.get(sessionId);
|
|
75
|
+
return record ? cloneStoredSessionRecord(record) : undefined;
|
|
76
|
+
},
|
|
77
|
+
set(record) {
|
|
78
|
+
records.set(record.id, cloneStoredSessionRecord(record));
|
|
79
|
+
},
|
|
80
|
+
delete(sessionId) {
|
|
81
|
+
records.delete(sessionId);
|
|
82
|
+
},
|
|
83
|
+
clear() {
|
|
84
|
+
records.clear();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function resetInMemorySessionStore(store = getDefaultInMemorySessionStore()) {
|
|
89
|
+
store.clear();
|
|
90
|
+
}
|
|
91
|
+
function getDefaultSessionStore() {
|
|
92
|
+
return getDefaultInMemorySessionStore();
|
|
93
|
+
}
|
|
94
|
+
function getDefaultInMemorySessionStore() {
|
|
95
|
+
const globalStore = globalThis;
|
|
96
|
+
const existing = globalStore[SESSION_STORE_KEY];
|
|
97
|
+
if (isInMemorySessionStore(existing)) {
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
const store = createInMemorySessionStore();
|
|
101
|
+
globalStore[SESSION_STORE_KEY] = store;
|
|
102
|
+
return store;
|
|
103
|
+
}
|
|
104
|
+
function normalizeCookies(input) {
|
|
105
|
+
if (!input) {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
if (typeof input === 'object' && !Array.isArray(input)) {
|
|
109
|
+
return { ...input };
|
|
110
|
+
}
|
|
111
|
+
const raw = Array.isArray(input) ? input.join('; ') : input;
|
|
112
|
+
const cookies = {};
|
|
113
|
+
for (const part of raw.split(';')) {
|
|
114
|
+
const trimmed = part.trim();
|
|
115
|
+
if (!trimmed) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const separatorIndex = trimmed.indexOf('=');
|
|
119
|
+
if (separatorIndex === -1) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const name = trimmed.slice(0, separatorIndex).trim();
|
|
123
|
+
const value = trimmed.slice(separatorIndex + 1).trim();
|
|
124
|
+
if (!name) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
cookies[name] = decodeCookieValue(value);
|
|
128
|
+
}
|
|
129
|
+
return cookies;
|
|
130
|
+
}
|
|
131
|
+
function decodeCookieValue(value) {
|
|
132
|
+
try {
|
|
133
|
+
return decodeURIComponent(value);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function verifySignedSessionCookie(cookieValue, secret) {
|
|
140
|
+
if (!cookieValue) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const separatorIndex = cookieValue.indexOf('.');
|
|
144
|
+
if (separatorIndex <= 0) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
const sessionId = cookieValue.slice(0, separatorIndex);
|
|
148
|
+
const signature = cookieValue.slice(separatorIndex + 1);
|
|
149
|
+
const expected = signSessionId(sessionId, secret);
|
|
150
|
+
const signatureBuffer = Buffer.from(signature);
|
|
151
|
+
const expectedBuffer = Buffer.from(expected);
|
|
152
|
+
if (signatureBuffer.length !== expectedBuffer.length) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
return timingSafeEqual(signatureBuffer, expectedBuffer) ? sessionId : undefined;
|
|
156
|
+
}
|
|
157
|
+
function signSessionId(sessionId, secret) {
|
|
158
|
+
return createHmac('sha256', secret).update(sessionId).digest('base64url');
|
|
159
|
+
}
|
|
160
|
+
function loadSessionRecord(store, sessionId, now) {
|
|
161
|
+
const record = store.get(sessionId);
|
|
162
|
+
if (!record) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const expiresAt = Date.parse(record.expiresAt);
|
|
166
|
+
if (Number.isFinite(expiresAt) && expiresAt <= now().getTime()) {
|
|
167
|
+
store.delete(sessionId);
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return record;
|
|
171
|
+
}
|
|
172
|
+
function resolveConsumedFlash(flash, route) {
|
|
173
|
+
const consume = new Set([
|
|
174
|
+
...(route?.flash?.consume ?? []),
|
|
175
|
+
...(route?.form?.flash?.consume ?? []),
|
|
176
|
+
]);
|
|
177
|
+
if (consume.size === 0) {
|
|
178
|
+
return {
|
|
179
|
+
flash: [],
|
|
180
|
+
remaining: flash.map((message) => ({ ...message })),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const delivered = [];
|
|
184
|
+
const remaining = [];
|
|
185
|
+
for (const message of flash) {
|
|
186
|
+
if (consume.has(message.key)) {
|
|
187
|
+
delivered.push({ ...message });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
remaining.push({ ...message });
|
|
191
|
+
}
|
|
192
|
+
return { flash: delivered, remaining };
|
|
193
|
+
}
|
|
194
|
+
function resolvePublishedFlash(route, result, now) {
|
|
195
|
+
const definitions = [...(route?.flash?.publish ?? []), ...(route?.form?.flash?.publish ?? [])];
|
|
196
|
+
if (definitions.length === 0) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const condition = resolveFlashCondition(result);
|
|
200
|
+
return definitions
|
|
201
|
+
.filter((definition) => shouldPublishFlash(definition.when, condition))
|
|
202
|
+
.filter((definition) => typeof definition.key === 'string' && definition.key.length > 0)
|
|
203
|
+
.map((definition) => ({
|
|
204
|
+
key: definition.key,
|
|
205
|
+
level: definition.level ?? (condition === 'error' ? 'error' : 'info'),
|
|
206
|
+
createdAt: now().toISOString(),
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
function resolveFlashCondition(result) {
|
|
210
|
+
if (result?.errors) {
|
|
211
|
+
return 'error';
|
|
212
|
+
}
|
|
213
|
+
if ((result?.status ?? 200) >= 400) {
|
|
214
|
+
return 'error';
|
|
215
|
+
}
|
|
216
|
+
return 'success';
|
|
217
|
+
}
|
|
218
|
+
function shouldPublishFlash(when, condition) {
|
|
219
|
+
if (!when || when === 'always') {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return when === condition;
|
|
223
|
+
}
|
|
224
|
+
function normalizeSessionValue(session) {
|
|
225
|
+
if (session === null) {
|
|
226
|
+
return { session: null };
|
|
227
|
+
}
|
|
228
|
+
const metadata = readSessionMetadata(session);
|
|
229
|
+
const cloned = cloneValue(session);
|
|
230
|
+
stripSessionMetadataFields(cloned);
|
|
231
|
+
delete cloned[LEGACY_FORM_RUNTIME_KEY];
|
|
232
|
+
return {
|
|
233
|
+
session: cloned,
|
|
234
|
+
metadata,
|
|
235
|
+
runtime: readSessionRuntimeState(session),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function createStoredSessionRecord(options) {
|
|
239
|
+
const sessionValue = (options.session ?? {});
|
|
240
|
+
const sessionId = options.metadata?.id ?? options.fallbackId ?? randomUUID();
|
|
241
|
+
const createdAt = options.metadata?.createdAt ?? options.initialRecord?.createdAt ?? options.now().toISOString();
|
|
242
|
+
const expiresAt = options.metadata?.expiresAt ??
|
|
243
|
+
options.initialRecord?.expiresAt ??
|
|
244
|
+
new Date(options.now().getTime() + options.config.maxAgeSeconds * 1000).toISOString();
|
|
245
|
+
return {
|
|
246
|
+
id: sessionId,
|
|
247
|
+
value: { ...sessionValue },
|
|
248
|
+
runtime: createSessionStoreRuntime(options.runtime, options.flash),
|
|
249
|
+
createdAt,
|
|
250
|
+
expiresAt,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function serializeSessionCookie(sessionId, config) {
|
|
254
|
+
const value = `${encodeURIComponent(sessionId)}.${signSessionId(sessionId, config.secret)}`;
|
|
255
|
+
return serializeCookie(config, value, config.maxAgeSeconds);
|
|
256
|
+
}
|
|
257
|
+
function serializeExpiredCookie(config) {
|
|
258
|
+
return serializeCookie(config, '', 0);
|
|
259
|
+
}
|
|
260
|
+
function serializeCookie(config, value, maxAgeSeconds) {
|
|
261
|
+
const parts = [`${config.cookieName}=${value}`];
|
|
262
|
+
parts.push(`Path=${config.path ?? '/'}`);
|
|
263
|
+
parts.push(`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`);
|
|
264
|
+
parts.push(`SameSite=${config.sameSite ?? 'Lax'}`);
|
|
265
|
+
parts.push('HttpOnly');
|
|
266
|
+
if (config.secure) {
|
|
267
|
+
parts.push('Secure');
|
|
268
|
+
}
|
|
269
|
+
return parts.join('; ');
|
|
270
|
+
}
|
|
271
|
+
function normalizeText(value) {
|
|
272
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
273
|
+
}
|
|
274
|
+
function normalizeDate(value) {
|
|
275
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
276
|
+
return value.toISOString();
|
|
277
|
+
}
|
|
278
|
+
if (typeof value === 'string') {
|
|
279
|
+
const timestamp = Date.parse(value);
|
|
280
|
+
if (!Number.isNaN(timestamp)) {
|
|
281
|
+
return new Date(timestamp).toISOString();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
function cloneValue(value) {
|
|
287
|
+
if (typeof structuredClone === 'function') {
|
|
288
|
+
return structuredClone(value);
|
|
289
|
+
}
|
|
290
|
+
return JSON.parse(JSON.stringify(value));
|
|
291
|
+
}
|
|
292
|
+
function cloneStoredSessionRecord(record) {
|
|
293
|
+
const flash = cloneFlashMessages(record.flash);
|
|
294
|
+
const runtime = cloneSessionStoreRuntime(record.runtime);
|
|
295
|
+
return {
|
|
296
|
+
id: record.id,
|
|
297
|
+
value: cloneValue(record.value),
|
|
298
|
+
...(flash.length > 0 ? { flash } : {}),
|
|
299
|
+
...(runtime ? { runtime } : {}),
|
|
300
|
+
createdAt: record.createdAt,
|
|
301
|
+
expiresAt: record.expiresAt,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function restoreStoredSessionState(record) {
|
|
305
|
+
const session = cloneValue(record.value);
|
|
306
|
+
const legacyRuntime = restoreLegacyFormRuntime(session);
|
|
307
|
+
const legacyMetadata = readSessionMetadata(session);
|
|
308
|
+
stripSessionMetadataFields(session);
|
|
309
|
+
return {
|
|
310
|
+
session: session,
|
|
311
|
+
metadata: {
|
|
312
|
+
id: normalizeText(record.id) ?? legacyMetadata?.id ?? randomUUID(),
|
|
313
|
+
createdAt: normalizeDate(record.createdAt) ?? legacyMetadata?.createdAt ?? new Date(0).toISOString(),
|
|
314
|
+
expiresAt: normalizeDate(record.expiresAt) ?? legacyMetadata?.expiresAt ?? new Date(0).toISOString(),
|
|
315
|
+
},
|
|
316
|
+
runtime: mergeSessionRuntimeState(readStoredRuntimeState(record), legacyRuntime),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function restoreLegacyFormRuntime(session) {
|
|
320
|
+
const legacy = coerceSessionRuntimeFormState(session[LEGACY_FORM_RUNTIME_KEY]);
|
|
321
|
+
delete session[LEGACY_FORM_RUNTIME_KEY];
|
|
322
|
+
return legacy ? { form: legacy } : undefined;
|
|
323
|
+
}
|
|
324
|
+
function isInMemorySessionStore(value) {
|
|
325
|
+
return Boolean(value &&
|
|
326
|
+
typeof value === 'object' &&
|
|
327
|
+
typeof value.get === 'function' &&
|
|
328
|
+
typeof value.set === 'function' &&
|
|
329
|
+
typeof value.delete === 'function' &&
|
|
330
|
+
typeof value.clear === 'function');
|
|
331
|
+
}
|
|
332
|
+
function readStoredFlash(record) {
|
|
333
|
+
if (!record) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
return cloneFlashMessages(record.runtime?.flash ?? record.flash);
|
|
337
|
+
}
|
|
338
|
+
function readStoredRuntimeState(record) {
|
|
339
|
+
return cloneSessionRuntimeState(record.runtime);
|
|
340
|
+
}
|
|
341
|
+
function createSessionStoreRuntime(runtime, flash) {
|
|
342
|
+
const form = cloneSessionRuntimeState(runtime)?.form;
|
|
343
|
+
const storedFlash = cloneFlashMessages(flash);
|
|
344
|
+
if (!form && storedFlash.length === 0) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
...(form ? { form } : {}),
|
|
349
|
+
...(storedFlash.length > 0 ? { flash: storedFlash } : {}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function cloneSessionStoreRuntime(runtime) {
|
|
353
|
+
if (!runtime) {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
const form = cloneSessionRuntimeState(runtime)?.form;
|
|
357
|
+
const flash = cloneFlashMessages(runtime.flash);
|
|
358
|
+
if (!form && flash.length === 0) {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
...(form ? { form } : {}),
|
|
363
|
+
...(flash.length > 0 ? { flash } : {}),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function cloneFlashMessages(flash) {
|
|
367
|
+
if (!Array.isArray(flash)) {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
return flash.flatMap((message) => {
|
|
371
|
+
if (!isRecord(message) ||
|
|
372
|
+
typeof message.key !== 'string' ||
|
|
373
|
+
typeof message.createdAt !== 'string') {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
const level = normalizeFlashLevel(message.level);
|
|
377
|
+
if (!level) {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
return [
|
|
381
|
+
{
|
|
382
|
+
key: message.key,
|
|
383
|
+
level,
|
|
384
|
+
createdAt: message.createdAt,
|
|
385
|
+
},
|
|
386
|
+
];
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function normalizeFlashLevel(value) {
|
|
390
|
+
return value === 'info' || value === 'success' || value === 'warning' || value === 'error'
|
|
391
|
+
? value
|
|
392
|
+
: undefined;
|
|
393
|
+
}
|
|
394
|
+
function isRecord(value) {
|
|
395
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
396
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface EnvAccessorLike {
|
|
2
|
+
get(name: string): string | undefined;
|
|
3
|
+
require(name: string): string;
|
|
4
|
+
entries(): Record<string, string | undefined>;
|
|
5
|
+
}
|
|
6
|
+
export interface LoggerLike {
|
|
7
|
+
readonly level?: string;
|
|
8
|
+
log?(level: string, message: string, metadata?: Record<string, unknown>): void;
|
|
9
|
+
debug?(message: string, metadata?: Record<string, unknown>): void;
|
|
10
|
+
info?(message: string, metadata?: Record<string, unknown>): void;
|
|
11
|
+
warn?(message: string, metadata?: Record<string, unknown>): void;
|
|
12
|
+
error?(message: string, metadata?: Record<string, unknown>): void;
|
|
13
|
+
with?(bindings: Record<string, unknown>): LoggerLike;
|
|
14
|
+
}
|
|
15
|
+
export interface ViewDefinitionLike {
|
|
16
|
+
name?: string;
|
|
17
|
+
path?: string;
|
|
18
|
+
renderMode?: 'ssg' | 'ssr' | 'spa';
|
|
19
|
+
}
|
|
20
|
+
export type RequestTimeDocumentCacheStatus = 'miss' | 'hit' | 'stale';
|
|
21
|
+
export interface RequestTimeDocumentCacheMetadata {
|
|
22
|
+
readonly status: RequestTimeDocumentCacheStatus;
|
|
23
|
+
readonly documentPath: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RenderedRequestTimeView {
|
|
26
|
+
readonly html: string;
|
|
27
|
+
readonly documentCache: RequestTimeDocumentCacheMetadata;
|
|
28
|
+
}
|
|
29
|
+
export interface SSRContextLike {
|
|
30
|
+
readonly url: URL;
|
|
31
|
+
readonly params: Record<string, string>;
|
|
32
|
+
readonly cookies: Record<string, string>;
|
|
33
|
+
readonly headers: Record<string, string>;
|
|
34
|
+
readonly auth: unknown;
|
|
35
|
+
readonly session: Record<string, unknown> | null;
|
|
36
|
+
readonly env: EnvAccessorLike;
|
|
37
|
+
readonly logger: LoggerLike;
|
|
38
|
+
readonly requestId?: string;
|
|
39
|
+
readonly now: () => Date;
|
|
40
|
+
}
|
|
41
|
+
export interface ModuleViewLike {
|
|
42
|
+
readonly definition?: ViewDefinitionLike;
|
|
43
|
+
readonly load?: (context: SSRContextLike) => Promise<unknown> | unknown;
|
|
44
|
+
}
|
|
45
|
+
export interface CompiledView {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly pathPattern: string;
|
|
48
|
+
readonly definition?: ViewDefinitionLike;
|
|
49
|
+
readonly load?: ModuleViewLike['load'];
|
|
50
|
+
readonly match: (pathname: string) => {
|
|
51
|
+
matched: boolean;
|
|
52
|
+
params: Record<string, string>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export declare function compileViews(views: readonly ModuleViewLike[]): CompiledView[];
|
|
56
|
+
export declare function matchView(views: readonly CompiledView[], pathname: string): {
|
|
57
|
+
view: CompiledView;
|
|
58
|
+
params: Record<string, string>;
|
|
59
|
+
} | undefined;
|
|
60
|
+
export declare function renderRequestTimeView(options: {
|
|
61
|
+
workspaceRoot?: string;
|
|
62
|
+
url: URL;
|
|
63
|
+
view: CompiledView;
|
|
64
|
+
params: Record<string, string>;
|
|
65
|
+
cookies: Record<string, string>;
|
|
66
|
+
headers: Record<string, string>;
|
|
67
|
+
auth: unknown;
|
|
68
|
+
session: Record<string, unknown> | null;
|
|
69
|
+
env: EnvAccessorLike;
|
|
70
|
+
logger: LoggerLike;
|
|
71
|
+
requestId?: string;
|
|
72
|
+
now?: () => Date;
|
|
73
|
+
}): Promise<RenderedRequestTimeView>;
|
|
74
|
+
export declare function toHeaderRecord(headers: Record<string, string | string[] | undefined>): Record<string, string>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { access, stat } from 'node:fs/promises';
|
|
3
|
+
import { resolveWorkspaceRoot } from '../workspace.js';
|
|
4
|
+
import { readTextFile } from '../utils/bun.js';
|
|
5
|
+
export function compileViews(views) {
|
|
6
|
+
const compiled = [];
|
|
7
|
+
for (const view of views) {
|
|
8
|
+
const pathPattern = normalizePath(view.definition?.path ?? '/');
|
|
9
|
+
compiled.push({
|
|
10
|
+
name: view.definition?.name ?? pathPattern,
|
|
11
|
+
pathPattern,
|
|
12
|
+
definition: view.definition,
|
|
13
|
+
load: view.load,
|
|
14
|
+
match: createPathMatcher(pathPattern),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return compiled;
|
|
18
|
+
}
|
|
19
|
+
export function matchView(views, pathname) {
|
|
20
|
+
for (const view of views) {
|
|
21
|
+
const matched = view.match(pathname);
|
|
22
|
+
if (matched.matched) {
|
|
23
|
+
return {
|
|
24
|
+
view,
|
|
25
|
+
params: matched.params,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
export async function renderRequestTimeView(options) {
|
|
32
|
+
const { workspaceRoot, url, view, params, cookies, headers, auth, session, env, logger, requestId, } = options;
|
|
33
|
+
const now = options.now ?? (() => new Date());
|
|
34
|
+
const document = await loadFrontendDocument(resolveWorkspaceRoot(workspaceRoot), url.pathname);
|
|
35
|
+
const viewData = view.load
|
|
36
|
+
? await view.load({
|
|
37
|
+
url,
|
|
38
|
+
params,
|
|
39
|
+
cookies,
|
|
40
|
+
headers,
|
|
41
|
+
auth,
|
|
42
|
+
session,
|
|
43
|
+
env,
|
|
44
|
+
logger,
|
|
45
|
+
requestId,
|
|
46
|
+
now,
|
|
47
|
+
})
|
|
48
|
+
: null;
|
|
49
|
+
return {
|
|
50
|
+
html: injectViewState(document.html, {
|
|
51
|
+
name: view.name,
|
|
52
|
+
templatePath: view.pathPattern,
|
|
53
|
+
pathname: normalizePath(url.pathname),
|
|
54
|
+
params,
|
|
55
|
+
data: viewData ?? null,
|
|
56
|
+
requestId,
|
|
57
|
+
}),
|
|
58
|
+
documentCache: {
|
|
59
|
+
status: document.cacheStatus,
|
|
60
|
+
documentPath: document.path,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function toHeaderRecord(headers) {
|
|
65
|
+
const normalized = {};
|
|
66
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
67
|
+
if (typeof value === 'string') {
|
|
68
|
+
normalized[key] = value;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
normalized[key] = value.join(', ');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return normalized;
|
|
76
|
+
}
|
|
77
|
+
function createPathMatcher(pattern) {
|
|
78
|
+
const normalized = normalizePath(pattern);
|
|
79
|
+
const paramRegex = /:([A-Za-z0-9_]+)/g;
|
|
80
|
+
const regex = new RegExp('^' +
|
|
81
|
+
normalized
|
|
82
|
+
.replace(/\//g, '\\/')
|
|
83
|
+
.replace(paramRegex, (_segment, name) => `(?<${name}>[^/]+)`) +
|
|
84
|
+
'$');
|
|
85
|
+
return (pathname) => {
|
|
86
|
+
const pathToTest = normalizePath(pathname);
|
|
87
|
+
const match = regex.exec(pathToTest);
|
|
88
|
+
if (!match) {
|
|
89
|
+
return { matched: false, params: {} };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
matched: true,
|
|
93
|
+
params: (match.groups ?? {}),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function normalizePath(value) {
|
|
98
|
+
if (!value || value === '/') {
|
|
99
|
+
return '/';
|
|
100
|
+
}
|
|
101
|
+
const trimmed = value.endsWith('/') ? value.slice(0, -1) : value;
|
|
102
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
103
|
+
}
|
|
104
|
+
function firstPathSegment(pathname) {
|
|
105
|
+
const normalized = normalizePath(pathname);
|
|
106
|
+
if (normalized === '/') {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
110
|
+
return parts[0];
|
|
111
|
+
}
|
|
112
|
+
const documentTemplateCache = new Map();
|
|
113
|
+
async function loadFrontendDocument(workspaceRoot, pathname) {
|
|
114
|
+
const documentPath = await resolveFrontendDocumentPath(workspaceRoot, pathname);
|
|
115
|
+
const documentStats = await stat(documentPath);
|
|
116
|
+
const cached = documentTemplateCache.get(documentPath);
|
|
117
|
+
if (cached &&
|
|
118
|
+
cached.ctimeMs === documentStats.ctimeMs &&
|
|
119
|
+
cached.mtimeMs === documentStats.mtimeMs &&
|
|
120
|
+
cached.size === documentStats.size) {
|
|
121
|
+
return {
|
|
122
|
+
path: documentPath,
|
|
123
|
+
html: cached.html,
|
|
124
|
+
cacheStatus: 'hit',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const html = await readTextFile(documentPath);
|
|
128
|
+
documentTemplateCache.set(documentPath, {
|
|
129
|
+
html,
|
|
130
|
+
ctimeMs: documentStats.ctimeMs,
|
|
131
|
+
mtimeMs: documentStats.mtimeMs,
|
|
132
|
+
size: documentStats.size,
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
path: documentPath,
|
|
136
|
+
html,
|
|
137
|
+
cacheStatus: cached ? 'stale' : 'miss',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async function resolveFrontendDocumentPath(workspaceRoot, pathname) {
|
|
141
|
+
const candidates = getFrontendDocumentCandidates(workspaceRoot, pathname);
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (await fileExists(candidate)) {
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`Frontend document for ${normalizePath(pathname)} was not found. Checked ${candidates.join(', ')}.`);
|
|
148
|
+
}
|
|
149
|
+
function getFrontendDocumentCandidates(workspaceRoot, pathname) {
|
|
150
|
+
const pageName = firstPathSegment(pathname) ?? 'home';
|
|
151
|
+
const relativeCandidates = pageName === 'home'
|
|
152
|
+
? [
|
|
153
|
+
path.join('pages', 'home', 'index.html'),
|
|
154
|
+
path.join('home', 'index.html'),
|
|
155
|
+
'home.html',
|
|
156
|
+
'index.html',
|
|
157
|
+
]
|
|
158
|
+
: [
|
|
159
|
+
path.join('pages', pageName, 'index.html'),
|
|
160
|
+
path.join(pageName, 'index.html'),
|
|
161
|
+
`${pageName}.html`,
|
|
162
|
+
];
|
|
163
|
+
const candidates = [
|
|
164
|
+
...relativeCandidates.map((relativePath) => path.join(workspaceRoot, 'build', 'frontend', relativePath)),
|
|
165
|
+
...relativeCandidates.map((relativePath) => path.join(workspaceRoot, 'dist', 'frontend', relativePath)),
|
|
166
|
+
];
|
|
167
|
+
return Array.from(new Set(candidates));
|
|
168
|
+
}
|
|
169
|
+
async function fileExists(targetPath) {
|
|
170
|
+
try {
|
|
171
|
+
await access(targetPath);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function injectViewState(documentHtml, state) {
|
|
179
|
+
const payload = serializeJsonForHtml({
|
|
180
|
+
view: {
|
|
181
|
+
name: state.name,
|
|
182
|
+
path: state.templatePath,
|
|
183
|
+
pathname: state.pathname,
|
|
184
|
+
params: state.params,
|
|
185
|
+
},
|
|
186
|
+
data: state.data,
|
|
187
|
+
requestId: state.requestId ?? null,
|
|
188
|
+
});
|
|
189
|
+
const scriptTag = `<script type="application/json" id="webstir-view-state">${payload}</script>`;
|
|
190
|
+
const htmlWithBodyAttributes = documentHtml.replace(/<body\b([^>]*)>/i, (_match, existingAttributes) => {
|
|
191
|
+
const attrs = [
|
|
192
|
+
`data-webstir-view-name="${escapeHtmlAttribute(state.name)}"`,
|
|
193
|
+
`data-webstir-view-pathname="${escapeHtmlAttribute(state.pathname)}"`,
|
|
194
|
+
`data-webstir-view-template="${escapeHtmlAttribute(state.templatePath)}"`,
|
|
195
|
+
];
|
|
196
|
+
return `<body${existingAttributes} ${attrs.join(' ')}>`;
|
|
197
|
+
});
|
|
198
|
+
if (/<\/body>/i.test(htmlWithBodyAttributes)) {
|
|
199
|
+
return htmlWithBodyAttributes.replace(/<\/body>/i, `${scriptTag}\n</body>`);
|
|
200
|
+
}
|
|
201
|
+
if (/<\/html>/i.test(htmlWithBodyAttributes)) {
|
|
202
|
+
return htmlWithBodyAttributes.replace(/<\/html>/i, `${scriptTag}\n</html>`);
|
|
203
|
+
}
|
|
204
|
+
return `${htmlWithBodyAttributes}\n${scriptTag}`;
|
|
205
|
+
}
|
|
206
|
+
function serializeJsonForHtml(value) {
|
|
207
|
+
return JSON.stringify(value)
|
|
208
|
+
.replace(/</g, '\\u003c')
|
|
209
|
+
.replace(/>/g, '\\u003e')
|
|
210
|
+
.replace(/&/g, '\\u0026')
|
|
211
|
+
.replace(/\u2028/g, '\\u2028')
|
|
212
|
+
.replace(/\u2029/g, '\\u2029');
|
|
213
|
+
}
|
|
214
|
+
function escapeHtmlAttribute(value) {
|
|
215
|
+
return value
|
|
216
|
+
.replaceAll('&', '&')
|
|
217
|
+
.replaceAll('"', '"')
|
|
218
|
+
.replaceAll("'", ''')
|
|
219
|
+
.replaceAll('<', '<')
|
|
220
|
+
.replaceAll('>', '>');
|
|
221
|
+
}
|