@dolphy-app/extension-sdk 0.4.0 → 0.6.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.
@@ -20,49 +20,31 @@ File `extension.json` (events):
20
20
  "name": "Day streak",
21
21
  "description": "Counts the days in a row with a closed attempt and shows the streak.",
22
22
  "author": "your-github-login",
23
- "permissions": ["learning.events"],
24
- "tags": ["learning"],
25
- "contributes": {
26
- "events": [{ "event": "attempt.closed" }],
27
- "commands": [
28
- { "id": "acme.hello.show", "title": "Show the streak", "category": "Streak" },
29
- { "id": "acme.hello.data", "title": "Streak data", "palette": false }
30
- ],
31
- "panels": [{ "id": "acme.hello.view", "title": "Streak" }]
32
- }
23
+ "tags": ["learning"]
33
24
  }
34
25
  ```
35
26
 
36
- - `permissions: ["learning.events"]` and `contributes.events` are both required
37
- to receive an event. The events are `session.started`, `session.finished` and
38
- `attempt.closed`. The payloads carry ids, the grade, the outcome and the time,
39
- never the learner's answer or the exercise text.
40
- - Mention the permission in your `README.md` and say what it is for: the catalog
41
- review asks.
42
- - Storage and settings need no permission.
27
+ Nothing about events is in the manifest: `server.on` is the subscription.
43
28
 
44
- ## The code
29
+ ## The server part
45
30
 
46
31
  File `src/index.ts` (events):
47
32
 
48
33
  ```ts
49
- import {
50
- defineExtension,
51
- defineExtensionPanel,
52
- inActivate,
53
- notify,
54
- openPanel,
55
- } from '@dolphy-app/extension-sdk';
56
- import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
34
+ export { client } from './client.ts';
35
+ export { server } from './server.ts';
36
+ ```
57
37
 
38
+ File `src/streak.ts` (events):
39
+
40
+ ```ts
58
41
  // a `type`, not an `interface`: an interface has no index signature and is
59
- // not JSON for `ctx.storage`
42
+ // not JSON for `server.storage`
60
43
  export type Streak = {
61
44
  days: number;
62
45
  last: string;
63
46
  };
64
47
 
65
- const KEY = 'streak';
66
48
  const DAY_MS = 86_400_000;
67
49
 
68
50
  const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
@@ -76,69 +58,111 @@ export const advance = (streak: Streak | undefined, at: number): Streak => {
76
58
  streak !== undefined && dayOf(Date.parse(streak.last) + DAY_MS) === day;
77
59
  return { days: continues ? streak.days + 1 : 1, last: day };
78
60
  };
61
+ ```
79
62
 
80
- // every event and command declared in extension.json is listed here;
81
- // `inActivate` means "registered in `activate`": the handlers need `ctx`
82
- export const host = defineExtension({
83
- events: { 'attempt.closed': inActivate },
84
- commands: { 'acme.hello.show': inActivate, 'acme.hello.data': inActivate },
85
- activate(ctx) {
86
- // delivered asynchronously, once per recorded attempt
87
- ctx.events.on('attempt.closed', async ({ at, outcome }) => {
88
- if (outcome === 'gave-up') return;
89
- const streak = await ctx.storage.get<Streak>(KEY);
90
- await ctx.storage.set(KEY, advance(streak, at));
91
- });
63
+ File `src/server.ts` (events):
92
64
 
93
- // data for the panel: hidden from the palette, the panel calls it
94
- ctx.commands.register(
95
- 'acme.hello.data',
96
- async () => (await ctx.storage.get<Streak>(KEY)) ?? { days: 0, last: '' },
97
- );
65
+ ```ts
66
+ import { defineServer, notify, openPanel } from '@dolphy-app/extension-sdk';
67
+ import { advance } from './streak.ts';
68
+ import type { Streak } from './streak.ts';
69
+
70
+ const KEY = 'streak';
98
71
 
99
- ctx.commands.register('acme.hello.show', async () => {
100
- const streak = await ctx.storage.get<Streak>(KEY);
72
+ // runs in the extension host: every call registers a contribution
73
+ export const server = defineServer((s) => {
74
+ // delivered asynchronously, once per recorded attempt
75
+ s.on('attempt.closed', async ({ at, outcome }) => {
76
+ if (outcome === 'gave-up') return;
77
+ const streak = await s.storage.get<Streak>(KEY);
78
+ await s.storage.set(KEY, advance(streak, at));
79
+ });
80
+
81
+ // data for the panel: hidden from the palette, the panel calls it
82
+ s.registerCommand({
83
+ id: 'acme.hello.data',
84
+ title: 'Streak data',
85
+ palette: false,
86
+ run: async () =>
87
+ (await s.storage.get<Streak>(KEY)) ?? { days: 0, last: '' },
88
+ });
89
+
90
+ s.registerCommand({
91
+ id: 'acme.hello.show',
92
+ title: { en: 'Show the streak', ru: 'Показать серию' },
93
+ category: 'Streak',
94
+ run: async () => {
95
+ const streak = await s.storage.get<Streak>(KEY);
101
96
  if (streak === undefined) {
102
97
  return notify('No streak yet: finish your first exercise.');
103
98
  }
104
99
  return openPanel('acme.hello.view', { days: streak.days });
105
- });
106
- },
107
- });
108
-
109
- // the panel runs in an isolated frame: no network, the only way out is `ctx.call`
110
- export const panels = {
111
- 'acme.hello.view': defineExtensionPanel({
112
- async mount(container, ctx) {
113
- const line = container.ownerDocument.createElement('p');
114
- container.append(line);
115
- const render = async () => {
116
- const streak = (await ctx.call('acme.hello.data')) as Streak;
117
- line.textContent =
118
- streak.days === 0
119
- ? 'No streak yet.'
120
- : `Streak: ${streak.days} days, last day ${streak.last}`;
121
- };
122
- // the command opens the panel again with new properties: redraw
123
- ctx.signal.addEventListener(
124
- 'abort',
125
- ctx.onProps(() => void render()),
126
- );
127
- await render();
128
100
  },
129
- }),
130
- } satisfies ExtensionPanels;
101
+ });
102
+ });
131
103
  ```
132
104
 
133
- - `ctx.events.on(name, handler)`: one handler per event. Delivery is
134
- asynchronous, in order and at most once; a handler gets 2 seconds, the queue
135
- holds 100 events per extension, and a failing handler only reaches the log.
136
- - `ctx.storage` is JSON under string keys, private to the extension, kept across
137
- restarts and updates. The value must be JSON: that is why `Streak` is a
105
+ - `server.on(name, handler)` subscribes to a learning event: `session.started`,
106
+ `session.finished` or `attempt.closed`. The payloads carry ids, the grade, the
107
+ outcome and the time, never the learner's answer or the exercise text. One
108
+ handler per event. Delivery is asynchronous, in order and at most once; a
109
+ handler gets 2 seconds, the queue holds 100 events per extension, and a
110
+ failing handler only reaches the log.
111
+ - `server.storage` is JSON under string keys, private to the extension, kept
112
+ across restarts and updates. The value must be JSON: that is why `Streak` is a
138
113
  `type`, not an `interface`. Ceilings: key 128 characters, value 64 KiB, 256
139
114
  keys, 1 MiB in total; exceeding one throws `StorageQuotaError`.
140
- - `advance` is a pure function exported for the test. The hidden command
141
- `acme.hello.data` is how the panel reads the stored value.
115
+ - `advance` is a pure function in its own file, so the test can import it. The
116
+ hidden command `acme.hello.data` is how the panel reads the stored value.
117
+
118
+ ## The client part
119
+
120
+ File `src/client.ts` (events):
121
+
122
+ ```ts
123
+ import { defineClient } from '@dolphy-app/extension-sdk';
124
+ import { StreakPanel } from './streak-panel.ts';
125
+
126
+ // runs in the app window: the panel is a Vue component the app draws
127
+ export const client = defineClient((c) => {
128
+ c.addPanel({
129
+ id: 'acme.hello.view',
130
+ title: { en: 'Streak', ru: 'Серия' },
131
+ component: StreakPanel,
132
+ });
133
+ });
134
+ ```
135
+
136
+ File `src/streak-panel.ts` (events):
137
+
138
+ ```ts
139
+ import { usePanel } from '@dolphy-app/extension-sdk/client';
140
+ import { defineComponent, h, ref, watchEffect } from 'vue';
141
+ import type { Streak } from './streak.ts';
142
+
143
+ // the only way to the data is `panel.call` to the commands of the server
144
+ export const StreakPanel = defineComponent({
145
+ setup() {
146
+ const panel = usePanel();
147
+ const text = ref('');
148
+ // the command opens the panel again with new properties: ask again
149
+ watchEffect(async () => {
150
+ void panel.props;
151
+ const streak = (await panel.call('acme.hello.data')) as Streak;
152
+ text.value =
153
+ streak.days === 0
154
+ ? 'No streak yet.'
155
+ : `Streak: ${streak.days} days, last day ${streak.last}`;
156
+ });
157
+ return () => h('p', text.value);
158
+ },
159
+ });
160
+ ```
161
+
162
+ - The panel is a Vue component registered with `client.addPanel`: `usePanel()`
163
+ gives it `call` for the commands of the server part and the reactive `props`.
164
+ Reading `panel.props` inside `watchEffect` makes the panel ask again when the
165
+ `show` command opens it with new properties.
142
166
 
143
167
  ## The tests
144
168
 
@@ -146,15 +170,20 @@ File `test/index.test.ts` (events):
146
170
 
147
171
  ```ts
148
172
  // @vitest-environment happy-dom
149
- import type { LearningEventPayloads } from '@dolphy-app/extension-api';
173
+ import { PANEL_HANDLE_KEY } from '@dolphy-app/extension-sdk';
174
+ import type {
175
+ JsonValue,
176
+ LearningEventPayloads,
177
+ PanelHandle,
178
+ } from '@dolphy-app/extension-sdk';
150
179
  import {
151
- createMemoryStorage,
152
- loadCommands,
153
- loadEvents,
154
- loadPanel,
180
+ createTestClient,
181
+ createTestServer,
155
182
  } from '@dolphy-app/extension-sdk/testing';
156
183
  import { afterEach, describe, expect, it } from 'vitest';
157
- import { host, panels } from '../src/index.ts';
184
+ import { createApp, h, nextTick, shallowReactive } from 'vue';
185
+ import { client, server } from '../src/index.ts';
186
+ import { StreakPanel } from '../src/streak-panel.ts';
158
187
 
159
188
  const disposables: { dispose(): unknown }[] = [];
160
189
  afterEach(async () => {
@@ -173,44 +202,65 @@ const attempt = (day: string, outcome: Attempt['outcome'] = 'passed'): Attempt =
173
202
  at: Date.parse(`${day}T12:00:00Z`),
174
203
  });
175
204
 
176
- const load = async () => {
177
- const storage = createMemoryStorage();
178
- const events = await loadEvents(host, {
179
- storage,
180
- declared: ['attempt.closed'],
205
+ const start = async () => {
206
+ const running = await createTestServer(server, { extensionId: 'acme.hello' });
207
+ disposables.push(running);
208
+ return running;
209
+ };
210
+
211
+ // draws the panel the way the app does: the handle is provided to the component
212
+ const mountPanel = async (call: PanelHandle['call']) => {
213
+ const handle = shallowReactive({
214
+ panelId: 'acme.hello.view',
215
+ props: undefined as JsonValue | undefined,
216
+ context: { courseId: null },
217
+ call,
181
218
  });
182
- const commands = await loadCommands(host, {
183
- storage,
184
- declaredCommands: ['acme.hello.show', 'acme.hello.data'],
185
- declaredPanels: ['acme.hello.view'],
219
+ const host = document.createElement('div');
220
+ document.body.append(host);
221
+ const app = createApp({ render: () => h(StreakPanel) });
222
+ app.provide(PANEL_HANDLE_KEY, handle);
223
+ app.mount(host);
224
+ disposables.push({
225
+ dispose: () => {
226
+ app.unmount();
227
+ host.remove();
228
+ },
186
229
  });
187
- disposables.push(events, commands);
188
- return { storage, events, commands };
230
+ // the panel asks a command: wait for the reply, then for the redraw
231
+ await new Promise((resolve) => setTimeout(resolve, 0));
232
+ await nextTick();
233
+ return host;
189
234
  };
190
235
 
191
236
  describe('acme.hello: events and storage', () => {
237
+ it('subscribes to attempt.closed', async () => {
238
+ const running = await start();
239
+ expect(running.registration.events).toEqual(['attempt.closed']);
240
+ });
241
+
192
242
  it('counts consecutive days, ignores a repeat on the same day', async () => {
193
- const { storage, events } = await load();
194
- await events.emit('attempt.closed', attempt('2026-10-01'));
195
- await events.emit('attempt.closed', attempt('2026-10-01'));
196
- await events.emit('attempt.closed', attempt('2026-10-02'));
197
- expect(await storage.get('streak')).toEqual({
243
+ const running = await start();
244
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
245
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
246
+ await running.events.emit('attempt.closed', attempt('2026-10-02'));
247
+ expect(await running.storage.get('streak')).toEqual({
198
248
  days: 2,
199
249
  last: '2026-10-02',
200
250
  });
201
251
  });
202
252
 
203
253
  it('a skipped day starts over; giving up leaves the streak alone', async () => {
204
- const { storage, events } = await load();
205
- await events.emit('attempt.closed', attempt('2026-10-01'));
206
- await events.emit('attempt.closed', attempt('2026-10-02'));
207
- await events.emit('attempt.closed', attempt('2026-10-03', 'gave-up'));
208
- expect(await storage.get('streak')).toEqual({
254
+ const running = await start();
255
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
256
+ await running.events.emit('attempt.closed', attempt('2026-10-02'));
257
+ await running.events.emit('attempt.closed', attempt('2026-10-03', 'gave-up'));
258
+ expect(await running.storage.get('streak')).toEqual({
209
259
  days: 2,
210
260
  last: '2026-10-02',
211
261
  });
212
- await events.emit('attempt.closed', attempt('2026-10-05'));
213
- expect(await storage.get('streak')).toEqual({
262
+ await running.events.emit('attempt.closed', attempt('2026-10-05'));
263
+ expect(await running.storage.get('streak')).toEqual({
214
264
  days: 1,
215
265
  last: '2026-10-05',
216
266
  });
@@ -219,46 +269,56 @@ describe('acme.hello: events and storage', () => {
219
269
 
220
270
  describe('acme.hello: commands and panel', () => {
221
271
  it('without a streak the show command notifies, the data command returns zeros', async () => {
222
- const { commands } = await load();
223
- expect(await commands.run('acme.hello.show')).toMatchObject({ kind: 'notify' });
224
- expect(await commands.run('acme.hello.data')).toEqual({
272
+ const running = await start();
273
+ expect(await running.commands.run('acme.hello.show')).toMatchObject({
274
+ kind: 'notify',
275
+ });
276
+ expect(await running.commands.run('acme.hello.data')).toEqual({
225
277
  kind: 'data',
226
278
  value: { days: 0, last: '' },
227
279
  });
228
280
  });
229
281
 
230
282
  it('with a streak the show command opens the panel with the days', async () => {
231
- const { events, commands } = await load();
232
- await events.emit('attempt.closed', attempt('2026-10-01'));
233
- expect(await commands.run('acme.hello.show')).toEqual({
283
+ const running = await start();
284
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
285
+ expect(await running.commands.run('acme.hello.show')).toEqual({
234
286
  kind: 'openPanel',
235
287
  panelId: 'acme.hello.view',
236
288
  props: { days: 1 },
237
289
  });
238
290
  });
239
291
 
292
+ it('the client adds the panel the show command opens', async () => {
293
+ const running = await createTestClient(client, { extensionId: 'acme.hello' });
294
+ disposables.push(running);
295
+ expect(running.panels.map((panel) => panel.id)).toEqual(['acme.hello.view']);
296
+ });
297
+
240
298
  it('the panel shows what the data command returns', async () => {
241
- const { events, commands } = await load();
242
- await events.emit('attempt.closed', attempt('2026-10-01'));
243
- const panel = await loadPanel(panels, 'acme.hello.view', {
244
- call: async (commandId) => {
245
- const result = await commands.run(commandId);
246
- return result.kind === 'data' ? result.value : undefined;
247
- },
299
+ const running = await start();
300
+ await running.events.emit('attempt.closed', attempt('2026-10-01'));
301
+ const panel = await mountPanel(async (commandId) => {
302
+ const result = await running.commands.run(commandId);
303
+ return result.kind === 'data' ? (result.value as JsonValue) : undefined;
248
304
  });
249
- disposables.push(panel);
250
- expect(panel.container.querySelector('p')?.textContent).toBe(
305
+ expect(panel.querySelector('p')?.textContent).toBe(
251
306
  'Streak: 1 days, last day 2026-10-01',
252
307
  );
253
308
  });
254
309
  });
255
310
  ```
256
311
 
257
- `loadEvents` activates the extension and gives `emit(name, payload)`, so the test
258
- sends events the way the host delivers them. `createMemoryStorage` has the same
259
- ceilings as the app. Pass the same `storage` to `loadEvents` and `loadCommands`
260
- and both see one state. Unlike the host, the helpers do not swallow a handler's
261
- failure and do not count the 2 seconds.
312
+ `createTestServer(server, { extensionId })` starts `server`, and
313
+ `running.events.emit(name, payload)` delivers an event the way the host does and
314
+ awaits the handler; `running.registration.events` lists the subscriptions.
315
+ `running.storage` is in memory with the same ceilings as the app, and the test
316
+ reads what the handler wrote. Unlike the host, the harness does not swallow a
317
+ handler's failure and does not count the 2 seconds.
318
+
319
+ The panel is mounted with `createApp` in `happy-dom`;
320
+ `app.provide(PANEL_HANDLE_KEY, handle)` gives it a handle whose `call` runs the
321
+ real command handlers of the running server.
262
322
 
263
323
  ## Try and ship
264
324