@webstir-io/webstir 0.1.1 → 0.1.3

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 (78) hide show
  1. package/README.md +13 -0
  2. package/assets/deployment/docker/.dockerignore +7 -0
  3. package/assets/deployment/docker/Dockerfile +17 -0
  4. package/assets/deployment/docker/README.md +44 -0
  5. package/assets/deployment/docker/example.env +3 -0
  6. package/assets/features/client_nav/client_nav.ts +369 -264
  7. package/assets/features/client_nav/document_navigation.ts +344 -0
  8. package/assets/features/client_nav/form_enhancement.ts +275 -0
  9. package/assets/templates/api/src/backend/index.ts +71 -10
  10. package/assets/templates/api/src/backend/tsconfig.json +6 -1
  11. package/assets/templates/full/src/backend/index.ts +71 -10
  12. package/assets/templates/full/src/backend/module.ts +515 -0
  13. package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +180 -0
  14. package/assets/templates/full/src/backend/tsconfig.json +6 -1
  15. package/assets/templates/full/src/frontend/app/scripts/features/client-nav.ts +574 -0
  16. package/assets/templates/full/src/frontend/app/scripts/features/document-navigation.ts +344 -0
  17. package/assets/templates/full/src/frontend/app/scripts/features/form-enhancement.ts +275 -0
  18. package/assets/templates/full/src/frontend/pages/home/index.css +8 -0
  19. package/assets/templates/full/src/frontend/pages/home/index.html +6 -1
  20. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +12 -2
  21. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +10 -2
  22. package/package.json +31 -13
  23. package/scripts/check-feature-projections.mjs +87 -0
  24. package/scripts/check-full-demo-sync.mjs +89 -0
  25. package/scripts/check-package-install.mjs +537 -0
  26. package/scripts/check-standalone-install.mjs +221 -0
  27. package/scripts/pack-standalone.mjs +52 -28
  28. package/scripts/publish.sh +9 -0
  29. package/scripts/run-tests.mjs +103 -0
  30. package/scripts/sync-assets.mjs +175 -17
  31. package/src/add-backend-compat.ts +628 -0
  32. package/src/add-backend.ts +155 -27
  33. package/src/add.ts +111 -4
  34. package/src/agent.ts +393 -0
  35. package/src/api-watch.ts +7 -4
  36. package/src/backend-inspect.ts +70 -2
  37. package/src/backend-runtime.ts +22 -14
  38. package/src/build.ts +1 -3
  39. package/src/bun-generated-frontend-watch.ts +209 -0
  40. package/src/bun-globals.d.ts +23 -0
  41. package/src/bun-spa-document.ts +310 -0
  42. package/src/bun-spa-routes.ts +159 -0
  43. package/src/bun-spa-watch.ts +29 -0
  44. package/src/bun-ssg-watch.ts +304 -0
  45. package/src/cli.ts +381 -50
  46. package/src/compile-tests.ts +37 -29
  47. package/src/dev-server.ts +215 -144
  48. package/src/doctor.ts +164 -0
  49. package/src/enable-assets.ts +18 -1
  50. package/src/enable.ts +133 -41
  51. package/src/execute.ts +30 -4
  52. package/src/external-workspace.ts +178 -0
  53. package/src/format.ts +296 -17
  54. package/src/frontend-inspect.ts +32 -0
  55. package/src/frontend-watch.ts +27 -102
  56. package/src/full-watch.ts +13 -18
  57. package/src/index.ts +7 -0
  58. package/src/init-assets.ts +41 -11
  59. package/src/init.ts +85 -71
  60. package/src/inspect.ts +112 -0
  61. package/src/mcp/run-cli-json.ts +46 -0
  62. package/src/mcp/server.ts +307 -0
  63. package/src/operations.ts +176 -0
  64. package/src/providers.ts +20 -18
  65. package/src/refresh.ts +29 -3
  66. package/src/repair.ts +110 -43
  67. package/src/runtime-filter.ts +41 -0
  68. package/src/runtime.ts +1 -1
  69. package/src/smoke.ts +48 -16
  70. package/src/test.ts +54 -16
  71. package/src/testing-runtime.ts +273 -0
  72. package/src/types.ts +1 -4
  73. package/src/watch-events.ts +46 -17
  74. package/src/watch.ts +25 -14
  75. package/src/workspace-lock.ts +207 -0
  76. package/src/workspace-watcher.ts +10 -6
  77. package/src/workspace.ts +4 -2
  78. package/src/watch-daemon-client.ts +0 -171
@@ -2,12 +2,17 @@
2
2
  "extends": "../../base.tsconfig.json",
3
3
  "compilerOptions": {
4
4
  "target": "ES2022",
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
5
7
  "lib": ["ES2022"],
6
8
  "outDir": "../../build/backend",
7
9
  "rootDir": ".",
8
10
  "baseUrl": ".",
9
11
  "incremental": true,
10
- "tsBuildInfoFile": "../../build/backend/.tsbuildinfo"
12
+ "tsBuildInfoFile": "../../build/backend/.tsbuildinfo",
13
+ "types": ["node"],
14
+ "skipLibCheck": true,
15
+ "resolveJsonModule": true
11
16
  },
12
17
  "include": ["**/*.ts", "../../types/**/*.d.ts"],
13
18
  "exclude": ["node_modules", "../../build", "../../dist"],
@@ -1,13 +1,74 @@
1
- import http from 'node:http';
1
+ import crypto from 'node:crypto';
2
2
 
3
- const PORT = Number(process.env.PORT || 4321);
4
- const server = http.createServer((req, res) => {
5
- res.writeHead(200, { 'Content-Type': 'text/plain' });
6
- res.end('API server running');
7
- });
3
+ import {
4
+ createDefaultBunBackendBootstrap,
5
+ startBunBackend,
6
+ type BunRuntimeEnvLike,
7
+ } from '@webstir-io/webstir-backend';
8
8
 
9
- server.listen(PORT, '0.0.0.0', () => {
10
- console.log(`API server running at http://localhost:${PORT}`);
11
- });
9
+ type BackendEnv = BunRuntimeEnvLike<Record<string, never>, Record<string, never>>;
12
10
 
13
- export default server;
11
+ const GENERATED_SESSION_SECRET = crypto.randomBytes(32).toString('hex');
12
+
13
+ function loadEnv(): BackendEnv {
14
+ const port = Number(process.env.PORT ?? '4321');
15
+
16
+ return {
17
+ NODE_ENV: process.env.NODE_ENV ?? 'development',
18
+ PORT: Number.isFinite(port) ? port : 4321,
19
+ auth: {},
20
+ metrics: {},
21
+ http: {
22
+ bodyLimitBytes: 1_048_576,
23
+ },
24
+ sessions: {
25
+ secret: resolveSessionSecret(),
26
+ cookieName: 'webstir_session',
27
+ secure: false,
28
+ maxAgeSeconds: 60 * 60 * 24 * 7,
29
+ path: '/',
30
+ sameSite: 'Lax',
31
+ },
32
+ };
33
+ }
34
+
35
+ function resolveSessionSecret(): string {
36
+ const explicitSecret = process.env.SESSION_SECRET?.trim();
37
+ if (explicitSecret) {
38
+ return explicitSecret;
39
+ }
40
+
41
+ if ((process.env.NODE_ENV ?? 'development').trim().toLowerCase() === 'production') {
42
+ throw new Error('SESSION_SECRET is required when NODE_ENV=production.');
43
+ }
44
+
45
+ return process.env.AUTH_JWT_SECRET ?? GENERATED_SESSION_SECRET;
46
+ }
47
+
48
+ export async function start(): Promise<void> {
49
+ await startBunBackend(
50
+ createDefaultBunBackendBootstrap({
51
+ importMetaUrl: import.meta.url,
52
+ loadEnv,
53
+ }),
54
+ );
55
+ }
56
+
57
+ const isMain = (() => {
58
+ try {
59
+ const argv1 = process.argv?.[1];
60
+ if (!argv1) return false;
61
+ const here = new URL(import.meta.url);
62
+ const run = new URL(`file://${argv1}`);
63
+ return here.pathname === run.pathname;
64
+ } catch {
65
+ return false;
66
+ }
67
+ })();
68
+
69
+ if (isMain) {
70
+ start().catch((err) => {
71
+ console.error(err);
72
+ process.exitCode = 1;
73
+ });
74
+ }
@@ -0,0 +1,515 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import type { RouteHandlerResult } from '@webstir-io/webstir-backend/runtime/bun';
6
+
7
+ const ROOT_PATH = '/';
8
+ const DEMO_PATH = '/demo/progressive-enhancement';
9
+ const FRAGMENT_TARGET = 'greeting-preview';
10
+ const SESSION_PANEL_TARGET = 'session-panel';
11
+ const SESSION_COOKIE_NAME = 'webstir_demo_session';
12
+ const SESSION_SIGN_IN_ACTION = './progressive-enhancement/session/sign-in';
13
+ const SESSION_SIGN_OUT_ACTION = './progressive-enhancement/session/sign-out';
14
+ const DEV_FRONTEND_ASSETS = {
15
+ cssHref: '/src/frontend/app/app.css',
16
+ scriptSrc: '/src/frontend/app/app.ts',
17
+ } as const;
18
+
19
+ type BunCookieMapInstance = {
20
+ get(name: string): string | null;
21
+ set(options: {
22
+ name: string;
23
+ value: string;
24
+ path?: string;
25
+ httpOnly?: boolean;
26
+ sameSite?: 'lax' | 'strict' | 'none';
27
+ maxAge?: number;
28
+ expires?: Date | number | string;
29
+ }): void;
30
+ toSetCookieHeaders(): string[];
31
+ };
32
+
33
+ type BunCookieMapConstructor = new (
34
+ init?: string[][] | Record<string, string> | string
35
+ ) => BunCookieMapInstance;
36
+
37
+ type BunRuntime = {
38
+ CookieMap?: BunCookieMapConstructor;
39
+ };
40
+
41
+ interface RouteMatch {
42
+ readonly name: string;
43
+ readonly method: 'GET' | 'POST';
44
+ readonly path: string;
45
+ readonly summary: string;
46
+ readonly interaction?: 'navigation' | 'mutation';
47
+ readonly form?: {
48
+ readonly contentType: 'application/x-www-form-urlencoded';
49
+ };
50
+ readonly fragment?: {
51
+ readonly target: string;
52
+ readonly selector?: string;
53
+ readonly mode?: 'replace' | 'append' | 'prepend';
54
+ };
55
+ }
56
+
57
+ interface RouteContext {
58
+ readonly request: Request;
59
+ readonly query: Record<string, string>;
60
+ readonly body: unknown;
61
+ }
62
+
63
+ interface DemoRoute {
64
+ readonly definition: RouteMatch;
65
+ readonly handler: (context: RouteContext) => Promise<RouteHandlerResult> | RouteHandlerResult;
66
+ }
67
+
68
+ function readSubmittedName(body: unknown): string {
69
+ return readSubmittedText(body, 'name', 'Webstir');
70
+ }
71
+
72
+ function readSubmittedSessionName(body: unknown): string {
73
+ return readSubmittedText(body, 'sessionName', 'Webstir User');
74
+ }
75
+
76
+ function readSubmittedText(body: unknown, key: string, fallback: string): string {
77
+ if (!body || typeof body !== 'object') {
78
+ return fallback;
79
+ }
80
+
81
+ const rawValue = (body as Record<string, unknown>)[key];
82
+ const normalized = typeof rawValue === 'string' ? rawValue.trim() : '';
83
+ return normalized || fallback;
84
+ }
85
+
86
+ function readQueryName(query: Record<string, string>): string {
87
+ const normalized = String(query.name ?? '').trim();
88
+ return normalized || 'Webstir';
89
+ }
90
+
91
+ function readSessionQueryState(query: Record<string, string>): 'signed-in' | 'signed-out' | 'none' {
92
+ const normalized = String(query.session ?? '').trim().toLowerCase();
93
+ if (normalized === 'signed-in' || normalized === 'signed-out') {
94
+ return normalized;
95
+ }
96
+ return 'none';
97
+ }
98
+
99
+ function isEnhancedRequest(request: Request): boolean {
100
+ return request.headers.get('x-webstir-client-nav') === '1';
101
+ }
102
+
103
+ function escapeHtml(value: string): string {
104
+ return value
105
+ .replaceAll('&', '&amp;')
106
+ .replaceAll('<', '&lt;')
107
+ .replaceAll('>', '&gt;')
108
+ .replaceAll('"', '&quot;')
109
+ .replaceAll("'", '&#39;');
110
+ }
111
+
112
+ function renderGreeting(name: string, source: 'baseline' | 'redirect' | 'fragment'): string {
113
+ const escapedName = escapeHtml(name);
114
+ const message =
115
+ source === 'redirect'
116
+ ? 'The browser completed a full-page redirect after the form POST.'
117
+ : source === 'fragment'
118
+ ? 'JavaScript enhancement can replace just this region without a full reload.'
119
+ : 'Submit the form with or without JavaScript to compare the two flows.';
120
+ const focusBadge =
121
+ source === 'fragment'
122
+ ? ' <button type="button" id="greeting-update-focus" class="chip" autofocus>Greeting updated</button>\n'
123
+ : '';
124
+
125
+ return [
126
+ `<section id="${FRAGMENT_TARGET}" data-webstir-fragment-target="${FRAGMENT_TARGET}" aria-live="polite">`,
127
+ focusBadge.trimEnd(),
128
+ ` <h2>Hello, ${escapedName}</h2>`,
129
+ ` <p>${message}</p>`,
130
+ '</section>',
131
+ ]
132
+ .filter(Boolean)
133
+ .join('\n');
134
+ }
135
+
136
+ function renderSessionPanel(
137
+ sessionName: string | null,
138
+ state: 'baseline' | 'signed-in' | 'signed-out' | 'fragment',
139
+ ): string {
140
+ const escapedSessionName = sessionName ? escapeHtml(sessionName) : null;
141
+ const status = sessionName
142
+ ? state === 'signed-in'
143
+ ? `Signed in as <strong>${escapedSessionName}</strong> via the no-JavaScript redirect path.`
144
+ : `Signed in as <strong>${escapedSessionName}</strong>. Reload the page to confirm the session persists.`
145
+ : state === 'signed-out'
146
+ ? 'Signed out via the no-JavaScript redirect path.'
147
+ : state === 'fragment'
148
+ ? 'Signed out without a full page reload.'
149
+ : 'Not signed in. Submit the form to create a cookie-backed session.';
150
+
151
+ if (sessionName) {
152
+ return [
153
+ `<section id="${SESSION_PANEL_TARGET}" data-webstir-fragment-target="${SESSION_PANEL_TARGET}" aria-live="polite" class="card stack">`,
154
+ ' <h2>Session demo</h2>',
155
+ ` <p class="status" id="session-status">${status}</p>`,
156
+ ` <p id="session-user" data-session-user="${escapedSessionName}">${escapedSessionName}</p>`,
157
+ ` <form method="post" action="${SESSION_SIGN_OUT_ACTION}">`,
158
+ ' <button id="demo-sign-out" type="submit">Sign out</button>',
159
+ ' </form>',
160
+ '</section>',
161
+ ].join('\n');
162
+ }
163
+
164
+ return [
165
+ `<section id="${SESSION_PANEL_TARGET}" data-webstir-fragment-target="${SESSION_PANEL_TARGET}" aria-live="polite" class="card stack">`,
166
+ ' <h2>Session demo</h2>',
167
+ ` <p class="status" id="session-status">${status}</p>`,
168
+ ` <form method="post" action="${SESSION_SIGN_IN_ACTION}" class="stack">`,
169
+ ' <label for="session-name">Session name</label>',
170
+ ' <input id="session-name" name="sessionName" value="Webstir User" autocomplete="username" />',
171
+ ' <button id="demo-sign-in" type="submit">Sign in</button>',
172
+ ' </form>',
173
+ '</section>',
174
+ ].join('\n');
175
+ }
176
+
177
+ function renderDemoPage(
178
+ name: string,
179
+ source: 'baseline' | 'redirect',
180
+ sessionName: string | null,
181
+ sessionState: 'signed-in' | 'signed-out' | 'none',
182
+ ): string {
183
+ const escapedName = escapeHtml(name);
184
+ const assets = resolveFrontendAssets();
185
+ const status =
186
+ source === 'redirect'
187
+ ? '<p class="status">Last submit used the no-JavaScript redirect path.</p>'
188
+ : '<p class="status">This page starts with server-first HTML. Opt into <code>client-nav</code> if you want fragment updates.</p>';
189
+ const sessionPanelState = sessionName
190
+ ? sessionState === 'signed-in'
191
+ ? 'signed-in'
192
+ : 'baseline'
193
+ : sessionState === 'signed-out'
194
+ ? 'signed-out'
195
+ : 'baseline';
196
+
197
+ return `<!DOCTYPE html>
198
+ <html lang="en">
199
+ <head>
200
+ <meta charset="utf-8" />
201
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
202
+ <title>Progressive Enhancement Demo</title>
203
+ <link rel="stylesheet" href="${assets.cssHref}" />
204
+ <style>
205
+ body { background: #f6f7f3; color: #132019; }
206
+ main { max-width: 52rem; margin: 0 auto; padding: 3rem 1.5rem 4rem; }
207
+ .stack { display: grid; gap: 1.25rem; }
208
+ .card { background: #fffdf8; border: 1px solid #d7dfd4; border-radius: 1rem; padding: 1.25rem; box-shadow: 0 1rem 2rem rgba(19, 32, 25, 0.06); }
209
+ .status { color: #456152; margin: 0; }
210
+ .chip { width: fit-content; padding: 0.4rem 0.75rem; border: 1px solid #b8c5b7; border-radius: 999px; background: #edf3e9; color: #1d6b45; }
211
+ form { display: grid; gap: 0.75rem; }
212
+ label { font-weight: 600; }
213
+ input, button { font: inherit; }
214
+ input { padding: 0.75rem 0.9rem; border: 1px solid #b8c5b7; border-radius: 0.75rem; }
215
+ button { width: fit-content; padding: 0.75rem 1rem; border: 0; border-radius: 999px; background: #1d6b45; color: white; cursor: pointer; }
216
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
217
+ </style>
218
+ <script type="module" src="${assets.scriptSrc}"></script>
219
+ </head>
220
+ <body>
221
+ <main class="stack">
222
+ <header class="stack">
223
+ <p><a href="/">Back to the full demo home page</a></p>
224
+ <div class="stack">
225
+ <h1>Progressive enhancement form flow</h1>
226
+ <p>This route is served by the backend runtime. The form still works without client JavaScript, and <code>client-nav</code> remains an optional enhancement for fragment updates.</p>
227
+ ${status}
228
+ </div>
229
+ </header>
230
+ ${renderSessionPanel(sessionName, sessionPanelState)}
231
+ <section class="card stack">
232
+ <form id="greeting-form" method="post">
233
+ <label for="demo-name">Name</label>
234
+ <input id="demo-name" name="name" value="${escapedName}" autocomplete="name" autofocus />
235
+ <button id="demo-update-greeting" type="submit">Update greeting</button>
236
+ </form>
237
+ ${renderGreeting(name, source)}
238
+ </section>
239
+ </main>
240
+ </body>
241
+ </html>`;
242
+ }
243
+
244
+ const rootStatusRoute: DemoRoute = {
245
+ definition: {
246
+ name: 'rootStatusPage',
247
+ method: 'GET',
248
+ path: ROOT_PATH,
249
+ summary: 'Render the default backend status page.',
250
+ interaction: 'navigation',
251
+ },
252
+ handler: () => ({
253
+ status: 200,
254
+ headers: {
255
+ 'content-type': 'text/plain; charset=utf-8',
256
+ },
257
+ body: 'API server running',
258
+ }),
259
+ };
260
+
261
+ const progressiveEnhancementPageRoute: DemoRoute = {
262
+ definition: {
263
+ name: 'progressiveEnhancementPage',
264
+ method: 'GET',
265
+ path: DEMO_PATH,
266
+ summary: 'Render the progressive enhancement form demo.',
267
+ interaction: 'navigation',
268
+ },
269
+ handler: (context) => {
270
+ const source = context.query.source === 'redirect' ? 'redirect' : 'baseline';
271
+ const name = readQueryName(context.query);
272
+ const sessionName = readSessionName(context.request);
273
+ const sessionState = readSessionQueryState(context.query);
274
+
275
+ return {
276
+ status: 200,
277
+ body: renderDemoPage(name, source, sessionName, sessionState),
278
+ };
279
+ },
280
+ };
281
+
282
+ const progressiveEnhancementSubmitRoute: DemoRoute = {
283
+ definition: {
284
+ name: 'progressiveEnhancementSubmit',
285
+ method: 'POST',
286
+ path: DEMO_PATH,
287
+ summary: 'Handle the progressive enhancement form submission.',
288
+ interaction: 'mutation',
289
+ form: {
290
+ contentType: 'application/x-www-form-urlencoded',
291
+ },
292
+ fragment: {
293
+ target: FRAGMENT_TARGET,
294
+ selector: `#${FRAGMENT_TARGET}`,
295
+ mode: 'replace',
296
+ },
297
+ },
298
+ handler: (context) => {
299
+ const name = readSubmittedName(context.body);
300
+ if (isEnhancedRequest(context.request)) {
301
+ return {
302
+ status: 200,
303
+ fragment: {
304
+ target: FRAGMENT_TARGET,
305
+ selector: `#${FRAGMENT_TARGET}`,
306
+ mode: 'replace',
307
+ body: renderGreeting(name, 'fragment'),
308
+ },
309
+ };
310
+ }
311
+
312
+ return {
313
+ status: 303,
314
+ redirect: {
315
+ location: `${DEMO_PATH}?source=redirect&name=${encodeURIComponent(name)}`,
316
+ },
317
+ };
318
+ },
319
+ };
320
+
321
+ const sessionSignInRoute: DemoRoute = {
322
+ definition: {
323
+ name: 'progressiveEnhancementSessionSignIn',
324
+ method: 'POST',
325
+ path: `${DEMO_PATH}/session/sign-in`,
326
+ summary: 'Create a demo session for the progressive enhancement page.',
327
+ interaction: 'mutation',
328
+ form: {
329
+ contentType: 'application/x-www-form-urlencoded',
330
+ },
331
+ fragment: {
332
+ target: SESSION_PANEL_TARGET,
333
+ selector: `#${SESSION_PANEL_TARGET}`,
334
+ mode: 'replace',
335
+ },
336
+ },
337
+ handler: (context) => {
338
+ const sessionName = readSubmittedSessionName(context.body);
339
+ const headers = {
340
+ 'set-cookie': createSessionCookie(sessionName),
341
+ };
342
+
343
+ if (isEnhancedRequest(context.request)) {
344
+ return {
345
+ status: 200,
346
+ headers,
347
+ fragment: {
348
+ target: SESSION_PANEL_TARGET,
349
+ selector: `#${SESSION_PANEL_TARGET}`,
350
+ mode: 'replace',
351
+ body: renderSessionPanel(sessionName, 'fragment'),
352
+ },
353
+ };
354
+ }
355
+
356
+ return {
357
+ status: 303,
358
+ headers,
359
+ redirect: {
360
+ location: `${DEMO_PATH}?session=signed-in`,
361
+ },
362
+ };
363
+ },
364
+ };
365
+
366
+ const sessionSignOutRoute: DemoRoute = {
367
+ definition: {
368
+ name: 'progressiveEnhancementSessionSignOut',
369
+ method: 'POST',
370
+ path: `${DEMO_PATH}/session/sign-out`,
371
+ summary: 'Clear the demo session for the progressive enhancement page.',
372
+ interaction: 'mutation',
373
+ form: {
374
+ contentType: 'application/x-www-form-urlencoded',
375
+ },
376
+ fragment: {
377
+ target: SESSION_PANEL_TARGET,
378
+ selector: `#${SESSION_PANEL_TARGET}`,
379
+ mode: 'replace',
380
+ },
381
+ },
382
+ handler: (context) => {
383
+ const headers = {
384
+ 'set-cookie': clearSessionCookie(),
385
+ };
386
+
387
+ if (isEnhancedRequest(context.request)) {
388
+ return {
389
+ status: 200,
390
+ headers,
391
+ fragment: {
392
+ target: SESSION_PANEL_TARGET,
393
+ selector: `#${SESSION_PANEL_TARGET}`,
394
+ mode: 'replace',
395
+ body: renderSessionPanel(null, 'fragment'),
396
+ },
397
+ };
398
+ }
399
+
400
+ return {
401
+ status: 303,
402
+ headers,
403
+ redirect: {
404
+ location: `${DEMO_PATH}?session=signed-out`,
405
+ },
406
+ };
407
+ },
408
+ };
409
+
410
+ const routes: readonly DemoRoute[] = [
411
+ rootStatusRoute,
412
+ progressiveEnhancementPageRoute,
413
+ progressiveEnhancementSubmitRoute,
414
+ sessionSignInRoute,
415
+ sessionSignOutRoute,
416
+ ];
417
+
418
+ export const module = {
419
+ manifest: {
420
+ contractVersion: '1.0.0',
421
+ name: '@demo/full-progressive-enhancement',
422
+ version: '1.0.0',
423
+ kind: 'backend',
424
+ capabilities: ['http'],
425
+ routes: routes.map((route) => route.definition),
426
+ },
427
+ routes,
428
+ };
429
+
430
+ function readSessionName(request: Request): string | null {
431
+ const cookies = new (requireBunCookieMap())(request.headers.get('cookie') ?? '');
432
+ return cookies.get(SESSION_COOKIE_NAME);
433
+ }
434
+
435
+ function createSessionCookie(sessionName: string): string {
436
+ const cookies = new (requireBunCookieMap())();
437
+ cookies.set({
438
+ name: SESSION_COOKIE_NAME,
439
+ value: sessionName,
440
+ path: '/',
441
+ httpOnly: true,
442
+ sameSite: 'lax',
443
+ maxAge: 3600,
444
+ });
445
+ const [header] = cookies.toSetCookieHeaders();
446
+ if (!header) {
447
+ throw new Error('Bun.CookieMap did not serialize the progressive-enhancement session cookie.');
448
+ }
449
+ return header;
450
+ }
451
+
452
+ function clearSessionCookie(): string {
453
+ const cookies = new (requireBunCookieMap())();
454
+ cookies.set({
455
+ name: SESSION_COOKIE_NAME,
456
+ value: '',
457
+ path: '/',
458
+ httpOnly: true,
459
+ sameSite: 'lax',
460
+ maxAge: 0,
461
+ expires: new Date(0),
462
+ });
463
+ const [header] = cookies.toSetCookieHeaders();
464
+ if (!header) {
465
+ throw new Error('Bun.CookieMap did not serialize the progressive-enhancement session clear cookie.');
466
+ }
467
+ return header;
468
+ }
469
+
470
+ function requireBunCookieMap(): BunCookieMapConstructor {
471
+ const runtime = (globalThis as typeof globalThis & { Bun?: BunRuntime }).Bun;
472
+ const CookieMap = runtime?.CookieMap;
473
+ if (!CookieMap) {
474
+ throw new Error('This demo requires Bun.CookieMap.');
475
+ }
476
+ return CookieMap;
477
+ }
478
+
479
+ function resolveFrontendAssets(): { cssHref: string; scriptSrc: string } {
480
+ if (process.env.WEBSTIR_FRONTEND_DEV_SERVER === '1') {
481
+ return DEV_FRONTEND_ASSETS;
482
+ }
483
+
484
+ const manifestPath = path.join(resolveWorkspaceRoot(), 'dist', 'frontend', 'manifest.json');
485
+ if (!existsSync(manifestPath)) {
486
+ return DEV_FRONTEND_ASSETS;
487
+ }
488
+
489
+ try {
490
+ const raw = readFileSync(manifestPath, 'utf8');
491
+ const parsed = JSON.parse(raw) as {
492
+ shared?: {
493
+ css?: string;
494
+ js?: string;
495
+ };
496
+ };
497
+
498
+ const sharedCss = parsed.shared?.css;
499
+ const sharedJs = parsed.shared?.js;
500
+ if (!sharedCss || !sharedJs) {
501
+ return DEV_FRONTEND_ASSETS;
502
+ }
503
+
504
+ return {
505
+ cssHref: `/app/${sharedCss}`,
506
+ scriptSrc: `/app/${sharedJs}`,
507
+ };
508
+ } catch {
509
+ return DEV_FRONTEND_ASSETS;
510
+ }
511
+ }
512
+
513
+ function resolveWorkspaceRoot(): string {
514
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
515
+ }