feat(server): the relay joins modems of different projects by a code (K3)
A modem that offers a connection to another project is found by six characters derived from its project and itself (modemCode): a copy of the project has another code, so no two modems offer the same one. The room names its modems of other projects to F's relay at a start and none at a stop; the relay pairs each offer with the first modem that joins its code, one to one as Java's server modem takes one client, and passes the frames between the rooms' threads. Every client hears how each modem stands (`modems`: connected, waiting, taken, unpaired, ambiguous, detached, or unavailable without F). The document keeps the connection and the code. Measured on F with two threads: a ping from one project's notebook to the other's goes through; a third project that wants the same code reads taken, and takes it when the second stops; a deleted project's modem leaves with its room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
firewallConfiguration,
|
||||
firewallRule,
|
||||
forwardsIPv4ByDefault,
|
||||
modemCodeOf,
|
||||
type FirewallAction,
|
||||
type FirewallProtocol,
|
||||
type DhcpStaticAssignment,
|
||||
@@ -226,12 +227,37 @@ export function setModemPort(doc: Y.Doc, id: Id, text: string, origin: unknown):
|
||||
doc,
|
||||
id,
|
||||
checkField('port', text),
|
||||
(node, value) =>
|
||||
settingMap(node, 'remoteLink', { pairIdentifier: '', latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true }).set('pairIdentifier', modemPair(Number(value))),
|
||||
(node, value) => modemSetting(node).set('pairIdentifier', modemPair(Number(value))),
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
/** How a modem finds its partner (FILIUS_MODEM_PLAN): the pair on its port in this project, or another project's modem — offering a code, or joining one. */
|
||||
export type ModemConnection = 'inProject' | 'offer' | 'join';
|
||||
|
||||
const modemSetting = (node: Y.Map<unknown>) =>
|
||||
settingMap(node, 'remoteLink', { pairIdentifier: '', latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true });
|
||||
|
||||
/** A modem's connection; joining starts without a code. Its port stays for the pair in the project. */
|
||||
export function setModemConnection(doc: Y.Doc, id: Id, connection: ModemConnection, origin: unknown): void {
|
||||
const node = nodes(doc).get(id);
|
||||
if (node === undefined || kindOf(node) !== 'remoteLink') return;
|
||||
doc.transact(() => {
|
||||
const setting = modemSetting(node);
|
||||
if (connection === 'inProject') setting.delete('relay');
|
||||
else setting.set('relay', toY(connection === 'offer' ? { role: 'offer' } : { role: 'join', code: '' }));
|
||||
}, origin);
|
||||
}
|
||||
|
||||
/** The code of the modem a joining modem connects to, as typed (spaces, dashes, small letters allowed); refused when it is no code. */
|
||||
export function setModemCode(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField {
|
||||
const node = nodes(doc).get(id);
|
||||
const code = modemCodeOf(text);
|
||||
if (node === undefined || code === undefined) return { invalid: true };
|
||||
doc.transact(() => modemSetting(node).set('relay', toY({ role: 'join', code })), origin);
|
||||
return { value: code };
|
||||
}
|
||||
|
||||
const portsOf = (node: Y.Map<unknown>) => node.get('ports') as Y.Array<Y.Map<unknown>>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
type HostLabelPolicy,
|
||||
type ManualRoute,
|
||||
type NodeKind,
|
||||
type RemoteLinkRelay,
|
||||
type RouteRow,
|
||||
} from '@mdg-filius/core';
|
||||
import { modemPort } from '@mdg-filius/fls';
|
||||
import type { ModemConnection } from './settings.js';
|
||||
import { inOrder, ROOTS } from './projectDoc.js';
|
||||
import { fromY } from './values.js';
|
||||
import { cardAddress, nodeLabel } from './view.js';
|
||||
@@ -79,7 +81,12 @@ export interface SwitchSheet {
|
||||
export interface ModemSheet {
|
||||
readonly kind: 'remoteLink';
|
||||
readonly name: string;
|
||||
/** The pair in the project: two modems on one port. */
|
||||
readonly port: number;
|
||||
/** How it finds its partner (setModemConnection). */
|
||||
readonly connection: ModemConnection;
|
||||
/** Joining: the code of the modem it connects to ('' while none is given). */
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
/** A router's or home router's card. */
|
||||
@@ -239,8 +246,17 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
|
||||
cloud: config?.get('showsAsCloud') === true,
|
||||
};
|
||||
}
|
||||
case 'remoteLink':
|
||||
return { kind, name, port: modemPort(setting(node, 'remoteLink')?.get('pairIdentifier') as string | undefined) };
|
||||
case 'remoteLink': {
|
||||
const config = setting(node, 'remoteLink');
|
||||
const relay = fromY(config?.get('relay')) as RemoteLinkRelay | undefined;
|
||||
return {
|
||||
kind,
|
||||
name,
|
||||
port: modemPort(config?.get('pairIdentifier') as string | undefined),
|
||||
connection: relay?.role ?? 'inProject',
|
||||
code: relay?.role === 'join' ? relay.code : '',
|
||||
};
|
||||
}
|
||||
case 'router': {
|
||||
const interfaces = interfacesOf(doc, id, node);
|
||||
const gateway = text(setting(node, 'device'), 'defaultGateway');
|
||||
|
||||
@@ -18,12 +18,15 @@ import {
|
||||
connect,
|
||||
createUndoManager,
|
||||
deviceSheet,
|
||||
docSnapshot,
|
||||
readProject,
|
||||
renameDevice,
|
||||
ROOTS,
|
||||
setDhcpClient,
|
||||
setHostAddress,
|
||||
setHostLabels,
|
||||
setModemCode,
|
||||
setModemConnection,
|
||||
setHostWireless,
|
||||
setIpForwarding,
|
||||
setModemPort,
|
||||
@@ -498,7 +501,7 @@ describe("a switch's and a modem's sheet", () => {
|
||||
it('pairs a modem by its port, and renames a router', () => {
|
||||
const doc = new Y.Doc();
|
||||
const modem = add(doc, 'remoteLink', 'Modem');
|
||||
expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 });
|
||||
expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345, connection: 'inProject', code: '' });
|
||||
expect(setModemPort(doc, modem, '65536', 'me')).toEqual({ invalid: true });
|
||||
expect(setModemPort(doc, modem, ' 80', 'me')).toEqual({ value: '80' });
|
||||
expect(readProject(doc).remoteLinkConfigurations.get(modem)).toEqual({ pairIdentifier: 'java-modem-port-80', latencyMilliseconds: 20, isEnabled: true });
|
||||
@@ -510,6 +513,44 @@ describe("a switch's and a modem's sheet", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a modem's connection to another project", () => {
|
||||
it('offers a code, joins one as typed, and goes back to the pair on its port', () => {
|
||||
const doc = new Y.Doc();
|
||||
const modem = add(doc, 'remoteLink', 'Modem');
|
||||
setModemConnection(doc, modem, 'offer', 'me');
|
||||
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'offer', code: '' });
|
||||
expect(readProject(doc).remoteLinkConfigurations.get(modem)).toMatchObject({ relay: { role: 'offer' } });
|
||||
expect(docSnapshot(doc).remoteLinkConfigurationsByNodeId.get(modem)).toMatchObject({ relay: { role: 'offer' } });
|
||||
setModemConnection(doc, modem, 'join', 'me');
|
||||
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: '' });
|
||||
expect(setModemCode(doc, modem, 'k7f-3qx', 'me')).toEqual({ value: 'K7F3QX' });
|
||||
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: 'K7F3QX' });
|
||||
expect(docSnapshot(doc).remoteLinkConfigurationsByNodeId.get(modem)).toMatchObject({ relay: { role: 'join', code: 'K7F3QX' } });
|
||||
// Misread characters (0, O, 1, I) and a wrong length are no code; the code stays.
|
||||
for (const text of ['K7F3Q0', 'K7F3QI', 'K7F3Q', 'K7F3QXY']) expect(setModemCode(doc, modem, text, 'me')).toEqual({ invalid: true });
|
||||
expect(deviceSheet(doc, modem)).toMatchObject({ code: 'K7F3QX' });
|
||||
setModemConnection(doc, modem, 'inProject', 'me');
|
||||
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'inProject', code: '', port: 12_345 });
|
||||
expect(readProject(doc).remoteLinkConfigurations.get(modem)).not.toHaveProperty('relay');
|
||||
});
|
||||
|
||||
it('is each one step of the undo, and touches nothing but a modem', () => {
|
||||
const doc = new Y.Doc();
|
||||
const modem = add(doc, 'remoteLink', 'Modem');
|
||||
const notebook = add(doc, 'notebook');
|
||||
const undo = createUndoManager(doc, 'me');
|
||||
setModemConnection(doc, modem, 'join', 'me');
|
||||
undo.stopCapturing();
|
||||
setModemCode(doc, modem, 'ABCDEF', 'me');
|
||||
undo.undo();
|
||||
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: '' });
|
||||
undo.undo();
|
||||
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'inProject' });
|
||||
setModemConnection(doc, notebook, 'offer', 'me');
|
||||
expect((doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(notebook)!.get('settings') as Y.Map<unknown>).has('remoteLink')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet's changes", () => {
|
||||
it('are each one step of the person’s undo, the wireless join with its connection', () => {
|
||||
const doc = new Y.Doc();
|
||||
|
||||
Reference in New Issue
Block a user