rift-plugin 0.2.0

Guest SDK for Rift WebAssembly plugins (rift:plugin@0.2.0)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
/// The Rift plugin contract.
///
/// One event schema and one host API, shared by every execution mode: the
/// sandboxed configuration script, persistent Lua and WebAssembly plugins and
/// remote bus responders. Rust host types mirror these definitions with serde
/// derives; the bus uses their JSON form.
///
/// Rules that keep C and Go guests pleasant (see docs/plugins.md):
/// - WASI 0.2, no `async`, no `stream` or `future`.
/// - No `resource` types: host-managed things are plain `u64` handles.
/// - Plain data only: no `flags`, no nested options, tuples only as pairs.
/// - Plugin configuration and free-form payloads are JSON strings.
/// - Every hook is an export and every export has a default in each SDK.
/// - The guest is single-threaded; the host never calls one instance
///   concurrently.
/// - Additive changes keep compiled components loading: new host functions,
///   new types and new interfaces, never a changed type or a new case in an
///   existing enum or variant. Wasmtime matches `@0.2.x` names with each
///   other, so a component built against 0.2.0 loads on every later 0.2.
/// - Breaking changes move to the next minor version until 1.0. 0.2.0 is
///   0.1.5 without its duplicates: one `text` type instead of plain and
///   `-json` forms, one `decision`, and `dialog`, `chat` and `kick` in
///   `handler`. Components built against 0.1 do not load.
package rift:plugin@0.2.0;

interface types {
    /// Log severity.
    enum level {
        trace,
        debug,
        info,
        warn,
        error,
    }

    /// Text shown to a player.
    variant text {
        /// 1..=1024 bytes, shown exactly as written.
        plain(string),
        /// A JSON text component of at most 32 KiB, for colours, line
        /// breaks, hover text and click events, including the `mini` and
        /// `legacy` authoring objects (see docs/text.md). Rift spells hover
        /// and click events the way each player's client reads them.
        json(string),
    }

    /// One property of a player's profile, as the session server signed it.
    record profile-property {
        /// For example `textures`, the skin and cape.
        name: string,
        value: string,
        signature: option<string>,
    }

    /// A joined player in the process-wide directory.
    record player-info {
        connection-id: u64,
        /// 32 lowercase hexadecimal digits, without dashes.
        uuid: string,
        name: string,
        /// The backend the player is connected to.
        server: string,
        protocol: s32,
        /// The address the player connected from, without the port.
        ip: string,
        /// The profile properties the player logged in with.
        properties: list<profile-property>,
        /// The permission node a viewer needs to see the player, from their
        /// `rift.visible-to` meta, or `none` when everyone sees them. `list`
        /// and `get` return hidden players too; Rift leaves them out of tab
        /// lists, counts and completion for viewers without the node.
        visible-to: option<string>,
        /// The language the player's client reported, as the client spells
        /// it (`en_us`; old clients send `en_US`), or `none` while Rift does
        /// not know it yet. The client sends it after login: during
        /// configuration from 1.20.2, after Join Game before.
        locale: option<string>,
        /// The language Rift's own messages use for the player: the one set
        /// with `players.set-language`, else `locale`.
        language: option<string>,
    }

    /// What Rift remembers about a player who joined at least once, online
    /// or not.
    record player-record {
        uuid: string,
        /// The name the player last joined with.
        name: string,
        /// Unix seconds.
        first-seen: u64,
        /// Unix seconds: the last join or quit.
        last-seen: u64,
        /// Empty when `players.record_ip` is off.
        last-ip: string,
        last-server: string,
        online: bool,
        /// The language set with `players.set-language`, if any.
        language: option<string>,
    }

    /// The session an authenticated player hook is about. Fields that do not
    /// apply to a hook are `none` or empty.
    record context {
        connection-id: u64,
        uuid: string,
        name: string,
        listener: string,
        hostname: string,
        peer-ip: string,
        protocol: s32,
        server: option<string>,
        default-server: option<string>,
        /// `before-transfer`/`after-transfer`: the destination.
        target: option<string>,
        /// `before-transfer`: why the transfer happens; `disconnect`: why the
        /// session closed.
        reason: option<string>,
        /// `after-transfer`: whether the transfer completed.
        success: option<bool>,
        /// `command`/`suggest`, and `chat` for a command: the command name
        /// and the text after it.
        command: option<string>,
        args: string,
        /// `suggest`: the argument being completed and its 1-based position.
        word: string,
        position: u32,
        /// The language set for the player with `players.set-language`, known
        /// from `login` on, else the client's locale once it is known.
        language: option<string>,
        /// `command`/`suggest`: the operator console runs the command, not a
        /// player. `connection-id` is 0, `uuid` is empty and `name` is
        /// `CONSOLE`; only commands registered with `console` get here.
        console: bool,
    }

    /// Metadata available right after TCP accept, before any bytes are read.
    record connection {
        listener: string,
        peer-addr: string,
        peer-ip: string,
        peer-port: u16,
        local-addr: string,
        local-ip: string,
        local-port: u16,
        default-backend: option<string>,
    }

    /// `route`: keep the configured policy, pick a configured backend or
    /// group, or close the connection.
    variant route-decision {
        default,
        backend(string),
        reject(option<string>),
    }

    /// The answer to a decision hook. Each hook accepts a subset:
    /// - login, before-transfer, chat: continue, deny
    /// - initial-server, kick: continue, deny, server
    /// - command: continue, message, server, queue, leave-queue
    variant decision {
        continue,
        deny(text),
        server(string),
        message(text),
        queue(string),
        leave-queue,
    }

    record suggestion {
        text: string,
        tooltip: option<text>,
    }

    /// A server-list ping, before any backend contact.
    record ping {
        listener: string,
        hostname: string,
        protocol: s32,
        peer-ip: string,
        /// The routed backend or group; `none` for an unmatched hostname.
        backend: option<string>,
        maintenance: bool,
        online: u64,
        max-players: u64,
    }

    /// Fields a `status` hook replaces for one ping. Clients show the
    /// version and the sample lines as `ยง` codes.
    record status-override {
        motd: option<text>,
        version: option<text>,
        protocol: option<s32>,
        online: option<u32>,
        max-players: option<u32>,
        sample: option<list<text>>,
        /// A `data:image/png;base64,...` URI.
        favicon: option<string>,
    }

    /// An authenticated request to `/ext` or `/ext/*` on the web server.
    record http-request {
        method: string,
        path: string,
        query: string,
        body: string,
        headers: list<tuple<string, string>>,
        /// The status object with operator detail, as a JSON object, plus
        /// `operator`: the authenticated caller as `{name, permissions,
        /// groups}`. `permissions` is `null` for full access and `groups` is
        /// `null` when the caller is not limited to service groups. Any
        /// caller with the `extensions` permission reaches this hook, so check
        /// `operator` before acting for it.
        context-json: string,
    }

    record http-response {
        status: u16,
        content-type: string,
        body: string,
    }

    /// A bus message delivered to a subscription made with `bus.subscribe`.
    record bus-message {
        /// The handle `bus.subscribe` returned.
        handle: u64,
        subject: string,
        payload: list<u8>,
        reply: option<string>,
    }

    /// A Rift event, mirroring a `rift.events.*` publication.
    record rift-event {
        /// For example `rift.events.players` or `rift.events.instances`.
        subject: string,
        /// The event body as published on the bus (a JSON object).
        payload-json: string,
    }

    /// How a command's arguments are completed.
    variant suggest-kind {
        none,
        /// A fixed list, filtered by prefix.
        fixed(list<string>),
        /// Online player names.
        players,
        /// Servers the player may join.
        servers,
        /// Ask the plugin's `suggest` export.
        dynamic,
    }

    record command-spec {
        /// 1..=64 bytes of `a-z0-9_-`; `server` and `hub` are reserved.
        name: string,
        /// Other names for the command, each like `name`, at most 16. The
        /// hooks always see `name` in `ctx.command`.
        aliases: list<string>,
        /// The permission node a player needs to see and run the command.
        /// Empty means every player may see and run it.
        permission: string,
        suggest: suggest-kind,
        /// Operators may run the command from the console (`rift admin
        /// plugins run`); the hook then sees `ctx.console`.
        console: bool,
    }

    /// Events a plugin can receive. The host skips exports a plugin does not
    /// list, so unused hooks cost nothing per event.
    enum hook {
        route,
        login,
        initial-server,
        before-transfer,
        command,
        suggest,
        status,
        http,
        join,
        after-transfer,
        disconnect,
        message,
        timer,
        event,
        /// Never implied by an empty list: Rift asks about every message of
        /// every player.
        chat,
        /// Never implied by an empty list.
        kick,
    }

    /// What `init` returns. Subscriptions and timers are created from inside
    /// `init` through the host API.
    record registration {
        commands: list<command-spec>,
        /// Events to deliver. An empty list delivers every hook except
        /// `chat` and `kick`. `dialog` and `job-done` need no entry: they go
        /// to the plugin that showed the dialog or started the job.
        hooks: list<hook>,
    }

    /// A boss bar shown to one player.
    record boss-bar-spec {
        text: text,
        /// 0.0 to 1.0.
        progress: f32,
        /// pink, blue, red, green, yellow, purple or white.
        color: string,
        /// progress, notched_6, notched_10, notched_12 or notched_20.
        style: string,
        darken-sky: bool,
        play-music: bool,
        create-fog: bool,
    }

    /// A title; times are ticks. At least one of `title` and `subtitle` is
    /// set; without `title` only the subtitle is shown.
    record title-spec {
        title: option<text>,
        subtitle: option<text>,
        fade-in: s32,
        stay: s32,
        fade-out: s32,
    }

    /// What a player sent through the chat box.
    enum chat-kind {
        /// A chat message.
        message,
        /// A command that neither Rift nor a plugin owns, on its way to the
        /// backend.
        command,
    }

    /// A chat message or backend command a player sent, before the backend
    /// sees it; the input of the `chat` hook.
    record chat-attempt {
        /// The player. `server` is their current backend. For `command`,
        /// `ctx.command` is the root name and `ctx.args` the rest.
        ctx: context,
        kind: chat-kind,
        /// The message, or the command line without the leading slash.
        text: string,
    }

    /// A click on a plugin action of a dialog this plugin showed with
    /// `players.dialog`.
    record dialog-response {
        /// The player who clicked. `server` is their current backend; the
        /// command, target and suggestion fields are empty.
        ctx: context,
        /// The action's `id` as the plugin wrote it, such as `submit`.
        action: string,
        /// The player's input values by key, as a JSON object: strings for
        /// text and single-option inputs, booleans, numbers. `{}` for a
        /// `custom` action. They come from the client: check them like any
        /// player input.
        values-json: string,
        /// The action's `payload` (`custom`) or `additions`
        /// (`dynamic/custom`) as JSON, exactly as the plugin wrote it. Rift
        /// keeps it, so the client can neither see nor change it.
        payload-json: option<string>,
    }
}

/// The joined players. `target` is a name, a UUID or a connection id.
interface players {
    use types.{player-info, player-record, title-spec, boss-bar-spec, text};

    /// Players, optionally only those on one server or service group.
    %list: func(server: option<string>) -> result<list<player-info>, string>;
    get: func(target: string) -> result<option<player-info>, string>;
    /// Names starting with `prefix`, sorted.
    names: func(prefix: option<string>) -> list<string>;
    /// Each returns whether the player was online.
    message: func(target: string, text: text) -> result<bool, string>;
    action-bar: func(target: string, text: text) -> result<bool, string>;
    title: func(target: string, title: title-spec) -> result<bool, string>;
    /// Both `none` restores the configured tab list.
    tab-list: func(target: string, header: option<text>, footer: option<text>) -> result<bool, string>;
    /// `none` removes the bar called `name`.
    boss-bar: func(target: string, name: string, bar: option<boss-bar-spec>) -> result<bool, string>;
    kick: func(target: string, reason: text) -> result<bool, string>;
    /// Show a dialog to a player on Minecraft 1.21.6 or newer, replacing any
    /// open one. `dialog-json` is a dialog in the vanilla JSON format; see
    /// docs/dialogs.md for the accepted subset. Buttons whose `custom` or
    /// `dynamic/custom` action has an `id` without a namespace come back
    /// through the `dialog` hook. Returns false when the player is offline,
    /// older than 1.21.6 or has too many undelivered actions; an invalid
    /// dialog is an error.
    dialog: func(target: string, dialog-json: string) -> result<bool, string>;
    /// Close the dialog this Rift showed, if it is still open.
    clear-dialog: func(target: string) -> result<bool, string>;
    transfer: func(target: string, server: string) -> result<bool, string>;
    /// Returns how many players received the message.
    broadcast: func(text: text) -> result<u32, string>;
    /// What Rift remembers about a player who joined at least once, by UUID
    /// or by the name they last joined with (case-insensitive), whether they
    /// are online or not. `none` for a player Rift never saw.
    lookup: func(target: string) -> result<option<player-record>, string>;
    /// Remembered players whose name starts with `prefix`, most recently
    /// seen first, at most `limit` (1..=100).
    search: func(prefix: string, limit: u32) -> result<list<player-record>, string>;
    /// Set the language Rift's own messages use for a player, online or not:
    /// a tag of 2..=16 bytes of `a-z0-9_`, such as `de` or `de_de`; `none`
    /// goes back to the client's locale. `target` is a UUID or a name Rift
    /// has seen. It is remembered across sessions, so it is known at
    /// `login`. Returns whether the player is known.
    set-language: func(target: string, language: option<string>) -> result<bool, string>;
}

/// The durable key-value store. Namespaces are prefixed with
/// `plugin.<name>.` for persistent plugins.
interface store {
    get: func(namespace: string, key: string) -> result<option<list<u8>>, string>;
    set: func(namespace: string, key: string, value: list<u8>) -> result<_, string>;
    /// Returns whether an entry existed.
    delete: func(namespace: string, key: string) -> result<bool, string>;
    /// Adds `delta` to an integer entry (missing counts as 0) and returns it.
    increment: func(namespace: string, key: string, delta: s64) -> result<s64, string>;
    /// Entries whose key starts with `prefix`, sorted by key.
    %list: func(namespace: string, prefix: string) -> result<list<tuple<string, list<u8>>>, string>;
}

/// Rift's permission store: user nodes and group memberships.
interface permissions {
    /// Whether the node is true in the player's current contexts.
    has: func(uuid: string, node: string) -> bool;
    /// Set the user's global permission node; `none` removes it.
    set: func(uuid: string, node: string, value: option<bool>) -> result<_, string>;
    /// Add the user to a group, until `expires-at` Unix seconds if given
    /// (replacing an earlier expiry). Returns whether anything changed.
    add-group: func(uuid: string, group: string, expires-at: option<u64>) -> result<bool, string>;
    remove-group: func(uuid: string, group: string) -> result<bool, string>;
    /// The player's groups, including inherited and `default`.
    groups: func(uuid: string) -> list<string>;
    /// Every group with its weight, display name and nodes, as a JSON object.
    list-groups: func() -> string;
    /// One meta value in the player's current contexts.
    meta: func(uuid: string, key: string) -> option<string>;
    /// Set the user's meta node `key` in exactly the contexts of
    /// `contexts-json` (a JSON object such as `{"server":"lobby-1"}`; `none`
    /// is global), expiring at `expires-at` Unix seconds if given; a `none`
    /// value removes the node in those contexts. Returns whether anything
    /// changed.
    set-meta: func(uuid: string, key: string, value: option<string>, contexts-json: option<string>, expires-at: option<u64>) -> result<bool, string>;
}

/// The message bus.
interface bus {
    /// Returns how many subscribers received the message.
    publish: func(subject: string, payload: list<u8>, reply: option<string>) -> result<u32, string>;
    /// Send a request and wait for the first reply.
    request: func(subject: string, payload: list<u8>, timeout-ms: u32) -> result<list<u8>, string>;
    /// Messages arrive through the `message` export with this handle.
    /// `queue-group` load-balances between subscribers of the same group.
    subscribe: func(subject: string, queue-group: option<string>) -> result<u64, string>;
    unsubscribe: func(handle: u64) -> bool;
    /// Append to a durable stream; returns the sequence number.
    stream-publish: func(subject: string, payload: list<u8>) -> result<u64, string>;
}

/// Managed servers, through the same `rift.control.*` operations as the bus.
/// Results are the control service's JSON replies.
interface servers {
    create: func(group: string) -> result<string, string>;
    remove: func(name: string) -> result<string, string>;
    start: func(name: string) -> result<string, string>;
    stop: func(name: string) -> result<string, string>;
    download: func(template: string) -> result<string, string>;
    /// Any other `rift.control.<operation>` (status, servers, groups, nodes,
    /// templates, transfer, drain, maintenance, reload) with a JSON payload.
    control: func(operation: string, payload-json: string) -> result<string, string>;
}

/// Timers fire through the `timer` export with the returned handle.
interface timers {
    /// Repeats every `ms` milliseconds (at least 10).
    every: func(ms: u32) -> u64;
    /// Fires once after `ms` milliseconds.
    after: func(ms: u32) -> u64;
    /// Returns whether the timer existed.
    cancel: func(handle: u64) -> bool;
}

/// Work that must not hold up the plugin's hooks. A plugin runs one call at
/// a time, so a slow lookup in `login` would keep `chat` waiting past its
/// deadline. Instead the hook defers its answer, hands the lookup to a
/// background worker and returns; the answer follows from `job-done`.
interface background {
    use types.{decision};

    /// Call inside `login`, `initial-server`, `before-transfer`, `command`,
    /// `chat` or `kick`: the hook's own return value is ignored, and the
    /// answer is the one given to `resolve` with the returned ticket. The
    /// hook's deadline keeps running: an answer that comes too late counts
    /// as a missed deadline. An error outside those hooks, or when the call
    /// was deferred already.
    defer: func() -> result<u64, string>;
    /// Answer a deferred hook. Returns false when nobody waits for the
    /// ticket any more (the deadline passed, or it was answered).
    resolve: func(ticket: u64, decision: decision) -> result<bool, string>;
    /// Queue `payload` for the plugin's background workers (manifest
    /// `[background] workers`, an error without any): one of them calls
    /// `work(payload)`, and its result comes back through `job-done` with
    /// the returned handle. At most 1024 jobs wait at a time.
    run: func(payload: list<u8>) -> result<u64, string>;
    /// Whether this instance is a background worker.
    is-worker: func() -> bool;
}

/// Rift's log stream, prefixed with the plugin name.
interface log {
    use types.{level};
    log: func(level: level, message: string);
}

interface meta {
    name: func() -> string;
    version: func() -> string;
    /// Guest path of the plugin's private data directory (`/data`).
    data-dir: func() -> string;
    /// Guest path of the configuration directory (`/host`), if granted.
    config-dir: func() -> option<string>;
    /// Monotonic seconds since Rift started.
    clock: func() -> f64;
    /// Unix time in seconds.
    time: func() -> s64;
    /// The `rift:plugin` version this host implements, such as `0.2.0`.
    api: func() -> string;
}

/// Hooks the host calls. Every export has a default in each SDK, and the
/// host skips hooks the plugin does not list in its registration.
interface handler {
    use types.{
        context, connection, route-decision, decision, suggestion, ping,
        status-override, http-request, http-response, bus-message, rift-event,
        registration, dialog-response, chat-attempt,
    };

    /// Called once per instance with the `rift.plugin(name, config)` table
    /// encoded as JSON (`{}` without one).
    init: func(config-json: string) -> result<registration, string>;
    /// The configuration changed and the manifest sets `hot_reload = true`.
    reload: func(config-json: string) -> result<_, string>;
    shutdown: func(reason: string);

    route: func(connection: connection) -> route-decision;
    login: func(ctx: context) -> decision;
    initial-server: func(ctx: context) -> decision;
    before-transfer: func(ctx: context) -> decision;
    command: func(ctx: context) -> decision;
    suggest: func(ctx: context) -> list<suggestion>;
    status: func(ping: ping) -> option<status-override>;
    http: func(request: http-request) -> option<http-response>;
    /// A chat message or a backend command before the backend sees it, for
    /// mutes and filters; only for a registration that lists `chat`.
    /// `continue` passes the packet on unchanged; `deny` drops it and shows
    /// the player the text. Withholding one signed message breaks a player's
    /// signature chain, so unless the proxy sets
    /// `authentication.signed_chat = false`, the first deny costs that player
    /// their chat signing until they reconnect. Commands that Rift or a
    /// plugin owns never come here. The deadline is 50 ms unless the manifest
    /// sets `deadlines.chat`.
    chat: func(attempt: chat-attempt) -> decision;
    /// A backend kicked a player in play; only for a registration that lists
    /// `kick`. `ctx.server` is the backend and `ctx.reason` its reason
    /// without formatting. `continue` leaves it to `network.on_kick`, `deny`
    /// disconnects the player with the text, `server` moves them there.
    kick: func(ctx: context) -> decision;

    join: func(ctx: context);
    after-transfer: func(ctx: context);
    disconnect: func(ctx: context);
    message: func(message: bus-message);
    timer: func(handle: u64);
    event: func(event: rift-event);
    /// A player clicked a plugin action of a dialog this plugin showed.
    dialog: func(response: dialog-response);
    /// A job started with `background.run` finished: what `work` returned,
    /// or why it failed.
    job-done: func(handle: u64, outcome: result<list<u8>, string>);

    /// Background workers only (manifest `[background] workers`): each is
    /// its own instance of the component with its own memory, and receives
    /// no hooks and makes no registration. `work-init` runs once per worker
    /// before its first job, with the plugin's configuration.
    work-init: func(config-json: string) -> result<_, string>;
    /// One job queued with `background.run`, on a worker. It may block: the
    /// plugin's hooks keep running meanwhile. The limit is 30 s unless the
    /// manifest sets `deadlines.work`.
    work: func(payload: list<u8>) -> result<list<u8>, string>;
}

/// Every import and export, without WASI: what the host requires.
world core {
    import players;
    import store;
    import permissions;
    import bus;
    import servers;
    import timers;
    import background;
    import log;
    import meta;
    export handler;
}

/// The world guests build against. TinyGo's `wasip2` target expects the
/// WASI CLI import set even when a plugin does not use it.
world plugin {
    include wasi:cli/imports@0.2.0;
    include core;
}