@thibautrey/chatons-extension-minecraft-servers 1.0.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,152 @@
1
+ {
2
+ "id": "@thibautrey/chatons-extension-minecraft-servers",
3
+ "name": "Minecraft Servers",
4
+ "version": "1.0.0",
5
+ "description": "Manage and inspect a list of Minecraft servers from Chatons.",
6
+ "icon": "icon.svg",
7
+ "capabilities": [
8
+ "ui.menu",
9
+ "ui.mainView",
10
+ "llm.tools",
11
+ "storage.kv"
12
+ ],
13
+ "ui": {
14
+ "menuItems": [
15
+ {
16
+ "id": "minecraft-servers.menu",
17
+ "label": "Minecraft Servers",
18
+ "icon": "Blocks",
19
+ "location": "sidebar",
20
+ "order": 26,
21
+ "openMainView": "minecraft-servers.main"
22
+ }
23
+ ],
24
+ "mainViews": [
25
+ {
26
+ "viewId": "minecraft-servers.main",
27
+ "title": "Minecraft Servers",
28
+ "webviewUrl": "chaton-extension://@thibautrey/chatons-extension-minecraft-servers/index.html",
29
+ "initialRoute": "/"
30
+ }
31
+ ]
32
+ },
33
+ "apis": {
34
+ "exposes": [
35
+ { "name": "minecraft.servers.list", "version": "1.0.0" },
36
+ { "name": "minecraft.servers.add", "version": "1.0.0" },
37
+ { "name": "minecraft.servers.update", "version": "1.0.0" },
38
+ { "name": "minecraft.servers.remove", "version": "1.0.0" },
39
+ { "name": "minecraft.servers.status", "version": "1.0.0" },
40
+ { "name": "minecraft.servers.players", "version": "1.0.0" }
41
+ ]
42
+ },
43
+ "llm": {
44
+ "tools": [
45
+ {
46
+ "name": "minecraft_servers_list",
47
+ "label": "List Minecraft servers",
48
+ "description": "List the configured Minecraft servers with their saved metadata.",
49
+ "promptSnippet": "List configured Minecraft servers.",
50
+ "parameters": {
51
+ "type": "object",
52
+ "properties": {
53
+ "includeStatus": {
54
+ "type": "boolean",
55
+ "description": "When true, also fetch live public status for each server."
56
+ }
57
+ }
58
+ }
59
+ },
60
+ {
61
+ "name": "minecraft_servers_add",
62
+ "label": "Add Minecraft server",
63
+ "description": "Add a Minecraft server to the saved list.",
64
+ "promptSnippet": "Add a Minecraft server to the saved list.",
65
+ "parameters": {
66
+ "type": "object",
67
+ "properties": {
68
+ "name": { "type": "string", "description": "Display name for the server." },
69
+ "host": { "type": "string", "description": "Hostname or IP address." },
70
+ "port": { "type": "number", "description": "Server port. Defaults to 25565 for Java or 19132 for Bedrock." },
71
+ "edition": { "type": "string", "description": "Edition: java, bedrock, or auto." },
72
+ "notes": { "type": "string", "description": "Optional notes." },
73
+ "tags": {
74
+ "type": "array",
75
+ "items": { "type": "string" },
76
+ "description": "Optional tags."
77
+ }
78
+ },
79
+ "required": ["name", "host"]
80
+ }
81
+ },
82
+ {
83
+ "name": "minecraft_servers_update",
84
+ "label": "Update Minecraft server",
85
+ "description": "Update an existing saved Minecraft server by id, name, or host.",
86
+ "promptSnippet": "Update a saved Minecraft server.",
87
+ "parameters": {
88
+ "type": "object",
89
+ "properties": {
90
+ "id": { "type": "string", "description": "Saved server id." },
91
+ "lookup": { "type": "string", "description": "Alternative lookup by name or host if id is unknown." },
92
+ "name": { "type": "string", "description": "New display name." },
93
+ "host": { "type": "string", "description": "New hostname or IP address." },
94
+ "port": { "type": "number", "description": "New port." },
95
+ "edition": { "type": "string", "description": "Edition: java, bedrock, or auto." },
96
+ "notes": { "type": "string", "description": "New notes value." },
97
+ "tags": {
98
+ "type": "array",
99
+ "items": { "type": "string" },
100
+ "description": "Replacement tag list."
101
+ }
102
+ }
103
+ }
104
+ },
105
+ {
106
+ "name": "minecraft_servers_remove",
107
+ "label": "Remove Minecraft server",
108
+ "description": "Remove a saved Minecraft server by id, name, or host.",
109
+ "promptSnippet": "Remove a saved Minecraft server.",
110
+ "parameters": {
111
+ "type": "object",
112
+ "properties": {
113
+ "id": { "type": "string", "description": "Saved server id." },
114
+ "lookup": { "type": "string", "description": "Alternative lookup by name or host." }
115
+ }
116
+ }
117
+ },
118
+ {
119
+ "name": "minecraft_servers_status",
120
+ "label": "Get Minecraft server status",
121
+ "description": "Fetch public status information for a Minecraft server without logging in, including MOTD, version, player counts, latency, and favicon when available.",
122
+ "promptSnippet": "Fetch public Minecraft server status information.",
123
+ "parameters": {
124
+ "type": "object",
125
+ "properties": {
126
+ "id": { "type": "string", "description": "Saved server id." },
127
+ "lookup": { "type": "string", "description": "Saved server name or host." },
128
+ "host": { "type": "string", "description": "Direct hostname or IP if not using a saved server." },
129
+ "port": { "type": "number", "description": "Port override." },
130
+ "edition": { "type": "string", "description": "Edition: java, bedrock, or auto." }
131
+ }
132
+ }
133
+ },
134
+ {
135
+ "name": "minecraft_servers_players",
136
+ "label": "List online Minecraft players",
137
+ "description": "List the players currently reported as online on a Minecraft server using public status data. The result depends on what the server chooses to expose.",
138
+ "promptSnippet": "List online players from Minecraft server status.",
139
+ "parameters": {
140
+ "type": "object",
141
+ "properties": {
142
+ "id": { "type": "string", "description": "Saved server id." },
143
+ "lookup": { "type": "string", "description": "Saved server name or host." },
144
+ "host": { "type": "string", "description": "Direct hostname or IP if not using a saved server." },
145
+ "port": { "type": "number", "description": "Port override." },
146
+ "edition": { "type": "string", "description": "Edition: java, bedrock, or auto." }
147
+ }
148
+ }
149
+ }
150
+ ]
151
+ }
152
+ }
package/handler.js ADDED
@@ -0,0 +1,473 @@
1
+ const EXTENSION_ID = '@thibautrey/chatons-extension-minecraft-servers'
2
+ const STORAGE_KEY = 'minecraft.servers'
3
+ const JAVA_DEFAULT_PORT = 25565
4
+ const BEDROCK_DEFAULT_PORT = 19132
5
+ const SOCKET_TIMEOUT_MS = 5000
6
+
7
+ function safeArray(value) {
8
+ return Array.isArray(value) ? value : []
9
+ }
10
+
11
+ function nowIso() {
12
+ return new Date().toISOString()
13
+ }
14
+
15
+ function randomId() {
16
+ return 'mc_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
17
+ }
18
+
19
+ function normalizeString(value) {
20
+ return String(value == null ? '' : value).trim()
21
+ }
22
+
23
+ function normalizeEdition(value) {
24
+ const edition = normalizeString(value).toLowerCase()
25
+ if (edition === 'java' || edition === 'bedrock') return edition
26
+ return 'auto'
27
+ }
28
+
29
+ function resolvePort(port, edition) {
30
+ if (Number.isFinite(port) && Number(port) > 0 && Number(port) < 65536) return Number(port)
31
+ return edition === 'bedrock' ? BEDROCK_DEFAULT_PORT : JAVA_DEFAULT_PORT
32
+ }
33
+
34
+ function normalizeTags(value) {
35
+ const input = Array.isArray(value) ? value : []
36
+ return input
37
+ .map((item) => normalizeString(item))
38
+ .filter(Boolean)
39
+ }
40
+
41
+ function sanitizeServer(input, previous) {
42
+ const edition = normalizeEdition(input.edition || (previous && previous.edition))
43
+ const host = normalizeString(input.host || (previous && previous.host))
44
+ const name = normalizeString(input.name || (previous && previous.name))
45
+ const port = resolvePort(Number(input.port), edition === 'auto' ? normalizeEdition(previous && previous.edition) : edition)
46
+ if (!name) throw new Error('Server name is required')
47
+ if (!host) throw new Error('Server host is required')
48
+ return {
49
+ id: normalizeString(input.id || (previous && previous.id)) || randomId(),
50
+ name,
51
+ host,
52
+ port,
53
+ edition,
54
+ notes: normalizeString(input.notes || (previous && previous.notes)),
55
+ tags: normalizeTags(typeof input.tags === 'undefined' ? (previous && previous.tags) : input.tags),
56
+ updatedAt: nowIso(),
57
+ createdAt: previous && previous.createdAt ? previous.createdAt : nowIso(),
58
+ }
59
+ }
60
+
61
+ async function loadServers(ctx) {
62
+ const result = await ctx.storageKvGet(EXTENSION_ID, STORAGE_KEY)
63
+ if (result && result.ok && Array.isArray(result.data)) {
64
+ return result.data
65
+ }
66
+ return []
67
+ }
68
+
69
+ async function saveServers(ctx, servers) {
70
+ return ctx.storageKvSet(EXTENSION_ID, STORAGE_KEY, servers)
71
+ }
72
+
73
+ function findServer(servers, payload) {
74
+ const id = normalizeString(payload && payload.id)
75
+ const lookup = normalizeString(payload && payload.lookup).toLowerCase()
76
+ if (id) {
77
+ return servers.find((server) => server.id === id) || null
78
+ }
79
+ if (lookup) {
80
+ return servers.find((server) => {
81
+ return (
82
+ normalizeString(server.name).toLowerCase() === lookup ||
83
+ normalizeString(server.host).toLowerCase() === lookup ||
84
+ (normalizeString(server.host) + ':' + String(server.port)).toLowerCase() === lookup
85
+ )
86
+ }) || null
87
+ }
88
+ return null
89
+ }
90
+
91
+ function notFound(message) {
92
+ return { ok: false, error: { code: 'not_found', message } }
93
+ }
94
+
95
+ function invalid(message) {
96
+ return { ok: false, error: { code: 'invalid_args', message } }
97
+ }
98
+
99
+ function internal(message) {
100
+ return { ok: false, error: { code: 'internal', message } }
101
+ }
102
+
103
+ function toUnsignedVarInt(value) {
104
+ const out = []
105
+ let remaining = value >>> 0
106
+ do {
107
+ let temp = remaining & 0x7f
108
+ remaining >>>= 7
109
+ if (remaining !== 0) temp |= 0x80
110
+ out.push(temp)
111
+ } while (remaining !== 0)
112
+ return Buffer.from(out)
113
+ }
114
+
115
+ function encodeString(value) {
116
+ const buf = Buffer.from(String(value), 'utf8')
117
+ return Buffer.concat([toUnsignedVarInt(buf.length), buf])
118
+ }
119
+
120
+ function readVarInt(buffer, offset) {
121
+ let num = 0
122
+ let shift = 0
123
+ let pos = offset
124
+ while (true) {
125
+ if (pos >= buffer.length) throw new Error('Unexpected end of varint')
126
+ const byte = buffer[pos]
127
+ num |= (byte & 0x7f) << shift
128
+ pos += 1
129
+ if ((byte & 0x80) !== 0x80) break
130
+ shift += 7
131
+ if (shift > 35) throw new Error('Varint too large')
132
+ }
133
+ return { value: num, offset: pos }
134
+ }
135
+
136
+ function buildJavaHandshake(host, port) {
137
+ const packetId = Buffer.from([0x00])
138
+ const protocolVersion = toUnsignedVarInt(758)
139
+ const hostField = encodeString(host)
140
+ const portField = Buffer.alloc(2)
141
+ portField.writeUInt16BE(port, 0)
142
+ const nextState = toUnsignedVarInt(1)
143
+ const payload = Buffer.concat([packetId, protocolVersion, hostField, portField, nextState])
144
+ return Buffer.concat([toUnsignedVarInt(payload.length), payload])
145
+ }
146
+
147
+ function buildJavaStatusRequest() {
148
+ return Buffer.from([0x01, 0x00])
149
+ }
150
+
151
+ function stripMinecraftFormatting(input) {
152
+ return String(input == null ? '' : input).replace(/§[0-9A-FK-OR]/gi, '')
153
+ }
154
+
155
+ function simplifyJavaDescription(description) {
156
+ if (typeof description === 'string') return stripMinecraftFormatting(description)
157
+ if (!description || typeof description !== 'object') return ''
158
+ if (typeof description.text === 'string') {
159
+ return stripMinecraftFormatting(description.text)
160
+ }
161
+ if (Array.isArray(description.extra)) {
162
+ return description.extra.map(simplifyJavaDescription).join(' ').trim()
163
+ }
164
+ return ''
165
+ }
166
+
167
+ async function javaStatus(host, port) {
168
+ const net = await import('node:net')
169
+ return new Promise((resolve, reject) => {
170
+ const socket = new net.Socket()
171
+ const chunks = []
172
+ let done = false
173
+ const started = Date.now()
174
+
175
+ function finish(err, payload) {
176
+ if (done) return
177
+ done = true
178
+ try {
179
+ socket.destroy()
180
+ } catch {}
181
+ if (err) reject(err)
182
+ else resolve(payload)
183
+ }
184
+
185
+ socket.setTimeout(SOCKET_TIMEOUT_MS)
186
+ socket.on('timeout', () => finish(new Error('Java status request timed out')))
187
+ socket.on('error', (error) => finish(error))
188
+ socket.on('data', (chunk) => {
189
+ chunks.push(chunk)
190
+ try {
191
+ const buffer = Buffer.concat(chunks)
192
+ const packetLength = readVarInt(buffer, 0)
193
+ if (buffer.length < packetLength.offset + packetLength.value) return
194
+ const packetStart = packetLength.offset
195
+ const packetId = readVarInt(buffer, packetStart)
196
+ if (packetId.value !== 0x00) {
197
+ finish(new Error('Unexpected Java status packet id'))
198
+ return
199
+ }
200
+ const jsonLength = readVarInt(buffer, packetId.offset)
201
+ const jsonStart = jsonLength.offset
202
+ const jsonEnd = jsonStart + jsonLength.value
203
+ if (buffer.length < jsonEnd) return
204
+ const raw = buffer.slice(jsonStart, jsonEnd).toString('utf8')
205
+ const parsed = JSON.parse(raw)
206
+ const players = parsed.players || {}
207
+ const sample = Array.isArray(players.sample) ? players.sample : []
208
+ finish(null, {
209
+ ok: true,
210
+ resolvedEdition: 'java',
211
+ host,
212
+ port,
213
+ latencyMs: Date.now() - started,
214
+ motd: simplifyJavaDescription(parsed.description),
215
+ version: parsed.version && parsed.version.name ? parsed.version.name : null,
216
+ protocol: parsed.version && typeof parsed.version.protocol === 'number' ? parsed.version.protocol : null,
217
+ players: {
218
+ online: typeof players.online === 'number' ? players.online : null,
219
+ max: typeof players.max === 'number' ? players.max : null,
220
+ sample: sample
221
+ .map((entry) => (entry && typeof entry.name === 'string' ? entry.name : null))
222
+ .filter(Boolean),
223
+ },
224
+ favicon: typeof parsed.favicon === 'string' ? parsed.favicon : null,
225
+ raw: parsed,
226
+ })
227
+ } catch (error) {
228
+ finish(error)
229
+ }
230
+ })
231
+ socket.connect(port, host, () => {
232
+ socket.write(buildJavaHandshake(host, port))
233
+ socket.write(buildJavaStatusRequest())
234
+ })
235
+ })
236
+ }
237
+
238
+ function buildBedrockUnconnectedPing() {
239
+ const magic = Buffer.from('00ffff00fefefefefdfdfdfd12345678', 'hex')
240
+ const buffer = Buffer.alloc(1 + 8 + 8 + 16)
241
+ let offset = 0
242
+ buffer.writeUInt8(0x01, offset)
243
+ offset += 1
244
+ const pingTime = BigInt(Date.now())
245
+ buffer.writeBigInt64BE(pingTime, offset)
246
+ offset += 8
247
+ buffer.writeBigInt64BE(0x0123456789ABCDEFn, offset)
248
+ offset += 8
249
+ magic.copy(buffer, offset)
250
+ return buffer
251
+ }
252
+
253
+ function parseBedrockMotd(raw) {
254
+ const parts = String(raw || '').split(';')
255
+ return {
256
+ edition: parts[0] || null,
257
+ motd: parts[1] || null,
258
+ protocol: parts[2] ? Number(parts[2]) : null,
259
+ version: parts[3] || null,
260
+ online: parts[4] ? Number(parts[4]) : null,
261
+ max: parts[5] ? Number(parts[5]) : null,
262
+ serverId: parts[6] || null,
263
+ map: parts[7] || null,
264
+ gamemode: parts[8] || null,
265
+ gamemodeNumeric: parts[9] ? Number(parts[9]) : null,
266
+ portIPv4: parts[10] ? Number(parts[10]) : null,
267
+ portIPv6: parts[11] ? Number(parts[11]) : null,
268
+ }
269
+ }
270
+
271
+ async function bedrockStatus(host, port) {
272
+ const dgram = await import('node:dgram')
273
+ return new Promise((resolve, reject) => {
274
+ const socket = dgram.createSocket('udp4')
275
+ const started = Date.now()
276
+ let done = false
277
+
278
+ function finish(err, payload) {
279
+ if (done) return
280
+ done = true
281
+ try {
282
+ socket.close()
283
+ } catch {}
284
+ if (err) reject(err)
285
+ else resolve(payload)
286
+ }
287
+
288
+ socket.on('error', (error) => finish(error))
289
+ socket.on('message', (msg) => {
290
+ try {
291
+ if (msg.length < 35 || msg.readUInt8(0) !== 0x1c) {
292
+ finish(new Error('Unexpected Bedrock pong packet'))
293
+ return
294
+ }
295
+ const motdLength = msg.readUInt16BE(33)
296
+ const motd = msg.slice(35, 35 + motdLength).toString('utf8')
297
+ const parsed = parseBedrockMotd(motd)
298
+ finish(null, {
299
+ ok: true,
300
+ resolvedEdition: 'bedrock',
301
+ host,
302
+ port,
303
+ latencyMs: Date.now() - started,
304
+ motd: parsed.motd,
305
+ version: parsed.version,
306
+ protocol: parsed.protocol,
307
+ players: {
308
+ online: Number.isFinite(parsed.online) ? parsed.online : null,
309
+ max: Number.isFinite(parsed.max) ? parsed.max : null,
310
+ sample: [],
311
+ },
312
+ gamemode: parsed.gamemode,
313
+ map: parsed.map,
314
+ raw: parsed,
315
+ })
316
+ } catch (error) {
317
+ finish(error)
318
+ }
319
+ })
320
+
321
+ socket.send(buildBedrockUnconnectedPing(), port, host, (error) => {
322
+ if (error) {
323
+ finish(error)
324
+ return
325
+ }
326
+ setTimeout(() => finish(new Error('Bedrock status request timed out')), SOCKET_TIMEOUT_MS)
327
+ })
328
+ })
329
+ }
330
+
331
+ async function probeServer(target) {
332
+ const edition = normalizeEdition(target.edition)
333
+ const host = normalizeString(target.host)
334
+ if (!host) throw new Error('Server host is required')
335
+
336
+ const candidates = edition === 'auto'
337
+ ? [
338
+ { edition: 'java', port: resolvePort(Number(target.port), 'java') },
339
+ { edition: 'bedrock', port: resolvePort(Number(target.port), 'bedrock') },
340
+ ]
341
+ : [{ edition, port: resolvePort(Number(target.port), edition) }]
342
+
343
+ const errors = []
344
+ for (const candidate of candidates) {
345
+ try {
346
+ if (candidate.edition === 'java') {
347
+ return await javaStatus(host, candidate.port)
348
+ }
349
+ return await bedrockStatus(host, candidate.port)
350
+ } catch (error) {
351
+ errors.push(candidate.edition + ': ' + (error && error.message ? error.message : String(error)))
352
+ }
353
+ }
354
+ return {
355
+ ok: false,
356
+ host,
357
+ port: Number(target.port) || null,
358
+ resolvedEdition: edition === 'auto' ? null : edition,
359
+ checkedAt: nowIso(),
360
+ error: errors.join(' | ') || 'Server unreachable',
361
+ }
362
+ }
363
+
364
+ async function apiListServers(payload, ctx) {
365
+ const servers = await loadServers(ctx)
366
+ if (payload && payload.includeStatus) {
367
+ const enriched = []
368
+ for (const server of servers) {
369
+ const status = await probeServer(server)
370
+ enriched.push({ ...server, status })
371
+ }
372
+ return { ok: true, data: { servers: enriched } }
373
+ }
374
+ return { ok: true, data: { servers } }
375
+ }
376
+
377
+ async function apiAddServer(payload, ctx) {
378
+ const servers = await loadServers(ctx)
379
+ const normalized = sanitizeServer(payload || {}, null)
380
+ const duplicate = servers.find((server) => normalizeString(server.host).toLowerCase() === normalized.host.toLowerCase() && Number(server.port) === Number(normalized.port))
381
+ if (duplicate) {
382
+ return invalid('A server with the same host and port already exists')
383
+ }
384
+ const next = servers.concat([normalized])
385
+ await saveServers(ctx, next)
386
+ return { ok: true, data: { server: normalized, servers: next } }
387
+ }
388
+
389
+ async function apiUpdateServer(payload, ctx) {
390
+ const servers = await loadServers(ctx)
391
+ const current = findServer(servers, payload || {})
392
+ if (!current) return notFound('Saved server not found')
393
+ const updated = sanitizeServer(payload || {}, current)
394
+ const next = servers.map((server) => {
395
+ if (server.id !== current.id) return server
396
+ return updated
397
+ })
398
+ await saveServers(ctx, next)
399
+ return { ok: true, data: { server: updated, servers: next } }
400
+ }
401
+
402
+ async function apiRemoveServer(payload, ctx) {
403
+ const servers = await loadServers(ctx)
404
+ const current = findServer(servers, payload || {})
405
+ if (!current) return notFound('Saved server not found')
406
+ const next = servers.filter((server) => server.id !== current.id)
407
+ await saveServers(ctx, next)
408
+ return { ok: true, data: { removed: current, servers: next } }
409
+ }
410
+
411
+ async function resolveProbeTarget(payload, ctx) {
412
+ const id = normalizeString(payload && payload.id)
413
+ const lookup = normalizeString(payload && payload.lookup)
414
+ if (id || lookup) {
415
+ const servers = await loadServers(ctx)
416
+ const saved = findServer(servers, payload || {})
417
+ if (!saved) throw new Error('Saved server not found')
418
+ return saved
419
+ }
420
+
421
+ const host = normalizeString(payload && payload.host)
422
+ if (!host) throw new Error('Provide id, lookup, or host')
423
+ return {
424
+ host,
425
+ port: Number(payload && payload.port),
426
+ edition: normalizeEdition(payload && payload.edition),
427
+ }
428
+ }
429
+
430
+ async function apiStatus(payload, ctx) {
431
+ const target = await resolveProbeTarget(payload, ctx)
432
+ const status = await probeServer(target)
433
+ return { ok: true, data: status }
434
+ }
435
+
436
+ async function apiPlayers(payload, ctx) {
437
+ const target = await resolveProbeTarget(payload, ctx)
438
+ const status = await probeServer(target)
439
+ if (status.ok === false) {
440
+ return { ok: false, error: { code: 'internal', message: status.error || 'Server is offline' } }
441
+ }
442
+ return {
443
+ ok: true,
444
+ data: {
445
+ host: status.host,
446
+ port: status.port,
447
+ edition: status.resolvedEdition,
448
+ online: status.players ? status.players.online : null,
449
+ max: status.players ? status.players.max : null,
450
+ players: status.players && Array.isArray(status.players.sample) ? status.players.sample : [],
451
+ status,
452
+ },
453
+ }
454
+ }
455
+
456
+ export default async function handler(apiName, payload, ctx) {
457
+ try {
458
+ if (!ctx || typeof ctx.storageKvGet !== 'function' || typeof ctx.storageKvSet !== 'function') {
459
+ return internal('Missing handler context')
460
+ }
461
+
462
+ if (apiName === 'minecraft.servers.list') return apiListServers(payload, ctx)
463
+ if (apiName === 'minecraft.servers.add') return apiAddServer(payload, ctx)
464
+ if (apiName === 'minecraft.servers.update') return apiUpdateServer(payload, ctx)
465
+ if (apiName === 'minecraft.servers.remove') return apiRemoveServer(payload, ctx)
466
+ if (apiName === 'minecraft.servers.status') return apiStatus(payload, ctx)
467
+ if (apiName === 'minecraft.servers.players') return apiPlayers(payload, ctx)
468
+
469
+ return notFound('Unknown API: ' + apiName)
470
+ } catch (error) {
471
+ return internal(error && error.message ? error.message : String(error))
472
+ }
473
+ }
package/icon.svg ADDED
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <rect width="64" height="64" rx="16" fill="#1f2937"/>
3
+ <rect x="14" y="16" width="36" height="10" rx="3" fill="#22c55e"/>
4
+ <rect x="14" y="29" width="36" height="8" rx="3" fill="#4b5563"/>
5
+ <rect x="14" y="40" width="36" height="8" rx="3" fill="#4b5563"/>
6
+ <circle cx="20" cy="33" r="2" fill="#a7f3d0"/>
7
+ <circle cx="20" cy="44" r="2" fill="#d1d5db"/>
8
+ <path d="M40 20h4M46 20h0" stroke="#052e16" stroke-width="3" stroke-linecap="round"/>
9
+ </svg>
package/index.html ADDED
@@ -0,0 +1,178 @@
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Minecraft Servers</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ }
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+ body {
15
+ margin: 0;
16
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
17
+ background: transparent;
18
+ color: inherit;
19
+ }
20
+ .app {
21
+ display: grid;
22
+ gap: 16px;
23
+ }
24
+ .layout {
25
+ display: grid;
26
+ grid-template-columns: 1.1fr 1.4fr;
27
+ gap: 16px;
28
+ align-items: start;
29
+ }
30
+ .toolbar {
31
+ display: flex;
32
+ flex-wrap: wrap;
33
+ gap: 10px;
34
+ justify-content: space-between;
35
+ align-items: center;
36
+ }
37
+ .stats {
38
+ display: flex;
39
+ gap: 10px;
40
+ flex-wrap: wrap;
41
+ }
42
+ .stat {
43
+ display: inline-flex;
44
+ gap: 8px;
45
+ align-items: center;
46
+ padding: 8px 10px;
47
+ border-radius: 999px;
48
+ background: color-mix(in srgb, var(--ce-muted, #e5e7eb) 60%, transparent);
49
+ font-size: 12px;
50
+ }
51
+ .field-grid {
52
+ display: grid;
53
+ grid-template-columns: 1fr 1fr;
54
+ gap: 12px;
55
+ }
56
+ .field-grid .full {
57
+ grid-column: 1 / -1;
58
+ }
59
+ .actions {
60
+ display: flex;
61
+ flex-wrap: wrap;
62
+ gap: 10px;
63
+ }
64
+ input, textarea, select {
65
+ width: 100%;
66
+ box-sizing: border-box;
67
+ }
68
+ textarea {
69
+ min-height: 84px;
70
+ resize: vertical;
71
+ }
72
+ .server-list {
73
+ display: grid;
74
+ gap: 12px;
75
+ }
76
+ .server-card {
77
+ border: 1px solid color-mix(in srgb, var(--ce-border, #d1d5db) 90%, transparent);
78
+ border-radius: 14px;
79
+ padding: 14px;
80
+ background: color-mix(in srgb, var(--ce-card, #ffffff) 88%, transparent);
81
+ display: grid;
82
+ gap: 10px;
83
+ }
84
+ .server-card.is-selected {
85
+ border-color: color-mix(in srgb, #22c55e 55%, var(--ce-border, #d1d5db));
86
+ box-shadow: 0 0 0 1px color-mix(in srgb, #22c55e 30%, transparent);
87
+ }
88
+ .server-head {
89
+ display: flex;
90
+ justify-content: space-between;
91
+ align-items: start;
92
+ gap: 12px;
93
+ }
94
+ .server-title {
95
+ display: grid;
96
+ gap: 4px;
97
+ }
98
+ .server-title h3,
99
+ .server-title p,
100
+ .server-title small {
101
+ margin: 0;
102
+ }
103
+ .server-badges {
104
+ display: flex;
105
+ flex-wrap: wrap;
106
+ gap: 6px;
107
+ }
108
+ .badge {
109
+ display: inline-flex;
110
+ align-items: center;
111
+ gap: 6px;
112
+ padding: 4px 8px;
113
+ border-radius: 999px;
114
+ font-size: 11px;
115
+ background: color-mix(in srgb, var(--ce-muted, #e5e7eb) 75%, transparent);
116
+ }
117
+ .badge.ok {
118
+ background: rgba(34, 197, 94, 0.18);
119
+ }
120
+ .badge.warn {
121
+ background: rgba(245, 158, 11, 0.18);
122
+ }
123
+ .badge.error {
124
+ background: rgba(239, 68, 68, 0.18);
125
+ }
126
+ .row-actions {
127
+ display: flex;
128
+ flex-wrap: wrap;
129
+ gap: 8px;
130
+ }
131
+ .muted {
132
+ color: var(--ce-muted-fg, #6b7280);
133
+ font-size: 13px;
134
+ }
135
+ .empty {
136
+ padding: 18px;
137
+ border-radius: 12px;
138
+ background: color-mix(in srgb, var(--ce-muted, #e5e7eb) 55%, transparent);
139
+ font-size: 13px;
140
+ }
141
+ .status-box {
142
+ display: grid;
143
+ gap: 8px;
144
+ font-size: 13px;
145
+ }
146
+ .status-box pre {
147
+ margin: 0;
148
+ padding: 12px;
149
+ border-radius: 12px;
150
+ overflow: auto;
151
+ background: color-mix(in srgb, var(--ce-muted, #e5e7eb) 55%, transparent);
152
+ font-size: 12px;
153
+ line-height: 1.4;
154
+ white-space: pre-wrap;
155
+ word-break: break-word;
156
+ }
157
+ @media (max-width: 960px) {
158
+ .layout, .field-grid {
159
+ grid-template-columns: 1fr;
160
+ }
161
+ }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ <div class="ce-page">
166
+ <div class="ce-page-header">
167
+ <div class="ce-page-title-group">
168
+ <h1 class="ce-page-title">Minecraft Servers</h1>
169
+ <p class="ce-page-description">Gere une liste de serveurs Minecraft et interroge leur statut public sans connexion joueur.</p>
170
+ </div>
171
+ </div>
172
+
173
+ <div id="app" class="app"></div>
174
+ </div>
175
+
176
+ <script src="./index.js"></script>
177
+ </body>
178
+ </html>
package/index.js ADDED
@@ -0,0 +1,420 @@
1
+ const EXTENSION_ID = '@thibautrey/chatons-extension-minecraft-servers'
2
+ const STORAGE_KEY = 'minecraft.servers'
3
+
4
+ const DEFAULT_FORM = {
5
+ id: '',
6
+ name: '',
7
+ host: '',
8
+ port: '',
9
+ edition: 'auto',
10
+ notes: '',
11
+ tags: '',
12
+ }
13
+
14
+ function clone(value) {
15
+ return JSON.parse(JSON.stringify(value))
16
+ }
17
+
18
+ function escapeHtml(value) {
19
+ return String(value == null ? '' : value)
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;')
25
+ }
26
+
27
+ async function kvGet(key, fallback) {
28
+ const res = await window.chaton.extensionStorageKvGet(EXTENSION_ID, key)
29
+ if (!res || !res.ok || typeof res.data === 'undefined') return clone(fallback)
30
+ return res.data
31
+ }
32
+
33
+ async function call(apiName, payload) {
34
+ return window.chaton.extensionCall('chatons-ui', EXTENSION_ID, apiName, '^1.0.0', payload || {})
35
+ }
36
+
37
+ const state = {
38
+ servers: [],
39
+ selectedId: null,
40
+ form: clone(DEFAULT_FORM),
41
+ liveById: {},
42
+ message: '',
43
+ messageKind: '',
44
+ loading: false,
45
+ }
46
+
47
+ function normalizeTagsText(tags) {
48
+ return Array.isArray(tags) ? tags.join(', ') : ''
49
+ }
50
+
51
+ function setMessage(text, kind) {
52
+ state.message = text || ''
53
+ state.messageKind = kind || ''
54
+ const node = document.getElementById('statusMessage')
55
+ if (node) {
56
+ node.textContent = state.message
57
+ node.dataset.kind = state.messageKind
58
+ }
59
+ }
60
+
61
+ function formFromServer(server) {
62
+ return {
63
+ id: server.id || '',
64
+ name: server.name || '',
65
+ host: server.host || '',
66
+ port: server.port == null ? '' : String(server.port),
67
+ edition: server.edition || 'auto',
68
+ notes: server.notes || '',
69
+ tags: normalizeTagsText(server.tags),
70
+ }
71
+ }
72
+
73
+ function parseTags(value) {
74
+ return String(value || '')
75
+ .split(',')
76
+ .map((item) => item.trim())
77
+ .filter(Boolean)
78
+ }
79
+
80
+ function hydrateFormFromDom() {
81
+ state.form = {
82
+ id: document.getElementById('field-id').value.trim(),
83
+ name: document.getElementById('field-name').value.trim(),
84
+ host: document.getElementById('field-host').value.trim(),
85
+ port: document.getElementById('field-port').value.trim(),
86
+ edition: document.getElementById('field-edition').value,
87
+ notes: document.getElementById('field-notes').value.trim(),
88
+ tags: document.getElementById('field-tags').value.trim(),
89
+ }
90
+ }
91
+
92
+ function selectedServer() {
93
+ return state.servers.find((server) => server.id === state.selectedId) || null
94
+ }
95
+
96
+ async function loadServers() {
97
+ state.servers = await kvGet(STORAGE_KEY, [])
98
+ if (!state.selectedId && state.servers.length > 0) {
99
+ state.selectedId = state.servers[0].id
100
+ }
101
+ if (state.selectedId && !state.servers.some((server) => server.id === state.selectedId)) {
102
+ state.selectedId = state.servers.length > 0 ? state.servers[0].id : null
103
+ }
104
+ }
105
+
106
+ function describeLiveStatus(status) {
107
+ if (!status) return '<span class="badge">Aucun statut charge</span>'
108
+ if (status.ok === false) {
109
+ return '<span class="badge error">Hors ligne</span>'
110
+ }
111
+ const badges = []
112
+ badges.push('<span class="badge ok">En ligne</span>')
113
+ if (status.resolvedEdition) {
114
+ badges.push('<span class="badge">' + escapeHtml(status.resolvedEdition) + '</span>')
115
+ }
116
+ if (status.players && typeof status.players.online === 'number') {
117
+ badges.push('<span class="badge">' + escapeHtml(String(status.players.online)) + ' joueurs</span>')
118
+ }
119
+ if (typeof status.latencyMs === 'number') {
120
+ badges.push('<span class="badge">' + escapeHtml(String(status.latencyMs)) + ' ms</span>')
121
+ }
122
+ return badges.join('')
123
+ }
124
+
125
+ function serverCardHtml(server) {
126
+ const status = state.liveById[server.id]
127
+ const endpoint = server.host + ':' + server.port
128
+ return `
129
+ <div class="server-card ${state.selectedId === server.id ? 'is-selected' : ''}" data-server-id="${escapeHtml(server.id)}">
130
+ <div class="server-head">
131
+ <div class="server-title">
132
+ <h3>${escapeHtml(server.name)}</h3>
133
+ <p class="muted">${escapeHtml(endpoint)}</p>
134
+ <small class="muted">${escapeHtml(server.notes || '')}</small>
135
+ </div>
136
+ <div class="server-badges">
137
+ <span class="badge">${escapeHtml(server.edition || 'auto')}</span>
138
+ ${(server.tags || []).map((tag) => `<span class="badge">${escapeHtml(tag)}</span>`).join('')}
139
+ </div>
140
+ </div>
141
+ <div class="server-badges">${describeLiveStatus(status)}</div>
142
+ <div class="row-actions">
143
+ <button class="chaton-ui-button chaton-ui-button--ghost" data-action="select" data-server-id="${escapeHtml(server.id)}">Editer</button>
144
+ <button class="chaton-ui-button" data-action="probe" data-server-id="${escapeHtml(server.id)}">Tester</button>
145
+ <button class="chaton-ui-button chaton-ui-button--ghost" data-action="players" data-server-id="${escapeHtml(server.id)}">Joueurs</button>
146
+ <button class="chaton-ui-button chaton-ui-button--ghost" data-action="delete" data-server-id="${escapeHtml(server.id)}">Supprimer</button>
147
+ </div>
148
+ </div>
149
+ `
150
+ }
151
+
152
+ function statusDetailHtml(server, status) {
153
+ if (!server) {
154
+ return '<div class="empty">Selectionne un serveur pour voir les details.</div>'
155
+ }
156
+ if (!status) {
157
+ return '<div class="empty">Aucun statut live charge pour ce serveur.</div>'
158
+ }
159
+ return `
160
+ <div class="status-box">
161
+ <div class="server-badges">${describeLiveStatus(status)}</div>
162
+ <pre>${escapeHtml(JSON.stringify(status, null, 2))}</pre>
163
+ </div>
164
+ `
165
+ }
166
+
167
+ function render() {
168
+ const app = document.getElementById('app')
169
+ const selected = selectedServer()
170
+ const statsCount = state.servers.length
171
+ const onlineCount = Object.values(state.liveById).filter((entry) => entry && entry.ok !== false).length
172
+
173
+ app.innerHTML = `
174
+ <div class="toolbar">
175
+ <div class="stats">
176
+ <div class="stat"><strong>${statsCount}</strong><span>serveurs</span></div>
177
+ <div class="stat"><strong>${onlineCount}</strong><span>en ligne probes</span></div>
178
+ </div>
179
+ <div class="actions">
180
+ <button id="refreshAllBtn" class="chaton-ui-button">Actualiser tout</button>
181
+ <button id="resetFormBtn" class="chaton-ui-button chaton-ui-button--ghost">Nouveau serveur</button>
182
+ </div>
183
+ </div>
184
+
185
+ <div id="statusMessage" class="muted"></div>
186
+
187
+ <div class="layout">
188
+ <section class="ce-card">
189
+ <div class="ce-card__body">
190
+ <h2 class="ce-section-title">Configuration</h2>
191
+ <div class="field-grid">
192
+ <div class="full ce-field">
193
+ <label class="ce-label" for="field-name">Nom</label>
194
+ <input id="field-name" value="${escapeHtml(state.form.name)}" placeholder="Mon serveur survie">
195
+ </div>
196
+ <div class="ce-field">
197
+ <label class="ce-label" for="field-host">Host</label>
198
+ <input id="field-host" value="${escapeHtml(state.form.host)}" placeholder="play.example.net">
199
+ </div>
200
+ <div class="ce-field">
201
+ <label class="ce-label" for="field-port">Port</label>
202
+ <input id="field-port" value="${escapeHtml(state.form.port)}" placeholder="25565">
203
+ </div>
204
+ <div class="ce-field">
205
+ <label class="ce-label" for="field-edition">Edition</label>
206
+ <select id="field-edition">
207
+ <option value="auto" ${state.form.edition === 'auto' ? 'selected' : ''}>Auto</option>
208
+ <option value="java" ${state.form.edition === 'java' ? 'selected' : ''}>Java</option>
209
+ <option value="bedrock" ${state.form.edition === 'bedrock' ? 'selected' : ''}>Bedrock</option>
210
+ </select>
211
+ </div>
212
+ <div class="full ce-field">
213
+ <label class="ce-label" for="field-tags">Tags</label>
214
+ <input id="field-tags" value="${escapeHtml(state.form.tags)}" placeholder="survie, prive, amis">
215
+ </div>
216
+ <div class="full ce-field">
217
+ <label class="ce-label" for="field-notes">Notes</label>
218
+ <textarea id="field-notes" placeholder="Infos libres sur le serveur">${escapeHtml(state.form.notes)}</textarea>
219
+ </div>
220
+ </div>
221
+ <input id="field-id" type="hidden" value="${escapeHtml(state.form.id)}">
222
+ <div class="actions">
223
+ <button id="saveBtn" class="chaton-ui-button chaton-ui-button--primary">${state.form.id ? 'Mettre a jour' : 'Ajouter'}</button>
224
+ <button id="probeBtn" class="chaton-ui-button">Tester ce serveur</button>
225
+ <button id="playersBtn" class="chaton-ui-button chaton-ui-button--ghost">Lister les joueurs</button>
226
+ </div>
227
+ <div class="muted">Les informations live utilisent uniquement le ping public du serveur. La liste des joueurs depend de ce que le serveur expose.</div>
228
+ </div>
229
+ </section>
230
+
231
+ <section class="ce-card">
232
+ <div class="ce-card__body">
233
+ <h2 class="ce-section-title">Serveurs enregistres</h2>
234
+ <div class="server-list">
235
+ ${state.servers.length ? state.servers.map(serverCardHtml).join('') : '<div class="empty">Aucun serveur configure pour le moment.</div>'}
236
+ </div>
237
+ </div>
238
+ </section>
239
+ </div>
240
+
241
+ <section class="ce-card">
242
+ <div class="ce-card__body">
243
+ <h2 class="ce-section-title">Details du statut</h2>
244
+ ${statusDetailHtml(selected, selected ? state.liveById[selected.id] : null)}
245
+ </div>
246
+ </section>
247
+ `
248
+
249
+ bindEvents()
250
+ }
251
+
252
+ async function refresh() {
253
+ await loadServers()
254
+ if (!state.form.id && state.selectedId) {
255
+ const server = selectedServer()
256
+ if (server) state.form = formFromServer(server)
257
+ }
258
+ render()
259
+ }
260
+
261
+ async function saveCurrentForm() {
262
+ hydrateFormFromDom()
263
+ const payload = {
264
+ id: state.form.id || undefined,
265
+ name: state.form.name,
266
+ host: state.form.host,
267
+ port: state.form.port ? Number(state.form.port) : undefined,
268
+ edition: state.form.edition,
269
+ notes: state.form.notes,
270
+ tags: parseTags(state.form.tags),
271
+ }
272
+
273
+ if (!payload.name || !payload.host) {
274
+ setMessage('Le nom et le host sont obligatoires.', 'error')
275
+ return
276
+ }
277
+
278
+ const result = payload.id
279
+ ? await call('minecraft.servers.update', payload)
280
+ : await call('minecraft.servers.add', payload)
281
+
282
+ if (!result || !result.ok) {
283
+ setMessage(result && result.error ? result.error.message : 'Echec de sauvegarde.', 'error')
284
+ return
285
+ }
286
+
287
+ state.selectedId = result.data && result.data.server ? result.data.server.id : state.selectedId
288
+ state.form = state.selectedId && result.data && result.data.server ? formFromServer(result.data.server) : clone(DEFAULT_FORM)
289
+ setMessage(payload.id ? 'Serveur mis a jour.' : 'Serveur ajoute.', 'success')
290
+ await refresh()
291
+ }
292
+
293
+ async function probeServer(server) {
294
+ if (!server) {
295
+ setMessage('Aucun serveur selectionne.', 'error')
296
+ return
297
+ }
298
+ const result = await call('minecraft.servers.status', { id: server.id })
299
+ if (!result || !result.ok) {
300
+ state.liveById[server.id] = { ok: false, error: result && result.error ? result.error.message : 'Probe failed' }
301
+ setMessage(result && result.error ? result.error.message : 'Probe impossible.', 'error')
302
+ render()
303
+ return
304
+ }
305
+ state.liveById[server.id] = result.data
306
+ setMessage('Statut mis a jour pour ' + server.name + '.', 'success')
307
+ render()
308
+ }
309
+
310
+ async function listPlayers(server) {
311
+ if (!server) {
312
+ setMessage('Aucun serveur selectionne.', 'error')
313
+ return
314
+ }
315
+ const result = await call('minecraft.servers.players', { id: server.id })
316
+ if (!result || !result.ok) {
317
+ setMessage(result && result.error ? result.error.message : 'Impossible de lister les joueurs.', 'error')
318
+ return
319
+ }
320
+ const players = result.data && Array.isArray(result.data.players) ? result.data.players : []
321
+ setMessage(players.length ? ('Joueurs en ligne: ' + players.join(', ')) : 'Aucun joueur nomme expose par le serveur.', 'success')
322
+ if (result.data && result.data.status) {
323
+ state.liveById[server.id] = result.data.status
324
+ }
325
+ render()
326
+ }
327
+
328
+ async function removeServer(serverId) {
329
+ const result = await call('minecraft.servers.remove', { id: serverId })
330
+ if (!result || !result.ok) {
331
+ setMessage(result && result.error ? result.error.message : 'Suppression impossible.', 'error')
332
+ return
333
+ }
334
+ delete state.liveById[serverId]
335
+ if (state.selectedId === serverId) {
336
+ state.selectedId = null
337
+ state.form = clone(DEFAULT_FORM)
338
+ }
339
+ setMessage('Serveur supprime.', 'success')
340
+ await refresh()
341
+ }
342
+
343
+ async function refreshAll() {
344
+ await loadServers()
345
+ for (const server of state.servers) {
346
+ const result = await call('minecraft.servers.status', { id: server.id })
347
+ state.liveById[server.id] = result && result.ok ? result.data : { ok: false, error: result && result.error ? result.error.message : 'Probe failed' }
348
+ }
349
+ setMessage('Statuts actualises.', 'success')
350
+ render()
351
+ }
352
+
353
+ function bindEvents() {
354
+ const saveBtn = document.getElementById('saveBtn')
355
+ const probeBtn = document.getElementById('probeBtn')
356
+ const playersBtn = document.getElementById('playersBtn')
357
+ const resetFormBtn = document.getElementById('resetFormBtn')
358
+ const refreshAllBtn = document.getElementById('refreshAllBtn')
359
+
360
+ if (saveBtn) {
361
+ saveBtn.onclick = function () {
362
+ void saveCurrentForm()
363
+ }
364
+ }
365
+ if (probeBtn) {
366
+ probeBtn.onclick = function () {
367
+ void probeServer(selectedServer())
368
+ }
369
+ }
370
+ if (playersBtn) {
371
+ playersBtn.onclick = function () {
372
+ void listPlayers(selectedServer())
373
+ }
374
+ }
375
+ if (resetFormBtn) {
376
+ resetFormBtn.onclick = function () {
377
+ state.selectedId = null
378
+ state.form = clone(DEFAULT_FORM)
379
+ render()
380
+ }
381
+ }
382
+ if (refreshAllBtn) {
383
+ refreshAllBtn.onclick = function () {
384
+ void refreshAll()
385
+ }
386
+ }
387
+
388
+ Array.from(document.querySelectorAll('[data-action]')).forEach((node) => {
389
+ node.onclick = function () {
390
+ const action = node.getAttribute('data-action')
391
+ const serverId = node.getAttribute('data-server-id')
392
+ const server = state.servers.find((entry) => entry.id === serverId)
393
+ if (!server) return
394
+ if (action === 'select') {
395
+ state.selectedId = server.id
396
+ state.form = formFromServer(server)
397
+ render()
398
+ return
399
+ }
400
+ if (action === 'probe') {
401
+ void probeServer(server)
402
+ return
403
+ }
404
+ if (action === 'players') {
405
+ void listPlayers(server)
406
+ return
407
+ }
408
+ if (action === 'delete') {
409
+ void removeServer(server.id)
410
+ }
411
+ }
412
+ })
413
+ }
414
+
415
+ ;(async function init() {
416
+ if (window.chatonExtensionComponents) {
417
+ window.chatonExtensionComponents.ensureStyles()
418
+ }
419
+ await refresh()
420
+ })()
package/package.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@thibautrey/chatons-extension-minecraft-servers",
3
+ "version": "1.0.0",
4
+ "description": "Minecraft servers extension for Chatons",
5
+ "type": "module",
6
+ "license": "MIT"
7
+ }