@foss.global/codefeed 3.0.0 → 3.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/changelog.md +12 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourcetransfer.d.ts +85 -0
- package/dist_ts/classes.giteasourcetransfer.js +164 -0
- package/dist_ts/classes.lfsbatchclient.d.ts +126 -0
- package/dist_ts/classes.lfsbatchclient.js +582 -0
- package/dist_ts/forgeinventory.transport.d.ts +33 -0
- package/dist_ts/forgeinventory.transport.js +98 -37
- package/dist_ts/giteasourceinventory.git.d.ts +16 -0
- package/dist_ts/giteasourceinventory.git.js +41 -16
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/plugins.d.ts +4 -1
- package/dist_ts/plugins.js +5 -2
- package/package.json +3 -3
- package/readme.md +71 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourcetransfer.ts +213 -0
- package/ts/classes.lfsbatchclient.ts +614 -0
- package/ts/forgeinventory.transport.ts +115 -33
- package/ts/giteasourceinventory.git.ts +42 -15
- package/ts/index.ts +3 -0
- package/ts/plugins.ts +4 -1
|
@@ -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
|
|
42
|
-
return (url, headers, signal, body) =>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
};
|
|
@@ -98,6 +98,21 @@ export class GitScratchBudget {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The process-wide budget of the file system that holds `directoryArg`,
|
|
103
|
+
* keyed by its device; every mirror clone and every scratch GitManager in
|
|
104
|
+
* the process reserves through it.
|
|
105
|
+
*/
|
|
106
|
+
static async forDirectory(directoryArg: string): Promise<GitScratchBudget> {
|
|
107
|
+
const device = (await plugins.fs.stat(directoryArg, { bigint: true })).dev;
|
|
108
|
+
let budget = scratchBudgets.get(device);
|
|
109
|
+
if (!budget) {
|
|
110
|
+
budget = new GitScratchBudget();
|
|
111
|
+
scratchBudgets.set(device, budget);
|
|
112
|
+
}
|
|
113
|
+
return budget;
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
#releaser(bytesArg: bigint): () => void {
|
|
102
117
|
let held = true;
|
|
103
118
|
return () => {
|
|
@@ -127,16 +142,28 @@ export class GitScratchBudget {
|
|
|
127
142
|
/** One budget per file system, shared by every reader in the process. */
|
|
128
143
|
const scratchBudgets = new Map<bigint, GitScratchBudget>();
|
|
129
144
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Reserves the scratch of one mirror clone into `directoryArg` in the
|
|
147
|
+
* process-wide budget of its file system and resolves with the function that
|
|
148
|
+
* releases it. Rejects with `git-workdir-too-small` when the clone does not
|
|
149
|
+
* fit even alone, `git-workdir-failed` when the directory or its free space
|
|
150
|
+
* cannot be read and `request-aborted` when `signalArg` aborts first.
|
|
151
|
+
*/
|
|
152
|
+
export const reserveMirrorScratch = async (directoryArg: string, reportedSizeKiBArg: bigint, signalArg?: AbortSignal): Promise<() => void> => {
|
|
153
|
+
let release: (() => void) | null;
|
|
154
|
+
try {
|
|
155
|
+
release = await (await GitScratchBudget.forDirectory(directoryArg)).reserve(directoryArg, mirrorScratchBytes(reportedSizeKiBArg), signalArg);
|
|
156
|
+
} catch {
|
|
157
|
+
throw new validation.ForgeInventoryError(signalArg?.aborted ? 'request-aborted' : 'git-workdir-failed');
|
|
136
158
|
}
|
|
137
|
-
|
|
159
|
+
if (release === null) throw new validation.ForgeInventoryError('git-workdir-too-small');
|
|
160
|
+
return release;
|
|
138
161
|
};
|
|
139
162
|
|
|
163
|
+
/** The HTTPS Git URL of a Gitea repository; it never carries credentials. */
|
|
164
|
+
export const giteaCloneUrl = (baseUrlArg: string, ownerArg: string, nameArg: string): string =>
|
|
165
|
+
`${baseUrlArg}/${encodeURIComponent(ownerArg)}/${encodeURIComponent(nameArg)}.git`;
|
|
166
|
+
|
|
140
167
|
const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
|
|
141
168
|
const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
|
|
142
169
|
|
|
@@ -167,14 +194,13 @@ const stepFailure = (errorArg: unknown, stepArg: string, signalArg?: AbortSignal
|
|
|
167
194
|
export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ git: IGitCounts; lfs: ILfsCounts }> => {
|
|
168
195
|
const { signal } = sourceArg;
|
|
169
196
|
if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
170
|
-
let release: (
|
|
197
|
+
let release: () => void;
|
|
171
198
|
try {
|
|
172
|
-
release = await (
|
|
173
|
-
} catch {
|
|
174
|
-
const failure =
|
|
199
|
+
release = await reserveMirrorScratch(sourceArg.workDir, sourceArg.reportedSizeKiB, signal);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const failure = stepFailure(error, 'git-workdir-failed', signal);
|
|
175
202
|
return { git: failedGit(failure), lfs: failedLfs(failure) };
|
|
176
203
|
}
|
|
177
|
-
if (release === null) return { git: failedGit('git-workdir-too-small'), lfs: failedLfs('git-workdir-too-small') };
|
|
178
204
|
try {
|
|
179
205
|
return await measureReservedMirror(sourceArg);
|
|
180
206
|
} finally {
|
|
@@ -192,7 +218,8 @@ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ g
|
|
|
192
218
|
return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed') };
|
|
193
219
|
}
|
|
194
220
|
const manager = new GitManager({ dataDir: directory });
|
|
195
|
-
//
|
|
221
|
+
// The engine serves this measurement alone; stopping it ends the ref, object
|
|
222
|
+
// and LFS scans in flight, which take no signal. The clone takes the signal.
|
|
196
223
|
const abort = () => { void manager.stop().catch(() => undefined); };
|
|
197
224
|
signal?.addEventListener('abort', abort, { once: true });
|
|
198
225
|
const selector = { org: 'source', repo: 'mirror' };
|
|
@@ -203,11 +230,11 @@ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ g
|
|
|
203
230
|
await manager.start();
|
|
204
231
|
await manager.importMirror({
|
|
205
232
|
...selector,
|
|
206
|
-
remoteUrl:
|
|
233
|
+
remoteUrl: giteaCloneUrl(sourceArg.baseUrl, sourceArg.owner, sourceArg.name),
|
|
207
234
|
credential: { kind: 'basic', username: sourceArg.username, password: sourceArg.token },
|
|
208
235
|
caCertificates: sourceArg.caCertificates,
|
|
209
236
|
timeoutMs: sourceArg.timeoutMs,
|
|
210
|
-
});
|
|
237
|
+
}, { signal });
|
|
211
238
|
} catch (error) {
|
|
212
239
|
const failure = stepFailure(error, 'git-mirror-failed', signal);
|
|
213
240
|
return { git: failedGit(failure), lfs: failedLfs(failure) };
|
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
|
-
|
|
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';
|