redweb 0.13.3 → 0.14.0

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 (63) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +23 -33
  3. package/bin/redweb.js +1 -1
  4. package/docs/APPLICATION.md +98 -0
  5. package/docs/CLI.md +1 -1
  6. package/docs/COVERAGE_SCOPE_AUDIT.md +7 -0
  7. package/docs/DEFINE_APP_VERIFICATION.md +101 -0
  8. package/docs/DEVELOPMENT.md +1 -1
  9. package/docs/GETTING_STARTED.md +1 -1
  10. package/docs/LIVE_HTML.md +2 -2
  11. package/docs/MIGRATION.md +1 -1
  12. package/docs/MULTIPLAYER_OPERATIONS.md +6 -0
  13. package/docs/RELEASE_TRUST.md +4 -4
  14. package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
  15. package/docs/SOCKET_CONTRACTS.md +2 -2
  16. package/docs/STARTER_LIFECYCLE_VERIFICATION.md +9 -0
  17. package/docs/generated.json +327 -273
  18. package/docs/guides/http-websocket.md +4 -4
  19. package/docs/guides/jsx-without-react.md +1 -1
  20. package/docs/reference.json +40 -1
  21. package/docs/releases/0.13.4.json +2154 -0
  22. package/docs/releases/0.13.5.json +2154 -0
  23. package/docs/releases/0.14.0.json +2208 -0
  24. package/docs/topics.json +1 -0
  25. package/examples/live-html/cards.js +87 -86
  26. package/examples/live-html/cards.ts +3 -2
  27. package/examples/live-html/chatroom.js +210 -207
  28. package/examples/live-html/chatroom.tsx +6 -2
  29. package/examples/live-html/components.js +103 -102
  30. package/examples/live-html/components.ts +3 -2
  31. package/examples/live-html/counter.js +74 -73
  32. package/examples/live-html/counter.ts +3 -2
  33. package/examples/live-html/jsx-page.js +2 -1
  34. package/examples/live-html/jsx-page.tsx +3 -2
  35. package/index.d.ts +53 -5
  36. package/index.js +3 -0
  37. package/package.json +5 -4
  38. package/recipes/chat/app.test.cjs +3 -1
  39. package/recipes/chat/app.tsx +4 -7
  40. package/recipes/dashboard/README.md +3 -3
  41. package/recipes/dashboard/app.test.cjs +41 -11
  42. package/recipes/dashboard/app.tsx +50 -30
  43. package/recipes/dashboard/auth.ts +6 -4
  44. package/recipes/dashboard/rate-window.test.cjs +4 -4
  45. package/recipes/http-ws/README.md +1 -1
  46. package/recipes/http-ws/app.test.cjs +8 -10
  47. package/recipes/http-ws/app.tsx +9 -20
  48. package/recipes/realtime/app.tsx +3 -6
  49. package/recipes/shared/README.md +3 -1
  50. package/recipes/shared/lifecycle.test.cjs +81 -0
  51. package/recipes/shared/network.cjs +10 -5
  52. package/recipes/site/app.tsx +3 -6
  53. package/recipes/socket/app.tsx +3 -10
  54. package/src/Application.js +239 -0
  55. package/src/StartupCleanup.js +24 -0
  56. package/src/cli/SourceInspector.js +5 -0
  57. package/src/cli/templates.js +3 -4
  58. package/src/htmx/LiveHtmlServer.js +14 -5
  59. package/src/htmx/PageManager.js +3 -1
  60. package/src/ws/BaseSocketServer.js +13 -13
  61. package/src/ws/SocketRoute.js +6 -2
  62. package/recipes/shared/run-app.test.cjs +0 -158
  63. package/recipes/shared/run-app.ts +0 -50
@@ -14,11 +14,11 @@ npm test
14
14
  npm run dev
15
15
  ```
16
16
 
17
- The provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.
17
+ The provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login** or **http://localhost:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.
18
18
 
19
- `npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.
19
+ `npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks, including hostile cross-loopback form and socket origins. Redweb's release gate also signs in with both valid and invalid credentials through a visible Chromium window, using `localhost` so synthetic request headers cannot hide browser-origin bugs. CI supplies a virtual display for that same headed test. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.
20
20
 
21
- `npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage. It also waits through the actual one-minute login admission window without mocking the clock. The report includes TypeScript-generated decorator accessor functions; inspect that distinction rather than assuming a library coverage figure applies to this recipe. The generated npm configuration enforces this recipe's Node engine requirement before installation.
21
+ `npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage. Login-window expiry uses the same real limiter with an explicitly short test configuration; it never waits through the production one-minute default. The report includes TypeScript-generated decorator accessor functions; inspect that distinction rather than assuming a library coverage figure applies to this recipe. The generated npm configuration enforces this recipe's Node engine requirement before installation.
22
22
 
23
23
  ## Where the behavior lives
24
24
 
@@ -8,7 +8,7 @@ const { DatabaseSync } = require('node:sqlite');
8
8
  const { spawn, spawnSync } = require('node:child_process');
9
9
  const net = require('node:net');
10
10
  const { WebSocketServer, WebSocket } = require('ws');
11
- const { createApp, databasePath } = require('../dist/app');
11
+ const { allowsDashboardOrigin, createApp, databasePath } = require('../dist/app');
12
12
  const { DashboardStore } = require('../dist/store');
13
13
  const { DashboardAuth, credentials, sessionToken } = require('../dist/auth');
14
14
  const { PrivateCards } = require('../dist/cards');
@@ -29,8 +29,8 @@ async function fixture(t, options = {}) {
29
29
  t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });
30
30
  async function restart() {
31
31
  await app?.shutdown();
32
- app = createApp({ port: 0, database, ...options });
33
- if (!app.server.listening) await once(app.server, 'listening');
32
+ app = createApp({ port: 0, database, signals: false, ...options });
33
+ await app.run();
34
34
  return `http://127.0.0.1:${app.server.address().port}`;
35
35
  }
36
36
  return { database, restart, origin: await restart(), get app() { return app; } };
@@ -70,9 +70,12 @@ async function cardClient(t, origin, cookie) {
70
70
  test('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and durable restart', async t => {
71
71
  const fixtureApp = await fixture(t);
72
72
  let { origin } = fixtureApp;
73
+ const localhost = origin.replace('127.0.0.1', 'localhost');
73
74
  assert.equal((await fetch(`${origin}/login`)).status, 200);
74
75
  assert.equal((await fetch(origin)).status, 401);
75
76
  assert.equal((await post(origin, '/login', { account: 'alice', password }, '', 'https://foreign.example')).status, 403);
77
+ assert.equal((await post(origin, '/login', { account: 'alice', password }, '', localhost)).status, 403);
78
+ assert.equal((await post(localhost, '/login', { account: 'alice', password: 'wrong-password-at-least-16' })).status, 401);
76
79
  assert.equal((await post(origin, '/login', { account: 'alice', password: 'wrong-password-at-least-16' })).status, 401);
77
80
  const alice = await login(origin);
78
81
  const alice2 = await login(origin);
@@ -80,6 +83,14 @@ test('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and
80
83
  const page = await fetch(origin, { headers: { Cookie: alice } });
81
84
  assert.match(page.headers.get('cache-control'), /private.*no-store/);
82
85
  assert.equal(page.headers.get('etag'), null);
86
+ const document = await page.text();
87
+ const pageConfig = JSON.parse(document.match(/id="__redweb_page">([^<]+)/)[1]);
88
+ const hostile = new WebSocket(`${origin.replace('http:', 'ws:')}${pageConfig.socketPath}?pageId=${pageConfig.pageId}&redwebVersion=${encodeURIComponent(pageConfig.version)}`,
89
+ { headers: { Cookie: alice, Origin: localhost } });
90
+ hostile.on('error', () => {});
91
+ const [, rejection] = await once(hostile, 'unexpected-response');
92
+ assert.equal(rejection.statusCode, 403);
93
+ rejection.resume();
83
94
  const first = await cardClient(t, origin, alice);
84
95
  const second = await cardClient(t, origin, alice2);
85
96
  const other = await cardClient(t, origin, bob);
@@ -124,6 +135,23 @@ test('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and
124
135
  await client.items(value => value.length === 1);
125
136
  });
126
137
 
138
+ test('unit: development accepts only equivalent loopback browser origins on the listening port', () => {
139
+ const expected = 'http://127.0.0.1:8181';
140
+ assert.equal(allowsDashboardOrigin('http://localhost:8181', expected, false, 'localhost:8181'), true);
141
+ assert.equal(allowsDashboardOrigin('http://127.0.0.1:8181', expected, false, '127.0.0.1:8181'), true);
142
+ assert.equal(allowsDashboardOrigin('http://[::1]:8181', expected, false, '[::1]:8181'), true);
143
+ assert.equal(allowsDashboardOrigin('http://localhost:8181', expected, false, '127.0.0.1:8181'), false);
144
+ assert.equal(allowsDashboardOrigin('https://localhost:8181', expected, false, 'localhost:8181'), false);
145
+ assert.equal(allowsDashboardOrigin('http://localhost:8182', expected, false, 'localhost:8182'), false);
146
+ assert.equal(allowsDashboardOrigin('http://localhost:8181/path', expected, false, 'localhost:8181'), false);
147
+ assert.equal(allowsDashboardOrigin('not an origin', expected, false, 'localhost:8181'), false);
148
+ assert.equal(allowsDashboardOrigin(undefined, expected, false, 'localhost:8181'), false);
149
+ assert.equal(allowsDashboardOrigin('http://localhost:8181', expected, false), false);
150
+ assert.equal(allowsDashboardOrigin('http://localhost:8181', expected, false, 'localhost:8181/path'), false);
151
+ assert.equal(allowsDashboardOrigin('https://dashboard.example', 'https://dashboard.example', true), true);
152
+ assert.equal(allowsDashboardOrigin('http://localhost:8181', 'https://dashboard.example', true), false);
153
+ });
154
+
127
155
  test('session expiry closes idle sockets and rejects later HTTP access', async t => {
128
156
  const { origin } = await fixture(t, { sessionLifetimeMs: 1200 });
129
157
  const cookie = await login(origin);
@@ -200,6 +228,8 @@ test('logout fences password checks in flight; close and admission bounds stop n
200
228
  assert.equal(await closing, undefined);
201
229
  assert.equal(await auth.login('peer', 'alice', password), undefined);
202
230
  assert.throws(() => new DashboardAuth(store, 0));
231
+ assert.throws(() => new DashboardAuth(store, 3600000, 19));
232
+ assert.throws(() => new DashboardAuth(store, 3600000, 60001));
203
233
  const limited = new DashboardAuth(store);
204
234
  const concurrent = Array.from({ length: 5 }, (_, index) => limited.login(`peer-${index}`, 'alice', password));
205
235
  assert.equal(await concurrent[4], undefined);
@@ -266,11 +296,11 @@ test('incomplete HTTP uploads cannot keep shutdown or the database alive indefin
266
296
  const database = join(directory, 'drain.sqlite');
267
297
  let app;
268
298
  t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });
269
- assert.throws(() => createApp({ port: 0, database, sessionLifetimeMs: 0 }), /lifetime/);
299
+ await assert.rejects(createApp({ port: 0, database, sessionLifetimeMs: 0 }).run(), /lifetime/);
270
300
  assert.throws(() => createApp({ port: 0, database, origin: 'https://example.com/path' }), /exact/);
271
301
  assert.throws(() => createApp({ port: 0, database, origin: 'ftp://example.com' }), /exact/);
272
- app = createApp({ port: 0, database });
273
- await once(app.server, 'listening');
302
+ app = createApp({ port: 0, database, shutdownTimeoutMs: 30 });
303
+ await app.run();
274
304
  const socket = net.connect(app.server.address().port, '127.0.0.1');
275
305
  t.after(() => socket.destroy());
276
306
  socket.on('error', () => {});
@@ -320,14 +350,14 @@ test('production origin/cookies and malformed forms use real HTTP', async t => {
320
350
  test('unit: listener-error cleanup observes rejection without hiding it from the application owner', async t => {
321
351
  const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cleanup-'));
322
352
  const database = join(directory, 'cards.sqlite');
323
- const app = createApp({ port: 0, database });
353
+ const app = createApp({ port: 0, database, signals: false });
324
354
  t.after(async () => {
325
355
  // This test deliberately makes the returned cleanup promise reject.
326
356
  // Await settlement before removing files, including on assertion failure.
327
357
  await Promise.allSettled([app.shutdown()]);
328
358
  rmSync(directory, { recursive: true, force: true });
329
359
  });
330
- await once(app.server, 'listening');
360
+ await app.run();
331
361
  const failure = new Error('Injected database cleanup failure');
332
362
  const close = DashboardStore.prototype.close;
333
363
  // Unit-only fault injection, not a claim of a naturally occurring SQLite
@@ -339,7 +369,7 @@ test('unit: listener-error cleanup observes rejection without hiding it from the
339
369
  app.server.emit('error', new Error('Injected listener failure'));
340
370
  const closing = app.shutdown();
341
371
  assert.equal(app.shutdown(), closing);
342
- await assert.rejects(closing, error => error === failure);
372
+ await assert.rejects(closing, error => error.errors.length === 1 && error.errors[0] === failure);
343
373
  assert.equal(injected.mock.callCount(), 1);
344
374
  assert.equal(app.server.listening, false);
345
375
  injected.mock.restore();
@@ -421,10 +451,10 @@ test('real administrator and standalone startup commands expose errors and persi
421
451
  try { assert.ok(store.credentials('carol')); }
422
452
  finally { store.close(); }
423
453
  app = createApp({ database, port: 0 });
424
- await once(app.server, 'listening');
454
+ await app.run();
425
455
  const unavailable = run('dist/app.js', [], { PORT: String(app.server.address().port) });
426
456
  assert.equal(unavailable.status, 1);
427
- assert.match(unavailable.stderr, /Application listener failed/);
457
+ assert.match(unavailable.stderr, /EADDRINUSE/);
428
458
  // Windows kill('SIGTERM') terminates immediately without invoking Node handlers.
429
459
  // An actual IPC message delivers the signal event there; Unix uses its OS signal.
430
460
  const signalControl = join(directory, 'signal.cjs');
@@ -1,15 +1,34 @@
1
1
  import express, { type ErrorRequestHandler } from 'express';
2
2
  import { mkdirSync } from 'node:fs';
3
+ import type { IncomingMessage } from 'node:http';
3
4
  import { dirname, resolve } from 'node:path';
4
- import { page, start, type LivePageRequestContext } from 'redweb';
5
+ import { defineApp, page, type LivePageRequestContext } from 'redweb';
5
6
  import { DashboardAuth, sessionToken } from './auth';
6
7
  import { Cards, PrivateCards } from './cards';
7
8
  import { DashboardStore } from './store';
8
- import { runApp } from './run-app';
9
9
 
10
- export interface DashboardOptions { port?: number; database?: string; origin?: string; sessionLifetimeMs?: number; }
10
+ export interface DashboardOptions { port?: number; database?: string; origin?: string; sessionLifetimeMs?: number; signals?: boolean; shutdownTimeoutMs?: number; }
11
11
 
12
- export function databasePath() { return resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite'); }
12
+ export function databasePath() {
13
+ return process.env.DASHBOARD_DATABASE === ':memory:' ? ':memory:' : resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite');
14
+ }
15
+
16
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
17
+
18
+ /** Development is loopback-only, so its equivalent browser hostnames share one trust boundary. */
19
+ export function allowsDashboardOrigin(candidate: string | undefined, expected: string, configured: boolean, host?: string) {
20
+ if (!candidate) return false;
21
+ if (configured) return candidate === expected;
22
+ if (!host || /[\/@?#\\]/.test(host)) return false;
23
+ try {
24
+ const actual = new URL(candidate);
25
+ const local = new URL(expected);
26
+ const target = new URL(`http://${host}`);
27
+ return actual.origin === candidate && actual.protocol === 'http:'
28
+ && actual.origin === target.origin && actual.port === local.port
29
+ && LOOPBACK_HOSTS.has(actual.hostname);
30
+ } catch { return false; }
31
+ }
13
32
 
14
33
  export function createApp(options: DashboardOptions = {}) {
15
34
  const port = options.port ?? Number(process.env.PORT ?? 8181);
@@ -19,11 +38,9 @@ export function createApp(options: DashboardOptions = {}) {
19
38
  }
20
39
  if (process.env.NODE_ENV === 'production' && !configuredOrigin?.startsWith('https://')) throw new Error('Production requires an explicit HTTPS DASHBOARD_ORIGIN.');
21
40
  const filename = options.database ?? databasePath();
22
- mkdirSync(dirname(filename), { recursive: true });
23
- const store = new DashboardStore(filename);
24
- try {
25
- const cards = new PrivateCards(store);
26
- const auth = new DashboardAuth(store, options.sessionLifetimeMs);
41
+ let store: DashboardStore | undefined;
42
+ let cards: PrivateCards;
43
+ let auth: DashboardAuth | undefined;
27
44
  const app = express();
28
45
  app.disable('x-powered-by');
29
46
  app.use(express.urlencoded({ extended: false, limit: '4kb', parameterLimit: 4 }));
@@ -31,7 +48,9 @@ export function createApp(options: DashboardOptions = {}) {
31
48
  if (!response.destroyed) response.status(400).send('Invalid form submission.');
32
49
  };
33
50
  app.use(invalidBody);
34
- const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server.address() as { port: number }).port}`;
51
+ const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server!.address() as { port: number }).port}`;
52
+ const allowsOrigin = (candidate: string | undefined, request: IncomingMessage) =>
53
+ allowsDashboardOrigin(candidate, origin(), Boolean(configuredOrigin), request.headers.host);
35
54
 
36
55
  @page('/login', { live: false, css: 'app.css', head: { title: 'Sign in · Your cards' } })
37
56
  class Login {
@@ -57,30 +76,31 @@ export function createApp(options: DashboardOptions = {}) {
57
76
  }
58
77
  }
59
78
 
60
- auth.mount(app, origin, account => server.revoke(account));
61
- const server = start([Login, Dashboard], {
79
+ class Workspace {
80
+ onInit() {
81
+ mkdirSync(dirname(filename), { recursive: true });
82
+ store = new DashboardStore(filename);
83
+ cards = new PrivateCards(store);
84
+ auth = new DashboardAuth(store, options.sessionLifetimeMs);
85
+ auth.mount(app, origin, allowsOrigin, account => server.revoke(account));
86
+ }
87
+ onShutdown() {
88
+ try { auth?.close(); }
89
+ finally { store?.close(); }
90
+ }
91
+ }
92
+ const server = defineApp({
93
+ pages: [Login, Dashboard], services: [Workspace], signals: options.signals, shutdownTimeoutMs: options.shutdownTimeoutMs,
62
94
  server: app, port, bind: configuredOrigin ? '0.0.0.0' : '127.0.0.1', logger: null, templateRoot: __dirname,
63
- origins: value => value === origin(),
95
+ origins: allowsOrigin,
64
96
  authenticate: request => request.method === 'GET' && request.url?.split('?')[0] === '/login'
65
- ? true : store.session(sessionToken(request.headers.cookie))?.account,
97
+ ? true : store!.session(sessionToken(request.headers.cookie))?.account,
66
98
  });
67
- let closing: Promise<void> | undefined;
68
- const shutdown = () => {
69
- auth.close();
70
- if (!closing) {
71
- closing = server.shutdown().finally(() => store.close());
72
- }
73
- return closing;
74
- };
75
- server.server.once('error', () => { void shutdown().catch(() => {}); });
76
- return {
77
- server: server.server,
78
- shutdown,
79
- };
80
- } catch (error) { store.close(); throw error; }
99
+ return server;
81
100
  }
82
101
 
83
102
  if (require.main === module) {
84
- const app = runApp(createApp);
85
- app?.server.once('listening', () => console.log(`Dashboard: ${process.env.DASHBOARD_ORIGIN ?? `http://127.0.0.1:${(app.server.address() as { port: number }).port}`}/login`));
103
+ const app = createApp();
104
+ void app.run().then(running => console.log(`Dashboard: ${process.env.DASHBOARD_ORIGIN ?? `http://127.0.0.1:${(running.server.address() as { port: number }).port}`}/login`))
105
+ .catch(error => { console.error(error); process.exitCode = 1; });
86
106
  }
@@ -26,8 +26,10 @@ export class DashboardAuth {
26
26
  private closed = false;
27
27
  private readonly attempts = new Map<string, { count: number; expires: number }>();
28
28
 
29
- constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000) {
29
+ constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000,
30
+ private readonly attemptWindowMs = 60000) {
30
31
  if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Invalid session lifetime.');
32
+ if (!Number.isInteger(attemptWindowMs) || attemptWindowMs < 20 || attemptWindowMs > 60000) throw new RangeError('Invalid login attempt window.');
31
33
  }
32
34
 
33
35
  async login(ip: string, account: unknown, password: unknown): Promise<string | undefined> {
@@ -37,7 +39,7 @@ export class DashboardAuth {
37
39
  let attempt = this.attempts.get(ip);
38
40
  if (!attempt) {
39
41
  if (this.attempts.size >= 1024) return undefined;
40
- attempt = { count: 0, expires: now + 60000 };
42
+ attempt = { count: 0, expires: now + this.attemptWindowMs };
41
43
  this.attempts.set(ip, attempt);
42
44
  }
43
45
  if (++attempt.count > 10 || this.active >= 4) return undefined;
@@ -55,12 +57,12 @@ export class DashboardAuth {
55
57
 
56
58
  close() { this.closed = true; this.attempts.clear(); }
57
59
 
58
- mount(app: Application, origin: () => string, revoke: (account: string) => Promise<unknown>) {
60
+ mount(app: Application, origin: () => string, allowsOrigin: (candidate: string | undefined, request: Request) => boolean, revoke: (account: string) => Promise<unknown>) {
59
61
  const cookie = (token: string) => `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? Math.ceil(this.ttlMs / 1000) : 0}${origin().startsWith('https:') ? '; Secure' : ''}`;
60
62
  const post = (route: string, handler: (request: Request, response: Response) => Promise<void>) => {
61
63
  app.post(route, (request, response) => {
62
64
  response.set('Cache-Control', 'private, no-store');
63
- if (request.get('origin') !== origin()) { response.status(403).send('This form must be submitted from this site.'); return; }
65
+ if (!allowsOrigin(request.get('origin'), request)) { response.status(403).send('This form must be submitted from this site.'); return; }
64
66
  void handler(request, response).catch(() => response.status(503).send('Unable to complete the request. Try again later.'));
65
67
  });
66
68
  };
@@ -3,15 +3,15 @@ const { test } = require('node:test');
3
3
  const { DashboardStore } = require('../dist/store');
4
4
  const { DashboardAuth, credentials } = require('../dist/auth');
5
5
 
6
- test('login admission reopens after the real one-minute window, without clock mocks', { timeout: 65000 }, async t => {
6
+ test('login admission reopens after its configured short real window', async t => {
7
7
  const store = new DashboardStore(':memory:');
8
- const auth = new DashboardAuth(store);
8
+ const auth = new DashboardAuth(store, 3600000, 250);
9
9
  t.after(() => { auth.close(); store.close(); });
10
10
  const password = 'test-only-login-window-password';
11
11
  store.provision('alice', await credentials(password));
12
- for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid', password), undefined);
12
+ for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid', 'short'), undefined);
13
13
  assert.equal(await auth.login('same-peer', 'alice', password), undefined);
14
- await new Promise(resolve => setTimeout(resolve, 60010));
14
+ await new Promise(resolve => setTimeout(resolve, 275));
15
15
  const token = await auth.login('same-peer', 'alice', password);
16
16
  assert.equal(store.session(token).account, 'alice');
17
17
  });
@@ -4,7 +4,7 @@ One Node server answers ordinary HTTP requests and upgrades `/chat` connections
4
4
 
5
5
  After starting the application, request `http://127.0.0.1:8181/health` to receive `{"ok":true}`. Connect a WebSocket to `ws://127.0.0.1:8181/chat` and send `{"type":"hello"}` to receive `{"type":"hello","message":"Hello from the server!"}`. This is a raw JSON socket example, not a chatroom UI or the versioned socket-contract protocol. Use the chat or socket starter for those applications.
6
6
 
7
- The HTTP builder does not bind a port. The socket service explicitly takes responsibility for listening and closing the supplied server with `listen: true` and `closeServerOnShutdown: true`. Call the returned application's `shutdown()`; it processes route failures and still closes its HTTP/TCP peers. Do not separately close the HTTP builder. Importing this module creates no listener; the standalone entrypoint uses the same bounded `runApp` helper as the other starters.
7
+ `defineApp` registers the socket route and the HTTP endpoint on one owned listener. `app.run()` opens it; `app.shutdown()` closes it even when route cleanup fails. You do not need separate server variables, listener flags, or a startup helper. Importing the definition creates no listener.
8
8
 
9
9
  `/health` reports liveness, not readiness or completed application work. Loopback binding is intentional. Before exposing the service, choose your deployment bind address, configure HTTPS/WSS, trusted origins, authentication, authorization, payload/connection limits, and any persistence you need. Shared listeners do not automatically provide these policies. Shutdown may force connections closed; it does not guarantee message delivery or durable work.
10
10
 
@@ -4,8 +4,8 @@ const net = require('node:net');
4
4
  const { once } = require('node:events');
5
5
  const WebSocket = require('ws');
6
6
  const { SocketRoute } = require('redweb');
7
- const { createApp, Hello } = require('../dist/app.js');
8
- const { listen, connect } = require('./network.cjs');
7
+ const { Hello } = require('../dist/app.js');
8
+ const { createApp, listen, connect } = require('./network.cjs');
9
9
 
10
10
  test('an absent PORT binds the documented default or reports that exact port occupied', { timeout: 10000 }, async () => {
11
11
  const { spawnSync } = require('node:child_process');
@@ -15,12 +15,11 @@ test('an absent PORT binds the documented default or reports that exact port occ
15
15
  const assert = require('node:assert/strict');
16
16
  const { once } = require('node:events');
17
17
  const WebSocket = require('ws');
18
- const { createApp } = require('./dist/app.js');
18
+ const { app } = require('./dist/app.js');
19
19
  (async () => {
20
- const app = createApp();
21
20
  let socket;
22
21
  try {
23
- try { if (!app.server.listening) await once(app.server, 'listening'); }
22
+ try { await app.run(); }
24
23
  catch (error) {
25
24
  assert.equal(error.code, 'EADDRINUSE');
26
25
  assert.equal(error.port, 8181);
@@ -60,17 +59,16 @@ test('HTTP and separate message handlers share one port, with strict socket path
60
59
 
61
60
  for (const failingRoute of [false, true]) {
62
61
  test(`shutdown closes incomplete HTTP peers${failingRoute ? ' despite a route failure' : ' idempotently'}`, { timeout: 10000 }, async t => {
63
- const app = createApp({ port: 0, logger: null });
62
+ const app = createApp({ port: 0, logger: null, shutdownTimeoutMs: 30 });
64
63
  t.after(() => app.shutdown().catch(() => {}));
65
- if (!app.server.listening) await once(app.server, 'listening');
66
- assert.equal(app.closeServerOnShutdown, true);
64
+ await app.run();
67
65
  const failure = new Error('Application cleanup failed');
68
66
  if (failingRoute) {
69
67
  class FailingRoute extends SocketRoute {
70
68
  constructor() { super({ path: '/fails', handlers: [Hello] }); }
71
69
  async shutdown() { await super.shutdown(); throw failure; }
72
70
  }
73
- app.addRoute(FailingRoute);
71
+ app.sockets.addRoute(FailingRoute);
74
72
  }
75
73
  const accepted = once(app.server, 'connection');
76
74
  const peer = net.connect(app.server.address().port, '127.0.0.1');
@@ -82,7 +80,7 @@ for (const failingRoute of [false, true]) {
82
80
  const closed = once(serverPeer, 'close');
83
81
  const shutdown = app.shutdown();
84
82
  assert.equal(app.shutdown(), shutdown);
85
- if (failingRoute) await assert.rejects(shutdown, error => error.errors.length === 1 && error.errors[0] === failure);
83
+ if (failingRoute) await assert.rejects(shutdown, error => error.errors.length === 1 && error.errors[0].errors[0] === failure);
86
84
  else await shutdown;
87
85
  await closed;
88
86
  assert.equal(serverPeer.destroyed, true);
@@ -1,5 +1,4 @@
1
- import { BaseHandler, HttpServer, METHODS, SocketRoute, SocketServer, type RedWebSocket, type SocketServerOptions } from 'redweb';
2
- import { runApp } from './run-app';
1
+ import { BaseHandler, defineApp, METHODS, SocketRoute, type RedWebSocket } from 'redweb';
3
2
 
4
3
  export class Hello extends BaseHandler {
5
4
  constructor() { super('hello'); }
@@ -15,22 +14,12 @@ export class ChatRoute extends SocketRoute {
15
14
  }
16
15
  }
17
16
 
18
- export function createApp(options: Pick<SocketServerOptions, 'port' | 'bind' | 'logger'> = {}) {
19
- const http = new HttpServer({
20
- listen: false,
21
- publicPaths: [],
22
- services: [{ serviceName: '/health', method: METHODS.GET, function: (_req, res) => res.json({ ok: true }) }],
23
- });
17
+ export const app = defineApp({
18
+ sockets: [ChatRoute],
19
+ port: Number(process.env.PORT ?? 8181),
20
+ bind: '127.0.0.1',
21
+ publicPaths: [],
22
+ httpServices: [{ serviceName: '/health', method: METHODS.GET, function: (_req, res) => res.json({ ok: true }) }],
23
+ });
24
24
 
25
- return new SocketServer({
26
- port: options.port ?? Number(process.env.PORT ?? 8181),
27
- bind: options.bind ?? '127.0.0.1',
28
- logger: options.logger,
29
- server: http.server,
30
- routes: [ChatRoute],
31
- listen: true,
32
- closeServerOnShutdown: true, // One owner closes routes and the shared HTTP listener.
33
- });
34
- }
35
-
36
- if (require.main === module) runApp(createApp);
25
+ if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
@@ -1,5 +1,4 @@
1
- import { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';
2
- import { runApp } from './run-app';
1
+ import { action, defineApp, page, state } from 'redweb';
3
2
 
4
3
  @page('/', { css: 'app.css', shared: true })
5
4
  export class CounterPage {
@@ -21,8 +20,6 @@ export class CounterPage {
21
20
  }
22
21
  }
23
22
 
24
- export function createApp(options: LiveHtmlStartOptions = {}) {
25
- return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });
26
- }
23
+ export const app = defineApp({ pages: [CounterPage], port: Number(process.env.PORT ?? 8181), templateRoot: __dirname });
27
24
 
28
- if (require.main === module) runApp(createApp);
25
+ if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
@@ -29,7 +29,9 @@ custom hostnames, tunnels and proxy-forwarded origins are not supported by this
29
29
  Run `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,
30
30
  then install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.
31
31
 
32
- The standalone entrypoint calls the shared `runApp(createApp)` helper. Importing either module starts no listener and installs no process handlers. On SIGINT/SIGTERM, a listener error, or native listener closure, the helper calls application shutdown once. Repeated signals do not bypass cleanup. The five-second outer deadline covers the whole application, including database/worker cleanup after HTTP closes; customize it with the helper's second argument if necessary. Cleanup must resolve only after resources are released. A failed cleanup sets a failure exit status and retains a deadline for any surviving handles; the helper never resets an existing failure status. If cleanup does not finish in time, the entrypoint terminates the process with a failure status. This cannot preempt synchronous code blocking Node's event loop and does not make in-memory state durable. Factory functions remain responsible for releasing partially constructed resources before throwing.
32
+ The standalone entrypoint calls `app.run()` on a `defineApp` definition. Importing it opens no listener and installs no process handlers. Redweb owns HTTP and WebSocket startup together, including signal handling and bounded shutdown; no generated `run-app.ts` helper is needed. Configure `startupTimeoutMs` and `shutdownTimeoutMs` on the definition (both default to five seconds). App-wide service classes acquire resources in `onInit(app, signal)` and release them in `onShutdown()`; the dashboard uses this for its auth/database resources. The dashboard's factory configures an independent private workspace but does not start it.
33
+
34
+ Repeated signals do not bypass cleanup. Failed process-owned cleanup sets a failure exit status and retains a deadline for surviving handles. Explicit `shutdown()` rejects on cleanup failures without terminating its caller. Deadlines cannot preempt synchronous code blocking Node's event loop or arbitrary operations that ignore cancellation. Tests can define an independent application from `{ ...app.options, port: 0, signals: false }` and await `run()`; app-owned state is isolated, but objects deliberately captured by page class closures remain shared unless a new class/room is created.
33
35
 
34
36
  The shipped lifecycle tests exercise actual processes, HTTP/TCP/WebSocket peers and timers. Linux uses actual OS signals; Windows tests explicitly emit signal events inside the process because killing a Windows child does not exercise graceful POSIX signal delivery. This is not a claim that Windows console/service managers forward the same signals. Deploy with a supervisor that forwards the supported termination signal and allows longer than the configured cleanup deadline.
35
37
 
@@ -0,0 +1,81 @@
1
+ const assert = require('node:assert/strict');
2
+ const { test } = require('node:test');
3
+ const { spawn } = require('node:child_process');
4
+ const { once } = require('node:events');
5
+
6
+ function execute(t, args, env = process.env) {
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn(process.execPath, args, { env, windowsHide: true });
9
+ let stdout = '', stderr = '', finished = false;
10
+ const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));
11
+ const deadline = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Entrypoint did not exit')); }, 5000);
12
+ t.after(async () => {
13
+ clearTimeout(deadline);
14
+ if (!finished) { child.kill('SIGKILL'); await closed; }
15
+ });
16
+ child.stdout.on('data', data => { stdout += data; });
17
+ child.stderr.on('data', data => { stderr += data; });
18
+ child.once('error', reject);
19
+ child.once('close', (code, signal) => { clearTimeout(deadline); resolve({ code, signal, stdout, stderr }); });
20
+ });
21
+ }
22
+
23
+ // Each generated app is tested in a real process. Framework deadline/failure
24
+ // coverage lives with Application itself, not in six copied startup helpers.
25
+ for (const mode of ['SIGINT', 'SIGTERM', 'native-close']) {
26
+ test(`import is inert; application owns ${mode} cleanup`, { timeout: 7000 }, async t => {
27
+ const result = await execute(t, ['-e', String.raw`
28
+ const assert = require('node:assert/strict');
29
+ const { once } = require('node:events');
30
+ const { defineApp } = require('redweb');
31
+ const initial = ['SIGINT', 'SIGTERM'].map(signal => process.listenerCount(signal));
32
+ const source = require('./dist/app');
33
+ assert.deepEqual(['SIGINT', 'SIGTERM'].map(signal => process.listenerCount(signal)), initial);
34
+ const app = source.app
35
+ ? defineApp({ ...source.app.options, port: 0, bind: '127.0.0.1', logger: null })
36
+ : source.createApp({ port: 0, database: ':memory:' });
37
+ assert.equal(app.server, null);
38
+ (async () => {
39
+ const running = await app.run();
40
+ const response = await fetch('http://127.0.0.1:' + running.server.address().port, { headers: { Connection: 'close' } });
41
+ assert.ok(response.status < 500);
42
+ await response.arrayBuffer();
43
+ const closed = once(running.server, 'close');
44
+ if (process.argv[1] === 'native-close') {
45
+ running.server.close();
46
+ } else {
47
+ // Windows kill does not deliver a graceful POSIX signal.
48
+ if (process.platform === 'win32') process.emit(process.argv[1]);
49
+ else process.kill(process.pid, process.argv[1]);
50
+ }
51
+ await closed;
52
+ await app.shutdown();
53
+ assert.equal(running.server.listening, false);
54
+ assert.deepEqual(['SIGINT', 'SIGTERM'].map(signal => process.listenerCount(signal)), initial);
55
+ })().catch(error => { console.error(error); process.exitCode = 1; });
56
+ `, mode]);
57
+ assert.equal(result.code, 0, result.stdout + result.stderr);
58
+ assert.equal(result.signal, null);
59
+ });
60
+ }
61
+
62
+ test('the actual application entrypoint reports an occupied port', { timeout: 7000 }, async t => {
63
+ const net = require('node:net');
64
+ const occupied = net.createServer(socket => socket.destroy());
65
+ const loopback = net.createServer(socket => socket.destroy());
66
+ t.after(async () => {
67
+ for (const server of [occupied, loopback]) await new Promise(resolve => server.close(resolve));
68
+ });
69
+ occupied.listen(0, '0.0.0.0');
70
+ await once(occupied, 'listening');
71
+ // Windows may allow separate wildcard and loopback binds to the same port.
72
+ loopback.listen(occupied.address().port, '127.0.0.1');
73
+ try { await once(loopback, 'listening'); }
74
+ catch (error) { assert.equal(error.code, 'EADDRINUSE'); }
75
+ const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: ':memory:' };
76
+ delete env.DASHBOARD_ORIGIN;
77
+ const result = await execute(t, ['dist/app.js'], env);
78
+ assert.equal(result.code, 1, result.stdout + result.stderr);
79
+ assert.equal(result.signal, null);
80
+ assert.match(result.stderr, /EADDRINUSE/);
81
+ });
@@ -1,12 +1,17 @@
1
1
  const assert = require('node:assert/strict');
2
2
  const { once } = require('node:events');
3
3
  const WebSocket = require('ws');
4
- const { createApp } = require('../dist/app.js');
4
+ const { defineApp } = require('redweb');
5
+ const { app: definition } = require('../dist/app.js');
5
6
 
6
- async function listen(t) {
7
- const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });
7
+ function createApp(options = {}) {
8
+ return defineApp({ ...definition.options, port: 0, bind: '127.0.0.1', logger: null, signals: false, ...options });
9
+ }
10
+
11
+ async function listen(t, options) {
12
+ const app = createApp(options);
8
13
  t.after(() => app.shutdown());
9
- if (!app.server.listening) await once(app.server, 'listening');
14
+ await app.run();
10
15
  return `http://127.0.0.1:${app.server.address().port}`;
11
16
  }
12
17
 
@@ -56,4 +61,4 @@ async function live(t, origin, headers = {}) {
56
61
  };
57
62
  }
58
63
 
59
- module.exports = { listen, connect, live };
64
+ module.exports = { createApp, listen, connect, live };
@@ -1,5 +1,4 @@
1
- import { defineSite, start, type LiveHtmlStartOptions } from 'redweb';
2
- import { runApp } from './run-app';
1
+ import { defineApp, defineSite } from 'redweb';
3
2
 
4
3
  const site = defineSite({
5
4
  css: 'app.css',
@@ -18,8 +17,6 @@ export class AboutPage {
18
17
  render() { return <main class="home"><h1>About</h1><p>Shared layout, separate pages, no browser JavaScript.</p></main>; }
19
18
  }
20
19
 
21
- export function createApp(options: LiveHtmlStartOptions = {}) {
22
- return start([HomePage, AboutPage], { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });
23
- }
20
+ export const app = defineApp({ pages: [HomePage, AboutPage], port: Number(process.env.PORT ?? 8181), templateRoot: __dirname });
24
21
 
25
- if (require.main === module) runApp(createApp);
22
+ if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
@@ -1,7 +1,6 @@
1
- import { SocketRoute, SocketServer, type SocketServerOptions } from 'redweb';
1
+ import { defineApp, SocketRoute } from 'redweb';
2
2
  import { match } from './contract';
3
3
  import { Join, Move, Resume } from './handlers';
4
- import { runApp } from './run-app';
5
4
 
6
5
  export class MatchRoute extends SocketRoute {
7
6
  constructor() {
@@ -19,12 +18,6 @@ export class MatchRoute extends SocketRoute {
19
18
  }
20
19
  }
21
20
 
22
- export function createApp(options: SocketServerOptions = {}) {
23
- return new SocketServer({
24
- port: Number(process.env.PORT ?? 8181),
25
- routes: [MatchRoute],
26
- ...options,
27
- });
28
- }
21
+ export const app = defineApp({ sockets: [MatchRoute], port: Number(process.env.PORT ?? 8181) });
29
22
 
30
- if (require.main === module) runApp(createApp);
23
+ if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });