@context-use/open-sync 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-use/open-sync",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/open-sync.ts",
@@ -23,7 +23,8 @@
23
23
  "dependencies": {
24
24
  "@cfworker/json-schema": "4.1.1",
25
25
  "elysia": "^1.4.30",
26
- "@oomol-lab/open-connector": "1.6.0"
26
+ "minisearch": "^7.2.0",
27
+ "@oomol-lab/open-connector": "1.6.3"
27
28
  },
28
29
  "devDependencies": {
29
30
  "typescript": "^7.0.2"
package/src/api.ts CHANGED
@@ -10,6 +10,7 @@ export type SyncApi = Pick<
10
10
  | 'destinations'
11
11
  | 'createDestination'
12
12
  | 'createInstallation'
13
+ | 'connectInstallation'
13
14
  | 'installations'
14
15
  | 'installation'
15
16
  | 'setEnabled'
@@ -31,6 +32,7 @@ export function syncApi(service: SyncManagement): SyncApi {
31
32
  runs: service.runs.bind(service),
32
33
  createDestination: service.createDestination.bind(service),
33
34
  createInstallation: service.createInstallation.bind(service),
35
+ connectInstallation: service.connectInstallation.bind(service),
34
36
  installations: service.installations.bind(service),
35
37
  installation: service.installation.bind(service),
36
38
  setEnabled: service.setEnabled.bind(service),
package/src/build.ts CHANGED
@@ -1,8 +1,4 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { prepareConnector } from './build/connector';
1
+ import { getConnectorBuildOptions } from '@oomol-lab/open-connector/build';
6
2
  import type { SyncRegistration } from './models/definition';
7
3
 
8
4
  /** Provider dependencies declared by registrations, without loading their sync executables. */
@@ -21,20 +17,6 @@ export function providersFromDefinitions(definitions: readonly SyncRegistration[
21
17
  * to compile.assets. Await dispose() after the build (also on failure); it removes staged files.
22
18
  * An empty provider list packages no providers. The installed dependency is never modified.
23
19
  */
24
- export async function getOpenSyncBuildOptions(options: { providers: readonly string[] }) {
25
- const directory = await mkdtemp(join(tmpdir(), 'open-sync-build-'));
26
- const dispose = () => rm(directory, { recursive: true, force: true });
27
- try {
28
- const entrypoint = fileURLToPath(import.meta.resolve('@oomol-lab/open-connector'));
29
- const assets = join(directory, 'open-connector');
30
- const plugin = await prepareConnector({
31
- root: dirname(dirname(dirname(entrypoint))),
32
- providers: [...new Set(options.providers)].sort(),
33
- assets,
34
- });
35
- return { assets: [assets], external: ['proxy-agent'], plugins: [plugin], dispose };
36
- } catch (error) {
37
- await dispose();
38
- throw error;
39
- }
20
+ export function getOpenSyncBuildOptions(options: { providers: readonly string[] }) {
21
+ return getConnectorBuildOptions(options);
40
22
  }
@@ -28,6 +28,17 @@ export function createProviderController(input: {
28
28
  async ({ request }) => await input.providers.connections(await scope(request)),
29
29
  )
30
30
  .get('/', async ({ request }) => await input.providers.catalog(await scope(request)))
31
+ .get(
32
+ '/catalog',
33
+ async ({ request, query }) =>
34
+ input.providers.catalogPage({ ...(await scope(request)), ...query }),
35
+ {
36
+ query: t.Object({
37
+ q: t.Optional(t.String({ maxLength: 200 })),
38
+ offset: t.Optional(t.Integer({ minimum: 0, maximum: 1000000 })),
39
+ }),
40
+ },
41
+ )
31
42
  .get(
32
43
  '/:service',
33
44
  async ({ request, params }) =>
@@ -50,6 +61,7 @@ export function createProviderController(input: {
50
61
  {
51
62
  params: t.Object({ service }),
52
63
  body: t.Object({
64
+ connectionId: t.Optional(id),
53
65
  authorizationOptionIds: t.Optional(
54
66
  t.Array(t.String({ maxLength: 256 }), { maxItems: 128 }),
55
67
  ),
@@ -63,6 +75,7 @@ export function createProviderController(input: {
63
75
  {
64
76
  params: t.Object({ service }),
65
77
  body: t.Object({
78
+ connectionId: t.Optional(id),
66
79
  authType: t.Union([t.Literal('api_key'), t.Literal('custom_credential')]),
67
80
  values,
68
81
  }),
@@ -0,0 +1,57 @@
1
+ import MiniSearch from 'minisearch';
2
+ import type { ProviderCatalogEntry } from './providers';
3
+
4
+ /** Provider metadata is fixed for the lifetime of a connector runtime. */
5
+ export class ProviderCatalog {
6
+ private index?: MiniSearch;
7
+ private byService?: Map<string, ProviderCatalogEntry>;
8
+ readonly entries: ProviderCatalogEntry[];
9
+ constructor(entries: ProviderCatalogEntry[]) {
10
+ this.entries = entries.map(
11
+ ({ service, displayName, iconUrl, categories, scenario, authTypes }) => ({
12
+ service,
13
+ displayName,
14
+ iconUrl,
15
+ categories,
16
+ scenario,
17
+ authTypes,
18
+ }),
19
+ );
20
+ }
21
+
22
+ page(input: { q?: string; offset?: number }) {
23
+ const providers = this.entries;
24
+ const query = input.q?.trim();
25
+ let matches = providers;
26
+ if (query) {
27
+ if (!this.index) {
28
+ this.index = new MiniSearch({
29
+ idField: 'service',
30
+ fields: ['service', 'displayName', 'categoryNames', 'scenario', 'authentication'],
31
+ searchOptions: {
32
+ prefix: true,
33
+ combineWith: 'AND',
34
+ boost: { displayName: 3, service: 2 },
35
+ },
36
+ });
37
+ this.index.addAll(
38
+ providers.map((provider) => ({
39
+ ...provider,
40
+ categoryNames: provider.categories.map((category) => category.displayName).join(' '),
41
+ authentication: provider.authTypes.map((type) => type.replaceAll('_', ' ')).join(' '),
42
+ })),
43
+ );
44
+ }
45
+ this.byService ??= new Map(providers.map((provider) => [provider.service, provider]));
46
+ matches = this.index.search(query).map((result) => this.byService!.get(result.id)!);
47
+ }
48
+ const pageSize = 30;
49
+ const offset = input.offset ?? 0;
50
+ return {
51
+ providers: matches.slice(offset, offset + pageSize),
52
+ total: matches.length,
53
+ pageSize,
54
+ hasMore: offset + pageSize < matches.length,
55
+ };
56
+ }
57
+ }
package/src/open-sync.ts CHANGED
@@ -31,6 +31,10 @@ export interface OpenSyncOptions {
31
31
  authorize(request: Request): Scope | null | Promise<Scope | null>;
32
32
  /** OAuth application settings are instance-wide, so require the host's administrator policy. */
33
33
  canConfigureProviders(scope: Scope): Promise<boolean>;
34
+ /** Called after an owned connection is saved, including a retried OAuth completion. */
35
+ onProviderConnected?(
36
+ input: Scope & { connection: { id: string; service: string } },
37
+ ): Promise<void>;
34
38
  /** Final host UI location after Open Sync completes authorization. */
35
39
  authorizationRedirect?(input: { service: string; outcome: 'connected' | 'failed' }): string;
36
40
  }
@@ -126,6 +130,7 @@ export async function createOpenSync(options: OpenSyncOptions): Promise<OpenSync
126
130
  connector: management,
127
131
  signal: lifetime.signal,
128
132
  canConfigure: options.canConfigureProviders,
133
+ onConnected: options.onProviderConnected,
129
134
  returnUrl: (input) =>
130
135
  `${publicUrl}/providers/${encodeURIComponent(input.service)}/return/${input.id}`,
131
136
  });
@@ -1,4 +1,4 @@
1
- import type { SyncDefinition } from '../../models/definition';
1
+ import type { ConnectionRef, SyncDefinition } from '../../models/definition';
2
2
  import type { Destination } from '../../models/delivery';
3
3
  import type { Resource, Scope } from '../../models/identity';
4
4
  import type { CreateInstallation, Installation, SyncRun } from '../../models/installation';
@@ -18,6 +18,7 @@ export interface CatalogRepository {
18
18
  hasMore: boolean;
19
19
  pageSize: number;
20
20
  };
21
+ connectInstallation(input: Resource & { connection: ConnectionRef }): Installation;
21
22
  setEnabled(input: Resource & { enabled: boolean }): Installation;
22
23
  queue(input: Resource & { checkpoint?: JsonValue }): void;
23
24
  }
@@ -1,5 +1,5 @@
1
1
  import type { Database } from 'bun:sqlite';
2
- import type { SyncDefinition } from '../../models/definition';
2
+ import type { ConnectionRef, SyncDefinition } from '../../models/definition';
3
3
  import { fail } from '../../models/error';
4
4
  import type { Resource, Scope } from '../../models/identity';
5
5
  import type { CreateInstallation } from '../../models/installation';
@@ -113,6 +113,21 @@ export class SqliteCatalog implements CatalogRepository {
113
113
  pageSize: limit,
114
114
  };
115
115
  }
116
+ connectInstallation(input: Resource & { connection: ConnectionRef }) {
117
+ return this.db
118
+ .transaction(() => {
119
+ const installation = this.installation(input);
120
+ if (installation.connection || installation.enabled) {
121
+ fail('already_connected');
122
+ }
123
+ this.db
124
+ .query(`UPDATE installations SET connection=?,enabled=1,
125
+ binding_epoch=binding_epoch+1,status='ready',next_due_at=? WHERE owner_id=? AND id=?`)
126
+ .run(canonicalJson(input.connection).json, Date.now(), input.ownerId, input.id);
127
+ return this.installation(input);
128
+ })
129
+ .immediate();
130
+ }
116
131
  setEnabled(input: Resource & { enabled: boolean }) {
117
132
  return this.db
118
133
  .transaction(() => {
@@ -10,6 +10,7 @@ export interface ProviderRepository {
10
10
  owns(input: ProviderScope & { connectorId: string }): Promise<boolean>;
11
11
  start(input: ProviderScope & ProviderAuthorization): Promise<void>;
12
12
  pending(input: ProviderScope & { id: string }): Promise<ProviderAuthorization | null>;
13
+ updateAccount(input: ProviderScope & ProviderConnection): Promise<void>;
13
14
  add(input: ProviderScope & ProviderConnection): Promise<void>;
14
- complete(input: ProviderScope & ProviderConnection): Promise<void>;
15
+ complete(input: ProviderScope & ProviderConnection & { requestId: string }): Promise<void>;
15
16
  }
@@ -52,6 +52,14 @@ export class SqliteProviders implements ProviderRepository {
52
52
  .get(input.ownerId, input.id),
53
53
  );
54
54
  }
55
+ updateAccount(input: ProviderScope & ProviderConnection) {
56
+ this.db
57
+ .query(
58
+ 'UPDATE provider_connections SET account=? WHERE owner_id=? AND id=? AND connector_id=?',
59
+ )
60
+ .run(input.account, input.ownerId, input.id, input.connectorId);
61
+ return Promise.resolve();
62
+ }
55
63
  add(input: ProviderScope & ProviderConnection) {
56
64
  this.db
57
65
  .query(
@@ -60,18 +68,26 @@ export class SqliteProviders implements ProviderRepository {
60
68
  .run(input.ownerId, input.id, input.connectorId, input.account, input.service);
61
69
  return Promise.resolve();
62
70
  }
63
- complete(input: ProviderScope & ProviderConnection) {
71
+ complete(input: ProviderScope & ProviderConnection & { requestId: string }) {
64
72
  this.db
65
73
  .transaction(() => {
66
74
  // A superseded authorization must never claim a connection.
67
75
  this.db
68
76
  .query(`INSERT INTO provider_connections(owner_id,id,connector_id,account,service)
69
- SELECT owner_id,id,?,?,? FROM provider_authorizations WHERE owner_id=? AND id=?
70
- ON CONFLICT(owner_id,id) DO NOTHING`)
71
- .run(input.connectorId, input.account, input.service, input.ownerId, input.id);
77
+ SELECT owner_id,id,?,?,? FROM provider_authorizations WHERE owner_id=? AND id=? AND request_id=?
78
+ ON CONFLICT(owner_id,id) DO UPDATE SET account=excluded.account
79
+ WHERE provider_connections.connector_id=excluded.connector_id`)
80
+ .run(
81
+ input.connectorId,
82
+ input.account,
83
+ input.service,
84
+ input.ownerId,
85
+ input.id,
86
+ input.requestId,
87
+ );
72
88
  this.db
73
- .query('DELETE FROM provider_authorizations WHERE owner_id=? AND id=?')
74
- .run(input.ownerId, input.id);
89
+ .query('DELETE FROM provider_authorizations WHERE owner_id=? AND id=? AND request_id=?')
90
+ .run(input.ownerId, input.id, input.requestId);
75
91
  })
76
92
  .immediate();
77
93
  return Promise.resolve();
@@ -1,5 +1,6 @@
1
1
  import { bindProvider, type ProviderGateway } from '../execution/provider';
2
2
  import type { WorkerControl } from '../execution/worker';
3
+ import type { ConnectionRef } from '../models/definition';
3
4
  import { fail } from '../models/error';
4
5
  import type { Resource, Scope } from '../models/identity';
5
6
  import type { CreateInstallation } from '../models/installation';
@@ -65,7 +66,7 @@ export class SyncManagement {
65
66
  if (input.intervalMs !== undefined) {
66
67
  positive(input.intervalMs);
67
68
  }
68
- if (definition.provider) {
69
+ if (definition.provider && (input.connection || input.enabled !== false)) {
69
70
  await bindProvider({
70
71
  actorId: input.actorId,
71
72
  ownerId: input.ownerId,
@@ -92,8 +93,34 @@ export class SyncManagement {
92
93
  this.guard(input);
93
94
  return this.input.catalog.installation(input);
94
95
  }
96
+ async connectInstallation(input: Resource & { connection: ConnectionRef }) {
97
+ this.guard(input);
98
+ const installation = this.input.catalog.installation(input);
99
+ if (installation.connection || installation.enabled) {
100
+ fail('already_connected');
101
+ }
102
+ const { definition } = this.input.registry.definition(installation.definition);
103
+ if (!definition.provider) {
104
+ fail('unexpected_connection');
105
+ }
106
+ await bindProvider({
107
+ ...input,
108
+ requirements: definition.provider,
109
+ gateway: this.input.gateway,
110
+ signal: AbortSignal.timeout(this.input.timeoutMs),
111
+ });
112
+ this.guard(input);
113
+ return this.input.catalog.connectInstallation(input);
114
+ }
95
115
  async setEnabled(input: Resource & { enabled: boolean }) {
96
116
  this.guard(input);
117
+ const installation = this.input.catalog.installation(input);
118
+ if (input.enabled && !installation.connection) {
119
+ const { definition } = this.input.registry.definition(installation.definition);
120
+ if (definition.provider) {
121
+ fail('connection_required');
122
+ }
123
+ }
97
124
  const result = this.input.catalog.setEnabled(input);
98
125
  await this.input.worker.cancel(input);
99
126
  return result;
@@ -1,4 +1,5 @@
1
1
  import { fail, SyncError } from '../../models/error';
2
+ import { ProviderCatalog } from '../../models/provider-catalog';
2
3
  import { oauthClientValues, publicConnection } from '../../models/provider-values';
3
4
  import type {
4
5
  ConnectorManagement,
@@ -9,6 +10,7 @@ import { identifier } from '../../models/validation';
9
10
  import type { ProviderRepository } from '../../repositories/providers/contract';
10
11
 
11
12
  export class ProviderService {
13
+ private providerCatalog?: Promise<ProviderCatalog>;
12
14
  constructor(
13
15
  private readonly input: {
14
16
  repository: ProviderRepository;
@@ -16,9 +18,23 @@ export class ProviderService {
16
18
  returnUrl(input: { service: string; id: string }): string;
17
19
  canConfigure(scope: ProviderScope): Promise<boolean>;
18
20
  signal: AbortSignal;
21
+ onConnected?(
22
+ input: ProviderScope & { connection: { id: string; service: string } },
23
+ ): Promise<void>;
19
24
  },
20
25
  ) {}
21
26
 
27
+ private loadCatalog() {
28
+ this.providerCatalog ??= this.input.connector
29
+ .catalog()
30
+ .then((entries) => new ProviderCatalog(entries))
31
+ .catch((error) => {
32
+ this.providerCatalog = undefined;
33
+ throw error;
34
+ });
35
+ return this.providerCatalog;
36
+ }
37
+
22
38
  private guard(scope: ProviderScope) {
23
39
  this.input.signal.throwIfAborted();
24
40
  identifier(scope.ownerId);
@@ -42,15 +58,11 @@ export class ProviderService {
42
58
  }
43
59
  async catalog(scope: ProviderScope) {
44
60
  this.guard(scope);
45
- const providers = await this.input.connector.catalog();
46
- return providers.map(({ service, displayName, iconUrl, authTypes, categories, scenario }) => ({
47
- service,
48
- displayName,
49
- iconUrl,
50
- authTypes,
51
- categories,
52
- scenario,
53
- }));
61
+ return (await this.loadCatalog()).entries;
62
+ }
63
+ async catalogPage(input: ProviderScope & { q?: string; offset?: number }) {
64
+ this.guard(input);
65
+ return (await this.loadCatalog()).page(input);
54
66
  }
55
67
  private async setup(service: string): Promise<RuntimeProviderSetup> {
56
68
  const setup = await this.input.connector.call({
@@ -82,7 +94,17 @@ export class ProviderService {
82
94
  (metadata.status === 'active' || metadata.status === 'reauth_required')
83
95
  ? metadata.status
84
96
  : 'unknown';
85
- return { id, account, status };
97
+ const authType = metadata?.authType;
98
+ const supportedAuth: RuntimeProviderSetup['auth'][number]['type'] | undefined =
99
+ authType === 'oauth2' || authType === 'api_key' || authType === 'custom_credential'
100
+ ? authType
101
+ : undefined;
102
+ return {
103
+ id,
104
+ account,
105
+ status,
106
+ authType: supportedAuth,
107
+ };
86
108
  }),
87
109
  ),
88
110
  };
@@ -107,11 +129,30 @@ export class ProviderService {
107
129
  });
108
130
  return { configured: true };
109
131
  }
110
- async start(input: ProviderScope & { service: string; authorizationOptionIds?: string[] }) {
132
+ private async reconnectTarget(input: ProviderScope & { service: string; connectionId?: string }) {
133
+ if (!input.connectionId) {
134
+ return undefined;
135
+ }
136
+ const target = await this.input.repository.connection({ ...input, id: input.connectionId });
137
+ if (!target || target.service !== input.service) {
138
+ fail('not_found');
139
+ }
140
+ return target;
141
+ }
142
+ async start(
143
+ input: ProviderScope & {
144
+ service: string;
145
+ authorizationOptionIds?: string[];
146
+ connectionId?: string;
147
+ },
148
+ ) {
111
149
  this.guard(input);
112
- const id = `connection_${crypto.randomUUID()}`;
150
+ const target = await this.reconnectTarget(input);
151
+ const id = target?.id ?? `connection_${crypto.randomUUID()}`;
113
152
  const result = await this.input.connector.call({
114
- path: `/v1/connections/${encodeURIComponent(input.service)}/connect`,
153
+ path: target
154
+ ? `/v1/connections/by-id/${encodeURIComponent(target.connectorId)}/connect`
155
+ : `/v1/connections/${encodeURIComponent(input.service)}/connect`,
115
156
  method: 'POST',
116
157
  body: {
117
158
  returnUri: this.input.returnUrl({ service: input.service, id }),
@@ -135,33 +176,51 @@ export class ProviderService {
135
176
  input: ProviderScope & {
136
177
  service: string;
137
178
  authType: 'api_key' | 'custom_credential';
179
+ connectionId?: string;
138
180
  values: Record<string, string>;
139
181
  },
140
182
  ) {
141
183
  this.guard(input);
184
+ const target = await this.reconnectTarget(input);
185
+ const path = target
186
+ ? `/v1/connections/by-id/${encodeURIComponent(target.connectorId)}/connect`
187
+ : `/v1/connections/${encodeURIComponent(input.service)}/connect`;
142
188
  const { apiKey, ...extra } = input.values;
143
189
  const result = await this.input.connector.call({
144
- path: `/v1/connections/${encodeURIComponent(input.service)}/connect/${input.authType === 'api_key' ? 'api-key' : 'custom-credential'}`,
190
+ path: `${path}/${input.authType === 'api_key' ? 'api-key' : 'custom-credential'}`,
145
191
  method: 'POST',
146
192
  body: input.authType === 'api_key' ? { apiKey, extra } : { values: input.values },
147
193
  });
148
194
  const connection = publicConnection({ metadata: result, service: input.service });
149
195
  this.input.signal.throwIfAborted();
150
- await this.input.repository.add({
196
+ const id = target?.id ?? `connection_${crypto.randomUUID()}`;
197
+ const owned = { actorId: input.actorId, ownerId: input.ownerId, id, ...connection };
198
+ if (target) {
199
+ if (connection.connectorId !== target.connectorId) {
200
+ fail('connection_unavailable');
201
+ }
202
+ await this.input.repository.updateAccount(owned);
203
+ } else {
204
+ await this.input.repository.add(owned);
205
+ }
206
+ await this.input.onConnected?.({
151
207
  actorId: input.actorId,
152
208
  ownerId: input.ownerId,
153
- id: `connection_${crypto.randomUUID()}`,
154
- ...connection,
209
+ connection: { id, service: input.service },
155
210
  });
156
211
  return { connected: true };
157
212
  }
158
213
  async complete(input: ProviderScope & { service: string; id: string }): Promise<void> {
159
214
  this.guard(input);
160
215
  const existing = await this.input.repository.connection(input);
161
- if (existing?.service === input.service) {
216
+ const pending = await this.input.repository.pending(input);
217
+ if (!pending && existing?.service === input.service) {
218
+ await this.input.onConnected?.({
219
+ ...input,
220
+ connection: { id: input.id, service: input.service },
221
+ });
162
222
  return;
163
223
  }
164
- const pending = await this.input.repository.pending(input);
165
224
  if (!pending || pending.service !== input.service) {
166
225
  fail('not_found');
167
226
  }
@@ -178,13 +237,20 @@ export class ProviderService {
178
237
  path: `/v1/connections/by-id/${encodeURIComponent(result.appId)}`,
179
238
  });
180
239
  const connection = publicConnection({ metadata, service: input.service });
181
- if (connection.connectorId !== result.appId) {
240
+ if (
241
+ connection.connectorId !== result.appId ||
242
+ (existing && existing.connectorId !== connection.connectorId)
243
+ ) {
182
244
  throw new SyncError({ code: 'provider_request_failed', message: 'Invalid connection.' });
183
245
  }
184
246
  this.input.signal.throwIfAborted();
185
- await this.input.repository.complete({ ...input, ...connection });
247
+ await this.input.repository.complete({ ...input, ...connection, requestId: pending.requestId });
186
248
  if (!(await this.input.repository.connection(input))) {
187
249
  fail('not_found');
188
250
  }
251
+ await this.input.onConnected?.({
252
+ ...input,
253
+ connection: { id: input.id, service: input.service },
254
+ });
189
255
  }
190
256
  }
@@ -1,143 +0,0 @@
1
- import { access, cp, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import type { BunPlugin } from 'bun';
4
-
5
- // This adapter deliberately supports one published layout. Review it when upgrading Connector.
6
- const supportedVersion = '1.6.0';
7
- const indexVersion = 1;
8
- interface CatalogEntry {
9
- file: string;
10
- bytes: number;
11
- provider: { service: string };
12
- }
13
-
14
- function unsupported(detail: string): never {
15
- throw new Error(
16
- `Unsupported Open Connector package: ${detail}. Review the Open Sync build adapter.`,
17
- );
18
- }
19
-
20
- /** Parse only the pinned generator's format; never evaluate dependency code during preparation. */
21
- function executorRegistry(source: string): Map<string, string> {
22
- const lines = source.trim().split('\n');
23
- if (
24
- lines.shift() !== '/** Generated lazy imports for provider executors. Do not hand-edit. */' ||
25
- lines.shift() !== 'export const executorModules = {' ||
26
- lines.pop() !== '};'
27
- ) {
28
- unsupported('executor registry format changed');
29
- }
30
- const modules = new Map<string, string>();
31
- for (const line of lines) {
32
- const match =
33
- /^ {4}(?:"([\w-]+)"|([\w]+)): \(\) => import\("(\.\/[\w-]+\/executors\.js)"\),$/.exec(line);
34
- const service = match?.[1] ?? match?.[2];
35
- const path = match?.[3];
36
- if (!service || path !== `./${service}/executors.js` || modules.has(service)) {
37
- unsupported('executor registry entry changed');
38
- }
39
- modules.set(service, path);
40
- }
41
- return modules;
42
- }
43
-
44
- function catalogEntries(value: unknown): CatalogEntry[] {
45
- const index = value as { version?: unknown; providers?: CatalogEntry[] } | null;
46
- if (index?.version !== indexVersion || !Array.isArray(index.providers)) {
47
- unsupported('catalog index format changed');
48
- }
49
- const services = new Set<string>();
50
- for (const entry of index.providers) {
51
- const service = entry?.provider?.service;
52
- if (
53
- typeof service !== 'string' ||
54
- !/^[\w-]+$/.test(service) ||
55
- entry.file !== `${service}.json` ||
56
- !Number.isInteger(entry.bytes) ||
57
- entry.bytes < 0 ||
58
- services.has(service)
59
- ) {
60
- unsupported('catalog index entry changed');
61
- }
62
- services.add(service);
63
- }
64
- return index.providers;
65
- }
66
-
67
- /** All knowledge of Connector's private package layout stays behind this build adapter. */
68
- export async function prepareConnector(input: {
69
- root: string;
70
- providers: readonly string[];
71
- assets: string;
72
- }): Promise<BunPlugin> {
73
- const root = await realpath(input.root);
74
- const metadata = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
75
- if (metadata.name !== '@oomol-lab/open-connector' || metadata.version !== supportedVersion) {
76
- unsupported(
77
- `expected @oomol-lab/open-connector ${supportedVersion}, found ${metadata.version}`,
78
- );
79
- }
80
- const registry = join(root, 'src/providers/registry.generated.js');
81
- const modules = executorRegistry(await readFile(registry, 'utf8'));
82
- const sourceAssets = join(root, 'assets/open-connector');
83
- const entries = catalogEntries(
84
- JSON.parse(await readFile(join(sourceAssets, 'catalog/apps-index.json'), 'utf8')),
85
- );
86
- const selected = input.providers.map((service) => {
87
- const entry = entries.find((item) => item.provider.service === service);
88
- if (!entry || !modules.has(service)) {
89
- throw new Error(
90
- `Cannot bundle Open Connector provider ${JSON.stringify(service)}: catalog or executor is missing.`,
91
- );
92
- }
93
- return entry;
94
- });
95
- await mkdir(join(input.assets, 'catalog/apps'), { recursive: true });
96
- for (const entry of selected) {
97
- await access(join(root, 'src/providers', modules.get(entry.provider.service)!));
98
- const content = await readFile(join(sourceAssets, 'catalog/apps', entry.file));
99
- if (
100
- content.byteLength !== entry.bytes ||
101
- JSON.parse(content.toString()).service !== entry.provider.service
102
- ) {
103
- unsupported(`catalog file does not match its index: ${entry.file}`);
104
- }
105
- await writeFile(join(input.assets, 'catalog/apps', entry.file), content);
106
- }
107
- if (!selected.length) {
108
- // Bun embeds files, so retain the directory that Connector enumerates even with zero providers.
109
- await writeFile(join(input.assets, 'catalog/apps/empty'), '');
110
- }
111
- await writeFile(
112
- join(input.assets, 'catalog/apps-index.json'),
113
- JSON.stringify({ version: indexVersion, providers: selected }),
114
- );
115
- await cp(join(sourceAssets, 'migrations'), join(input.assets, 'migrations'), { recursive: true });
116
- const contents = `export const executorModules = {\n${selected
117
- .map(
118
- ({ provider }) =>
119
- `${JSON.stringify(provider.service)}: () => import(${JSON.stringify(modules.get(provider.service))}),`,
120
- )
121
- .join('\n')}\n};`;
122
- return {
123
- name: 'open-sync-providers',
124
- setup(build) {
125
- let replaced = false;
126
- build.onStart(() => {
127
- replaced = false;
128
- });
129
- build.onLoad({ filter: /[/\\]providers[/\\]registry\.generated\.js$/ }, ({ path }) => {
130
- if (path !== registry) {
131
- return;
132
- }
133
- replaced = true;
134
- return { contents, loader: 'js' };
135
- });
136
- build.onEnd((result) => {
137
- if (result.success && !replaced) {
138
- unsupported('the build did not load the expected executor registry');
139
- }
140
- });
141
- },
142
- };
143
- }