redweb 0.12.0 → 0.13.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 +166 -9
- package/README.md +177 -523
- package/bin/redweb.js +11 -20
- package/client.d.ts +7 -2
- package/config/tsconfig.json +14 -14
- package/contract.d.ts +45 -0
- package/contract.js +5 -0
- package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
- package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
- package/docs/AGENT_ACCESS.md +35 -0
- package/docs/AGENT_EVALUATION.md +58 -0
- package/docs/AGENT_READY_ACCEPTANCE.md +763 -0
- package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
- package/docs/BENCHMARK_VERIFICATION.md +307 -0
- package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
- package/docs/CLI.md +116 -0
- package/docs/CLIENT_DEVELOPMENT.md +152 -0
- package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
- package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
- package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
- package/docs/DEVELOPMENT.md +79 -0
- package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
- package/docs/DOCUMENTATION.md +37 -0
- package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
- package/docs/GETTING_STARTED.md +58 -0
- package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
- package/docs/LIVE_HTML.md +169 -21
- package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
- package/docs/MIGRATION.md +28 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
- package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
- package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
- package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
- package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
- package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
- package/docs/PRODUCTION_READINESS.md +11 -2
- package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
- package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
- package/docs/RECOVERY_CODE_CENSUS.md +158 -0
- package/docs/RECOVERY_COMPARISON.md +103 -0
- package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
- package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
- package/docs/RECOVERY_INVESTIGATION.md +229 -0
- package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
- package/docs/RELEASE_TRUST.md +58 -0
- package/docs/ROOM_AUTHORIZATION.md +49 -0
- package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
- package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
- package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
- package/docs/SOAK_VERIFICATION.md +154 -0
- package/docs/SOCKET_CONTRACTS.md +39 -0
- package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
- package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
- package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
- package/docs/STARTER_REPORT_RETENTION.md +73 -0
- package/docs/VERIFICATION_EVIDENCE.md +2 -0
- package/docs/generated.json +2154 -0
- package/docs/guides/chatroom.md +27 -0
- package/docs/guides/http-websocket.md +28 -0
- package/docs/guides/jsx-without-react.md +26 -0
- package/docs/guides/realtime-dashboard.md +29 -0
- package/docs/guides/typed-websockets.md +26 -0
- package/docs/reference.json +1207 -0
- package/docs/snippets/room-access.tsx +51 -0
- package/docs/topics.json +21 -0
- package/examples/live-html/chatroom.js +207 -268
- package/examples/live-html/chatroom.tsx +167 -0
- package/examples/live-html/jsx-page.js +1 -1
- package/examples/live-html/jsx-page.tsx +1 -1
- package/examples/live-html/tsconfig.json +8 -7
- package/index.d.ts +170 -45
- package/index.js +2 -0
- package/jsx-dev-runtime.js +2 -2
- package/jsx-runtime.d.ts +7 -2
- package/package.json +88 -7
- package/recipes/add/artifact.test.cjs +57 -0
- package/recipes/add/live.tsx +18 -0
- package/recipes/add/socket-route.ts +24 -0
- package/recipes/chat/README.md +22 -0
- package/recipes/chat/app.test.cjs +105 -0
- package/recipes/chat/app.tsx +9 -0
- package/recipes/dashboard/README.md +43 -0
- package/recipes/dashboard/admin.ts +21 -0
- package/recipes/dashboard/app.css +16 -0
- package/recipes/dashboard/app.test.cjs +450 -0
- package/recipes/dashboard/app.tsx +86 -0
- package/recipes/dashboard/auth.ts +80 -0
- package/recipes/dashboard/cards.tsx +102 -0
- package/recipes/dashboard/rate-window.test.cjs +17 -0
- package/recipes/dashboard/store.ts +120 -0
- package/recipes/http-ws/README.md +11 -0
- package/recipes/http-ws/app.test.cjs +92 -0
- package/recipes/http-ws/app.tsx +36 -0
- package/recipes/realtime/README.md +8 -0
- package/recipes/realtime/app.test.cjs +15 -0
- package/recipes/realtime/app.tsx +28 -0
- package/recipes/shared/README.md +40 -0
- package/recipes/shared/app.css +8 -0
- package/recipes/shared/copy-assets.cjs +8 -0
- package/recipes/shared/network.cjs +59 -0
- package/recipes/shared/run-app.test.cjs +158 -0
- package/recipes/shared/run-app.ts +50 -0
- package/recipes/site/README.md +4 -0
- package/recipes/site/app.test.cjs +19 -0
- package/recipes/site/app.tsx +25 -0
- package/recipes/socket/README.md +39 -0
- package/recipes/socket/app.test.cjs +85 -0
- package/recipes/socket/app.tsx +30 -0
- package/recipes/socket/contract.ts +12 -0
- package/recipes/socket/handlers.ts +40 -0
- package/src/OwnedServerLifecycle.js +66 -0
- package/src/access/AccessPolicy.js +37 -0
- package/src/access/AuthenticationFailure.js +13 -0
- package/src/access/RequestFailure.js +33 -0
- package/src/access/failure-codes.json +25 -0
- package/src/async/BoundedOperation.js +62 -0
- package/src/cli/ActionReferences.js +193 -0
- package/src/cli/AdditionLayout.js +140 -0
- package/src/cli/FilePlan.js +94 -0
- package/src/cli/ProjectAddition.js +60 -0
- package/src/cli/ProjectConfig.js +26 -0
- package/src/cli/ProjectDoctor.js +112 -0
- package/src/cli/ProjectInitializer.js +20 -30
- package/src/cli/SourceInspector.js +207 -0
- package/src/cli/StaticSource.js +192 -0
- package/src/cli/arguments.js +62 -0
- package/src/cli/formatCommand.js +10 -0
- package/src/cli/run.js +57 -0
- package/src/cli/templates.js +86 -87
- package/src/context/RequestSnapshot.js +41 -0
- package/src/dataProperty.js +11 -0
- package/src/development/DevelopmentPageManager.js +48 -0
- package/src/development/Inspection.js +104 -0
- package/src/development/ObservedRenderer.js +42 -0
- package/src/development/description.js +35 -0
- package/src/development/loopbackRequest.js +27 -0
- package/src/development/refreshBrowser.js +96 -0
- package/src/development/refreshStyles.js +9 -0
- package/src/development/settings.js +17 -0
- package/src/docs/Documentation.js +182 -0
- package/src/htmx/ActionDefinition.js +44 -0
- package/src/htmx/Jsx.js +24 -8
- package/src/htmx/LiveHtmlServer.js +41 -19
- package/src/htmx/LivePage.js +63 -13
- package/src/htmx/PageIdentity.js +32 -0
- package/src/htmx/PageLifetime.js +37 -0
- package/src/htmx/PageManager.js +203 -74
- package/src/htmx/ReactiveRenderer.js +241 -0
- package/src/htmx/StaticExporter.js +1 -1
- package/src/htmx/TemplateRenderer.js +13 -7
- package/src/htmx/browserRuntime.js +2 -93
- package/src/htmx/metadata.js +19 -7
- package/src/validation/ActionInputError.js +12 -0
- package/src/validation/SchemaValidator.js +38 -0
- package/src/ws/AdmissionPolicy.js +24 -23
- package/src/ws/BaseSocketServer.js +53 -38
- package/src/ws/ContractValidationError.js +12 -0
- package/src/ws/HeartbeatMonitor.js +19 -7
- package/src/ws/ProtocolPolicy.js +1 -1
- package/src/ws/RoomAccess.js +82 -0
- package/src/ws/RoomRegistry.js +56 -6
- package/src/ws/RouteRuntime.js +56 -10
- package/src/ws/SocketContract.js +112 -0
- package/src/ws/SocketRoute.js +18 -0
- package/src/ws/protocol-schema.json +6 -1
- package/examples/live-html/chatroom.ts +0 -217
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { test } = require('node:test');
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.
|
|
6
|
+
// Windows cannot deliver POSIX signals through child.kill, so only that platform
|
|
7
|
+
// explicitly emits the signal event inside the child. Linux uses real OS signals.
|
|
8
|
+
const fixture = String.raw`
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const http = require('node:http');
|
|
11
|
+
const net = require('node:net');
|
|
12
|
+
const { once } = require('node:events');
|
|
13
|
+
const WebSocket = require('ws');
|
|
14
|
+
const mode = process.argv[1];
|
|
15
|
+
const signals = ['SIGINT', 'SIGTERM'];
|
|
16
|
+
const initial = signals.map(signal => process.listenerCount(signal));
|
|
17
|
+
const { runApp } = require('./dist/run-app.js');
|
|
18
|
+
assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
|
|
19
|
+
require('./dist/app.js');
|
|
20
|
+
assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
|
|
21
|
+
let cleanups = 0;
|
|
22
|
+
process.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));
|
|
23
|
+
const signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);
|
|
24
|
+
if (mode === 'invalid') {
|
|
25
|
+
for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);
|
|
26
|
+
} else if (mode === 'factory') {
|
|
27
|
+
assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);
|
|
28
|
+
} else {
|
|
29
|
+
if (mode === 'preserve') process.exitCode = '7';
|
|
30
|
+
const server = http.createServer((_request, response) => response.end('ready'));
|
|
31
|
+
const wss = new WebSocket.Server({ server });
|
|
32
|
+
wss.on('error', () => {}); // The HTTP listener error is owned by runApp.
|
|
33
|
+
const peers = new Set();
|
|
34
|
+
server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });
|
|
35
|
+
const close = async () => {
|
|
36
|
+
for (const peer of peers) peer.destroy();
|
|
37
|
+
for (const peer of wss.clients) peer.terminate();
|
|
38
|
+
await new Promise(resolve => wss.close(resolve));
|
|
39
|
+
await new Promise(resolve => server.close(resolve));
|
|
40
|
+
};
|
|
41
|
+
const app = runApp(() => ({ server, shutdown() {
|
|
42
|
+
cleanups++;
|
|
43
|
+
console.log('cleanup-started');
|
|
44
|
+
if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }
|
|
45
|
+
if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));
|
|
46
|
+
return close().then(async () => {
|
|
47
|
+
if (mode === 'hung') return new Promise(() => {});
|
|
48
|
+
if (mode === 'reject') throw Error('private cleanup detail');
|
|
49
|
+
if (mode === 'repeat') {
|
|
50
|
+
signal('SIGINT'); signal('SIGTERM');
|
|
51
|
+
server.emit('error', Error('private listener detail'));
|
|
52
|
+
}
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
54
|
+
});
|
|
55
|
+
} }), 200);
|
|
56
|
+
assert.equal(app.server, server);
|
|
57
|
+
(async () => {
|
|
58
|
+
if (mode === 'occupied') {
|
|
59
|
+
const other = http.createServer();
|
|
60
|
+
await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));
|
|
61
|
+
server.once('error', () => other.close());
|
|
62
|
+
server.listen(other.address().port, '127.0.0.1');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
66
|
+
const port = server.address().port;
|
|
67
|
+
const response = await fetch('http://127.0.0.1:' + port);
|
|
68
|
+
assert.equal(await response.text(), 'ready');
|
|
69
|
+
const peer = net.connect(port, '127.0.0.1');
|
|
70
|
+
peer.on('error', () => {});
|
|
71
|
+
await once(peer, 'connect');
|
|
72
|
+
peer.write('GET / HTTP/1.1\r\nHost: localhost\r\n');
|
|
73
|
+
const socket = new WebSocket('ws://127.0.0.1:' + port);
|
|
74
|
+
socket.on('error', () => {});
|
|
75
|
+
await once(socket, 'open');
|
|
76
|
+
if (mode === 'native-close') {
|
|
77
|
+
for (const connection of peers) connection.destroy();
|
|
78
|
+
server.close();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// A partial HTTP peer otherwise prevents native close; application cleanup
|
|
82
|
+
// begins via the signal and the later native close must not end its timer.
|
|
83
|
+
signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');
|
|
84
|
+
})().catch(error => { console.error(error); process.exit(99); });
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
function execute(mode, t, args = ['-e', fixture, mode], env = process.env) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });
|
|
91
|
+
let stdout = '', stderr = '';
|
|
92
|
+
let timedOut = false, finished = false;
|
|
93
|
+
const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));
|
|
94
|
+
const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);
|
|
95
|
+
t.after(async () => {
|
|
96
|
+
clearTimeout(deadline);
|
|
97
|
+
if (!finished) { child.kill('SIGKILL'); await closed; }
|
|
98
|
+
});
|
|
99
|
+
child.stdout.on('data', data => { stdout += data; });
|
|
100
|
+
child.stderr.on('data', data => { stderr += data; });
|
|
101
|
+
child.once('error', reject);
|
|
102
|
+
child.once('close', (code, signal) => {
|
|
103
|
+
clearTimeout(deadline);
|
|
104
|
+
if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\n${stdout}\n${stderr}`));
|
|
105
|
+
else resolve({ code, signal, stdout, stderr });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {
|
|
111
|
+
const net = require('node:net');
|
|
112
|
+
const { once } = require('node:events');
|
|
113
|
+
const fs = require('node:fs');
|
|
114
|
+
const path = require('node:path');
|
|
115
|
+
const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));
|
|
116
|
+
const occupied = net.createServer(socket => socket.destroy());
|
|
117
|
+
const loopback = net.createServer(socket => socket.destroy());
|
|
118
|
+
let failure;
|
|
119
|
+
try {
|
|
120
|
+
occupied.listen(0, '0.0.0.0');
|
|
121
|
+
await once(occupied, 'listening');
|
|
122
|
+
// Windows permits distinct wildcard/loopback binds on the same port.
|
|
123
|
+
// Hold both addresses; Unix may already reject the second bind.
|
|
124
|
+
loopback.listen(occupied.address().port, '127.0.0.1');
|
|
125
|
+
try { await once(loopback, 'listening'); }
|
|
126
|
+
catch (error) { assert.equal(error.code, 'EADDRINUSE'); }
|
|
127
|
+
const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };
|
|
128
|
+
delete env.DASHBOARD_ORIGIN;
|
|
129
|
+
const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);
|
|
130
|
+
assert.equal(result.code, 1, `${result.stdout}\n${result.stderr}`);
|
|
131
|
+
assert.equal(result.signal, null);
|
|
132
|
+
assert.match(result.stderr, /Application listener failed/);
|
|
133
|
+
} catch (error) { failure = error; }
|
|
134
|
+
const cleanup = await Promise.allSettled([
|
|
135
|
+
...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>
|
|
136
|
+
error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),
|
|
137
|
+
fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
|
138
|
+
]);
|
|
139
|
+
const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];
|
|
140
|
+
if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
for (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {
|
|
144
|
+
test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {
|
|
145
|
+
const result = await execute(mode, t);
|
|
146
|
+
const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;
|
|
147
|
+
assert.equal(result.code, expected, `${result.stdout}\n${result.stderr}`);
|
|
148
|
+
assert.equal(result.signal, null);
|
|
149
|
+
assert.doesNotMatch(result.stderr, /private .* detail/);
|
|
150
|
+
const noApp = ['invalid', 'factory'].includes(mode);
|
|
151
|
+
assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);
|
|
152
|
+
if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);
|
|
153
|
+
if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {
|
|
154
|
+
const snapshot = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1));
|
|
155
|
+
assert.deepEqual(snapshot.signals, snapshot.initial);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
|
|
3
|
+
interface Application { server: Server; shutdown(): Promise<void>; }
|
|
4
|
+
|
|
5
|
+
/** Entry-point policy only: importing a recipe never installs process handlers. */
|
|
6
|
+
export function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {
|
|
7
|
+
if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {
|
|
8
|
+
throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');
|
|
9
|
+
}
|
|
10
|
+
const fail = (message: string) => {
|
|
11
|
+
console.error(message);
|
|
12
|
+
if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;
|
|
13
|
+
};
|
|
14
|
+
let app: T;
|
|
15
|
+
try { app = createApp(); }
|
|
16
|
+
catch { fail('Application startup failed.'); return undefined; }
|
|
17
|
+
|
|
18
|
+
let closing: Promise<void> | undefined;
|
|
19
|
+
const stop = () => {
|
|
20
|
+
if (!closing) {
|
|
21
|
+
let failed = false;
|
|
22
|
+
const deadline = setTimeout(() => {
|
|
23
|
+
fail('Application cleanup exceeded its deadline; terminating the process.');
|
|
24
|
+
process.exit();
|
|
25
|
+
}, shutdownTimeoutMs);
|
|
26
|
+
closing = Promise.resolve().then(() => app.shutdown()).catch(() => {
|
|
27
|
+
failed = true;
|
|
28
|
+
fail('Application cleanup failed.');
|
|
29
|
+
}).finally(() => {
|
|
30
|
+
// Failed cleanup may leave live handles. Permit natural exit if none
|
|
31
|
+
// remain, but still force a bounded exit when resources were leaked.
|
|
32
|
+
if (failed) { deadline.unref(); return; }
|
|
33
|
+
clearTimeout(deadline);
|
|
34
|
+
process.off('SIGINT', stop);
|
|
35
|
+
process.off('SIGTERM', stop);
|
|
36
|
+
app.server.off('error', onError);
|
|
37
|
+
app.server.off('close', stop);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return closing;
|
|
41
|
+
};
|
|
42
|
+
const onError = () => { fail('Application listener failed.'); void stop(); };
|
|
43
|
+
// Persistent handlers keep repeated signals from bypassing active cleanup.
|
|
44
|
+
process.on('SIGINT', stop);
|
|
45
|
+
process.on('SIGTERM', stop);
|
|
46
|
+
app.server.on('error', onError);
|
|
47
|
+
// Native close can precede database/worker cleanup: it starts, never ends, shutdown.
|
|
48
|
+
app.server.once('close', stop);
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { listen } = require('./network.cjs');
|
|
4
|
+
|
|
5
|
+
test('pages share a layout, serve CSS, and require no browser runtime', { timeout: 10000 }, async t => {
|
|
6
|
+
const origin = await listen(t);
|
|
7
|
+
for (const route of ['/', '/about']) {
|
|
8
|
+
const response = await fetch(`${origin}${route}`);
|
|
9
|
+
assert.equal(response.status, 200);
|
|
10
|
+
const document = await response.text();
|
|
11
|
+
assert.match(document, /<nav>/);
|
|
12
|
+
assert.doesNotMatch(document, /<script/);
|
|
13
|
+
const css = document.match(/<link rel="stylesheet" href="([^"]+)"/)[1];
|
|
14
|
+
const stylesheet = await fetch(`${origin}${css}`);
|
|
15
|
+
assert.equal(stylesheet.status, 200);
|
|
16
|
+
assert.match(await stylesheet.text(), /\.home/);
|
|
17
|
+
}
|
|
18
|
+
assert.equal((await fetch(`${origin}/missing`)).status, 404);
|
|
19
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineSite, start, type LiveHtmlStartOptions } from 'redweb';
|
|
2
|
+
import { runApp } from './run-app';
|
|
3
|
+
|
|
4
|
+
const site = defineSite({
|
|
5
|
+
css: 'app.css',
|
|
6
|
+
layout: content => <body><nav><a href="/">Home</a> · <a href="/about">About</a></nav>{content}</body>,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
@site.page('/', { head: { title: 'My Redweb site', description: 'A server-rendered TypeScript site.' } })
|
|
10
|
+
export class HomePage {
|
|
11
|
+
render() {
|
|
12
|
+
return <main class="home"><h1>Your server-rendered app is ready.</h1><p>Edit src/app.tsx to make it yours.</p></main>;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@site.page('/about', { head: { title: 'About' } })
|
|
17
|
+
export class AboutPage {
|
|
18
|
+
render() { return <main class="home"><h1>About</h1><p>Shared layout, separate pages, no browser JavaScript.</p></main>; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createApp(options: LiveHtmlStartOptions = {}) {
|
|
22
|
+
return start([HomePage, AboutPage], { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (require.main === module) runApp(createApp);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
## Socket starter
|
|
2
|
+
|
|
3
|
+
This is a WebSocket service, not an HTML page. Connect to `ws://localhost:8181/match?redwebVersion=1`.
|
|
4
|
+
The URL selects the match route; `type` selects its individual `Join`, `Move`, or `Resume` handler.
|
|
5
|
+
There are no socket decorators or secondary `message.action` dispatchers.
|
|
6
|
+
|
|
7
|
+
Read `src/contract.ts`, `src/app.tsx`, and `src/handlers.ts` together: they define
|
|
8
|
+
the wire contract, route/server configuration, and join/move/resume handlers.
|
|
9
|
+
The displayed handlers depend on those other generated files; initialize the
|
|
10
|
+
complete recipe first. Session ownership is separate from room fan-out.
|
|
11
|
+
|
|
12
|
+
`src/contract.ts` declares the wire payloads once using Zod, a Standard Schema validator. Both the server and a bundled browser/Node client can import it for runtime validation and inferred TypeScript types:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { match } from './contract';
|
|
16
|
+
|
|
17
|
+
const socket = new WebSocket('ws://localhost:8181/match?redwebVersion=1');
|
|
18
|
+
const client = match.client(socket);
|
|
19
|
+
socket.addEventListener('open', () => {
|
|
20
|
+
client.send('join', { name: 'Ada' }).catch(console.error);
|
|
21
|
+
});
|
|
22
|
+
socket.addEventListener('message', async event => {
|
|
23
|
+
try { console.log(await client.parse(event)); }
|
|
24
|
+
catch (error) { console.error(error); }
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The initial `state` response contains `{ session, name, x, y }`. Send `move` with `{ x: 7, y: -3 }` to change your server-owned position; send `resume` with `{ session }` on a new connection to recover it. Messages are processed in order on each connection. The client wrapper validates messages; it does not open or reconnect the WebSocket for you. Use WSS outside local development.
|
|
29
|
+
|
|
30
|
+
`npm test` opens real sockets and checks independent players, server-side moves, disconnect/resume, client validation, and server rejection of a malformed raw message. The starter also passes with the original source directory unavailable after building.
|
|
31
|
+
|
|
32
|
+
### Boundaries
|
|
33
|
+
|
|
34
|
+
- This demonstrates session-aware dispatch, not a complete authoritative game simulation. Coordinates are bounded integers; applications must enforce their own movement/rate/game rules.
|
|
35
|
+
- The random session ID is a bearer credential. Anyone holding it can resume that player and replace its previous connection. Keep it private; do not broadcast the `state` response to other players. Add account authentication and bind sessions to authenticated identity for production.
|
|
36
|
+
- Sessions are in memory, local to this server, capped at 100, and expire 30 seconds after disconnect. Server restart loses them. This is not persistent storage or a multi-instance session system.
|
|
37
|
+
- Calling `join` or `resume` while already joined is rejected. Movement before joining and unknown/expired sessions are rejected. Application failures currently use the protocol's sanitized `HANDLER_FAILED` error.
|
|
38
|
+
- Invalid contract payloads produce `INVALID_PAYLOAD` and close that connection with code 1008. The contract's `state` type is server output; it has no client-callable handler.
|
|
39
|
+
- Transport and heartbeat bounds are illustrative; tune and load-test them for your deployment. Zod belongs to this starter, not Redweb's runtime dependencies.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { once } = require('node:events');
|
|
4
|
+
const { listen, connect } = require('./network.cjs');
|
|
5
|
+
const { match } = require('../dist/contract.js');
|
|
6
|
+
|
|
7
|
+
async function rejected(client, type, payload) {
|
|
8
|
+
const closed = once(client.socket, 'close');
|
|
9
|
+
await match.client(client.socket).send(type, payload);
|
|
10
|
+
assert.equal((await client.receive(message => message.type === 'error')).error.code, 'HANDLER_FAILED');
|
|
11
|
+
assert.equal((await closed)[0], 1011);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('join, move and resume use separate validated handlers with isolated server sessions', { timeout: 10000 }, async t => {
|
|
15
|
+
const origin = await listen(t);
|
|
16
|
+
const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;
|
|
17
|
+
const first = await connect(t, url, origin);
|
|
18
|
+
const second = await connect(t, url, origin);
|
|
19
|
+
const client = match.client(first.socket);
|
|
20
|
+
await client.send('join', { name: ' Ada ' }, { requestId: 'join-1' });
|
|
21
|
+
const joined = await first.receive(message => message.type === 'state');
|
|
22
|
+
assert.equal(joined.requestId, 'join-1');
|
|
23
|
+
assert.equal(joined.payload.name, 'Ada');
|
|
24
|
+
assert.deepEqual([joined.payload.x, joined.payload.y], [0, 0]);
|
|
25
|
+
|
|
26
|
+
const unjoined = await connect(t, url, origin);
|
|
27
|
+
await rejected(unjoined, 'move', { x: 2, y: 3 });
|
|
28
|
+
await match.client(second.socket).send('join', { name: 'Grace' });
|
|
29
|
+
const other = await second.receive(message => message.type === 'state');
|
|
30
|
+
assert.notEqual(other.payload.session, joined.payload.session);
|
|
31
|
+
|
|
32
|
+
await client.send('move', { x: 7, y: -3 }, { requestId: 'move-1' });
|
|
33
|
+
assert.deepEqual((await first.receive(message => message.type === 'state')).payload,
|
|
34
|
+
{ ...joined.payload, x: 7, y: -3 });
|
|
35
|
+
await assert.rejects(client.send('move', { x: 101, y: 0 }), { code: 'INVALID_PAYLOAD' });
|
|
36
|
+
const closed = once(first.socket, 'close');
|
|
37
|
+
first.socket.close();
|
|
38
|
+
await closed;
|
|
39
|
+
const resumed = await connect(t, url, origin);
|
|
40
|
+
await match.client(resumed.socket).send('resume', { session: joined.payload.session });
|
|
41
|
+
assert.deepEqual((await resumed.receive(message => message.type === 'state')).payload,
|
|
42
|
+
{ ...joined.payload, x: 7, y: -3 });
|
|
43
|
+
|
|
44
|
+
// Bypass client validation to prove the server independently rejects malformed input.
|
|
45
|
+
const rejectedClosed = once(resumed.socket, 'close');
|
|
46
|
+
resumed.send({ v: match.version, type: 'move', payload: { x: 'wrong', y: 0 } });
|
|
47
|
+
assert.equal((await resumed.receive(message => message.type === 'error')).error.code, 'INVALID_PAYLOAD');
|
|
48
|
+
assert.equal((await rejectedClosed)[0], 1008);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('joined identities cannot join/resume again and unknown sessions fail closed', { timeout: 10000 }, async t => {
|
|
52
|
+
const origin = await listen(t);
|
|
53
|
+
const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;
|
|
54
|
+
for (const type of ['join', 'resume']) {
|
|
55
|
+
const client = await connect(t, url, origin);
|
|
56
|
+
await match.client(client.socket).send('join', { name: 'Ada' });
|
|
57
|
+
const joined = await client.receive(message => message.type === 'state');
|
|
58
|
+
await rejected(client, type, type === 'join' ? { name: 'Replacement' } : { session: joined.payload.session });
|
|
59
|
+
}
|
|
60
|
+
const visitor = await connect(t, url, origin);
|
|
61
|
+
await rejected(visitor, 'resume', { session: require('node:crypto').randomUUID() });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('retained disconnected sessions count toward the bounded session capacity', { timeout: 20000 }, async t => {
|
|
65
|
+
const origin = await listen(t);
|
|
66
|
+
const url = `${origin.replace('http:', 'ws:')}/match?redwebVersion=${match.version}`;
|
|
67
|
+
const sessions = new Set();
|
|
68
|
+
for (let index = 0; index < 100; index++) {
|
|
69
|
+
const client = await connect(t, url, origin);
|
|
70
|
+
await match.client(client.socket).send('join', { name: `player-${index}` });
|
|
71
|
+
const joined = await client.receive(message => message.type === 'state');
|
|
72
|
+
sessions.add(joined.payload.session);
|
|
73
|
+
const closed = once(client.socket, 'close');
|
|
74
|
+
client.socket.close();
|
|
75
|
+
await closed;
|
|
76
|
+
}
|
|
77
|
+
assert.equal(sessions.size, 100);
|
|
78
|
+
const overflow = await connect(t, url, origin);
|
|
79
|
+
await rejected(overflow, 'join', { name: 'overflow' });
|
|
80
|
+
// Capacity rejection does not invalidate a previously issued bearer session.
|
|
81
|
+
const resumed = await connect(t, url, origin);
|
|
82
|
+
const session = sessions.values().next().value;
|
|
83
|
+
await match.client(resumed.socket).send('resume', { session });
|
|
84
|
+
assert.equal((await resumed.receive(message => message.type === 'state')).payload.session, session);
|
|
85
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { SocketRoute, SocketServer, type SocketServerOptions } from 'redweb';
|
|
2
|
+
import { match } from './contract';
|
|
3
|
+
import { Join, Move, Resume } from './handlers';
|
|
4
|
+
import { runApp } from './run-app';
|
|
5
|
+
|
|
6
|
+
export class MatchRoute extends SocketRoute {
|
|
7
|
+
constructor() {
|
|
8
|
+
super({
|
|
9
|
+
path: '/match',
|
|
10
|
+
handlers: [Join, Move, Resume],
|
|
11
|
+
protocol: match.protocol,
|
|
12
|
+
orderedMessages: true,
|
|
13
|
+
sessions: { ttlMs: 30000, maxSessions: 100 },
|
|
14
|
+
heartbeat: { intervalMs: 15000, timeoutMs: 10000 },
|
|
15
|
+
allowDuplicateConnections: true,
|
|
16
|
+
websocketOptions: { maxPayload: 4096 },
|
|
17
|
+
limits: { maxConnections: 100, maxPendingMessages: 32, maxBufferedBytes: 65536 },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createApp(options: SocketServerOptions = {}) {
|
|
23
|
+
return new SocketServer({
|
|
24
|
+
port: Number(process.env.PORT ?? 8181),
|
|
25
|
+
routes: [MatchRoute],
|
|
26
|
+
...options,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (require.main === module) runApp(createApp);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineSocketContract } from 'redweb/contract';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const position = { x: z.number().int().min(-100).max(100), y: z.number().int().min(-100).max(100) };
|
|
5
|
+
|
|
6
|
+
// Share this module with a browser or Node client. It imports no server application code.
|
|
7
|
+
export const match = defineSocketContract('1', {
|
|
8
|
+
join: z.object({ name: z.string().trim().min(1).max(40) }).strict(),
|
|
9
|
+
move: z.object(position).strict(),
|
|
10
|
+
resume: z.object({ session: z.string().uuid() }).strict(),
|
|
11
|
+
state: z.object({ session: z.string().uuid(), name: z.string(), ...position }).strict(),
|
|
12
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { RedWebSocket } from 'redweb';
|
|
3
|
+
import { match } from './contract';
|
|
4
|
+
|
|
5
|
+
class Player {
|
|
6
|
+
readonly session = randomUUID();
|
|
7
|
+
x = 0;
|
|
8
|
+
y = 0;
|
|
9
|
+
constructor(readonly name: string) {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function requireUnjoined(socket: RedWebSocket) {
|
|
13
|
+
if (socket.context?.session) throw new Error('Already joined.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function currentPlayer(socket: RedWebSocket) {
|
|
17
|
+
const session = socket.context?.session as { data?: unknown } | null | undefined;
|
|
18
|
+
if (!(session?.data instanceof Player)) throw new Error('Join or resume first.');
|
|
19
|
+
return session.data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const Join = match.handler('join', (socket, { name }, message) => {
|
|
23
|
+
requireUnjoined(socket);
|
|
24
|
+
const player = new Player(name);
|
|
25
|
+
if (!socket.createSession?.(player.session, player)) throw new Error('Session capacity reached.');
|
|
26
|
+
return match.send(socket, 'state', player, { requestId: message.requestId });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const Move = match.handler('move', (socket, { x, y }, message) => {
|
|
30
|
+
const player = currentPlayer(socket);
|
|
31
|
+
player.x = x;
|
|
32
|
+
player.y = y;
|
|
33
|
+
return match.send(socket, 'state', player, { requestId: message.requestId });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const Resume = match.handler('resume', (socket, { session }, message) => {
|
|
37
|
+
requireUnjoined(socket);
|
|
38
|
+
if (!(socket.resumeSession?.(session) instanceof Player)) throw new Error('Session expired or unknown.');
|
|
39
|
+
return match.send(socket, 'state', currentPlayer(socket), { requestId: message.requestId });
|
|
40
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Tracks only a caller-owned listener, including pre-HTTP/pre-TLS TCP peers. */
|
|
4
|
+
class OwnedServerLifecycle {
|
|
5
|
+
constructor(server) {
|
|
6
|
+
this.server = server;
|
|
7
|
+
this.connections = new Set();
|
|
8
|
+
this.forced = false;
|
|
9
|
+
this.onEmpty = null;
|
|
10
|
+
this.onConnection = socket => {
|
|
11
|
+
this.connections.add(socket);
|
|
12
|
+
socket.once('close', () => {
|
|
13
|
+
this.connections.delete(socket);
|
|
14
|
+
this.onEmpty?.();
|
|
15
|
+
});
|
|
16
|
+
if (this.forced) socket.destroy();
|
|
17
|
+
};
|
|
18
|
+
this.dispose = () => {
|
|
19
|
+
server.off('connection', this.onConnection);
|
|
20
|
+
server.off('close', this.dispose);
|
|
21
|
+
};
|
|
22
|
+
server.on('connection', this.onConnection);
|
|
23
|
+
server.once('close', this.dispose);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
forceClose() {
|
|
27
|
+
this.forced = true;
|
|
28
|
+
const errors = [];
|
|
29
|
+
// A failed/non-cooperating close callback must not leave admission open.
|
|
30
|
+
if (this.server.listening) {
|
|
31
|
+
try { this.server.close(); } catch (error) { errors.push(error); }
|
|
32
|
+
}
|
|
33
|
+
for (const socket of this.connections) {
|
|
34
|
+
try { socket.destroy(); } catch (error) { errors.push(error); }
|
|
35
|
+
}
|
|
36
|
+
return errors;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
close(timeoutMs, closeCallback) {
|
|
40
|
+
let timer;
|
|
41
|
+
const empty = new Promise(resolve => {
|
|
42
|
+
this.onEmpty = () => { if (this.connections.size === 0) resolve(); };
|
|
43
|
+
});
|
|
44
|
+
const closing = Promise.resolve().then(closeCallback).catch(error => {
|
|
45
|
+
const cleanup = this.forceClose();
|
|
46
|
+
if (cleanup.length) throw new AggregateError([error, ...cleanup], error.message, { cause: error });
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
const timeout = new Promise((resolve, reject) => {
|
|
50
|
+
timer = setTimeout(() => {
|
|
51
|
+
const errors = this.forceClose();
|
|
52
|
+
if (errors.length) reject(new AggregateError(errors, 'Owned listener force-close failed.'));
|
|
53
|
+
else resolve();
|
|
54
|
+
}, timeoutMs);
|
|
55
|
+
});
|
|
56
|
+
this.onEmpty();
|
|
57
|
+
return Promise.race([Promise.all([closing, empty]), timeout]).then(() => undefined).finally(() => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
this.onEmpty = null;
|
|
60
|
+
// Retain the forced-peer guard until native close when peers remain.
|
|
61
|
+
if (!this.server.listening && this.connections.size === 0) this.dispose();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = OwnedServerLifecycle;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { BoundedOperation, OperationInterrupted } = require('../async/BoundedOperation');
|
|
2
|
+
const codes = require('./failure-codes.json');
|
|
3
|
+
|
|
4
|
+
class AccessDenied extends Error {
|
|
5
|
+
constructor(code = 'ACCESS_DENIED') {
|
|
6
|
+
const safeCode = typeof code === 'string' && code.startsWith('ACCESS_') && Object.hasOwn(codes, code) ? code : 'ACCESS_DENIED';
|
|
7
|
+
super(codes[safeCode].message);
|
|
8
|
+
this.code = safeCode;
|
|
9
|
+
this.status = codes[safeCode].status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Explicit permission, not identity lookup. Only a literal true permits work. */
|
|
14
|
+
class AccessPolicy {
|
|
15
|
+
constructor(authorize, timeoutMs) {
|
|
16
|
+
if (authorize !== undefined && typeof authorize !== 'function') throw new TypeError('authorize must be a function.');
|
|
17
|
+
if (authorize === undefined && timeoutMs !== undefined) throw new TypeError('authorizationTimeoutMs requires authorize.');
|
|
18
|
+
this.authorize = authorize;
|
|
19
|
+
this.boundary = new BoundedOperation(timeoutMs);
|
|
20
|
+
Object.freeze(this);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async check(context, input) {
|
|
24
|
+
if (!this.authorize) return;
|
|
25
|
+
try {
|
|
26
|
+
const allowed = await this.boundary.run(signal => this.authorize(Object.freeze({ ...context, signal }), input), context?.signal);
|
|
27
|
+
if (allowed !== true) throw new AccessDenied();
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof AccessDenied) throw new AccessDenied(error.code);
|
|
30
|
+
if (error instanceof OperationInterrupted) throw new AccessDenied(error.reason === 'timeout' ? 'ACCESS_TIMEOUT' : 'ACCESS_CANCELLED');
|
|
31
|
+
// Do not turn a broken policy into a permission denial or expose its details.
|
|
32
|
+
throw new Error('Authorization policy failed.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { AccessPolicy, AccessDenied };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const codes = require('./failure-codes.json');
|
|
3
|
+
|
|
4
|
+
/** Safe authentication diagnostics shared by page and socket boundaries. */
|
|
5
|
+
class AuthenticationFailure extends Error {
|
|
6
|
+
constructor(code = 'AUTHENTICATION_REQUIRED') {
|
|
7
|
+
super(codes.AUTHENTICATION_REQUIRED.message);
|
|
8
|
+
this.code = typeof code === 'string' && code.startsWith('AUTHENTICATION_') && Object.hasOwn(codes, code) ? code : 'AUTHENTICATION_FAILED';
|
|
9
|
+
this.status = codes[this.code].status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = AuthenticationFailure;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { STATUS_CODES } = require('http');
|
|
4
|
+
const AuthenticationFailure = require('./AuthenticationFailure');
|
|
5
|
+
const { AccessDenied } = require('./AccessPolicy');
|
|
6
|
+
const ActionInputError = require('../validation/ActionInputError');
|
|
7
|
+
const codes = require('./failure-codes.json');
|
|
8
|
+
|
|
9
|
+
const UPGRADE_REJECTION = Symbol('redweb.upgradeRejection');
|
|
10
|
+
|
|
11
|
+
/** Only allowlisted codes cross the HTTP upgrade boundary; never callback text. */
|
|
12
|
+
class RequestFailure extends Error {
|
|
13
|
+
constructor(code) {
|
|
14
|
+
const safeCode = typeof code === 'string' && Object.hasOwn(codes, code) ? code : 'ADMISSION_FAILED';
|
|
15
|
+
const statusCode = codes[safeCode].status;
|
|
16
|
+
super(safeCode.startsWith('AUTHENTICATION_') ? codes.AUTHENTICATION_REQUIRED.message : codes[safeCode].message || STATUS_CODES[statusCode]);
|
|
17
|
+
this.code = safeCode;
|
|
18
|
+
this.status = statusCode;
|
|
19
|
+
this.rejection = Object.freeze({
|
|
20
|
+
statusCode, statusText: STATUS_CODES[statusCode],
|
|
21
|
+
headers: Object.freeze({ 'Redweb-Error': safeCode, 'Cache-Control': 'no-store' }),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static from(error, fallback = 'ADMISSION_FAILED') {
|
|
26
|
+
try {
|
|
27
|
+
return new RequestFailure(error instanceof RequestFailure || error instanceof AuthenticationFailure || error instanceof AccessDenied || error instanceof ActionInputError
|
|
28
|
+
? error.code : fallback);
|
|
29
|
+
} catch { return new RequestFailure(fallback); }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { RequestFailure, UPGRADE_REJECTION };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"REQUEST_INVALID": { "status": 400 },
|
|
3
|
+
"AUTHENTICATION_REQUIRED": { "status": 401, "message": "Page authentication did not complete successfully." },
|
|
4
|
+
"AUTHENTICATION_FAILED": { "status": 500 },
|
|
5
|
+
"AUTHENTICATION_TIMEOUT": { "status": 503 },
|
|
6
|
+
"AUTHENTICATION_CANCELLED": { "status": 503 },
|
|
7
|
+
"ORIGIN_DENIED": { "status": 403 },
|
|
8
|
+
"PLACEMENT_DENIED": { "status": 403 },
|
|
9
|
+
"PLACEMENT_INVALID": { "status": 500 },
|
|
10
|
+
"ADMISSION_FAILED": { "status": 500 },
|
|
11
|
+
"ADMISSION_TIMEOUT": { "status": 503 },
|
|
12
|
+
"ADMISSION_CANCELLED": { "status": 503 },
|
|
13
|
+
"ADMISSION_CAPACITY": { "status": 503 },
|
|
14
|
+
"SERVER_DRAINING": { "status": 503 },
|
|
15
|
+
"ROUTE_UNAVAILABLE": { "status": 503 },
|
|
16
|
+
"ACCESS_DENIED": { "status": 403, "message": "This operation is not permitted." },
|
|
17
|
+
"ACCESS_TIMEOUT": { "status": 503, "message": "Authorization timed out. The operation was not run." },
|
|
18
|
+
"ACCESS_CANCELLED": { "status": 503, "message": "Authorization was cancelled. The operation was not run." },
|
|
19
|
+
"ACCESS_CAPACITY": { "status": 503, "message": "Authorization capacity reached. The operation was not run." },
|
|
20
|
+
"PAGE_FAILED": { "status": 500, "message": "Page request failed." },
|
|
21
|
+
"PAGE_CAPACITY": { "status": 503, "message": "Page session capacity reached." },
|
|
22
|
+
"ACTION_INVALID_INPUT": { "status": 400, "message": "Action input is invalid. Check the form values and try again." },
|
|
23
|
+
"ACTION_VALIDATION_TIMEOUT": { "status": 503, "message": "Action input validation timed out. The action was not run." },
|
|
24
|
+
"ACTION_CANCELLED": { "status": 503, "message": "The connection closed before input validation completed. The action was not run." }
|
|
25
|
+
}
|