@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.
Files changed (123) hide show
  1. package/README.md +106 -79
  2. package/dist/add.d.ts +59 -0
  3. package/dist/add.js +626 -0
  4. package/dist/build/artifacts.d.ts +115 -1
  5. package/dist/build/artifacts.js +4 -4
  6. package/dist/build/entries.js +1 -1
  7. package/dist/build/pipeline.d.ts +33 -1
  8. package/dist/build/pipeline.js +307 -65
  9. package/dist/cache/diff.js +9 -8
  10. package/dist/cache/reporters.js +1 -1
  11. package/dist/deploy-cli.d.ts +2 -0
  12. package/dist/deploy-cli.js +86 -0
  13. package/dist/diagnostics/summary.js +2 -2
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +4 -0
  16. package/dist/manifest/pipeline.js +103 -32
  17. package/dist/provider.js +35 -17
  18. package/dist/runtime/bun.d.ts +51 -0
  19. package/dist/runtime/bun.js +499 -0
  20. package/dist/runtime/core.d.ts +141 -0
  21. package/dist/runtime/core.js +316 -0
  22. package/dist/runtime/deploy-backend.d.ts +20 -0
  23. package/dist/runtime/deploy-backend.js +175 -0
  24. package/dist/runtime/deploy-shared.d.ts +43 -0
  25. package/dist/runtime/deploy-shared.js +75 -0
  26. package/dist/runtime/deploy-static.d.ts +2 -0
  27. package/dist/runtime/deploy-static.js +161 -0
  28. package/dist/runtime/deploy.d.ts +3 -0
  29. package/dist/runtime/deploy.js +91 -0
  30. package/dist/runtime/forms.d.ts +73 -0
  31. package/dist/runtime/forms.js +236 -0
  32. package/dist/runtime/request-hooks.d.ts +47 -0
  33. package/dist/runtime/request-hooks.js +102 -0
  34. package/dist/runtime/session-metadata.d.ts +13 -0
  35. package/dist/runtime/session-metadata.js +98 -0
  36. package/dist/runtime/session-runtime.d.ts +28 -0
  37. package/dist/runtime/session-runtime.js +180 -0
  38. package/dist/runtime/session.d.ts +83 -0
  39. package/dist/runtime/session.js +396 -0
  40. package/dist/runtime/views.d.ts +74 -0
  41. package/dist/runtime/views.js +221 -0
  42. package/dist/scaffold/assets.js +25 -21
  43. package/dist/testing/context.js +1 -1
  44. package/dist/testing/index.d.ts +1 -1
  45. package/dist/testing/index.js +100 -56
  46. package/dist/utils/bun.d.ts +2 -0
  47. package/dist/utils/bun.js +13 -0
  48. package/dist/watch.d.ts +13 -1
  49. package/dist/watch.js +345 -97
  50. package/dist/workspace.d.ts +8 -0
  51. package/dist/workspace.js +44 -3
  52. package/package.json +49 -14
  53. package/scripts/publish.sh +2 -92
  54. package/scripts/smoke.mjs +282 -107
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/add.ts +964 -0
  57. package/src/build/artifacts.ts +49 -46
  58. package/src/build/entries.ts +12 -12
  59. package/src/build/pipeline.ts +779 -403
  60. package/src/cache/diff.ts +111 -105
  61. package/src/cache/reporters.ts +26 -26
  62. package/src/deploy-cli.ts +111 -0
  63. package/src/diagnostics/summary.ts +28 -22
  64. package/src/index.ts +11 -0
  65. package/src/manifest/pipeline.ts +328 -215
  66. package/src/provider.ts +115 -98
  67. package/src/runtime/bun.ts +793 -0
  68. package/src/runtime/core.ts +598 -0
  69. package/src/runtime/deploy-backend.ts +239 -0
  70. package/src/runtime/deploy-shared.ts +136 -0
  71. package/src/runtime/deploy-static.ts +191 -0
  72. package/src/runtime/deploy.ts +143 -0
  73. package/src/runtime/forms.ts +364 -0
  74. package/src/runtime/request-hooks.ts +165 -0
  75. package/src/runtime/session-metadata.ts +135 -0
  76. package/src/runtime/session-runtime.ts +267 -0
  77. package/src/runtime/session.ts +642 -0
  78. package/src/runtime/views.ts +385 -0
  79. package/src/scaffold/assets.ts +77 -73
  80. package/src/testing/context.js +8 -9
  81. package/src/testing/context.ts +9 -9
  82. package/src/testing/index.d.ts +14 -3
  83. package/src/testing/index.js +254 -175
  84. package/src/testing/index.ts +298 -195
  85. package/src/testing/types.d.ts +18 -19
  86. package/src/testing/types.ts +18 -18
  87. package/src/utils/bun.ts +26 -0
  88. package/src/watch.ts +503 -99
  89. package/src/workspace.ts +59 -3
  90. package/templates/backend/.env.example +15 -0
  91. package/templates/backend/auth/adapter.ts +335 -36
  92. package/templates/backend/db/connection.ts +190 -65
  93. package/templates/backend/db/migrate.ts +149 -43
  94. package/templates/backend/db/types.d.ts +1 -1
  95. package/templates/backend/env.ts +132 -20
  96. package/templates/backend/functions/hello/index.ts +1 -2
  97. package/templates/backend/index.ts +15 -508
  98. package/templates/backend/jobs/nightly/index.ts +1 -1
  99. package/templates/backend/jobs/runtime.ts +24 -11
  100. package/templates/backend/jobs/scheduler.ts +208 -46
  101. package/templates/backend/module.ts +227 -13
  102. package/templates/backend/observability/logger.ts +2 -12
  103. package/templates/backend/observability/metrics.ts +8 -5
  104. package/templates/backend/session/sqlite.ts +152 -0
  105. package/templates/backend/session/store.ts +45 -0
  106. package/templates/backend/tsconfig.json +1 -1
  107. package/tests/add.test.js +327 -0
  108. package/tests/authAdapter.test.js +315 -0
  109. package/tests/bundlerParity.test.js +217 -0
  110. package/tests/cacheReporter.test.js +10 -10
  111. package/tests/dbConnection.test.js +209 -0
  112. package/tests/deploy.test.js +357 -0
  113. package/tests/envLoader.test.js +271 -17
  114. package/tests/integration.test.js +2432 -3
  115. package/tests/jobsScheduler.test.js +253 -0
  116. package/tests/manifest.test.js +287 -12
  117. package/tests/migrationRunner.test.js +249 -0
  118. package/tests/sessionScaffoldStore.test.js +752 -0
  119. package/tests/sessionStore.test.js +490 -0
  120. package/tests/testing.test.js +252 -0
  121. package/tests/watch.test.js +192 -32
  122. package/tsconfig.json +3 -10
  123. package/templates/backend/server/fastify.ts +0 -288
@@ -0,0 +1,642 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+
3
+ import {
4
+ attachSessionRuntimeState,
5
+ cloneSessionRuntimeState,
6
+ coerceSessionRuntimeFormState,
7
+ hasSessionRuntimeState,
8
+ mergeSessionRuntimeState,
9
+ readSessionRuntimeState,
10
+ type SessionRuntimeState,
11
+ } from './session-runtime.js';
12
+ import {
13
+ attachSessionMetadata,
14
+ readSessionMetadata,
15
+ stripSessionMetadataFields,
16
+ type SessionMetadata,
17
+ type SessionMetadataInput,
18
+ } from './session-metadata.js';
19
+
20
+ export type FlashLevel = 'info' | 'success' | 'warning' | 'error';
21
+ export type FlashPublishCondition = 'always' | 'success' | 'error';
22
+
23
+ export interface SessionCookieConfig {
24
+ secret: string;
25
+ cookieName: string;
26
+ secure: boolean;
27
+ maxAgeSeconds: number;
28
+ path?: string;
29
+ sameSite?: 'Lax' | 'Strict' | 'None';
30
+ }
31
+
32
+ export interface SessionFlashMessage {
33
+ key: string;
34
+ level: FlashLevel;
35
+ createdAt: string;
36
+ }
37
+
38
+ export interface RouteSessionDefinitionLike {
39
+ mode?: 'optional' | 'required';
40
+ write?: boolean;
41
+ }
42
+
43
+ export interface RouteFlashMessageDefinitionLike {
44
+ key?: string;
45
+ level?: FlashLevel;
46
+ when?: FlashPublishCondition;
47
+ }
48
+
49
+ export interface RouteFlashDefinitionLike {
50
+ consume?: readonly string[];
51
+ publish?: readonly RouteFlashMessageDefinitionLike[];
52
+ }
53
+
54
+ export interface RouteFormDefinitionLike {
55
+ session?: RouteSessionDefinitionLike;
56
+ flash?: RouteFlashDefinitionLike;
57
+ }
58
+
59
+ export interface SessionAwareRouteDefinitionLike {
60
+ session?: RouteSessionDefinitionLike;
61
+ flash?: RouteFlashDefinitionLike;
62
+ form?: RouteFormDefinitionLike;
63
+ }
64
+
65
+ export interface SessionCommitResult<TSession> {
66
+ session: TSession | null;
67
+ setCookie?: string;
68
+ }
69
+
70
+ export interface PreparedSessionState<TSession, TResult> {
71
+ session: TSession | null;
72
+ flash: SessionFlashMessage[];
73
+ commit(options: {
74
+ session: TSession | null;
75
+ route?: SessionAwareRouteDefinitionLike;
76
+ result?: TResult;
77
+ }): SessionCommitResult<TSession>;
78
+ }
79
+
80
+ export interface SessionStoreRuntimeState extends SessionRuntimeState {
81
+ flash?: SessionFlashMessage[];
82
+ }
83
+
84
+ export interface SessionStoreRecord<
85
+ TSession extends Record<string, unknown> = Record<string, unknown>,
86
+ > {
87
+ id: string;
88
+ value: TSession;
89
+ flash?: SessionFlashMessage[];
90
+ runtime?: SessionStoreRuntimeState;
91
+ createdAt: string;
92
+ expiresAt: string;
93
+ }
94
+
95
+ export interface SessionStore<TSession extends Record<string, unknown> = Record<string, unknown>> {
96
+ get(sessionId: string): SessionStoreRecord<TSession> | undefined;
97
+ set(record: SessionStoreRecord<TSession>): void;
98
+ delete(sessionId: string): void;
99
+ }
100
+
101
+ export interface InMemorySessionStore<
102
+ TSession extends Record<string, unknown> = Record<string, unknown>,
103
+ > extends SessionStore<TSession> {
104
+ clear(): void;
105
+ }
106
+
107
+ const SESSION_STORE_KEY = Symbol.for('webstir.webstir-backend.session-store');
108
+ const LEGACY_FORM_RUNTIME_KEY = '__webstir_form_runtime';
109
+
110
+ export function prepareSessionState<
111
+ TSession extends Record<string, unknown>,
112
+ TResult extends { status?: number; errors?: unknown },
113
+ >(options: {
114
+ cookies?: Record<string, string> | string | string[];
115
+ route?: SessionAwareRouteDefinitionLike;
116
+ config: SessionCookieConfig;
117
+ store?: SessionStore<TSession>;
118
+ now?: () => Date;
119
+ }): PreparedSessionState<TSession, TResult> {
120
+ const now = options.now ?? (() => new Date());
121
+ const cookies = normalizeCookies(options.cookies);
122
+ const store = options.store ?? getDefaultSessionStore<TSession>();
123
+ const sessionCookie = cookies[options.config.cookieName];
124
+ const initialId = verifySignedSessionCookie(sessionCookie, options.config.secret);
125
+ const invalidCookie = Boolean(sessionCookie) && !initialId;
126
+ const initialRecord = initialId ? loadSessionRecord(store, initialId, now) : undefined;
127
+ const staleCookie = Boolean(initialId) && !initialRecord;
128
+ const delivered = resolveConsumedFlash(readStoredFlash(initialRecord), options.route);
129
+ const initialState = initialRecord ? restoreStoredSessionState(initialRecord) : undefined;
130
+ const initialSession = initialState?.session
131
+ ? attachSessionRuntimeState(
132
+ attachSessionMetadata(initialState.session, initialState.metadata),
133
+ initialState.runtime,
134
+ )
135
+ : null;
136
+ const hasPendingConsumption = delivered.flash.length > 0;
137
+
138
+ return {
139
+ session: initialSession,
140
+ flash: delivered.flash,
141
+ commit({ session, route, result }) {
142
+ const publishFlash = resolvePublishedFlash(route ?? options.route, result, now);
143
+ const normalized = normalizeSessionValue<TSession>(session);
144
+
145
+ if (initialRecord) {
146
+ store.delete(initialRecord.id);
147
+ }
148
+
149
+ const shouldPersist =
150
+ normalized.session !== null ||
151
+ publishFlash.length > 0 ||
152
+ (initialRecord !== undefined && delivered.remaining.length > 0) ||
153
+ hasPendingConsumption ||
154
+ hasSessionRuntimeState(normalized.runtime);
155
+
156
+ if (!shouldPersist) {
157
+ return {
158
+ session: null,
159
+ setCookie:
160
+ initialRecord || invalidCookie || staleCookie
161
+ ? serializeExpiredCookie(options.config)
162
+ : undefined,
163
+ };
164
+ }
165
+
166
+ const record = createStoredSessionRecord({
167
+ session: normalized.session,
168
+ runtime: normalized.runtime,
169
+ metadata: normalized.metadata,
170
+ fallbackId:
171
+ normalized.metadata?.id ?? (publishFlash.length > 0 ? undefined : initialRecord?.id),
172
+ initialRecord,
173
+ flash: [...delivered.remaining, ...publishFlash],
174
+ config: options.config,
175
+ now,
176
+ });
177
+
178
+ store.set(record);
179
+
180
+ return {
181
+ session: attachSessionRuntimeState(
182
+ attachSessionMetadata(cloneValue(record.value) as TSession, {
183
+ id: record.id,
184
+ createdAt: record.createdAt,
185
+ expiresAt: record.expiresAt,
186
+ }),
187
+ record.runtime,
188
+ ),
189
+ setCookie:
190
+ initialRecord?.id === record.id && !invalidCookie && !staleCookie
191
+ ? undefined
192
+ : serializeSessionCookie(record.id, options.config),
193
+ };
194
+ },
195
+ };
196
+ }
197
+
198
+ export function parseCookieHeader(header: string | string[] | undefined): Record<string, string> {
199
+ return normalizeCookies(header);
200
+ }
201
+
202
+ export function createInMemorySessionStore<
203
+ TSession extends Record<string, unknown> = Record<string, unknown>,
204
+ >(): InMemorySessionStore<TSession> {
205
+ const records = new Map<string, SessionStoreRecord<TSession>>();
206
+ return {
207
+ get(sessionId) {
208
+ const record = records.get(sessionId);
209
+ return record ? cloneStoredSessionRecord(record) : undefined;
210
+ },
211
+ set(record) {
212
+ records.set(record.id, cloneStoredSessionRecord(record));
213
+ },
214
+ delete(sessionId) {
215
+ records.delete(sessionId);
216
+ },
217
+ clear() {
218
+ records.clear();
219
+ },
220
+ };
221
+ }
222
+
223
+ export function resetInMemorySessionStore(
224
+ store: InMemorySessionStore<Record<string, unknown>> = getDefaultInMemorySessionStore(),
225
+ ): void {
226
+ store.clear();
227
+ }
228
+
229
+ function getDefaultSessionStore<
230
+ TSession extends Record<string, unknown>,
231
+ >(): SessionStore<TSession> {
232
+ return getDefaultInMemorySessionStore() as SessionStore<TSession>;
233
+ }
234
+
235
+ function getDefaultInMemorySessionStore(): InMemorySessionStore<Record<string, unknown>> {
236
+ const globalStore = globalThis as Record<PropertyKey, unknown>;
237
+ const existing = globalStore[SESSION_STORE_KEY];
238
+ if (isInMemorySessionStore(existing)) {
239
+ return existing;
240
+ }
241
+ const store = createInMemorySessionStore<Record<string, unknown>>();
242
+ globalStore[SESSION_STORE_KEY] = store;
243
+ return store;
244
+ }
245
+
246
+ function normalizeCookies(
247
+ input: Record<string, string> | string | string[] | undefined,
248
+ ): Record<string, string> {
249
+ if (!input) {
250
+ return {};
251
+ }
252
+ if (typeof input === 'object' && !Array.isArray(input)) {
253
+ return { ...input };
254
+ }
255
+ const raw = Array.isArray(input) ? input.join('; ') : input;
256
+ const cookies: Record<string, string> = {};
257
+ for (const part of raw.split(';')) {
258
+ const trimmed = part.trim();
259
+ if (!trimmed) {
260
+ continue;
261
+ }
262
+ const separatorIndex = trimmed.indexOf('=');
263
+ if (separatorIndex === -1) {
264
+ continue;
265
+ }
266
+ const name = trimmed.slice(0, separatorIndex).trim();
267
+ const value = trimmed.slice(separatorIndex + 1).trim();
268
+ if (!name) {
269
+ continue;
270
+ }
271
+ cookies[name] = decodeCookieValue(value);
272
+ }
273
+ return cookies;
274
+ }
275
+
276
+ function decodeCookieValue(value: string): string {
277
+ try {
278
+ return decodeURIComponent(value);
279
+ } catch {
280
+ return value;
281
+ }
282
+ }
283
+
284
+ function verifySignedSessionCookie(
285
+ cookieValue: string | undefined,
286
+ secret: string,
287
+ ): string | undefined {
288
+ if (!cookieValue) {
289
+ return undefined;
290
+ }
291
+ const separatorIndex = cookieValue.indexOf('.');
292
+ if (separatorIndex <= 0) {
293
+ return undefined;
294
+ }
295
+ const sessionId = cookieValue.slice(0, separatorIndex);
296
+ const signature = cookieValue.slice(separatorIndex + 1);
297
+ const expected = signSessionId(sessionId, secret);
298
+ const signatureBuffer = Buffer.from(signature);
299
+ const expectedBuffer = Buffer.from(expected);
300
+ if (signatureBuffer.length !== expectedBuffer.length) {
301
+ return undefined;
302
+ }
303
+ return timingSafeEqual(signatureBuffer, expectedBuffer) ? sessionId : undefined;
304
+ }
305
+
306
+ function signSessionId(sessionId: string, secret: string): string {
307
+ return createHmac('sha256', secret).update(sessionId).digest('base64url');
308
+ }
309
+
310
+ function loadSessionRecord<TSession extends Record<string, unknown>>(
311
+ store: SessionStore<TSession>,
312
+ sessionId: string,
313
+ now: () => Date,
314
+ ): SessionStoreRecord<TSession> | undefined {
315
+ const record = store.get(sessionId);
316
+ if (!record) {
317
+ return undefined;
318
+ }
319
+ const expiresAt = Date.parse(record.expiresAt);
320
+ if (Number.isFinite(expiresAt) && expiresAt <= now().getTime()) {
321
+ store.delete(sessionId);
322
+ return undefined;
323
+ }
324
+ return record;
325
+ }
326
+
327
+ function resolveConsumedFlash(
328
+ flash: readonly SessionFlashMessage[],
329
+ route: SessionAwareRouteDefinitionLike | undefined,
330
+ ): { flash: SessionFlashMessage[]; remaining: SessionFlashMessage[] } {
331
+ const consume = new Set<string>([
332
+ ...(route?.flash?.consume ?? []),
333
+ ...(route?.form?.flash?.consume ?? []),
334
+ ]);
335
+ if (consume.size === 0) {
336
+ return {
337
+ flash: [],
338
+ remaining: flash.map((message) => ({ ...message })),
339
+ };
340
+ }
341
+
342
+ const delivered: SessionFlashMessage[] = [];
343
+ const remaining: SessionFlashMessage[] = [];
344
+ for (const message of flash) {
345
+ if (consume.has(message.key)) {
346
+ delivered.push({ ...message });
347
+ continue;
348
+ }
349
+ remaining.push({ ...message });
350
+ }
351
+ return { flash: delivered, remaining };
352
+ }
353
+
354
+ function resolvePublishedFlash<TResult extends { status?: number; errors?: unknown }>(
355
+ route: SessionAwareRouteDefinitionLike | undefined,
356
+ result: TResult | undefined,
357
+ now: () => Date,
358
+ ): SessionFlashMessage[] {
359
+ const definitions = [...(route?.flash?.publish ?? []), ...(route?.form?.flash?.publish ?? [])];
360
+ if (definitions.length === 0) {
361
+ return [];
362
+ }
363
+
364
+ const condition = resolveFlashCondition(result);
365
+ return definitions
366
+ .filter((definition) => shouldPublishFlash(definition.when, condition))
367
+ .filter(
368
+ (definition): definition is RouteFlashMessageDefinitionLike & { key: string } =>
369
+ typeof definition.key === 'string' && definition.key.length > 0,
370
+ )
371
+ .map((definition) => ({
372
+ key: definition.key,
373
+ level: definition.level ?? (condition === 'error' ? 'error' : 'info'),
374
+ createdAt: now().toISOString(),
375
+ }));
376
+ }
377
+
378
+ function resolveFlashCondition(
379
+ result: { status?: number; errors?: unknown } | undefined,
380
+ ): FlashPublishCondition {
381
+ if (result?.errors) {
382
+ return 'error';
383
+ }
384
+ if ((result?.status ?? 200) >= 400) {
385
+ return 'error';
386
+ }
387
+ return 'success';
388
+ }
389
+
390
+ function shouldPublishFlash(
391
+ when: FlashPublishCondition | undefined,
392
+ condition: FlashPublishCondition,
393
+ ): boolean {
394
+ if (!when || when === 'always') {
395
+ return true;
396
+ }
397
+ return when === condition;
398
+ }
399
+
400
+ function normalizeSessionValue<TSession extends Record<string, unknown>>(
401
+ session: TSession | null,
402
+ ): { session: TSession | null; runtime?: SessionRuntimeState; metadata?: SessionMetadataInput } {
403
+ if (session === null) {
404
+ return { session: null };
405
+ }
406
+
407
+ const metadata = readSessionMetadata(session);
408
+ const cloned = cloneValue(session) as Record<string, unknown>;
409
+ stripSessionMetadataFields(cloned);
410
+ delete cloned[LEGACY_FORM_RUNTIME_KEY];
411
+
412
+ return {
413
+ session: cloned as TSession,
414
+ metadata,
415
+ runtime: readSessionRuntimeState(session),
416
+ };
417
+ }
418
+
419
+ function createStoredSessionRecord<TSession extends Record<string, unknown>>(options: {
420
+ session: TSession | null;
421
+ runtime?: SessionRuntimeState;
422
+ metadata?: SessionMetadataInput;
423
+ fallbackId?: string;
424
+ initialRecord?: SessionStoreRecord<TSession>;
425
+ flash: SessionFlashMessage[];
426
+ config: SessionCookieConfig;
427
+ now: () => Date;
428
+ }): SessionStoreRecord<TSession> {
429
+ const sessionValue = (options.session ?? {}) as Record<string, unknown>;
430
+ const sessionId = options.metadata?.id ?? options.fallbackId ?? randomUUID();
431
+ const createdAt =
432
+ options.metadata?.createdAt ?? options.initialRecord?.createdAt ?? options.now().toISOString();
433
+ const expiresAt =
434
+ options.metadata?.expiresAt ??
435
+ options.initialRecord?.expiresAt ??
436
+ new Date(options.now().getTime() + options.config.maxAgeSeconds * 1000).toISOString();
437
+
438
+ return {
439
+ id: sessionId,
440
+ value: { ...sessionValue } as unknown as TSession,
441
+ runtime: createSessionStoreRuntime(options.runtime, options.flash),
442
+ createdAt,
443
+ expiresAt,
444
+ };
445
+ }
446
+
447
+ function serializeSessionCookie(sessionId: string, config: SessionCookieConfig): string {
448
+ const value = `${encodeURIComponent(sessionId)}.${signSessionId(sessionId, config.secret)}`;
449
+ return serializeCookie(config, value, config.maxAgeSeconds);
450
+ }
451
+
452
+ function serializeExpiredCookie(config: SessionCookieConfig): string {
453
+ return serializeCookie(config, '', 0);
454
+ }
455
+
456
+ function serializeCookie(
457
+ config: SessionCookieConfig,
458
+ value: string,
459
+ maxAgeSeconds: number,
460
+ ): string {
461
+ const parts = [`${config.cookieName}=${value}`];
462
+ parts.push(`Path=${config.path ?? '/'}`);
463
+ parts.push(`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`);
464
+ parts.push(`SameSite=${config.sameSite ?? 'Lax'}`);
465
+ parts.push('HttpOnly');
466
+ if (config.secure) {
467
+ parts.push('Secure');
468
+ }
469
+ return parts.join('; ');
470
+ }
471
+
472
+ function normalizeText(value: unknown): string | undefined {
473
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
474
+ }
475
+
476
+ function normalizeDate(value: unknown): string | undefined {
477
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
478
+ return value.toISOString();
479
+ }
480
+ if (typeof value === 'string') {
481
+ const timestamp = Date.parse(value);
482
+ if (!Number.isNaN(timestamp)) {
483
+ return new Date(timestamp).toISOString();
484
+ }
485
+ }
486
+ return undefined;
487
+ }
488
+
489
+ function cloneValue<T>(value: T): T {
490
+ if (typeof structuredClone === 'function') {
491
+ return structuredClone(value);
492
+ }
493
+ return JSON.parse(JSON.stringify(value)) as T;
494
+ }
495
+
496
+ function cloneStoredSessionRecord<TSession extends Record<string, unknown>>(
497
+ record: SessionStoreRecord<TSession>,
498
+ ): SessionStoreRecord<TSession> {
499
+ const flash = cloneFlashMessages(record.flash);
500
+ const runtime = cloneSessionStoreRuntime(record.runtime);
501
+ return {
502
+ id: record.id,
503
+ value: cloneValue(record.value),
504
+ ...(flash.length > 0 ? { flash } : {}),
505
+ ...(runtime ? { runtime } : {}),
506
+ createdAt: record.createdAt,
507
+ expiresAt: record.expiresAt,
508
+ };
509
+ }
510
+
511
+ function restoreStoredSessionState<TSession extends Record<string, unknown>>(
512
+ record: SessionStoreRecord<TSession>,
513
+ ): { session: TSession; runtime?: SessionRuntimeState; metadata?: SessionMetadata } {
514
+ const session = cloneValue(record.value) as Record<string, unknown>;
515
+ const legacyRuntime = restoreLegacyFormRuntime(session);
516
+ const legacyMetadata = readSessionMetadata(session);
517
+ stripSessionMetadataFields(session);
518
+
519
+ return {
520
+ session: session as TSession,
521
+ metadata: {
522
+ id: normalizeText(record.id) ?? legacyMetadata?.id ?? randomUUID(),
523
+ createdAt:
524
+ normalizeDate(record.createdAt) ?? legacyMetadata?.createdAt ?? new Date(0).toISOString(),
525
+ expiresAt:
526
+ normalizeDate(record.expiresAt) ?? legacyMetadata?.expiresAt ?? new Date(0).toISOString(),
527
+ },
528
+ runtime: mergeSessionRuntimeState(readStoredRuntimeState(record), legacyRuntime),
529
+ };
530
+ }
531
+
532
+ function restoreLegacyFormRuntime(
533
+ session: Record<string, unknown>,
534
+ ): SessionRuntimeState | undefined {
535
+ const legacy = coerceSessionRuntimeFormState(session[LEGACY_FORM_RUNTIME_KEY]);
536
+ delete session[LEGACY_FORM_RUNTIME_KEY];
537
+ return legacy ? { form: legacy } : undefined;
538
+ }
539
+
540
+ function isInMemorySessionStore(
541
+ value: unknown,
542
+ ): value is InMemorySessionStore<Record<string, unknown>> {
543
+ return Boolean(
544
+ value &&
545
+ typeof value === 'object' &&
546
+ typeof (value as InMemorySessionStore<Record<string, unknown>>).get === 'function' &&
547
+ typeof (value as InMemorySessionStore<Record<string, unknown>>).set === 'function' &&
548
+ typeof (value as InMemorySessionStore<Record<string, unknown>>).delete === 'function' &&
549
+ typeof (value as InMemorySessionStore<Record<string, unknown>>).clear === 'function',
550
+ );
551
+ }
552
+
553
+ function readStoredFlash<TSession extends Record<string, unknown>>(
554
+ record: SessionStoreRecord<TSession> | undefined,
555
+ ): SessionFlashMessage[] {
556
+ if (!record) {
557
+ return [];
558
+ }
559
+ return cloneFlashMessages(record.runtime?.flash ?? record.flash);
560
+ }
561
+
562
+ function readStoredRuntimeState<TSession extends Record<string, unknown>>(
563
+ record: SessionStoreRecord<TSession>,
564
+ ): SessionRuntimeState | undefined {
565
+ return cloneSessionRuntimeState(record.runtime);
566
+ }
567
+
568
+ function createSessionStoreRuntime(
569
+ runtime: SessionRuntimeState | undefined,
570
+ flash: readonly SessionFlashMessage[],
571
+ ): SessionStoreRuntimeState | undefined {
572
+ const form = cloneSessionRuntimeState(runtime)?.form;
573
+ const storedFlash = cloneFlashMessages(flash);
574
+ if (!form && storedFlash.length === 0) {
575
+ return undefined;
576
+ }
577
+
578
+ return {
579
+ ...(form ? { form } : {}),
580
+ ...(storedFlash.length > 0 ? { flash: storedFlash } : {}),
581
+ };
582
+ }
583
+
584
+ function cloneSessionStoreRuntime(
585
+ runtime: SessionStoreRuntimeState | undefined,
586
+ ): SessionStoreRuntimeState | undefined {
587
+ if (!runtime) {
588
+ return undefined;
589
+ }
590
+
591
+ const form = cloneSessionRuntimeState(runtime)?.form;
592
+ const flash = cloneFlashMessages(runtime.flash);
593
+ if (!form && flash.length === 0) {
594
+ return undefined;
595
+ }
596
+
597
+ return {
598
+ ...(form ? { form } : {}),
599
+ ...(flash.length > 0 ? { flash } : {}),
600
+ };
601
+ }
602
+
603
+ function cloneFlashMessages(
604
+ flash: readonly SessionFlashMessage[] | undefined,
605
+ ): SessionFlashMessage[] {
606
+ if (!Array.isArray(flash)) {
607
+ return [];
608
+ }
609
+
610
+ return flash.flatMap((message) => {
611
+ if (
612
+ !isRecord(message) ||
613
+ typeof message.key !== 'string' ||
614
+ typeof message.createdAt !== 'string'
615
+ ) {
616
+ return [];
617
+ }
618
+
619
+ const level = normalizeFlashLevel(message.level);
620
+ if (!level) {
621
+ return [];
622
+ }
623
+
624
+ return [
625
+ {
626
+ key: message.key,
627
+ level,
628
+ createdAt: message.createdAt,
629
+ },
630
+ ];
631
+ });
632
+ }
633
+
634
+ function normalizeFlashLevel(value: unknown): FlashLevel | undefined {
635
+ return value === 'info' || value === 'success' || value === 'warning' || value === 'error'
636
+ ? value
637
+ : undefined;
638
+ }
639
+
640
+ function isRecord(value: unknown): value is Record<string, unknown> {
641
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
642
+ }