@bun-native/protocol 0.0.0-stage → 0.33.1

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,263 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { parseChunk, parseFilePath, parseRootId, parseRootPath } from './files.ts'
3
+ import { fields, 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
+ const PROCESS_LIMITS_KEYS = fields(...LIMIT_NAMES)
52
+
53
+ function parseProcessLimits(value: unknown): ProcessLimits {
54
+ const data = object(value)
55
+ keys(data, PROCESS_LIMITS_KEYS)
56
+ const limits: ProcessLimits = {}
57
+ for (const name of LIMIT_NAMES)
58
+ if (name in data) {
59
+ const [min, max] = LIMIT_BOUNDS[name]
60
+ limits[name] = integer(data[name], 'process ' + name, min, max)
61
+ }
62
+ if (Object.keys(limits).length === 0)
63
+ throw new ProtocolError('Process limits must set at least one limit')
64
+ return limits
65
+ }
66
+
67
+ /** `cpu_seconds=N,memory_bytes=N,open_files=N`, only the set limits, in that order. */
68
+ export function processLimitsArgument(limits: ProcessLimits): string {
69
+ const parsed = parseProcessLimits(limits)
70
+ return LIMIT_NAMES.filter((name) => parsed[name] !== undefined)
71
+ .map((name) => name + '=' + parsed[name])
72
+ .join(',')
73
+ }
74
+
75
+ /** Parses `processLimitsArgument`'s form strictly, as the native host does. */
76
+ export function parseProcessLimitsArgument(text: string): ProcessLimits {
77
+ const data: Record<string, number> = {}
78
+ let last = -1
79
+ for (const part of text.split(',')) {
80
+ const match = /^([a-z_]+)=(0|[1-9][0-9]{0,19})$/.exec(part)
81
+ const order = LIMIT_NAMES.indexOf(match?.[1] as (typeof LIMIT_NAMES)[number])
82
+ if (!match || order <= last)
83
+ throw new ProtocolError('Process limits must be cpu_seconds=N,memory_bytes=N,open_files=N')
84
+ last = order
85
+ data[match[1]!] = Number(match[2])
86
+ }
87
+ return parseProcessLimits(data)
88
+ }
89
+
90
+ export interface TerminalSize {
91
+ columns: number
92
+ rows: number
93
+ }
94
+
95
+ export interface ProcessLaunch {
96
+ executable: string
97
+ args: string[]
98
+ env: Record<string, string>
99
+ cwd?: { grant_id: number; path: string }
100
+ terminal?: TerminalSize
101
+ }
102
+
103
+ export const PROCESS_SIGNALS = Object.freeze(['interrupt', 'terminate', 'kill', 'hangup'] as const)
104
+ export type ProcessSignal = (typeof PROCESS_SIGNALS)[number]
105
+ export type ProcessStream = 'stdout' | 'stderr' | 'terminal'
106
+ export type ProcessExit = { code: number } | { signal: number }
107
+
108
+ const EXECUTABLE_KEYS = fields('id', 'executable', 'limits')
109
+
110
+ function executable(value: unknown): ProcessExecutable {
111
+ const data = object(value)
112
+ keys(data, EXECUTABLE_KEYS)
113
+ const path = parseRootPath(data['executable'])
114
+ // `/` and `X:\` are the only normalized paths ending in a separator.
115
+ if (path.endsWith('/') || path.endsWith('\\'))
116
+ throw new ProtocolError('Process executable must name a file')
117
+ const entry: ProcessExecutable = { id: parseRootId(data['id']), executable: path }
118
+ if ('limits' in data) entry.limits = parseProcessLimits(data['limits'])
119
+ return entry
120
+ }
121
+
122
+ export function parseProcessExecutables(value: unknown): ProcessExecutable[] {
123
+ if (!Array.isArray(value) || value.length > MAX_PROCESS_EXECUTABLES)
124
+ throw new ProtocolError(
125
+ 'At most ' + MAX_PROCESS_EXECUTABLES + ' process executables may be granted'
126
+ )
127
+ const entries = value.map(executable)
128
+ if (new Set(entries.map((entry) => entry.id)).size !== entries.length)
129
+ throw new ProtocolError('Process executable IDs must be unique')
130
+ return entries
131
+ }
132
+
133
+ /**
134
+ * The reserved native launch arguments for one validated executable: the
135
+ * executable itself, then its limits when it has any.
136
+ */
137
+ export function processArguments(value: ProcessExecutable): string[] {
138
+ const entry = executable(value)
139
+ const argv = [PROCESS_ARGUMENT + entry.id + ':' + entry.executable]
140
+ if (entry.limits)
141
+ argv.push(PROCESS_LIMITS_ARGUMENT + entry.id + ':' + processLimitsArgument(entry.limits))
142
+ return argv
143
+ }
144
+
145
+ export function processesFromArguments(argv: readonly string[]): ProcessExecutable[] {
146
+ // One pass, as the host parses them: limits name an executable granted by
147
+ // an earlier argument, at most once.
148
+ const entries: ProcessExecutable[] = []
149
+ for (const argument of argv) {
150
+ if (argument.startsWith(PROCESS_ARGUMENT)) {
151
+ const [entry, ...path] = argument.slice(PROCESS_ARGUMENT.length).split(':')
152
+ entries.push({ id: entry!, executable: path.join(':') })
153
+ } else if (argument.startsWith(PROCESS_LIMITS_ARGUMENT)) {
154
+ const text = argument.slice(PROCESS_LIMITS_ARGUMENT.length)
155
+ const separator = text.indexOf(':')
156
+ const entry = entries.find((item) => item.id === text.slice(0, separator))
157
+ if (separator < 0 || !entry || entry.limits)
158
+ throw new ProtocolError('Process limits must name one granted executable once')
159
+ entry.limits = parseProcessLimitsArgument(text.slice(separator + 1))
160
+ }
161
+ }
162
+ return parseProcessExecutables(entries)
163
+ }
164
+
165
+ function plain(value: unknown, limit: number, field: string): string {
166
+ if (
167
+ typeof value !== 'string' ||
168
+ !value.isWellFormed() ||
169
+ value.includes('\0') ||
170
+ bytes(value) > limit
171
+ )
172
+ throw new ProtocolError(field + ' must be a NUL-free string of at most ' + limit + ' bytes')
173
+ return value
174
+ }
175
+
176
+ const TERMINAL_SIZE_KEYS = fields('columns', 'rows')
177
+
178
+ export function parseTerminalSize(value: unknown): TerminalSize {
179
+ const data = object(value)
180
+ keys(data, TERMINAL_SIZE_KEYS)
181
+ return {
182
+ columns: integer(data['columns'], 'terminal columns', 1, MAX_TERMINAL_DIMENSION),
183
+ rows: integer(data['rows'], 'terminal rows', 1, MAX_TERMINAL_DIMENSION),
184
+ }
185
+ }
186
+
187
+ function parseEnvName(value: string): string {
188
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value) || value.length > MAX_PROCESS_ENV_NAME_BYTES)
189
+ throw new ProtocolError(
190
+ 'Environment names must be ASCII letters, digits and "_", not starting with a digit'
191
+ )
192
+ return value
193
+ }
194
+
195
+ const PROCESS_LAUNCH_KEYS = fields('executable', 'args', 'env', 'cwd', 'terminal')
196
+ const PROCESS_CWD_KEYS = fields('grant_id', 'path')
197
+
198
+ export function parseProcessLaunch(value: unknown): ProcessLaunch {
199
+ const data = object(value)
200
+ keys(data, PROCESS_LAUNCH_KEYS)
201
+ const args = data['args']
202
+ if (!Array.isArray(args) || args.length > MAX_PROCESS_ARGS)
203
+ throw new ProtocolError('Process arguments must be an array of at most ' + MAX_PROCESS_ARGS)
204
+ const parsedArgs = args.map((item) => plain(item, MAX_PROCESS_ARG_BYTES, 'Process argument'))
205
+ const env = object(data['env'])
206
+ const names = Object.keys(env)
207
+ if (names.length > MAX_PROCESS_ENV)
208
+ throw new ProtocolError('At most ' + MAX_PROCESS_ENV + ' environment variables may be set')
209
+ const parsedEnv: Record<string, string> = {}
210
+ for (const name of names)
211
+ parsedEnv[parseEnvName(name)] = plain(
212
+ env[name],
213
+ MAX_PROCESS_ENV_VALUE_BYTES,
214
+ 'Environment value'
215
+ )
216
+ const total =
217
+ parsedArgs.reduce((sum, item) => sum + bytes(item), 0) +
218
+ names.reduce((sum, name) => sum + bytes(name) + bytes(parsedEnv[name]!), 0)
219
+ if (total > MAX_PROCESS_LAUNCH_BYTES)
220
+ throw new ProtocolError('Process arguments and environment exceed 32768 bytes')
221
+ const launch: ProcessLaunch = {
222
+ executable: parseRootId(data['executable']),
223
+ args: parsedArgs,
224
+ env: parsedEnv,
225
+ }
226
+ if ('cwd' in data) {
227
+ const cwd = object(data['cwd'])
228
+ keys(cwd, PROCESS_CWD_KEYS)
229
+ launch.cwd = { grant_id: id(cwd['grant_id']), path: parseFilePath(cwd['path']) }
230
+ }
231
+ if ('terminal' in data) launch.terminal = parseTerminalSize(data['terminal'])
232
+ return launch
233
+ }
234
+
235
+ export function parseProcessSignal(value: unknown): ProcessSignal {
236
+ if (!PROCESS_SIGNALS.includes(value as ProcessSignal))
237
+ throw new ProtocolError('Unknown process signal')
238
+ return value as ProcessSignal
239
+ }
240
+
241
+ export function parseProcessStream(value: unknown): ProcessStream {
242
+ if (value !== 'stdout' && value !== 'stderr' && value !== 'terminal')
243
+ throw new ProtocolError('Unknown process stream')
244
+ return value
245
+ }
246
+
247
+ export function parseProcessOutput(value: unknown): string {
248
+ const data = parseChunk(value)
249
+ if (!data) throw new ProtocolError('Process output chunks are nonempty')
250
+ return data
251
+ }
252
+
253
+ const PROCESS_EXIT_KEYS = fields('code', 'signal')
254
+
255
+ export function parseProcessExit(value: unknown): ProcessExit {
256
+ const data = object(value)
257
+ keys(data, PROCESS_EXIT_KEYS)
258
+ if ('code' in data && !('signal' in data))
259
+ return { code: integer(data['code'], 'exit code', 0, 255) }
260
+ if ('signal' in data && !('code' in data))
261
+ return { signal: integer(data['signal'], 'exit signal', 1, 64) }
262
+ throw new ProtocolError('A process exit has exactly one exit code or signal')
263
+ }
@@ -0,0 +1,208 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { fields, 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
+ const SHORTCUT_MODIFIERS_KEYS = fields('control', 'alt', 'shift', 'platform')
57
+
58
+ export function parseShortcutModifiers(value: unknown): ShortcutModifiers {
59
+ const data = object(value)
60
+ keys(data, SHORTCUT_MODIFIERS_KEYS)
61
+ const flag = (key: keyof ShortcutModifiers) => {
62
+ if (typeof data[key] !== 'boolean')
63
+ throw new ProtocolError('Global shortcut modifiers must state each modifier explicitly')
64
+ return data[key]
65
+ }
66
+ const modifiers = {
67
+ control: flag('control'),
68
+ alt: flag('alt'),
69
+ shift: flag('shift'),
70
+ platform: flag('platform'),
71
+ }
72
+ if (!modifiers.control && !modifiers.alt && !modifiers.platform)
73
+ throw new ProtocolError('Global shortcut requires control, alt or platform')
74
+ return modifiers
75
+ }
76
+
77
+ export function parseShortcutKey(value: unknown): string {
78
+ if (typeof value !== 'string' || !(GLOBAL_SHORTCUT_KEYS as readonly string[]).includes(value))
79
+ throw new ProtocolError('Global shortcut key must be one of a-z, 0-9 or f1-f12')
80
+ return value
81
+ }
82
+
83
+ /** Plain-text notification content: one nonempty title line and a body that
84
+ * may hold line feeds but no other control characters. Neither is markup. */
85
+ export function parseNotificationContent(title: unknown, body: unknown) {
86
+ const parsedTitle = string(title, 'notification title', MAX_NOTIFICATION_TITLE_BYTES)
87
+ if (!parsedTitle || /\p{Cc}/u.test(parsedTitle))
88
+ throw new ProtocolError('Notification title must be nonempty and contain no control characters')
89
+ const parsedBody = string(body, 'notification body', MAX_NOTIFICATION_BODY_BYTES)
90
+ if (/[^\P{Cc}\n]/u.test(parsedBody))
91
+ throw new ProtocolError(
92
+ 'Notification body may contain no control characters other than line feeds'
93
+ )
94
+ return { title: parsedTitle, body: parsedBody }
95
+ }
96
+
97
+ export const NOTIFICATION_DISMISSALS = Object.freeze(['expired', 'dismissed', 'closed'] as const)
98
+ export type NotificationDismissal = (typeof NOTIFICATION_DISMISSALS)[number]
99
+
100
+ export function parseNotificationDismissal(value: unknown): NotificationDismissal {
101
+ if (!NOTIFICATION_DISMISSALS.includes(value as NotificationDismissal))
102
+ throw new ProtocolError('Unknown notification dismissal reason')
103
+ return value as NotificationDismissal
104
+ }
105
+
106
+ export type ClipboardText = { kind: 'empty' } | { kind: 'text'; text: string }
107
+
108
+ const CLIPBOARD_TEXT_EMPTY_KEYS = fields('kind')
109
+ const CLIPBOARD_TEXT_READ_KEYS = fields('kind', 'text')
110
+
111
+ export function parseClipboardText(value: unknown): ClipboardText {
112
+ const data = object(value)
113
+ if (data['kind'] === 'empty') {
114
+ keys(data, CLIPBOARD_TEXT_EMPTY_KEYS)
115
+ return { kind: 'empty' }
116
+ }
117
+ keys(data, CLIPBOARD_TEXT_READ_KEYS)
118
+ if (data['kind'] !== 'text') throw new ProtocolError('Unknown clipboard text result')
119
+ return { kind: 'text', text: string(data['text'], 'clipboard text', MAX_CLIPBOARD_BYTES) }
120
+ }
121
+
122
+ interface DisplayBounds {
123
+ x: number
124
+ y: number
125
+ width: number
126
+ height: number
127
+ }
128
+
129
+ export interface DisplaySnapshot {
130
+ bounds: DisplayBounds
131
+ scale_factor: number
132
+ /** Work area in the logical coordinates of `bounds` (`display_details_v1` only). */
133
+ visible_bounds?: DisplayBounds
134
+ /** Platform display identifier, lowercase hyphenated (`display_identity_v1` only). */
135
+ display_uuid?: string
136
+ }
137
+
138
+ const DISPLAY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
139
+
140
+ const DISPLAY_BOUNDS_KEYS = fields('x', 'y', 'width', 'height')
141
+
142
+ function displayBounds(value: unknown, name: string): DisplayBounds {
143
+ const bounds = object(value)
144
+ keys(bounds, DISPLAY_BOUNDS_KEYS)
145
+ return {
146
+ x: number(bounds['x'], name + ' x', -1_048_576, 1_048_576),
147
+ y: number(bounds['y'], name + ' y', -1_048_576, 1_048_576),
148
+ width: number(bounds['width'], name + ' width', Number.MIN_VALUE, 1_048_576),
149
+ height: number(bounds['height'], name + ' height', Number.MIN_VALUE, 1_048_576),
150
+ }
151
+ }
152
+
153
+ const SERVICE_POLICY_KEYS = fields(
154
+ 'window_display',
155
+ 'clipboard_read',
156
+ 'clipboard_write',
157
+ 'global_shortcuts',
158
+ 'notifications',
159
+ 'drag_drop',
160
+ 'filesystem_roots',
161
+ 'processes',
162
+ 'file_dialogs',
163
+ 'app_menu',
164
+ 'task_dialogs'
165
+ )
166
+
167
+ export function parseServicePolicy(value: unknown): ServicePolicy {
168
+ const data = object(value)
169
+ keys(data, SERVICE_POLICY_KEYS)
170
+ const grant = (key: keyof ServicePolicy) => {
171
+ if (typeof data[key] !== 'boolean')
172
+ throw new ProtocolError('Native service policy must explicitly grant or deny each operation')
173
+ return data[key]
174
+ }
175
+ return {
176
+ window_display: grant('window_display'),
177
+ clipboard_read: grant('clipboard_read'),
178
+ clipboard_write: grant('clipboard_write'),
179
+ global_shortcuts: grant('global_shortcuts'),
180
+ notifications: grant('notifications'),
181
+ drag_drop: grant('drag_drop'),
182
+ filesystem_roots: parseFilesystemRoots(data['filesystem_roots']),
183
+ processes: parseProcessExecutables(data['processes']),
184
+ ...('file_dialogs' in data ? { file_dialogs: grant('file_dialogs') } : {}),
185
+ ...('app_menu' in data ? { app_menu: grant('app_menu') } : {}),
186
+ ...('task_dialogs' in data ? { task_dialogs: grant('task_dialogs') } : {}),
187
+ }
188
+ }
189
+
190
+ const DISPLAY_SNAPSHOT_KEYS = fields('bounds', 'scale_factor', 'visible_bounds', 'display_uuid')
191
+
192
+ export function parseDisplaySnapshot(value: unknown): DisplaySnapshot {
193
+ const data = object(value)
194
+ keys(data, DISPLAY_SNAPSHOT_KEYS)
195
+ const snapshot: DisplaySnapshot = {
196
+ bounds: displayBounds(data['bounds'], 'display'),
197
+ scale_factor: number(data['scale_factor'], 'display scale factor', 0.125, 16),
198
+ }
199
+ if ('visible_bounds' in data)
200
+ snapshot.visible_bounds = displayBounds(data['visible_bounds'], 'display work area')
201
+ if ('display_uuid' in data) {
202
+ const uuid = string(data['display_uuid'], 'display UUID', 36)
203
+ if (!DISPLAY_UUID.test(uuid))
204
+ throw new ProtocolError('Native display identity must be a lowercase hyphenated UUID')
205
+ snapshot.display_uuid = uuid
206
+ }
207
+ return snapshot
208
+ }
package/src/values.ts ADDED
@@ -0,0 +1,61 @@
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
+ /** A message's accepted property names, hoisted to module scope so validation allocates nothing. */
14
+ export function fields(...names: readonly string[]): ReadonlySet<string> {
15
+ return new Set(names)
16
+ }
17
+
18
+ /** Reject own enumerable properties outside `allowed`; inherited keys are ignored. */
19
+ export function keys(value: Record<string, unknown>, allowed: ReadonlySet<string>): void {
20
+ for (const key of Object.keys(value))
21
+ if (!allowed.has(key)) throw new ProtocolError('Unknown message property: ' + key)
22
+ }
23
+
24
+ export function number(value: unknown, field: string, min: number, max: number): number {
25
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max)
26
+ throw new ProtocolError(field + ' must be finite and between ' + min + ' and ' + max)
27
+ return value
28
+ }
29
+
30
+ export function integer(
31
+ value: unknown,
32
+ field: string,
33
+ min = 0,
34
+ max = Number.MAX_SAFE_INTEGER
35
+ ): number {
36
+ const result = number(value, field, min, max)
37
+ if (!Number.isSafeInteger(result)) throw new ProtocolError(field + ' must be a safe integer')
38
+ return result
39
+ }
40
+
41
+ export function id(value: unknown): number {
42
+ return integer(value, 'Identifier', 1)
43
+ }
44
+
45
+ /**
46
+ * UTF-8 length bound without encoding when the UTF-16 length decides it: each
47
+ * code unit encodes to one to three bytes (a surrogate pair to four).
48
+ */
49
+ function withinBytes(value: string, limit: number): boolean {
50
+ if (value.length > limit) return false
51
+ if (value.length * 3 <= limit) return true
52
+ return encoder.encode(value).byteLength <= limit
53
+ }
54
+
55
+ export function string(value: unknown, field: string, limit = MAX_TEXT_BYTES): string {
56
+ if (typeof value !== 'string' || !withinBytes(value, limit))
57
+ throw new ProtocolError(field + ' must be a string within ' + limit + ' UTF-8 bytes')
58
+ if (!value.isWellFormed()) throw new ProtocolError(field + ' contains an unpaired surrogate')
59
+ if (value.includes('\0')) throw new ProtocolError(field + ' contains a NUL character')
60
+ return value
61
+ }