redweb 0.13.5 → 0.15.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.
- package/CHANGELOG.md +20 -3
- package/README.md +289 -288
- package/contract.d.ts +11 -3
- package/docs/APPLICATION.md +98 -0
- package/docs/CLI.md +1 -1
- package/docs/CLIENT_DEVELOPMENT.md +9 -5
- package/docs/COVERAGE_SCOPE_AUDIT.md +7 -0
- package/docs/DEFINE_APP_VERIFICATION.md +101 -0
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LIVE_HTML.md +2 -2
- package/docs/MIGRATION.md +1 -1
- package/docs/MULTIPLAYER_OPERATIONS.md +6 -0
- package/docs/RELEASE_TRUST.md +29 -6
- package/docs/RUNTIME_DIAGNOSTICS.md +1 -1
- package/docs/SOCKET_CONTRACTS.md +6 -3
- package/docs/SOCKET_PAGES.md +99 -0
- package/docs/SOCKET_PAGE_RELEASE_PREPARATION.md +120 -0
- package/docs/SOCKET_PAGE_VERIFICATION.md +85 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +9 -0
- package/docs/generated.json +2217 -2154
- package/docs/guides/http-websocket.md +4 -4
- package/docs/guides/jsx-without-react.md +1 -1
- package/docs/reference.json +40 -1
- package/docs/releases/0.14.0.json +2208 -0
- package/docs/releases/0.15.0.json +2217 -0
- package/docs/topics.json +3 -1
- package/examples/live-html/cards.js +87 -86
- package/examples/live-html/cards.ts +3 -2
- package/examples/live-html/chatroom.js +210 -207
- package/examples/live-html/chatroom.tsx +6 -2
- package/examples/live-html/components.js +103 -102
- package/examples/live-html/components.ts +3 -2
- package/examples/live-html/counter.js +74 -73
- package/examples/live-html/counter.ts +3 -2
- package/examples/live-html/jsx-page.js +2 -1
- package/examples/live-html/jsx-page.tsx +3 -2
- package/index.d.ts +59 -7
- package/index.js +3 -0
- package/package.json +8 -5
- package/recipes/chat/app.test.cjs +3 -1
- package/recipes/chat/app.tsx +4 -7
- package/recipes/dashboard/app.test.cjs +10 -10
- package/recipes/dashboard/app.tsx +29 -29
- package/recipes/http-ws/README.md +1 -1
- package/recipes/http-ws/app.test.cjs +8 -10
- package/recipes/http-ws/app.tsx +9 -20
- package/recipes/realtime/app.tsx +3 -6
- package/recipes/shared/README.md +10 -1
- package/recipes/shared/lifecycle.test.cjs +81 -0
- package/recipes/shared/network.cjs +10 -5
- package/recipes/site/app.tsx +3 -6
- package/recipes/socket/app.tsx +3 -10
- package/src/Application.js +239 -0
- package/src/StartupCleanup.js +24 -0
- package/src/cli/SourceInspector.js +5 -0
- package/src/cli/templates.js +6 -6
- package/src/htmx/Jsx.js +2 -2
- package/src/htmx/LiveHtmlServer.js +14 -5
- package/src/htmx/PageManager.js +13 -8
- package/src/htmx/PageSocketRoute.js +132 -0
- package/src/htmx/ReactiveRenderer.js +8 -2
- package/src/htmx/SocketAction.js +19 -0
- package/src/htmx/metadata.js +6 -2
- package/src/ws/BaseHandler.js +6 -4
- package/src/ws/BaseSocketServer.js +13 -13
- package/src/ws/HandlerGuard.js +4 -0
- package/src/ws/SocketAction.js +16 -0
- package/src/ws/SocketContract.js +3 -2
- package/src/ws/SocketRoute.js +9 -4
- package/recipes/shared/run-app.test.cjs +0 -158
- package/recipes/shared/run-app.ts +0 -50
|
@@ -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
|
-
|
|
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; } };
|
|
@@ -296,11 +296,11 @@ test('incomplete HTTP uploads cannot keep shutdown or the database alive indefin
|
|
|
296
296
|
const database = join(directory, 'drain.sqlite');
|
|
297
297
|
let app;
|
|
298
298
|
t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });
|
|
299
|
-
assert.
|
|
299
|
+
await assert.rejects(createApp({ port: 0, database, sessionLifetimeMs: 0 }).run(), /lifetime/);
|
|
300
300
|
assert.throws(() => createApp({ port: 0, database, origin: 'https://example.com/path' }), /exact/);
|
|
301
301
|
assert.throws(() => createApp({ port: 0, database, origin: 'ftp://example.com' }), /exact/);
|
|
302
|
-
app = createApp({ port: 0, database });
|
|
303
|
-
await
|
|
302
|
+
app = createApp({ port: 0, database, shutdownTimeoutMs: 30 });
|
|
303
|
+
await app.run();
|
|
304
304
|
const socket = net.connect(app.server.address().port, '127.0.0.1');
|
|
305
305
|
t.after(() => socket.destroy());
|
|
306
306
|
socket.on('error', () => {});
|
|
@@ -350,14 +350,14 @@ test('production origin/cookies and malformed forms use real HTTP', async t => {
|
|
|
350
350
|
test('unit: listener-error cleanup observes rejection without hiding it from the application owner', async t => {
|
|
351
351
|
const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cleanup-'));
|
|
352
352
|
const database = join(directory, 'cards.sqlite');
|
|
353
|
-
const app = createApp({ port: 0, database });
|
|
353
|
+
const app = createApp({ port: 0, database, signals: false });
|
|
354
354
|
t.after(async () => {
|
|
355
355
|
// This test deliberately makes the returned cleanup promise reject.
|
|
356
356
|
// Await settlement before removing files, including on assertion failure.
|
|
357
357
|
await Promise.allSettled([app.shutdown()]);
|
|
358
358
|
rmSync(directory, { recursive: true, force: true });
|
|
359
359
|
});
|
|
360
|
-
await
|
|
360
|
+
await app.run();
|
|
361
361
|
const failure = new Error('Injected database cleanup failure');
|
|
362
362
|
const close = DashboardStore.prototype.close;
|
|
363
363
|
// Unit-only fault injection, not a claim of a naturally occurring SQLite
|
|
@@ -369,7 +369,7 @@ test('unit: listener-error cleanup observes rejection without hiding it from the
|
|
|
369
369
|
app.server.emit('error', new Error('Injected listener failure'));
|
|
370
370
|
const closing = app.shutdown();
|
|
371
371
|
assert.equal(app.shutdown(), closing);
|
|
372
|
-
await assert.rejects(closing, error => error === failure);
|
|
372
|
+
await assert.rejects(closing, error => error.errors.length === 1 && error.errors[0] === failure);
|
|
373
373
|
assert.equal(injected.mock.callCount(), 1);
|
|
374
374
|
assert.equal(app.server.listening, false);
|
|
375
375
|
injected.mock.restore();
|
|
@@ -451,10 +451,10 @@ test('real administrator and standalone startup commands expose errors and persi
|
|
|
451
451
|
try { assert.ok(store.credentials('carol')); }
|
|
452
452
|
finally { store.close(); }
|
|
453
453
|
app = createApp({ database, port: 0 });
|
|
454
|
-
await
|
|
454
|
+
await app.run();
|
|
455
455
|
const unavailable = run('dist/app.js', [], { PORT: String(app.server.address().port) });
|
|
456
456
|
assert.equal(unavailable.status, 1);
|
|
457
|
-
assert.match(unavailable.stderr, /
|
|
457
|
+
assert.match(unavailable.stderr, /EADDRINUSE/);
|
|
458
458
|
// Windows kill('SIGTERM') terminates immediately without invoking Node handlers.
|
|
459
459
|
// An actual IPC message delivers the signal event there; Unix uses its OS signal.
|
|
460
460
|
const signalControl = join(directory, 'signal.cjs');
|
|
@@ -2,15 +2,16 @@ import express, { type ErrorRequestHandler } from 'express';
|
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
3
|
import type { IncomingMessage } from 'node:http';
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { defineApp, page, type LivePageRequestContext } from 'redweb';
|
|
6
6
|
import { DashboardAuth, sessionToken } from './auth';
|
|
7
7
|
import { Cards, PrivateCards } from './cards';
|
|
8
8
|
import { DashboardStore } from './store';
|
|
9
|
-
import { runApp } from './run-app';
|
|
10
9
|
|
|
11
|
-
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; }
|
|
12
11
|
|
|
13
|
-
export function databasePath() {
|
|
12
|
+
export function databasePath() {
|
|
13
|
+
return process.env.DASHBOARD_DATABASE === ':memory:' ? ':memory:' : resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite');
|
|
14
|
+
}
|
|
14
15
|
|
|
15
16
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
16
17
|
|
|
@@ -37,11 +38,9 @@ export function createApp(options: DashboardOptions = {}) {
|
|
|
37
38
|
}
|
|
38
39
|
if (process.env.NODE_ENV === 'production' && !configuredOrigin?.startsWith('https://')) throw new Error('Production requires an explicit HTTPS DASHBOARD_ORIGIN.');
|
|
39
40
|
const filename = options.database ?? databasePath();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const cards = new PrivateCards(store);
|
|
44
|
-
const auth = new DashboardAuth(store, options.sessionLifetimeMs);
|
|
41
|
+
let store: DashboardStore | undefined;
|
|
42
|
+
let cards: PrivateCards;
|
|
43
|
+
let auth: DashboardAuth | undefined;
|
|
45
44
|
const app = express();
|
|
46
45
|
app.disable('x-powered-by');
|
|
47
46
|
app.use(express.urlencoded({ extended: false, limit: '4kb', parameterLimit: 4 }));
|
|
@@ -49,7 +48,7 @@ export function createApp(options: DashboardOptions = {}) {
|
|
|
49
48
|
if (!response.destroyed) response.status(400).send('Invalid form submission.');
|
|
50
49
|
};
|
|
51
50
|
app.use(invalidBody);
|
|
52
|
-
const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server
|
|
51
|
+
const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server!.address() as { port: number }).port}`;
|
|
53
52
|
const allowsOrigin = (candidate: string | undefined, request: IncomingMessage) =>
|
|
54
53
|
allowsDashboardOrigin(candidate, origin(), Boolean(configuredOrigin), request.headers.host);
|
|
55
54
|
|
|
@@ -77,30 +76,31 @@ export function createApp(options: DashboardOptions = {}) {
|
|
|
77
76
|
}
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
82
94
|
server: app, port, bind: configuredOrigin ? '0.0.0.0' : '127.0.0.1', logger: null, templateRoot: __dirname,
|
|
83
95
|
origins: allowsOrigin,
|
|
84
96
|
authenticate: request => request.method === 'GET' && request.url?.split('?')[0] === '/login'
|
|
85
|
-
? true : store
|
|
97
|
+
? true : store!.session(sessionToken(request.headers.cookie))?.account,
|
|
86
98
|
});
|
|
87
|
-
|
|
88
|
-
const shutdown = () => {
|
|
89
|
-
auth.close();
|
|
90
|
-
if (!closing) {
|
|
91
|
-
closing = server.shutdown().finally(() => store.close());
|
|
92
|
-
}
|
|
93
|
-
return closing;
|
|
94
|
-
};
|
|
95
|
-
server.server.once('error', () => { void shutdown().catch(() => {}); });
|
|
96
|
-
return {
|
|
97
|
-
server: server.server,
|
|
98
|
-
shutdown,
|
|
99
|
-
};
|
|
100
|
-
} catch (error) { store.close(); throw error; }
|
|
99
|
+
return server;
|
|
101
100
|
}
|
|
102
101
|
|
|
103
102
|
if (require.main === module) {
|
|
104
|
-
const app =
|
|
105
|
-
app
|
|
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; });
|
|
106
106
|
}
|
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
18
|
+
const { app } = require('./dist/app.js');
|
|
19
19
|
(async () => {
|
|
20
|
-
const app = createApp();
|
|
21
20
|
let socket;
|
|
22
21
|
try {
|
|
23
|
-
try {
|
|
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
|
-
|
|
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);
|
package/recipes/http-ws/app.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { BaseHandler,
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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; });
|
package/recipes/realtime/app.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { action,
|
|
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
|
|
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)
|
|
25
|
+
if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
|
package/recipes/shared/README.md
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -38,3 +40,10 @@ and application-specific rate limits. These starters are demonstrations, not a h
|
|
|
38
40
|
Never commit secrets; `.env` is ignored but is not loaded automatically.
|
|
39
41
|
|
|
40
42
|
`npx --no-install redweb doctor --json` reports configuration problems without changing your files.
|
|
43
|
+
## Dependency security
|
|
44
|
+
|
|
45
|
+
This starter includes an application-root npm override for Express 4's `qs`
|
|
46
|
+
dependency, selecting patched `qs@6.16.0`. Keep the override when merging this
|
|
47
|
+
starter into an existing application, refresh its lockfile and run `npm audit`.
|
|
48
|
+
Overrides in Redweb's own package do not apply to installed consumers. Recheck
|
|
49
|
+
upstream Express/body-parser releases before removing this temporary mitigation.
|
|
@@ -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 {
|
|
4
|
+
const { defineApp } = require('redweb');
|
|
5
|
+
const { app: definition } = require('../dist/app.js');
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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 };
|
package/recipes/site/app.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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)
|
|
22
|
+
if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
|
package/recipes/socket/app.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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)
|
|
23
|
+
if (require.main === module) void app.run().catch(error => { console.error(error); process.exitCode = 1; });
|