[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
{
"name": "@netcatty/plugin-contract",
"version": "0.1.0-internal",
"private": true,
"type": "module",
"license": "GPL-3.0-or-later",
"files": [
"dist",
"schema"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./schema/plugin-contract.schema.json": "./schema/plugin-contract.schema.json"
},
"scripts": {
"build": "tsc -p tsconfig.build.json"
},
"devDependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
// This file is generated from schema/plugin-contract.schema.json.
// Run `npm run generate:plugin-contract` after changing the contract.
// Do not edit this file directly.
export const PLUGIN_JSON_MAX_DEPTH = 128 as const;
export const PLUGIN_JSON_MAX_NODES = 100000 as const;
export const PLUGIN_WIRE_MAX_SAFE_INTEGER = 9007199254740991 as const;
export const PLUGIN_RPC_MAX_JSON_BYTES = 1048576 as const;
export const PLUGIN_RPC_ERROR_CODES = [-32700,-32600,-32601,-32602,-32603,-32001,-32002,-32003,-32004,-32005,-32006,-32007,-32008,-32009,-32010,-32011,-32012,-32013,-32014,-32015,-32016] as const;
export const PLUGIN_STREAM_MAX_ID_LENGTH = 128 as const;
export const PLUGIN_STREAM_MAX_CHUNK_BYTES = 16777216 as const;
export const PLUGIN_STREAM_MAX_FRAME_JSON_BYTES = 25165824 as const;
export const PLUGIN_STREAM_MIN_WINDOW_BYTES = 1024 as const;
export const PLUGIN_STREAM_MAX_WINDOW_BYTES = 16777216 as const;
export const PLUGIN_STREAM_MAX_CREDIT_BYTES = 16777216 as const;
export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES = 65536 as const;
export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES = 262144 as const;
export const PLUGIN_IMPORTER_MAX_INPUT_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_OUTPUT_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_RECORD_BYTES = 67108864 as const;
export const PLUGIN_IMPORTER_MAX_RECORDS = 10000 as const;
export const PLUGIN_SYNC_MAX_OBJECT_BYTES = 67108864 as const;
export const PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH = 1024 as const;
export const PLUGIN_SYNC_MAX_REVISION_LENGTH = 256 as const;
export const PLUGIN_SYNC_INLINE_OBJECT_BYTES = 92160 as const;

View File

@@ -0,0 +1,951 @@
// This file is generated from schema/plugin-contract.schema.json.
// Run `npm run generate:plugin-contract` after changing the contract.
// Do not edit this file directly.
export type ActivationEvent = "onStartupFinished" | `onCommand:${ContributionId}` | `onView:${ContributionId}` | `onProvider:${ContributionId}`;
export type AuthenticationBeginPayload = {
operationId: string;
connectionProviderId: ContributionId;
configuration: JsonValue;
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
};
export type AuthenticationChallenge = ({
id: string;
kind: "text" | "password" | "otp";
title: string;
message?: string;
placeholder?: string;
}) | ({
id: string;
kind: "choice";
title: string;
message?: string;
choices: Array<{
id: string;
label: string;
description?: string;
}>;
multiple?: boolean;
}) | ({
id: string;
kind: "confirmation";
title: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
}) | ({
id: string;
kind: "browser";
title: string;
url: string;
callbackUri?: string;
}) | ({
id: string;
kind: "deviceCode";
title: string;
verificationUri: string;
userCode: string;
expiresAt: number;
intervalMs?: number;
});
export type AuthenticationResponsePayload = {
operationId: string;
challengeId: string;
response: (string) | (boolean) | (Array<string>) | (SecretLeaseRef);
};
export type AuthenticationResult = ({
status: "challenge";
challenge: AuthenticationChallenge;
}) | ({
status: "authenticated";
credential?: (SecretRef) | (CredentialRef);
}) | ({
status: "cancelled";
message?: string;
}) | ({
status: "failed";
message?: string;
});
export type BoundedPermissionResource = string;
export type CommandContribution = {
id: ContributionId;
title: LocalizedText;
category?: LocalizedText;
description?: LocalizedText;
icon?: IconReference;
enablement?: ContextKeyExpression;
};
export type CompanionExecutable = {
id: ContributionId;
variants: Array<CompanionExecutableVariant>;
permissions?: Array<PluginPermission>;
};
export type CompanionExecutableVariant = {
path: RelativePackagePath;
platforms: Array<CompanionPlatform>;
sha256: string;
};
export type CompanionPlatform = "darwin-arm64" | "darwin-x64" | "linux-arm64" | "linux-x64" | "win32-arm64" | "win32-x64";
export type ConnectionConfigurationPayload = {
configuration: JsonValue;
};
export type ConnectionControlPayload = {
connectionId: string;
operationId: string;
};
export type ConnectionControlResult = null;
export type ConnectionOpenPayload = {
configuration: JsonValue;
operationId: string;
columns: number;
rows: number;
inputStreamId: string;
outputStreamId: string;
windowBytes: number;
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
authenticationProviderId?: ContributionId;
};
export type ConnectionOpenResult = {
connectionId: string;
status: "connecting" | "connected";
diagnostics?: Array<ProviderValidationIssue>;
};
export type ConnectionProbeResult = {
available: boolean;
message?: string;
capabilities?: { [key: string]: JsonValue };
};
export type ConnectionResizePayload = {
connectionId: string;
operationId: string;
columns: number;
rows: number;
};
export type ConnectionSignalPayload = {
connectionId: string;
operationId: string;
signal: "interrupt" | "terminate" | "kill" | "eof" | "break";
};
export type ConnectionStatusResult = {
status: "connecting" | "connected" | "reconnecting" | "closed" | "error";
message?: string;
retryable?: boolean;
diagnostics?: Array<ProviderValidationIssue>;
};
export type ConnectionValidateResult = {
valid: boolean;
issues: Array<ProviderValidationIssue>;
};
export type ContextKeyExpression = string;
export type ContributionId = string;
export type CredentialRef = {
kind: "credential";
id: string;
};
export type FeatureId = string;
export type IconReference = (ThemeIcon) | (PackageIcon);
export type ImporterDetectPayload = {
fileName?: string;
mediaType?: string;
sample: {
encoding: "base64";
data: string;
};
};
export type ImporterDetectResult = {
confidence: number;
format?: string;
reason?: string;
};
export type ImporterGroupDraft = string | { path: string; label?: string } | { path?: string; label: string };
export type ImporterHostDraft = ({
id?: string;
label?: string;
username?: string;
group?: string;
tags?: Array<string>;
os?: "linux" | "windows" | "macos";
deviceType?: "general" | "network";
identityId?: string;
identityFileId?: string;
telnetIdentityId?: string;
notes?: string;
theme?: string;
sftpEncoding?: string;
sftpFileProtocol?: "auto" | "sftp" | "scp";
moshEnabled?: boolean;
etEnabled?: boolean;
telnetEnabled?: boolean;
sftpSudo?: boolean;
requiresMfa?: boolean;
useSshAgent?: boolean;
identitiesOnly?: boolean;
agentForwarding?: boolean;
x11Forwarding?: boolean;
showLineTimestamps?: boolean;
disableDynamicTabTitle?: boolean;
pinned?: boolean;
autoOpenSftpPanel?: boolean;
sftpFollowTerminalCwd?: boolean;
port?: number;
telnetPort?: number;
etPort?: number;
keepaliveInterval?: number;
keepaliveCountMax?: number;
} & ({
hostname: string;
protocol?: "ssh" | "telnet" | "mosh" | "et" | "local" | "serial";
pluginConnection?: never;
} | {
hostname?: string;
protocol: PluginHostProtocol;
pluginConnection: ImporterPluginConnectionDraft;
}));
export type ImporterIdentityDraft = {
id?: string;
label: string;
username: string;
authMethod: "password" | "key" | "certificate";
password?: string;
keyId?: string;
};
export type ImporterKeyDraft = ({
id?: string;
label: string;
type: "RSA" | "ECDSA" | "ED25519";
publicKey?: string;
certificate?: string;
passphrase?: string;
category?: "key" | "certificate" | "identity";
} & ({
privateKey: string;
filePath?: never;
} | {
privateKey?: never;
filePath: string;
}));
export type ImporterLimits = {"maxInputBytes":67108864,"maxOutputBytes":67108864,"maxRecordBytes":67108864,"maxRecords":10000};
export type ImporterParsePayload = {
operationId: string;
fileName?: string;
mediaType?: string;
inputStreamId: string;
outputStreamId: string;
windowBytes: number;
options?: JsonValue;
};
export type ImporterParseResult = {
parsed: number;
warnings: number;
errors: number;
};
export type ImporterPluginConnectionDraft = {
providerId: ContributionId;
configuration: JsonValue;
authenticationProviderId?: ContributionId;
credentialId?: string;
};
export type ImporterRecord = ({
type: "draft";
draft: ({
kind: "host";
value: ImporterHostDraft;
}) | ({
kind: "identity";
value: ImporterIdentityDraft;
}) | ({
kind: "key";
value: ImporterKeyDraft;
}) | ({
kind: "snippet";
value: ImporterSnippetDraft;
}) | ({
kind: "group";
value: ImporterGroupDraft;
});
}) | ({
type: "warning" | "error";
code?: string;
message: string;
path?: string;
}) | ({
type: "progress";
completed: number;
total?: number;
message?: string;
});
export type ImporterSnippetDraft = {
id?: string;
label: string;
command: string;
tags?: Array<string>;
kind?: "snippet" | "script";
description?: string;
};
export type JsonPrimitive = (string) | (number) | (boolean) | (null);
export type JsonRpcStandardErrorCode = -32700 | -32600 | -32601 | -32602 | -32603;
export type JsonValue = (JsonPrimitive) | (Array<JsonValue>) | ({ [key: string]: JsonValue });
export type JsonValueLimits = {"maxDepth":128,"maxNodes":100000};
export type KeybindingContribution = {
command: ContributionId;
key: string;
mac?: string;
linux?: string;
windows?: string;
when?: ContextKeyExpression;
args?: JsonValue;
};
export type LocalizedText = (string) | ({ [key: string]: string });
export type MenuContribution = {
command: ContributionId;
alt?: ContributionId;
location: MenuLocation;
title?: LocalizedText;
icon?: IconReference;
group?: string;
order?: number;
when?: ContextKeyExpression;
enablement?: ContextKeyExpression;
checked?: ContextKeyExpression;
showKeybinding?: boolean;
};
export type MenuLocation = "commandPalette" | "application" | "host/context" | "terminal/context" | "terminal/toolbar" | "statusBar";
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type NullableRpcId = (RpcId) | (null);
export type PackageIcon = {
kind: "package";
light: RelativePackagePath;
dark?: RelativePackagePath;
};
export type PermissionDecision = ({
requestId: string;
decision: "allow";
scope: PermissionGrantScope;
resources?: Array<PermissionResource>;
}) | ({
requestId: string;
decision: "deny" | "cancel";
});
export type PermissionDeclaration = (PluginPermission) | (PermissionResourceDeclaration);
export type PermissionGrantScope = "once" | "session" | "application" | "always";
export type PermissionRequest = {
requestId: string;
pluginId: PluginId;
pluginVersion?: SemanticVersion;
pluginName?: string;
publisher?: string;
runtimeId?: string | null;
runtimeKind?: "browser" | "utility" | null;
permission: PluginPermission;
resources?: Array<PermissionResource>;
resourceKinds?: Array<PermissionResourceKind>;
reason: string;
operationId?: string;
sessionId?: string;
allowedScopes?: Array<PermissionGrantScope>;
};
export type PermissionResource = string;
export type PermissionResourceDeclaration = {
permission: PluginPermission;
resources: Array<PermissionResource>;
reason?: string;
};
export type PermissionResourceKind = "exact" | "directory";
export type PermissionSet = {
required?: Array<RequiredPermissionDeclaration>;
optional?: Array<PermissionDeclaration>;
};
export type PluginContributions = {
settings?: Array<SettingContribution>;
commands?: Array<CommandContribution>;
keybindings?: Array<KeybindingContribution>;
menus?: Array<MenuContribution>;
views?: Array<ViewContribution>;
providers?: Array<ProviderContribution>;
};
export type PluginEngineHeader = ({
netcatty: SemverRange;
api: SemverRange;
} & Record<string, unknown>);
export type PluginEngines = {
netcatty: SemverRange;
api: SemverRange;
};
export type PluginEntrypoints = {
browser?: RelativePackagePath;
node?: RelativePackagePath;
};
export type PluginErrorData = {
pluginCode: PluginErrorName;
details?: JsonValue;
};
export type PluginErrorName = "cancelled" | "unknown" | "deadline_exceeded" | "invalid_argument" | "not_found" | "already_exists" | "permission_denied" | "resource_exhausted" | "failed_precondition" | "aborted" | "out_of_range" | "unavailable" | "unsupported" | "internal" | "data_loss" | "unauthenticated";
export type PluginFeatures = {
required?: Array<FeatureId>;
optional?: Array<FeatureId>;
};
export type PluginHostProtocol = `plugin:${ContributionId}`;
export type PluginId = string;
export type PluginManifest = {
$schema?: string;
manifestVersion: 1;
id: PluginId;
name: string;
displayName?: LocalizedText;
description?: LocalizedText;
version: SemanticVersion;
publisher: string;
license?: string;
homepage?: string;
repository?: string;
engines: PluginEngines;
features?: PluginFeatures;
main: PluginEntrypoints;
activationEvents?: Array<ActivationEvent>;
permissions?: PermissionSet;
contributes?: PluginContributions;
companionExecutables?: Array<CompanionExecutable>;
};
export type PluginManifestHeader = ({
$schema?: string;
manifestVersion: number;
id: PluginId;
version: SemanticVersion;
engines: PluginEngineHeader;
} & Record<string, unknown>);
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type PluginWireErrorCode = -32001 | -32002 | -32003 | -32004 | -32005 | -32006 | -32007 | -32008 | -32009 | -32010 | -32011 | -32012 | -32013 | -32014 | -32015 | -32016;
export type ProgressBegin = {
kind: "begin";
title: string;
message?: string;
percentage?: number;
cancellable?: boolean;
};
export type ProgressEnd = {
kind: "end";
message?: string;
};
export type ProgressReport = {
kind: "report";
message?: string;
percentage?: number;
increment?: number;
};
export type ProgressToken = RpcId;
export type ProgressValue = (ProgressBegin) | (ProgressReport) | (ProgressEnd);
export type ProviderContribution = {
id: ContributionId;
label: LocalizedText;
description?: LocalizedText;
kind: ProviderKind;
capabilities?: Array<FeatureId>;
configurationSchema?: JsonValue;
};
export type ProviderKind = "terminal.completion" | "terminal.decoration" | "terminal.link" | "terminal.hover" | "terminal.matcher" | "terminal.semantic" | "terminal.prompt" | "terminal.background" | "terminal.theme" | "terminal.interceptor.input" | "terminal.interceptor.output" | "connection" | "authentication" | "sync" | "importer";
export type ProviderRequest = {
providerId: ContributionId;
operation: string;
requestId: string;
payload?: JsonValue;
deadlineMs?: number;
cancellationId?: string;
};
export type ProviderResult = ({
requestId: string;
status: "ok";
result: JsonValue;
}) | ({
requestId: string;
status: "cancelled";
}) | ({
requestId: string;
status: "failed";
error: RpcErrorObject;
});
export type ProviderValidationIssue = {
path?: string;
severity: "warning" | "error";
message: string;
};
export type RelativePackagePath = string;
export type RequiredPermissionDeclaration = (NonResourceScopedPermission) | (RequiredPermissionResourceDeclaration);
export type RequiredPermissionResourceDeclaration = {
permission: ResourceScopedPermission;
resources: Array<BoundedPermissionResource>;
reason?: string;
};
export type ResourceScopedPermission = "network" | "filesystem.read" | "filesystem.write" | "companion.execute";
export type RpcCancel = {
jsonrpc: "2.0";
method: "$/cancelRequest";
params: {
cancellationId: string;
};
};
export type RpcErrorCode = (JsonRpcStandardErrorCode) | (PluginWireErrorCode);
export type RpcErrorObject = {
code: RpcErrorCode;
message: string;
data?: JsonValue;
};
export type RpcFailure = {
jsonrpc: "2.0";
id: NullableRpcId;
error: RpcErrorObject;
};
export type RpcId = (string) | (SafeUnsignedInteger);
export type RpcLimits = {"maxJsonBytes":1048576};
export type RpcMessage = (RpcRequest) | (RpcNotification) | (RpcSuccess) | (RpcFailure) | (RpcCancel) | (RpcProgressNotification) | (RuntimeInitializeRequest) | (TerminalInterceptorAttachmentRequest);
export type RpcNotification = {
jsonrpc: "2.0";
method: string;
params?: JsonValue;
};
export type RpcProgressNotification = {
jsonrpc: "2.0";
method: "$/progress";
params: {
token: ProgressToken;
value: ProgressValue;
};
};
export type RpcRequest = {
jsonrpc: "2.0";
id: RpcId;
method: string;
params?: JsonValue;
deadlineMs?: number;
cancellationId?: string;
};
export type RpcSuccess = {
jsonrpc: "2.0";
id: RpcId;
result: JsonValue;
};
export type RuntimeInitializeParams = {
netcattyVersion: SemanticVersion;
apiVersion: SemanticVersion;
supportedFeatures: Array<FeatureId>;
};
export type RuntimeInitializeRequest = {
jsonrpc: "2.0";
id: RpcId;
method: "plugin.initialize";
params: RuntimeInitializeParams;
deadlineMs?: number;
cancellationId?: string;
};
export type RuntimeInitializeResult = {
pluginId: PluginId;
pluginVersion: SemanticVersion;
apiVersion: SemanticVersion;
enabledFeatures: Array<FeatureId>;
};
export type RuntimeInitializeSuccess = {
jsonrpc: "2.0";
id: RpcId;
result: RuntimeInitializeResult;
};
export type SafePositiveInteger = number;
export type SafeUnsignedInteger = number;
export type SecretLeaseRef = {
kind: "secret-lease";
id: string;
operationId: string;
expiresAt: SafePositiveInteger;
};
export type SecretRef = {
kind: "secret";
id: string;
key: string;
};
export type SemanticVersion = string;
export type SemverRange = string;
export type SettingContribution = {
id: ContributionId;
label: LocalizedText;
description?: LocalizedText;
control: SettingControl;
scope: SettingScope;
default?: JsonValue;
secret?: boolean;
required?: boolean;
options?: Array<SettingOption>;
minimum?: number;
maximum?: number;
step?: number;
pattern?: string;
placeholder?: LocalizedText;
when?: ContextKeyExpression;
restartRequired?: boolean;
sync?: boolean;
sortable?: boolean;
valueSchema?: JsonValue;
};
export type SettingControl = "switch" | "radio" | "select" | "multiselect" | "text" | "textarea" | "number" | "slider" | "password" | "color" | "font" | "file" | "directory" | "keybinding" | "list" | "table";
export type SettingOption = {
value: string;
label: LocalizedText;
description?: LocalizedText;
};
export type SettingScope = "application" | "workspace" | "host" | "session" | "device";
export type StreamChunkByteLength = number;
export type StreamChunkData = ({
encoding: "json";
value: JsonValue;
byteLength: StreamChunkByteLength;
}) | ({
encoding: "base64";
value: string;
byteLength: StreamChunkByteLength;
}) | ({
encoding: "transfer";
byteLength: StreamChunkByteLength;
});
export type StreamCreditBytes = number;
export type StreamFrame = ({
streamId: StreamId;
sequence: 0;
kind: "open";
windowBytes: StreamWindowBytes;
}) | ({
streamId: StreamId;
sequence: SafePositiveInteger;
kind: "chunk";
data: StreamChunkData;
}) | ({
streamId: StreamId;
sequence: SafePositiveInteger;
kind: "end" | "cancel";
}) | ({
streamId: StreamId;
sequence: SafePositiveInteger;
kind: "error";
error: RpcErrorObject;
}) | ({
streamId: StreamId;
sequence: SafeUnsignedInteger;
kind: "windowUpdate";
creditBytes: StreamCreditBytes;
});
export type StreamId = string;
export type StreamLimits = {"maxStreamIdLength":128,"maxChunkBytes":16777216,"maxFrameJsonBytes":25165824,"minWindowBytes":1024,"maxWindowBytes":16777216,"maxCreditBytes":16777216};
export type StreamWindowBytes = number;
export type SyncAccount = {
id: string;
email?: string;
name?: string;
avatarUrl?: string;
};
export type SyncCapabilitiesResult = {
revisions: boolean;
conditionalWrites: boolean;
atomicReplacement: boolean;
maxObjectBytes?: number;
maxObjects?: number;
};
export type SyncConnectPayload = {
configuration: JsonValue;
operationId: string;
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
};
export type SyncConnectResult = {
account: SyncAccount;
};
export type SyncDeleteObjectPayload = {
key: SyncObjectKey;
operationId: string;
expectedRevision?: SyncObjectRevision;
};
export type SyncDeleteObjectResult = {
deleted: boolean;
};
export type SyncDisconnectPayload = {
operationId?: string;
};
export type SyncDisconnectResult = null;
export type SyncGetAccountPayload = {
operationId?: string;
};
export type SyncGetAccountResult = {
account: (SyncAccount) | (null);
};
export type SyncGetCapabilitiesPayload = {
operationId?: string;
};
export type SyncLimits = {"maxObjectBytes":67108864,"maxObjectKeyLength":1024,"maxRevisionLength":256,"inlineObjectBytes":92160};
export type SyncObjectKey = string;
export type SyncObjectRevision = string;
export type SyncReadObjectPayload = {
key: SyncObjectKey;
operationId: string;
outputStreamId?: string;
windowBytes?: number;
};
export type SyncReadObjectResult = ({
found: false;
}) | ({
found: true;
byteLength: number;
encoding: "base64";
data: string;
revision?: SyncObjectRevision;
contentType?: string;
}) | ({
found: true;
byteLength: number;
streamed: true;
revision?: SyncObjectRevision;
contentType?: string;
});
export type SyncWriteObjectPayload = {
key: SyncObjectKey;
operationId: string;
byteLength: number;
expectedRevision?: (SyncObjectRevision) | (null);
encoding?: "base64";
data?: string;
inputStreamId?: string;
windowBytes?: number;
};
export type SyncWriteObjectResult = {
created: boolean;
revision?: SyncObjectRevision;
};
export type TerminalInterceptorAttachmentDescriptor = {
providerId: ContributionId;
direction: "input" | "output";
session: TerminalSessionSnapshot;
};
export type TerminalInterceptorAttachmentParams = {
descriptor: TerminalInterceptorAttachmentDescriptor;
};
export type TerminalInterceptorAttachmentRequest = {
jsonrpc: "2.0";
id: RpcId;
method: "plugin.terminal.interceptor.attach";
params: TerminalInterceptorAttachmentParams;
deadlineMs?: number;
cancellationId?: string;
};
export type TerminalInterceptorAttachmentResult = {
accepted: true;
};
export type TerminalInterceptorAttachmentSuccess = {
jsonrpc: "2.0";
id: RpcId;
result: TerminalInterceptorAttachmentResult;
};
export type TerminalInterceptorChunkByteLength = number;
export type TerminalInterceptorChunkFrame = {
type: "netcatty:terminal-interceptor:chunk";
sequence: SafePositiveInteger;
direction: TerminalInterceptorDirection;
creditBytes: TerminalInterceptorCreditBytes;
byteLength: TerminalInterceptorChunkByteLength;
};
export type TerminalInterceptorCreditBytes = number;
export type TerminalInterceptorDirection = "input" | "output";
export type TerminalInterceptorFailedResultFrame = {
type: "netcatty:terminal-interceptor:result";
sequence: SafePositiveInteger;
status: "failed";
};
export type TerminalInterceptorFrame = (TerminalInterceptorReadyFrame) | (TerminalInterceptorChunkFrame) | (TerminalInterceptorOkResultFrame) | (TerminalInterceptorFailedResultFrame);
export type TerminalInterceptorLimits = {"maxChunkBytes":65536,"maxWindowBytes":262144};
export type TerminalInterceptorOkResultFrame = {
type: "netcatty:terminal-interceptor:result";
sequence: SafePositiveInteger;
status: "ok";
creditBytes: TerminalInterceptorChunkByteLength;
byteLength: TerminalInterceptorChunkByteLength;
};
export type TerminalInterceptorReadyFrame = {
type: "netcatty:terminal-interceptor:ready";
sessionId: string;
direction: TerminalInterceptorDirection;
windowBytes: TerminalInterceptorWindowBytes;
};
export type TerminalInterceptorWindowBytes = number;
export type TerminalSessionSnapshot = {
sessionId: string;
hostId?: string;
workspaceId?: string;
protocol: string;
status: "connecting" | "connected" | "disconnected";
cwd?: string;
title?: string;
shellType?: "posix" | "fish" | "powershell" | "cmd" | "unknown";
cols?: number;
rows?: number;
alternateScreen?: boolean;
};
export type ThemeIcon = {
kind: "theme";
name: string;
};
export type ViewContribution = {
id: ContributionId;
title: LocalizedText;
location: ViewLocation;
entry: RelativePackagePath;
icon?: IconReference;
order?: number;
when?: ContextKeyExpression;
retainContextWhenHidden?: boolean;
};
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings";
export type WireIntegerLimits = {"maxSafeInteger":9007199254740991};

View File

@@ -0,0 +1,49 @@
export const PLUGIN_API_VERSION = "0.1.0-internal" as const;
export const PLUGIN_MANIFEST_FILE = "netcatty.plugin.json" as const;
export const PLUGIN_PACKAGE_EXTENSION = ".ncpkg" as const;
export type * from "./generated/plugin-contract.js";
export {
PLUGIN_JSON_MAX_DEPTH,
PLUGIN_JSON_MAX_NODES,
assertJsonValue,
serializeJsonValue,
} from "./jsonValue.js";
export {
PLUGIN_IMPORTER_MAX_INPUT_BYTES,
PLUGIN_IMPORTER_MAX_OUTPUT_BYTES,
PLUGIN_IMPORTER_MAX_RECORD_BYTES,
PLUGIN_IMPORTER_MAX_RECORDS,
PLUGIN_RPC_ERROR_CODES,
PLUGIN_RPC_MAX_JSON_BYTES,
PLUGIN_SYNC_INLINE_OBJECT_BYTES,
PLUGIN_SYNC_MAX_OBJECT_BYTES,
PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH,
PLUGIN_SYNC_MAX_REVISION_LENGTH,
PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
PLUGIN_WIRE_MAX_SAFE_INTEGER,
} from "./generated/plugin-contract-limits.js";
export {
COMPANION_STDIO_MAX_CONTENT_BYTES,
COMPANION_STDIO_MAX_HEADER_BYTES,
ContentLengthFrameDecoder,
encodeContentLengthFrame,
type ContentLengthFrameDecoderOptions,
} from "./stdioFraming.js";
export {
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_CREDIT_BYTES,
PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
assertStreamChunkData,
assertStreamFrame,
createBase64StreamChunk,
createJsonStreamChunk,
createMessagePortStreamEnvelope,
materializeStreamChunk,
type MaterializedStreamChunk,
type MessagePortStreamEnvelope,
} from "./streamTransport.js";

View File

@@ -0,0 +1,149 @@
import type { JsonValue } from "./generated/plugin-contract.js";
import {
PLUGIN_JSON_MAX_DEPTH,
PLUGIN_JSON_MAX_NODES,
} from "./generated/plugin-contract-limits.js";
export {
PLUGIN_JSON_MAX_DEPTH,
PLUGIN_JSON_MAX_NODES,
} from "./generated/plugin-contract-limits.js";
interface JsonValidationBudget {
nodes: number;
}
function assertJsonValueInternal(
value: unknown,
ancestors: WeakSet<object>,
depth: number,
budget: JsonValidationBudget,
): void {
if (depth > PLUGIN_JSON_MAX_DEPTH) {
throw new RangeError(
`JSON values must not exceed ${PLUGIN_JSON_MAX_DEPTH} levels of nesting`,
);
}
budget.nodes += 1;
if (budget.nodes > PLUGIN_JSON_MAX_NODES) {
throw new RangeError(
`JSON values must not contain more than ${PLUGIN_JSON_MAX_NODES} nodes`,
);
}
if (value === null || typeof value === "string" || typeof value === "boolean") return;
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("JSON numbers must be finite");
return;
}
if (typeof value !== "object") {
throw new TypeError(`Unsupported JSON value type: ${typeof value}`);
}
if (ancestors.has(value)) throw new TypeError("JSON values must not contain cycles");
ancestors.add(value);
try {
if (Array.isArray(value)) {
const keys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (keys.length !== value.length || ownKeys.length !== value.length + 1) {
throw new TypeError("JSON arrays must be dense and contain no named properties");
}
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
throw new TypeError("JSON arrays must contain enumerable data properties only");
}
assertJsonValueInternal(descriptor.value, ancestors, depth + 1, budget);
}
return;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("JSON objects must be plain records");
}
const stringKeys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (ownKeys.length !== stringKeys.length) {
throw new TypeError("JSON objects must not contain symbols or non-enumerable properties");
}
for (const key of stringKeys) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("JSON objects must not contain accessor properties");
}
assertJsonValueInternal(descriptor.value, ancestors, depth + 1, budget);
}
} finally {
ancestors.delete(value);
}
}
export function assertJsonValue(value: unknown): asserts value is JsonValue {
assertJsonValueInternal(value, new WeakSet(), 0, { nodes: 0 });
}
export interface JsonValuePropertyObservation {
readonly depth: number;
readonly parentKey: string | number | undefined;
readonly key: string | number;
readonly value: JsonValue;
}
export type JsonValuePropertyObserver = (
observation: JsonValuePropertyObservation,
) => void;
function serializeValidatedJsonValue(
value: JsonValue,
observer: JsonValuePropertyObserver | undefined,
depth: number,
parentKey: string | number | undefined,
): string {
if (value === null || typeof value !== "object") {
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Value is not serializable JSON");
return serialized;
}
if (Array.isArray(value)) {
const serializedItems: string[] = [];
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("JSON arrays must contain data properties only");
}
const item = descriptor.value as JsonValue;
observer?.({ depth, parentKey, key: index, value: item });
serializedItems.push(serializeValidatedJsonValue(item, observer, depth + 1, index));
}
return `[${serializedItems.join(",")}]`;
}
const serializedEntries: string[] = [];
for (const key of Object.keys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("JSON objects must contain data properties only");
}
const propertyValue = descriptor.value as JsonValue;
observer?.({ depth, parentKey, key, value: propertyValue });
serializedEntries.push(
`${JSON.stringify(key)}:${serializeValidatedJsonValue(
propertyValue,
observer,
depth + 1,
key,
)}`,
);
}
return `{${serializedEntries.join(",")}}`;
}
export function serializeJsonValueWithPropertyObserver(
value: unknown,
observer: JsonValuePropertyObserver | undefined,
): string {
assertJsonValue(value);
return serializeValidatedJsonValue(value, observer, 0, undefined);
}
export function serializeJsonValue(value: unknown): string {
return serializeJsonValueWithPropertyObserver(value, undefined);
}

View File

@@ -0,0 +1,215 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
COMPANION_STDIO_MAX_CONTENT_BYTES,
ContentLengthFrameDecoder,
encodeContentLengthFrame,
} from "./stdioFraming.ts";
test("content-length framing round-trips fragmented and coalesced JSON messages", () => {
const first = encodeContentLengthFrame({ jsonrpc: "2.0", id: 1, method: "plugin.initialize" });
const second = encodeContentLengthFrame({ jsonrpc: "2.0", id: 1, result: { ok: true } });
const joined = new Uint8Array(first.byteLength + second.byteLength);
joined.set(first);
joined.set(second, first.byteLength);
const decoder = new ContentLengthFrameDecoder();
assert.deepEqual(decoder.push(joined.subarray(0, 7)), []);
assert.deepEqual(decoder.push(joined.subarray(7, first.byteLength + 3)), [
{ jsonrpc: "2.0", id: 1, method: "plugin.initialize" },
]);
assert.deepEqual(decoder.push(joined.subarray(first.byteLength + 3)), [
{ jsonrpc: "2.0", id: 1, result: { ok: true } },
]);
assert.doesNotThrow(() => decoder.finish());
for (let split = 0; split <= first.byteLength; split += 1) {
const splitDecoder = new ContentLengthFrameDecoder();
const message = { jsonrpc: "2.0", id: 1, method: "plugin.initialize" };
assert.deepEqual(
splitDecoder.push(first.subarray(0, split)),
split === first.byteLength ? [message] : [],
);
assert.deepEqual(
splitDecoder.push(first.subarray(split)),
split === first.byteLength ? [] : [message],
);
assert.doesNotThrow(() => splitDecoder.finish());
}
});
test("content-length framing rejects ambiguous headers and oversized payloads", () => {
assert.throws(
() => encodeContentLengthFrame(undefined as never),
/Unsupported JSON value type/,
);
assert.throws(
() => encodeContentLengthFrame({ value: Number.NaN } as never),
/JSON numbers must be finite/,
);
assert.throws(
() => encodeContentLengthFrame({
streamId: "stream-1",
sequence: 1,
kind: "chunk",
data: { encoding: "transfer", byteLength: 4 },
}),
/cannot be encoded over companion stdio/,
);
let accessorReads = 0;
const accessorFrame = { streamId: "stream-1", sequence: 1 } as Record<string, unknown>;
Object.defineProperty(accessorFrame, "kind", {
enumerable: true,
get() {
accessorReads += 1;
return "chunk";
},
});
assert.throws(
() => encodeContentLengthFrame(accessorFrame as never),
/must not contain accessor properties/,
);
assert.equal(accessorReads, 0, "framing must reject accessors without invoking them");
let proxyReads = 0;
const proxyFrame = new Proxy({
streamId: "stream-1",
sequence: 1,
kind: "chunk",
data: { encoding: "transfer", byteLength: 4 },
}, {
get(target, property, receiver) {
proxyReads += 1;
return Reflect.get(target, property, receiver);
},
});
assert.throws(
() => encodeContentLengthFrame(proxyFrame),
/cannot be encoded over companion stdio/,
);
assert.equal(proxyReads, 0, "framing must inspect descriptor values instead of reading proxy fields");
let inheritedReads = 0;
const pollutedPrototype = {} as Record<string, unknown>;
Object.defineProperty(pollutedPrototype, "kind", {
enumerable: true,
get() {
inheritedReads += 1;
return "chunk";
},
});
const pollutedFrame = Object.assign(Object.create(pollutedPrototype), {
streamId: "stream-1",
sequence: 1,
data: { encoding: "transfer", byteLength: 4 },
});
assert.throws(
() => encodeContentLengthFrame(pollutedFrame),
/plain records/,
);
assert.equal(inheritedReads, 0, "framing must reject polluted prototypes without reading them");
const duplicate = new ContentLengthFrameDecoder();
assert.throws(
() => duplicate.push("Content-Length: 2\r\ncontent-length: 2\r\n\r\n{}"),
/Duplicate companion stdio header/,
);
const whitespaceBeforeColon = new ContentLengthFrameDecoder();
assert.throws(
() => whitespaceBeforeColon.push("Content-Length : 2\r\n\r\n{}"),
/Malformed companion stdio header/,
);
const unsupported = new ContentLengthFrameDecoder();
assert.throws(
() => unsupported.push("Content-Length: 2\r\nX-Mode: unsafe\r\n\r\n{}"),
/Unsupported companion stdio header/,
);
const oversized = new ContentLengthFrameDecoder({ maxContentBytes: 4 });
assert.throws(
() => oversized.push("Content-Length: 5\r\n\r\n12345"),
/exceeds 4 bytes/,
);
const truncated = new ContentLengthFrameDecoder();
assert.deepEqual(truncated.push("Content-Length: 5\r\n\r\n12"), []);
assert.throws(() => truncated.finish(), /truncated frame/);
assert.throws(
() => new ContentLengthFrameDecoder({
maxContentBytes: COMPANION_STDIO_MAX_CONTENT_BYTES + 1,
}),
/must be an integer between/,
);
for (const payload of ["1e999", '{"value":1e999}']) {
const nonFinite = new ContentLengthFrameDecoder();
const payloadBytes = new TextEncoder().encode(payload).byteLength;
assert.throws(
() => nonFinite.push(`Content-Length: ${payloadBytes}\r\n\r\n${payload}`),
/outside the JSON value contract: JSON numbers must be finite/,
);
}
});
test("content-length framing accepts a split delimiter at the header byte limit", () => {
const maxHeaderBytes = 32;
const header = `Content-Length:${" ".repeat(16)}2`;
assert.equal(new TextEncoder().encode(header).byteLength, maxHeaderBytes);
const separator = "\r\n\r\n";
for (let split = 0; split <= separator.length; split += 1) {
const decoder = new ContentLengthFrameDecoder({ maxHeaderBytes });
assert.deepEqual(decoder.push(`${header}${separator.slice(0, split)}`), []);
assert.deepEqual(decoder.push(`${separator.slice(split)}{}`), [{}]);
assert.doesNotThrow(() => decoder.finish());
}
const oversized = new ContentLengthFrameDecoder({ maxHeaderBytes });
assert.throws(
() => oversized.push(`${header} \r\n\r\n{}`),
/header exceeds 32 bytes/,
);
});
test("content-length framing stays linear under adversarial byte fragmentation", () => {
const message = { value: "x".repeat(100_000) };
const frame = encodeContentLengthFrame(message);
const decoder = new ContentLengthFrameDecoder();
const startedAt = performance.now();
let decoded: unknown[] = [];
for (let index = 0; index < frame.byteLength; index += 1) {
const messages = decoder.push(frame.subarray(index, index + 1));
if (messages.length > 0) decoded = messages;
}
const elapsedMs = performance.now() - startedAt;
assert.deepEqual(decoded, [message]);
assert.doesNotThrow(() => decoder.finish());
assert.ok(
elapsedMs < 3_000,
`byte-fragmented frame decoding took ${Math.round(elapsedMs)}ms`,
);
});
test("content-length framing coalesces fragmented headers without losing the body", () => {
const header = `Content-Length:${" ".repeat(2_000)}2\r\n\r\n`;
const bytes = new TextEncoder().encode(`${header}{}`);
const decoder = new ContentLengthFrameDecoder();
let decoded: unknown[] = [];
for (let index = 0; index < bytes.byteLength; index += 1) {
const messages = decoder.push(bytes.subarray(index, index + 1));
if (messages.length > 0) decoded = messages;
}
assert.deepEqual(decoded, [{}]);
assert.doesNotThrow(() => decoder.finish());
});
test("content-length framing snapshots Buffer input before returning", () => {
const decoder = new ContentLengthFrameDecoder();
const prefix = Buffer.from("Content-Length: 2\r\n\r\n{");
assert.deepEqual(decoder.push(prefix), []);
prefix.fill(0);
assert.deepEqual(decoder.push("}"), [{}]);
assert.doesNotThrow(() => decoder.finish());
});

View File

@@ -0,0 +1,272 @@
import type {
JsonValue,
RpcMessage,
StreamFrame,
} from "./generated/plugin-contract.js";
import {
assertJsonValue,
serializeJsonValueWithPropertyObserver,
} from "./jsonValue.js";
export const COMPANION_STDIO_MAX_HEADER_BYTES = 8 * 1024;
export const COMPANION_STDIO_MAX_CONTENT_BYTES = 16 * 1024 * 1024;
const HEADER_SEPARATOR = new Uint8Array([13, 10, 13, 10]);
const ABSOLUTE_MAX_HEADER_BYTES = 64 * 1024;
const BYTE_QUEUE_SLAB_BYTES = 64 * 1024;
const encoder = new TextEncoder();
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
interface ByteQueueChunk {
readonly bytes: Uint8Array;
length: number;
}
class ByteQueue {
readonly #chunks: ByteQueueChunk[] = [];
#headIndex = 0;
#headOffset = 0;
#byteLength = 0;
get byteLength(): number {
return this.#byteLength;
}
push(chunk: Uint8Array): void {
if (chunk.byteLength === 0) return;
let inputOffset = 0;
while (inputOffset < chunk.byteLength) {
let tail = this.#chunks.at(-1);
if (!tail || tail.length === tail.bytes.byteLength) {
const remaining = chunk.byteLength - inputOffset;
const capacity = remaining >= BYTE_QUEUE_SLAB_BYTES
? remaining
: BYTE_QUEUE_SLAB_BYTES;
tail = { bytes: new Uint8Array(capacity), length: 0 };
this.#chunks.push(tail);
}
const take = Math.min(
tail.bytes.byteLength - tail.length,
chunk.byteLength - inputOffset,
);
tail.bytes.set(chunk.subarray(inputOffset, inputOffset + take), tail.length);
tail.length += take;
inputOffset += take;
this.#byteLength += take;
}
}
indexOf(needle: Uint8Array, limit: number): number {
let matched = 0;
let index = 0;
for (let chunkIndex = this.#headIndex; chunkIndex < this.#chunks.length; chunkIndex += 1) {
const chunk = this.#chunks[chunkIndex];
const start = chunkIndex === this.#headIndex ? this.#headOffset : 0;
for (let offset = start; offset < chunk.length; offset += 1) {
if (index >= limit) return -1;
const byte = chunk.bytes[offset];
if (byte === needle[matched]) {
matched += 1;
if (matched === needle.byteLength) return index - needle.byteLength + 1;
} else {
matched = byte === needle[0] ? 1 : 0;
}
index += 1;
}
}
return -1;
}
consume(byteLength: number): Uint8Array {
if (byteLength < 0 || byteLength > this.#byteLength) {
throw new RangeError(`Cannot consume ${byteLength} bytes from ${this.#byteLength}`);
}
const output = new Uint8Array(byteLength);
let outputOffset = 0;
let remaining = byteLength;
while (remaining > 0) {
const head = this.#chunks[this.#headIndex];
const available = head.length - this.#headOffset;
const take = Math.min(available, remaining);
output.set(
head.bytes.subarray(this.#headOffset, this.#headOffset + take),
outputOffset,
);
outputOffset += take;
remaining -= take;
this.#headOffset += take;
this.#byteLength -= take;
if (this.#headOffset === head.length) {
this.#headIndex += 1;
this.#headOffset = 0;
}
}
if (this.#byteLength === 0) {
this.#chunks.length = 0;
this.#headIndex = 0;
} else if (this.#headIndex >= 1_024 && this.#headIndex * 2 >= this.#chunks.length) {
this.#chunks.splice(0, this.#headIndex);
this.#headIndex = 0;
}
return output;
}
}
function decodeAscii(bytes: Uint8Array): string {
for (const byte of bytes) {
if (byte > 0x7f) throw new Error("Companion stdio headers must contain ASCII only");
}
return utf8Decoder.decode(bytes);
}
function parseContentLength(headerBytes: Uint8Array, maxContentBytes: number): number {
const values = new Map<string, string>();
for (const line of decodeAscii(headerBytes).split("\r\n")) {
const match = /^([A-Za-z][A-Za-z0-9-]*):[ \t]*(.*)$/.exec(line);
if (!match) throw new Error(`Malformed companion stdio header: ${line}`);
const name = match[1].toLowerCase();
const value = match[2].trim();
if (values.has(name)) throw new Error(`Duplicate companion stdio header: ${name}`);
if (name !== "content-length" && name !== "content-type") {
throw new Error(`Unsupported companion stdio header: ${name}`);
}
values.set(name, value);
}
const rawLength = values.get("content-length");
if (!rawLength || !/^(0|[1-9]\d*)$/.test(rawLength)) {
throw new Error("Companion stdio frame requires one decimal Content-Length header");
}
const contentLength = Number(rawLength);
if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
throw new Error("Companion stdio Content-Length must be a positive safe integer");
}
if (contentLength > maxContentBytes) {
throw new Error(`Companion stdio frame exceeds ${maxContentBytes} bytes`);
}
const contentType = values.get("content-type")?.toLowerCase();
if (contentType !== undefined
&& contentType !== "application/json"
&& contentType !== "application/json; charset=utf-8") {
throw new Error(`Unsupported companion stdio Content-Type: ${contentType}`);
}
return contentLength;
}
export function encodeContentLengthFrame(
value: JsonValue | RpcMessage | StreamFrame,
): Uint8Array {
let rootKind: JsonValue | undefined;
let rootDataEncoding: JsonValue | undefined;
const serialized = serializeJsonValueWithPropertyObserver(value, (observation) => {
if (observation.depth === 0 && observation.key === "kind") {
rootKind = observation.value;
} else if (observation.depth === 1
&& observation.parentKey === "data"
&& observation.key === "encoding") {
rootDataEncoding = observation.value;
}
});
if (rootKind === "chunk" && rootDataEncoding === "transfer") {
throw new Error("Transfer stream chunks cannot be encoded over companion stdio");
}
const content = encoder.encode(serialized);
if (content.byteLength === 0 || content.byteLength > COMPANION_STDIO_MAX_CONTENT_BYTES) {
throw new Error(
`Companion stdio content must be between 1 and ${COMPANION_STDIO_MAX_CONTENT_BYTES} bytes`,
);
}
const header = encoder.encode(
`Content-Length: ${content.byteLength}\r\nContent-Type: application/json; charset=utf-8\r\n\r\n`,
);
const frame = new Uint8Array(header.byteLength + content.byteLength);
frame.set(header, 0);
frame.set(content, header.byteLength);
return frame;
}
export interface ContentLengthFrameDecoderOptions {
readonly maxHeaderBytes?: number;
readonly maxContentBytes?: number;
}
export class ContentLengthFrameDecoder {
readonly #queue = new ByteQueue();
readonly #maxHeaderBytes: number;
readonly #maxContentBytes: number;
#expectedContentBytes: number | undefined;
constructor(options: ContentLengthFrameDecoderOptions = {}) {
this.#maxHeaderBytes = options.maxHeaderBytes ?? COMPANION_STDIO_MAX_HEADER_BYTES;
this.#maxContentBytes = options.maxContentBytes ?? COMPANION_STDIO_MAX_CONTENT_BYTES;
if (!Number.isInteger(this.#maxHeaderBytes)
|| this.#maxHeaderBytes < 32
|| this.#maxHeaderBytes > ABSOLUTE_MAX_HEADER_BYTES) {
throw new RangeError(
`maxHeaderBytes must be an integer between 32 and ${ABSOLUTE_MAX_HEADER_BYTES}`,
);
}
if (!Number.isInteger(this.#maxContentBytes)
|| this.#maxContentBytes < 1
|| this.#maxContentBytes > COMPANION_STDIO_MAX_CONTENT_BYTES) {
throw new RangeError(
`maxContentBytes must be an integer between 1 and ${COMPANION_STDIO_MAX_CONTENT_BYTES}`,
);
}
}
push(chunk: Uint8Array | string): JsonValue[] {
this.#queue.push(typeof chunk === "string" ? encoder.encode(chunk) : chunk);
const messages: JsonValue[] = [];
while (true) {
if (this.#expectedContentBytes === undefined) {
const separatorIndex = this.#queue.indexOf(
HEADER_SEPARATOR,
this.#maxHeaderBytes + HEADER_SEPARATOR.byteLength,
);
if (separatorIndex === -1) {
const maximumIncompleteHeaderBytes = this.#maxHeaderBytes
+ HEADER_SEPARATOR.byteLength
- 1;
if (this.#queue.byteLength > maximumIncompleteHeaderBytes) {
throw new Error(`Companion stdio header exceeds ${this.#maxHeaderBytes} bytes`);
}
return messages;
}
if (separatorIndex > this.#maxHeaderBytes) {
throw new Error(`Companion stdio header exceeds ${this.#maxHeaderBytes} bytes`);
}
const header = this.#queue.consume(separatorIndex + HEADER_SEPARATOR.byteLength)
.subarray(0, separatorIndex);
this.#expectedContentBytes = parseContentLength(header, this.#maxContentBytes);
}
if (this.#queue.byteLength < this.#expectedContentBytes) return messages;
const content = this.#queue.consume(this.#expectedContentBytes);
this.#expectedContentBytes = undefined;
let value: unknown;
try {
value = JSON.parse(utf8Decoder.decode(content));
} catch (error) {
throw new Error(
`Companion stdio payload is not valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
try {
assertJsonValue(value);
} catch (error) {
throw new Error(
`Companion stdio payload is outside the JSON value contract: ${error instanceof Error ? error.message : String(error)}`,
);
}
messages.push(value);
}
}
finish(): void {
if (this.#expectedContentBytes !== undefined || this.#queue.byteLength > 0) {
throw new Error("Companion stdio stream ended with a truncated frame");
}
}
}

View File

@@ -0,0 +1,417 @@
import assert from "node:assert/strict";
import test from "node:test";
import { runInNewContext } from "node:vm";
import {
PLUGIN_JSON_MAX_DEPTH,
PLUGIN_JSON_MAX_NODES,
assertJsonValue,
serializeJsonValue,
} from "./jsonValue.ts";
import {
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_CREDIT_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
assertStreamChunkData,
assertStreamFrame,
createBase64StreamChunk,
createJsonStreamChunk,
createMessagePortStreamEnvelope,
materializeStreamChunk,
} from "./streamTransport.ts";
import { PLUGIN_WIRE_MAX_SAFE_INTEGER } from "./generated/plugin-contract-limits.ts";
test("validated JSON serialization matches standard JSON bytes for plain values", () => {
const values = [
null,
true,
false,
0,
-0,
1.25,
1e30,
"quotes \" slashes \\ controls \n unicode 你好",
[],
[null, true, 3, "value", { nested: [1, 2, 3] }],
{ first: 1, second: "two", third: false },
{ 10: "ten", 2: "two", tail: "last" },
];
for (const value of values) {
assert.equal(serializeJsonValue(value), JSON.stringify(value));
}
});
test("JSON validation rejects excessive structural depth and node counts", () => {
let deepValue: unknown = null;
for (let depth = 0; depth <= PLUGIN_JSON_MAX_DEPTH; depth += 1) {
deepValue = [deepValue];
}
assert.throws(
() => assertJsonValue(deepValue),
new RegExp(`must not exceed ${PLUGIN_JSON_MAX_DEPTH} levels`),
);
const wideValue = Array.from({ length: PLUGIN_JSON_MAX_NODES }, () => null);
assert.throws(
() => assertJsonValue(wideValue),
new RegExp(`must not contain more than ${PLUGIN_JSON_MAX_NODES} nodes`),
);
});
test("JSON stream chunks use verified UTF-8 byte accounting", () => {
const chunk = createJsonStreamChunk({ text: "你好" });
assert.equal(chunk.encoding, "json");
assert.equal(chunk.byteLength, new TextEncoder().encode('{"text":"你好"}').byteLength);
assert.deepEqual(materializeStreamChunk(chunk), {
encoding: "json",
value: { text: "你好" },
});
assert.throws(
() => materializeStreamChunk({ ...chunk, byteLength: chunk.byteLength + 1 }),
/JSON byteLength mismatch/,
);
assert.throws(
() => createJsonStreamChunk({ value: Number.NaN } as never),
/JSON numbers must be finite/,
);
assert.throws(
() => createJsonStreamChunk({ value: undefined } as never),
/Unsupported JSON value type/,
);
const sparse = new Array(2) as never;
assert.throws(() => createJsonStreamChunk(sparse), /JSON arrays must be dense/);
const accessor = {} as Record<string, unknown>;
Object.defineProperty(accessor, "value", { enumerable: true, get: () => "unsafe" });
assert.throws(
() => createJsonStreamChunk(accessor as never),
/must not contain accessor properties/,
);
class CustomJsonValue {
readonly value = "validated";
toJSON() {
return { value: "different" };
}
}
assert.throws(
() => createJsonStreamChunk(new CustomJsonValue() as never),
/plain records/,
);
const arrayWithInheritedToJson = ["validated"];
Object.setPrototypeOf(arrayWithInheritedToJson, {
toJSON: () => ["different"],
});
const inheritedToJsonChunk = createJsonStreamChunk(arrayWithInheritedToJson);
assert.equal(inheritedToJsonChunk.byteLength, new TextEncoder().encode('["validated"]').byteLength);
const nullPrototypeValue = Object.assign(Object.create(null), { value: "validated" });
assert.deepEqual(createJsonStreamChunk(nullPrototypeValue), {
encoding: "json",
value: nullPrototypeValue,
byteLength: new TextEncoder().encode('{"value":"validated"}').byteLength,
});
});
test("base64 stream chunks round-trip bytes and reject length or encoding ambiguity", () => {
const bytes = new Uint8Array([0, 1, 2, 127, 128, 253, 254, 255]);
const chunk = createBase64StreamChunk(bytes);
const materialized = materializeStreamChunk(chunk);
assert.equal(materialized.encoding, "binary");
assert.deepEqual(materialized.bytes, bytes);
assert.throws(
() => materializeStreamChunk({ ...chunk, byteLength: bytes.byteLength + 1 }),
/byteLength mismatch/,
);
assert.throws(
() => materializeStreamChunk({ encoding: "base64", value: "not-base64", byteLength: 1 }),
/canonical RFC 4648 base64/,
);
assert.throws(
() => materializeStreamChunk({ encoding: "base64", value: "AB==", byteLength: 1 }),
/canonical RFC 4648 base64/,
);
assert.throws(
() => materializeStreamChunk({
encoding: "base64",
value: "",
byteLength: PLUGIN_STREAM_MAX_CHUNK_BYTES + 1,
}),
/byteLength must be an integer between/,
);
for (let length = 0; length <= 257; length += 1) {
const sample = Uint8Array.from(
{ length },
(_, index) => (length * 17 + index * 31) & 0xff,
);
const roundTrip = materializeStreamChunk(createBase64StreamChunk(sample));
assert.equal(roundTrip.encoding, "binary");
assert.deepEqual(roundTrip.bytes, sample, `base64 length ${length}`);
}
});
test("stream chunk assertions validate inline bytes before accepting frames", () => {
const jsonChunk = createJsonStreamChunk({ text: "你好" });
const base64Chunk = createBase64StreamChunk(new Uint8Array([0, 1, 2, 255]));
assert.doesNotThrow(() => assertStreamChunkData(jsonChunk));
assert.doesNotThrow(() => assertStreamChunkData(base64Chunk));
assert.doesNotThrow(() => assertStreamChunkData({ encoding: "transfer", byteLength: 4 }));
const invalidInlineChunks: readonly [unknown, RegExp][] = [
[{ ...jsonChunk, byteLength: jsonChunk.byteLength + 1 }, /JSON byteLength mismatch/],
[{ ...base64Chunk, byteLength: base64Chunk.byteLength + 1 }, /base64 byteLength mismatch/],
[
{ encoding: "base64", value: "not-base64", byteLength: 1 },
/canonical RFC 4648 base64/,
],
[{ encoding: "base64", value: "AB==", byteLength: 1 }, /canonical RFC 4648 base64/],
];
for (const [data, expectedError] of invalidInlineChunks) {
assert.throws(() => assertStreamChunkData(data), expectedError);
assert.throws(
() => assertStreamFrame({ streamId: "stream-1", sequence: 1, kind: "chunk", data }),
expectedError,
);
}
});
test("MessagePort stream envelopes carry and validate the transferred ArrayBuffer", () => {
const transfer = new Uint8Array([1, 2, 3, 4]).buffer;
const frame = {
streamId: "stream-1",
sequence: 1,
kind: "chunk" as const,
data: { encoding: "transfer" as const, byteLength: 4 },
};
const envelope = createMessagePortStreamEnvelope(frame, transfer);
assert.equal(envelope.transfer, transfer);
const materialized = materializeStreamChunk(frame.data, envelope.transfer);
assert.equal(materialized.encoding, "binary");
assert.deepEqual(materialized.bytes, new Uint8Array([1, 2, 3, 4]));
const crossRealmTransfer = runInNewContext("new ArrayBuffer(4)") as ArrayBuffer;
assert.equal(crossRealmTransfer instanceof ArrayBuffer, false);
const crossRealmMaterialized = materializeStreamChunk(frame.data, crossRealmTransfer);
assert.equal(crossRealmMaterialized.encoding, "binary");
assert.equal(crossRealmMaterialized.bytes.byteLength, 4);
assert.throws(
() => createMessagePortStreamEnvelope(frame),
/require an ArrayBuffer/,
);
assert.throws(
() => createMessagePortStreamEnvelope(frame, { byteLength: 4 } as never),
/require a real, attached ArrayBuffer/,
);
assert.throws(
() => createMessagePortStreamEnvelope(
frame,
{
byteLength: 4,
[Symbol.toStringTag]: "ArrayBuffer",
} as never,
),
/require a real, attached ArrayBuffer/,
);
const detached = new ArrayBuffer(4);
structuredClone(detached, { transfer: [detached] });
assert.throws(
() => createMessagePortStreamEnvelope(
{ ...frame, data: { ...frame.data, byteLength: 0 } },
detached,
),
/require a real, attached ArrayBuffer/,
);
assert.throws(
() => materializeStreamChunk(
{ encoding: "bogus", byteLength: 4 } as never,
transfer,
),
/Unsupported stream chunk encoding/,
);
assert.throws(
() => createMessagePortStreamEnvelope(
{ streamId: "stream-1", sequence: 0, kind: "open", windowBytes: 65_536 },
transfer,
),
/Only transfer-encoded chunk frames/,
);
assert.deepEqual(createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER,
kind: "windowUpdate",
creditBytes: 4096,
}), {
frame: {
streamId: "stream-1",
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER,
kind: "windowUpdate",
creditBytes: 4096,
},
});
assert.throws(
() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER + 1,
kind: "windowUpdate",
creditBytes: 4096,
}),
/sequence must be a safe integer/,
);
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "open",
windowBytes: PLUGIN_STREAM_MIN_WINDOW_BYTES,
}));
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "open",
windowBytes: PLUGIN_STREAM_MAX_WINDOW_BYTES,
}));
for (const windowBytes of [
0,
PLUGIN_STREAM_MIN_WINDOW_BYTES - 1,
PLUGIN_STREAM_MAX_WINDOW_BYTES + 1,
Number.POSITIVE_INFINITY,
]) {
assert.throws(
() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "open",
windowBytes,
}),
/windowBytes must be an integer between|JSON numbers must be finite/,
);
}
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "windowUpdate",
creditBytes: PLUGIN_STREAM_MAX_CREDIT_BYTES,
}));
for (const creditBytes of [
0,
PLUGIN_STREAM_MAX_CREDIT_BYTES + 1,
Number.POSITIVE_INFINITY,
]) {
assert.throws(
() => createMessagePortStreamEnvelope({
streamId: "stream-1",
sequence: 0,
kind: "windowUpdate",
creditBytes,
}),
/creditBytes must be an integer between|JSON numbers must be finite/,
);
}
});
test("MessagePort stream envelopes reject frames outside the complete wire schema", () => {
const validError = {
streamId: "stream-1",
sequence: 1,
kind: "error",
error: { code: -32001, message: "cancelled", data: { retryable: false } },
};
assert.doesNotThrow(() => assertStreamFrame(validError));
assert.doesNotThrow(() => createMessagePortStreamEnvelope(validError));
const invalidFrames: readonly [unknown, RegExp][] = [
[null, /plain JSON object/],
[[], /plain JSON object/],
[{ streamId: "", sequence: 1, kind: "end" }, /between 1 and/],
[
{ streamId: "x".repeat(PLUGIN_STREAM_MAX_ID_LENGTH + 1), sequence: 1, kind: "end" },
/between 1 and/,
],
[{ streamId: "stream-1", sequence: 1, kind: "bogus" }, /Unsupported stream frame kind/],
[{ streamId: "stream-1", sequence: 0, kind: "open" }, /missing or unsupported/],
[
{ streamId: "stream-1", sequence: 0, kind: "open", windowBytes: 4096, extra: true },
/missing or unsupported/,
],
[{ streamId: "stream-1", sequence: 1, kind: "chunk", data: null }, /plain JSON object/],
[
{
streamId: "stream-1",
sequence: 1,
kind: "chunk",
data: { encoding: "bogus", byteLength: 0 },
},
/Unsupported stream chunk encoding/,
],
[
{
streamId: "stream-1",
sequence: 1,
kind: "chunk",
data: { encoding: "transfer", byteLength: 0, value: "extra" },
},
/missing or unsupported/,
],
[{ streamId: "stream-1", sequence: 1, kind: "end", data: null }, /missing or unsupported/],
[
{
streamId: "stream-1",
sequence: 1,
kind: "error",
error: { code: -1, message: "bad" },
},
/supported RPC error code/,
],
[
{
streamId: "stream-1",
sequence: 1,
kind: "error",
error: { code: -32001, message: "", data: null },
},
/between 1 and/,
],
[
{
streamId: "stream-1",
sequence: 1,
kind: "error",
error: { code: -32001, message: "bad", extra: true },
},
/missing or unsupported/,
],
[{ streamId: "stream-1", sequence: 0, kind: "cancel" }, /sequence must be a safe integer/],
[
{
streamId: "stream-1",
sequence: 0,
kind: "windowUpdate",
creditBytes: 1,
extra: true,
},
/missing or unsupported/,
],
];
for (const [frame, expectedError] of invalidFrames) {
assert.throws(() => createMessagePortStreamEnvelope(frame), expectedError);
}
let getterRead = false;
const accessorFrame = { streamId: "stream-1", sequence: 1 } as Record<string, unknown>;
Object.defineProperty(accessorFrame, "kind", {
enumerable: true,
get: () => {
getterRead = true;
return "end";
},
});
assert.throws(
() => createMessagePortStreamEnvelope(accessorFrame),
/accessor properties/,
);
assert.equal(getterRead, false);
});

View File

@@ -0,0 +1,436 @@
import type {
JsonValue,
RpcErrorObject,
StreamChunkData,
StreamFrame,
} from "./generated/plugin-contract.js";
import {
PLUGIN_RPC_ERROR_CODES,
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_CREDIT_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
PLUGIN_WIRE_MAX_SAFE_INTEGER,
} from "./generated/plugin-contract-limits.js";
import { assertJsonValue, serializeJsonValue } from "./jsonValue.js";
export {
PLUGIN_STREAM_MAX_CHUNK_BYTES,
PLUGIN_STREAM_MAX_CREDIT_BYTES,
PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
PLUGIN_STREAM_MAX_ID_LENGTH,
PLUGIN_STREAM_MAX_WINDOW_BYTES,
PLUGIN_STREAM_MIN_WINDOW_BYTES,
} from "./generated/plugin-contract-limits.js";
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const BASE64_VALUE = new Map(
[...BASE64_ALPHABET].map((character, index) => [character, index] as const),
);
export interface MessagePortStreamEnvelope {
readonly frame: StreamFrame;
readonly transfer?: ArrayBuffer;
}
const PLUGIN_STREAM_MAX_BASE64_CHARACTERS = 4 * Math.ceil(PLUGIN_STREAM_MAX_CHUNK_BYTES / 3);
const RPC_ERROR_CODES = new Set<number>(PLUGIN_RPC_ERROR_CODES);
export type MaterializedStreamChunk =
| { readonly encoding: "json"; readonly value: JsonValue }
| { readonly encoding: "binary"; readonly bytes: Uint8Array };
const jsonEncoder = new TextEncoder();
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(
ArrayBuffer.prototype,
"byteLength",
)?.get;
function materializeArrayBuffer(value: unknown): Uint8Array {
if (!arrayBufferByteLength) {
throw new TypeError("ArrayBuffer byteLength getter is unavailable");
}
try {
arrayBufferByteLength.call(value);
return new Uint8Array(value as ArrayBuffer);
} catch {
throw new TypeError("Transfer stream chunks require a real, attached ArrayBuffer");
}
}
function serializedJsonByteLength(value: JsonValue): number {
const serialized = serializeJsonValue(value);
return jsonEncoder.encode(serialized).byteLength;
}
function assertChunkByteLength(byteLength: number): void {
if (!Number.isInteger(byteLength)
|| byteLength < 0
|| byteLength > PLUGIN_STREAM_MAX_CHUNK_BYTES) {
throw new RangeError(
`Stream chunk byteLength must be an integer between 0 and ${PLUGIN_STREAM_MAX_CHUNK_BYTES}`,
);
}
}
type JsonRecord = Record<string, JsonValue>;
function readJsonRecord(value: unknown, label: string): JsonRecord {
assertJsonValue(value);
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label} must be a plain JSON object`);
}
const record: JsonRecord = Object.create(null) as JsonRecord;
for (const key of Object.keys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError(`${label} must contain data properties only`);
}
record[key] = descriptor.value as JsonValue;
}
return record;
}
function assertExactKeys(
record: JsonRecord,
expectedKeys: readonly string[],
label: string,
): void {
const actualKeys = Object.keys(record);
if (actualKeys.length !== expectedKeys.length
|| expectedKeys.some((key) => !Object.hasOwn(record, key))) {
throw new TypeError(`${label} has missing or unsupported properties`);
}
}
function assertBoundedString(
value: JsonValue | undefined,
minimum: number,
maximum: number,
label: string,
): asserts value is string {
if (typeof value !== "string") {
throw new TypeError(`${label} must be a string`);
}
const length = [...value].length;
if (length < minimum || length > maximum) {
throw new RangeError(`${label} must contain between ${minimum} and ${maximum} characters`);
}
}
function parseRpcErrorObject(value: unknown): RpcErrorObject {
const error = readJsonRecord(value, "Stream error");
const expectedKeys = Object.hasOwn(error, "data")
? ["code", "message", "data"]
: ["code", "message"];
assertExactKeys(error, expectedKeys, "Stream error");
if (typeof error.code !== "number"
|| !Number.isInteger(error.code)
|| !RPC_ERROR_CODES.has(error.code)) {
throw new RangeError("Stream error code is not a supported RPC error code");
}
assertBoundedString(error.message, 1, 2048, "Stream error message");
return Object.hasOwn(error, "data")
? {
code: error.code as RpcErrorObject["code"],
message: error.message,
data: error.data ?? null,
}
: { code: error.code as RpcErrorObject["code"], message: error.message };
}
function parseStreamChunkDataShape(value: unknown): StreamChunkData {
const data = readJsonRecord(value, "Stream chunk data");
if (data.encoding === "json") {
assertExactKeys(data, ["encoding", "value", "byteLength"], "JSON stream chunk data");
if (typeof data.byteLength !== "number") {
throw new TypeError("Stream chunk byteLength must be a number");
}
assertChunkByteLength(data.byteLength);
return { encoding: "json", value: data.value ?? null, byteLength: data.byteLength };
} else if (data.encoding === "base64") {
assertExactKeys(data, ["encoding", "value", "byteLength"], "Base64 stream chunk data");
if (typeof data.value !== "string") {
throw new TypeError("Base64 stream chunk value must be a string");
}
if (typeof data.byteLength !== "number") {
throw new TypeError("Stream chunk byteLength must be a number");
}
assertChunkByteLength(data.byteLength);
return { encoding: "base64", value: data.value, byteLength: data.byteLength };
} else if (data.encoding === "transfer") {
assertExactKeys(data, ["encoding", "byteLength"], "Transfer stream chunk data");
if (typeof data.byteLength !== "number") {
throw new TypeError("Stream chunk byteLength must be a number");
}
assertChunkByteLength(data.byteLength);
return { encoding: "transfer", byteLength: data.byteLength };
} else {
throw new TypeError("Unsupported stream chunk encoding");
}
}
function assertInlineStreamChunkBytes(data: StreamChunkData): void {
if (data.encoding === "json") {
const byteLength = serializedJsonByteLength(data.value);
if (byteLength !== data.byteLength) {
throw new Error(
`Stream JSON byteLength mismatch: declared ${data.byteLength}, encoded ${byteLength}`,
);
}
} else if (data.encoding === "base64") {
decodeValidatedBase64(data.value, data.byteLength);
}
}
function parseStreamChunkData(value: unknown): StreamChunkData {
const data = parseStreamChunkDataShape(value);
assertInlineStreamChunkBytes(data);
return data;
}
export function assertStreamChunkData(value: unknown): asserts value is StreamChunkData {
parseStreamChunkData(value);
}
function assertStreamSequence(kind: StreamFrame["kind"], sequence: number): void {
const minimum = kind === "open" || kind === "windowUpdate" ? 0 : 1;
if (!Number.isSafeInteger(sequence)
|| sequence < minimum
|| sequence > PLUGIN_WIRE_MAX_SAFE_INTEGER
|| (kind === "open" && sequence !== 0)) {
const expected = kind === "open"
? "exactly 0"
: `a safe integer between ${minimum} and ${PLUGIN_WIRE_MAX_SAFE_INTEGER}`;
throw new RangeError(`Stream ${kind} sequence must be ${expected}`);
}
}
function assertStreamWindowBytes(windowBytes: number): void {
if (!Number.isInteger(windowBytes)
|| windowBytes < PLUGIN_STREAM_MIN_WINDOW_BYTES
|| windowBytes > PLUGIN_STREAM_MAX_WINDOW_BYTES) {
throw new RangeError(
`Stream open windowBytes must be an integer between ${PLUGIN_STREAM_MIN_WINDOW_BYTES} and ${PLUGIN_STREAM_MAX_WINDOW_BYTES}`,
);
}
}
function assertStreamCreditBytes(creditBytes: number): void {
if (!Number.isInteger(creditBytes)
|| creditBytes < 1
|| creditBytes > PLUGIN_STREAM_MAX_CREDIT_BYTES) {
throw new RangeError(
`Stream windowUpdate creditBytes must be an integer between 1 and ${PLUGIN_STREAM_MAX_CREDIT_BYTES}`,
);
}
}
function parseStreamFrame(value: unknown): StreamFrame {
const frame = readJsonRecord(value, "Stream frame");
assertBoundedString(frame.streamId, 1, PLUGIN_STREAM_MAX_ID_LENGTH, "Stream frame streamId");
if (typeof frame.kind !== "string") {
throw new TypeError("Stream frame kind must be a string");
}
if (typeof frame.sequence !== "number") {
throw new TypeError("Stream frame sequence must be a number");
}
switch (frame.kind) {
case "open": {
assertExactKeys(frame, ["streamId", "sequence", "kind", "windowBytes"], "Open stream frame");
if (typeof frame.windowBytes !== "number") {
throw new TypeError("Stream open windowBytes must be a number");
}
assertStreamSequence(frame.kind, frame.sequence);
assertStreamWindowBytes(frame.windowBytes);
return {
streamId: frame.streamId,
sequence: 0,
kind: "open",
windowBytes: frame.windowBytes,
};
}
case "chunk": {
assertExactKeys(frame, ["streamId", "sequence", "kind", "data"], "Chunk stream frame");
assertStreamSequence(frame.kind, frame.sequence);
return {
streamId: frame.streamId,
sequence: frame.sequence,
kind: "chunk",
data: parseStreamChunkData(frame.data),
};
}
case "end":
case "cancel": {
assertExactKeys(frame, ["streamId", "sequence", "kind"], `${frame.kind} stream frame`);
assertStreamSequence(frame.kind, frame.sequence);
return { streamId: frame.streamId, sequence: frame.sequence, kind: frame.kind };
}
case "error": {
assertExactKeys(frame, ["streamId", "sequence", "kind", "error"], "Error stream frame");
assertStreamSequence(frame.kind, frame.sequence);
return {
streamId: frame.streamId,
sequence: frame.sequence,
kind: "error",
error: parseRpcErrorObject(frame.error),
};
}
case "windowUpdate": {
assertExactKeys(
frame,
["streamId", "sequence", "kind", "creditBytes"],
"Window-update stream frame",
);
if (typeof frame.creditBytes !== "number") {
throw new TypeError("Stream windowUpdate creditBytes must be a number");
}
assertStreamSequence(frame.kind, frame.sequence);
assertStreamCreditBytes(frame.creditBytes);
return {
streamId: frame.streamId,
sequence: frame.sequence,
kind: "windowUpdate",
creditBytes: frame.creditBytes,
};
}
default:
throw new TypeError(`Unsupported stream frame kind: ${frame.kind}`);
}
}
export function assertStreamFrame(value: unknown): asserts value is StreamFrame {
parseStreamFrame(value);
}
function encodeBase64(bytes: Uint8Array): string {
let output = "";
for (let offset = 0; offset < bytes.byteLength; offset += 3) {
const first = bytes[offset];
const hasSecond = offset + 1 < bytes.byteLength;
const hasThird = offset + 2 < bytes.byteLength;
const second = hasSecond ? bytes[offset + 1] : 0;
const third = hasThird ? bytes[offset + 2] : 0;
output += BASE64_ALPHABET[first >> 2];
output += BASE64_ALPHABET[((first & 0x03) << 4) | (second >> 4)];
output += hasSecond
? BASE64_ALPHABET[((second & 0x0f) << 2) | (third >> 6)]
: "=";
output += hasThird ? BASE64_ALPHABET[third & 0x3f] : "=";
}
return output;
}
function decodeBase64(value: string): Uint8Array {
if (value.length > PLUGIN_STREAM_MAX_BASE64_CHARACTERS) {
throw new RangeError(
`Stream base64 data exceeds ${PLUGIN_STREAM_MAX_BASE64_CHARACTERS} characters`,
);
}
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
throw new Error("Stream base64 data is not canonical RFC 4648 base64");
}
if (value.length === 0) return new Uint8Array(0);
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
const output = new Uint8Array((value.length / 4) * 3 - padding);
let outputOffset = 0;
for (let offset = 0; offset < value.length; offset += 4) {
const first = BASE64_VALUE.get(value[offset]) ?? 0;
const second = BASE64_VALUE.get(value[offset + 1]) ?? 0;
const third = BASE64_VALUE.get(value[offset + 2]) ?? 0;
const fourth = BASE64_VALUE.get(value[offset + 3]) ?? 0;
if (outputOffset < output.byteLength) output[outputOffset++] = (first << 2) | (second >> 4);
if (outputOffset < output.byteLength) output[outputOffset++] = (second << 4) | (third >> 2);
if (outputOffset < output.byteLength) output[outputOffset++] = (third << 6) | fourth;
}
if (encodeBase64(output) !== value) {
throw new Error("Stream base64 data is not canonical RFC 4648 base64");
}
return output;
}
function decodeValidatedBase64(value: string, declaredByteLength: number): Uint8Array {
const bytes = decodeBase64(value);
if (bytes.byteLength !== declaredByteLength) {
throw new Error(
`Stream base64 byteLength mismatch: declared ${declaredByteLength}, decoded ${bytes.byteLength}`,
);
}
return bytes;
}
export function createBase64StreamChunk(bytes: Uint8Array): StreamChunkData {
assertChunkByteLength(bytes.byteLength);
return {
encoding: "base64",
value: encodeBase64(bytes),
byteLength: bytes.byteLength,
};
}
export function createJsonStreamChunk(value: JsonValue): StreamChunkData {
const byteLength = serializedJsonByteLength(value);
assertChunkByteLength(byteLength);
return {
encoding: "json",
value,
byteLength,
};
}
function materializeValidatedStreamChunk(
data: StreamChunkData,
transfer?: ArrayBuffer,
): MaterializedStreamChunk {
if (data.encoding === "json") {
if (transfer !== undefined) {
throw new Error("JSON stream chunks must not include a transferable buffer");
}
assertInlineStreamChunkBytes(data);
return { encoding: "json", value: data.value };
}
if (data.encoding === "base64") {
if (transfer !== undefined) {
throw new Error("Base64 stream chunks must not include a transferable buffer");
}
const bytes = decodeValidatedBase64(data.value, data.byteLength);
return { encoding: "binary", bytes };
}
if (transfer === undefined) {
throw new Error("Transfer stream chunks require an ArrayBuffer in the message envelope");
}
const bytes = materializeArrayBuffer(transfer);
if (bytes.byteLength !== data.byteLength) {
throw new Error(
`Stream transfer byteLength mismatch: declared ${data.byteLength}, received ${bytes.byteLength}`,
);
}
return { encoding: "binary", bytes };
}
export function materializeStreamChunk(
data: unknown,
transfer?: ArrayBuffer,
): MaterializedStreamChunk {
return materializeValidatedStreamChunk(parseStreamChunkDataShape(data), transfer);
}
export function createMessagePortStreamEnvelope(
frame: unknown,
transfer?: ArrayBuffer,
): MessagePortStreamEnvelope {
const validatedFrame = parseStreamFrame(frame);
if (validatedFrame.kind === "chunk") {
if (validatedFrame.data.encoding === "transfer") {
materializeValidatedStreamChunk(validatedFrame.data, transfer);
} else if (transfer !== undefined) {
throw new Error("Only transfer-encoded chunk frames may include an ArrayBuffer");
}
} else if (transfer !== undefined) {
throw new Error("Only transfer-encoded chunk frames may include an ArrayBuffer");
}
return transfer === undefined
? { frame: validatedFrame }
: { frame: validatedFrame, transfer };
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}