@nimbalyst/extension-sdk 0.1.5 → 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.
- package/CHANGELOG.md +30 -0
- package/dist/__tests__/createTextCollabContentAdapter.test.d.ts +2 -0
- package/dist/__tests__/createTextCollabContentAdapter.test.d.ts.map +1 -0
- package/dist/__tests__/createTextCollabContentAdapter.test.js +60 -0
- package/dist/__tests__/hasSeedableContent.test.d.ts +2 -0
- package/dist/__tests__/hasSeedableContent.test.d.ts.map +1 -0
- package/dist/__tests__/hasSeedableContent.test.js +25 -0
- package/dist/__tests__/manifestValidation.agentProviders.test.d.ts +2 -0
- package/dist/__tests__/manifestValidation.agentProviders.test.d.ts.map +1 -0
- package/dist/__tests__/manifestValidation.agentProviders.test.js +44 -0
- package/dist/agents/AgentProtocol.d.ts +227 -0
- package/dist/agents/AgentProtocol.d.ts.map +1 -0
- package/dist/agents/AgentProtocol.js +23 -0
- package/dist/agents/AgentProtocolHost.d.ts +190 -0
- package/dist/agents/AgentProtocolHost.d.ts.map +1 -0
- package/dist/agents/AgentProtocolHost.js +42 -0
- package/dist/agents/index.d.ts +26 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +24 -0
- package/dist/collab/createTextCollabContentAdapter.d.ts +26 -0
- package/dist/collab/createTextCollabContentAdapter.d.ts.map +1 -0
- package/dist/collab/createTextCollabContentAdapter.js +79 -0
- package/dist/createReadOnlyHost.d.ts +5 -0
- package/dist/createReadOnlyHost.d.ts.map +1 -1
- package/dist/createReadOnlyHost.js +4 -0
- package/dist/externals.d.ts +3 -3
- package/dist/externals.d.ts.map +1 -1
- package/dist/externals.js +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/manifestValidation.d.ts +118 -0
- package/dist/manifestValidation.d.ts.map +1 -0
- package/dist/manifestValidation.js +475 -0
- package/dist/types/collab.d.ts +138 -0
- package/dist/types/collab.d.ts.map +1 -0
- package/dist/types/collab.js +1 -0
- package/dist/types/editor.d.ts +198 -0
- package/dist/types/editor.d.ts.map +1 -1
- package/dist/types/editors.d.ts +4 -0
- package/dist/types/editors.d.ts.map +1 -1
- package/dist/types/extension.d.ts +438 -5
- package/dist/types/extension.d.ts.map +1 -1
- package/dist/types/extension.js +11 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +3 -0
- package/dist/types/panel.d.ts +50 -0
- package/dist/types/panel.d.ts.map +1 -1
- package/dist/types/permissions.d.ts +117 -0
- package/dist/types/permissions.d.ts.map +1 -0
- package/dist/types/permissions.js +33 -0
- package/dist/types/theme.d.ts +67 -0
- package/dist/types/theme.d.ts.map +1 -1
- package/dist/types/theme.js +7 -1
- package/dist/types/trackerImporter.d.ts +147 -0
- package/dist/types/trackerImporter.d.ts.map +1 -0
- package/dist/types/trackerImporter.js +33 -0
- package/dist/useCollaborativeEditor.d.ts +165 -0
- package/dist/useCollaborativeEditor.d.ts.map +1 -0
- package/dist/useCollaborativeEditor.js +253 -0
- package/dist/useEditorLifecycle.d.ts.map +1 -1
- package/dist/useEditorLifecycle.js +9 -5
- package/dist/validate.browser.d.ts +5 -0
- package/dist/validate.browser.d.ts.map +1 -0
- package/dist/validate.browser.js +27 -0
- package/dist/validate.d.ts +2 -8
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +117 -0
- package/dist/validationTypes.d.ts +9 -0
- package/dist/validationTypes.d.ts.map +1 -0
- package/dist/validationTypes.js +1 -0
- package/package.json +16 -3
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCollaborativeEditor Hook
|
|
3
|
+
*
|
|
4
|
+
* Companion to `useEditorLifecycle` for collaborative documents. When the
|
|
5
|
+
* host opens a file with `host.collaboration` defined, this hook manages the
|
|
6
|
+
* binding lifecycle: wait for sync, optionally seed the Y.Doc from file
|
|
7
|
+
* content if this client is first, create the editor's binding, destroy on
|
|
8
|
+
* unmount.
|
|
9
|
+
*
|
|
10
|
+
* Extensions wire it like this:
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* function MyEditor({ host }: EditorHostProps) {
|
|
14
|
+
* const apiRef = useRef<MyImperativeAPI | null>(null);
|
|
15
|
+
*
|
|
16
|
+
* // Local-only path (unchanged for non-collab opens).
|
|
17
|
+
* const { isLoading } = useEditorLifecycle(host, { ... });
|
|
18
|
+
*
|
|
19
|
+
* // Collaborative path (no-op when host.collaboration is undefined).
|
|
20
|
+
* const { isCollaborative, status, collaborators } = useCollaborativeEditor(host, {
|
|
21
|
+
* createBinding: ({ yDoc, awareness, user }) => {
|
|
22
|
+
* const b = new MyBinding(yDoc, apiRef.current!, awareness, user);
|
|
23
|
+
* return { destroy: () => b.destroy() };
|
|
24
|
+
* },
|
|
25
|
+
* initializeFromContent: (yDoc, content) => seedYDocFromFile(yDoc, content),
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* return <MyLibrary ref={apiRef} ... />;
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Bootstrap-race safety: if two clients both open an empty document, both
|
|
33
|
+
* will call `initializeFromContent` and their CRDT updates merge. To avoid
|
|
34
|
+
* duplicate elements your seeded shared types MUST use **content-derived
|
|
35
|
+
* stable IDs** (e.g. element `id` from the file), not random IDs. The same
|
|
36
|
+
* input yields the same Y.Doc state; merged duplicates collapse.
|
|
37
|
+
*/
|
|
38
|
+
import * as Y from 'yjs';
|
|
39
|
+
import type { CollaborationStatus, CollaboratorInfo, EditorHost } from './types/editor.js';
|
|
40
|
+
import type { CollabCodec } from './types/collab.js';
|
|
41
|
+
/**
|
|
42
|
+
* Origin tag used when the SDK wraps `initializeFromContent` in a Y.Doc
|
|
43
|
+
* transaction. Extension bindings can compare a transaction's origin
|
|
44
|
+
* against this to suppress their own change handlers during seeding
|
|
45
|
+
* (otherwise the binding would echo the seed back into local-edit state).
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* yDoc.on('update', (update, origin) => {
|
|
49
|
+
* if (origin === COLLAB_INIT_ORIGIN) return;
|
|
50
|
+
* // ... apply remote change
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare const COLLAB_INIT_ORIGIN: unique symbol;
|
|
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;
|
|
88
|
+
/**
|
|
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.
|
|
93
|
+
*/
|
|
94
|
+
bind(ctx: CollabBindContext): CollabBindResult;
|
|
95
|
+
/**
|
|
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`.
|
|
117
|
+
*/
|
|
118
|
+
isEmpty?(yDoc: Y.Doc): boolean;
|
|
119
|
+
/**
|
|
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.
|
|
124
|
+
*/
|
|
125
|
+
initializeFromContent(yDoc: Y.Doc, content: string | ArrayBuffer): void;
|
|
126
|
+
onSeedOutcome?(outcome: {
|
|
127
|
+
ok: boolean;
|
|
128
|
+
error?: unknown;
|
|
129
|
+
}): void;
|
|
130
|
+
}
|
|
131
|
+
export type UseCollaborativeEditorConfig = UseCollaborativeEditorCodecConfig | UseCollaborativeEditorLegacyConfig;
|
|
132
|
+
export interface UseCollaborativeEditorResult {
|
|
133
|
+
/** True when `host.collaboration` is defined. */
|
|
134
|
+
isCollaborative: boolean;
|
|
135
|
+
/** Current connection status. Always `'disconnected'` when not collab. */
|
|
136
|
+
status: CollaborationStatus;
|
|
137
|
+
/**
|
|
138
|
+
* Remote collaborators keyed by their stable user id (not the y-protocols
|
|
139
|
+
* client id). Mirrors awareness; updated when remote presence changes.
|
|
140
|
+
*/
|
|
141
|
+
collaborators: Map<string, CollaboratorInfo>;
|
|
142
|
+
/**
|
|
143
|
+
* The binding handle once the binding factory has run, or `null` until
|
|
144
|
+
* collaboration is ready / when not collab.
|
|
145
|
+
*/
|
|
146
|
+
binding: {
|
|
147
|
+
destroy: () => void;
|
|
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;
|
|
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;
|
|
164
|
+
export declare function useCollaborativeEditor(host: EditorHost, config: UseCollaborativeEditorConfig): UseCollaborativeEditorResult;
|
|
165
|
+
//# sourceMappingURL=useCollaborativeEditor.d.ts.map
|
|
@@ -0,0 +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;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"}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCollaborativeEditor Hook
|
|
3
|
+
*
|
|
4
|
+
* Companion to `useEditorLifecycle` for collaborative documents. When the
|
|
5
|
+
* host opens a file with `host.collaboration` defined, this hook manages the
|
|
6
|
+
* binding lifecycle: wait for sync, optionally seed the Y.Doc from file
|
|
7
|
+
* content if this client is first, create the editor's binding, destroy on
|
|
8
|
+
* unmount.
|
|
9
|
+
*
|
|
10
|
+
* Extensions wire it like this:
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* function MyEditor({ host }: EditorHostProps) {
|
|
14
|
+
* const apiRef = useRef<MyImperativeAPI | null>(null);
|
|
15
|
+
*
|
|
16
|
+
* // Local-only path (unchanged for non-collab opens).
|
|
17
|
+
* const { isLoading } = useEditorLifecycle(host, { ... });
|
|
18
|
+
*
|
|
19
|
+
* // Collaborative path (no-op when host.collaboration is undefined).
|
|
20
|
+
* const { isCollaborative, status, collaborators } = useCollaborativeEditor(host, {
|
|
21
|
+
* createBinding: ({ yDoc, awareness, user }) => {
|
|
22
|
+
* const b = new MyBinding(yDoc, apiRef.current!, awareness, user);
|
|
23
|
+
* return { destroy: () => b.destroy() };
|
|
24
|
+
* },
|
|
25
|
+
* initializeFromContent: (yDoc, content) => seedYDocFromFile(yDoc, content),
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* return <MyLibrary ref={apiRef} ... />;
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Bootstrap-race safety: if two clients both open an empty document, both
|
|
33
|
+
* will call `initializeFromContent` and their CRDT updates merge. To avoid
|
|
34
|
+
* duplicate elements your seeded shared types MUST use **content-derived
|
|
35
|
+
* stable IDs** (e.g. element `id` from the file), not random IDs. The same
|
|
36
|
+
* input yields the same Y.Doc state; merged duplicates collapse.
|
|
37
|
+
*/
|
|
38
|
+
import { useEffect, useRef, useState } from 'react';
|
|
39
|
+
import * as Y from 'yjs';
|
|
40
|
+
/**
|
|
41
|
+
* Origin tag used when the SDK wraps `initializeFromContent` in a Y.Doc
|
|
42
|
+
* transaction. Extension bindings can compare a transaction's origin
|
|
43
|
+
* against this to suppress their own change handlers during seeding
|
|
44
|
+
* (otherwise the binding would echo the seed back into local-edit state).
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* yDoc.on('update', (update, origin) => {
|
|
48
|
+
* if (origin === COLLAB_INIT_ORIGIN) return;
|
|
49
|
+
* // ... apply remote change
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export const COLLAB_INIT_ORIGIN = Symbol('nimbalyst:collab-init');
|
|
54
|
+
function defaultIsEmpty(yDoc) {
|
|
55
|
+
// A fully empty Y.Doc encodes to ~2 bytes (header only).
|
|
56
|
+
return Y.encodeStateAsUpdate(yDoc).byteLength <= 2;
|
|
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
|
+
}
|
|
99
|
+
export function useCollaborativeEditor(host, config) {
|
|
100
|
+
const isCollaborative = !!host.collaboration;
|
|
101
|
+
const [status, setStatus] = useState(host.collaboration?.getStatus() ?? 'disconnected');
|
|
102
|
+
const [collaborators, setCollaborators] = useState(() => new Map());
|
|
103
|
+
const [binding, setBinding] = useState(null);
|
|
104
|
+
const [seedError, setSeedError] = useState(null);
|
|
105
|
+
// Keep config in a ref so the binding-creation effect doesn't tear down on
|
|
106
|
+
// every render. Hosts pass fresh config objects each render, but the
|
|
107
|
+
// intent is "wire once, keep until the host changes".
|
|
108
|
+
const configRef = useRef(config);
|
|
109
|
+
configRef.current = config;
|
|
110
|
+
// Status subscription.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const collab = host.collaboration;
|
|
113
|
+
if (!collab) {
|
|
114
|
+
setStatus('disconnected');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
setStatus(collab.getStatus());
|
|
118
|
+
return collab.onStatusChange(setStatus);
|
|
119
|
+
}, [host]);
|
|
120
|
+
// Awareness subscription -> collaborators map. The host populates the
|
|
121
|
+
// standard `user: { id, name, color }` field on every remote awareness
|
|
122
|
+
// state via the StandardAwarenessState contract.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const collab = host.collaboration;
|
|
125
|
+
if (!collab) {
|
|
126
|
+
setCollaborators(new Map());
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const rebuild = () => {
|
|
130
|
+
const next = new Map();
|
|
131
|
+
const states = collab.awareness.getStates();
|
|
132
|
+
for (const [clientId, state] of states) {
|
|
133
|
+
// Don't include ourselves.
|
|
134
|
+
if (clientId === collab.awareness.clientID)
|
|
135
|
+
continue;
|
|
136
|
+
const user = state.user;
|
|
137
|
+
if (!user || !user.id)
|
|
138
|
+
continue;
|
|
139
|
+
next.set(user.id, { user });
|
|
140
|
+
}
|
|
141
|
+
setCollaborators(next);
|
|
142
|
+
};
|
|
143
|
+
rebuild();
|
|
144
|
+
collab.awareness.on('change', rebuild);
|
|
145
|
+
return () => collab.awareness.off('change', rebuild);
|
|
146
|
+
}, [host]);
|
|
147
|
+
// Binding lifecycle.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const collab = host.collaboration;
|
|
150
|
+
if (!collab)
|
|
151
|
+
return;
|
|
152
|
+
let cancelled = false;
|
|
153
|
+
let handle = null;
|
|
154
|
+
const tryStart = async () => {
|
|
155
|
+
if (cancelled || handle)
|
|
156
|
+
return;
|
|
157
|
+
if (collab.getStatus() !== 'connected')
|
|
158
|
+
return;
|
|
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)) {
|
|
166
|
+
try {
|
|
167
|
+
const content = await collab.loadInitialContent();
|
|
168
|
+
if (cancelled)
|
|
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
|
+
}
|
|
179
|
+
// Re-check emptiness in case another client seeded while we were
|
|
180
|
+
// awaiting -- they would have raced through the WebSocket and
|
|
181
|
+
// applied their update during our await gap. Avoid double-seeding
|
|
182
|
+
// in that case; CRDT merge would otherwise insert duplicate
|
|
183
|
+
// content unless the seed is fully deterministic.
|
|
184
|
+
else if (cfg.isEmpty(collab.yDoc)) {
|
|
185
|
+
collab.yDoc.transact(() => {
|
|
186
|
+
cfg.seed(collab.yDoc, content);
|
|
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
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
console.error('[useCollaborativeEditor] Failed to load/seed initial content:', err);
|
|
209
|
+
setSeedError(err);
|
|
210
|
+
cfg.onSeedOutcome?.({ ok: false, error: err });
|
|
211
|
+
// Continue with bind -- the doc may still be usable once another
|
|
212
|
+
// client seeds it.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (cancelled)
|
|
216
|
+
return;
|
|
217
|
+
const created = cfg.bind({
|
|
218
|
+
yDoc: collab.yDoc,
|
|
219
|
+
awareness: collab.awareness,
|
|
220
|
+
user: collab.user,
|
|
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;
|
|
236
|
+
setBinding(handle);
|
|
237
|
+
};
|
|
238
|
+
void tryStart();
|
|
239
|
+
const unsubscribe = collab.onStatusChange(() => {
|
|
240
|
+
void tryStart();
|
|
241
|
+
});
|
|
242
|
+
return () => {
|
|
243
|
+
cancelled = true;
|
|
244
|
+
unsubscribe();
|
|
245
|
+
if (handle) {
|
|
246
|
+
handle.destroy();
|
|
247
|
+
handle = null;
|
|
248
|
+
}
|
|
249
|
+
setBinding(null);
|
|
250
|
+
};
|
|
251
|
+
}, [host]);
|
|
252
|
+
return { isCollaborative, status, collaborators, binding, seedError };
|
|
253
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useEditorLifecycle.d.ts","sourceRoot":"","sources":["../src/useEditorLifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAMhE,MAAM,WAAW,yBAAyB,CAAC,CAAC;IAC1C;;;;;OAKG;IACH,YAAY,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,IAAI,CAAC;IAEnC;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC,CAAC;IAE5B;;;OAGG;IACH,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,CAAC,CAAC;IAE3B;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,MAAM,CAAC;IAEnC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAEtB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,IAAI,CAAC;IAExC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAE7B;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IAE/C;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,SAAS,CAAC,CAAC;IAC1B,iCAAiC;IACjC,QAAQ,EAAE,CAAC,CAAC;IAEZ,qDAAqD;IACrD,QAAQ,EAAE,CAAC,CAAC;IAEZ,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IAEd,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAElB,8BAA8B;IAC9B,MAAM,EAAE,MAAM,IAAI,CAAC;IAEnB,mDAAmD;IACnD,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB,CAAC,CAAC;IACzC;;;OAGG;IACH,SAAS,EAAE,MAAM,IAAI,CAAC;IAEtB,gDAAgD;IAChD,SAAS,EAAE,OAAO,CAAC;IAEnB,wCAAwC;IACxC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAEpB,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAC;IAEd,8CAA8C;IAC9C,OAAO,EAAE,OAAO,CAAC;IAEjB;;;OAGG;IACH,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAE/B,iFAAiF;IACjF,gBAAgB,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;IAE3C,+CAA+C;IAC/C,YAAY,EAAE,OAAO,CAAC;CACvB;AAMD,wBAAgB,kBAAkB,CAAC,CAAC,GAAG,MAAM,EAC3C,IAAI,EAAE,UAAU,EAChB,OAAO,EAAE,yBAAyB,CAAC,CAAC,CAAC,GACpC,wBAAwB,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"useEditorLifecycle.d.ts","sourceRoot":"","sources":["../src/useEditorLifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAMhE,MAAM,WAAW,yBAAyB,CAAC,CAAC;IAC1C;;;;;OAKG;IACH,YAAY,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,IAAI,CAAC;IAEnC;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC,CAAC;IAE5B;;;OAGG;IACH,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,CAAC,CAAC;IAE3B;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,MAAM,CAAC;IAEnC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAEtB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,IAAI,CAAC;IAExC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAE7B;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IAE/C;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,SAAS,CAAC,CAAC;IAC1B,iCAAiC;IACjC,QAAQ,EAAE,CAAC,CAAC;IAEZ,qDAAqD;IACrD,QAAQ,EAAE,CAAC,CAAC;IAEZ,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IAEd,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAElB,8BAA8B;IAC9B,MAAM,EAAE,MAAM,IAAI,CAAC;IAEnB,mDAAmD;IACnD,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB,CAAC,CAAC;IACzC;;;OAGG;IACH,SAAS,EAAE,MAAM,IAAI,CAAC;IAEtB,gDAAgD;IAChD,SAAS,EAAE,OAAO,CAAC;IAEnB,wCAAwC;IACxC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAEpB,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAC;IAEd,8CAA8C;IAC9C,OAAO,EAAE,OAAO,CAAC;IAEjB;;;OAGG;IACH,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAE/B,iFAAiF;IACjF,gBAAgB,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;IAE3C,+CAA+C;IAC/C,YAAY,EAAE,OAAO,CAAC;CACvB;AAMD,wBAAgB,kBAAkB,CAAC,CAAC,GAAG,MAAM,EAC3C,IAAI,EAAE,UAAU,EAChB,OAAO,EAAE,yBAAyB,CAAC,CAAC,CAAC,GACpC,wBAAwB,CAAC,CAAC,CAAC,CAiS7B"}
|
|
@@ -81,8 +81,6 @@ export function useEditorLifecycle(host, options) {
|
|
|
81
81
|
optionsRef.current = options;
|
|
82
82
|
// Echo detection: stores the last serialized string we saved
|
|
83
83
|
const lastSavedContentRef = useRef('');
|
|
84
|
-
// Prevent double-loading
|
|
85
|
-
const loadedRef = useRef(false);
|
|
86
84
|
// Track dirty state in a ref too (for use in callbacks without stale closure)
|
|
87
85
|
const isDirtyRef = useRef(false);
|
|
88
86
|
// ---- Parse / Serialize helpers ----
|
|
@@ -118,10 +116,16 @@ export function useEditorLifecycle(host, options) {
|
|
|
118
116
|
}
|
|
119
117
|
}, [host]);
|
|
120
118
|
// ---- Initial load ----
|
|
119
|
+
// Note: do NOT use a ref-based "already loaded" guard here. React 18
|
|
120
|
+
// StrictMode in dev runs effects setup -> cleanup -> setup. A persistent
|
|
121
|
+
// ref makes the second setup early-return, while the first run's `mounted`
|
|
122
|
+
// closure has already been flipped to false by cleanup, so
|
|
123
|
+
// `setIsLoading(false)` is never called and the editor is stuck on the
|
|
124
|
+
// initial Loading state forever. The dep array (`[host, parseContent]`)
|
|
125
|
+
// already prevents re-loads when nothing has changed; the strict-mode
|
|
126
|
+
// double load is idempotent (same IPC + applyContent) and only happens in
|
|
127
|
+
// dev.
|
|
121
128
|
useEffect(() => {
|
|
122
|
-
if (loadedRef.current)
|
|
123
|
-
return;
|
|
124
|
-
loadedRef.current = true;
|
|
125
129
|
let mounted = true;
|
|
126
130
|
const doLoad = async () => {
|
|
127
131
|
try {
|
|
@@ -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
|
+
}
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
*
|
package/dist/validate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"
|
|
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
|