[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
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:
25
packages/plugin-contract/package.json
Normal file
25
packages/plugin-contract/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2131
packages/plugin-contract/schema/plugin-contract.schema.json
Normal file
2131
packages/plugin-contract/schema/plugin-contract.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
1474
packages/plugin-contract/src/contract.test.ts
Normal file
1474
packages/plugin-contract/src/contract.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
951
packages/plugin-contract/src/generated/plugin-contract.ts
Normal file
951
packages/plugin-contract/src/generated/plugin-contract.ts
Normal 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};
|
||||
49
packages/plugin-contract/src/index.ts
Normal file
49
packages/plugin-contract/src/index.ts
Normal 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";
|
||||
149
packages/plugin-contract/src/jsonValue.ts
Normal file
149
packages/plugin-contract/src/jsonValue.ts
Normal 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);
|
||||
}
|
||||
215
packages/plugin-contract/src/stdioFraming.test.ts
Normal file
215
packages/plugin-contract/src/stdioFraming.test.ts
Normal 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());
|
||||
});
|
||||
272
packages/plugin-contract/src/stdioFraming.ts
Normal file
272
packages/plugin-contract/src/stdioFraming.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
417
packages/plugin-contract/src/streamTransport.test.ts
Normal file
417
packages/plugin-contract/src/streamTransport.test.ts
Normal 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);
|
||||
});
|
||||
436
packages/plugin-contract/src/streamTransport.ts
Normal file
436
packages/plugin-contract/src/streamTransport.ts
Normal 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 };
|
||||
}
|
||||
16
packages/plugin-contract/tsconfig.build.json
Normal file
16
packages/plugin-contract/tsconfig.build.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user