@nimbalyst/extension-sdk 0.2.0 → 0.2.1

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/__tests__/createTextCollabContentAdapter.test.d.ts +2 -0
  3. package/dist/__tests__/createTextCollabContentAdapter.test.d.ts.map +1 -0
  4. package/dist/__tests__/createTextCollabContentAdapter.test.js +60 -0
  5. package/dist/__tests__/hasSeedableContent.test.d.ts +2 -0
  6. package/dist/__tests__/hasSeedableContent.test.d.ts.map +1 -0
  7. package/dist/__tests__/hasSeedableContent.test.js +25 -0
  8. package/dist/__tests__/manifestValidation.agentProviders.test.d.ts +2 -0
  9. package/dist/__tests__/manifestValidation.agentProviders.test.d.ts.map +1 -0
  10. package/dist/__tests__/manifestValidation.agentProviders.test.js +44 -0
  11. package/dist/agents/AgentProtocol.d.ts +227 -0
  12. package/dist/agents/AgentProtocol.d.ts.map +1 -0
  13. package/dist/agents/AgentProtocol.js +23 -0
  14. package/dist/agents/AgentProtocolHost.d.ts +190 -0
  15. package/dist/agents/AgentProtocolHost.d.ts.map +1 -0
  16. package/dist/agents/AgentProtocolHost.js +42 -0
  17. package/dist/agents/index.d.ts +26 -0
  18. package/dist/agents/index.d.ts.map +1 -0
  19. package/dist/agents/index.js +24 -0
  20. package/dist/collab/createTextCollabContentAdapter.d.ts +26 -0
  21. package/dist/collab/createTextCollabContentAdapter.d.ts.map +1 -0
  22. package/dist/collab/createTextCollabContentAdapter.js +79 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -0
  26. package/dist/manifestValidation.d.ts +118 -0
  27. package/dist/manifestValidation.d.ts.map +1 -0
  28. package/dist/manifestValidation.js +475 -0
  29. package/dist/types/collab.d.ts +138 -0
  30. package/dist/types/collab.d.ts.map +1 -0
  31. package/dist/types/collab.js +1 -0
  32. package/dist/types/editor.d.ts +71 -0
  33. package/dist/types/editor.d.ts.map +1 -1
  34. package/dist/types/editors.d.ts +4 -0
  35. package/dist/types/editors.d.ts.map +1 -1
  36. package/dist/types/extension.d.ts +364 -5
  37. package/dist/types/extension.d.ts.map +1 -1
  38. package/dist/types/extension.js +11 -1
  39. package/dist/types/index.d.ts +3 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/dist/types/index.js +3 -0
  42. package/dist/types/panel.d.ts +50 -0
  43. package/dist/types/panel.d.ts.map +1 -1
  44. package/dist/types/permissions.d.ts +117 -0
  45. package/dist/types/permissions.d.ts.map +1 -0
  46. package/dist/types/permissions.js +33 -0
  47. package/dist/types/theme.d.ts +50 -0
  48. package/dist/types/theme.d.ts.map +1 -1
  49. package/dist/types/theme.js +7 -1
  50. package/dist/types/trackerImporter.d.ts +147 -0
  51. package/dist/types/trackerImporter.d.ts.map +1 -0
  52. package/dist/types/trackerImporter.js +33 -0
  53. package/dist/useCollaborativeEditor.d.ts +84 -30
  54. package/dist/useCollaborativeEditor.d.ts.map +1 -1
  55. package/dist/useCollaborativeEditor.js +95 -7
  56. package/dist/validate.browser.d.ts +5 -0
  57. package/dist/validate.browser.d.ts.map +1 -0
  58. package/dist/validate.browser.js +27 -0
  59. package/dist/validate.d.ts +2 -8
  60. package/dist/validate.d.ts.map +1 -1
  61. package/dist/validate.js +117 -0
  62. package/dist/validationTypes.d.ts +9 -0
  63. package/dist/validationTypes.d.ts.map +1 -0
  64. package/dist/validationTypes.js +1 -0
  65. package/package.json +4 -1
@@ -37,6 +37,7 @@
37
37
  */
38
38
  import * as Y from 'yjs';
39
39
  import type { CollaborationStatus, CollaboratorInfo, EditorHost } from './types/editor.js';
40
+ import type { CollabCodec } from './types/collab.js';
40
41
  /**
41
42
  * Origin tag used when the SDK wraps `initializeFromContent` in a Y.Doc
42
43
  * transaction. Extension bindings can compare a transaction's origin
@@ -51,44 +52,83 @@ import type { CollaborationStatus, CollaboratorInfo, EditorHost } from './types/
51
52
  * ```
52
53
  */
53
54
  export declare const COLLAB_INIT_ORIGIN: unique symbol;
54
- export interface UseCollaborativeEditorConfig {
55
+ /** Context passed to the binding factory (`bind` / `createBinding`). */
56
+ export interface CollabBindContext {
57
+ yDoc: Y.Doc;
58
+ awareness: import('y-protocols/awareness').Awareness;
59
+ user: {
60
+ id: string;
61
+ name: string;
62
+ color: string;
63
+ };
64
+ }
65
+ export type CollabBindResult = {
66
+ destroy: () => void;
67
+ } | Promise<{
68
+ destroy: () => void;
69
+ }>;
70
+ /**
71
+ * Preferred config: pass the SAME pure {@link CollabCodec} the extension
72
+ * registered via `context.services.collab.registerContentAdapter(...)`, plus
73
+ * the one React-coupled piece (`bind`). `isEmpty` / `seedFromFile` are read
74
+ * off the codec, so the live seed and every headless seed provably run the
75
+ * same code -- an extension can no longer implement emptiness/seeding twice
76
+ * and have the two disagree.
77
+ *
78
+ * ```tsx
79
+ * useCollaborativeEditor(host, {
80
+ * codec: mindmapCodec,
81
+ * bind: ({ yDoc, awareness, user }) => new MindmapBinding(yDoc, api, awareness),
82
+ * });
83
+ * ```
84
+ */
85
+ export interface UseCollaborativeEditorCodecConfig {
86
+ /** The pure codec. Its `isEmpty` gates seeding and `seedFromFile` performs it. */
87
+ codec: CollabCodec;
55
88
  /**
56
- * Create the yJS binding that wires editor state to the Y.Doc. Called
57
- * once when collaboration is ready (sync done, seed applied if needed).
58
- * Returns a destroy fn invoked on unmount or when the binding needs to
59
- * be torn down.
89
+ * Create the yJS binding that wires editor state to the Y.Doc. Called once
90
+ * when collaboration is ready (sync done, seed applied if needed). May return
91
+ * a Promise so extensions can defer construction until their imperative API
92
+ * has finished mounting; the hook awaits it and honors cancellation.
60
93
  */
61
- createBinding(ctx: {
62
- yDoc: Y.Doc;
63
- awareness: import('y-protocols/awareness').Awareness;
64
- user: {
65
- id: string;
66
- name: string;
67
- color: string;
68
- };
69
- }): {
70
- destroy: () => void;
71
- };
94
+ bind(ctx: CollabBindContext): CollabBindResult;
72
95
  /**
73
- * Decide whether the Y.Doc still needs to be seeded from file content.
74
- * Returning `true` means "this Y.Doc has no extension content yet -- call
75
- * initializeFromContent". Default: `Y.encodeStateAsUpdate(yDoc).byteLength <= 2`,
76
- * which matches a fully empty Y.Doc.
77
- *
78
- * Override when your shared types may exist as empty containers (e.g. you
79
- * always call `yDoc.getMap('meta')` even on first open) and a length-2
80
- * encoded state would still be considered "empty" by your check.
96
+ * Optional: notified when the first-open seed fails or its flush is not
97
+ * confirmed by the server. The host uses this to surface the failure via the
98
+ * pending-seed machinery instead of leaving a silent `console.error`.
99
+ */
100
+ onSeedOutcome?(outcome: {
101
+ ok: boolean;
102
+ error?: unknown;
103
+ }): void;
104
+ }
105
+ /**
106
+ * @deprecated Legacy config shape. Prefer {@link UseCollaborativeEditorCodecConfig}
107
+ * (`{ codec, bind }`), which keeps the pure `isEmpty`/`seed` on the codec so
108
+ * live and headless seeding cannot diverge. This shape traps those pure
109
+ * functions inside React. Still accepted so existing editors keep working.
110
+ */
111
+ export interface UseCollaborativeEditorLegacyConfig {
112
+ /** @deprecated Use `bind` on the codec config. */
113
+ createBinding(ctx: CollabBindContext): CollabBindResult;
114
+ /**
115
+ * @deprecated Provide `isEmpty` on the codec. Decide whether the Y.Doc still
116
+ * needs seeding from file content. Default: `Y.encodeStateAsUpdate(yDoc).byteLength <= 2`.
81
117
  */
82
118
  isEmpty?(yDoc: Y.Doc): boolean;
83
119
  /**
84
- * Populate the Y.Doc from raw file content when this client is the first
85
- * to open the document. Called inside a `yDoc.transact(..., COLLAB_INIT_ORIGIN)`
86
- * so bindings can ignore the seeding transaction.
87
- *
88
- * Bootstrap race: see file-level docs. Use content-derived stable IDs.
120
+ * @deprecated Provide `seedFromFile` on the codec. Populate the Y.Doc from
121
+ * raw file content when this client is first. Called inside a
122
+ * `yDoc.transact(..., COLLAB_INIT_ORIGIN)`. Use content-derived stable IDs
123
+ * (see file-level docs) to keep the bootstrap race deterministic.
89
124
  */
90
125
  initializeFromContent(yDoc: Y.Doc, content: string | ArrayBuffer): void;
126
+ onSeedOutcome?(outcome: {
127
+ ok: boolean;
128
+ error?: unknown;
129
+ }): void;
91
130
  }
131
+ export type UseCollaborativeEditorConfig = UseCollaborativeEditorCodecConfig | UseCollaborativeEditorLegacyConfig;
92
132
  export interface UseCollaborativeEditorResult {
93
133
  /** True when `host.collaboration` is defined. */
94
134
  isCollaborative: boolean;
@@ -100,12 +140,26 @@ export interface UseCollaborativeEditorResult {
100
140
  */
101
141
  collaborators: Map<string, CollaboratorInfo>;
102
142
  /**
103
- * The binding handle once `createBinding` has run, or `null` until
143
+ * The binding handle once the binding factory has run, or `null` until
104
144
  * collaboration is ready / when not collab.
105
145
  */
106
146
  binding: {
107
147
  destroy: () => void;
108
148
  } | null;
149
+ /**
150
+ * Non-null when the first-open seed failed or its flush was not confirmed by
151
+ * the server. Hosts read this to surface the failure (pending-seed toast)
152
+ * rather than leaving a silent console error.
153
+ */
154
+ seedError: unknown | null;
109
155
  }
156
+ /**
157
+ * True when `content` can legitimately seed a first-open collaborative doc.
158
+ * Empty/whitespace content must never seed: the host returns '' when it has
159
+ * no bytes for the document (e.g. reopening an already-shared doc), and
160
+ * seeding from that writes a DEFAULT document into the shared room —
161
+ * clobbering the room's real content for every client. Exported for tests.
162
+ */
163
+ export declare function hasSeedableContent(content: string | ArrayBuffer | Uint8Array | null | undefined): boolean;
110
164
  export declare function useCollaborativeEditor(host: EditorHost, config: UseCollaborativeEditorConfig): UseCollaborativeEditorResult;
111
165
  //# sourceMappingURL=useCollaborativeEditor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useCollaborativeEditor.d.ts","sourceRoot":"","sources":["../src/useCollaborativeEditor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAGH,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AACzB,OAAO,KAAK,EAEV,mBAAmB,EACnB,gBAAgB,EAChB,UAAU,EACX,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,eAAkC,CAAC;AAElE,MAAM,WAAW,4BAA4B;IAC3C;;;;;OAKG;IACH,aAAa,CAAC,GAAG,EAAE;QACjB,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC;QACZ,SAAS,EAAE,OAAO,uBAAuB,EAAE,SAAS,CAAC;QACrD,IAAI,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC;KACnD,GAAG;QAAE,OAAO,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC;IAE5B;;;;;;;;;OASG;IACH,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,GAAG,OAAO,CAAC;IAE/B;;;;;;OAMG;IACH,qBAAqB,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;CACzE;AAED,MAAM,WAAW,4BAA4B;IAC3C,iDAAiD;IACjD,eAAe,EAAE,OAAO,CAAC;IACzB,0EAA0E;IAC1E,MAAM,EAAE,mBAAmB,CAAC;IAC5B;;;OAGG;IACH,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC7C;;;OAGG;IACH,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CACzC;AAOD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,4BAA4B,GACnC,4BAA4B,CAwH9B"}
1
+ {"version":3,"file":"useCollaborativeEditor.d.ts","sourceRoot":"","sources":["../src/useCollaborativeEditor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAGH,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AACzB,OAAO,KAAK,EAEV,mBAAmB,EACnB,gBAAgB,EAChB,UAAU,EACX,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,WAAW,EAA2B,MAAM,mBAAmB,CAAC;AAE9E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,eAAkC,CAAC;AAElE,wEAAwE;AACxE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC;IACZ,SAAS,EAAE,OAAO,uBAAuB,EAAE,SAAS,CAAC;IACrD,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACnD;AAED,MAAM,MAAM,gBAAgB,GACxB;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,GACvB,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAAC,CAAC;AAErC;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,iCAAiC;IAChD,kFAAkF;IAClF,KAAK,EAAE,WAAW,CAAC;IAEnB;;;;;OAKG;IACH,IAAI,CAAC,GAAG,EAAE,iBAAiB,GAAG,gBAAgB,CAAC;IAE/C;;;;OAIG;IACH,aAAa,CAAC,CAAC,OAAO,EAAE;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC;CACjE;AAED;;;;;GAKG;AACH,MAAM,WAAW,kCAAkC;IACjD,kDAAkD;IAClD,aAAa,CAAC,GAAG,EAAE,iBAAiB,GAAG,gBAAgB,CAAC;IAExD;;;OAGG;IACH,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,GAAG,OAAO,CAAC;IAE/B;;;;;OAKG;IACH,qBAAqB,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IAExE,aAAa,CAAC,CAAC,OAAO,EAAE;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC;CACjE;AAED,MAAM,MAAM,4BAA4B,GACpC,iCAAiC,GACjC,kCAAkC,CAAC;AAEvC,MAAM,WAAW,4BAA4B;IAC3C,iDAAiD;IACjD,eAAe,EAAE,OAAO,CAAC;IACzB,0EAA0E;IAC1E,MAAM,EAAE,mBAAmB,CAAC;IAC5B;;;OAGG;IACH,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC7C;;;OAGG;IACH,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;IACxC;;;;OAIG;IACH,SAAS,EAAE,OAAO,GAAG,IAAI,CAAC;CAC3B;AAyCD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,UAAU,GAAG,IAAI,GAAG,SAAS,GAC5D,OAAO,CAIT;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,4BAA4B,GACnC,4BAA4B,CA6K9B"}
@@ -55,11 +55,53 @@ function defaultIsEmpty(yDoc) {
55
55
  // A fully empty Y.Doc encodes to ~2 bytes (header only).
56
56
  return Y.encodeStateAsUpdate(yDoc).byteLength <= 2;
57
57
  }
58
+ /**
59
+ * Normalize the config to a single internal shape. `codec` (preferred) takes
60
+ * its `isEmpty`/`seed` from the pure codec; the legacy shape keeps the
61
+ * hand-rolled `isEmpty`/`initializeFromContent`. Content is normalized to
62
+ * `string | Uint8Array` for the codec path (the host's `loadInitialContent`
63
+ * still yields `string | ArrayBuffer`).
64
+ */
65
+ function resolveConfig(config) {
66
+ if ('codec' in config) {
67
+ const { codec, bind, onSeedOutcome } = config;
68
+ return {
69
+ bind,
70
+ isEmpty: (yDoc) => codec.isEmpty(yDoc),
71
+ seed: (yDoc, content) => codec.seedFromFile(yDoc, toCodecSource(content)),
72
+ onSeedOutcome,
73
+ };
74
+ }
75
+ return {
76
+ bind: config.createBinding,
77
+ isEmpty: config.isEmpty ?? defaultIsEmpty,
78
+ seed: config.initializeFromContent,
79
+ onSeedOutcome: config.onSeedOutcome,
80
+ };
81
+ }
82
+ function toCodecSource(content) {
83
+ return typeof content === 'string' ? content : new Uint8Array(content);
84
+ }
85
+ /**
86
+ * True when `content` can legitimately seed a first-open collaborative doc.
87
+ * Empty/whitespace content must never seed: the host returns '' when it has
88
+ * no bytes for the document (e.g. reopening an already-shared doc), and
89
+ * seeding from that writes a DEFAULT document into the shared room —
90
+ * clobbering the room's real content for every client. Exported for tests.
91
+ */
92
+ export function hasSeedableContent(content) {
93
+ if (content == null)
94
+ return false;
95
+ if (typeof content === 'string')
96
+ return content.trim().length > 0;
97
+ return content.byteLength > 0;
98
+ }
58
99
  export function useCollaborativeEditor(host, config) {
59
100
  const isCollaborative = !!host.collaboration;
60
101
  const [status, setStatus] = useState(host.collaboration?.getStatus() ?? 'disconnected');
61
102
  const [collaborators, setCollaborators] = useState(() => new Map());
62
103
  const [binding, setBinding] = useState(null);
104
+ const [seedError, setSeedError] = useState(null);
63
105
  // Keep config in a ref so the binding-creation effect doesn't tear down on
64
106
  // every render. Hosts pass fresh config objects each render, but the
65
107
  // intent is "wire once, keep until the host changes".
@@ -114,37 +156,83 @@ export function useCollaborativeEditor(host, config) {
114
156
  return;
115
157
  if (collab.getStatus() !== 'connected')
116
158
  return;
117
- const cfg = configRef.current;
118
- const isEmptyFn = cfg.isEmpty ?? defaultIsEmpty;
119
- if (isEmptyFn(collab.yDoc)) {
159
+ const cfg = resolveConfig(configRef.current);
160
+ // NEVER seed when the transport skipped payloads it could not decode:
161
+ // the Y.Doc looking empty then means "content exists but is unreadable
162
+ // on this client", and seeding would write a default document over the
163
+ // real content for every client (the "Untitled map" clobber).
164
+ const undecoded = collab.hasUndecodedContent?.() === true;
165
+ if (!undecoded && cfg.isEmpty(collab.yDoc)) {
120
166
  try {
121
167
  const content = await collab.loadInitialContent();
122
168
  if (cancelled)
123
169
  return;
170
+ // NEVER seed from empty content. A host that has no bytes for this
171
+ // document (reopen of an already-shared doc: no in-memory share
172
+ // payload, no file) returns ''/empty -- seeding from that writes a
173
+ // default document into the shared room and clobbers whatever the
174
+ // room's real content is for every client. Fall through to bind:
175
+ // the room content (or a teammate's seed) will populate the doc.
176
+ if (!hasSeedableContent(content)) {
177
+ console.warn('[useCollaborativeEditor] Skipping first-open seed: host returned empty initial content.');
178
+ }
124
179
  // Re-check emptiness in case another client seeded while we were
125
180
  // awaiting -- they would have raced through the WebSocket and
126
181
  // applied their update during our await gap. Avoid double-seeding
127
182
  // in that case; CRDT merge would otherwise insert duplicate
128
183
  // content unless the seed is fully deterministic.
129
- if (isEmptyFn(collab.yDoc)) {
184
+ else if (cfg.isEmpty(collab.yDoc)) {
130
185
  collab.yDoc.transact(() => {
131
- cfg.initializeFromContent(collab.yDoc, content);
186
+ cfg.seed(collab.yDoc, content);
132
187
  }, COLLAB_INIT_ORIGIN);
188
+ // Durability: the seed the user sees locally must reach the server
189
+ // before this provider can tear down. flushWithAck resolves only
190
+ // after a server-persisted ack; flushLocalState is the deprecated
191
+ // fire-and-forget fallback for older hosts.
192
+ const flushed = collab.flushWithAck
193
+ ? await collab.flushWithAck()
194
+ : (await collab.flushLocalState?.(), true);
195
+ if (!flushed) {
196
+ const err = new Error('Seed flush was not confirmed by the server before timeout; content may not have persisted.');
197
+ console.warn('[useCollaborativeEditor]', err.message);
198
+ setSeedError(err);
199
+ cfg.onSeedOutcome?.({ ok: false, error: err });
200
+ }
201
+ else {
202
+ setSeedError(null);
203
+ cfg.onSeedOutcome?.({ ok: true });
204
+ }
133
205
  }
134
206
  }
135
207
  catch (err) {
136
208
  console.error('[useCollaborativeEditor] Failed to load/seed initial content:', err);
209
+ setSeedError(err);
210
+ cfg.onSeedOutcome?.({ ok: false, error: err });
137
211
  // Continue with bind -- the doc may still be usable once another
138
212
  // client seeds it.
139
213
  }
140
214
  }
141
215
  if (cancelled)
142
216
  return;
143
- handle = cfg.createBinding({
217
+ const created = cfg.bind({
144
218
  yDoc: collab.yDoc,
145
219
  awareness: collab.awareness,
146
220
  user: collab.user,
147
221
  });
222
+ const resolved = created instanceof Promise ? await created : created;
223
+ if (cancelled) {
224
+ // Effect unmounted while we were awaiting the extension's
225
+ // imperative API. Destroy the freshly-built handle so any
226
+ // observers/awareness subscriptions inside it get cleaned up.
227
+ try {
228
+ resolved.destroy();
229
+ }
230
+ catch (err) {
231
+ console.error('[useCollaborativeEditor] post-cancel destroy failed:', err);
232
+ }
233
+ return;
234
+ }
235
+ handle = resolved;
148
236
  setBinding(handle);
149
237
  };
150
238
  void tryStart();
@@ -161,5 +249,5 @@ export function useCollaborativeEditor(host, config) {
161
249
  setBinding(null);
162
250
  };
163
251
  }, [host]);
164
- return { isCollaborative, status, collaborators, binding };
252
+ return { isCollaborative, status, collaborators, binding, seedError };
165
253
  }
@@ -0,0 +1,5 @@
1
+ import type { ValidationResult } from './validationTypes.js';
2
+ export declare function validateExtensionBundle(_distPath: string, _bundleName?: string): Promise<ValidationResult>;
3
+ export declare function printValidationResult(result: ValidationResult): void;
4
+ export type { ValidationResult } from './validationTypes.js';
5
+ //# sourceMappingURL=validate.browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.browser.d.ts","sourceRoot":"","sources":["../src/validate.browser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAM7D,wBAAsB,uBAAuB,CAC3C,SAAS,EAAE,MAAM,EACjB,WAAW,SAAa,GACvB,OAAO,CAAC,gBAAgB,CAAC,CAM3B;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAmBpE;AAED,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,27 @@
1
+ const BROWSER_VALIDATION_ERROR = 'validateExtensionBundle() is only available in Node.js build tooling. ' +
2
+ 'Import it from a Vite config, build script, or other Node runtime.';
3
+ export async function validateExtensionBundle(_distPath, _bundleName = 'index.js') {
4
+ return {
5
+ valid: false,
6
+ errors: [BROWSER_VALIDATION_ERROR],
7
+ warnings: [],
8
+ };
9
+ }
10
+ export function printValidationResult(result) {
11
+ if (result.errors.length === 0 && result.warnings.length === 0) {
12
+ console.warn(BROWSER_VALIDATION_ERROR);
13
+ return;
14
+ }
15
+ if (result.errors.length > 0) {
16
+ console.error('Extension bundle validation errors:');
17
+ for (const error of result.errors) {
18
+ console.error(` - ${error}`);
19
+ }
20
+ }
21
+ if (result.warnings.length > 0) {
22
+ console.warn('Extension bundle validation warnings:');
23
+ for (const warning of result.warnings) {
24
+ console.warn(` - ${warning}`);
25
+ }
26
+ }
27
+ }
@@ -1,11 +1,5 @@
1
- export interface ValidationResult {
2
- /** Whether the bundle passed validation */
3
- valid: boolean;
4
- /** Critical errors that will cause runtime failures */
5
- errors: string[];
6
- /** Warnings that may cause issues */
7
- warnings: string[];
8
- }
1
+ import type { ValidationResult } from './validationTypes.js';
2
+ export type { ValidationResult } from './validationTypes.js';
9
3
  /**
10
4
  * Validates an extension bundle for common issues.
11
5
  *
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,gBAAgB;IAC/B,2CAA2C;IAC3C,KAAK,EAAE,OAAO,CAAC;IAEf,uDAAuD;IACvD,MAAM,EAAE,MAAM,EAAE,CAAC;IAEjB,qCAAqC;IACrC,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,UAAU,SAAa,GACtB,OAAO,CAAC,gBAAgB,CAAC,CAkH3B;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAmBpE"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE7D;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,UAAU,SAAa,GACtB,OAAO,CAAC,gBAAgB,CAAC,CAsP3B;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAmBpE"}
package/dist/validate.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
+ import { validateAgentProviders, validateBackendModules, } from './manifestValidation.js';
6
7
  /**
7
8
  * Validates an extension bundle for common issues.
8
9
  *
@@ -94,6 +95,122 @@ export async function validateExtensionBundle(distPath, bundleName = 'index.js')
94
95
  warnings.push(`manifest.json "styles" points to ${manifest.styles} but file does not exist`);
95
96
  }
96
97
  }
98
+ // Validate backendModules if present. These run outside the renderer
99
+ // under a host-managed permission system; malformed declarations would
100
+ // either fail to load at runtime or, worse, declare unknown permission
101
+ // ids the consent prompt can't render. Catch both at build time.
102
+ const backendModulesIssues = validateBackendModules(manifest?.contributions?.backendModules);
103
+ for (const issue of backendModulesIssues) {
104
+ errors.push(issue.message);
105
+ }
106
+ // Also check the declared entry files exist on disk.
107
+ if (Array.isArray(manifest?.contributions?.backendModules)) {
108
+ for (const module of manifest.contributions.backendModules) {
109
+ if (typeof module?.entry === 'string') {
110
+ const entryPath = path.join(path.dirname(manifestPath), module.entry);
111
+ if (!fs.existsSync(entryPath)) {
112
+ errors.push(`backendModules[${module.id ?? '?'}].entry points to ${module.entry} but file does not exist`);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ // Validate aiAgentProviders contributions: id/name/backendModuleId
118
+ // shape, uniqueness, model fields, toolFileLinks keys, and provider
119
+ // count cap. Cross-references contributions.backendModules so a typo
120
+ // in backendModuleId fails the build instead of erroring at runtime.
121
+ const agentProviderIssues = validateAgentProviders(manifest?.contributions?.aiAgentProviders, manifest?.contributions?.backendModules);
122
+ for (const issue of agentProviderIssues) {
123
+ if (issue.severity === 'warning') {
124
+ warnings.push(issue.message);
125
+ }
126
+ else {
127
+ errors.push(issue.message);
128
+ }
129
+ }
130
+ // For each agent provider, verify that the backend module it points at
131
+ // actually exports a usable entry point in the built bundle. The
132
+ // privileged host will either call `activate(context)` (the generic
133
+ // BackendModule contract) or `createAgentProvider(context)` (the
134
+ // agent-provider factory) -- catching missing exports here keeps a
135
+ // broken contribution from reaching the consent prompt.
136
+ if (Array.isArray(manifest?.contributions?.aiAgentProviders)) {
137
+ // Build a quick lookup so the per-provider check below can find the
138
+ // corresponding backend-module entry path without re-scanning the
139
+ // array each time.
140
+ const moduleEntries = new Map();
141
+ if (Array.isArray(manifest?.contributions?.backendModules)) {
142
+ for (const moduleRaw of manifest.contributions.backendModules) {
143
+ const id = moduleRaw?.id;
144
+ const entry = moduleRaw?.entry;
145
+ if (typeof id === 'string' && typeof entry === 'string') {
146
+ moduleEntries.set(id, entry);
147
+ }
148
+ }
149
+ }
150
+ for (const providerRaw of manifest.contributions.aiAgentProviders) {
151
+ const providerId = typeof providerRaw?.id === 'string' ? providerRaw.id : '?';
152
+ const backendModuleId = typeof providerRaw?.backendModuleId === 'string'
153
+ ? providerRaw.backendModuleId
154
+ : undefined;
155
+ if (!backendModuleId) {
156
+ // Already reported by validateAgentProviders above; don't
157
+ // duplicate the error here.
158
+ continue;
159
+ }
160
+ const entryRel = moduleEntries.get(backendModuleId);
161
+ if (!entryRel) {
162
+ // Cross-reference error already reported by
163
+ // validateAgentProviders; skip the bundle scan.
164
+ continue;
165
+ }
166
+ const entryAbs = path.join(path.dirname(manifestPath), entryRel);
167
+ if (!fs.existsSync(entryAbs)) {
168
+ // The missing-file error is already raised by the backendModules
169
+ // entry check above; don't pile on with a confusingly worded
170
+ // duplicate. Skip the export scan.
171
+ continue;
172
+ }
173
+ // We deliberately do a textual scan rather than executing the
174
+ // module. The backend module is meant to run inside a privileged
175
+ // worker; loading it inside the build script could trigger side
176
+ // effects (open ports, spawn processes, write disk) that have no
177
+ // business firing during `vite build`. A string check for the
178
+ // known export shapes is enough to catch typos and forgotten
179
+ // exports, which is the failure mode users actually hit.
180
+ let entrySource;
181
+ try {
182
+ entrySource = fs.readFileSync(entryAbs, 'utf8');
183
+ }
184
+ catch (readErr) {
185
+ errors.push(`aiAgentProviders[${providerId}].backendModuleId "${backendModuleId}" ` +
186
+ `points at entry ${entryRel} which exists but could not be read (${String(readErr)}). ` +
187
+ 'The validator needs to scan the entry for an activate / createAgentProvider export.');
188
+ continue;
189
+ }
190
+ // Look for either a top-level `export function activate` /
191
+ // `export const activate` / `export { activate }`, or the same
192
+ // shapes for `createAgentProvider`. Bundlers normalize these in
193
+ // varied ways; the union of these patterns covers Vite, esbuild,
194
+ // and tsc output. We do NOT match `exports.activate =` because
195
+ // backend modules ship as ESM; CJS output would have been flagged
196
+ // by the `require(` check earlier in this function anyway.
197
+ const exportPatterns = [
198
+ /export\s+(?:async\s+)?function\s+activate\b/,
199
+ /export\s+(?:const|let|var)\s+activate\b/,
200
+ /export\s*\{[^}]*\bactivate\b[^}]*\}/,
201
+ /export\s+(?:async\s+)?function\s+createAgentProvider\b/,
202
+ /export\s+(?:const|let|var)\s+createAgentProvider\b/,
203
+ /export\s*\{[^}]*\bcreateAgentProvider\b[^}]*\}/,
204
+ ];
205
+ const hasExport = exportPatterns.some((p) => p.test(entrySource));
206
+ if (!hasExport) {
207
+ errors.push(`aiAgentProviders[${providerId}].backendModuleId "${backendModuleId}" ` +
208
+ `resolves to ${entryRel}, but the entry file does not appear to export ` +
209
+ 'an `activate` or `createAgentProvider` function. The privileged host calls one ' +
210
+ 'of these to bring the provider online.');
211
+ }
212
+ }
213
+ }
97
214
  }
98
215
  catch (e) {
99
216
  errors.push(`Failed to parse manifest.json: ${e}`);
@@ -0,0 +1,9 @@
1
+ export interface ValidationResult {
2
+ /** Whether the bundle passed validation */
3
+ valid: boolean;
4
+ /** Critical errors that will cause runtime failures */
5
+ errors: string[];
6
+ /** Warnings that may cause issues */
7
+ warnings: string[];
8
+ }
9
+ //# sourceMappingURL=validationTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validationTypes.d.ts","sourceRoot":"","sources":["../src/validationTypes.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,2CAA2C;IAC3C,KAAK,EAAE,OAAO,CAAC;IAEf,uDAAuD;IACvD,MAAM,EAAE,MAAM,EAAE,CAAC;IAEjB,qCAAqC;IACrC,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB"}
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbalyst/extension-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "SDK for building Nimbalyst extensions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,6 +13,9 @@
13
13
  },
14
14
  "type": "module",
15
15
  "sideEffects": false,
16
+ "browser": {
17
+ "./dist/validate.js": "./dist/validate.browser.js"
18
+ },
16
19
  "nimbalyst": {
17
20
  "minAppVersion": "0.58.5"
18
21
  },