@smartdoca/web-plugin-registry 0.1.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/index.js ADDED
@@ -0,0 +1,394 @@
1
+ export class RegistryConflictError extends Error {
2
+ code;
3
+ contributionId;
4
+ name = "RegistryConflictError";
5
+ constructor(code, message, contributionId) {
6
+ super(`${contributionId}: ${message}`);
7
+ this.code = code;
8
+ this.contributionId = contributionId;
9
+ }
10
+ }
11
+ const pluginIdentifier = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
12
+ const messageIdentifier = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
13
+ const semanticVersion = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
14
+ function assertClientManifest(manifest, target) {
15
+ if (!pluginIdentifier.test(manifest.pluginId))
16
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", "manifest pluginId must be a stable lowercase dotted or dashed identifier", manifest.pluginId);
17
+ if (!semanticVersion.test(manifest.version))
18
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", "manifest version must be semantic", manifest.pluginId);
19
+ if (new Set(manifest.targets).size !== manifest.targets.length ||
20
+ !manifest.targets.includes(target))
21
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", `manifest must declare the ${target} target exactly once`, manifest.pluginId);
22
+ }
23
+ function assertOwnedContribution(contribution) {
24
+ if (!pluginIdentifier.test(contribution.pluginId))
25
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", "pluginId must be a stable lowercase dotted or dashed identifier", contribution.id);
26
+ if (!messageIdentifier.test(contribution.id) ||
27
+ !contribution.id.startsWith(`${contribution.pluginId}.`))
28
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", `id must be namespaced by ${contribution.pluginId}`, contribution.id);
29
+ if (contribution.order !== undefined &&
30
+ !Number.isFinite(contribution.order))
31
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", "order must be finite", contribution.id);
32
+ }
33
+ function compareContributions(left, right) {
34
+ return (left.order ?? 0) - (right.order ?? 0) ||
35
+ left.id.localeCompare(right.id);
36
+ }
37
+ /**
38
+ * A synchronous, effect-owned registry. Registration either succeeds
39
+ * atomically or leaves no record behind; the returned disposer is idempotent.
40
+ */
41
+ export class EffectRegistry {
42
+ point;
43
+ conflictKey;
44
+ #byId = new Map();
45
+ #byConflictKey = new Map();
46
+ constructor(point, conflictKey) {
47
+ this.point = point;
48
+ this.conflictKey = conflictKey;
49
+ }
50
+ register(contribution) {
51
+ assertOwnedContribution(contribution);
52
+ const existing = this.#byId.get(contribution.id);
53
+ if (existing)
54
+ throw new RegistryConflictError("DUPLICATE_CONTRIBUTION_ID", `${this.point} id is already registered by ${existing.pluginId}`, contribution.id);
55
+ const conflictKey = this.conflictKey?.(contribution);
56
+ const conflicting = conflictKey
57
+ ? this.#byConflictKey.get(conflictKey)
58
+ : undefined;
59
+ if (conflicting)
60
+ throw new RegistryConflictError("DUPLICATE_CONTRIBUTION_PATH", `${this.point} key ${conflictKey} is already registered by ${conflicting.pluginId}`, contribution.id);
61
+ this.#byId.set(contribution.id, contribution);
62
+ if (conflictKey)
63
+ this.#byConflictKey.set(conflictKey, contribution);
64
+ let active = true;
65
+ return () => {
66
+ if (!active)
67
+ return;
68
+ active = false;
69
+ if (this.#byId.get(contribution.id) === contribution)
70
+ this.#byId.delete(contribution.id);
71
+ if (conflictKey &&
72
+ this.#byConflictKey.get(conflictKey) === contribution)
73
+ this.#byConflictKey.delete(conflictKey);
74
+ };
75
+ }
76
+ get(id) {
77
+ return this.#byId.get(id);
78
+ }
79
+ getByConflictKey(key) {
80
+ return this.#byConflictKey.get(key);
81
+ }
82
+ list() {
83
+ return [...this.#byId.values()].sort(compareContributions);
84
+ }
85
+ }
86
+ function registerOwned(registry, contributions, pluginId, effects) {
87
+ for (const contribution of contributions ?? []) {
88
+ if (contribution.pluginId !== pluginId)
89
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", `contribution owner does not match ${pluginId}`, contribution.id);
90
+ effects.push(registry.register(contribution));
91
+ }
92
+ }
93
+ function normalizeRoutePath(path) {
94
+ const clean = path.trim().split(/[?#]/, 1)[0] || "/";
95
+ const prefixed = clean.startsWith("/") ? clean : `/${clean}`;
96
+ return prefixed.length > 1 ? prefixed.replace(/\/+$/, "") : prefixed;
97
+ }
98
+ function assertRoutePath(path, contributionId) {
99
+ const normalized = normalizeRoutePath(path);
100
+ if (!/^\/(?:[a-z0-9._-]+|:[a-z][A-Za-z0-9]*)(?:\/(?:[a-z0-9._-]+|:[a-z][A-Za-z0-9]*))*$/i.test(normalized) &&
101
+ normalized !== "/")
102
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", `invalid route path ${path}`, contributionId);
103
+ return normalized;
104
+ }
105
+ function matchRoute(pattern, input) {
106
+ const path = normalizeRoutePath(input);
107
+ const patternParts = normalizeRoutePath(pattern).split("/").filter(Boolean);
108
+ const pathParts = path.split("/").filter(Boolean);
109
+ if (patternParts.length !== pathParts.length)
110
+ return undefined;
111
+ const params = {};
112
+ for (let index = 0; index < patternParts.length; index++) {
113
+ const expected = patternParts[index];
114
+ const actual = pathParts[index];
115
+ if (expected.startsWith(":")) {
116
+ params[expected.slice(1)] = decodeURIComponent(actual);
117
+ }
118
+ else if (expected !== actual) {
119
+ return undefined;
120
+ }
121
+ }
122
+ return { path, params };
123
+ }
124
+ function validateMessages(contribution) {
125
+ const englishKeys = Object.keys(contribution.messages.en).sort();
126
+ const chineseKeys = Object.keys(contribution.messages.zh).sort();
127
+ if (englishKeys.length !== chineseKeys.length ||
128
+ englishKeys.some((key, index) => key !== chineseKeys[index]))
129
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", "zh and en dictionaries must contain exactly the same keys", contribution.id);
130
+ for (const key of englishKeys) {
131
+ if (!messageIdentifier.test(key) ||
132
+ !key.startsWith(`${contribution.pluginId}.`))
133
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", `message key ${key} must be a stable key namespaced by ${contribution.pluginId}`, contribution.id);
134
+ }
135
+ }
136
+ function interpolate(message, values) {
137
+ if (!values)
138
+ return message;
139
+ return message.replace(/\{([A-Za-z][A-Za-z0-9_]*)\}/g, (_, key) => values[key] === undefined ? `{${key}}` : String(values[key]));
140
+ }
141
+ export class WebPluginRegistry {
142
+ routes = new EffectRegistry("web route", (route) => assertRoutePath(route.path, route.id));
143
+ navigation = new EffectRegistry("web navigation", (item) => item.scope);
144
+ adminPanels = new EffectRegistry("web admin panel", (panel) => panel.tab);
145
+ settingsFields = new EffectRegistry("web settings field", (field) => field.path);
146
+ aiBlocks = new EffectRegistry("web AI block", (block) => block.kind);
147
+ searchResults = new EffectRegistry("web search result", (result) => result.kind);
148
+ knowledgeSources = new EffectRegistry("web knowledge source", (source) => source.sourceKind);
149
+ filePickers = new EffectRegistry("web file picker", (picker) => picker.capability);
150
+ messages = new EffectRegistry("web messages");
151
+ #messageOwners = new Map();
152
+ #manifests = new Map();
153
+ register(bundle) {
154
+ assertClientManifest(bundle.manifest, "web");
155
+ if (this.#manifests.has(bundle.manifest.pluginId))
156
+ throw new RegistryConflictError("DUPLICATE_CONTRIBUTION_ID", "web plugin manifest is already registered", bundle.manifest.pluginId);
157
+ const effects = [];
158
+ try {
159
+ const pluginId = bundle.manifest.pluginId;
160
+ registerOwned(this.routes, bundle.routes, pluginId, effects);
161
+ registerOwned(this.navigation, bundle.navigation, pluginId, effects);
162
+ registerOwned(this.adminPanels, bundle.adminPanels, pluginId, effects);
163
+ registerOwned(this.settingsFields, bundle.settingsFields, pluginId, effects);
164
+ registerOwned(this.aiBlocks, bundle.aiBlocks, pluginId, effects);
165
+ registerOwned(this.searchResults, bundle.searchResults, pluginId, effects);
166
+ registerOwned(this.knowledgeSources, bundle.knowledgeSources, pluginId, effects);
167
+ registerOwned(this.filePickers, bundle.filePickers, pluginId, effects);
168
+ for (const contribution of bundle.messages ?? []) {
169
+ if (contribution.pluginId !== bundle.manifest.pluginId)
170
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", `message owner does not match ${bundle.manifest.pluginId}`, contribution.id);
171
+ validateMessages(contribution);
172
+ for (const key of Object.keys(contribution.messages.en)) {
173
+ const owner = this.#messageOwners.get(key);
174
+ if (owner)
175
+ throw new RegistryConflictError("DUPLICATE_CONTRIBUTION_ID", `message key ${key} is already registered by ${owner}`, contribution.id);
176
+ }
177
+ effects.push(this.messages.register(contribution));
178
+ for (const key of Object.keys(contribution.messages.en))
179
+ this.#messageOwners.set(key, contribution.id);
180
+ effects.push(() => {
181
+ for (const key of Object.keys(contribution.messages.en))
182
+ if (this.#messageOwners.get(key) === contribution.id)
183
+ this.#messageOwners.delete(key);
184
+ });
185
+ }
186
+ this.#manifests.set(bundle.manifest.pluginId, bundle.manifest);
187
+ effects.push(() => {
188
+ if (this.#manifests.get(bundle.manifest.pluginId) === bundle.manifest)
189
+ this.#manifests.delete(bundle.manifest.pluginId);
190
+ });
191
+ }
192
+ catch (error) {
193
+ for (let index = effects.length - 1; index >= 0; index--)
194
+ effects[index]();
195
+ throw error;
196
+ }
197
+ let active = true;
198
+ return () => {
199
+ if (!active)
200
+ return;
201
+ active = false;
202
+ for (let index = effects.length - 1; index >= 0; index--)
203
+ effects[index]();
204
+ };
205
+ }
206
+ manifests() {
207
+ return [...this.#manifests.values()].sort((left, right) => left.pluginId.localeCompare(right.pluginId));
208
+ }
209
+ resolveRoute(path) {
210
+ for (const contribution of this.routes.list()) {
211
+ const match = matchRoute(contribution.path, path);
212
+ if (match)
213
+ return { contribution, match };
214
+ }
215
+ return undefined;
216
+ }
217
+ translate(locale, key, values) {
218
+ const selected = locale === "zh"
219
+ ? "zh"
220
+ : locale === undefined
221
+ ? "zh"
222
+ : "en";
223
+ for (const contribution of this.messages.list()) {
224
+ const message = contribution.messages[selected][key] ?? contribution.messages.en[key];
225
+ if (message !== undefined)
226
+ return interpolate(message, values);
227
+ }
228
+ return key;
229
+ }
230
+ }
231
+ export class MobilePluginRegistry {
232
+ routes = new EffectRegistry("mobile route", (route) => assertRoutePath(route.path, route.id));
233
+ tabs = new EffectRegistry("mobile tab", (tab) => tab.route);
234
+ aiNodes = new EffectRegistry("mobile AI node", (node) => node.kind);
235
+ searchResults = new EffectRegistry("mobile search result", (result) => result.kind);
236
+ filePickers = new EffectRegistry("mobile file picker", (picker) => picker.capability);
237
+ messages = new EffectRegistry("mobile messages");
238
+ #manifests = new Map();
239
+ register(bundle) {
240
+ assertClientManifest(bundle.manifest, "mobile");
241
+ if (this.#manifests.has(bundle.manifest.pluginId))
242
+ throw new RegistryConflictError("DUPLICATE_CONTRIBUTION_ID", "mobile plugin manifest is already registered", bundle.manifest.pluginId);
243
+ const effects = [];
244
+ try {
245
+ const pluginId = bundle.manifest.pluginId;
246
+ registerOwned(this.routes, bundle.routes, pluginId, effects);
247
+ registerOwned(this.tabs, bundle.tabs, pluginId, effects);
248
+ registerOwned(this.aiNodes, bundle.aiNodes, pluginId, effects);
249
+ registerOwned(this.searchResults, bundle.searchResults, pluginId, effects);
250
+ registerOwned(this.filePickers, bundle.filePickers, pluginId, effects);
251
+ for (const contribution of bundle.messages ?? [])
252
+ validateMessages(contribution);
253
+ registerOwned(this.messages, bundle.messages, pluginId, effects);
254
+ this.#manifests.set(bundle.manifest.pluginId, bundle.manifest);
255
+ effects.push(() => {
256
+ if (this.#manifests.get(bundle.manifest.pluginId) === bundle.manifest)
257
+ this.#manifests.delete(bundle.manifest.pluginId);
258
+ });
259
+ }
260
+ catch (error) {
261
+ for (let index = effects.length - 1; index >= 0; index--)
262
+ effects[index]();
263
+ throw error;
264
+ }
265
+ let active = true;
266
+ return () => {
267
+ if (!active)
268
+ return;
269
+ active = false;
270
+ for (let index = effects.length - 1; index >= 0; index--)
271
+ effects[index]();
272
+ };
273
+ }
274
+ manifests() {
275
+ return [...this.#manifests.values()].sort((left, right) => left.pluginId.localeCompare(right.pluginId));
276
+ }
277
+ }
278
+ function conversationKey(kind, id) {
279
+ return `${kind}\u0000${id}`;
280
+ }
281
+ function assertConversationEvent(event) {
282
+ if (!event.kind.trim() || !event.id.trim() || !event.type.trim())
283
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", "conversation event kind, id and type are required", `${event.kind}/${event.id}`);
284
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0)
285
+ throw new RegistryConflictError("INVALID_CONTRIBUTION", "conversation event seq must be a non-negative safe integer", `${event.kind}/${event.id}`);
286
+ }
287
+ function canonical(value) {
288
+ if (value === null || typeof value !== "object") {
289
+ const encoded = JSON.stringify(value);
290
+ if (encoded === undefined)
291
+ throw new TypeError("conversation events must be JSON serializable");
292
+ return encoded;
293
+ }
294
+ if (Array.isArray(value))
295
+ return `[${value.map(canonical).join(",")}]`;
296
+ const object = value;
297
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`).join(",")}}`;
298
+ }
299
+ /**
300
+ * Stores events by stable (kind,id,seq) and replays each stream from sorted
301
+ * sequence numbers for every projection. Arrival order never affects output.
302
+ */
303
+ export class ConversationAssembler {
304
+ definitions = new EffectRegistry("conversation definition", (definition) => definition.kind);
305
+ #streams = new Map();
306
+ append(event) {
307
+ assertConversationEvent(event);
308
+ const key = conversationKey(event.kind, event.id);
309
+ let stream = this.#streams.get(key);
310
+ if (!stream) {
311
+ stream = new Map();
312
+ this.#streams.set(key, stream);
313
+ }
314
+ const existing = stream.get(event.seq);
315
+ if (existing) {
316
+ if (canonical(existing) === canonical(event))
317
+ return false;
318
+ throw new RegistryConflictError("EVENT_SEQUENCE_CONFLICT", `seq ${event.seq} already contains a different event`, `${event.kind}/${event.id}`);
319
+ }
320
+ stream.set(event.seq, event);
321
+ return true;
322
+ }
323
+ appendAll(events) {
324
+ for (const event of events)
325
+ this.append(event);
326
+ return this.snapshot();
327
+ }
328
+ clear(kind, id) {
329
+ if (kind !== undefined && id !== undefined) {
330
+ this.#streams.delete(conversationKey(kind, id));
331
+ return;
332
+ }
333
+ this.#streams.clear();
334
+ }
335
+ snapshot() {
336
+ const nodes = [];
337
+ for (const [key, stream] of this.#streams) {
338
+ const events = [...stream.values()].sort((left, right) => left.seq - right.seq);
339
+ if (!events.length)
340
+ continue;
341
+ const first = events[0];
342
+ const last = events.at(-1);
343
+ const definition = this.definitions.getByConflictKey(first.kind);
344
+ if (!definition) {
345
+ nodes.push({
346
+ key,
347
+ kind: first.kind,
348
+ id: first.id,
349
+ firstSeq: first.seq,
350
+ lastSeq: last.seq,
351
+ settled: events.some((event) => event.settled === true),
352
+ definitionId: "generic",
353
+ value: Object.freeze({
354
+ type: "generic-event",
355
+ kind: first.kind,
356
+ id: first.id,
357
+ events: Object.freeze(events),
358
+ }),
359
+ });
360
+ continue;
361
+ }
362
+ let state;
363
+ let started = false;
364
+ for (const event of events) {
365
+ if (!started) {
366
+ if (!definition.isStart(event))
367
+ continue;
368
+ state = definition.start(event);
369
+ started = true;
370
+ }
371
+ else {
372
+ state = definition.update(state, event);
373
+ }
374
+ }
375
+ // Known updates remain pending until their start fact is available.
376
+ if (!started)
377
+ continue;
378
+ nodes.push({
379
+ key,
380
+ kind: first.kind,
381
+ id: first.id,
382
+ firstSeq: first.seq,
383
+ lastSeq: last.seq,
384
+ settled: events.some((event) => event.settled === true),
385
+ definitionId: definition.id,
386
+ value: definition.materialize(state, {
387
+ key,
388
+ events: Object.freeze(events),
389
+ }),
390
+ });
391
+ }
392
+ return nodes.sort((left, right) => left.firstSeq - right.firstSeq || left.key.localeCompare(right.key));
393
+ }
394
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@smartdoca/web-plugin-registry",
3
+ "version": "0.1.0",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "types": "./dist/index.d.ts"
19
+ }