@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bunly contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bun-native/protocol
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The private native protocol shared by Bun Native packages and hosts.
4
+
5
+ Part of [Bun Native](https://github.com/0xPlayerOne/bun-native), an experimental developer preview. Version 0.33.0 was built from [fa8bef754122](https://github.com/0xPlayerOne/bun-native/tree/fa8bef754122621d4235eb06e30bb1085b4f7b1e) and published from GitHub Actions with npm provenance. Use the Bun revision pinned in that commit's toolchain.json. Start with the [getting-started guide](https://github.com/0xPlayerOne/bun-native/blob/fa8bef754122621d4235eb06e30bb1085b4f7b1e/docs/getting-started.md); `bun-native host install` fetches the matching release host. The license notice is in LICENSE.
@@ -0,0 +1,75 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "commit": "fa8bef754122621d4235eb06e30bb1085b4f7b1e",
4
+ "tree": "f805e2eb19a4e10fc29d1dc827cf5bc617b5773b",
5
+ "files": {
6
+ "package.json": {
7
+ "bytes": 2164,
8
+ "sha256": "db50b6f81f25c113a46f8c9b038bc548a92effaeb4ad9562e5e95044baa7b66f"
9
+ },
10
+ "packages/protocol/package.json": {
11
+ "bytes": 129,
12
+ "sha256": "668f52c10a6db801c2d0c68c59998c489b50c0bb79cb7f60298dfbb83923b0ba"
13
+ },
14
+ "packages/protocol/src/app-menu.ts": {
15
+ "bytes": 6435,
16
+ "sha256": "52ec565f7423671a13e3bc43d49129496ed9869f22b4fa1b7e3eba79191137b8"
17
+ },
18
+ "packages/protocol/src/dialogs.ts": {
19
+ "bytes": 4896,
20
+ "sha256": "c2ea74d7175c75710146c788b261e5566301da5952359b8e50bc69a323cdef00"
21
+ },
22
+ "packages/protocol/src/drops.ts": {
23
+ "bytes": 1968,
24
+ "sha256": "478b9952e406853b22e32e8b5c227fdc5936363665d984c628d18581de112191"
25
+ },
26
+ "packages/protocol/src/elements.ts": {
27
+ "bytes": 16161,
28
+ "sha256": "3529a4f7fefa9d56880a5f32be83b7daea9ad492784478ddc413d2deb85f5283"
29
+ },
30
+ "packages/protocol/src/files.ts": {
31
+ "bytes": 10163,
32
+ "sha256": "d4d890f22b6abdc713e544a4e3f7b0ed53df4a1d5f44044febea8d8cf9ca96a5"
33
+ },
34
+ "packages/protocol/src/framing.ts": {
35
+ "bytes": 3259,
36
+ "sha256": "47d2473785f245d8f85bd1447fba3f7ed47d5ddac170f590df5c83623064158a"
37
+ },
38
+ "packages/protocol/src/index.ts": {
39
+ "bytes": 3388,
40
+ "sha256": "7c7fd785b5037bc74ce0594f0ec5c49fca7ef9ae0b4dbab8fbf83d8427f6ead8"
41
+ },
42
+ "packages/protocol/src/message-dialogs.ts": {
43
+ "bytes": 3997,
44
+ "sha256": "551dc23a2fde7771c28e485212d670f37db199da6f2f006344b0dc6b9064ef2b"
45
+ },
46
+ "packages/protocol/src/messages.ts": {
47
+ "bytes": 43791,
48
+ "sha256": "cef29b0d2fa4b84d73df5c9b8abbf321dafc6a66d688ad00838f0c8aa50a1d03"
49
+ },
50
+ "packages/protocol/src/processes.ts": {
51
+ "bytes": 9709,
52
+ "sha256": "80f109623ec5f17bc21fb20c4e3093829124839c6c9e5b2457120bff0a45c778"
53
+ },
54
+ "packages/protocol/src/services.ts": {
55
+ "bytes": 7587,
56
+ "sha256": "eb128b1ef12d2650acc53b71c2c802f35d3fb983d1f584aa1d81251adfccd37b"
57
+ },
58
+ "packages/protocol/src/values.ts": {
59
+ "bytes": 2078,
60
+ "sha256": "75d8f7ee9772c1aa2929a9dc4c416f42569795db089d17321398c3cdf482731b"
61
+ },
62
+ "LICENSE": {
63
+ "bytes": 1075,
64
+ "sha256": "86673fb485f12fa9aaa3941dfccc9f69a7f8322f8141d0d5c73db4323de10ac5"
65
+ }
66
+ },
67
+ "toolchain": {
68
+ "bytes": 3012,
69
+ "sha256": "00c3f707aae8abe9ca75cd2e162f5e1c7c34362a03675da39afead60d856066a"
70
+ },
71
+ "dependencyLock": {
72
+ "bytes": 28357,
73
+ "sha256": "65e3dbd01be4a1889367690faf1a9c3f08fb31d22dbd273d9bb9f39dd6baa6fb"
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@bun-native/protocol",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.33.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "files": [
7
+ "src",
8
+ "LICENSE",
9
+ "README.md",
10
+ "bun-native-source.json"
11
+ ],
12
+ "engines": {
13
+ "bun": "1.4.4-canary.1"
14
+ },
15
+ "exports": "./src/index.ts",
16
+ "description": "The private native protocol shared by Bun Native packages and hosts.",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/0xPlayerOne/bun-native.git",
20
+ "directory": "packages/protocol"
21
+ },
22
+ "homepage": "https://github.com/0xPlayerOne/bun-native#readme",
23
+ "bugs": "https://github.com/0xPlayerOne/bun-native/issues",
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ }
28
+ }
@@ -0,0 +1,209 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { keys, object, string } from './values.ts'
3
+
4
+ /** Top-level menus in one menu bar. */
5
+ export const MAX_APP_MENUS = 16
6
+ /** Items, separators and submenus in one menu bar, at every depth. */
7
+ export const MAX_APP_MENU_ENTRIES = 128
8
+ /** A top-level menu is depth 1; submenus nest at most two levels below it. */
9
+ export const MAX_APP_MENU_DEPTH = 3
10
+ export const MAX_APP_MENU_LABEL_BYTES = 64
11
+ export const MAX_APP_MENU_ITEM_ID_BYTES = 64
12
+
13
+ export const APP_MENU_NAMED_KEYS = Object.freeze([
14
+ 'enter',
15
+ 'escape',
16
+ 'tab',
17
+ 'space',
18
+ 'backspace',
19
+ 'delete',
20
+ 'up',
21
+ 'down',
22
+ 'left',
23
+ 'right',
24
+ 'home',
25
+ 'end',
26
+ 'pageup',
27
+ 'pagedown',
28
+ ] as const)
29
+ export const APP_MENU_PUNCTUATION_KEYS = Object.freeze([
30
+ ',',
31
+ '.',
32
+ '/',
33
+ ';',
34
+ "'",
35
+ '[',
36
+ ']',
37
+ '-',
38
+ '=',
39
+ '`',
40
+ '\\',
41
+ ] as const)
42
+
43
+ /** The closed key set of a menu shortcut. */
44
+ export const APP_MENU_KEYS = Object.freeze([
45
+ ...'abcdefghijklmnopqrstuvwxyz0123456789',
46
+ ...Array.from({ length: 12 }, (_, index) => `f${index + 1}`),
47
+ ...APP_MENU_PUNCTUATION_KEYS,
48
+ ...APP_MENU_NAMED_KEYS,
49
+ ]) as readonly string[]
50
+
51
+ export interface MenuShortcutModifiers {
52
+ control: boolean
53
+ alt: boolean
54
+ shift: boolean
55
+ /** Command on macOS. */
56
+ platform: boolean
57
+ }
58
+
59
+ export interface MenuShortcut {
60
+ key: string
61
+ modifiers: MenuShortcutModifiers
62
+ }
63
+
64
+ export type AppMenuEntry =
65
+ | {
66
+ kind: 'item'
67
+ id: string
68
+ label: string
69
+ shortcut?: MenuShortcut
70
+ enabled: boolean
71
+ checked: boolean
72
+ }
73
+ | { kind: 'separator' }
74
+ | { kind: 'submenu'; label: string; enabled: boolean; items: AppMenuEntry[] }
75
+
76
+ export interface AppMenu {
77
+ title: string
78
+ items: AppMenuEntry[]
79
+ }
80
+
81
+ const ITEM_ID = /^[A-Za-z0-9._:-]{1,64}$/
82
+
83
+ /** `[A-Za-z0-9._:-]`, 1-64 bytes: an application identifier, never a native name. */
84
+ export function parseAppMenuItemId(value: unknown): string {
85
+ if (typeof value !== 'string' || !ITEM_ID.test(value))
86
+ throw new ProtocolError(
87
+ "Menu item id must be 1-64 bytes of letters, digits, '.', '_', ':' or '-'"
88
+ )
89
+ return value
90
+ }
91
+
92
+ function label(value: unknown, field: string): string {
93
+ const text = string(value, field, MAX_APP_MENU_LABEL_BYTES)
94
+ if (!text || /\p{Cc}/u.test(text))
95
+ throw new ProtocolError(field + ' must be nonempty and contain no control characters')
96
+ return text
97
+ }
98
+
99
+ function flag(data: Record<string, unknown>, key: string): boolean {
100
+ const value = data[key]
101
+ if (typeof value !== 'boolean') throw new ProtocolError('Menu ' + key + ' must be a boolean')
102
+ return value
103
+ }
104
+
105
+ const isFunctionKey = (key: string) => /^f([1-9]|1[0-2])$/.test(key)
106
+
107
+ function parseMenuShortcut(value: unknown): MenuShortcut {
108
+ const data = object(value)
109
+ keys(data, ['key', 'modifiers'])
110
+ const key = data['key']
111
+ if (typeof key !== 'string' || !APP_MENU_KEYS.includes(key))
112
+ throw new ProtocolError('Menu shortcut key is not in the closed key set')
113
+ const raw = object(data['modifiers'])
114
+ keys(raw, ['control', 'alt', 'shift', 'platform'])
115
+ const modifiers = {
116
+ control: flag(raw, 'control'),
117
+ alt: flag(raw, 'alt'),
118
+ shift: flag(raw, 'shift'),
119
+ platform: flag(raw, 'platform'),
120
+ }
121
+ if (!isFunctionKey(key) && !modifiers.control && !modifiers.alt && !modifiers.platform)
122
+ throw new ProtocolError('Menu shortcut requires control, alt or platform unless it is f1-f12')
123
+ return { key, modifiers }
124
+ }
125
+
126
+ const shortcutKey = ({ key, modifiers: m }: MenuShortcut) =>
127
+ `${+m.control}${+m.alt}${+m.shift}${+m.platform}${key}`
128
+
129
+ interface Census {
130
+ entries: number
131
+ ids: Set<string>
132
+ shortcuts: Set<string>
133
+ }
134
+
135
+ function entries(value: unknown, depth: number, census: Census): AppMenuEntry[] {
136
+ if (!Array.isArray(value) || !value.length)
137
+ throw new ProtocolError('A menu or submenu needs at least one entry')
138
+ return value.map((raw): AppMenuEntry => {
139
+ if (++census.entries > MAX_APP_MENU_ENTRIES)
140
+ throw new ProtocolError('A menu bar holds at most 128 entries')
141
+ const data = object(raw)
142
+ switch (data['kind']) {
143
+ case 'item': {
144
+ keys(data, ['kind', 'id', 'label', 'shortcut', 'enabled', 'checked'])
145
+ const id = parseAppMenuItemId(data['id'])
146
+ if (census.ids.has(id))
147
+ throw new ProtocolError('Menu item ids must be unique in a menu bar')
148
+ census.ids.add(id)
149
+ const entry: AppMenuEntry = {
150
+ kind: 'item',
151
+ id,
152
+ label: label(data['label'], 'menu label'),
153
+ enabled: flag(data, 'enabled'),
154
+ checked: flag(data, 'checked'),
155
+ }
156
+ if ('shortcut' in data) {
157
+ const shortcut = parseMenuShortcut(data['shortcut'])
158
+ const identity = shortcutKey(shortcut)
159
+ if (census.shortcuts.has(identity))
160
+ throw new ProtocolError('Menu shortcuts must be unique in a menu bar')
161
+ census.shortcuts.add(identity)
162
+ entry.shortcut = shortcut
163
+ }
164
+ return entry
165
+ }
166
+ case 'separator':
167
+ keys(data, ['kind'])
168
+ return { kind: 'separator' }
169
+ case 'submenu': {
170
+ keys(data, ['kind', 'label', 'enabled', 'items'])
171
+ const text = label(data['label'], 'menu label')
172
+ const enabled = flag(data, 'enabled')
173
+ if (depth >= MAX_APP_MENU_DEPTH)
174
+ throw new ProtocolError('Submenus nest at most two levels below a top-level menu')
175
+ return {
176
+ kind: 'submenu',
177
+ label: text,
178
+ enabled,
179
+ items: entries(data['items'], depth + 1, census),
180
+ }
181
+ }
182
+ default:
183
+ throw new ProtocolError('Unknown menu entry kind')
184
+ }
185
+ })
186
+ }
187
+
188
+ /**
189
+ * A context menu: the entries of one menu-bar menu, with the same bounds,
190
+ * unique ids and shortcuts, and submenus nested at most two levels deep.
191
+ */
192
+ export function parseContextMenuItems(value: unknown): AppMenuEntry[] {
193
+ return entries(value, 1, { entries: 0, ids: new Set(), shortcuts: new Set() })
194
+ }
195
+
196
+ /** An empty bar is valid: it removes the application's menus. */
197
+ export function parseAppMenus(value: unknown): AppMenu[] {
198
+ if (!Array.isArray(value) || value.length > MAX_APP_MENUS)
199
+ throw new ProtocolError('A menu bar holds at most 16 menus')
200
+ const census: Census = { entries: 0, ids: new Set(), shortcuts: new Set() }
201
+ return value.map((raw) => {
202
+ const data = object(raw)
203
+ keys(data, ['title', 'items'])
204
+ return {
205
+ title: label(data['title'], 'menu title'),
206
+ items: entries(data['items'], 1, census),
207
+ }
208
+ })
209
+ }
package/src/dialogs.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { keys, object, string } from './values.ts'
3
+
4
+ /** Live file dialogs per session. Each window owns at most one at a time. */
5
+ export const MAX_FILE_DIALOGS = 16
6
+ export const MAX_DIALOG_TITLE_BYTES = 256
7
+ export const MAX_DIALOG_LABEL_BYTES = 64
8
+ export const MAX_DIALOG_PATH_BYTES = 4_096
9
+ export const MAX_DIALOG_NAME_BYTES = 255
10
+ export const MAX_DIALOG_FILTERS = 16
11
+ export const MAX_DIALOG_FILTER_PATTERNS = 16
12
+ export const MAX_DIALOG_FILTER_NAME_BYTES = 128
13
+ export const MAX_DIALOG_PATTERN_BYTES = 64
14
+ /** Selected paths in one result. The whole result must also fit one frame. */
15
+ export const MAX_DIALOG_PATHS = 256
16
+
17
+ export const FILE_DIALOG_MODES = Object.freeze([
18
+ 'open_file',
19
+ 'open_files',
20
+ 'open_directory',
21
+ 'save_file',
22
+ ] as const)
23
+ export type FileDialogMode = (typeof FILE_DIALOG_MODES)[number]
24
+
25
+ export interface FileFilter {
26
+ name: string
27
+ patterns: readonly string[]
28
+ }
29
+
30
+ export interface FileDialogOptions {
31
+ mode: FileDialogMode
32
+ title: string
33
+ accept_label?: string
34
+ folder?: string
35
+ name?: string
36
+ filters?: readonly FileFilter[]
37
+ }
38
+
39
+ /** Paths are data: a selection carries no filesystem authority. */
40
+ export type FileDialogResult =
41
+ | { kind: 'selected'; paths: readonly string[] }
42
+ | { kind: 'cancelled' }
43
+ | { kind: 'failed'; code: 'native_failure' | 'payload_too_large' }
44
+
45
+ function line(value: unknown, field: string, limit: number): string {
46
+ const text = string(value, field, limit)
47
+ if (!text || /\p{Cc}/u.test(text))
48
+ throw new ProtocolError(field + ' must be nonempty and contain no control characters')
49
+ return text
50
+ }
51
+
52
+ /** A nonempty absolute POSIX path without NUL, at most 4096 UTF-8 bytes. */
53
+ function parseDialogPath(value: unknown, field = 'dialog path'): string {
54
+ const path = string(value, field, MAX_DIALOG_PATH_BYTES)
55
+ if (!path.startsWith('/')) throw new ProtocolError(field + ' must be absolute')
56
+ return path
57
+ }
58
+
59
+ function filter(value: unknown): FileFilter {
60
+ const data = object(value)
61
+ keys(data, ['name', 'patterns'])
62
+ const patterns = data['patterns']
63
+ if (
64
+ !Array.isArray(patterns) ||
65
+ patterns.length < 1 ||
66
+ patterns.length > MAX_DIALOG_FILTER_PATTERNS
67
+ )
68
+ throw new ProtocolError('A file filter needs between 1 and 16 patterns')
69
+ return {
70
+ name: line(data['name'], 'file filter name', MAX_DIALOG_FILTER_NAME_BYTES),
71
+ patterns: patterns.map((pattern) =>
72
+ line(pattern, 'file filter pattern', MAX_DIALOG_PATTERN_BYTES)
73
+ ),
74
+ }
75
+ }
76
+
77
+ export function parseFileDialogOptions(value: unknown): FileDialogOptions {
78
+ const data = object(value)
79
+ keys(data, ['mode', 'title', 'accept_label', 'folder', 'name', 'filters'])
80
+ const mode = FILE_DIALOG_MODES.find((candidate) => candidate === data['mode'])
81
+ if (!mode) throw new ProtocolError('Unknown file dialog mode')
82
+ const options: FileDialogOptions = {
83
+ mode,
84
+ title: line(data['title'], 'dialog title', MAX_DIALOG_TITLE_BYTES),
85
+ }
86
+ if ('accept_label' in data)
87
+ options.accept_label = line(data['accept_label'], 'dialog accept label', MAX_DIALOG_LABEL_BYTES)
88
+ if ('folder' in data) options.folder = parseDialogPath(data['folder'], 'dialog folder')
89
+ if ('name' in data) {
90
+ if (mode !== 'save_file') throw new ProtocolError('Only a save dialog accepts a suggested name')
91
+ const name = line(data['name'], 'suggested name', MAX_DIALOG_NAME_BYTES)
92
+ if (name.includes('/') || name === '.' || name === '..')
93
+ throw new ProtocolError('Suggested name must be one path component')
94
+ options.name = name
95
+ }
96
+ if ('filters' in data) {
97
+ const filters = data['filters']
98
+ if (!Array.isArray(filters) || filters.length > MAX_DIALOG_FILTERS)
99
+ throw new ProtocolError('A file dialog accepts at most 16 filters')
100
+ if (filters.length && mode === 'open_directory')
101
+ throw new ProtocolError('A folder dialog does not accept file filters')
102
+ if (filters.length) options.filters = filters.map(filter)
103
+ }
104
+ return options
105
+ }
106
+
107
+ export function parseFileDialogResult(value: unknown): FileDialogResult {
108
+ const data = object(value)
109
+ switch (data['kind']) {
110
+ case 'selected': {
111
+ keys(data, ['kind', 'paths'])
112
+ const paths = data['paths']
113
+ if (!Array.isArray(paths) || paths.length < 1 || paths.length > MAX_DIALOG_PATHS)
114
+ throw new ProtocolError('A dialog selection holds between 1 and 256 paths')
115
+ return { kind: 'selected', paths: paths.map((path) => parseDialogPath(path)) }
116
+ }
117
+ case 'cancelled':
118
+ keys(data, ['kind'])
119
+ return { kind: 'cancelled' }
120
+ case 'failed': {
121
+ keys(data, ['kind', 'code'])
122
+ const code = (['native_failure', 'payload_too_large'] as const).find(
123
+ (candidate) => candidate === data['code']
124
+ )
125
+ if (!code) throw new ProtocolError('Unknown file dialog failure')
126
+ return { kind: 'failed', code }
127
+ }
128
+ default:
129
+ throw new ProtocolError('Unknown file dialog result')
130
+ }
131
+ }
package/src/drops.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { integer, keys, object, string } from './values.ts'
3
+
4
+ /** Live drop targets across a session. */
5
+ export const MAX_DROP_TARGETS = 32
6
+ /** Items one drop may deliver. A larger drop is rejected, never truncated. */
7
+ export const MAX_DROP_ITEMS = 64
8
+ /** UTF-8 bytes of one untrusted display name. */
9
+ export const MAX_DROP_NAME_BYTES = 255
10
+
11
+ export const DRAG_STATES = Object.freeze(['entered', 'left'] as const)
12
+ export type DragState = (typeof DRAG_STATES)[number]
13
+ export const DROP_REJECTIONS = Object.freeze(['empty', 'limit_exceeded'] as const)
14
+ export type DropRejection = (typeof DROP_REJECTIONS)[number]
15
+
16
+ /** Untrusted display metadata for one dropped item. It is not a path. */
17
+ export interface DroppedItem {
18
+ name: string
19
+ }
20
+
21
+ function choice<T extends string>(value: unknown, values: readonly T[], field: string): T {
22
+ const found = values.find((candidate) => candidate === value)
23
+ if (!found) throw new ProtocolError(field + ' must be one of ' + values.join(', '))
24
+ return found
25
+ }
26
+
27
+ export const parseDragState = (value: unknown): DragState =>
28
+ choice(value, DRAG_STATES, 'Drag state')
29
+ export const parseDropRejection = (value: unknown): DropRejection =>
30
+ choice(value, DROP_REJECTIONS, 'Drop rejection')
31
+
32
+ export function parseDropItemIndex(value: unknown): number {
33
+ return integer(value, 'Dropped item index', 0, MAX_DROP_ITEMS - 1)
34
+ }
35
+
36
+ export function parseDroppedItems(value: unknown): DroppedItem[] {
37
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_DROP_ITEMS)
38
+ throw new ProtocolError('A drop must deliver between 1 and ' + MAX_DROP_ITEMS + ' items')
39
+ return value.map((entry) => {
40
+ const data = object(entry)
41
+ keys(data, ['name'])
42
+ const name = string(data['name'], 'Dropped item name', MAX_DROP_NAME_BYTES)
43
+ if (!name || /\p{Cc}/u.test(name))
44
+ throw new ProtocolError('Dropped item name must be nonempty without control characters')
45
+ return { name }
46
+ })
47
+ }