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,102 @@
|
|
|
1
|
+
import { action, component, state, type ActionInput, type LivePageConnectionContext, type LivePageRequestContext } from 'redweb';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { sessionToken } from './auth';
|
|
4
|
+
import { DashboardStore, MAX_CARDS, type Card } from './store';
|
|
5
|
+
|
|
6
|
+
const addInput = z.object({ title: z.string().trim().min(1).max(80).regex(/^[^\p{Cc}\p{Cf}]+$/u) }).strict();
|
|
7
|
+
const removeInput = z.object({ id: z.string().uuid() }).strict();
|
|
8
|
+
const tokenOf = (context: LivePageRequestContext) => sessionToken(context.request.get('cookie'));
|
|
9
|
+
|
|
10
|
+
interface Subscriber { token: string; update(): void; close(): void; }
|
|
11
|
+
|
|
12
|
+
/** Single-process notifications; SQLite remains the source of truth on every connection. */
|
|
13
|
+
export class PrivateCards {
|
|
14
|
+
private readonly accounts = new Map<string, Set<Subscriber>>();
|
|
15
|
+
constructor(readonly store: DashboardStore) {}
|
|
16
|
+
|
|
17
|
+
allowed(context: LivePageRequestContext) {
|
|
18
|
+
const session = this.store.session(tokenOf(context));
|
|
19
|
+
return !!session && session.account === context.principal && !context.signal.aborted;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
subscribe(context: LivePageConnectionContext, update: (cards: Card[]) => void): () => void {
|
|
23
|
+
const token = tokenOf(context);
|
|
24
|
+
const session = this.store.session(token);
|
|
25
|
+
if (!session || !this.allowed(context)) throw new Error('Sign in again.');
|
|
26
|
+
let group = this.accounts.get(session.account);
|
|
27
|
+
if (!group) this.accounts.set(session.account, group = new Set());
|
|
28
|
+
let closed = false;
|
|
29
|
+
const subscriber: Subscriber = {
|
|
30
|
+
token, update: () => update(this.store.list(token)),
|
|
31
|
+
close: () => { unsubscribe(); context.socket.close(1008, 'Sign in again.'); },
|
|
32
|
+
};
|
|
33
|
+
const unsubscribe = () => {
|
|
34
|
+
if (closed) return;
|
|
35
|
+
closed = true;
|
|
36
|
+
clearTimeout(expiry);
|
|
37
|
+
context.signal.removeEventListener('abort', unsubscribe);
|
|
38
|
+
group.delete(subscriber);
|
|
39
|
+
if (!group.size && this.accounts.get(session.account) === group) this.accounts.delete(session.account);
|
|
40
|
+
};
|
|
41
|
+
const expiry = setTimeout(subscriber.close, Math.max(1, session.expires - Date.now()));
|
|
42
|
+
expiry.unref();
|
|
43
|
+
group.add(subscriber);
|
|
44
|
+
context.signal.addEventListener('abort', unsubscribe, { once: true });
|
|
45
|
+
try { subscriber.update(); }
|
|
46
|
+
catch (error) { unsubscribe(); throw error; }
|
|
47
|
+
return unsubscribe;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
publish(account: string) {
|
|
51
|
+
for (const subscriber of this.accounts.get(account) ?? []) {
|
|
52
|
+
try {
|
|
53
|
+
if (this.store.session(subscriber.token)?.account === account) subscriber.update();
|
|
54
|
+
else subscriber.close();
|
|
55
|
+
} catch { subscriber.close(); }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@component()
|
|
61
|
+
export class Cards {
|
|
62
|
+
@state() items: Card[] = [];
|
|
63
|
+
private unsubscribe?: () => void;
|
|
64
|
+
|
|
65
|
+
constructor(private readonly cards: PrivateCards) {}
|
|
66
|
+
loading(context: LivePageRequestContext) { this.items = this.cards.store.list(tokenOf(context)); }
|
|
67
|
+
connected(context: LivePageConnectionContext) {
|
|
68
|
+
this.disconnected();
|
|
69
|
+
this.unsubscribe = this.cards.subscribe(context, items => { this.items = items; });
|
|
70
|
+
}
|
|
71
|
+
disconnected() { this.unsubscribe?.(); this.unsubscribe = undefined; }
|
|
72
|
+
disposed() { this.disconnected(); }
|
|
73
|
+
|
|
74
|
+
@action({ input: addInput })
|
|
75
|
+
add({ title }: ActionInput<typeof addInput>, context: LivePageConnectionContext) {
|
|
76
|
+
this.cards.publish(this.cards.store.add(tokenOf(context), title));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@action({ input: removeInput })
|
|
80
|
+
remove({ id }: ActionInput<typeof removeInput>, context: LivePageConnectionContext) {
|
|
81
|
+
this.cards.publish(this.cards.store.remove(tokenOf(context), id));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
render() {
|
|
85
|
+
return <section class="cards" aria-label="Your saved cards">
|
|
86
|
+
<form rw-submit="add">
|
|
87
|
+
<label for="card-title">New card</label>
|
|
88
|
+
<input id="card-title" name="title" maxlength="80" required autocomplete="off" />
|
|
89
|
+
<button type="submit" disabled={this.items.length >= MAX_CARDS}>Add card</button>
|
|
90
|
+
</form>
|
|
91
|
+
<p>{this.items.length} / {MAX_CARDS} cards · saved automatically</p>
|
|
92
|
+
<ul class="card-grid">{this.items.map(card => <li key={card.id} data-card-id={card.id}>
|
|
93
|
+
<h2>{card.title}</h2>
|
|
94
|
+
<form rw-submit="remove">
|
|
95
|
+
<input type="hidden" name="id" value={card.id} />
|
|
96
|
+
<button type="submit" aria-label={`Delete ${card.title}`}>Delete</button>
|
|
97
|
+
</form>
|
|
98
|
+
</li>)}</ul>
|
|
99
|
+
{!this.items.length && <p>No cards yet. Add your first one above.</p>}
|
|
100
|
+
</section>;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { test } = require('node:test');
|
|
3
|
+
const { DashboardStore } = require('../dist/store');
|
|
4
|
+
const { DashboardAuth, credentials } = require('../dist/auth');
|
|
5
|
+
|
|
6
|
+
test('login admission reopens after the real one-minute window, without clock mocks', { timeout: 65000 }, async t => {
|
|
7
|
+
const store = new DashboardStore(':memory:');
|
|
8
|
+
const auth = new DashboardAuth(store);
|
|
9
|
+
t.after(() => { auth.close(); store.close(); });
|
|
10
|
+
const password = 'test-only-login-window-password';
|
|
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);
|
|
13
|
+
assert.equal(await auth.login('same-peer', 'alice', password), undefined);
|
|
14
|
+
await new Promise(resolve => setTimeout(resolve, 60010));
|
|
15
|
+
const token = await auth.login('same-peer', 'alice', password);
|
|
16
|
+
assert.equal(store.session(token).account, 'alice');
|
|
17
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
|
|
4
|
+
export interface Card { id: string; title: string; }
|
|
5
|
+
export interface Session { account: string; expires: number; }
|
|
6
|
+
export interface Credentials { salt: string; hash: string; }
|
|
7
|
+
export const MAX_CARDS = 100;
|
|
8
|
+
export const USERNAME = /^[a-z][a-z0-9_-]{2,31}$/;
|
|
9
|
+
const digest = (token: string) => createHash('sha256').update(token).digest('hex');
|
|
10
|
+
|
|
11
|
+
/** Recipe-local persistence. Every private query derives its owner from a live session. */
|
|
12
|
+
export class DashboardStore {
|
|
13
|
+
private readonly db: DatabaseSync;
|
|
14
|
+
private closed = false;
|
|
15
|
+
|
|
16
|
+
constructor(filename: string) {
|
|
17
|
+
this.db = new DatabaseSync(filename);
|
|
18
|
+
try {
|
|
19
|
+
this.db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 1000;');
|
|
20
|
+
const version = this.db.prepare('PRAGMA user_version').get()!.user_version;
|
|
21
|
+
if (version !== 0 && version !== 1) throw new Error('Unsupported dashboard database version.');
|
|
22
|
+
this.db.exec(`
|
|
23
|
+
BEGIN IMMEDIATE;
|
|
24
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
25
|
+
id TEXT PRIMARY KEY, salt TEXT NOT NULL, hash TEXT NOT NULL, epoch INTEGER NOT NULL DEFAULT 0
|
|
26
|
+
) STRICT;
|
|
27
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
28
|
+
token TEXT PRIMARY KEY, account TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
29
|
+
expires INTEGER NOT NULL
|
|
30
|
+
) STRICT;
|
|
31
|
+
CREATE INDEX IF NOT EXISTS sessions_owner ON sessions(account);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS cards (
|
|
34
|
+
id TEXT PRIMARY KEY, account TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
35
|
+
title TEXT NOT NULL CHECK(length(title) BETWEEN 1 AND 80)
|
|
36
|
+
) STRICT;
|
|
37
|
+
CREATE INDEX IF NOT EXISTS cards_owner ON cards(account);
|
|
38
|
+
PRAGMA user_version = 1;
|
|
39
|
+
COMMIT;
|
|
40
|
+
`);
|
|
41
|
+
} catch (error) { this.db.close(); throw error; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
provision(account: string, credentials: Credentials) {
|
|
45
|
+
if (!USERNAME.test(account) || !/^[a-f0-9]{32}$/.test(credentials.salt) || !/^[a-f0-9]{128}$/.test(credentials.hash)) {
|
|
46
|
+
throw new TypeError('Invalid account credentials.');
|
|
47
|
+
}
|
|
48
|
+
this.db.prepare('INSERT INTO accounts(id, salt, hash) VALUES (?, ?, ?)').run(account, credentials.salt, credentials.hash);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
credentials(account: string): (Credentials & { epoch: number }) | undefined {
|
|
52
|
+
return this.db.prepare('SELECT salt, hash, epoch FROM accounts WHERE id = ?').get(account) as unknown as (Credentials & { epoch: number }) | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
issue(account: string, ttlMs: number, expectedEpoch?: number): string {
|
|
56
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Session lifetime must be 100ms–24h.');
|
|
57
|
+
return this.transaction(() => {
|
|
58
|
+
if (expectedEpoch !== undefined && this.credentials(account)?.epoch !== expectedEpoch) throw new Error('Sign-out occurred during sign-in. Try again.');
|
|
59
|
+
this.db.prepare('DELETE FROM sessions WHERE expires <= ?').run(Date.now());
|
|
60
|
+
const count = this.db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE account = ?').get(account)!.count as number;
|
|
61
|
+
if (count >= 32) throw new Error('Sign out existing sessions before signing in again.');
|
|
62
|
+
const token = randomBytes(32).toString('base64url');
|
|
63
|
+
this.db.prepare('INSERT INTO sessions(token, account, expires) VALUES (?, ?, ?)').run(digest(token), account, Date.now() + ttlMs);
|
|
64
|
+
return token;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
session(token: string): Session | undefined {
|
|
69
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return undefined;
|
|
70
|
+
return this.db.prepare('SELECT account, expires FROM sessions WHERE token = ? AND expires > ?').get(digest(token), Date.now()) as unknown as Session | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
list(token: string): Card[] {
|
|
74
|
+
const { account } = this.requireSession(token);
|
|
75
|
+
return this.db.prepare('SELECT id, title FROM cards WHERE account = ? ORDER BY rowid').all(account) as unknown as Card[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
add(token: string, title: string): string {
|
|
79
|
+
if (typeof title !== 'string' || !title.trim() || title.length > 80 || /[\p{Cc}\p{Cf}]/u.test(title)) throw new TypeError('Invalid card title.');
|
|
80
|
+
return this.transaction(() => {
|
|
81
|
+
const { account } = this.requireSession(token);
|
|
82
|
+
const count = this.db.prepare('SELECT COUNT(*) AS count FROM cards WHERE account = ?').get(account)!.count as number;
|
|
83
|
+
if (count >= MAX_CARDS) throw new Error('Card limit reached.');
|
|
84
|
+
this.db.prepare('INSERT INTO cards(id, account, title) VALUES (?, ?, ?)').run(randomUUID(), account, title.trim());
|
|
85
|
+
return account;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
remove(token: string, id: string): string {
|
|
90
|
+
return this.transaction(() => {
|
|
91
|
+
const { account } = this.requireSession(token);
|
|
92
|
+
this.db.prepare('DELETE FROM cards WHERE id = ? AND account = ?').run(id, account);
|
|
93
|
+
return account;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
signOut(token: string): string | undefined {
|
|
98
|
+
return this.transaction(() => {
|
|
99
|
+
const session = this.session(token);
|
|
100
|
+
if (!session) return undefined;
|
|
101
|
+
this.db.prepare('DELETE FROM sessions WHERE account = ?').run(session.account);
|
|
102
|
+
this.db.prepare('UPDATE accounts SET epoch = epoch + 1 WHERE id = ?').run(session.account);
|
|
103
|
+
return session.account;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
close() { if (!this.closed) { this.closed = true; this.db.close(); } }
|
|
108
|
+
|
|
109
|
+
private requireSession(token: string): Session {
|
|
110
|
+
const session = this.session(token);
|
|
111
|
+
if (!session) throw new Error('Session expired. Sign in again.');
|
|
112
|
+
return session;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private transaction<T>(operation: () => T): T {
|
|
116
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
117
|
+
try { const result = operation(); this.db.exec('COMMIT'); return result; }
|
|
118
|
+
catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## HTTP and WebSockets on one listener
|
|
2
|
+
|
|
3
|
+
One Node server answers ordinary HTTP requests and upgrades `/chat` connections to WebSockets. HTTP paths select Express services; a socket URL selects a route and each message's `type` selects a handler. No socket decorators or secondary `message.action` dispatcher are needed.
|
|
4
|
+
|
|
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
|
+
|
|
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.
|
|
8
|
+
|
|
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
|
+
|
|
11
|
+
`npm test` runs real HTTP and WebSocket requests on ephemeral ports, checks strict socket routing and multiple clients, verifies idempotent cleanup with an incomplete HTTP peer, and proves a failing route cleanup still closes the listener. It also runs the shared process-lifecycle suite. No mocks are used. The package verification gate repeats the tests against the compiled application with `src/` unavailable.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const net = require('node:net');
|
|
4
|
+
const { once } = require('node:events');
|
|
5
|
+
const WebSocket = require('ws');
|
|
6
|
+
const { SocketRoute } = require('redweb');
|
|
7
|
+
const { createApp, Hello } = require('../dist/app.js');
|
|
8
|
+
const { listen, connect } = require('./network.cjs');
|
|
9
|
+
|
|
10
|
+
test('an absent PORT binds the documented default or reports that exact port occupied', { timeout: 10000 }, async () => {
|
|
11
|
+
const { spawnSync } = require('node:child_process');
|
|
12
|
+
const env = { ...process.env };
|
|
13
|
+
delete env.PORT;
|
|
14
|
+
const result = spawnSync(process.execPath, ['-e', `
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const { once } = require('node:events');
|
|
17
|
+
const WebSocket = require('ws');
|
|
18
|
+
const { createApp } = require('./dist/app.js');
|
|
19
|
+
(async () => {
|
|
20
|
+
const app = createApp();
|
|
21
|
+
let socket;
|
|
22
|
+
try {
|
|
23
|
+
try { if (!app.server.listening) await once(app.server, 'listening'); }
|
|
24
|
+
catch (error) {
|
|
25
|
+
assert.equal(error.code, 'EADDRINUSE');
|
|
26
|
+
assert.equal(error.port, 8181);
|
|
27
|
+
return; // Never send test traffic to a listener this test does not own.
|
|
28
|
+
}
|
|
29
|
+
assert.equal(app.server.address().port, 8181);
|
|
30
|
+
const response = await fetch('http://127.0.0.1:8181/health', { signal: AbortSignal.timeout(3000) });
|
|
31
|
+
assert.deepEqual(await response.json(), { ok: true });
|
|
32
|
+
socket = new WebSocket('ws://127.0.0.1:8181/chat', { handshakeTimeout: 3000 });
|
|
33
|
+
await once(socket, 'open');
|
|
34
|
+
const reply = once(socket, 'message');
|
|
35
|
+
socket.send(JSON.stringify({ type: 'hello' }));
|
|
36
|
+
assert.equal(JSON.parse((await reply)[0].toString()).type, 'hello');
|
|
37
|
+
} finally { socket?.terminate(); await app.shutdown(); }
|
|
38
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
39
|
+
`], { env, encoding: 'utf8', timeout: 7000, windowsHide: true });
|
|
40
|
+
assert.equal(result.error, undefined);
|
|
41
|
+
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('HTTP and separate message handlers share one port, with strict socket paths', { timeout: 10000 }, async t => {
|
|
45
|
+
const origin = await listen(t);
|
|
46
|
+
const response = await fetch(`${origin}/health`, { signal: AbortSignal.timeout(3000) });
|
|
47
|
+
assert.equal(response.status, 200);
|
|
48
|
+
assert.deepEqual(await response.json(), { ok: true });
|
|
49
|
+
assert.equal((await fetch(`${origin}/missing`, { signal: AbortSignal.timeout(3000) })).status, 404);
|
|
50
|
+
for (let index = 0; index < 2; index++) {
|
|
51
|
+
const client = await connect(t, `${origin.replace('http:', 'ws:')}/chat`, origin);
|
|
52
|
+
client.send({ type: 'hello' });
|
|
53
|
+
assert.deepEqual(await client.receive(message => message.type === 'hello'),
|
|
54
|
+
{ type: 'hello', message: 'Hello from the server!' });
|
|
55
|
+
}
|
|
56
|
+
const unknown = new WebSocket(`${origin.replace('http:', 'ws:')}/missing`, { handshakeTimeout: 3000 });
|
|
57
|
+
t.after(() => unknown.terminate());
|
|
58
|
+
await once(unknown, 'error');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
for (const failingRoute of [false, true]) {
|
|
62
|
+
test(`shutdown closes incomplete HTTP peers${failingRoute ? ' despite a route failure' : ' idempotently'}`, { timeout: 10000 }, async t => {
|
|
63
|
+
const app = createApp({ port: 0, logger: null });
|
|
64
|
+
t.after(() => app.shutdown().catch(() => {}));
|
|
65
|
+
if (!app.server.listening) await once(app.server, 'listening');
|
|
66
|
+
assert.equal(app.closeServerOnShutdown, true);
|
|
67
|
+
const failure = new Error('Application cleanup failed');
|
|
68
|
+
if (failingRoute) {
|
|
69
|
+
class FailingRoute extends SocketRoute {
|
|
70
|
+
constructor() { super({ path: '/fails', handlers: [Hello] }); }
|
|
71
|
+
async shutdown() { await super.shutdown(); throw failure; }
|
|
72
|
+
}
|
|
73
|
+
app.addRoute(FailingRoute);
|
|
74
|
+
}
|
|
75
|
+
const accepted = once(app.server, 'connection');
|
|
76
|
+
const peer = net.connect(app.server.address().port, '127.0.0.1');
|
|
77
|
+
t.after(() => peer.destroy());
|
|
78
|
+
peer.on('error', () => {});
|
|
79
|
+
await once(peer, 'connect');
|
|
80
|
+
const [serverPeer] = await accepted;
|
|
81
|
+
peer.write('POST /health HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\nx');
|
|
82
|
+
const closed = once(serverPeer, 'close');
|
|
83
|
+
const shutdown = app.shutdown();
|
|
84
|
+
assert.equal(app.shutdown(), shutdown);
|
|
85
|
+
if (failingRoute) await assert.rejects(shutdown, error => error.errors.length === 1 && error.errors[0] === failure);
|
|
86
|
+
else await shutdown;
|
|
87
|
+
await closed;
|
|
88
|
+
assert.equal(serverPeer.destroyed, true);
|
|
89
|
+
assert.equal(app.server.listening, false);
|
|
90
|
+
assert.equal(app.server.listenerCount('upgrade'), 0);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BaseHandler, HttpServer, METHODS, SocketRoute, SocketServer, type RedWebSocket, type SocketServerOptions } from 'redweb';
|
|
2
|
+
import { runApp } from './run-app';
|
|
3
|
+
|
|
4
|
+
export class Hello extends BaseHandler {
|
|
5
|
+
constructor() { super('hello'); }
|
|
6
|
+
|
|
7
|
+
onMessage(socket: RedWebSocket) {
|
|
8
|
+
socket.sendJson({ type: 'hello', message: 'Hello from the server!' });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ChatRoute extends SocketRoute {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({ path: '/chat', handlers: [Hello], allowDuplicateConnections: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
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
|
+
});
|
|
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);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## Realtime starter
|
|
2
|
+
|
|
3
|
+
`CounterPage` owns its state on the server. `shared: true` deliberately shares the counter between visitors.
|
|
4
|
+
The browser button invokes only the decorated `increment` action; it does not supply the new count.
|
|
5
|
+
Open two tabs to check the broadcast. State is in memory and resets when the server restarts.
|
|
6
|
+
|
|
7
|
+
`<output>{this.count}</output>` updates automatically because the page reads decorated state during rendering.
|
|
8
|
+
No repeated binding name or browser-side state is needed. State changes are assignment-driven; render methods must not modify state.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { listen, live } = require('./network.cjs');
|
|
4
|
+
|
|
5
|
+
test('one server action updates both visitors', { timeout: 10000 }, async t => {
|
|
6
|
+
const origin = await listen(t);
|
|
7
|
+
const first = await live(t, origin);
|
|
8
|
+
const second = await live(t, origin);
|
|
9
|
+
await first.patch(patch => patch.html.includes('<output>0</output>'));
|
|
10
|
+
await second.patch(patch => patch.html.includes('<output>0</output>'));
|
|
11
|
+
first.action('increment');
|
|
12
|
+
await first.patch(patch => patch.html.includes('<output>1</output>'));
|
|
13
|
+
await second.patch(patch => patch.html.includes('<output>1</output>'));
|
|
14
|
+
assert.match(await (await fetch(origin)).text(), /<output>1<\/output>/);
|
|
15
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { action, page, start, state, type LiveHtmlStartOptions } from 'redweb';
|
|
2
|
+
import { runApp } from './run-app';
|
|
3
|
+
|
|
4
|
+
@page('/', { css: 'app.css', shared: true })
|
|
5
|
+
export class CounterPage {
|
|
6
|
+
@state() count = 0;
|
|
7
|
+
|
|
8
|
+
@action()
|
|
9
|
+
increment() { this.count += 1; }
|
|
10
|
+
|
|
11
|
+
render() {
|
|
12
|
+
return (
|
|
13
|
+
<main class="home">
|
|
14
|
+
<h1>A counter owned by the server</h1>
|
|
15
|
+
<p>Open this page in two tabs. Either button updates both.</p>
|
|
16
|
+
<button rw-click="increment">
|
|
17
|
+
Count <output>{this.count}</output>
|
|
18
|
+
</button>
|
|
19
|
+
</main>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createApp(options: LiveHtmlStartOptions = {}) {
|
|
25
|
+
return start(CounterPage, { port: Number(process.env.PORT ?? 8181), templateRoot: __dirname, ...options });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (require.main === module) runApp(createApp);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Your Redweb application
|
|
2
|
+
|
|
3
|
+
Requirements: Node.js 18 or newer and npm for the realtime, chat, site, socket and http-ws templates; the dashboard template requires Node.js 22.13+ for native SQLite. Use a currently supported Node.js release in production.
|
|
4
|
+
|
|
5
|
+
For an unreleased checkout or tarball, first run `npm install --save-exact TARBALL`, replacing `TARBALL` with the absolute path to the same tested Redweb tarball used to generate this app (quote paths containing spaces). This installs the matching package and its published client dependency. Do not substitute an older registry release or `latest`. Published Redweb releases can use the installation command below directly.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install
|
|
9
|
+
npm test
|
|
10
|
+
npm run dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
HTTP starters open at http://localhost:8181; the authenticated dashboard uses http://127.0.0.1:8181/login and requires account provisioning described below. Set the `PORT` environment variable to change the listener.
|
|
14
|
+
`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.
|
|
15
|
+
`npm run test:coverage` runs the same tests with application coverage mapped back to TypeScript. Reports are written to the ignored `coverage/` directory; this is separate from Redweb library coverage. TypeScript-generated decorator accessors can appear in function counts even when the framework does not call them. The report exposes remaining gaps; it does not certify complete application coverage. Source maps are generated during the build for diagnostics and coverage, but no coverage collector is loaded by `npm start`.
|
|
16
|
+
|
|
17
|
+
## Development and production
|
|
18
|
+
|
|
19
|
+
Edit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,
|
|
20
|
+
then rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,
|
|
21
|
+
HTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible
|
|
22
|
+
notice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,
|
|
23
|
+
not autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.
|
|
24
|
+
The generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.
|
|
25
|
+
The refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),
|
|
26
|
+
and creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;
|
|
27
|
+
custom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.
|
|
28
|
+
`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.
|
|
29
|
+
Run `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,
|
|
30
|
+
then install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.
|
|
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.
|
|
33
|
+
|
|
34
|
+
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
|
+
|
|
36
|
+
For public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,
|
|
37
|
+
and application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.
|
|
38
|
+
Never commit secrets; `.env` is ignored but is not loaded automatically.
|
|
39
|
+
|
|
40
|
+
`npx --no-install redweb doctor --json` reports configuration problems without changing your files.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #08090d; color: #fff; }
|
|
2
|
+
body { margin: 0; }
|
|
3
|
+
.home { width: min(42rem, calc(100% - 2rem)); margin: 18vh auto 0; }
|
|
4
|
+
h1 { font-size: clamp(2rem, 6vw, 4rem); line-height: 1.1; }
|
|
5
|
+
p { color: #bfc1ca; line-height: 1.6; }
|
|
6
|
+
button { padding: .8rem 1.2rem; background: #ff5064; color: #08090d; border: 0; border-radius: .5rem; cursor: pointer; font: inherit; }
|
|
7
|
+
button:focus-visible, a:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }
|
|
8
|
+
nav { padding: 1rem; } a { color: #ff8795; }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.
|
|
5
|
+
fs.cpSync('src', 'dist', {
|
|
6
|
+
recursive: true,
|
|
7
|
+
filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),
|
|
8
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { once } = require('node:events');
|
|
3
|
+
const WebSocket = require('ws');
|
|
4
|
+
const { createApp } = require('../dist/app.js');
|
|
5
|
+
|
|
6
|
+
async function listen(t) {
|
|
7
|
+
const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });
|
|
8
|
+
t.after(() => app.shutdown());
|
|
9
|
+
if (!app.server.listening) await once(app.server, 'listening');
|
|
10
|
+
return `http://127.0.0.1:${app.server.address().port}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function connect(t, url, origin, headers = {}) {
|
|
14
|
+
const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });
|
|
15
|
+
const messages = [];
|
|
16
|
+
socket.on('message', raw => messages.push(JSON.parse(raw.toString())));
|
|
17
|
+
t.after(async () => {
|
|
18
|
+
if (socket.readyState === WebSocket.CLOSED) return;
|
|
19
|
+
const closed = once(socket, 'close');
|
|
20
|
+
// Cleanup must not depend on a peer completing the closing handshake.
|
|
21
|
+
// Tests of graceful disconnect explicitly close and await their sockets.
|
|
22
|
+
socket.terminate();
|
|
23
|
+
await closed;
|
|
24
|
+
});
|
|
25
|
+
await once(socket, 'open');
|
|
26
|
+
return {
|
|
27
|
+
socket,
|
|
28
|
+
send: message => socket.send(JSON.stringify(message)),
|
|
29
|
+
async receive(predicate) {
|
|
30
|
+
const deadline = Date.now() + 3000;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
const index = messages.findIndex(predicate);
|
|
33
|
+
if (index !== -1) return messages.splice(index, 1)[0];
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
35
|
+
}
|
|
36
|
+
assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function live(t, origin, headers = {}) {
|
|
42
|
+
const response = await fetch(origin, { headers });
|
|
43
|
+
assert.equal(response.status, 200);
|
|
44
|
+
const document = await response.text();
|
|
45
|
+
const config = JSON.parse(document.match(/id="__redweb_page">([^<]+)</)[1]);
|
|
46
|
+
const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);
|
|
47
|
+
return {
|
|
48
|
+
...connection,
|
|
49
|
+
document, config,
|
|
50
|
+
patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),
|
|
51
|
+
action: (name, args = [], component) => connection.send({
|
|
52
|
+
v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },
|
|
53
|
+
}),
|
|
54
|
+
state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&
|
|
55
|
+
message.payload.name === name && message.payload.component === component && value(message.payload.value)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { listen, connect, live };
|