Merge pull request 'dev/2026-04-08' (#1) from dev/2026-04-08 into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
@@ -7,7 +7,8 @@ It is referenced as a git submodule by both `Yonexus.Server` and `Yonexus.Client
|
|||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- `PROTOCOL.md` — full protocol specification
|
- `PROTOCOL.md` — full protocol specification
|
||||||
- TypeScript type definitions (planned: `src/types.ts`)
|
- `src/types.ts` — shared builtin envelope / payload TypeScript definitions
|
||||||
|
- `src/index.ts` — package export surface
|
||||||
- Canonical JSON shape references
|
- Canonical JSON shape references
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|||||||
1826
package-lock.json
generated
Normal file
1826
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "yonexus-protocol",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Yonexus shared communication protocol",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vitest": "^1.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"src/",
|
||||||
|
"PROTOCOL.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
73
src/auth.ts
Normal file
73
src/auth.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { AuthRequestPayload } from "./types.js";
|
||||||
|
|
||||||
|
export const AUTH_NONCE_LENGTH = 24;
|
||||||
|
export const AUTH_MAX_TIMESTAMP_DRIFT_SECONDS = 10;
|
||||||
|
export const AUTH_MAX_ATTEMPTS_PER_WINDOW = 10;
|
||||||
|
export const AUTH_ATTEMPT_WINDOW_SECONDS = 10;
|
||||||
|
export const AUTH_RECENT_NONCE_WINDOW_SIZE = 10;
|
||||||
|
|
||||||
|
export interface AuthProofMaterial {
|
||||||
|
secret: string;
|
||||||
|
nonce: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthProofMaterial(input: AuthProofMaterial): AuthProofMaterial {
|
||||||
|
return {
|
||||||
|
secret: input.secret,
|
||||||
|
nonce: input.nonce,
|
||||||
|
timestamp: input.timestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAuthProofMaterial(input: AuthProofMaterial): string {
|
||||||
|
const material = createAuthProofMaterial(input);
|
||||||
|
return JSON.stringify({
|
||||||
|
secret: material.secret,
|
||||||
|
nonce: material.nonce,
|
||||||
|
timestamp: material.timestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidAuthNonce(nonce: string): boolean {
|
||||||
|
return /^[A-Za-z0-9_-]{24}$/.test(nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimestampFresh(
|
||||||
|
proofTimestamp: number,
|
||||||
|
now: number,
|
||||||
|
maxDriftSeconds: number = AUTH_MAX_TIMESTAMP_DRIFT_SECONDS
|
||||||
|
): { ok: true } | { ok: false; reason: "stale_timestamp" | "future_timestamp" } {
|
||||||
|
const drift = proofTimestamp - now;
|
||||||
|
if (Math.abs(drift) < maxDriftSeconds) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: drift < 0 ? "stale_timestamp" : "future_timestamp"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthRequestSigningInput(input: {
|
||||||
|
secret: string;
|
||||||
|
nonce: string;
|
||||||
|
proofTimestamp: number;
|
||||||
|
}): string {
|
||||||
|
return serializeAuthProofMaterial({
|
||||||
|
secret: input.secret,
|
||||||
|
nonce: input.nonce,
|
||||||
|
timestamp: input.proofTimestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAuthRequestSigningInput(
|
||||||
|
payload: Pick<AuthRequestPayload, "nonce" | "proofTimestamp">,
|
||||||
|
secret: string
|
||||||
|
): string {
|
||||||
|
return createAuthRequestSigningInput({
|
||||||
|
secret,
|
||||||
|
nonce: payload.nonce,
|
||||||
|
proofTimestamp: payload.proofTimestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
360
src/codec.ts
Normal file
360
src/codec.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import {
|
||||||
|
type BuiltinEnvelope,
|
||||||
|
type BuiltinMessageType,
|
||||||
|
type BuiltinPayloadMap
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yonexus Protocol Codec
|
||||||
|
*
|
||||||
|
* Provides unified string protocol parsing and serialization.
|
||||||
|
*
|
||||||
|
* Wire formats:
|
||||||
|
* - builtin::{json} for protocol messages
|
||||||
|
* - ${rule_identifier}::${message_content} for application messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Reserved rule identifier for builtin messages
|
||||||
|
export const BUILTIN_RULE = "builtin" as const;
|
||||||
|
|
||||||
|
// Delimiter for rule messages
|
||||||
|
const RULE_DELIMITER = "::";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors that can occur during encoding/decoding
|
||||||
|
*/
|
||||||
|
export class CodecError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CodecError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of parsing a rule message
|
||||||
|
*/
|
||||||
|
export interface ParsedRuleMessage {
|
||||||
|
/** The rule identifier (first segment before ::) */
|
||||||
|
readonly ruleIdentifier: string;
|
||||||
|
/** The raw content after the first :: delimiter */
|
||||||
|
readonly content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of parsing a server-rewritten rule message
|
||||||
|
*/
|
||||||
|
export interface ParsedRewrittenRuleMessage extends ParsedRuleMessage {
|
||||||
|
/** The sender identifier (second segment) */
|
||||||
|
readonly senderIdentifier: string;
|
||||||
|
/** The actual message content (after sender identifier) */
|
||||||
|
readonly messageContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a builtin envelope to wire format.
|
||||||
|
*
|
||||||
|
* @param envelope - The builtin envelope to encode
|
||||||
|
* @returns Wire format string: builtin::{json}
|
||||||
|
* @throws CodecError if envelope is invalid or JSON serialization fails
|
||||||
|
*/
|
||||||
|
export function encodeBuiltin<TType extends BuiltinMessageType>(
|
||||||
|
envelope: BuiltinEnvelope<TType, BuiltinPayloadMap[TType]>
|
||||||
|
): string {
|
||||||
|
if (!envelope || typeof envelope !== "object") {
|
||||||
|
throw new CodecError("Envelope must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envelope.type || typeof envelope.type !== "string") {
|
||||||
|
throw new CodecError("Envelope must have a valid 'type' field");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the envelope with defaults
|
||||||
|
const fullEnvelope: BuiltinEnvelope = {
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
...envelope
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(fullEnvelope);
|
||||||
|
return `${BUILTIN_RULE}${RULE_DELIMITER}${json}`;
|
||||||
|
} catch (cause) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Failed to serialize envelope: ${cause instanceof Error ? cause.message : String(cause)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a builtin message from wire format.
|
||||||
|
*
|
||||||
|
* @param raw - The raw wire message (expected: builtin::{json})
|
||||||
|
* @returns Parsed builtin envelope
|
||||||
|
* @throws CodecError if format is invalid or JSON parsing fails
|
||||||
|
*/
|
||||||
|
export function decodeBuiltin(raw: string): BuiltinEnvelope {
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
throw new CodecError("Input must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first :: delimiter
|
||||||
|
const delimiterIndex = raw.indexOf(RULE_DELIMITER);
|
||||||
|
if (delimiterIndex === -1) {
|
||||||
|
throw new CodecError("Malformed message: missing :: delimiter");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleIdentifier = raw.slice(0, delimiterIndex);
|
||||||
|
const content = raw.slice(delimiterIndex + RULE_DELIMITER.length);
|
||||||
|
|
||||||
|
if (ruleIdentifier !== BUILTIN_RULE) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Expected rule identifier '${BUILTIN_RULE}', got '${ruleIdentifier}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.length === 0) {
|
||||||
|
throw new CodecError("Empty builtin payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as unknown;
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new CodecError("Builtin payload must be a JSON object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!envelope.type || typeof envelope.type !== "string") {
|
||||||
|
throw new CodecError("Missing or invalid 'type' field in envelope");
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope as unknown as BuiltinEnvelope;
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof CodecError) {
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw new CodecError(
|
||||||
|
`Failed to parse JSON payload: ${cause instanceof Error ? cause.message : String(cause)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a rule message from wire format.
|
||||||
|
*
|
||||||
|
* Only splits on the first :: delimiter, preserving any :: in the content.
|
||||||
|
*
|
||||||
|
* @param raw - The raw wire message
|
||||||
|
* @returns Parsed rule message with identifier and content
|
||||||
|
* @throws CodecError if format is invalid
|
||||||
|
*/
|
||||||
|
export function parseRuleMessage(raw: string): ParsedRuleMessage {
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
throw new CodecError("Input must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const delimiterIndex = raw.indexOf(RULE_DELIMITER);
|
||||||
|
if (delimiterIndex === -1) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Malformed rule message: missing '${RULE_DELIMITER}' delimiter`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleIdentifier = raw.slice(0, delimiterIndex);
|
||||||
|
const content = raw.slice(delimiterIndex + RULE_DELIMITER.length);
|
||||||
|
|
||||||
|
if (ruleIdentifier.length === 0) {
|
||||||
|
throw new CodecError("Empty rule identifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rule identifier doesn't contain spaces or control characters
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(ruleIdentifier)) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Invalid rule identifier '${ruleIdentifier}': must be alphanumeric with underscores/hyphens only`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reserved builtin identifier
|
||||||
|
if (ruleIdentifier === BUILTIN_RULE) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ruleIdentifier,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a server-rewritten rule message.
|
||||||
|
*
|
||||||
|
* Server rewrites: ${rule}::${content} -> ${rule}::${sender}::${content}
|
||||||
|
*
|
||||||
|
* @param raw - The raw wire message from server
|
||||||
|
* @returns Parsed rewritten message with sender identifier
|
||||||
|
* @throws CodecError if format is invalid
|
||||||
|
*/
|
||||||
|
export function parseRewrittenRuleMessage(raw: string): ParsedRewrittenRuleMessage {
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
throw new CodecError("Input must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first delimiter
|
||||||
|
const firstDelimiterIndex = raw.indexOf(RULE_DELIMITER);
|
||||||
|
if (firstDelimiterIndex === -1) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Malformed rule message: missing '${RULE_DELIMITER}' delimiter`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleIdentifier = raw.slice(0, firstDelimiterIndex);
|
||||||
|
const afterRule = raw.slice(firstDelimiterIndex + RULE_DELIMITER.length);
|
||||||
|
|
||||||
|
if (ruleIdentifier.length === 0) {
|
||||||
|
throw new CodecError("Empty rule identifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find second delimiter
|
||||||
|
const secondDelimiterIndex = afterRule.indexOf(RULE_DELIMITER);
|
||||||
|
if (secondDelimiterIndex === -1) {
|
||||||
|
throw new CodecError(
|
||||||
|
"Malformed rewritten message: missing sender identifier delimiter"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderIdentifier = afterRule.slice(0, secondDelimiterIndex);
|
||||||
|
const messageContent = afterRule.slice(secondDelimiterIndex + RULE_DELIMITER.length);
|
||||||
|
|
||||||
|
if (senderIdentifier.length === 0) {
|
||||||
|
throw new CodecError("Empty sender identifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ruleIdentifier,
|
||||||
|
senderIdentifier,
|
||||||
|
content: afterRule, // raw content after rule (includes sender::message)
|
||||||
|
messageContent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a rule message to wire format.
|
||||||
|
*
|
||||||
|
* @param ruleIdentifier - The rule identifier
|
||||||
|
* @param content - The message content
|
||||||
|
* @returns Wire format string: ${rule_identifier}::${message_content}
|
||||||
|
* @throws CodecError if ruleIdentifier is invalid or reserved
|
||||||
|
*/
|
||||||
|
export function encodeRuleMessage(
|
||||||
|
ruleIdentifier: string,
|
||||||
|
content: string
|
||||||
|
): string {
|
||||||
|
if (typeof ruleIdentifier !== "string" || ruleIdentifier.length === 0) {
|
||||||
|
throw new CodecError("Rule identifier must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
throw new CodecError("Content must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rule identifier format
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(ruleIdentifier)) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Invalid rule identifier '${ruleIdentifier}': must be alphanumeric with underscores/hyphens only`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleIdentifier === BUILTIN_RULE) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ruleIdentifier}${RULE_DELIMITER}${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a server-rewritten rule message.
|
||||||
|
*
|
||||||
|
* @param ruleIdentifier - The rule identifier
|
||||||
|
* @param senderIdentifier - The client identifier that sent the message
|
||||||
|
* @param content - The original message content
|
||||||
|
* @returns Wire format string: ${rule}::${sender}::${content}
|
||||||
|
* @throws CodecError if identifiers are invalid
|
||||||
|
*/
|
||||||
|
export function encodeRewrittenRuleMessage(
|
||||||
|
ruleIdentifier: string,
|
||||||
|
senderIdentifier: string,
|
||||||
|
content: string
|
||||||
|
): string {
|
||||||
|
if (typeof ruleIdentifier !== "string" || ruleIdentifier.length === 0) {
|
||||||
|
throw new CodecError("Rule identifier must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof senderIdentifier !== "string" || senderIdentifier.length === 0) {
|
||||||
|
throw new CodecError("Sender identifier must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
throw new CodecError("Content must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleIdentifier === BUILTIN_RULE) {
|
||||||
|
throw new CodecError(
|
||||||
|
`Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ruleIdentifier}${RULE_DELIMITER}${senderIdentifier}${RULE_DELIMITER}${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a raw message is a builtin message.
|
||||||
|
*
|
||||||
|
* @param raw - The raw wire message
|
||||||
|
* @returns True if the message starts with "builtin::"
|
||||||
|
*/
|
||||||
|
export function isBuiltinMessage(raw: string): boolean {
|
||||||
|
return typeof raw === "string" && raw.startsWith(`${BUILTIN_RULE}${RULE_DELIMITER}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a type-safe builtin envelope builder.
|
||||||
|
*
|
||||||
|
* @param type - The builtin message type
|
||||||
|
* @returns A builder function that creates envelopes of that type
|
||||||
|
*/
|
||||||
|
export function createEnvelopeBuilder<TType extends keyof BuiltinPayloadMap>(
|
||||||
|
type: TType
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
payload: BuiltinPayloadMap[TType],
|
||||||
|
options?: { requestId?: string; timestamp?: number }
|
||||||
|
): BuiltinEnvelope<TType, BuiltinPayloadMap[TType]> => {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
requestId: options?.requestId,
|
||||||
|
timestamp: options?.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-built envelope builders for common message types
|
||||||
|
export const buildHello = createEnvelopeBuilder("hello");
|
||||||
|
export const buildHelloAck = createEnvelopeBuilder("hello_ack");
|
||||||
|
export const buildPairRequest = createEnvelopeBuilder("pair_request");
|
||||||
|
export const buildPairConfirm = createEnvelopeBuilder("pair_confirm");
|
||||||
|
export const buildPairSuccess = createEnvelopeBuilder("pair_success");
|
||||||
|
export const buildPairFailed = createEnvelopeBuilder("pair_failed");
|
||||||
|
export const buildAuthRequest = createEnvelopeBuilder("auth_request");
|
||||||
|
export const buildAuthSuccess = createEnvelopeBuilder("auth_success");
|
||||||
|
export const buildAuthFailed = createEnvelopeBuilder("auth_failed");
|
||||||
|
export const buildRePairRequired = createEnvelopeBuilder("re_pair_required");
|
||||||
|
export const buildHeartbeat = createEnvelopeBuilder("heartbeat");
|
||||||
|
export const buildHeartbeatAck = createEnvelopeBuilder("heartbeat_ack");
|
||||||
|
export const buildStatusUpdate = createEnvelopeBuilder("status_update");
|
||||||
|
export const buildDisconnectNotice = createEnvelopeBuilder("disconnect_notice");
|
||||||
|
export const buildError = createEnvelopeBuilder("error");
|
||||||
93
src/errors.ts
Normal file
93
src/errors.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { BuiltinEnvelope, ErrorPayload, ProtocolErrorCode, TypedBuiltinEnvelope } from "./types.js";
|
||||||
|
|
||||||
|
export type ProtocolErrorCategory =
|
||||||
|
| "configuration"
|
||||||
|
| "protocol"
|
||||||
|
| "authentication"
|
||||||
|
| "pairing"
|
||||||
|
| "runtime";
|
||||||
|
|
||||||
|
export interface ProtocolErrorOptions {
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
cause?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolErrorCategories: Record<ProtocolErrorCode, ProtocolErrorCategory> = {
|
||||||
|
MALFORMED_MESSAGE: "protocol",
|
||||||
|
UNSUPPORTED_PROTOCOL_VERSION: "protocol",
|
||||||
|
IDENTIFIER_NOT_ALLOWED: "protocol",
|
||||||
|
PAIRING_REQUIRED: "pairing",
|
||||||
|
PAIRING_EXPIRED: "pairing",
|
||||||
|
ADMIN_NOTIFICATION_FAILED: "pairing",
|
||||||
|
AUTH_FAILED: "authentication",
|
||||||
|
NONCE_COLLISION: "authentication",
|
||||||
|
RATE_LIMITED: "authentication",
|
||||||
|
RE_PAIR_REQUIRED: "authentication",
|
||||||
|
CLIENT_OFFLINE: "runtime",
|
||||||
|
INTERNAL_ERROR: "runtime"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getProtocolErrorCategory(code: ProtocolErrorCode): ProtocolErrorCategory {
|
||||||
|
return protocolErrorCategories[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YonexusProtocolError extends Error {
|
||||||
|
readonly code: ProtocolErrorCode;
|
||||||
|
readonly category: ProtocolErrorCategory;
|
||||||
|
readonly details?: Record<string, unknown>;
|
||||||
|
override readonly cause?: unknown;
|
||||||
|
|
||||||
|
constructor(code: ProtocolErrorCode, options: ProtocolErrorOptions = {}) {
|
||||||
|
super(options.message ?? code);
|
||||||
|
this.name = "YonexusProtocolError";
|
||||||
|
this.code = code;
|
||||||
|
this.category = getProtocolErrorCategory(code);
|
||||||
|
this.details = options.details;
|
||||||
|
this.cause = options.cause;
|
||||||
|
}
|
||||||
|
|
||||||
|
toPayload(): ErrorPayload {
|
||||||
|
return {
|
||||||
|
code: this.code,
|
||||||
|
message: this.message,
|
||||||
|
details: this.details
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toEnvelope(timestamp: number = Math.floor(Date.now() / 1000)): TypedBuiltinEnvelope<"error"> {
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
timestamp,
|
||||||
|
payload: this.toPayload()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProtocolError(
|
||||||
|
code: ProtocolErrorCode,
|
||||||
|
options: ProtocolErrorOptions = {}
|
||||||
|
): YonexusProtocolError {
|
||||||
|
return new YonexusProtocolError(code, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protocolErrorFromPayload(
|
||||||
|
payload: ErrorPayload,
|
||||||
|
cause?: unknown
|
||||||
|
): YonexusProtocolError {
|
||||||
|
return new YonexusProtocolError(payload.code, {
|
||||||
|
message: payload.message,
|
||||||
|
details: payload.details,
|
||||||
|
cause
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProtocolError(value: unknown): value is YonexusProtocolError {
|
||||||
|
return value instanceof YonexusProtocolError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProtocolErrorEnvelope(
|
||||||
|
envelope: BuiltinEnvelope
|
||||||
|
): envelope is TypedBuiltinEnvelope<"error"> {
|
||||||
|
return envelope.type === "error";
|
||||||
|
}
|
||||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./types.js";
|
||||||
|
export * from "./codec.js";
|
||||||
|
export * from "./errors.js";
|
||||||
|
export * from "./auth.js";
|
||||||
182
src/types.ts
Normal file
182
src/types.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
export const YONEXUS_PROTOCOL_VERSION = "1" as const;
|
||||||
|
|
||||||
|
export const builtinMessageTypes = [
|
||||||
|
"hello",
|
||||||
|
"hello_ack",
|
||||||
|
"pair_request",
|
||||||
|
"pair_confirm",
|
||||||
|
"pair_success",
|
||||||
|
"pair_failed",
|
||||||
|
"auth_request",
|
||||||
|
"auth_success",
|
||||||
|
"auth_failed",
|
||||||
|
"re_pair_required",
|
||||||
|
"heartbeat",
|
||||||
|
"heartbeat_ack",
|
||||||
|
"status_update",
|
||||||
|
"disconnect_notice",
|
||||||
|
"error"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BuiltinMessageType = (typeof builtinMessageTypes)[number];
|
||||||
|
|
||||||
|
export interface BuiltinEnvelope<
|
||||||
|
TType extends BuiltinMessageType = BuiltinMessageType,
|
||||||
|
TPayload = Record<string, unknown>
|
||||||
|
> {
|
||||||
|
type: TType;
|
||||||
|
requestId?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
payload?: TPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelloPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
hasSecret: boolean;
|
||||||
|
hasKeyPair: boolean;
|
||||||
|
publicKey?: string;
|
||||||
|
protocolVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HelloAckNextAction =
|
||||||
|
| "pair_required"
|
||||||
|
| "auth_required"
|
||||||
|
| "rejected"
|
||||||
|
| "waiting_pair_confirm";
|
||||||
|
|
||||||
|
export interface HelloAckPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
nextAction: HelloAckNextAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminNotificationStatus = "sent" | "failed";
|
||||||
|
|
||||||
|
export interface PairRequestPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
expiresAt: number;
|
||||||
|
ttlSeconds: number;
|
||||||
|
adminNotification: AdminNotificationStatus;
|
||||||
|
codeDelivery: "out_of_band";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairConfirmPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
pairingCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairSuccessPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
secret: string;
|
||||||
|
pairedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PairFailedReason =
|
||||||
|
| "expired"
|
||||||
|
| "invalid_code"
|
||||||
|
| "identifier_not_allowed"
|
||||||
|
| "admin_notification_failed"
|
||||||
|
| "internal_error";
|
||||||
|
|
||||||
|
export interface PairFailedPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
reason: PairFailedReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequestPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
nonce: string;
|
||||||
|
proofTimestamp: number;
|
||||||
|
signature: string;
|
||||||
|
publicKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSuccessPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
authenticatedAt: number;
|
||||||
|
status: "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthFailedReason =
|
||||||
|
| "unknown_identifier"
|
||||||
|
| "not_paired"
|
||||||
|
| "invalid_signature"
|
||||||
|
| "invalid_secret"
|
||||||
|
| "stale_timestamp"
|
||||||
|
| "future_timestamp"
|
||||||
|
| "nonce_collision"
|
||||||
|
| "rate_limited"
|
||||||
|
| "re_pair_required";
|
||||||
|
|
||||||
|
export interface AuthFailedPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
reason: AuthFailedReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RePairRequiredPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
reason: "nonce_collision" | "rate_limited" | "trust_revoked";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
status: "alive";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatAckPayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
status: "online" | "unstable" | "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusUpdatePayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
status: "online" | "unstable" | "offline";
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisconnectNoticePayload extends Record<string, unknown> {
|
||||||
|
identifier: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProtocolErrorCode =
|
||||||
|
| "MALFORMED_MESSAGE"
|
||||||
|
| "UNSUPPORTED_PROTOCOL_VERSION"
|
||||||
|
| "IDENTIFIER_NOT_ALLOWED"
|
||||||
|
| "PAIRING_REQUIRED"
|
||||||
|
| "PAIRING_EXPIRED"
|
||||||
|
| "ADMIN_NOTIFICATION_FAILED"
|
||||||
|
| "AUTH_FAILED"
|
||||||
|
| "NONCE_COLLISION"
|
||||||
|
| "RATE_LIMITED"
|
||||||
|
| "RE_PAIR_REQUIRED"
|
||||||
|
| "CLIENT_OFFLINE"
|
||||||
|
| "INTERNAL_ERROR";
|
||||||
|
|
||||||
|
export interface ErrorPayload extends Record<string, unknown> {
|
||||||
|
code: ProtocolErrorCode;
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuiltinPayloadMap = {
|
||||||
|
hello: HelloPayload;
|
||||||
|
hello_ack: HelloAckPayload;
|
||||||
|
pair_request: PairRequestPayload;
|
||||||
|
pair_confirm: PairConfirmPayload;
|
||||||
|
pair_success: PairSuccessPayload;
|
||||||
|
pair_failed: PairFailedPayload;
|
||||||
|
auth_request: AuthRequestPayload;
|
||||||
|
auth_success: AuthSuccessPayload;
|
||||||
|
auth_failed: AuthFailedPayload;
|
||||||
|
re_pair_required: RePairRequiredPayload;
|
||||||
|
heartbeat: HeartbeatPayload;
|
||||||
|
heartbeat_ack: HeartbeatAckPayload;
|
||||||
|
status_update: StatusUpdatePayload;
|
||||||
|
disconnect_notice: DisconnectNoticePayload;
|
||||||
|
error: ErrorPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypedBuiltinEnvelope<TType extends keyof BuiltinPayloadMap> = BuiltinEnvelope<
|
||||||
|
TType,
|
||||||
|
BuiltinPayloadMap[TType]
|
||||||
|
>;
|
||||||
252
tests/codec.test.ts
Normal file
252
tests/codec.test.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAuthFailed,
|
||||||
|
buildAuthRequest,
|
||||||
|
buildAuthSuccess,
|
||||||
|
buildDisconnectNotice,
|
||||||
|
buildError,
|
||||||
|
buildHeartbeat,
|
||||||
|
buildHeartbeatAck,
|
||||||
|
buildHello,
|
||||||
|
buildHelloAck,
|
||||||
|
buildPairConfirm,
|
||||||
|
buildPairFailed,
|
||||||
|
buildPairRequest,
|
||||||
|
buildPairSuccess,
|
||||||
|
buildRePairRequired,
|
||||||
|
buildStatusUpdate,
|
||||||
|
CodecError,
|
||||||
|
decodeBuiltin,
|
||||||
|
encodeBuiltin,
|
||||||
|
encodeRewrittenRuleMessage,
|
||||||
|
encodeRuleMessage,
|
||||||
|
isBuiltinMessage,
|
||||||
|
parseRewrittenRuleMessage,
|
||||||
|
parseRuleMessage
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
describe("Protocol Codec - encodeBuiltin / decodeBuiltin", () => {
|
||||||
|
it("encodes and decodes a hello envelope", () => {
|
||||||
|
const envelope = buildHello(
|
||||||
|
{
|
||||||
|
identifier: "client-a",
|
||||||
|
hasSecret: true,
|
||||||
|
hasKeyPair: true,
|
||||||
|
publicKey: "pk-test",
|
||||||
|
protocolVersion: "1"
|
||||||
|
},
|
||||||
|
{ requestId: "req-001" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const encoded = encodeBuiltin(envelope);
|
||||||
|
expect(encoded.startsWith("builtin::")).toBe(true);
|
||||||
|
|
||||||
|
const decoded = decodeBuiltin(encoded);
|
||||||
|
expect(decoded.type).toBe("hello");
|
||||||
|
expect(decoded.requestId).toBe("req-001");
|
||||||
|
expect(decoded.payload).toMatchObject({
|
||||||
|
identifier: "client-a",
|
||||||
|
hasSecret: true,
|
||||||
|
hasKeyPair: true,
|
||||||
|
publicKey: "pk-test",
|
||||||
|
protocolVersion: "1"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes and decodes all builtin message types", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
builder: buildHelloAck,
|
||||||
|
payload: { identifier: "client-a", nextAction: "pair_required" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildPairRequest,
|
||||||
|
payload: {
|
||||||
|
identifier: "client-a",
|
||||||
|
expiresAt: 1_710_000_000,
|
||||||
|
ttlSeconds: 300,
|
||||||
|
adminNotification: "sent" as const,
|
||||||
|
codeDelivery: "out_of_band" as const
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildPairConfirm,
|
||||||
|
payload: { identifier: "client-a", pairingCode: "ABCD-1234-XYZ" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildPairSuccess,
|
||||||
|
payload: { identifier: "client-a", secret: "secret-xyz", pairedAt: 1_710_000_000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildPairFailed,
|
||||||
|
payload: { identifier: "client-a", reason: "expired" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildAuthRequest,
|
||||||
|
payload: {
|
||||||
|
identifier: "client-a",
|
||||||
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "sig-base64",
|
||||||
|
publicKey: "pk-test"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildAuthSuccess,
|
||||||
|
payload: { identifier: "client-a", authenticatedAt: 1_710_000_000, status: "online" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildAuthFailed,
|
||||||
|
payload: { identifier: "client-a", reason: "invalid_signature" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildRePairRequired,
|
||||||
|
payload: { identifier: "client-a", reason: "nonce_collision" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildHeartbeat,
|
||||||
|
payload: { identifier: "client-a", status: "alive" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildHeartbeatAck,
|
||||||
|
payload: { identifier: "client-a", status: "online" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildStatusUpdate,
|
||||||
|
payload: { identifier: "client-a", status: "unstable" as const, reason: "heartbeat_timeout_7m" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildDisconnectNotice,
|
||||||
|
payload: { identifier: "client-a", reason: "heartbeat_timeout_11m" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildError,
|
||||||
|
payload: { code: "MALFORMED_MESSAGE" as const, message: "Invalid JSON" }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { builder, payload } of testCases) {
|
||||||
|
const envelope = builder(payload as never, { requestId: "test-req" });
|
||||||
|
const encoded = encodeBuiltin(envelope);
|
||||||
|
const decoded = decodeBuiltin(encoded);
|
||||||
|
|
||||||
|
expect(decoded.type).toBe(envelope.type);
|
||||||
|
expect(decoded.requestId).toBe("test-req");
|
||||||
|
expect(decoded.payload).toMatchObject(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for malformed messages", () => {
|
||||||
|
expect(() => decodeBuiltin("not-builtin::{")).toThrow(CodecError);
|
||||||
|
expect(() => decodeBuiltin("builtin::not-json")).toThrow(CodecError);
|
||||||
|
expect(() => decodeBuiltin("builtin::{}")).toThrow(CodecError); // missing type
|
||||||
|
expect(() => decodeBuiltin("no-delimiter")).toThrow(CodecError);
|
||||||
|
expect(() => decodeBuiltin("")).toThrow(CodecError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for invalid envelope input", () => {
|
||||||
|
expect(() => encodeBuiltin(null as never)).toThrow(CodecError);
|
||||||
|
expect(() => encodeBuiltin({} as never)).toThrow(CodecError);
|
||||||
|
expect(() => encodeBuiltin({ type: 123 } as never)).toThrow(CodecError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protocol Codec - Rule Message Parsing", () => {
|
||||||
|
it("parses rule messages correctly", () => {
|
||||||
|
const parsed = parseRuleMessage("chat_sync::{\"body\":\"hello\"}");
|
||||||
|
expect(parsed.ruleIdentifier).toBe("chat_sync");
|
||||||
|
expect(parsed.content).toBe('{"body":"hello"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles content containing :: delimiters", () => {
|
||||||
|
const parsed = parseRuleMessage("rule::a::b::c");
|
||||||
|
expect(parsed.ruleIdentifier).toBe("rule");
|
||||||
|
expect(parsed.content).toBe("a::b::c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for invalid rule messages", () => {
|
||||||
|
expect(() => parseRuleMessage("no-delimiter")).toThrow(CodecError);
|
||||||
|
expect(() => parseRuleMessage(":")).toThrow(CodecError);
|
||||||
|
expect(() => parseRuleMessage("")).toThrow(CodecError);
|
||||||
|
expect(() => parseRuleMessage("::content")).toThrow(CodecError); // empty identifier
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for reserved builtin identifier", () => {
|
||||||
|
expect(() => parseRuleMessage("builtin::something")).toThrow(
|
||||||
|
/reserved/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for invalid rule identifier characters", () => {
|
||||||
|
expect(() => parseRuleMessage("rule with spaces::content")).toThrow(CodecError);
|
||||||
|
// Note: special chars in content are allowed - only the rule identifier is validated
|
||||||
|
expect(() => parseRuleMessage("rule::with::special!::chars")).not.toThrow(); // content can contain special chars
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isBuiltinMessage identifies builtin messages", () => {
|
||||||
|
expect(isBuiltinMessage("builtin::{\"type\":\"hello\"}")).toBe(true);
|
||||||
|
expect(isBuiltinMessage("rule::content")).toBe(false);
|
||||||
|
expect(isBuiltinMessage("not-builtin::content")).toBe(false);
|
||||||
|
expect(isBuiltinMessage("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protocol Codec - Server Rewritten Messages", () => {
|
||||||
|
it("parses rewritten messages with sender identifier", () => {
|
||||||
|
const parsed = parseRewrittenRuleMessage("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
expect(parsed.ruleIdentifier).toBe("chat_sync");
|
||||||
|
expect(parsed.senderIdentifier).toBe("client-a");
|
||||||
|
expect(parsed.messageContent).toBe('{"body":"hello"}');
|
||||||
|
expect(parsed.content).toBe("client-a::{\"body\":\"hello\"}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles complex content with multiple delimiters", () => {
|
||||||
|
const parsed = parseRewrittenRuleMessage("rule::sender::a::b::c");
|
||||||
|
expect(parsed.ruleIdentifier).toBe("rule");
|
||||||
|
expect(parsed.senderIdentifier).toBe("sender");
|
||||||
|
expect(parsed.messageContent).toBe("a::b::c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for malformed rewritten messages", () => {
|
||||||
|
expect(() => parseRewrittenRuleMessage("no-delimiters")).toThrow(CodecError);
|
||||||
|
expect(() => parseRewrittenRuleMessage("rule::only-one")).toThrow(/sender/);
|
||||||
|
expect(() => parseRewrittenRuleMessage("::sender::content")).toThrow(/Empty/);
|
||||||
|
// Note: "rule:::content" has empty sender which is caught by "missing sender identifier delimiter" check
|
||||||
|
expect(() => parseRewrittenRuleMessage("rule:::content")).toThrow(/sender/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protocol Codec - encodeRuleMessage", () => {
|
||||||
|
it("encodes rule messages correctly", () => {
|
||||||
|
const encoded = encodeRuleMessage("chat_sync", '{"body":"hello"}');
|
||||||
|
expect(encoded).toBe('chat_sync::{"body":"hello"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for invalid rule identifiers", () => {
|
||||||
|
expect(() => encodeRuleMessage("", "content")).toThrow(CodecError);
|
||||||
|
expect(() => encodeRuleMessage("builtin", "content")).toThrow(/reserved/);
|
||||||
|
expect(() => encodeRuleMessage("rule with spaces", "content")).toThrow(CodecError);
|
||||||
|
expect(() => encodeRuleMessage("rule!", "content")).toThrow(CodecError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for non-string content", () => {
|
||||||
|
expect(() => encodeRuleMessage("rule", null as never)).toThrow(CodecError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protocol Codec - encodeRewrittenRuleMessage", () => {
|
||||||
|
it("encodes rewritten messages correctly", () => {
|
||||||
|
const encoded = encodeRewrittenRuleMessage("chat_sync", "client-a", '{"body":"hello"}');
|
||||||
|
expect(encoded).toBe('chat_sync::client-a::{"body":"hello"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for reserved rule identifier", () => {
|
||||||
|
expect(() => encodeRewrittenRuleMessage("builtin", "sender", "content")).toThrow(/reserved/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for empty identifiers", () => {
|
||||||
|
expect(() => encodeRewrittenRuleMessage("", "sender", "content")).toThrow(CodecError);
|
||||||
|
expect(() => encodeRewrittenRuleMessage("rule", "", "content")).toThrow(CodecError);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node"
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user