@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.
package/src/files.ts ADDED
@@ -0,0 +1,295 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { fields, integer, keys, object, string } from './values.ts'
3
+
4
+ export const MAX_FILESYSTEM_ROOTS = 8
5
+ export const MAX_FILE_GRANTS = 32
6
+ const MAX_FILE_PATH_BYTES = 1_024
7
+ const MAX_FILE_PATH_COMPONENTS = 32
8
+ const MAX_FILE_NAME_BYTES = 255
9
+ const MAX_ROOT_PATH_BYTES = 4_096
10
+ export const MAX_FILE_CHUNK_BYTES = 16_384
11
+ export const MAX_DIRECTORY_ENTRIES = 64
12
+ const MAX_FILE_CHUNK_BASE64 = Math.ceil(MAX_FILE_CHUNK_BYTES / 3) * 4
13
+ const FILESYSTEM_ROOT_ARGUMENT = '--bun-native-filesystem-root='
14
+ const encoder = new TextEncoder()
15
+
16
+ export const FILE_ACCESS = Object.freeze(['read', 'read_write'] as const)
17
+ export type FileAccess = (typeof FILE_ACCESS)[number]
18
+
19
+ export interface FilesystemRoot {
20
+ id: string
21
+ path: string
22
+ access: FileAccess
23
+ }
24
+
25
+ export type WriteMode = 'create' | 'truncate' | 'append'
26
+ export type FileKind = 'file' | 'directory' | 'symlink' | 'other'
27
+
28
+ export type FileOperation =
29
+ | { kind: 'stat' }
30
+ | { kind: 'list'; after?: string }
31
+ | { kind: 'read'; offset: number; length: number }
32
+ | { kind: 'write'; mode: WriteMode; data: string }
33
+ | { kind: 'create_directory' }
34
+ | { kind: 'remove' }
35
+
36
+ export interface DirectoryEntry {
37
+ name: string
38
+ kind: FileKind
39
+ }
40
+
41
+ export type FileResult =
42
+ | { kind: 'stat'; file_kind: FileKind; size: number; modified_ms: number }
43
+ | { kind: 'list'; entries: DirectoryEntry[]; complete: boolean; omitted: number }
44
+ | { kind: 'read'; data: string; end_of_file: boolean }
45
+ | { kind: 'written'; bytes: number }
46
+ | { kind: 'done' }
47
+
48
+ export function parseRootId(value: unknown): string {
49
+ if (typeof value !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(value))
50
+ throw new ProtocolError(
51
+ 'Filesystem root ID must be 1-32 lowercase ASCII letters, digits, "-" or "_", starting with a letter'
52
+ )
53
+ return value
54
+ }
55
+
56
+ const normalized = (names: string[]) =>
57
+ names.every((name) => name !== '' && name !== '.' && name !== '..')
58
+
59
+ /** `/` or `/`-joined names. */
60
+ function isPosixRootPath(path: string): boolean {
61
+ return path === '/' || (path.startsWith('/') && normalized(path.slice(1).split('/')))
62
+ }
63
+
64
+ /** `X:\` alone or followed by `\`-joined names without `/` or `:` (no UNC or `\\?\`). */
65
+ function isWindowsRootPath(path: string): boolean {
66
+ if (!/^[A-Za-z]:\\/.test(path)) return false
67
+ const rest = path.slice(3)
68
+ return rest === '' || (!/[/:]/.test(rest) && normalized(rest.split('\\')))
69
+ }
70
+
71
+ /**
72
+ * An absolute normalized POSIX path or Windows drive path. The native host
73
+ * accepts only its own platform's form.
74
+ */
75
+ export function parseRootPath(value: unknown): string {
76
+ const path = string(value, 'filesystem root path', MAX_ROOT_PATH_BYTES)
77
+ if (/\p{Cc}/u.test(path) || !(isPosixRootPath(path) || isWindowsRootPath(path)))
78
+ throw new ProtocolError(
79
+ 'Filesystem root path must be absolute and normalized, without control characters'
80
+ )
81
+ return path
82
+ }
83
+
84
+ export function parseFileAccess(value: unknown): FileAccess {
85
+ if (!FILE_ACCESS.includes(value as FileAccess))
86
+ throw new ProtocolError('File access must be "read" or "read_write"')
87
+ return value as FileAccess
88
+ }
89
+
90
+ const ROOT_KEYS = fields('id', 'path', 'access')
91
+
92
+ function root(value: unknown): FilesystemRoot {
93
+ const data = object(value)
94
+ keys(data, ROOT_KEYS)
95
+ return {
96
+ id: parseRootId(data['id']),
97
+ path: parseRootPath(data['path']),
98
+ access: parseFileAccess(data['access']),
99
+ }
100
+ }
101
+
102
+ export function parseFilesystemRoots(value: unknown): FilesystemRoot[] {
103
+ if (!Array.isArray(value) || value.length > MAX_FILESYSTEM_ROOTS)
104
+ throw new ProtocolError('At most ' + MAX_FILESYSTEM_ROOTS + ' filesystem roots may be granted')
105
+ const roots = value.map(root)
106
+ if (new Set(roots.map((item) => item.id)).size !== roots.length)
107
+ throw new ProtocolError('Filesystem root IDs must be unique')
108
+ return roots
109
+ }
110
+
111
+ /** The reserved native launch argument for one validated root. */
112
+ export function filesystemRootArgument(value: FilesystemRoot): string {
113
+ const item = root(value)
114
+ return FILESYSTEM_ROOT_ARGUMENT + item.access + ':' + item.id + ':' + item.path
115
+ }
116
+
117
+ /** Recover the launch roots from native arguments, in order. */
118
+ export function filesystemRootsFromArguments(argv: readonly string[]): FilesystemRoot[] {
119
+ return parseFilesystemRoots(
120
+ argv
121
+ .filter((argument) => argument.startsWith(FILESYSTEM_ROOT_ARGUMENT))
122
+ .map((argument) => {
123
+ const [access, id, ...path] = argument.slice(FILESYSTEM_ROOT_ARGUMENT.length).split(':')
124
+ return { access, id, path: path.join(':') }
125
+ })
126
+ )
127
+ }
128
+
129
+ function parseFileName(value: unknown): string {
130
+ const name = string(value, 'file name', MAX_FILE_NAME_BYTES)
131
+ if (!name || name === '.' || name === '..' || name.includes('/') || /\p{Cc}/u.test(name))
132
+ throw new ProtocolError(
133
+ 'File name must be 1-255 bytes, not "." or "..", without "/" or control characters'
134
+ )
135
+ return name
136
+ }
137
+
138
+ /** Empty (the grant root) or relative names joined by `/`. */
139
+ export function parseFilePath(value: unknown): string {
140
+ const path = string(value, 'file path', MAX_FILE_PATH_BYTES)
141
+ if (!path) return path
142
+ const names = path.split('/')
143
+ if (names.length > MAX_FILE_PATH_COMPONENTS)
144
+ throw new ProtocolError('File path exceeds ' + MAX_FILE_PATH_COMPONENTS + ' components')
145
+ for (const name of names) parseFileName(name)
146
+ return path
147
+ }
148
+
149
+ export function parseChunk(value: unknown): string {
150
+ if (
151
+ typeof value !== 'string' ||
152
+ value.length > MAX_FILE_CHUNK_BASE64 ||
153
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) ||
154
+ (value.length / 4) * 3 - (value.match(/=*$/)?.[0].length ?? 0) > MAX_FILE_CHUNK_BYTES
155
+ )
156
+ throw new ProtocolError('File data must be padded standard base64 of at most 16384 bytes')
157
+ return value
158
+ }
159
+
160
+ const WRITE_MODES: readonly WriteMode[] = ['create', 'truncate', 'append']
161
+ const FILE_KINDS: readonly FileKind[] = ['file', 'directory', 'symlink', 'other']
162
+
163
+ const FILE_OPERATION_STAT_KEYS = fields('kind')
164
+ const FILE_OPERATION_LIST_KEYS = fields('kind', 'after')
165
+ const FILE_OPERATION_READ_KEYS = fields('kind', 'offset', 'length')
166
+ const FILE_OPERATION_WRITE_KEYS = fields('kind', 'mode', 'data')
167
+
168
+ export function parseFileOperation(value: unknown, path: string): FileOperation {
169
+ const data = object(value)
170
+ const writes = ['write', 'create_directory', 'remove'].includes(data['kind'] as string)
171
+ if (writes && !path)
172
+ throw new ProtocolError('The grant root itself cannot be written, created or removed')
173
+ switch (data['kind']) {
174
+ case 'stat':
175
+ case 'create_directory':
176
+ case 'remove':
177
+ keys(data, FILE_OPERATION_STAT_KEYS)
178
+ return { kind: data['kind'] }
179
+ case 'list':
180
+ keys(data, FILE_OPERATION_LIST_KEYS)
181
+ return 'after' in data
182
+ ? { kind: 'list', after: parseFileName(data['after']) }
183
+ : { kind: 'list' }
184
+ case 'read':
185
+ keys(data, FILE_OPERATION_READ_KEYS)
186
+ return {
187
+ kind: 'read',
188
+ offset: integer(
189
+ data['offset'],
190
+ 'file read offset',
191
+ 0,
192
+ Number.MAX_SAFE_INTEGER - MAX_FILE_CHUNK_BYTES
193
+ ),
194
+ length: integer(data['length'], 'file read length', 1, MAX_FILE_CHUNK_BYTES),
195
+ }
196
+ case 'write': {
197
+ keys(data, FILE_OPERATION_WRITE_KEYS)
198
+ const mode = WRITE_MODES.find((item) => item === data['mode'])
199
+ if (!mode) throw new ProtocolError('Unknown file write mode')
200
+ return { kind: 'write', mode, data: parseChunk(data['data']) }
201
+ }
202
+ default:
203
+ throw new ProtocolError('Unknown file operation')
204
+ }
205
+ }
206
+
207
+ function fileKind(value: unknown): FileKind {
208
+ const kind = FILE_KINDS.find((item) => item === value)
209
+ if (!kind) throw new ProtocolError('Unknown file kind')
210
+ return kind
211
+ }
212
+
213
+ function compareBytes(left: string, right: string): number {
214
+ const a = encoder.encode(left)
215
+ const b = encoder.encode(right)
216
+ for (let index = 0; index < Math.min(a.length, b.length); index++)
217
+ if (a[index] !== b[index]) return a[index]! - b[index]!
218
+ return a.length - b.length
219
+ }
220
+
221
+ const FILE_RESULT_STAT_KEYS = fields('kind', 'file_kind', 'size', 'modified_ms')
222
+ const FILE_RESULT_LIST_KEYS = fields('kind', 'entries', 'complete', 'omitted')
223
+ const FILE_LIST_ENTRY_KEYS = fields('name', 'kind')
224
+ const FILE_RESULT_READ_KEYS = fields('kind', 'data', 'end_of_file')
225
+ const FILE_RESULT_WRITTEN_KEYS = fields('kind', 'bytes')
226
+ const FILE_RESULT_DONE_KEYS = fields('kind')
227
+
228
+ export function parseFileResult(value: unknown): FileResult {
229
+ const data = object(value)
230
+ switch (data['kind']) {
231
+ case 'stat':
232
+ keys(data, FILE_RESULT_STAT_KEYS)
233
+ return {
234
+ kind: 'stat',
235
+ file_kind: fileKind(data['file_kind']),
236
+ size: integer(data['size'], 'file size'),
237
+ modified_ms: integer(
238
+ data['modified_ms'],
239
+ 'file modification time',
240
+ -Number.MAX_SAFE_INTEGER
241
+ ),
242
+ }
243
+ case 'list': {
244
+ keys(data, FILE_RESULT_LIST_KEYS)
245
+ const entries = data['entries']
246
+ if (!Array.isArray(entries) || entries.length > MAX_DIRECTORY_ENTRIES)
247
+ throw new ProtocolError('Directory page exceeds ' + MAX_DIRECTORY_ENTRIES + ' entries')
248
+ const parsed = entries.map((entry) => {
249
+ const item = object(entry)
250
+ keys(item, FILE_LIST_ENTRY_KEYS)
251
+ return { name: parseFileName(item['name']), kind: fileKind(item['kind']) }
252
+ })
253
+ for (let index = 1; index < parsed.length; index++)
254
+ if (compareBytes(parsed[index - 1]!.name, parsed[index]!.name) >= 0)
255
+ throw new ProtocolError('Directory entries must be unique and sorted by name')
256
+ if (typeof data['complete'] !== 'boolean')
257
+ throw new ProtocolError('Directory completeness must be a boolean')
258
+ return {
259
+ kind: 'list',
260
+ entries: parsed,
261
+ complete: data['complete'],
262
+ omitted: integer(data['omitted'], 'omitted directory entries'),
263
+ }
264
+ }
265
+ case 'read':
266
+ keys(data, FILE_RESULT_READ_KEYS)
267
+ if (typeof data['end_of_file'] !== 'boolean')
268
+ throw new ProtocolError('File end-of-file flag must be a boolean')
269
+ return { kind: 'read', data: parseChunk(data['data']), end_of_file: data['end_of_file'] }
270
+ case 'written':
271
+ keys(data, FILE_RESULT_WRITTEN_KEYS)
272
+ return {
273
+ kind: 'written',
274
+ bytes: integer(data['bytes'], 'written bytes', 0, MAX_FILE_CHUNK_BYTES),
275
+ }
276
+ case 'done':
277
+ keys(data, FILE_RESULT_DONE_KEYS)
278
+ return { kind: 'done' }
279
+ default:
280
+ throw new ProtocolError('Unknown file result')
281
+ }
282
+ }
283
+
284
+ /** The result shape each operation produces. */
285
+ export function fileResultAnswers(operation: FileOperation, result: FileResult): boolean {
286
+ const expected = {
287
+ stat: 'stat',
288
+ list: 'list',
289
+ read: 'read',
290
+ write: 'written',
291
+ create_directory: 'done',
292
+ remove: 'done',
293
+ }[operation.kind]
294
+ return result.kind === expected
295
+ }
package/src/framing.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Maximum command frame (runtime to host) in UTF-8 JSON bytes, excluding the
3
+ * newline. One transaction is one batch, so this bounds a full-window mount.
4
+ */
5
+ export const MAX_FRAME_BYTES = 16_777_216
6
+ /** Maximum event frame (host to runtime): one text value or service result. */
7
+ export const MAX_EVENT_FRAME_BYTES = 65_536
8
+ /** Decoder capacity kept between frames; a larger frame borrows memory while it is read. */
9
+ const RETAINED_FRAME_BYTES = 65_536
10
+
11
+ export class ProtocolError extends Error {
12
+ override readonly name = 'ProtocolError'
13
+ }
14
+
15
+ const encoder = new TextEncoder()
16
+
17
+ /** Encodes one JSON value. This does not validate a command or event schema. */
18
+ export function encodeFrame(value: unknown): Uint8Array {
19
+ let json: string | undefined
20
+ try {
21
+ json = JSON.stringify(value)
22
+ } catch (error) {
23
+ throw new ProtocolError('Frame is not a JSON value', { cause: error })
24
+ }
25
+ if (json === undefined) throw new ProtocolError('Frame is not a JSON value')
26
+ // Each UTF-16 code unit encodes to at least one UTF-8 byte.
27
+ if (json.length > MAX_FRAME_BYTES) throw new ProtocolError('Frame exceeds byte limit')
28
+ const encoded = encoder.encode(`${json}\n`)
29
+ if (encoded.byteLength - 1 > MAX_FRAME_BYTES) throw new ProtocolError('Frame exceeds byte limit')
30
+ return encoded
31
+ }
32
+
33
+ /**
34
+ * Incremental, bounded JSON-lines decoder for a native process pipe. `limit`
35
+ * is the largest frame payload it accepts: the runtime reads events with
36
+ * `MAX_EVENT_FRAME_BYTES`; a peer reading commands keeps the default.
37
+ * A protocol error poisons the decoder; the caller must terminate that session.
38
+ * Successful finish closes the decoder and is idempotent.
39
+ */
40
+ export class FrameDecoder {
41
+ private buffer = new Uint8Array(0)
42
+ // Preserve a leading BOM so JSON.parse rejects it, matching Rust serde_json.
43
+ private readonly utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
44
+ private length = 0
45
+ private state: 'open' | 'finished' | 'failed' = 'open'
46
+
47
+ constructor(private readonly limit: number = MAX_FRAME_BYTES) {}
48
+
49
+ /** Append a partial frame, growing the buffer geometrically up to the limit. */
50
+ private append(bytes: Uint8Array): void {
51
+ const length = this.length + bytes.length
52
+ if (length > this.buffer.length) {
53
+ const grown = new Uint8Array(
54
+ Math.min(this.limit, Math.max(length, 2 * this.buffer.length, 4_096))
55
+ )
56
+ grown.set(this.buffer.subarray(0, this.length))
57
+ this.buffer = grown
58
+ }
59
+ this.buffer.set(bytes, this.length)
60
+ this.length = length
61
+ }
62
+
63
+ push(chunk: Uint8Array): unknown[] {
64
+ if (this.state !== 'open')
65
+ throw new ProtocolError(
66
+ this.state === 'failed'
67
+ ? 'Decoder is closed after a protocol error'
68
+ : 'Decoder is closed after end of stream'
69
+ )
70
+ const frames: unknown[] = []
71
+ let start = 0
72
+ try {
73
+ while (start < chunk.length) {
74
+ const newline = chunk.indexOf(10, start)
75
+ const end = newline === -1 ? chunk.length : newline
76
+ const size = end - start
77
+ if (this.length + size > this.limit) throw new ProtocolError('Frame exceeds byte limit')
78
+ if (newline !== -1 && this.length === 0) {
79
+ // A frame wholly inside this chunk decodes in place, without a copy.
80
+ if (size === 0) throw new ProtocolError('Empty frame')
81
+ frames.push(JSON.parse(this.utf8.decode(chunk.subarray(start, end))) as unknown)
82
+ start = newline + 1
83
+ continue
84
+ }
85
+ this.append(chunk.subarray(start, end))
86
+ if (newline === -1) break
87
+ const json = this.utf8.decode(this.buffer.subarray(0, this.length))
88
+ this.length = 0
89
+ if (this.buffer.length > RETAINED_FRAME_BYTES) this.buffer = new Uint8Array(0)
90
+ frames.push(JSON.parse(json) as unknown)
91
+ start = newline + 1
92
+ }
93
+ } catch (error) {
94
+ this.state = 'failed'
95
+ if (error instanceof ProtocolError) throw error
96
+ throw new ProtocolError('Invalid UTF-8 or JSON in native frame', { cause: error })
97
+ }
98
+ return frames
99
+ }
100
+
101
+ finish(): void {
102
+ if (this.state === 'failed') throw new ProtocolError('Decoder is closed after a protocol error')
103
+ if (this.length !== 0) {
104
+ this.state = 'failed'
105
+ throw new ProtocolError('Native stream ended inside a frame')
106
+ }
107
+ this.state = 'finished'
108
+ }
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,136 @@
1
+ export {
2
+ encodeFrame,
3
+ FrameDecoder,
4
+ MAX_EVENT_FRAME_BYTES,
5
+ MAX_FRAME_BYTES,
6
+ ProtocolError,
7
+ } from './framing.ts'
8
+ export {
9
+ DECORATION_FIELDS,
10
+ MAX_OVERLAY_DEPTH,
11
+ parseElement,
12
+ SPLIT_DIVIDER,
13
+ type Element,
14
+ type Style,
15
+ } from './elements.ts'
16
+ export { MAX_TEXT_BYTES } from './values.ts'
17
+ export {
18
+ FILE_ACCESS,
19
+ fileResultAnswers,
20
+ filesystemRootArgument,
21
+ filesystemRootsFromArguments,
22
+ MAX_DIRECTORY_ENTRIES,
23
+ MAX_FILE_CHUNK_BYTES,
24
+ MAX_FILE_GRANTS,
25
+ MAX_FILESYSTEM_ROOTS,
26
+ parseFilePath,
27
+ parseFilesystemRoots,
28
+ parseRootId,
29
+ type FileAccess,
30
+ type FileKind,
31
+ type FileOperation,
32
+ type FileResult,
33
+ type WriteMode,
34
+ } from './files.ts'
35
+ export {
36
+ MAX_PROCESS_ARGS,
37
+ MAX_PROCESS_ENV,
38
+ MAX_PROCESS_EXECUTABLES,
39
+ MAX_PROCESS_OUTPUT_CREDIT,
40
+ MAX_PROCESSES,
41
+ parseProcessExecutables,
42
+ parseProcessLaunch,
43
+ parseProcessLimitsArgument,
44
+ PROCESS_SIGNALS,
45
+ processArguments,
46
+ processLimitsArgument,
47
+ processesFromArguments,
48
+ type ProcessExit,
49
+ type ProcessLaunch,
50
+ type ProcessSignal,
51
+ type ProcessStream,
52
+ type TerminalSize,
53
+ } from './processes.ts'
54
+ export {
55
+ APP_MENU_KEYS,
56
+ MAX_APP_MENU_DEPTH,
57
+ MAX_APP_MENU_ENTRIES,
58
+ parseAppMenus,
59
+ parseContextMenuItems,
60
+ type AppMenu,
61
+ type AppMenuEntry,
62
+ } from './app-menu.ts'
63
+ export {
64
+ FILE_DIALOG_MODES,
65
+ MAX_DIALOG_FILTER_NAME_BYTES,
66
+ MAX_DIALOG_FILTER_PATTERNS,
67
+ MAX_DIALOG_FILTERS,
68
+ MAX_DIALOG_LABEL_BYTES,
69
+ MAX_DIALOG_NAME_BYTES,
70
+ MAX_DIALOG_PATH_BYTES,
71
+ MAX_DIALOG_PATHS,
72
+ MAX_DIALOG_PATTERN_BYTES,
73
+ MAX_DIALOG_TITLE_BYTES,
74
+ MAX_FILE_DIALOGS,
75
+ parseFileDialogOptions,
76
+ type FileDialogMode,
77
+ type FileDialogOptions,
78
+ type FileDialogResult,
79
+ } from './dialogs.ts'
80
+ export {
81
+ MAX_MESSAGE_DIALOG_BUTTONS,
82
+ MAX_MESSAGE_DIALOG_DETAIL_BYTES,
83
+ MAX_MESSAGE_DIALOG_ID_BYTES,
84
+ MAX_MESSAGE_DIALOG_LABEL_BYTES,
85
+ MAX_MESSAGE_DIALOG_MESSAGE_BYTES,
86
+ MAX_MESSAGE_DIALOG_TITLE_BYTES,
87
+ type MessageDialogButton,
88
+ type MessageDialogOptions,
89
+ type MessageDialogResult,
90
+ } from './message-dialogs.ts'
91
+ export {
92
+ DRAG_STATES,
93
+ DROP_REJECTIONS,
94
+ MAX_DROP_ITEMS,
95
+ MAX_DROP_NAME_BYTES,
96
+ MAX_DROP_TARGETS,
97
+ type DropRejection,
98
+ } from './drops.ts'
99
+ export {
100
+ GLOBAL_SHORTCUT_KEYS,
101
+ MAX_CLIPBOARD_BYTES,
102
+ MAX_GLOBAL_SHORTCUTS,
103
+ MAX_NOTIFICATION_BODY_BYTES,
104
+ MAX_NOTIFICATION_TITLE_BYTES,
105
+ MAX_NOTIFICATIONS,
106
+ NOTIFICATION_DISMISSALS,
107
+ type ClipboardText,
108
+ type DisplaySnapshot,
109
+ type GlobalShortcutKey,
110
+ type NotificationDismissal,
111
+ type ServicePolicy,
112
+ type ShortcutModifiers,
113
+ } from './services.ts'
114
+ export {
115
+ DEFAULT_LIMITS,
116
+ ERROR_CODES,
117
+ MAX_OPERATIONS,
118
+ MAX_SCROLL_OFFSET,
119
+ parseCommand,
120
+ parseEvent,
121
+ PROTOCOL_VERSION,
122
+ REQUIRED_CAPABILITIES,
123
+ type Command,
124
+ type ApplicationEvent,
125
+ type HostEvent,
126
+ type NativeEvent,
127
+ type WindowEvent,
128
+ type Operation,
129
+ type Limits,
130
+ type ErrorCode,
131
+ type Capability,
132
+ type KeyModifiers,
133
+ type PointerButton,
134
+ type DismissReason,
135
+ type ResizeSource,
136
+ } from './messages.ts'
@@ -0,0 +1,131 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { fields, keys, object, string } from './values.ts'
3
+
4
+ export const MAX_MESSAGE_DIALOG_BUTTONS = 4
5
+ export const MAX_MESSAGE_DIALOG_TITLE_BYTES = 256
6
+ export const MAX_MESSAGE_DIALOG_MESSAGE_BYTES = 2_048
7
+ export const MAX_MESSAGE_DIALOG_DETAIL_BYTES = 4_096
8
+ export const MAX_MESSAGE_DIALOG_LABEL_BYTES = 64
9
+ export const MAX_MESSAGE_DIALOG_ID_BYTES = 64
10
+
11
+ export interface MessageDialogButton {
12
+ id: string
13
+ label: string
14
+ }
15
+
16
+ export interface MessageDialogOptions {
17
+ title: string
18
+ message: string
19
+ detail?: string
20
+ buttons: readonly MessageDialogButton[]
21
+ default_button: string
22
+ cancel_button?: string
23
+ dismissible?: boolean
24
+ }
25
+
26
+ export type MessageDialogResult =
27
+ | { kind: 'button'; id: string }
28
+ | { kind: 'dismissed' }
29
+ | { kind: 'failed' }
30
+
31
+ /** Nonblank text; a block may contain line feeds, a line may not. */
32
+ function text(value: unknown, field: string, limit: number, block: boolean): string {
33
+ const result = string(value, field, limit)
34
+ if (!result.trim()) throw new ProtocolError(field + ' must be nonblank')
35
+ if ((block ? /[^\P{Cc}\n]/u : /\p{Cc}/u).test(result))
36
+ throw new ProtocolError(field + ' contains a control character')
37
+ return result
38
+ }
39
+
40
+ const BUTTON_KEYS = fields('id', 'label')
41
+
42
+ function button(value: unknown): MessageDialogButton {
43
+ const data = object(value)
44
+ keys(data, BUTTON_KEYS)
45
+ return {
46
+ id: text(data['id'], 'message dialog button id', MAX_MESSAGE_DIALOG_ID_BYTES, false),
47
+ label: text(
48
+ data['label'],
49
+ 'message dialog button label',
50
+ MAX_MESSAGE_DIALOG_LABEL_BYTES,
51
+ false
52
+ ),
53
+ }
54
+ }
55
+
56
+ const MESSAGE_DIALOG_OPTIONS_KEYS = fields(
57
+ 'title',
58
+ 'message',
59
+ 'detail',
60
+ 'buttons',
61
+ 'default_button',
62
+ 'cancel_button',
63
+ 'dismissible'
64
+ )
65
+
66
+ export function parseMessageDialogOptions(value: unknown): MessageDialogOptions {
67
+ const data = object(value)
68
+ keys(data, MESSAGE_DIALOG_OPTIONS_KEYS)
69
+ const list = data['buttons']
70
+ if (!Array.isArray(list) || list.length < 1 || list.length > MAX_MESSAGE_DIALOG_BUTTONS)
71
+ throw new ProtocolError('A message dialog needs between 1 and 4 buttons')
72
+ const buttons = list.map(button)
73
+ const ids = new Set(buttons.map((entry) => entry.id))
74
+ if (ids.size !== buttons.length)
75
+ throw new ProtocolError('Message dialog button ids must be unique')
76
+ const reference = (field: string) => {
77
+ const name = string(data[field], 'message dialog ' + field, MAX_MESSAGE_DIALOG_ID_BYTES)
78
+ if (!ids.has(name))
79
+ throw new ProtocolError('Message dialog ' + field + ' must name one of its buttons')
80
+ return name
81
+ }
82
+ const options: MessageDialogOptions = {
83
+ title: text(data['title'], 'message dialog title', MAX_MESSAGE_DIALOG_TITLE_BYTES, false),
84
+ message: text(
85
+ data['message'],
86
+ 'message dialog message',
87
+ MAX_MESSAGE_DIALOG_MESSAGE_BYTES,
88
+ true
89
+ ),
90
+ buttons,
91
+ default_button: reference('default_button'),
92
+ }
93
+ if ('detail' in data)
94
+ options.detail = text(
95
+ data['detail'],
96
+ 'message dialog detail',
97
+ MAX_MESSAGE_DIALOG_DETAIL_BYTES,
98
+ true
99
+ )
100
+ if ('cancel_button' in data) options.cancel_button = reference('cancel_button')
101
+ if ('dismissible' in data) {
102
+ if (typeof data['dismissible'] !== 'boolean')
103
+ throw new ProtocolError('Message dialog dismissible must be a boolean')
104
+ if (data['dismissible']) options.dismissible = true
105
+ }
106
+ return options
107
+ }
108
+
109
+ const MESSAGE_DIALOG_RESULT_BUTTON_KEYS = fields('kind', 'id')
110
+ const MESSAGE_DIALOG_RESULT_DISMISSED_KEYS = fields('kind')
111
+ const MESSAGE_DIALOG_RESULT_FAILED_KEYS = fields('kind')
112
+
113
+ export function parseMessageDialogResult(value: unknown): MessageDialogResult {
114
+ const data = object(value)
115
+ switch (data['kind']) {
116
+ case 'button':
117
+ keys(data, MESSAGE_DIALOG_RESULT_BUTTON_KEYS)
118
+ return {
119
+ kind: 'button',
120
+ id: text(data['id'], 'message dialog result id', MAX_MESSAGE_DIALOG_ID_BYTES, false),
121
+ }
122
+ case 'dismissed':
123
+ keys(data, MESSAGE_DIALOG_RESULT_DISMISSED_KEYS)
124
+ return { kind: 'dismissed' }
125
+ case 'failed':
126
+ keys(data, MESSAGE_DIALOG_RESULT_FAILED_KEYS)
127
+ return { kind: 'failed' }
128
+ default:
129
+ throw new ProtocolError('Unknown message dialog result')
130
+ }
131
+ }