@bun-native/protocol 0.0.0-stage → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { parseChunk, parseFilePath, parseRootId, parseRootPath } from './files.ts'
3
+ import { id, integer, keys, object } from './values.ts'
4
+
5
+ export const MAX_PROCESS_EXECUTABLES = 8
6
+ export const MAX_PROCESSES = 8
7
+ export const MAX_PROCESS_ARGS = 64
8
+ export const MAX_PROCESS_ENV = 64
9
+ export const MAX_PROCESS_OUTPUT_CREDIT = 4
10
+ const MAX_PROCESS_ARG_BYTES = 4_096
11
+ const MAX_PROCESS_ENV_NAME_BYTES = 128
12
+ const MAX_PROCESS_ENV_VALUE_BYTES = 4_096
13
+ const MAX_PROCESS_LAUNCH_BYTES = 32_768
14
+ const MAX_TERMINAL_DIMENSION = 1_000
15
+ const PROCESS_ARGUMENT = '--bun-native-process='
16
+ const PROCESS_LIMITS_ARGUMENT = '--bun-native-process-limits='
17
+ const MAX_PROCESS_CPU_SECONDS = 604_800
18
+ const MIN_PROCESS_MEMORY_BYTES = 2 ** 20
19
+ const MAX_PROCESS_MEMORY_BYTES = 2 ** 48
20
+ const MIN_PROCESS_OPEN_FILES = 3
21
+ const MAX_PROCESS_OPEN_FILES = 2 ** 20
22
+ const encoder = new TextEncoder()
23
+ const bytes = (value: string) => encoder.encode(value).byteLength
24
+
25
+ /**
26
+ * Resource limits every child started from one executable entry runs under.
27
+ * A platform that cannot enforce a requested limit refuses the spawn.
28
+ */
29
+ export interface ProcessLimits {
30
+ /** CPU time (POSIX `RLIMIT_CPU`, Windows per-process job time). */
31
+ cpu_seconds?: number
32
+ /** Memory (Linux `RLIMIT_AS`, Windows per-process job memory). */
33
+ memory_bytes?: number
34
+ /** Open descriptors (POSIX `RLIMIT_NOFILE`). */
35
+ open_files?: number
36
+ }
37
+
38
+ export interface ProcessExecutable {
39
+ id: string
40
+ executable: string
41
+ limits?: ProcessLimits
42
+ }
43
+
44
+ const LIMIT_NAMES = ['cpu_seconds', 'memory_bytes', 'open_files'] as const
45
+ const LIMIT_BOUNDS = {
46
+ cpu_seconds: [1, MAX_PROCESS_CPU_SECONDS],
47
+ memory_bytes: [MIN_PROCESS_MEMORY_BYTES, MAX_PROCESS_MEMORY_BYTES],
48
+ open_files: [MIN_PROCESS_OPEN_FILES, MAX_PROCESS_OPEN_FILES],
49
+ } as const
50
+
51
+ export function parseProcessLimits(value: unknown): ProcessLimits {
52
+ const data = object(value)
53
+ keys(data, [...LIMIT_NAMES])
54
+ const limits: ProcessLimits = {}
55
+ for (const name of LIMIT_NAMES)
56
+ if (name in data) {
57
+ const [min, max] = LIMIT_BOUNDS[name]
58
+ limits[name] = integer(data[name], 'process ' + name, min, max)
59
+ }
60
+ if (Object.keys(limits).length === 0)
61
+ throw new ProtocolError('Process limits must set at least one limit')
62
+ return limits
63
+ }
64
+
65
+ /** `cpu_seconds=N,memory_bytes=N,open_files=N`, only the set limits, in that order. */
66
+ export function processLimitsArgument(limits: ProcessLimits): string {
67
+ const parsed = parseProcessLimits(limits)
68
+ return LIMIT_NAMES.filter((name) => parsed[name] !== undefined)
69
+ .map((name) => name + '=' + parsed[name])
70
+ .join(',')
71
+ }
72
+
73
+ /** Parses `processLimitsArgument`'s form strictly, as the native host does. */
74
+ export function parseProcessLimitsArgument(text: string): ProcessLimits {
75
+ const data: Record<string, number> = {}
76
+ let last = -1
77
+ for (const part of text.split(',')) {
78
+ const match = /^([a-z_]+)=(0|[1-9][0-9]{0,19})$/.exec(part)
79
+ const order = LIMIT_NAMES.indexOf(match?.[1] as (typeof LIMIT_NAMES)[number])
80
+ if (!match || order <= last)
81
+ throw new ProtocolError('Process limits must be cpu_seconds=N,memory_bytes=N,open_files=N')
82
+ last = order
83
+ data[match[1]!] = Number(match[2])
84
+ }
85
+ return parseProcessLimits(data)
86
+ }
87
+
88
+ export interface TerminalSize {
89
+ columns: number
90
+ rows: number
91
+ }
92
+
93
+ export interface ProcessLaunch {
94
+ executable: string
95
+ args: string[]
96
+ env: Record<string, string>
97
+ cwd?: { grant_id: number; path: string }
98
+ terminal?: TerminalSize
99
+ }
100
+
101
+ export const PROCESS_SIGNALS = Object.freeze(['interrupt', 'terminate', 'kill', 'hangup'] as const)
102
+ export type ProcessSignal = (typeof PROCESS_SIGNALS)[number]
103
+ export type ProcessStream = 'stdout' | 'stderr' | 'terminal'
104
+ export type ProcessExit = { code: number } | { signal: number }
105
+
106
+ function executable(value: unknown): ProcessExecutable {
107
+ const data = object(value)
108
+ keys(data, ['id', 'executable', 'limits'])
109
+ const path = parseRootPath(data['executable'])
110
+ // `/` and `X:\` are the only normalized paths ending in a separator.
111
+ if (path.endsWith('/') || path.endsWith('\\'))
112
+ throw new ProtocolError('Process executable must name a file')
113
+ const entry: ProcessExecutable = { id: parseRootId(data['id']), executable: path }
114
+ if ('limits' in data) entry.limits = parseProcessLimits(data['limits'])
115
+ return entry
116
+ }
117
+
118
+ export function parseProcessExecutables(value: unknown): ProcessExecutable[] {
119
+ if (!Array.isArray(value) || value.length > MAX_PROCESS_EXECUTABLES)
120
+ throw new ProtocolError(
121
+ 'At most ' + MAX_PROCESS_EXECUTABLES + ' process executables may be granted'
122
+ )
123
+ const entries = value.map(executable)
124
+ if (new Set(entries.map((entry) => entry.id)).size !== entries.length)
125
+ throw new ProtocolError('Process executable IDs must be unique')
126
+ return entries
127
+ }
128
+
129
+ /**
130
+ * The reserved native launch arguments for one validated executable: the
131
+ * executable itself, then its limits when it has any.
132
+ */
133
+ export function processArguments(value: ProcessExecutable): string[] {
134
+ const entry = executable(value)
135
+ const argv = [PROCESS_ARGUMENT + entry.id + ':' + entry.executable]
136
+ if (entry.limits)
137
+ argv.push(PROCESS_LIMITS_ARGUMENT + entry.id + ':' + processLimitsArgument(entry.limits))
138
+ return argv
139
+ }
140
+
141
+ export function processesFromArguments(argv: readonly string[]): ProcessExecutable[] {
142
+ // One pass, as the host parses them: limits name an executable granted by
143
+ // an earlier argument, at most once.
144
+ const entries: ProcessExecutable[] = []
145
+ for (const argument of argv) {
146
+ if (argument.startsWith(PROCESS_ARGUMENT)) {
147
+ const [entry, ...path] = argument.slice(PROCESS_ARGUMENT.length).split(':')
148
+ entries.push({ id: entry!, executable: path.join(':') })
149
+ } else if (argument.startsWith(PROCESS_LIMITS_ARGUMENT)) {
150
+ const text = argument.slice(PROCESS_LIMITS_ARGUMENT.length)
151
+ const separator = text.indexOf(':')
152
+ const entry = entries.find((item) => item.id === text.slice(0, separator))
153
+ if (separator < 0 || !entry || entry.limits)
154
+ throw new ProtocolError('Process limits must name one granted executable once')
155
+ entry.limits = parseProcessLimitsArgument(text.slice(separator + 1))
156
+ }
157
+ }
158
+ return parseProcessExecutables(entries)
159
+ }
160
+
161
+ function plain(value: unknown, limit: number, field: string): string {
162
+ if (
163
+ typeof value !== 'string' ||
164
+ !value.isWellFormed() ||
165
+ value.includes('\0') ||
166
+ bytes(value) > limit
167
+ )
168
+ throw new ProtocolError(field + ' must be a NUL-free string of at most ' + limit + ' bytes')
169
+ return value
170
+ }
171
+
172
+ export function parseTerminalSize(value: unknown): TerminalSize {
173
+ const data = object(value)
174
+ keys(data, ['columns', 'rows'])
175
+ return {
176
+ columns: integer(data['columns'], 'terminal columns', 1, MAX_TERMINAL_DIMENSION),
177
+ rows: integer(data['rows'], 'terminal rows', 1, MAX_TERMINAL_DIMENSION),
178
+ }
179
+ }
180
+
181
+ function parseEnvName(value: string): string {
182
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value) || value.length > MAX_PROCESS_ENV_NAME_BYTES)
183
+ throw new ProtocolError(
184
+ 'Environment names must be ASCII letters, digits and "_", not starting with a digit'
185
+ )
186
+ return value
187
+ }
188
+
189
+ export function parseProcessLaunch(value: unknown): ProcessLaunch {
190
+ const data = object(value)
191
+ keys(data, ['executable', 'args', 'env', 'cwd', 'terminal'])
192
+ const args = data['args']
193
+ if (!Array.isArray(args) || args.length > MAX_PROCESS_ARGS)
194
+ throw new ProtocolError('Process arguments must be an array of at most ' + MAX_PROCESS_ARGS)
195
+ const parsedArgs = args.map((item) => plain(item, MAX_PROCESS_ARG_BYTES, 'Process argument'))
196
+ const env = object(data['env'])
197
+ const names = Object.keys(env)
198
+ if (names.length > MAX_PROCESS_ENV)
199
+ throw new ProtocolError('At most ' + MAX_PROCESS_ENV + ' environment variables may be set')
200
+ const parsedEnv: Record<string, string> = {}
201
+ for (const name of names)
202
+ parsedEnv[parseEnvName(name)] = plain(
203
+ env[name],
204
+ MAX_PROCESS_ENV_VALUE_BYTES,
205
+ 'Environment value'
206
+ )
207
+ const total =
208
+ parsedArgs.reduce((sum, item) => sum + bytes(item), 0) +
209
+ names.reduce((sum, name) => sum + bytes(name) + bytes(parsedEnv[name]!), 0)
210
+ if (total > MAX_PROCESS_LAUNCH_BYTES)
211
+ throw new ProtocolError('Process arguments and environment exceed 32768 bytes')
212
+ const launch: ProcessLaunch = {
213
+ executable: parseRootId(data['executable']),
214
+ args: parsedArgs,
215
+ env: parsedEnv,
216
+ }
217
+ if ('cwd' in data) {
218
+ const cwd = object(data['cwd'])
219
+ keys(cwd, ['grant_id', 'path'])
220
+ launch.cwd = { grant_id: id(cwd['grant_id']), path: parseFilePath(cwd['path']) }
221
+ }
222
+ if ('terminal' in data) launch.terminal = parseTerminalSize(data['terminal'])
223
+ return launch
224
+ }
225
+
226
+ export function parseProcessSignal(value: unknown): ProcessSignal {
227
+ if (!PROCESS_SIGNALS.includes(value as ProcessSignal))
228
+ throw new ProtocolError('Unknown process signal')
229
+ return value as ProcessSignal
230
+ }
231
+
232
+ export function parseProcessStream(value: unknown): ProcessStream {
233
+ if (value !== 'stdout' && value !== 'stderr' && value !== 'terminal')
234
+ throw new ProtocolError('Unknown process stream')
235
+ return value
236
+ }
237
+
238
+ export function parseProcessOutput(value: unknown): string {
239
+ const data = parseChunk(value)
240
+ if (!data) throw new ProtocolError('Process output chunks are nonempty')
241
+ return data
242
+ }
243
+
244
+ export function parseProcessExit(value: unknown): ProcessExit {
245
+ const data = object(value)
246
+ keys(data, ['code', 'signal'])
247
+ if ('code' in data && !('signal' in data))
248
+ return { code: integer(data['code'], 'exit code', 0, 255) }
249
+ if ('signal' in data && !('code' in data))
250
+ return { signal: integer(data['signal'], 'exit signal', 1, 64) }
251
+ throw new ProtocolError('A process exit has exactly one exit code or signal')
252
+ }
@@ -0,0 +1,197 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { keys, number, object, string } from './values.ts'
3
+ import { parseFilesystemRoots, type FilesystemRoot } from './files.ts'
4
+ import { parseProcessExecutables, type ProcessExecutable } from './processes.ts'
5
+
6
+ // Worst-case JSON escaping still leaves room within the 64KiB frame.
7
+ export const MAX_CLIPBOARD_BYTES = 8_192
8
+ export const MAX_SERVICE_REQUESTS = 8
9
+ export const MAX_GLOBAL_SHORTCUTS = 16
10
+ export const MAX_NOTIFICATIONS = 32
11
+ export const MAX_NOTIFICATION_TITLE_BYTES = 256
12
+ export const MAX_NOTIFICATION_BODY_BYTES = 2_048
13
+
14
+ export interface ServicePolicy {
15
+ window_display: boolean
16
+ clipboard_read: boolean
17
+ clipboard_write: boolean
18
+ global_shortcuts: boolean
19
+ notifications: boolean
20
+ /** Owned targets that receive external file drops as untrusted names. */
21
+ drag_drop: boolean
22
+ /** Directories a window may open as scoped grants, in launch order. */
23
+ filesystem_roots: FilesystemRoot[]
24
+ /** Executables a window may start as owned processes, in launch order. */
25
+ processes: ProcessExecutable[]
26
+ /** Optional on the wire, so existing peers stay exact: absence is a denial. */
27
+ file_dialogs?: boolean
28
+ /** Optional like `file_dialogs`: the application menu bar. */
29
+ app_menu?: boolean
30
+ /** Optional like `file_dialogs`: OS message dialogs. */
31
+ task_dialogs?: boolean
32
+ }
33
+
34
+ type Characters<S extends string> = S extends `${infer Head}${infer Tail}`
35
+ ? Head | Characters<Tail>
36
+ : never
37
+
38
+ /** A layout-level key name: a-z, 0-9 or f1-f12. */
39
+ export type GlobalShortcutKey =
40
+ | Characters<'abcdefghijklmnopqrstuvwxyz0123456789'>
41
+ | `f${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12}`
42
+
43
+ /** The closed set of layout-level keys a global shortcut may name. */
44
+ export const GLOBAL_SHORTCUT_KEYS = Object.freeze([
45
+ ...'abcdefghijklmnopqrstuvwxyz0123456789',
46
+ ...Array.from({ length: 12 }, (_, index) => `f${index + 1}`),
47
+ ] as GlobalShortcutKey[]) as readonly GlobalShortcutKey[]
48
+
49
+ export interface ShortcutModifiers {
50
+ control: boolean
51
+ alt: boolean
52
+ shift: boolean
53
+ platform: boolean
54
+ }
55
+
56
+ export function parseShortcutModifiers(value: unknown): ShortcutModifiers {
57
+ const data = object(value)
58
+ keys(data, ['control', 'alt', 'shift', 'platform'])
59
+ const flag = (key: keyof ShortcutModifiers) => {
60
+ if (typeof data[key] !== 'boolean')
61
+ throw new ProtocolError('Global shortcut modifiers must state each modifier explicitly')
62
+ return data[key]
63
+ }
64
+ const modifiers = {
65
+ control: flag('control'),
66
+ alt: flag('alt'),
67
+ shift: flag('shift'),
68
+ platform: flag('platform'),
69
+ }
70
+ if (!modifiers.control && !modifiers.alt && !modifiers.platform)
71
+ throw new ProtocolError('Global shortcut requires control, alt or platform')
72
+ return modifiers
73
+ }
74
+
75
+ export function parseShortcutKey(value: unknown): string {
76
+ if (typeof value !== 'string' || !(GLOBAL_SHORTCUT_KEYS as readonly string[]).includes(value))
77
+ throw new ProtocolError('Global shortcut key must be one of a-z, 0-9 or f1-f12')
78
+ return value
79
+ }
80
+
81
+ /** Plain-text notification content: one nonempty title line and a body that
82
+ * may hold line feeds but no other control characters. Neither is markup. */
83
+ export function parseNotificationContent(title: unknown, body: unknown) {
84
+ const parsedTitle = string(title, 'notification title', MAX_NOTIFICATION_TITLE_BYTES)
85
+ if (!parsedTitle || /\p{Cc}/u.test(parsedTitle))
86
+ throw new ProtocolError('Notification title must be nonempty and contain no control characters')
87
+ const parsedBody = string(body, 'notification body', MAX_NOTIFICATION_BODY_BYTES)
88
+ if (/[^\P{Cc}\n]/u.test(parsedBody))
89
+ throw new ProtocolError(
90
+ 'Notification body may contain no control characters other than line feeds'
91
+ )
92
+ return { title: parsedTitle, body: parsedBody }
93
+ }
94
+
95
+ export const NOTIFICATION_DISMISSALS = Object.freeze(['expired', 'dismissed', 'closed'] as const)
96
+ export type NotificationDismissal = (typeof NOTIFICATION_DISMISSALS)[number]
97
+
98
+ export function parseNotificationDismissal(value: unknown): NotificationDismissal {
99
+ if (!NOTIFICATION_DISMISSALS.includes(value as NotificationDismissal))
100
+ throw new ProtocolError('Unknown notification dismissal reason')
101
+ return value as NotificationDismissal
102
+ }
103
+
104
+ export type ClipboardText = { kind: 'empty' } | { kind: 'text'; text: string }
105
+
106
+ export function parseClipboardText(value: unknown): ClipboardText {
107
+ const data = object(value)
108
+ if (data['kind'] === 'empty') {
109
+ keys(data, ['kind'])
110
+ return { kind: 'empty' }
111
+ }
112
+ keys(data, ['kind', 'text'])
113
+ if (data['kind'] !== 'text') throw new ProtocolError('Unknown clipboard text result')
114
+ return { kind: 'text', text: string(data['text'], 'clipboard text', MAX_CLIPBOARD_BYTES) }
115
+ }
116
+
117
+ interface DisplayBounds {
118
+ x: number
119
+ y: number
120
+ width: number
121
+ height: number
122
+ }
123
+
124
+ export interface DisplaySnapshot {
125
+ bounds: DisplayBounds
126
+ scale_factor: number
127
+ /** Work area in the logical coordinates of `bounds` (`display_details_v1` only). */
128
+ visible_bounds?: DisplayBounds
129
+ /** Platform display identifier, lowercase hyphenated (`display_identity_v1` only). */
130
+ display_uuid?: string
131
+ }
132
+
133
+ const DISPLAY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
134
+
135
+ function displayBounds(value: unknown, name: string): DisplayBounds {
136
+ const bounds = object(value)
137
+ keys(bounds, ['x', 'y', 'width', 'height'])
138
+ return {
139
+ x: number(bounds['x'], name + ' x', -1_048_576, 1_048_576),
140
+ y: number(bounds['y'], name + ' y', -1_048_576, 1_048_576),
141
+ width: number(bounds['width'], name + ' width', Number.MIN_VALUE, 1_048_576),
142
+ height: number(bounds['height'], name + ' height', Number.MIN_VALUE, 1_048_576),
143
+ }
144
+ }
145
+
146
+ export function parseServicePolicy(value: unknown): ServicePolicy {
147
+ const data = object(value)
148
+ keys(data, [
149
+ 'window_display',
150
+ 'clipboard_read',
151
+ 'clipboard_write',
152
+ 'global_shortcuts',
153
+ 'notifications',
154
+ 'drag_drop',
155
+ 'filesystem_roots',
156
+ 'processes',
157
+ 'file_dialogs',
158
+ 'app_menu',
159
+ 'task_dialogs',
160
+ ])
161
+ const grant = (key: keyof ServicePolicy) => {
162
+ if (typeof data[key] !== 'boolean')
163
+ throw new ProtocolError('Native service policy must explicitly grant or deny each operation')
164
+ return data[key]
165
+ }
166
+ return {
167
+ window_display: grant('window_display'),
168
+ clipboard_read: grant('clipboard_read'),
169
+ clipboard_write: grant('clipboard_write'),
170
+ global_shortcuts: grant('global_shortcuts'),
171
+ notifications: grant('notifications'),
172
+ drag_drop: grant('drag_drop'),
173
+ filesystem_roots: parseFilesystemRoots(data['filesystem_roots']),
174
+ processes: parseProcessExecutables(data['processes']),
175
+ ...('file_dialogs' in data ? { file_dialogs: grant('file_dialogs') } : {}),
176
+ ...('app_menu' in data ? { app_menu: grant('app_menu') } : {}),
177
+ ...('task_dialogs' in data ? { task_dialogs: grant('task_dialogs') } : {}),
178
+ }
179
+ }
180
+
181
+ export function parseDisplaySnapshot(value: unknown): DisplaySnapshot {
182
+ const data = object(value)
183
+ keys(data, ['bounds', 'scale_factor', 'visible_bounds', 'display_uuid'])
184
+ const snapshot: DisplaySnapshot = {
185
+ bounds: displayBounds(data['bounds'], 'display'),
186
+ scale_factor: number(data['scale_factor'], 'display scale factor', 0.125, 16),
187
+ }
188
+ if ('visible_bounds' in data)
189
+ snapshot.visible_bounds = displayBounds(data['visible_bounds'], 'display work area')
190
+ if ('display_uuid' in data) {
191
+ const uuid = string(data['display_uuid'], 'display UUID', 36)
192
+ if (!DISPLAY_UUID.test(uuid))
193
+ throw new ProtocolError('Native display identity must be a lowercase hyphenated UUID')
194
+ snapshot.display_uuid = uuid
195
+ }
196
+ return snapshot
197
+ }
package/src/values.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { ProtocolError } from './framing.ts'
2
+
3
+ export const MAX_TEXT_BYTES = 16_384
4
+ export const MAX_TITLE_BYTES = 256
5
+ const encoder = new TextEncoder()
6
+
7
+ export function object(value: unknown): Record<string, unknown> {
8
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
9
+ throw new ProtocolError('Message must be an object')
10
+ return value as Record<string, unknown>
11
+ }
12
+
13
+ export function keys(value: Record<string, unknown>, allowed: readonly string[]): void {
14
+ for (const key of Object.keys(value))
15
+ if (!allowed.includes(key)) throw new ProtocolError('Unknown message property: ' + key)
16
+ }
17
+
18
+ export function number(value: unknown, field: string, min: number, max: number): number {
19
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max)
20
+ throw new ProtocolError(field + ' must be finite and between ' + min + ' and ' + max)
21
+ return value
22
+ }
23
+
24
+ export function integer(
25
+ value: unknown,
26
+ field: string,
27
+ min = 0,
28
+ max = Number.MAX_SAFE_INTEGER
29
+ ): number {
30
+ const result = number(value, field, min, max)
31
+ if (!Number.isSafeInteger(result)) throw new ProtocolError(field + ' must be a safe integer')
32
+ return result
33
+ }
34
+
35
+ export function id(value: unknown): number {
36
+ return integer(value, 'Identifier', 1)
37
+ }
38
+
39
+ /**
40
+ * UTF-8 length bound without encoding when the UTF-16 length decides it: each
41
+ * code unit encodes to one to three bytes (a surrogate pair to four).
42
+ */
43
+ function withinBytes(value: string, limit: number): boolean {
44
+ if (value.length > limit) return false
45
+ if (value.length * 3 <= limit) return true
46
+ return encoder.encode(value).byteLength <= limit
47
+ }
48
+
49
+ export function string(value: unknown, field: string, limit = MAX_TEXT_BYTES): string {
50
+ if (typeof value !== 'string' || !withinBytes(value, limit))
51
+ throw new ProtocolError(field + ' must be a string within ' + limit + ' UTF-8 bytes')
52
+ if (!value.isWellFormed()) throw new ProtocolError(field + ' contains an unpaired surrogate')
53
+ if (value.includes('\0')) throw new ProtocolError(field + ' contains a NUL character')
54
+ return value
55
+ }