@texisoft/nexus-plugin-testkit 0.0.0-stage → 0.1.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,78 @@
1
+ TEXISOFT PROPRIETARY LICENSE
2
+
3
+ Copyright (c) 2026 texisoft. All rights reserved.
4
+
5
+ This software, including its source code, documentation and associated
6
+ materials (the "Software"), is proprietary to texisoft ("Licensor"). It is made
7
+ available on public package registries for distribution convenience only.
8
+ Publication does NOT grant an open-source license and does NOT transfer or
9
+ waive any right of the Licensor.
10
+
11
+ 1. Limited permission
12
+ Subject to this license, you may install and run the Software solely to
13
+ develop, test, build and publish plugins for the Nexus Discord bot platform
14
+ operated by the Licensor ("Nexus"), and only for as long as you comply with
15
+ this license. This permission is personal, non-exclusive, non-transferable,
16
+ non-sublicensable and revocable at any time.
17
+
18
+ 2. Restrictions
19
+ Except where applicable law expressly forbids the restriction, you may not:
20
+ a) copy, modify, adapt, translate or create derivative works of the Software;
21
+ b) redistribute, publish, sell, rent, lease, sublicense or otherwise make
22
+ the Software or any part of it available to third parties, whether
23
+ modified or not, including re-publishing it to any package registry or
24
+ repository;
25
+ c) decompile, reverse engineer or attempt to derive source code, algorithms
26
+ or the sandbox, permission or protocol design, except for the source
27
+ that is delivered in readable form with the package, which remains
28
+ subject to this license;
29
+ d) use the Software, or knowledge gained from it, to build, operate or
30
+ support a platform, service or product that competes with or replaces
31
+ Nexus, or that is compatible with Nexus plugins;
32
+ e) circumvent, disable or probe the security, sandbox, permission, rate
33
+ limit, review or consent mechanisms of Nexus, or use the Software to
34
+ attack, overload or gain unauthorized access to Nexus or any third
35
+ party;
36
+ f) remove or alter copyright, license or attribution notices;
37
+ g) use the Software to train, fine-tune or evaluate machine-learning models,
38
+ or to build datasets for that purpose.
39
+
40
+ 3. Your plugins
41
+ Plugins you write remain yours. Submitting a plugin to Nexus is governed by
42
+ the Nexus developer terms, not by this license. Bundled portions of the
43
+ Software that the build tooling inserts into your plugin may be distributed
44
+ only as part of that plugin and only to Nexus.
45
+
46
+ 4. Ownership
47
+ The Software is licensed, not sold. The Licensor retains all right, title
48
+ and interest, including all intellectual property rights.
49
+
50
+ 5. Termination
51
+ This license ends automatically and without notice if you breach it. On
52
+ termination you must stop using the Software and delete all copies. The
53
+ Licensor may also withdraw this license at any time by publishing a notice
54
+ or removing the Software.
55
+
56
+ 6. No warranty
57
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
58
+ IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
59
+ NON-INFRINGEMENT, AVAILABILITY OR COMPATIBILITY WITH ANY FUTURE VERSION. THE
60
+ SOFTWARE IS A PRE-1.0 RELEASE AND ITS INTERFACES MAY CHANGE WITHOUT NOTICE.
61
+
62
+ 7. Limitation of liability
63
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE LICENSOR IS NOT LIABLE FOR ANY
64
+ INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, LOSS OF
65
+ DATA, PROFITS OR GOODWILL, ARISING FROM OR RELATED TO THE SOFTWARE. THE
66
+ LICENSOR'S TOTAL LIABILITY IS LIMITED TO EUR 100, EXCEPT FOR LIABILITY THAT
67
+ CANNOT BE EXCLUDED UNDER APPLICABLE LAW (INTENT, GROSS NEGLIGENCE, INJURY
68
+ TO LIFE, BODY OR HEALTH, AND LIABILITY UNDER THE PRODUCT LIABILITY ACT).
69
+
70
+ 8. Governing law
71
+ This license is governed by the laws of the Federal Republic of Germany,
72
+ excluding its conflict-of-laws rules and the UN Convention on Contracts for
73
+ the International Sale of Goods. If you are a merchant or a legal entity,
74
+ the courts at the Licensor's registered seat have exclusive jurisdiction.
75
+
76
+ 9. Severability
77
+ If a provision is invalid, the remaining provisions stay in force and the
78
+ invalid provision is replaced by the closest valid one.
package/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # @texisoft/nexus-plugin-testkit
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
+ Zum Testen von Nexus-Plugins, ohne dass Bot, Server oder Discord laufen. Das Testkit simuliert Events, Commands, Interaktionen, Jobs, Speicher, HTTP und Discord und hält sich dabei an dieselben Regeln wie die Sandbox in Produktion.
4
+
5
+ ```bash
6
+ npm install --save-dev @texisoft/nexus-plugin-testkit
7
+ ```
8
+
9
+ ## Beispiel
10
+
11
+ ```js
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { createProjectHarness } from '@texisoft/nexus-plugin-testkit'
16
+
17
+ const dir = fileURLToPath(new URL('..', import.meta.url))
18
+
19
+ test('/hello antwortet und zählt hoch', async () => {
20
+ const harness = await createProjectHarness(dir)
21
+ const first = await harness.runCommand('hello')
22
+ assert.equal(first.ok, true)
23
+ assert.equal(harness.storage.get('hello:count'), 1)
24
+ })
25
+ ```
26
+
27
+ `createProjectHarness(dir)` lädt `manifest.json` und `index.js` deines Projekts. Danach stehen dir unter anderem `runCommand`, `emit` für Events, `runJob`, `runUiData` und `runUiAction` zur Verfügung, dazu Aufrufe für Interaktionen.
28
+
29
+ Jeder Aufruf gibt `{ ok, value, error, replies, reply, calls, logs, delivered, durationMs }` zurück. Wenn dein Plugin einen Fehler auslöst, wirft das Testkit ihn nicht, sondern legt ihn in `error`, mit einem festen Code wie `permission_denied`, `http_blocked`, `quota_exceeded`, `rate_limited`, `invalid_args` oder `timeout`.
30
+
31
+ ## Wie in Produktion
32
+
33
+ Ein `ctx`-Aufruf ohne deklarierte Berechtigung schlägt mit `permission_denied` fehl. Eine Registrierung ohne passende Berechtigung bricht den Start ab, und Commands, die nicht im Manifest stehen, werden abgelehnt. Aufrufe an `ctx` direkt in `setup()` liefern `unavailable`. Mit `devMode: true` kommen Events und Interaktionen anderer Mitglieder gar nicht erst an.
34
+
35
+ Passend dazu gibt es `@texisoft/nexus-plugin-sdk` und `@texisoft/nexus-plugin-cli`.
36
+
37
+ ## Lizenz
38
+
39
+ Node.js 22 oder neuer. Das Paket ist proprietär, alle Rechte bleiben bei texisoft. Du darfst es nur nutzen, um Plugins für Nexus zu entwickeln, Genaueres steht in der `LICENSE`. Die Version ist noch 0.x, Schnittstellen können sich ändern.
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@texisoft/nexus-plugin-testkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Test-Kit für Nexus-Plugins: simulierte Events, Commands, Interaktionen, Jobs, Storage, HTTP und Discord mit denselben Regeln wie in Produktion.",
5
+ "keywords": [
6
+ "nexus",
7
+ "discord",
8
+ "discord-bot",
9
+ "plugin",
10
+ "texisoft"
11
+ ],
12
+ "license": "SEE LICENSE IN LICENSE",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/texisoft/discord-nexus-bot.git",
16
+ "directory": "packages/plugin-testkit"
17
+ },
18
+ "homepage": "https://github.com/texisoft/discord-nexus-bot/tree/master/packages/plugin-testkit",
19
+ "bugs": {
20
+ "url": "https://github.com/texisoft/discord-nexus-bot/issues"
21
+ },
22
+ "type": "module",
23
+ "main": "./src/index.js",
24
+ "exports": {
25
+ ".": "./src/index.js",
26
+ "./runner": "./src/runner.js",
27
+ "./project": "./src/project.js"
28
+ },
29
+ "sideEffects": false,
30
+ "files": [
31
+ "src",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "@texisoft/nexus-plugin-sdk": "^0.1.0"
43
+ }
44
+ }
@@ -0,0 +1,74 @@
1
+ let counter = 0
2
+
3
+ export function nextSnowflake() {
4
+ counter += 1
5
+ return String(400000000000000000n + BigInt(counter))
6
+ }
7
+
8
+ export const DEFAULT_IDS = Object.freeze({
9
+ guildId: '100000000000000001',
10
+ authorUserId: '200000000000000001',
11
+ otherUserId: '200000000000000002',
12
+ channelId: '300000000000000001'
13
+ })
14
+
15
+ export function makeUser(overrides = {}) {
16
+ const id = overrides.id ?? DEFAULT_IDS.authorUserId
17
+ return { id, username: `user${id.slice(-4)}`, bot: false, ...overrides }
18
+ }
19
+
20
+ export function makeMember(overrides = {}) {
21
+ return { userId: DEFAULT_IDS.authorUserId, nickname: null, roles: [], joinedAt: null, ...overrides }
22
+ }
23
+
24
+ export function makeMessage(overrides = {}) {
25
+ const authorId = overrides.authorId ?? overrides.author?.id ?? DEFAULT_IDS.authorUserId
26
+ return {
27
+ id: nextSnowflake(),
28
+ channelId: DEFAULT_IDS.channelId,
29
+ authorId,
30
+ author: makeUser({ id: authorId }),
31
+ content: 'Hallo Welt',
32
+ attachmentCount: 0,
33
+ createdAt: 1767225600000,
34
+ ...overrides
35
+ }
36
+ }
37
+
38
+ export const eventFixtures = Object.freeze({
39
+ messageCreate: (overrides = {}) => ({ message: makeMessage(overrides) }),
40
+ messageUpdate: (overrides = {}) => ({ message: makeMessage(overrides) }),
41
+ messageDelete: (overrides = {}) => ({ messageId: nextSnowflake(), channelId: DEFAULT_IDS.channelId, ...overrides }),
42
+ messageReactionAdd: (overrides = {}) => ({
43
+ messageId: nextSnowflake(), channelId: DEFAULT_IDS.channelId, userId: DEFAULT_IDS.authorUserId, emoji: { name: '👍' }, ...overrides
44
+ }),
45
+ messageReactionRemove: (overrides = {}) => ({
46
+ messageId: nextSnowflake(), channelId: DEFAULT_IDS.channelId, userId: DEFAULT_IDS.authorUserId, emoji: { name: '👍' }, ...overrides
47
+ }),
48
+ guildMemberAdd: (overrides = {}) => ({ user: makeUser(), member: makeMember(), ...overrides }),
49
+ guildMemberRemove: (overrides = {}) => ({ user: makeUser(), ...overrides }),
50
+ guildMemberUpdate: (overrides = {}) => ({
51
+ user: makeUser(), member: makeMember(), addedRoles: [], removedRoles: [], ...overrides
52
+ }),
53
+ voiceStateUpdate: (overrides = {}) => ({
54
+ userId: DEFAULT_IDS.authorUserId, oldChannelId: null, newChannelId: DEFAULT_IDS.channelId, ...overrides
55
+ })
56
+ })
57
+
58
+ export function actorOfEvent(name, payload) {
59
+ switch (name) {
60
+ case 'messageCreate':
61
+ case 'messageUpdate':
62
+ return payload?.message?.authorId ?? null
63
+ case 'messageReactionAdd':
64
+ case 'messageReactionRemove':
65
+ case 'voiceStateUpdate':
66
+ return payload?.userId ?? null
67
+ case 'guildMemberAdd':
68
+ case 'guildMemberRemove':
69
+ case 'guildMemberUpdate':
70
+ return payload?.user?.id ?? null
71
+ default:
72
+ return null
73
+ }
74
+ }
package/src/harness.js ADDED
@@ -0,0 +1,499 @@
1
+ import {
2
+ EVENT_NAMES, PROTOCOL_VERSION, RUNTIME_LIMITS as R, PluginValidationError, actionResultSchema, chartDataSchema,
3
+ checkCall, configValuesSchema, hasPermission, invokeSchema, isPluginDefinition, manifestSchema,
4
+ permissionForRegistration, registerSchema, shouldDeliverInDev, statDataSchema, tableDataSchema, utf8Size,
5
+ validate, validateCallArgs, validateFrame, validateOrThrow
6
+ } from '@texisoft/nexus-plugin-sdk'
7
+ import {
8
+ DEFAULT_IDS, actorOfEvent, eventFixtures, makeMember, makeUser, nextSnowflake
9
+ } from './fixtures.js'
10
+ import { PluginCallError, createPluginRunner } from './runner.js'
11
+ import { createMemoryKv } from './storage.js'
12
+
13
+ const DATA_SCHEMAS = { table: tableDataSchema, stat: statDataSchema, chart: chartDataSchema }
14
+
15
+ const DISCORD_DEFAULTS = {
16
+ sendMessage: (a) => ({ id: nextSnowflake(), channelId: a.channelId }),
17
+ editMessage: (a) => ({ id: a.messageId, channelId: a.channelId }),
18
+ sendDm: (a) => ({ id: nextSnowflake(), channelId: nextSnowflake(), userId: a.userId }),
19
+ createThread: (a) => ({ id: nextSnowflake(), name: a.name }),
20
+ archiveThread: (a) => ({ threadId: a.threadId, archived: true, locked: a.locked === true }),
21
+ setThreadTags: (a) => ({ threadId: a.threadId, tagIds: a.tagIds }),
22
+ fetchMember: (a) => ({ userId: a.userId, nickname: null, roles: [] }),
23
+ fetchChannel: (a) => ({ id: a.channelId, name: 'kanal', type: 'text' }),
24
+ fetchRole: (a) => ({ id: a.roleId, name: 'rolle' })
25
+ }
26
+
27
+ let fakeDepth = 0
28
+ let realNow = null
29
+
30
+ async function withFakeNow(clock, fn) {
31
+ if (fakeDepth === 0) {
32
+ realNow = Date.now
33
+ Date.now = () => clock.now()
34
+ }
35
+ fakeDepth += 1
36
+ try {
37
+ return await fn()
38
+ } finally {
39
+ fakeDepth -= 1
40
+ if (fakeDepth === 0) {
41
+ Date.now = realNow
42
+ realNow = null
43
+ }
44
+ }
45
+ }
46
+
47
+ function createClock(start) {
48
+ let current = start
49
+ return { now: () => current, set: (value) => { current = value } }
50
+ }
51
+
52
+ function issueSummary(errors) {
53
+ return errors.map((e) => `${e.path || '<root>'}:${e.code}`).join(', ')
54
+ }
55
+
56
+ function matchMock(matcher, req) {
57
+ if (typeof matcher === 'function') return Boolean(matcher(req))
58
+ if (matcher instanceof RegExp) return matcher.test(req.url)
59
+ if (typeof matcher === 'string') {
60
+ const space = matcher.indexOf(' ')
61
+ if (space > 0 && /^[A-Z]+$/.test(matcher.slice(0, space))) return matcher.slice(0, space) === req.method && matcher.slice(space + 1) === req.url
62
+ return matcher === req.url
63
+ }
64
+ return false
65
+ }
66
+
67
+ function normalizeResponse(raw) {
68
+ const response = raw ?? {}
69
+ const headers = Object.fromEntries(Object.entries(response.headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)]))
70
+ let text = response.text ?? ''
71
+ if (response.json !== undefined) {
72
+ text = JSON.stringify(response.json)
73
+ headers['content-type'] ??= 'application/json'
74
+ }
75
+ return { status: response.status ?? 200, headers, text: String(text) }
76
+ }
77
+
78
+ export function createTestHarness(plugin, options = {}) {
79
+ if (!isPluginDefinition(plugin)) throw new TypeError('createTestHarness: erwartet ein Ergebnis von definePlugin')
80
+ const manifest = validateOrThrow(manifestSchema, options.manifest)
81
+ const guildId = options.guildId ?? DEFAULT_IDS.guildId
82
+ const authorUserId = options.authorUserId ?? DEFAULT_IDS.authorUserId
83
+ const devMode = options.devMode ?? false
84
+ const locale = options.locale ?? manifest.locales.default
85
+ const deadlineMs = options.deadlineMs ?? R.invocationDeadlineMs
86
+ const clock = createClock(options.now ?? Date.now())
87
+ const kv = createMemoryKv({ quotaKb: manifest.permissions.storageQuotaKb, now: clock.now })
88
+ const allCalls = []
89
+ const allLogs = []
90
+ const records = new Map()
91
+ const mocks = []
92
+ const httpRequests = []
93
+ const discordResponders = new Map()
94
+ const discordCalls = []
95
+ const discordMessages = []
96
+ const configSchema = configValuesSchema(manifest.configSchema).partial()
97
+ const configFields = new Map(manifest.configSchema.map((f) => [f.key, f]))
98
+ const configValues = {}
99
+ let invocationCounter = 0
100
+ let runner = null
101
+ let startPromise = null
102
+ let jobTimers = []
103
+
104
+ for (const f of manifest.configSchema) if (f.default !== undefined) configValues[f.key] = structuredClone(f.default)
105
+
106
+ function setConfig(key, value) {
107
+ if (!configFields.has(key)) throw new PluginValidationError([{ path: key, code: 'unknown_key' }])
108
+ validateOrThrow(configSchema, { [key]: value })
109
+ configValues[key] = structuredClone(value)
110
+ }
111
+
112
+ for (const [key, value] of Object.entries(options.config ?? {})) setConfig(key, value)
113
+
114
+ function record(inv, entry) {
115
+ allCalls.push(entry)
116
+ records.get(inv.id)?.calls.push(entry)
117
+ }
118
+
119
+ function interactionCall(inv, method, args) {
120
+ if (inv.kind !== 'command' && inv.kind !== 'interaction') {
121
+ throw new PluginCallError('forbidden', `${method} ist nur in Command- und Interaktions-Aufrufen erlaubt`)
122
+ }
123
+ const s = inv.state
124
+ const rec = records.get(inv.id)
125
+ const type = method.slice('interaction.'.length)
126
+ if (type === 'reply' || type === 'update') {
127
+ if (type === 'update' && inv.kind !== 'interaction') throw new PluginCallError('forbidden', 'update ist nur für Komponenten-Interaktionen erlaubt')
128
+ if (s.replied) throw new PluginCallError('forbidden', 'Interaktion wurde bereits beantwortet')
129
+ s.replied = true
130
+ rec.replies.push({ type, message: args.message })
131
+ } else if (type === 'defer') {
132
+ if (s.replied || s.deferred) throw new PluginCallError('forbidden', 'Interaktion wurde bereits beantwortet')
133
+ s.deferred = true
134
+ rec.replies.push({ type, ephemeral: args.ephemeral })
135
+ } else if (type === 'followUp') {
136
+ if (!s.replied && !s.deferred) throw new PluginCallError('forbidden', 'followUp erfordert vorher reply oder defer')
137
+ rec.replies.push({ type, message: args.message })
138
+ } else if (type === 'showModal') {
139
+ if (s.replied || s.deferred) throw new PluginCallError('forbidden', 'Interaktion wurde bereits beantwortet')
140
+ s.replied = true
141
+ rec.replies.push({ type: 'modal', modal: args.modal })
142
+ }
143
+ }
144
+
145
+ async function httpFetch(inv, args) {
146
+ inv.state.http += 1
147
+ if (inv.state.http > R.httpRequestsPerInvocationMax) {
148
+ throw new PluginCallError('rate_limited', `Mehr als ${R.httpRequestsPerInvocationMax} HTTP-Anfragen je Aufruf`)
149
+ }
150
+ const req = { url: args.url, method: args.method, headers: args.headers ?? {}, body: args.body ?? null }
151
+ httpRequests.push(req)
152
+ const mock = mocks.find((m) => matchMock(m.matcher, req))
153
+ if (!mock) throw new PluginCallError('http_failed', `Kein HTTP-Mock für ${req.method} ${req.url}`)
154
+ let raw
155
+ try {
156
+ raw = typeof mock.responder === 'function' ? await mock.responder(req) : mock.responder
157
+ } catch (error) {
158
+ throw new PluginCallError('http_failed', error?.message ?? 'HTTP-Fehler')
159
+ }
160
+ const response = normalizeResponse(raw)
161
+ if (utf8Size(response.text) > R.httpResponseMaxBytes) {
162
+ throw new PluginCallError('http_failed', `Antwort größer als ${R.httpResponseMaxBytes} Bytes`)
163
+ }
164
+ return response
165
+ }
166
+
167
+ async function discordCall(method, args) {
168
+ const action = method.slice('discord.'.length)
169
+ const entry = { method: action, args: structuredClone(args) }
170
+ discordCalls.push(entry)
171
+ if (['sendMessage', 'editMessage', 'sendDm'].includes(action)) discordMessages.push(entry)
172
+ const responder = discordResponders.get(action)
173
+ if (responder !== undefined) return typeof responder === 'function' ? responder(args) : responder
174
+ return DISCORD_DEFAULTS[action] ? DISCORD_DEFAULTS[action](args) : null
175
+ }
176
+
177
+ async function call(method, rawArgs, inv) {
178
+ if (!inv) throw new PluginCallError('forbidden', 'ctx-Aufruf außerhalb eines Aufrufs')
179
+ const entry = { invocationId: inv.id, method, args: structuredClone(rawArgs), ok: true, error: null }
180
+ record(inv, entry)
181
+ try {
182
+ inv.state.calls += 1
183
+ if (inv.state.calls > R.callsPerInvocationMax) {
184
+ throw new PluginCallError('rate_limited', `Mehr als ${R.callsPerInvocationMax} ctx-Aufrufe je Aufruf`)
185
+ }
186
+ const verdict = checkCall(manifest, method, rawArgs)
187
+ if (!verdict.ok) {
188
+ const error = new PluginCallError(verdict.code, verdict.permission ? `Permission fehlt: ${verdict.permission}` : verdict.code)
189
+ error.permission = verdict.permission
190
+ throw error
191
+ }
192
+ const valid = validateCallArgs(method, rawArgs)
193
+ if (!valid.ok) throw new PluginCallError('invalid_args', issueSummary(valid.errors))
194
+ const args = valid.data
195
+ switch (method) {
196
+ case 'storage.kv.get': return kv.get(args.key)
197
+ case 'storage.kv.set': return kv.set(args.key, args.value, { ttlSeconds: args.ttlSeconds })
198
+ case 'storage.kv.delete': return kv.delete(args.key)
199
+ case 'storage.kv.list': return kv.list(args)
200
+ case 'storage.kv.incr': return kv.incr(args.key, args.by)
201
+ case 'config.get':
202
+ if (!configFields.has(args.key)) throw new PluginCallError('not_found', `Unbekannter Config-Schlüssel "${args.key}"`)
203
+ return structuredClone(configValues[args.key] ?? null)
204
+ case 'config.getAll': return structuredClone(configValues)
205
+ case 'http.fetch': return await httpFetch(inv, args)
206
+ case 'log.write': {
207
+ const log = { level: args.level, message: args.message }
208
+ allLogs.push(log)
209
+ records.get(inv.id)?.logs.push(log)
210
+ return undefined
211
+ }
212
+ default:
213
+ if (method.startsWith('interaction.')) return interactionCall(inv, method, args)
214
+ return await discordCall(method, args)
215
+ }
216
+ } catch (error) {
217
+ entry.ok = false
218
+ entry.error = { code: error.code ?? 'plugin_error', message: error.message }
219
+ throw error
220
+ }
221
+ }
222
+
223
+ function onRegister(registrations) {
224
+ const frame = validateFrame(registerSchema, { type: 'register', v: PROTOCOL_VERSION, registrations })
225
+ if (!frame.ok) throw new PluginCallError('invalid_message', `Ungültige Registrierung: ${issueSummary(frame.errors)}`)
226
+ const declared = new Set(manifest.commands.map((c) => c.name))
227
+ for (const r of registrations) {
228
+ const needed = permissionForRegistration(r.kind, r.name)
229
+ if (needed && !hasPermission(manifest, needed)) {
230
+ const error = new PluginCallError('permission_denied', `Permission fehlt: ${needed}`)
231
+ error.permission = needed
232
+ throw error
233
+ }
234
+ if (r.kind === 'command' && !declared.has(r.name)) {
235
+ throw new PluginCallError('forbidden', `Command "${r.name}" ist nicht im Manifest deklariert`)
236
+ }
237
+ }
238
+ if (registrations.filter((r) => r.kind === 'job').length > R.jobsMax) {
239
+ throw new PluginCallError('quota_exceeded', `Mehr als ${R.jobsMax} Jobs registriert`)
240
+ }
241
+ }
242
+
243
+ function createRunner() {
244
+ return createPluginRunner(plugin, {
245
+ meta: { guildId, pluginId: manifest.id, locale, defaultLocale: manifest.locales.default, supportedLocales: manifest.locales.supported },
246
+ call,
247
+ locales: options.locales ?? {},
248
+ onRegister
249
+ })
250
+ }
251
+
252
+ function start() {
253
+ if (!startPromise) {
254
+ runner = createRunner()
255
+ startPromise = withFakeNow(clock, () => runner.start()).then((registrations) => {
256
+ jobTimers = registrations
257
+ .filter((r) => r.kind === 'job' && r.options?.intervalSeconds)
258
+ .map((r) => ({ name: r.name, intervalMs: r.options.intervalSeconds * 1000, next: clock.now() + r.options.intervalSeconds * 1000 }))
259
+ return registrations
260
+ })
261
+ }
262
+ return startPromise
263
+ }
264
+
265
+ async function stop() {
266
+ if (!startPromise) return
267
+ const current = runner
268
+ const pending = startPromise
269
+ startPromise = null
270
+ runner = null
271
+ jobTimers = []
272
+ await pending.catch(() => {})
273
+ await current.stop().catch(() => {})
274
+ }
275
+
276
+ function skipped(reason) {
277
+ return { ok: true, value: undefined, error: null, delivered: false, skipped: reason, replies: [], reply: null, calls: [], logs: [], durationMs: 0 }
278
+ }
279
+
280
+ function gate(actorUserId) {
281
+ if (!devMode) return true
282
+ return shouldDeliverInDev({ authorUserId, actorUserId, guildId, boundGuilds: [guildId] })
283
+ }
284
+
285
+ function buildInvoke(kind, extra, actorUserId) {
286
+ invocationCounter += 1
287
+ return {
288
+ type: 'invoke', v: PROTOCOL_VERSION, id: `t${invocationCounter}`, guildId, actorUserId: actorUserId ?? null, deadlineMs, kind, ...extra
289
+ }
290
+ }
291
+
292
+ async function execute(message) {
293
+ const frame = validateFrame(invokeSchema, message)
294
+ if (!frame.ok) throw new PluginValidationError(frame.errors)
295
+ await start()
296
+ const rec = { calls: [], replies: [], logs: [] }
297
+ records.set(frame.data.id, rec)
298
+ const began = performance.now()
299
+ let outcome
300
+ try {
301
+ outcome = await withFakeNow(clock, () => runner.invoke(frame.data))
302
+ } finally {
303
+ records.delete(frame.data.id)
304
+ }
305
+ return {
306
+ ok: outcome.ok,
307
+ value: outcome.value,
308
+ error: outcome.error ?? null,
309
+ delivered: true,
310
+ replies: rec.replies,
311
+ reply: rec.replies.find((r) => r.type === 'reply' || r.type === 'update')?.message ?? null,
312
+ calls: rec.calls,
313
+ logs: rec.logs,
314
+ durationMs: performance.now() - began
315
+ }
316
+ }
317
+
318
+ function interactionBase(opts = {}) {
319
+ const user = opts.user ?? makeUser({ id: authorUserId })
320
+ return {
321
+ interactionId: nextSnowflake(),
322
+ guildId,
323
+ channelId: opts.channelId ?? DEFAULT_IDS.channelId,
324
+ user,
325
+ member: opts.member ?? makeMember({ userId: user.id }),
326
+ locale
327
+ }
328
+ }
329
+
330
+ function resolveOptionList(command, name, group, sub) {
331
+ const label = [name, group, sub].filter(Boolean).join(' ')
332
+ const hasNested = command.subcommands.length > 0 || command.groups.length > 0
333
+ if (!hasNested) {
334
+ if (group || sub) throw new TypeError(`/${name} hat keine Subcommands`)
335
+ return { options: command.options, label }
336
+ }
337
+ if (!sub) throw new TypeError(`/${name} braucht einen Subcommand`)
338
+ let list = command.subcommands
339
+ if (group) {
340
+ const found = command.groups.find((g) => g.name === group)
341
+ if (!found) throw new TypeError(`Gruppe "${group}" ist für /${name} nicht deklariert`)
342
+ list = found.subcommands
343
+ }
344
+ const subcommand = list.find((c) => c.name === sub)
345
+ if (!subcommand) throw new TypeError(`Subcommand "${sub}" ist für /${label} nicht deklariert`)
346
+ return { options: subcommand.options, label }
347
+ }
348
+
349
+ function checkOptions(name, given, group, sub) {
350
+ const command = manifest.commands.find((c) => c.name === name)
351
+ if (!command) throw new TypeError(`Command "${name}" ist nicht im Manifest deklariert`)
352
+ const resolved = resolveOptionList(command, name, group, sub)
353
+ const known = new Map(resolved.options.map((o) => [o.name, o]))
354
+ for (const key of Object.keys(given)) {
355
+ if (!known.has(key)) throw new TypeError(`Unbekannte Option "${key}" für /${resolved.label}`)
356
+ }
357
+ for (const o of resolved.options) {
358
+ const value = given[o.name]
359
+ if (value === undefined) {
360
+ if (o.required) throw new TypeError(`Pflicht-Option "${o.name}" fehlt für /${resolved.label}`)
361
+ continue
362
+ }
363
+ const ok = { string: 'string', integer: 'number', number: 'number', boolean: 'boolean', user: 'string', channel: 'string', role: 'string' }[o.type]
364
+ if (typeof value !== ok || (o.type === 'integer' && !Number.isInteger(value))) {
365
+ throw new TypeError(`Option "${o.name}" hat den falschen Typ (erwartet ${o.type})`)
366
+ }
367
+ if (o.min !== undefined && value < o.min) throw new TypeError(`Option "${o.name}" unter min`)
368
+ if (o.max !== undefined && value > o.max) throw new TypeError(`Option "${o.name}" über max`)
369
+ if (o.maxLength !== undefined && typeof value === 'string' && value.length > o.maxLength) throw new TypeError(`Option "${o.name}" über maxLength`)
370
+ if (o.choices && !o.choices.some((c) => c.value === value)) throw new TypeError(`Option "${o.name}" ist keine gültige Auswahl`)
371
+ }
372
+ }
373
+
374
+ async function emit(name, payload, opts = {}) {
375
+ if (!EVENT_NAMES.includes(name)) throw new TypeError(`Unbekanntes Event "${name}"`)
376
+ const dto = structuredClone(payload ?? eventFixtures[name]())
377
+ if (!manifest.permissions.messageContent) {
378
+ if (dto.message) delete dto.message.content
379
+ delete dto.oldContent
380
+ }
381
+ const actor = opts.actorUserId !== undefined ? opts.actorUserId : actorOfEvent(name, dto)
382
+ if (!gate(actor)) return skipped('dev_not_author')
383
+ return execute(buildInvoke('event', { name, payload: dto }, actor))
384
+ }
385
+
386
+ async function runCommand(name, opts = {}) {
387
+ const given = opts.options ?? {}
388
+ checkOptions(name, given, opts.subcommandGroup, opts.subcommand)
389
+ const base = interactionBase(opts)
390
+ if (!gate(base.user.id)) return skipped('dev_not_author')
391
+ const nested = {
392
+ ...(opts.subcommandGroup ? { subcommandGroup: opts.subcommandGroup } : {}),
393
+ ...(opts.subcommand ? { subcommand: opts.subcommand } : {})
394
+ }
395
+ return execute(buildInvoke('command', { interaction: { ...base, kind: 'command', commandName: name, ...nested, options: given } }, base.user.id))
396
+ }
397
+
398
+ async function component(kind, customId, extra, opts) {
399
+ const base = interactionBase(opts)
400
+ if (!gate(base.user.id)) return skipped('dev_not_author')
401
+ return execute(buildInvoke('interaction', { interaction: { ...base, kind, customId, ...extra } }, base.user.id))
402
+ }
403
+
404
+ const pressButton = (customId, opts = {}) => component('button', customId, { messageId: opts.messageId ?? nextSnowflake() }, opts)
405
+ const selectOption = (customId, values, opts = {}) => component('select', customId, { messageId: opts.messageId ?? nextSnowflake(), values }, opts)
406
+ const submitModal = (customId, fields, opts = {}) => component('modal', customId, { fields }, opts)
407
+
408
+ async function runJob(name) {
409
+ return execute(buildInvoke('job', { name }, null))
410
+ }
411
+
412
+ async function runUi(kind, name, params, opts) {
413
+ const viewer = opts.viewer ?? makeUser({ id: authorUserId })
414
+ const result = await execute(buildInvoke(kind, { name, params, viewer }, viewer.id))
415
+ if (result.ok && kind === 'uiAction') {
416
+ const check = validate(actionResultSchema, result.value)
417
+ if (!check.ok) return { ...result, ok: false, error: { code: 'invalid_args', message: `Aktionsergebnis ungültig: ${issueSummary(check.errors)}` } }
418
+ }
419
+ if (result.ok && opts.schema) {
420
+ const check = validate(DATA_SCHEMAS[opts.schema], result.value)
421
+ if (!check.ok) return { ...result, ok: false, error: { code: 'invalid_args', message: `Daten ungültig (${opts.schema}): ${issueSummary(check.errors)}` } }
422
+ }
423
+ return result
424
+ }
425
+
426
+ const runUiData = (name, params = {}, opts = {}) => runUi('uiData', name, params, opts)
427
+ const runUiAction = (name, params = {}, opts = {}) => runUi('uiAction', name, params, opts)
428
+
429
+ async function advance(ms) {
430
+ await start()
431
+ const target = clock.now() + ms
432
+ const results = []
433
+ for (;;) {
434
+ const due = jobTimers.filter((j) => j.next <= target).sort((a, b) => a.next - b.next)[0]
435
+ if (!due) break
436
+ clock.set(due.next)
437
+ due.next += due.intervalMs
438
+ results.push({ name: due.name, ...(await runJob(due.name)) })
439
+ }
440
+ clock.set(target)
441
+ return results
442
+ }
443
+
444
+ return {
445
+ manifest,
446
+ ids: { guildId, authorUserId },
447
+ start,
448
+ stop,
449
+ restart: async () => {
450
+ await stop()
451
+ return start()
452
+ },
453
+ registrations: async () => {
454
+ await start()
455
+ return runner.registry()
456
+ },
457
+ emit,
458
+ runCommand,
459
+ pressButton,
460
+ selectOption,
461
+ submitModal,
462
+ runJob,
463
+ runUiData,
464
+ runUiAction,
465
+ storage: {
466
+ get: kv.get, set: kv.set, delete: kv.delete, list: kv.list, incr: kv.incr, entries: kv.entries, clear: kv.clear, usedBytes: kv.usedBytes, quotaBytes: kv.quotaBytes
467
+ },
468
+ config: {
469
+ set: setConfig,
470
+ get: (key) => structuredClone(configValues[key] ?? null),
471
+ all: () => structuredClone(configValues)
472
+ },
473
+ http: {
474
+ mock: (matcher, responder) => { mocks.push({ matcher, responder }) },
475
+ reset: () => { mocks.length = 0 },
476
+ requests: httpRequests
477
+ },
478
+ discord: {
479
+ calls: discordCalls,
480
+ messages: discordMessages,
481
+ respond: (action, value) => { discordResponders.set(action, value) },
482
+ fail: (action, code = 'discord_failed', message) => {
483
+ discordResponders.set(action, () => { throw new PluginCallError(code, message ?? code) })
484
+ },
485
+ reset: () => {
486
+ discordCalls.length = 0
487
+ discordMessages.length = 0
488
+ discordResponders.clear()
489
+ }
490
+ },
491
+ calls: allCalls,
492
+ logs: allLogs,
493
+ clock: {
494
+ now: clock.now,
495
+ set: (value) => clock.set(typeof value === 'number' ? value : Date.parse(value)),
496
+ advance
497
+ }
498
+ }
499
+ }
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createTestHarness } from './harness.js'
2
+ export { createMemoryKv } from './storage.js'
3
+ export { PluginCallError, createPluginRunner, createTranslator } from './runner.js'
4
+ export {
5
+ DEFAULT_IDS, actorOfEvent, eventFixtures, makeMember, makeMessage, makeUser, nextSnowflake
6
+ } from './fixtures.js'
7
+ export { createProjectHarness, loadLocales, loadPluginProject } from './project.js'
package/src/project.js ADDED
@@ -0,0 +1,32 @@
1
+ import { readFile, readdir } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { createTestHarness } from './harness.js'
5
+
6
+ export async function loadLocales(dir) {
7
+ const locales = {}
8
+ let files = []
9
+ try {
10
+ files = await readdir(join(dir, 'locales'))
11
+ } catch {
12
+ return locales
13
+ }
14
+ for (const file of files) {
15
+ if (!file.endsWith('.json')) continue
16
+ locales[file.slice(0, -5)] = JSON.parse(await readFile(join(dir, 'locales', file), 'utf8'))
17
+ }
18
+ return locales
19
+ }
20
+
21
+ export async function loadPluginProject(dir) {
22
+ const root = resolve(dir)
23
+ const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'))
24
+ const entry = join(root, manifest.main ?? 'index.js')
25
+ const module = await import(pathToFileURL(entry).href)
26
+ return { dir: root, manifest, locales: await loadLocales(root), plugin: module.default }
27
+ }
28
+
29
+ export async function createProjectHarness(dir, options = {}) {
30
+ const project = await loadPluginProject(dir)
31
+ return createTestHarness(project.plugin, { manifest: project.manifest, locales: project.locales, ...options })
32
+ }
package/src/runner.js ADDED
@@ -0,0 +1,241 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import {
3
+ DISCORD_ACTIONS, RUNTIME_LIMITS, isPluginDefinition, makeError
4
+ } from '@texisoft/nexus-plugin-sdk'
5
+
6
+ export class PluginCallError extends Error {
7
+ constructor(code, message) {
8
+ super(message ?? code)
9
+ this.name = 'PluginCallError'
10
+ this.code = code
11
+ }
12
+ }
13
+
14
+ const LOG_LEVELS = ['debug', 'info', 'warn', 'error']
15
+
16
+ function formatLogMessage(message) {
17
+ let text
18
+ try {
19
+ text = typeof message === 'string' ? message : JSON.stringify(message)
20
+ } catch {
21
+ text = String(message)
22
+ }
23
+ if (typeof text !== 'string' || text.length === 0) text = '(leer)'
24
+ return text.slice(0, RUNTIME_LIMITS.logMessageMax)
25
+ }
26
+
27
+ function resolveText(table, key) {
28
+ if (!table) return undefined
29
+ if (Object.hasOwn(table, key) && typeof table[key] === 'string') return table[key]
30
+ let node = table
31
+ for (const part of key.split('.')) {
32
+ if (node && typeof node === 'object' && Object.hasOwn(node, part)) node = node[part]
33
+ else return undefined
34
+ }
35
+ return typeof node === 'string' ? node : undefined
36
+ }
37
+
38
+ export function createTranslator({ locales = {}, locale, defaultLocale }) {
39
+ return (key, vars = {}) => {
40
+ const text = resolveText(locales[locale], key) ?? resolveText(locales[defaultLocale], key) ?? key
41
+ return text.replace(/\{(\w+)\}/g, (match, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : match))
42
+ }
43
+ }
44
+
45
+ function toError(error) {
46
+ if (error instanceof PluginCallError) return makeError(error.code, error.message)
47
+ return makeError('plugin_error', error?.message ?? String(error))
48
+ }
49
+
50
+ export function toJsonValue(value) {
51
+ if (value === undefined) return undefined
52
+ try {
53
+ return JSON.parse(JSON.stringify(value))
54
+ } catch {
55
+ throw new PluginCallError('plugin_error', 'Rückgabewert ist kein gültiges JSON')
56
+ }
57
+ }
58
+
59
+ export function createPluginRunner(plugin, { meta, call, locales, onRegister } = {}) {
60
+ if (!isPluginDefinition(plugin)) throw new TypeError('createPluginRunner: erwartet ein Ergebnis von definePlugin')
61
+ const registry = []
62
+ const als = new AsyncLocalStorage()
63
+ let phase = 'new'
64
+ let ctx = null
65
+ const defaultLocale = meta.defaultLocale ?? meta.locale
66
+ const supportedLocales = meta.supportedLocales ?? [defaultLocale]
67
+ const currentLocale = () => als.getStore()?.locale ?? defaultLocale
68
+ const translate = (key, vars) => createTranslator({ locales, locale: currentLocale(), defaultLocale })(key, vars)
69
+
70
+ const rpc = (method, args) => {
71
+ if (phase !== 'running') {
72
+ return Promise.reject(new PluginCallError('unavailable', 'ctx-Aufrufe sind nur innerhalb eines Aufrufs erlaubt, nicht in setup()'))
73
+ }
74
+ return Promise.resolve(call(method, args ?? {}, als.getStore() ?? null))
75
+ }
76
+
77
+ const register = (kind, name, handler, options) => {
78
+ if (phase !== 'setup') throw new PluginCallError('unavailable', 'Registrierungen sind nur in setup() erlaubt')
79
+ if (typeof name !== 'string' || !name) throw new TypeError(`${kind}: Name muss ein nicht leerer String sein`)
80
+ if (typeof handler !== 'function') throw new TypeError(`${kind} "${name}": Handler muss eine Funktion sein`)
81
+ if (kind !== 'event' && registry.some((r) => r.kind === kind && r.name === name)) {
82
+ throw new TypeError(`doppelte Registrierung: ${kind}:${name}`)
83
+ }
84
+ registry.push({ kind, name, handler, options })
85
+ }
86
+
87
+ const log = Object.fromEntries(LOG_LEVELS.map((level) => [level, (message) => {
88
+ const store = als.getStore()
89
+ const promise = rpc('log.write', { level, message: formatLogMessage(message) }).catch(() => {})
90
+ store?.pending.add(promise)
91
+ promise.finally(() => store?.pending.delete(promise))
92
+ }]))
93
+
94
+ const interactionApi = Object.freeze({
95
+ reply: (message) => rpc('interaction.reply', { message }),
96
+ update: (message) => rpc('interaction.update', { message }),
97
+ defer: (options) => rpc('interaction.defer', options === undefined ? {} : options),
98
+ followUp: (message) => rpc('interaction.followUp', { message }),
99
+ showModal: (modal) => rpc('interaction.showModal', { modal })
100
+ })
101
+
102
+ const discord = Object.freeze(Object.fromEntries(
103
+ Object.keys(DISCORD_ACTIONS).map((action) => [action, (args = {}) => rpc(`discord.${action}`, args)])
104
+ ))
105
+
106
+ function buildCtx() {
107
+ return Object.freeze({
108
+ meta: Object.freeze({
109
+ guildId: meta.guildId,
110
+ pluginId: meta.pluginId,
111
+ get locale() {
112
+ return currentLocale()
113
+ }
114
+ }),
115
+ commands: Object.freeze({ on: (name, handler) => register('command', name, handler) }),
116
+ events: Object.freeze({ on: (name, handler) => register('event', name, handler) }),
117
+ interactions: Object.freeze({
118
+ button: (name, handler) => register('button', name, handler),
119
+ modal: (name, handler) => register('modal', name, handler),
120
+ select: (name, handler) => register('select', name, handler)
121
+ }),
122
+ jobs: Object.freeze({ schedule: (name, options, handler) => register('job', name, handler, options) }),
123
+ storage: Object.freeze({
124
+ kv: Object.freeze({
125
+ get: (key) => rpc('storage.kv.get', { key }),
126
+ set: (key, value, options) => rpc('storage.kv.set', {
127
+ key, value, ...(options && options.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {})
128
+ }),
129
+ delete: (key) => rpc('storage.kv.delete', { key }),
130
+ list: (options) => rpc('storage.kv.list', options === undefined ? {} : options),
131
+ incr: (key, by) => rpc('storage.kv.incr', by === undefined ? { key } : { key, by })
132
+ })
133
+ }),
134
+ config: Object.freeze({
135
+ get: (key) => rpc('config.get', { key }),
136
+ getAll: () => rpc('config.getAll', {})
137
+ }),
138
+ http: Object.freeze({ fetch: (url, options) => rpc('http.fetch', { ...(options || {}), url }) }),
139
+ discord,
140
+ ui: Object.freeze({
141
+ data: (name, handler) => register('uiData', name, handler),
142
+ action: (name, handler) => register('uiAction', name, handler)
143
+ }),
144
+ log: Object.freeze(log),
145
+ t: translate
146
+ })
147
+ }
148
+
149
+ function selectHandlers(msg) {
150
+ if (msg.kind === 'event') return registry.filter((r) => r.kind === 'event' && r.name === msg.name)
151
+ if (msg.kind === 'command') return registry.filter((r) => r.kind === 'command' && r.name === msg.interaction.commandName)
152
+ if (msg.kind === 'interaction') {
153
+ const kind = msg.interaction.kind
154
+ const id = msg.interaction.customId
155
+ const prefix = `plugin:${meta.pluginId}:`
156
+ const stripped = id.startsWith(prefix) ? id.slice(prefix.length) : id
157
+ const ofKind = registry.filter((r) => r.kind === kind)
158
+ for (const candidate of [id, stripped]) {
159
+ const exact = ofKind.filter((r) => r.name === candidate)
160
+ if (exact.length > 0) return exact
161
+ }
162
+ const byPrefix = ofKind.find((r) => stripped.startsWith(`${r.name}:`))
163
+ return byPrefix ? ofKind.filter((r) => r.name === byPrefix.name) : []
164
+ }
165
+ return registry.filter((r) => r.kind === msg.kind && r.name === msg.name)
166
+ }
167
+
168
+ function runHandler(handler, msg) {
169
+ switch (msg.kind) {
170
+ case 'event': return handler(msg.payload, ctx)
171
+ case 'command':
172
+ case 'interaction': return handler(msg.interaction, interactionApi)
173
+ case 'job': return handler(Object.freeze({ name: msg.name }))
174
+ default: return handler(msg.params ?? {}, Object.freeze({ viewer: msg.viewer ?? null }))
175
+ }
176
+ }
177
+
178
+ async function start() {
179
+ if (phase !== 'new') throw new Error('Runner ist bereits gestartet')
180
+ phase = 'setup'
181
+ ctx = buildCtx()
182
+ await plugin.setup(ctx)
183
+ phase = 'running'
184
+ const registrations = registry.map((r) => ({ kind: r.kind, name: r.name, ...(r.options ? { options: r.options } : {}) }))
185
+ if (onRegister) await onRegister(registrations)
186
+ return registrations
187
+ }
188
+
189
+ async function invoke(msg) {
190
+ const requested = typeof msg.interaction?.locale === 'string' ? msg.interaction.locale.slice(0, 2).toLowerCase() : null
191
+ const inv = {
192
+ locale: requested && supportedLocales.includes(requested) ? requested : defaultLocale,
193
+ id: msg.id,
194
+ kind: msg.kind,
195
+ guildId: msg.guildId,
196
+ actorUserId: msg.actorUserId ?? null,
197
+ pending: new Set(),
198
+ state: { calls: 0, http: 0, replied: false, deferred: false }
199
+ }
200
+ let timer
201
+ const deadline = new Promise((resolve) => {
202
+ timer = setTimeout(() => resolve({ ok: false, error: makeError('timeout', 'Frist überschritten') }), msg.deadlineMs ?? RUNTIME_LIMITS.invocationDeadlineMs)
203
+ })
204
+ const work = als.run(inv, async () => {
205
+ try {
206
+ if (phase !== 'running') throw new PluginCallError('unavailable', 'Runner ist nicht gestartet')
207
+ const handlers = selectHandlers(msg)
208
+ if (handlers.length === 0) throw new PluginCallError('not_registered', 'Kein Handler registriert')
209
+ let value
210
+ let firstError = null
211
+ for (const entry of handlers) {
212
+ try {
213
+ const result = await runHandler(entry.handler, msg)
214
+ if (result !== undefined && value === undefined) value = result
215
+ } catch (error) {
216
+ if (firstError === null) firstError = error
217
+ }
218
+ }
219
+ await Promise.allSettled([...inv.pending])
220
+ if (firstError !== null) throw firstError
221
+ if (msg.kind === 'uiData' || msg.kind === 'uiAction') return { ok: true, value: toJsonValue(value) }
222
+ return { ok: true, value: undefined }
223
+ } catch (error) {
224
+ return { ok: false, error: toError(error) }
225
+ }
226
+ })
227
+ try {
228
+ return await Promise.race([work, deadline])
229
+ } finally {
230
+ clearTimeout(timer)
231
+ }
232
+ }
233
+
234
+ async function stop() {
235
+ const previous = phase
236
+ phase = 'stopped'
237
+ if (previous === 'running' && typeof plugin.teardown === 'function') await plugin.teardown(ctx)
238
+ }
239
+
240
+ return { start, invoke, stop, registry: () => registry.map(({ kind, name, options }) => ({ kind, name, options })) }
241
+ }
package/src/storage.js ADDED
@@ -0,0 +1,87 @@
1
+ import { utf8Size } from '@texisoft/nexus-plugin-sdk'
2
+ import { PluginCallError } from './runner.js'
3
+
4
+ export function createMemoryKv({ quotaKb = 0, now = () => Date.now() } = {}) {
5
+ const quotaBytes = quotaKb * 1024
6
+ const map = new Map()
7
+
8
+ const entrySize = (key, value) => utf8Size(key) + utf8Size(value)
9
+
10
+ function purge() {
11
+ const t = now()
12
+ for (const [key, entry] of map) {
13
+ if (entry.expiresAt !== null && entry.expiresAt <= t) map.delete(key)
14
+ }
15
+ }
16
+
17
+ function usedBytes() {
18
+ purge()
19
+ let total = 0
20
+ for (const [key, entry] of map) total += entrySize(key, entry.value)
21
+ return total
22
+ }
23
+
24
+ function assertQuota(key, value) {
25
+ const existing = map.get(key)
26
+ const next = usedBytes() - (existing ? entrySize(key, existing.value) : 0) + entrySize(key, value)
27
+ if (next > quotaBytes) {
28
+ throw new PluginCallError('quota_exceeded', `Speicherkontingent von ${quotaKb} KB überschritten`)
29
+ }
30
+ }
31
+
32
+ function get(key) {
33
+ purge()
34
+ const entry = map.get(key)
35
+ return entry ? structuredClone(entry.value) : null
36
+ }
37
+
38
+ function set(key, value, { ttlSeconds } = {}) {
39
+ purge()
40
+ const cloned = structuredClone(value)
41
+ assertQuota(key, cloned)
42
+ map.set(key, { value: cloned, expiresAt: ttlSeconds ? now() + ttlSeconds * 1000 : null })
43
+ }
44
+
45
+ function remove(key) {
46
+ purge()
47
+ map.delete(key)
48
+ }
49
+
50
+ function list({ prefix = '', limit = 100, cursor } = {}) {
51
+ purge()
52
+ const keys = [...map.keys()].filter((k) => k.startsWith(prefix)).sort()
53
+ const start = cursor ? keys.findIndex((k) => k > cursor) : 0
54
+ if (start === -1) return { keys: [] }
55
+ const page = keys.slice(start, start + limit)
56
+ const more = start + limit < keys.length
57
+ return more ? { keys: page, cursor: page[page.length - 1] } : { keys: page }
58
+ }
59
+
60
+ function incr(key, by = 1) {
61
+ purge()
62
+ const existing = map.get(key)
63
+ const current = existing ? existing.value : 0
64
+ if (typeof current !== 'number' || !Number.isFinite(current)) {
65
+ throw new PluginCallError('invalid_args', `Wert von "${key}" ist keine Zahl`)
66
+ }
67
+ const next = current + by
68
+ assertQuota(key, next)
69
+ map.set(key, { value: next, expiresAt: existing ? existing.expiresAt : null })
70
+ return next
71
+ }
72
+
73
+ return {
74
+ get,
75
+ set,
76
+ delete: remove,
77
+ list,
78
+ incr,
79
+ usedBytes,
80
+ quotaBytes: () => quotaBytes,
81
+ entries: () => {
82
+ purge()
83
+ return Object.fromEntries([...map].map(([k, e]) => [k, structuredClone(e.value)]))
84
+ },
85
+ clear: () => map.clear()
86
+ }
87
+ }