@foss.global/codefeed 3.0.0 → 3.2.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.
@@ -32,43 +32,125 @@ export const globalFetchTransport: TForgeTransport = async (url, headers, signal
32
32
  return { status: response.status, headers: response.headers, body: response.body };
33
33
  };
34
34
 
35
+ /** The agents a node request may use; a URL whose protocol has no agent is refused. */
36
+ export interface INodeRequestAgents {
37
+ http?: InstanceType<typeof plugins.http.Agent>;
38
+ https?: InstanceType<typeof plugins.https.Agent>;
39
+ }
40
+
41
+ export interface INodeRequest {
42
+ method: string;
43
+ headers: Record<string, string>;
44
+ /** Sent in full, or streamed with backpressure; a streamed body needs its own `content-length` header. */
45
+ body?: Uint8Array | AsyncIterable<Uint8Array>;
46
+ signal: AbortSignal;
47
+ agents: INodeRequestAgents;
48
+ }
49
+
50
+ export interface INodeResponse extends IForgeTransportResponse {
51
+ /**
52
+ * Settles once the request body has been sent, or rejects with the error
53
+ * that ended it, such as the body source's own error. Always handled.
54
+ */
55
+ sent: Promise<void>;
56
+ }
57
+
58
+ /**
59
+ * Idle keep-alive agents that unreference their pooled sockets, so pooled
60
+ * connections never keep the process alive.
61
+ */
62
+ export const createNodeAgents = (caCertificatesArg?: string[], allowHttpArg = false): INodeRequestAgents => ({
63
+ https: new plugins.https.Agent({ ...(caCertificatesArg ? { ca: caCertificatesArg } : {}), keepAlive: true, timeout: idleSocketTimeoutMs }),
64
+ ...(allowHttpArg ? { http: new plugins.http.Agent({ keepAlive: true, timeout: idleSocketTimeoutMs }) } : {}),
65
+ });
66
+
67
+ /** Waits until the request can take more body bytes; rejects once it has closed. */
68
+ const drained = (requestArg: InstanceType<typeof plugins.http.ClientRequest>): Promise<void> => new Promise((resolve, reject) => {
69
+ const done = () => { requestArg.off('drain', done); requestArg.off('close', closed); resolve(); };
70
+ const closed = () => { requestArg.off('drain', done); requestArg.off('close', closed); reject(new Error('Request closed.')); };
71
+ requestArg.on('drain', done);
72
+ requestArg.on('close', closed);
73
+ });
74
+
75
+ /**
76
+ * Sends one request through `node:http` or `node:https` and resolves once
77
+ * response headers arrive. A redirect status rejects and is never followed;
78
+ * aborting `signal` discards the connection, also while a body is still being
79
+ * sent or read. A streamed body that fails destroys the request and rejects.
80
+ */
81
+ export const sendNodeRequest = (url: URL, requestArg: INodeRequest): Promise<INodeResponse> => new Promise<INodeResponse>((resolve, reject) => {
82
+ const { signal } = requestArg;
83
+ if (signal.aborted) return reject(signal.reason);
84
+ const agent = url.protocol === 'https:' ? requestArg.agents.https : url.protocol === 'http:' ? requestArg.agents.http : undefined;
85
+ if (!agent) return reject(new Error('Refused protocol.'));
86
+ const module = url.protocol === 'https:' ? plugins.https : plugins.http;
87
+ // Bodies are read as sent, so ask for them without a content coding.
88
+ const request = module.request(url, { method: requestArg.method, headers: { ...requestArg.headers, 'accept-encoding': 'identity' }, agent });
89
+ let settled = false;
90
+ const fail = (errorArg: unknown) => {
91
+ if (!settled) {
92
+ settled = true;
93
+ reject(errorArg);
94
+ }
95
+ };
96
+ // Stays attached for the request's lifetime, so a connection failure while
97
+ // the body is read never becomes an unhandled 'error' event.
98
+ request.on('error', fail);
99
+ // Destroyed without an error on purpose: an error would be re-emitted on the
100
+ // socket, which no longer has a listener once a keep-alive response completed.
101
+ const abort = () => {
102
+ fail(signal.reason);
103
+ request.destroy();
104
+ };
105
+ signal.addEventListener('abort', abort, { once: true });
106
+ request.once('close', () => signal.removeEventListener('abort', abort));
107
+ const sent = (async () => {
108
+ const { body } = requestArg;
109
+ if (body === undefined || body instanceof Uint8Array) {
110
+ request.end(body);
111
+ return;
112
+ }
113
+ try {
114
+ for await (const chunk of body) {
115
+ if (request.destroyed) throw new Error('Request closed.');
116
+ if (!request.write(chunk)) await drained(request);
117
+ }
118
+ request.end();
119
+ } catch (error) {
120
+ fail(error);
121
+ request.destroy();
122
+ throw error;
123
+ }
124
+ })();
125
+ sent.catch(() => undefined);
126
+ request.on('response', (response) => {
127
+ try {
128
+ const status = response.statusCode ?? 0;
129
+ if (redirectStatuses.has(status)) throw new Error('Refused redirect.');
130
+ // Same combination rules as fetch: repeated fields join with ", ".
131
+ const responseHeaders = new Headers();
132
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
133
+ responseHeaders.append(response.rawHeaders[index], response.rawHeaders[index + 1]);
134
+ }
135
+ if (settled) return void request.destroy();
136
+ settled = true;
137
+ resolve({ status, headers: responseHeaders, body: response, sent });
138
+ } catch (error) {
139
+ fail(error);
140
+ request.destroy();
141
+ }
142
+ });
143
+ });
144
+
35
145
  /**
36
146
  * `node:https` with an owned keep-alive agent whose `ca` list replaces the
37
147
  * default trust store for these connections only. The agent unreferences idle
38
148
  * sockets, so pooled connections never keep the process alive.
39
149
  */
40
150
  export const createHttpsTransport = (caCertificates: string[]): TForgeTransport => {
41
- const agent = new plugins.https.Agent({ ca: caCertificates, keepAlive: true, timeout: idleSocketTimeoutMs });
42
- return (url, headers, signal, body) => new Promise<IForgeTransportResponse>((resolve, reject) => {
43
- if (signal.aborted) return reject(signal.reason);
44
- // Bodies are read as sent, so ask for them without a content coding.
45
- const request = plugins.https.request(url, { method: body ? 'POST' : 'GET', headers: { ...headers, 'accept-encoding': 'identity' }, agent });
46
- // Stays attached for the request's lifetime, so a connection failure while
47
- // the body is read never becomes an unhandled 'error' event.
48
- request.on('error', reject);
49
- // Destroyed without an error on purpose: an error would be re-emitted on the
50
- // socket, which no longer has a listener once a keep-alive response completed.
51
- const abort = () => {
52
- reject(signal.reason);
53
- request.destroy();
54
- };
55
- signal.addEventListener('abort', abort, { once: true });
56
- request.once('close', () => signal.removeEventListener('abort', abort));
57
- request.on('response', (response) => {
58
- try {
59
- const status = response.statusCode ?? 0;
60
- if (redirectStatuses.has(status)) throw new Error('Refused redirect.');
61
- // Same combination rules as fetch: repeated fields join with ", ".
62
- const responseHeaders = new Headers();
63
- for (let index = 0; index < response.rawHeaders.length; index += 2) {
64
- responseHeaders.append(response.rawHeaders[index], response.rawHeaders[index + 1]);
65
- }
66
- resolve({ status, headers: responseHeaders, body: response });
67
- } catch (error) {
68
- reject(error);
69
- request.destroy();
70
- }
71
- });
72
- request.end(body);
73
- });
151
+ const agents = createNodeAgents(caCertificates);
152
+ return async (url, headers, signal, body) => {
153
+ const { sent: _sent, ...response } = await sendNodeRequest(url, { method: body ? 'POST' : 'GET', headers, body, signal, agents });
154
+ return response;
155
+ };
74
156
  };
@@ -5,6 +5,14 @@ import type { ForgeRequestClient } from './forgeinventory.client.js';
5
5
  type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
6
6
  type IGitCounts = Omit<plugins.fossInterfaces.data.IForgeSourceGitCounts, 'reportedSizeKiB'>;
7
7
  type ILfsCounts = plugins.fossInterfaces.data.IForgeSourceLfsCounts;
8
+ type IHead = plugins.fossInterfaces.data.IForgeSourceRepositoryHead;
9
+
10
+ /** The Git level measure of one repository; `head` is null when the mirror's refs could not be read. */
11
+ export interface IGiteaMirrorMeasure {
12
+ git: IGitCounts;
13
+ lfs: ILfsCounts;
14
+ head: IHead | null;
15
+ }
8
16
 
9
17
  export interface IGiteaMirrorSource {
10
18
  /** Instance root without a trailing slash. */
@@ -98,6 +106,21 @@ export class GitScratchBudget {
98
106
  }
99
107
  }
100
108
 
109
+ /**
110
+ * The process-wide budget of the file system that holds `directoryArg`,
111
+ * keyed by its device; every mirror clone and every scratch GitManager in
112
+ * the process reserves through it.
113
+ */
114
+ static async forDirectory(directoryArg: string): Promise<GitScratchBudget> {
115
+ const device = (await plugins.fs.stat(directoryArg, { bigint: true })).dev;
116
+ let budget = scratchBudgets.get(device);
117
+ if (!budget) {
118
+ budget = new GitScratchBudget();
119
+ scratchBudgets.set(device, budget);
120
+ }
121
+ return budget;
122
+ }
123
+
101
124
  #releaser(bytesArg: bigint): () => void {
102
125
  let held = true;
103
126
  return () => {
@@ -127,16 +150,28 @@ export class GitScratchBudget {
127
150
  /** One budget per file system, shared by every reader in the process. */
128
151
  const scratchBudgets = new Map<bigint, GitScratchBudget>();
129
152
 
130
- const scratchBudget = async (workDirArg: string): Promise<GitScratchBudget> => {
131
- const device = (await plugins.fs.stat(workDirArg, { bigint: true })).dev;
132
- let budget = scratchBudgets.get(device);
133
- if (!budget) {
134
- budget = new GitScratchBudget();
135
- scratchBudgets.set(device, budget);
153
+ /**
154
+ * Reserves the scratch of one mirror clone into `directoryArg` in the
155
+ * process-wide budget of its file system and resolves with the function that
156
+ * releases it. Rejects with `git-workdir-too-small` when the clone does not
157
+ * fit even alone, `git-workdir-failed` when the directory or its free space
158
+ * cannot be read and `request-aborted` when `signalArg` aborts first.
159
+ */
160
+ export const reserveMirrorScratch = async (directoryArg: string, reportedSizeKiBArg: bigint, signalArg?: AbortSignal): Promise<() => void> => {
161
+ let release: (() => void) | null;
162
+ try {
163
+ release = await (await GitScratchBudget.forDirectory(directoryArg)).reserve(directoryArg, mirrorScratchBytes(reportedSizeKiBArg), signalArg);
164
+ } catch {
165
+ throw new validation.ForgeInventoryError(signalArg?.aborted ? 'request-aborted' : 'git-workdir-failed');
136
166
  }
137
- return budget;
167
+ if (release === null) throw new validation.ForgeInventoryError('git-workdir-too-small');
168
+ return release;
138
169
  };
139
170
 
171
+ /** The HTTPS Git URL of a Gitea repository; it never carries credentials. */
172
+ export const giteaCloneUrl = (baseUrlArg: string, ownerArg: string, nameArg: string): string =>
173
+ `${baseUrlArg}/${encodeURIComponent(ownerArg)}/${encodeURIComponent(nameArg)}.git`;
174
+
140
175
  const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
141
176
  const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
142
177
 
@@ -158,23 +193,22 @@ const stepFailure = (errorArg: unknown, stepArg: string, signalArg?: AbortSignal
158
193
 
159
194
  /**
160
195
  * Measures one repository at the Git level: clones every ref Gitea advertises
161
- * into a temporary mirror, counts its refs and objects, finds the LFS pointers
196
+ * into a temporary mirror, records its HEAD, counts its refs and objects, finds the LFS pointers
162
197
  * of every reachable revision and checks through the LFS batch API which of
163
198
  * their objects Gitea holds. The mirror is removed afterwards. The clone starts
164
199
  * only once its scratch reservation fits the work directory's free space; a
165
200
  * repository too large for it fails with `git-workdir-too-small`.
166
201
  */
167
- export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ git: IGitCounts; lfs: ILfsCounts }> => {
202
+ export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise<IGiteaMirrorMeasure> => {
168
203
  const { signal } = sourceArg;
169
204
  if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
170
- let release: (() => void) | null;
205
+ let release: () => void;
171
206
  try {
172
- release = await (await scratchBudget(sourceArg.workDir)).reserve(sourceArg.workDir, mirrorScratchBytes(sourceArg.reportedSizeKiB), signal);
173
- } catch {
174
- const failure = signal?.aborted ? 'request-aborted' : 'git-workdir-failed';
175
- return { git: failedGit(failure), lfs: failedLfs(failure) };
207
+ release = await reserveMirrorScratch(sourceArg.workDir, sourceArg.reportedSizeKiB, signal);
208
+ } catch (error) {
209
+ const failure = stepFailure(error, 'git-workdir-failed', signal);
210
+ return { git: failedGit(failure), lfs: failedLfs(failure), head: null };
176
211
  }
177
- if (release === null) return { git: failedGit('git-workdir-too-small'), lfs: failedLfs('git-workdir-too-small') };
178
212
  try {
179
213
  return await measureReservedMirror(sourceArg);
180
214
  } finally {
@@ -182,17 +216,18 @@ export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise
182
216
  }
183
217
  };
184
218
 
185
- const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ git: IGitCounts; lfs: ILfsCounts }> => {
219
+ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<IGiteaMirrorMeasure> => {
186
220
  const { signal } = sourceArg;
187
221
  const { GitManager } = await plugins.getGitManager();
188
222
  let directory: string;
189
223
  try {
190
224
  directory = await plugins.fs.mkdtemp(plugins.path.join(sourceArg.workDir, 'mirror-'));
191
225
  } catch {
192
- return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed') };
226
+ return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed'), head: null };
193
227
  }
194
228
  const manager = new GitManager({ dataDir: directory });
195
- // Stopping the engine ends the operation in flight.
229
+ // The engine serves this measurement alone; stopping it ends the ref, object
230
+ // and LFS scans in flight, which take no signal. The clone takes the signal.
196
231
  const abort = () => { void manager.stop().catch(() => undefined); };
197
232
  signal?.addEventListener('abort', abort, { once: true });
198
233
  const selector = { org: 'source', repo: 'mirror' };
@@ -203,23 +238,23 @@ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ g
203
238
  await manager.start();
204
239
  await manager.importMirror({
205
240
  ...selector,
206
- remoteUrl: `${sourceArg.baseUrl}/${encodeURIComponent(sourceArg.owner)}/${encodeURIComponent(sourceArg.name)}.git`,
241
+ remoteUrl: giteaCloneUrl(sourceArg.baseUrl, sourceArg.owner, sourceArg.name),
207
242
  credential: { kind: 'basic', username: sourceArg.username, password: sourceArg.token },
208
243
  caCertificates: sourceArg.caCertificates,
209
244
  timeoutMs: sourceArg.timeoutMs,
210
- });
245
+ }, { signal });
211
246
  } catch (error) {
212
247
  const failure = stepFailure(error, 'git-mirror-failed', signal);
213
- return { git: failedGit(failure), lfs: failedLfs(failure) };
248
+ return { git: failedGit(failure), lfs: failedLfs(failure), head: null };
214
249
  }
215
- const git = await measureRefsAndObjects(manager, selector, signal);
250
+ const { git, head } = await measureRefsAndObjects(manager, selector, signal);
216
251
  let pointers: Array<{ oid: string; sizeBytes: number }>;
217
252
  try {
218
253
  pointers = await manager.listRepositoryLfsPointers(selector);
219
254
  } catch (error) {
220
- return { git, lfs: failedLfs(stepFailure(error, 'lfs-pointer-scan-failed', signal)) };
255
+ return { git, lfs: failedLfs(stepFailure(error, 'lfs-pointer-scan-failed', signal)), head };
221
256
  }
222
- return { git, lfs: await giteaLfsPresence(sourceArg, pointers) };
257
+ return { git, lfs: await giteaLfsPresence(sourceArg, pointers), head };
223
258
  } finally {
224
259
  signal?.removeEventListener('abort', abort);
225
260
  await manager.stop().catch(() => undefined);
@@ -231,8 +266,9 @@ const measureRefsAndObjects = async (
231
266
  managerArg: plugins.TGitManager,
232
267
  selectorArg: { org: string; repo: string },
233
268
  signalArg?: AbortSignal,
234
- ): Promise<IGitCounts> => {
269
+ ): Promise<Pick<IGiteaMirrorMeasure, 'git' | 'head'>> => {
235
270
  const counts = { branches: 0, tags: 0, pullRequestRefs: 0, otherRefs: 0 };
271
+ let head: IHead | null = null;
236
272
  let refsFailure: string | null = null;
237
273
  try {
238
274
  let cursor: string | undefined;
@@ -240,6 +276,8 @@ const measureRefsAndObjects = async (
240
276
  const page = await managerArg.listRefs({ ...selectorArg, cursor });
241
277
  // Nothing writes to the private mirror, so a changed inventory is a failure.
242
278
  if (page.status === 'stale') throw new validation.ForgeInventoryError('git-refs-changed');
279
+ // Every page carries HEAD; its digest binds it, so all pages agree. An unborn HEAD has no object.
280
+ head = { target: page.head.symbolicTarget, resolves: page.head.objectId !== null };
243
281
  for (const ref of page.refs) {
244
282
  if (ref.name.startsWith('refs/heads/')) counts.branches++;
245
283
  else if (ref.name.startsWith('refs/tags/')) counts.tags++;
@@ -249,6 +287,7 @@ const measureRefsAndObjects = async (
249
287
  cursor = page.pagination.status === 'more' ? page.pagination.cursor : undefined;
250
288
  } while (cursor !== undefined);
251
289
  } catch (error) {
290
+ head = null;
252
291
  refsFailure = stepFailure(error, 'git-refs-failed', signalArg);
253
292
  }
254
293
  const refs = (valueArg: number) => refsFailure === null ? complete(valueArg) : partial(0, refsFailure);
@@ -262,8 +301,11 @@ const measureRefsAndObjects = async (
262
301
  objects = objectBytes = partial(0, stepFailure(error, 'git-size-failed', signalArg));
263
302
  }
264
303
  return {
265
- branches: refs(counts.branches), tags: refs(counts.tags), pullRequestRefs: refs(counts.pullRequestRefs), otherRefs: refs(counts.otherRefs),
266
- objects, objectBytes,
304
+ git: {
305
+ branches: refs(counts.branches), tags: refs(counts.tags), pullRequestRefs: refs(counts.pullRequestRefs), otherRefs: refs(counts.otherRefs),
306
+ objects, objectBytes,
307
+ },
308
+ head,
267
309
  };
268
310
  };
269
311
 
package/ts/index.ts CHANGED
@@ -488,3 +488,6 @@ export class CodeFeed {
488
488
  export * from './classes.forgeinventory.js';
489
489
  export * from './classes.forgeinventoryscanner.js';
490
490
  export * from './classes.giteasourceinventory.js';
491
+ export * from './classes.giteasourcetransfer.js';
492
+ export * from './classes.lfsbatchclient.js';
493
+ export { GitScratchBudget, mirrorScratchBytes } from './giteasourceinventory.git.js';
package/ts/plugins.ts CHANGED
@@ -2,9 +2,12 @@ import type * as fossInterfaces from '@foss.global/interfaces';
2
2
 
3
3
  import * as crypto from 'node:crypto';
4
4
  import * as fs from 'node:fs/promises';
5
+ import * as http from 'node:http';
5
6
  import * as https from 'node:https';
7
+ import * as net from 'node:net';
6
8
  import * as path from 'node:path';
7
- export { crypto, fs, https, path };
9
+ import * as stream from 'node:stream';
10
+ export { crypto, fs, http, https, net, path, stream };
8
11
 
9
12
  // module
10
13
  import * as interfaces from './interfaces/index.js';