@ashdev/codex-plugin-sdk 1.9.3 → 1.10.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/dist/server.js CHANGED
@@ -1,10 +1,19 @@
1
1
  /**
2
2
  * Plugin server - handles JSON-RPC communication over stdio
3
+ *
4
+ * Provides factory functions for creating different plugin types.
5
+ * All plugin types share a common base server that handles:
6
+ * - stdin readline parsing
7
+ * - JSON-RPC error handling
8
+ * - initialize/ping/shutdown lifecycle methods
9
+ *
10
+ * Each plugin type adds its own method routing on top.
3
11
  */
4
12
  import { createInterface } from "node:readline";
5
13
  import { PluginError } from "./errors.js";
6
14
  import { createLogger } from "./logger.js";
7
- import { JSON_RPC_ERROR_CODES, } from "./types/rpc.js";
15
+ import { PluginStorage } from "./storage.js";
16
+ import { JSON_RPC_ERROR_CODES } from "./types/rpc.js";
8
17
  /**
9
18
  * Validate that the required string fields are present and non-empty
10
19
  */
@@ -87,78 +96,29 @@ function invalidParamsError(id, error) {
87
96
  };
88
97
  }
89
98
  /**
90
- * Create and run a metadata provider plugin
91
- *
92
- * Creates a plugin server that handles JSON-RPC communication over stdio.
93
- * The TypeScript compiler will ensure you implement all required methods.
94
- *
95
- * @example
96
- * ```typescript
97
- * import { createMetadataPlugin, type MetadataProvider } from "@ashdev/codex-plugin-sdk";
99
+ * Shared plugin server that handles JSON-RPC communication over stdio.
98
100
  *
99
- * const provider: MetadataProvider = {
100
- * async search(params) {
101
- * return {
102
- * results: [{
103
- * externalId: "123",
104
- * title: "Example",
105
- * alternateTitles: [],
106
- * relevanceScore: 0.95,
107
- * }],
108
- * };
109
- * },
110
- * async get(params) {
111
- * return {
112
- * externalId: params.externalId,
113
- * externalUrl: "https://example.com/123",
114
- * alternateTitles: [],
115
- * genres: [],
116
- * tags: [],
117
- * authors: [],
118
- * artists: [],
119
- * externalLinks: [],
120
- * };
121
- * },
122
- * };
123
- *
124
- * createMetadataPlugin({
125
- * manifest: {
126
- * name: "my-plugin",
127
- * displayName: "My Plugin",
128
- * version: "1.0.0",
129
- * description: "Example plugin",
130
- * author: "Me",
131
- * protocolVersion: "1.0",
132
- * capabilities: { metadataProvider: ["series"] },
133
- * },
134
- * provider,
135
- * });
136
- * ```
101
+ * Handles the common lifecycle methods (initialize, ping, shutdown) and
102
+ * delegates capability-specific methods to the provided router.
137
103
  */
138
- export function createMetadataPlugin(options) {
139
- const { manifest, provider, bookProvider, onInitialize, logLevel = "info" } = options;
104
+ function createPluginServer(options) {
105
+ const { manifest, onInitialize, logLevel = "info", label, router } = options;
140
106
  const logger = createLogger({ name: manifest.name, level: logLevel });
141
- // Validate that required providers are present based on manifest
142
- const contentTypes = manifest.capabilities.metadataProvider;
143
- if (contentTypes.includes("series") && !provider) {
144
- throw new Error("Series metadata provider is required when 'series' is in metadataProvider capabilities");
145
- }
146
- if (contentTypes.includes("book") && !bookProvider) {
147
- throw new Error("Book metadata provider is required when 'book' is in metadataProvider capabilities");
148
- }
149
- logger.info(`Starting plugin: ${manifest.displayName} v${manifest.version}`);
107
+ const prefix = label ? `${label} plugin` : "plugin";
108
+ const storage = new PluginStorage();
109
+ logger.info(`Starting ${prefix}: ${manifest.displayName} v${manifest.version}`);
150
110
  const rl = createInterface({
151
111
  input: process.stdin,
152
112
  terminal: false,
153
113
  });
154
114
  rl.on("line", (line) => {
155
- void handleLine(line, manifest, provider, bookProvider, onInitialize, logger);
115
+ void handleLine(line, manifest, onInitialize, router, logger, storage);
156
116
  });
157
117
  rl.on("close", () => {
158
118
  logger.info("stdin closed, shutting down");
119
+ storage.cancelAll();
159
120
  process.exit(0);
160
121
  });
161
- // Handle uncaught errors
162
122
  process.on("uncaughtException", (error) => {
163
123
  logger.error("Uncaught exception", error);
164
124
  process.exit(1);
@@ -167,47 +127,50 @@ export function createMetadataPlugin(options) {
167
127
  logger.error("Unhandled rejection", reason);
168
128
  });
169
129
  }
170
- // =============================================================================
171
- // Backwards Compatibility (deprecated)
172
- // =============================================================================
173
130
  /**
174
- * @deprecated Use createMetadataPlugin instead
131
+ * Detect whether a parsed JSON object is a JSON-RPC response (not a request).
132
+ *
133
+ * A response has `id` and either `result` or `error`, but no `method`.
134
+ * A request always has `method`.
175
135
  */
176
- export function createSeriesMetadataPlugin(options) {
177
- // Convert legacy options to new format
178
- const newOptions = {
179
- ...options,
180
- manifest: {
181
- ...options.manifest,
182
- capabilities: {
183
- ...options.manifest.capabilities,
184
- metadataProvider: ["series"],
185
- },
186
- },
187
- };
188
- createMetadataPlugin(newOptions);
136
+ function isJsonRpcResponse(obj) {
137
+ if (obj.method !== undefined)
138
+ return false;
139
+ if (obj.id === undefined || obj.id === null)
140
+ return false;
141
+ return "result" in obj || "error" in obj;
189
142
  }
190
- // =============================================================================
191
- // Internal Implementation
192
- // =============================================================================
193
- async function handleLine(line, manifest, provider, bookProvider, onInitialize, logger) {
143
+ async function handleLine(line, manifest, onInitialize, router, logger, storage) {
194
144
  const trimmed = line.trim();
195
145
  if (!trimmed)
196
146
  return;
147
+ // Try to detect storage responses before full request handling.
148
+ // Storage responses come from the host on stdin — they have id + (result|error)
149
+ // but no method field.
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(trimmed);
153
+ }
154
+ catch {
155
+ // Will be handled as a parse error below
156
+ }
157
+ if (parsed && isJsonRpcResponse(parsed)) {
158
+ logger.debug("Routing storage response", { id: parsed.id });
159
+ storage.handleResponse(trimmed);
160
+ return;
161
+ }
197
162
  let id = null;
198
163
  try {
199
- const request = JSON.parse(trimmed);
164
+ const request = (parsed ?? JSON.parse(trimmed));
200
165
  id = request.id;
201
166
  logger.debug(`Received request: ${request.method}`, { id: request.id });
202
- const response = await handleRequest(request, manifest, provider, bookProvider, onInitialize, logger);
203
- // Shutdown handler writes response directly and returns null
167
+ const response = await handleRequest(request, manifest, onInitialize, router, logger, storage);
204
168
  if (response !== null) {
205
169
  writeResponse(response);
206
170
  }
207
171
  }
208
172
  catch (error) {
209
173
  if (error instanceof SyntaxError) {
210
- // JSON parse error
211
174
  writeResponse({
212
175
  jsonrpc: "2.0",
213
176
  id: null,
@@ -238,205 +201,278 @@ async function handleLine(line, manifest, provider, bookProvider, onInitialize,
238
201
  }
239
202
  }
240
203
  }
241
- async function handleRequest(request, manifest, provider, bookProvider, onInitialize, logger) {
204
+ async function handleRequest(request, manifest, onInitialize, router, logger, storage) {
242
205
  const { method, params, id } = request;
206
+ // Common lifecycle methods
243
207
  switch (method) {
244
- case "initialize":
245
- // Call onInitialize callback if provided (to receive credentials/config)
208
+ case "initialize": {
209
+ const initParams = (params ?? {});
210
+ // Inject the storage client so plugins can persist data
211
+ initParams.storage = storage;
246
212
  if (onInitialize) {
247
- await onInitialize(params);
213
+ await onInitialize(initParams);
248
214
  }
249
- return {
250
- jsonrpc: "2.0",
251
- id,
252
- result: manifest,
253
- };
215
+ return { jsonrpc: "2.0", id, result: manifest };
216
+ }
254
217
  case "ping":
255
- return {
256
- jsonrpc: "2.0",
257
- id,
258
- result: "pong",
259
- };
218
+ return { jsonrpc: "2.0", id, result: "pong" };
260
219
  case "shutdown": {
261
220
  logger.info("Shutdown requested");
262
- // Write response directly with callback to ensure it's flushed before exit
263
- const response = {
264
- jsonrpc: "2.0",
265
- id,
266
- result: null,
267
- };
221
+ storage.cancelAll();
222
+ const response = { jsonrpc: "2.0", id, result: null };
268
223
  process.stdout.write(`${JSON.stringify(response)}\n`, () => {
269
- // Callback is called after the write is flushed to the OS
270
224
  process.exit(0);
271
225
  });
272
- // Return a sentinel that handleLine will recognize and skip normal writeResponse
226
+ // Response already written above; return null so handleLine skips the write
273
227
  return null;
274
228
  }
275
- // =========================================================================
276
- // Series metadata methods
277
- // =========================================================================
278
- case "metadata/series/search": {
279
- if (!provider) {
280
- return {
281
- jsonrpc: "2.0",
282
- id,
283
- error: {
284
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
285
- message: "This plugin does not support series metadata",
286
- },
287
- };
288
- }
289
- const validationError = validateSearchParams(params);
290
- if (validationError) {
291
- return invalidParamsError(id, validationError);
292
- }
293
- return {
294
- jsonrpc: "2.0",
295
- id,
296
- result: await provider.search(params),
297
- };
298
- }
299
- case "metadata/series/get": {
300
- if (!provider) {
301
- return {
302
- jsonrpc: "2.0",
303
- id,
304
- error: {
305
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
306
- message: "This plugin does not support series metadata",
307
- },
308
- };
309
- }
310
- const validationError = validateGetParams(params);
311
- if (validationError) {
312
- return invalidParamsError(id, validationError);
229
+ }
230
+ // Delegate to capability-specific router
231
+ const response = await router(method, params, id);
232
+ if (response !== null) {
233
+ return response;
234
+ }
235
+ // Unknown method
236
+ return {
237
+ jsonrpc: "2.0",
238
+ id,
239
+ error: {
240
+ code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
241
+ message: `Method not found: ${method}`,
242
+ },
243
+ };
244
+ }
245
+ function writeResponse(response) {
246
+ process.stdout.write(`${JSON.stringify(response)}\n`);
247
+ }
248
+ // =============================================================================
249
+ // Response Helpers
250
+ // =============================================================================
251
+ function methodNotFound(id, message) {
252
+ return {
253
+ jsonrpc: "2.0",
254
+ id,
255
+ error: {
256
+ code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
257
+ message,
258
+ },
259
+ };
260
+ }
261
+ function success(id, result) {
262
+ return { jsonrpc: "2.0", id, result };
263
+ }
264
+ /**
265
+ * Create and run a metadata provider plugin
266
+ *
267
+ * Creates a plugin server that handles JSON-RPC communication over stdio.
268
+ * The TypeScript compiler will ensure you implement all required methods.
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * import { createMetadataPlugin, type MetadataProvider } from "@ashdev/codex-plugin-sdk";
273
+ *
274
+ * const provider: MetadataProvider = {
275
+ * async search(params) {
276
+ * return {
277
+ * results: [{
278
+ * externalId: "123",
279
+ * title: "Example",
280
+ * alternateTitles: [],
281
+ * relevanceScore: 0.95,
282
+ * }],
283
+ * };
284
+ * },
285
+ * async get(params) {
286
+ * return {
287
+ * externalId: params.externalId,
288
+ * externalUrl: "https://example.com/123",
289
+ * alternateTitles: [],
290
+ * genres: [],
291
+ * tags: [],
292
+ * authors: [],
293
+ * artists: [],
294
+ * externalLinks: [],
295
+ * };
296
+ * },
297
+ * };
298
+ *
299
+ * createMetadataPlugin({
300
+ * manifest: {
301
+ * name: "my-plugin",
302
+ * displayName: "My Plugin",
303
+ * version: "1.0.0",
304
+ * description: "Example plugin",
305
+ * author: "Me",
306
+ * protocolVersion: "1.0",
307
+ * capabilities: { metadataProvider: ["series"] },
308
+ * },
309
+ * provider,
310
+ * });
311
+ * ```
312
+ */
313
+ export function createMetadataPlugin(options) {
314
+ const { manifest, provider, bookProvider, onInitialize, logLevel } = options;
315
+ // Validate that required providers are present based on manifest
316
+ const contentTypes = manifest.capabilities.metadataProvider;
317
+ if (contentTypes.includes("series") && !provider) {
318
+ throw new Error("Series metadata provider is required when 'series' is in metadataProvider capabilities");
319
+ }
320
+ if (contentTypes.includes("book") && !bookProvider) {
321
+ throw new Error("Book metadata provider is required when 'book' is in metadataProvider capabilities");
322
+ }
323
+ const router = async (method, params, id) => {
324
+ switch (method) {
325
+ // Series metadata methods
326
+ case "metadata/series/search": {
327
+ if (!provider)
328
+ return methodNotFound(id, "This plugin does not support series metadata");
329
+ const err = validateSearchParams(params);
330
+ if (err)
331
+ return invalidParamsError(id, err);
332
+ return success(id, await provider.search(params));
313
333
  }
314
- return {
315
- jsonrpc: "2.0",
316
- id,
317
- result: await provider.get(params),
318
- };
319
- }
320
- case "metadata/series/match": {
321
- if (!provider) {
322
- return {
323
- jsonrpc: "2.0",
324
- id,
325
- error: {
326
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
327
- message: "This plugin does not support series metadata",
328
- },
329
- };
334
+ case "metadata/series/get": {
335
+ if (!provider)
336
+ return methodNotFound(id, "This plugin does not support series metadata");
337
+ const err = validateGetParams(params);
338
+ if (err)
339
+ return invalidParamsError(id, err);
340
+ return success(id, await provider.get(params));
330
341
  }
331
- if (!provider.match) {
332
- return {
333
- jsonrpc: "2.0",
334
- id,
335
- error: {
336
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
337
- message: "This plugin does not support series match",
338
- },
339
- };
342
+ case "metadata/series/match": {
343
+ if (!provider)
344
+ return methodNotFound(id, "This plugin does not support series metadata");
345
+ if (!provider.match)
346
+ return methodNotFound(id, "This plugin does not support series match");
347
+ const err = validateMatchParams(params);
348
+ if (err)
349
+ return invalidParamsError(id, err);
350
+ return success(id, await provider.match(params));
340
351
  }
341
- const validationError = validateMatchParams(params);
342
- if (validationError) {
343
- return invalidParamsError(id, validationError);
352
+ // Book metadata methods
353
+ case "metadata/book/search": {
354
+ if (!bookProvider)
355
+ return methodNotFound(id, "This plugin does not support book metadata");
356
+ const err = validateBookSearchParams(params);
357
+ if (err)
358
+ return invalidParamsError(id, err);
359
+ return success(id, await bookProvider.search(params));
344
360
  }
345
- return {
346
- jsonrpc: "2.0",
347
- id,
348
- result: await provider.match(params),
349
- };
350
- }
351
- // =========================================================================
352
- // Book metadata methods
353
- // =========================================================================
354
- case "metadata/book/search": {
355
- if (!bookProvider) {
356
- return {
357
- jsonrpc: "2.0",
358
- id,
359
- error: {
360
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
361
- message: "This plugin does not support book metadata",
362
- },
363
- };
361
+ case "metadata/book/get": {
362
+ if (!bookProvider)
363
+ return methodNotFound(id, "This plugin does not support book metadata");
364
+ const err = validateGetParams(params);
365
+ if (err)
366
+ return invalidParamsError(id, err);
367
+ return success(id, await bookProvider.get(params));
364
368
  }
365
- const validationError = validateBookSearchParams(params);
366
- if (validationError) {
367
- return invalidParamsError(id, validationError);
369
+ case "metadata/book/match": {
370
+ if (!bookProvider)
371
+ return methodNotFound(id, "This plugin does not support book metadata");
372
+ if (!bookProvider.match)
373
+ return methodNotFound(id, "This plugin does not support book match");
374
+ const err = validateBookMatchParams(params);
375
+ if (err)
376
+ return invalidParamsError(id, err);
377
+ return success(id, await bookProvider.match(params));
368
378
  }
369
- return {
370
- jsonrpc: "2.0",
371
- id,
372
- result: await bookProvider.search(params),
373
- };
379
+ default:
380
+ return null;
374
381
  }
375
- case "metadata/book/get": {
376
- if (!bookProvider) {
377
- return {
378
- jsonrpc: "2.0",
379
- id,
380
- error: {
381
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
382
- message: "This plugin does not support book metadata",
383
- },
384
- };
385
- }
386
- const validationError = validateGetParams(params);
387
- if (validationError) {
388
- return invalidParamsError(id, validationError);
382
+ };
383
+ createPluginServer({ manifest, onInitialize, logLevel, router });
384
+ }
385
+ /**
386
+ * Create and run a sync provider plugin
387
+ *
388
+ * Creates a plugin server that handles JSON-RPC communication over stdio
389
+ * for sync operations (push/pull reading progress with external services).
390
+ *
391
+ * @example
392
+ * ```typescript
393
+ * import { createSyncPlugin, type SyncProvider } from "@ashdev/codex-plugin-sdk";
394
+ *
395
+ * const provider: SyncProvider = {
396
+ * async getUserInfo() {
397
+ * return { externalId: "123", username: "user" };
398
+ * },
399
+ * async pushProgress(params) {
400
+ * return { success: [], failed: [] };
401
+ * },
402
+ * async pullProgress(params) {
403
+ * return { entries: [], hasMore: false };
404
+ * },
405
+ * };
406
+ *
407
+ * createSyncPlugin({
408
+ * manifest: {
409
+ * name: "my-sync-plugin",
410
+ * displayName: "My Sync Plugin",
411
+ * version: "1.0.0",
412
+ * description: "Syncs reading progress",
413
+ * author: "Me",
414
+ * protocolVersion: "1.0",
415
+ * capabilities: { userReadSync: true },
416
+ * },
417
+ * provider,
418
+ * });
419
+ * ```
420
+ */
421
+ export function createSyncPlugin(options) {
422
+ const { manifest, provider, onInitialize, logLevel } = options;
423
+ const router = async (method, params, id) => {
424
+ switch (method) {
425
+ case "sync/getUserInfo":
426
+ return success(id, await provider.getUserInfo());
427
+ case "sync/pushProgress":
428
+ return success(id, await provider.pushProgress(params));
429
+ case "sync/pullProgress":
430
+ return success(id, await provider.pullProgress(params));
431
+ case "sync/status": {
432
+ if (!provider.status)
433
+ return methodNotFound(id, "This plugin does not support sync/status");
434
+ return success(id, await provider.status());
389
435
  }
390
- return {
391
- jsonrpc: "2.0",
392
- id,
393
- result: await bookProvider.get(params),
394
- };
436
+ default:
437
+ return null;
395
438
  }
396
- case "metadata/book/match": {
397
- if (!bookProvider) {
398
- return {
399
- jsonrpc: "2.0",
400
- id,
401
- error: {
402
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
403
- message: "This plugin does not support book metadata",
404
- },
405
- };
439
+ };
440
+ createPluginServer({ manifest, onInitialize, logLevel, label: "sync", router });
441
+ }
442
+ /**
443
+ * Create and run a recommendation provider plugin
444
+ *
445
+ * Creates a plugin server that handles JSON-RPC communication over stdio
446
+ * for recommendation operations (get recommendations, update profile, dismiss).
447
+ */
448
+ export function createRecommendationPlugin(options) {
449
+ const { manifest, provider, onInitialize, logLevel } = options;
450
+ const router = async (method, params, id) => {
451
+ switch (method) {
452
+ case "recommendations/get":
453
+ return success(id, await provider.get(params));
454
+ case "recommendations/updateProfile": {
455
+ if (!provider.updateProfile)
456
+ return methodNotFound(id, "This plugin does not support recommendations/updateProfile");
457
+ return success(id, await provider.updateProfile(params));
406
458
  }
407
- if (!bookProvider.match) {
408
- return {
409
- jsonrpc: "2.0",
410
- id,
411
- error: {
412
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
413
- message: "This plugin does not support book match",
414
- },
415
- };
459
+ case "recommendations/clear": {
460
+ if (!provider.clear)
461
+ return methodNotFound(id, "This plugin does not support recommendations/clear");
462
+ return success(id, await provider.clear());
416
463
  }
417
- const validationError = validateBookMatchParams(params);
418
- if (validationError) {
419
- return invalidParamsError(id, validationError);
464
+ case "recommendations/dismiss": {
465
+ if (!provider.dismiss)
466
+ return methodNotFound(id, "This plugin does not support recommendations/dismiss");
467
+ const err = validateStringFields(params, ["externalId"]);
468
+ if (err)
469
+ return invalidParamsError(id, err);
470
+ return success(id, await provider.dismiss(params));
420
471
  }
421
- return {
422
- jsonrpc: "2.0",
423
- id,
424
- result: await bookProvider.match(params),
425
- };
472
+ default:
473
+ return null;
426
474
  }
427
- default:
428
- return {
429
- jsonrpc: "2.0",
430
- id,
431
- error: {
432
- code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
433
- message: `Method not found: ${method}`,
434
- },
435
- };
436
- }
437
- }
438
- function writeResponse(response) {
439
- // Write to stdout - this is the JSON-RPC channel
440
- process.stdout.write(`${JSON.stringify(response)}\n`);
475
+ };
476
+ createPluginServer({ manifest, onInitialize, logLevel, label: "recommendation", router });
441
477
  }
442
478
  //# sourceMappingURL=server.js.map