@xemahq/repo-build-tooling 0.9.2 → 0.10.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/README.md +19 -0
- package/package.json +7 -5
- package/src/composed-image.mjs +4 -1
- package/src/ghcr-manifest.mjs +4 -2
- package/src/registry-fetch.mjs +82 -0
- package/src/registry-fetch.test.mjs +151 -0
- package/src/scrub-swagger-plugin-paths.mjs +83 -4
- package/src/service-bootstrap-concurrency.mjs +49 -0
- package/src/service-bootstrap-protocols.mjs +108 -0
- package/src/service-bootstrap-protocols.test.mjs +70 -0
- package/src/service-bootstrap.mjs +491 -0
- package/src/service-bootstrap.test.mjs +304 -0
- package/src/worker-descriptor-generation.mjs +77 -0
package/README.md
CHANGED
|
@@ -108,6 +108,25 @@ build carries every composable platform service of the repository at one commit.
|
|
|
108
108
|
Every step refuses rather than skips. The tool has no dependencies: the host and
|
|
109
109
|
the kernel schema are resolved from the members being built.
|
|
110
110
|
|
|
111
|
+
### `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]`
|
|
112
|
+
|
|
113
|
+
Generates the descriptors `@xemahq/xema-service-nest` boots from, out of the
|
|
114
|
+
repository's own `xema-biome.json` manifests. For every `kind: service` component
|
|
115
|
+
it writes `src/generated/<name>.bootstrap.generated.ts` (a `BiomeServiceDescriptor`),
|
|
116
|
+
and for every `kind: worker` component `src/generated/<key>.worker-descriptor.generated.ts`
|
|
117
|
+
(a `XemaWorkerDescriptor`; a `shared-oci-image` worker lands in the package of the
|
|
118
|
+
component whose image it shares). Only packages that declare
|
|
119
|
+
`@xemahq/xema-service-nest` are written to.
|
|
120
|
+
|
|
121
|
+
`requirements.scaling.concurrency.maximumPerInstance` becomes
|
|
122
|
+
`maximumConcurrentRequests` on a service and `maximumConcurrentActivities` on a
|
|
123
|
+
worker. A component without a positive integer there is refused, naming the
|
|
124
|
+
manifest and the component.
|
|
125
|
+
|
|
126
|
+
`--check` writes nothing and fails on any drift. A run that finds no manifest, or no
|
|
127
|
+
service or worker to generate, fails rather than reporting success. Run it from the
|
|
128
|
+
repository root, or pass `--root`.
|
|
129
|
+
|
|
111
130
|
## Scope boundary
|
|
112
131
|
|
|
113
132
|
Dev-time tooling that **more than one repository** runs. Two rules keep it from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM with zero dependencies so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
|
|
26
26
|
"xema-composed-image": "src/composed-image.mjs",
|
|
27
27
|
"xema-readiness": "src/readiness.mjs",
|
|
28
|
-
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs"
|
|
28
|
+
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
|
|
29
|
+
"xema-service-bootstrap": "src/service-bootstrap.mjs"
|
|
29
30
|
},
|
|
30
31
|
"exports": {
|
|
31
32
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
@@ -34,12 +35,13 @@
|
|
|
34
35
|
"./ghcr-manifest": "./src/ghcr-manifest.mjs",
|
|
35
36
|
"./package.json": "./package.json",
|
|
36
37
|
"./readiness": "./src/readiness.mjs",
|
|
37
|
-
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs"
|
|
38
|
+
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
|
|
39
|
+
"./service-bootstrap": "./src/service-bootstrap.mjs"
|
|
38
40
|
},
|
|
39
41
|
"scripts": {
|
|
40
|
-
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/ghcr-manifest.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs"
|
|
42
|
+
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/ghcr-manifest.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/registry-fetch.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs"
|
|
41
43
|
},
|
|
42
44
|
"xemaRelease": {
|
|
43
|
-
"sourceDigest": "sha256:
|
|
45
|
+
"sourceDigest": "sha256:35b4aafd2345b2d892fff707b22790985f6ae1e20f75d0f7467f3401f8b583d2"
|
|
44
46
|
}
|
|
45
47
|
}
|
package/src/composed-image.mjs
CHANGED
|
@@ -77,6 +77,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
77
77
|
|
|
78
78
|
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
79
79
|
import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
|
|
80
|
+
import { describeErrorChain } from './registry-fetch.mjs';
|
|
80
81
|
import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
|
|
81
82
|
import { runImageLoad } from './load-image.mjs';
|
|
82
83
|
import { runRefusalSmoke } from './smoke-refusal.mjs';
|
|
@@ -553,7 +554,9 @@ if (invokedPath !== undefined && import.meta.url === pathToFileURL(invokedPath).
|
|
|
553
554
|
// the verdict is in, so the step ends here rather than on their timers.
|
|
554
555
|
process.exit(0);
|
|
555
556
|
} catch (error) {
|
|
556
|
-
|
|
557
|
+
const [head, ...causes] = describeErrorChain(error);
|
|
558
|
+
console.error(`xema-composed-image: ${error?.message ?? head}`);
|
|
559
|
+
for (const cause of causes) console.error(` caused by ${cause}`);
|
|
557
560
|
process.exit(1);
|
|
558
561
|
}
|
|
559
562
|
}
|
package/src/ghcr-manifest.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// Only 200 and 404 are answers; anything else throws, so an unreachable or
|
|
5
5
|
// unauthorised registry can never read as "missing" and trigger a rebuild.
|
|
6
6
|
|
|
7
|
+
import { registryFetch } from './registry-fetch.mjs';
|
|
8
|
+
|
|
7
9
|
const MANIFEST_ACCEPT = [
|
|
8
10
|
'application/vnd.oci.image.index.v1+json',
|
|
9
11
|
'application/vnd.oci.image.manifest.v1+json',
|
|
@@ -38,7 +40,7 @@ export async function acquireGhcrPullToken({ registry, repository, user, token,
|
|
|
38
40
|
const url = new URL(`https://${registry}/token`);
|
|
39
41
|
url.searchParams.set('service', registry);
|
|
40
42
|
url.searchParams.set('scope', `repository:${repository}:pull`);
|
|
41
|
-
const response = await fetchImpl
|
|
43
|
+
const response = await registryFetch(fetchImpl, url, {
|
|
42
44
|
method: 'GET',
|
|
43
45
|
headers: { Authorization: basicAuthorization(user, token), Accept: 'application/json' },
|
|
44
46
|
});
|
|
@@ -60,7 +62,7 @@ export async function acquireGhcrPullToken({ registry, repository, user, token,
|
|
|
60
62
|
/** Returns `true` only for a proven existing manifest and `false` only for 404. */
|
|
61
63
|
export async function ghcrManifestExists({ registry, repository, contentHash, user, token, fetchImpl = fetch }) {
|
|
62
64
|
const bearer = await acquireGhcrPullToken({ registry, repository, user, token, fetchImpl });
|
|
63
|
-
const response = await fetchImpl
|
|
65
|
+
const response = await registryFetch(fetchImpl, `https://${registry}/v2/${repository}/manifests/${contentHash}`, {
|
|
64
66
|
method: 'HEAD',
|
|
65
67
|
headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
|
|
66
68
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The one place a registry request is sent and a network failure is named.
|
|
2
|
+
//
|
|
3
|
+
// Node's fetch rejects with `TypeError: fetch failed` and keeps the real reason
|
|
4
|
+
// (ENOTFOUND, ECONNREFUSED, a TLS error, a timeout) in `error.cause`. Letting that
|
|
5
|
+
// escape as a bare message hides which request failed and why.
|
|
6
|
+
|
|
7
|
+
/** The URL with credentials and every query value removed; only the query keys remain. */
|
|
8
|
+
export function redactUrl(input) {
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(String(input));
|
|
12
|
+
} catch {
|
|
13
|
+
return '<unparseable url>';
|
|
14
|
+
}
|
|
15
|
+
url.username = '';
|
|
16
|
+
url.password = '';
|
|
17
|
+
const keys = [...new Set(url.searchParams.keys())];
|
|
18
|
+
url.search = '';
|
|
19
|
+
url.hash = '';
|
|
20
|
+
return keys.length > 0 ? `${url.href}?${keys.map((key) => `${key}=<redacted>`).join('&')}` : url.href;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function describeOne(error) {
|
|
24
|
+
if (!(error instanceof Error)) return String(error);
|
|
25
|
+
const details = ['code', 'errno', 'syscall', 'hostname', 'address', 'port']
|
|
26
|
+
.filter((key) => error[key] !== undefined)
|
|
27
|
+
.map((key) => `${key}=${error[key]}`);
|
|
28
|
+
const head = `${error.name}: ${error.message}`;
|
|
29
|
+
return details.length > 0 ? `${head} (${details.join(', ')})` : head;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One line per link of the `cause` chain, outermost first. Bounded against cycles. */
|
|
33
|
+
export function describeErrorChain(error) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (let current = error; current !== undefined && !seen.has(current) && lines.length < 10; ) {
|
|
37
|
+
lines.push(describeOne(current));
|
|
38
|
+
if (!(current instanceof Error)) break;
|
|
39
|
+
seen.add(current);
|
|
40
|
+
current = current.cause;
|
|
41
|
+
}
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Total attempts per request, the first included. */
|
|
46
|
+
export const MAX_ATTEMPTS = 3;
|
|
47
|
+
/** Fixed wait in milliseconds after failed attempt n (1-based); deterministic, no jitter. */
|
|
48
|
+
export const RETRY_BACKOFF_MS = [1000, 3000];
|
|
49
|
+
|
|
50
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
+
|
|
52
|
+
function causeCode(error) {
|
|
53
|
+
for (let current = error, depth = 0; current instanceof Error && depth < 10; current = current.cause, depth += 1) {
|
|
54
|
+
if (current.code !== undefined) return String(current.code);
|
|
55
|
+
}
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Send one registry request. Only a transport failure (the fetch promise rejects) is
|
|
61
|
+
* retried (an abort, `AbortError` or an aborted `init.signal`, is rethrown at once), up to MAX_ATTEMPTS with RETRY_BACKOFF_MS between attempts; any HTTP response,
|
|
62
|
+
* whatever its status, is returned as is. The final failure is thrown naming the method,
|
|
63
|
+
* the redacted URL and the attempt count, with the last error as `cause`. Headers are
|
|
64
|
+
* never reported. `options.sleep` replaces the real wait (for tests).
|
|
65
|
+
*/
|
|
66
|
+
export async function registryFetch(fetchImpl, url, init, { sleep = realSleep } = {}) {
|
|
67
|
+
const method = init?.method ?? 'GET';
|
|
68
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
69
|
+
try {
|
|
70
|
+
return await fetchImpl(url, init);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.name === 'AbortError' || init?.signal?.aborted === true) throw error;
|
|
73
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
74
|
+
throw new Error(`${method} ${redactUrl(url)} failed after ${attempt} attempts`, { cause: error });
|
|
75
|
+
}
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`registry request failed (attempt ${attempt} of ${MAX_ATTEMPTS}): ${method} ${redactUrl(url)} cause=${causeCode(error)}; retrying\n`,
|
|
78
|
+
);
|
|
79
|
+
await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import { MAX_ATTEMPTS, RETRY_BACKOFF_MS, describeErrorChain, redactUrl, registryFetch } from './registry-fetch.mjs';
|
|
11
|
+
|
|
12
|
+
const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'composed-image.mjs');
|
|
13
|
+
|
|
14
|
+
async function closedPort() {
|
|
15
|
+
const server = net.createServer();
|
|
16
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
17
|
+
const { port } = server.address();
|
|
18
|
+
await new Promise((resolve) => server.close(resolve));
|
|
19
|
+
return port;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('redactUrl drops credentials, query values and the fragment', () => {
|
|
23
|
+
const out = redactUrl('https://user:pw@ghcr.io/token?service=ghcr.io&scope=secret-scope#frag');
|
|
24
|
+
assert.equal(out, 'https://ghcr.io/token?service=<redacted>&scope=<redacted>');
|
|
25
|
+
assert.equal(redactUrl('not a url'), '<unparseable url>');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a refused connection names the method, the URL and the cause code, never the headers', async () => {
|
|
29
|
+
const port = await closedPort();
|
|
30
|
+
const url = `http://127.0.0.1:${port}/v2/org/img/manifests/abc?access_token=SECRETQ`;
|
|
31
|
+
const error = await registryFetch(fetch, url, { method: 'HEAD', headers: { Authorization: 'Bearer SECRETH' } }, { sleep: async () => {} }).then(
|
|
32
|
+
() => assert.fail('expected the request to fail'),
|
|
33
|
+
(caught) => caught,
|
|
34
|
+
);
|
|
35
|
+
const text = [error.message, ...describeErrorChain(error)].join('\n');
|
|
36
|
+
assert.match(error.message, new RegExp(`^HEAD http://127\\.0\\.0\\.1:${port}/v2/org/img/manifests/abc\\?access_token=<redacted> failed after 3 attempts$`));
|
|
37
|
+
assert.match(text, /ECONNREFUSED/);
|
|
38
|
+
assert.doesNotMatch(text, /SECRETQ|SECRETH/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('describeErrorChain walks the cause chain and survives a cycle', () => {
|
|
42
|
+
const inner = Object.assign(new Error('getaddrinfo ENOTFOUND ghcr.io'), { code: 'ENOTFOUND', hostname: 'ghcr.io' });
|
|
43
|
+
const outer = new TypeError('fetch failed', { cause: inner });
|
|
44
|
+
inner.cause = outer;
|
|
45
|
+
const lines = describeErrorChain(outer);
|
|
46
|
+
assert.equal(lines.length, 2);
|
|
47
|
+
assert.match(lines[1], /code=ENOTFOUND, hostname=ghcr\.io/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the CLI prints the failing request and the cause chain to stderr and exits 1', () => {
|
|
51
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-cause-'));
|
|
52
|
+
try {
|
|
53
|
+
const hashDir = path.join(root, 'node_modules', '@xemahq', 'distribution-source-hash');
|
|
54
|
+
fs.mkdirSync(hashDir, { recursive: true });
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
path.join(hashDir, 'package.json'),
|
|
57
|
+
JSON.stringify({ name: '@xemahq/distribution-source-hash', bin: { 'xema-compute-image-tag': 'bin.js' } }),
|
|
58
|
+
);
|
|
59
|
+
fs.writeFileSync(path.join(hashDir, 'bin.js'), `console.log('${'a'.repeat(64)}');\n`);
|
|
60
|
+
fs.writeFileSync(path.join(root, 'package.json'), '{}');
|
|
61
|
+
const preload = path.join(root, 'preload.mjs');
|
|
62
|
+
fs.writeFileSync(
|
|
63
|
+
preload,
|
|
64
|
+
`globalThis.fetch = async () => { throw new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND ghcr.io'), { code: 'ENOTFOUND', hostname: 'ghcr.io' }) }); };\n`,
|
|
65
|
+
);
|
|
66
|
+
const result = spawnSync(process.execPath, ['--import', preload, BIN, 'coordinate', '--image-name', 'img'], {
|
|
67
|
+
cwd: root,
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
env: {
|
|
70
|
+
PATH: process.env.PATH,
|
|
71
|
+
SHA: 'b'.repeat(40),
|
|
72
|
+
REGISTRY: 'ghcr.io',
|
|
73
|
+
REGISTRY_ORG: 'org',
|
|
74
|
+
REGISTRY_USER: 'u',
|
|
75
|
+
REGISTRY_TOKEN: 'TOPSECRET',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
assert.equal(result.status, 1);
|
|
79
|
+
assert.match(result.stderr, /GET https:\/\/ghcr\.io\/token\?service=<redacted>&scope=<redacted> failed after 3 attempts/);
|
|
80
|
+
assert.match(result.stderr, /caused by TypeError: fetch failed/);
|
|
81
|
+
assert.match(result.stderr, /caused by Error: getaddrinfo ENOTFOUND ghcr\.io \(code=ENOTFOUND, hostname=ghcr\.io\)/);
|
|
82
|
+
assert.doesNotMatch(result.stderr, /TOPSECRET/);
|
|
83
|
+
} finally {
|
|
84
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const transportError = () =>
|
|
89
|
+
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo EAI_AGAIN ghcr.io'), { code: 'EAI_AGAIN' }) });
|
|
90
|
+
|
|
91
|
+
test('a transient transport failure is retried once and then returns the response', async () => {
|
|
92
|
+
let calls = 0;
|
|
93
|
+
const waits = [];
|
|
94
|
+
const response = new Response('ok', { status: 200 });
|
|
95
|
+
const fetchImpl = async () => {
|
|
96
|
+
calls += 1;
|
|
97
|
+
if (calls === 1) throw transportError();
|
|
98
|
+
return response;
|
|
99
|
+
};
|
|
100
|
+
const result = await registryFetch(fetchImpl, 'https://ghcr.io/token?scope=x', { method: 'GET' }, { sleep: async (ms) => waits.push(ms) });
|
|
101
|
+
assert.equal(calls, 2);
|
|
102
|
+
assert.equal(result, response);
|
|
103
|
+
assert.deepEqual(waits, [RETRY_BACKOFF_MS[0]]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('three transport failures throw naming 3 attempts and keep the cause chain', async () => {
|
|
107
|
+
let calls = 0;
|
|
108
|
+
const waits = [];
|
|
109
|
+
const fetchImpl = async () => {
|
|
110
|
+
calls += 1;
|
|
111
|
+
throw transportError();
|
|
112
|
+
};
|
|
113
|
+
const error = await registryFetch(fetchImpl, 'https://ghcr.io/v2/o/i/manifests/a', { method: 'HEAD' }, { sleep: async (ms) => waits.push(ms) }).then(
|
|
114
|
+
() => assert.fail('expected failure'),
|
|
115
|
+
(caught) => caught,
|
|
116
|
+
);
|
|
117
|
+
assert.equal(calls, MAX_ATTEMPTS);
|
|
118
|
+
assert.deepEqual(waits, [1000, 3000]);
|
|
119
|
+
assert.equal(error.message, 'HEAD https://ghcr.io/v2/o/i/manifests/a failed after 3 attempts');
|
|
120
|
+
assert.match(describeErrorChain(error).join('\n'), /fetch failed[\s\S]*code=EAI_AGAIN/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('an HTTP error status is returned after one call, never retried', async () => {
|
|
124
|
+
let calls = 0;
|
|
125
|
+
const fetchImpl = async () => {
|
|
126
|
+
calls += 1;
|
|
127
|
+
return new Response('down', { status: 503 });
|
|
128
|
+
};
|
|
129
|
+
const result = await registryFetch(fetchImpl, 'https://ghcr.io/token', { method: 'GET' }, { sleep: async () => assert.fail('must not sleep') });
|
|
130
|
+
assert.equal(calls, 1);
|
|
131
|
+
assert.equal(result.status, 503);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('an abort is rethrown at once, never retried', async () => {
|
|
135
|
+
let calls = 0;
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
controller.abort();
|
|
138
|
+
const fetchImpl = async () => {
|
|
139
|
+
calls += 1;
|
|
140
|
+
throw new Error('request failed');
|
|
141
|
+
};
|
|
142
|
+
const error = await registryFetch(fetchImpl, 'https://ghcr.io/token', { method: 'GET', signal: controller.signal }, { sleep: async () => assert.fail('must not sleep') }).then(
|
|
143
|
+
() => assert.fail('expected failure'),
|
|
144
|
+
(caught) => caught,
|
|
145
|
+
);
|
|
146
|
+
assert.equal(calls, 1);
|
|
147
|
+
assert.equal(error.message, 'request failed');
|
|
148
|
+
let named = 0;
|
|
149
|
+
await registryFetch(async () => { named += 1; throw new DOMException('aborted', 'AbortError'); }, 'https://ghcr.io/token', {}, { sleep: async () => assert.fail('must not sleep') }).catch(() => {});
|
|
150
|
+
assert.equal(named, 1);
|
|
151
|
+
});
|
|
@@ -167,12 +167,15 @@ const FLAT_LEAK_REGEX = new RegExp(
|
|
|
167
167
|
// a relative path from the service source file back up to the workspace root
|
|
168
168
|
// and down into the contracts package. There is no `node_modules/` segment
|
|
169
169
|
// so shapes 1 and 2 above never fire.
|
|
170
|
-
// Capture group 1:
|
|
170
|
+
// Capture group 1: relative path from the scrubbed file to the package directory.
|
|
171
171
|
// Capture group 2: first path segment after dist/ = the subpath export name.
|
|
172
|
+
// The public package name is NOT derived from the directory name or a fixed
|
|
173
|
+
// scope: it is read from the target package's own package.json `name`, so
|
|
174
|
+
// unscoped and differently-named workspace packages resolve correctly.
|
|
172
175
|
// Scoped to paths that pass through `packages/`, `biomes/`, or `kernel/` to
|
|
173
176
|
// avoid false-positive matches on intra-service relative requires.
|
|
174
177
|
const RELATIVE_WORKSPACE_REGEX = new RegExp(
|
|
175
|
-
`require\\("(?:\\.\\./)+(?:packages|biomes|kernel)/(?:[a-z0-9][a-z0-9.-]*/)*
|
|
178
|
+
`require\\("((?:\\.\\./)+(?:packages|biomes|kernel)/(?:[a-z0-9][a-z0-9.-]*/)*[a-z0-9][a-z0-9.-]*)/dist/([a-z0-9][a-z0-9-]*)(?:/[^"]*)?"\\)`,
|
|
176
179
|
'g',
|
|
177
180
|
);
|
|
178
181
|
|
|
@@ -416,6 +419,28 @@ function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
|
|
|
416
419
|
);
|
|
417
420
|
}
|
|
418
421
|
|
|
422
|
+
/**
|
|
423
|
+
* The npm name of the workspace package at `packageDir`, read from the
|
|
424
|
+
* package.json AT that directory. No walk-up: an ancestor's manifest (usually
|
|
425
|
+
* the workspace root) names a different package. Never a fixed scope or the
|
|
426
|
+
* directory name.
|
|
427
|
+
*/
|
|
428
|
+
function packageNameAt(packageDir, original) {
|
|
429
|
+
const manifest = join(packageDir, 'package.json');
|
|
430
|
+
let raw;
|
|
431
|
+
try {
|
|
432
|
+
raw = readFileSync(manifest, 'utf8');
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err;
|
|
435
|
+
throw new ScrubFailure(`${packageDir} has no package.json, so no specifier can be derived for\n ${original}`);
|
|
436
|
+
}
|
|
437
|
+
const { name } = JSON.parse(raw);
|
|
438
|
+
if (typeof name !== 'string' || name === '') {
|
|
439
|
+
throw new ScrubFailure(`${manifest} has no "name", so no specifier can be derived for\n ${original}`);
|
|
440
|
+
}
|
|
441
|
+
return name;
|
|
442
|
+
}
|
|
443
|
+
|
|
419
444
|
async function* walk(dir) {
|
|
420
445
|
let entries;
|
|
421
446
|
try {
|
|
@@ -561,9 +586,10 @@ async function scrubFile(path) {
|
|
|
561
586
|
` rather than reaching through dist/.`,
|
|
562
587
|
);
|
|
563
588
|
}
|
|
564
|
-
out = out.replace(RELATIVE_WORKSPACE_REGEX, (
|
|
589
|
+
out = out.replace(RELATIVE_WORKSPACE_REGEX, (match, relPackageDir, subpath) => {
|
|
565
590
|
count += 1;
|
|
566
|
-
|
|
591
|
+
const name = packageNameAt(resolve(dirname(path), relPackageDir), match);
|
|
592
|
+
return `require("${name}/${subpath}")`;
|
|
567
593
|
});
|
|
568
594
|
if (count > 0) {
|
|
569
595
|
await writeFile(path, out, 'utf8');
|
|
@@ -796,6 +822,59 @@ async function selfTest() {
|
|
|
796
822
|
throw new Error(`self-test: rewritten file still leaks: ${greenAfter}`);
|
|
797
823
|
}
|
|
798
824
|
|
|
825
|
+
// 4b. RELATIVE-WORKSPACE — the rewritten specifier is the target package's
|
|
826
|
+
// own manifest `name`: scoped, unscoped, and name != directory.
|
|
827
|
+
const ws = join(root, 'ws');
|
|
828
|
+
const pkgs = [
|
|
829
|
+
['packages/contracts/scoped-dir', '@xemahq/scoped-dir'],
|
|
830
|
+
['packages/contracts/plain-dir', 'plain-contract'],
|
|
831
|
+
['packages/contracts/other-dir', '@xemahq/renamed'],
|
|
832
|
+
];
|
|
833
|
+
for (const [dir, name] of pkgs) {
|
|
834
|
+
await mkdir(join(ws, dir, 'dist/lib'), { recursive: true });
|
|
835
|
+
await writeFile(join(ws, dir, 'package.json'), JSON.stringify({ name }), 'utf8');
|
|
836
|
+
}
|
|
837
|
+
await mkdir(join(ws, 'services/svc/dist'), { recursive: true });
|
|
838
|
+
const wsFile = join(ws, 'services/svc/dist/dto.js');
|
|
839
|
+
const relOf = (dir) => `../../../${dir}/dist/lib/x`;
|
|
840
|
+
await writeFile(
|
|
841
|
+
wsFile,
|
|
842
|
+
`class Dto {\n static _OPENAPI_METADATA_FACTORY() {\n return { ${pkgs
|
|
843
|
+
.map(([dir], i) => `k${i}: () => require("${relOf(dir)}")`)
|
|
844
|
+
.join(', ')} };\n }\n}\n`,
|
|
845
|
+
'utf8',
|
|
846
|
+
);
|
|
847
|
+
await run([join(ws, 'services/svc/dist')]);
|
|
848
|
+
const wsAfter = await readFile(wsFile, 'utf8');
|
|
849
|
+
for (const expected of ['require("@xemahq/scoped-dir/lib")', 'require("plain-contract/lib")', 'require("@xemahq/renamed/lib")']) {
|
|
850
|
+
if (!wsAfter.includes(expected)) {
|
|
851
|
+
throw new Error(`self-test: relative-workspace rewrite missing ${expected}: ${wsAfter}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// A target directory without its own package.json is a hard failure.
|
|
855
|
+
const orphan = join(root, 'orphan');
|
|
856
|
+
await mkdir(join(orphan, 'svc/dist'), { recursive: true });
|
|
857
|
+
await writeFile(
|
|
858
|
+
join(orphan, 'svc/dist/dto.js'),
|
|
859
|
+
'class D {\n static _OPENAPI_METADATA_FACTORY() {\n return { a: () => require("../../packages/none/dist/lib/x") };\n }\n}\n',
|
|
860
|
+
'utf8',
|
|
861
|
+
);
|
|
862
|
+
await expectFailure('relative workspace target without package.json', () => run([join(orphan, 'svc/dist')]));
|
|
863
|
+
|
|
864
|
+
// An ANCESTOR's package.json must not stand in for the target's own.
|
|
865
|
+
const walkup = join(root, 'walkup');
|
|
866
|
+
await mkdir(join(walkup, 'packages/none/dist/lib'), { recursive: true });
|
|
867
|
+
await mkdir(join(walkup, 'svc/dist'), { recursive: true });
|
|
868
|
+
await writeFile(join(walkup, 'package.json'), JSON.stringify({ name: 'workspace-root' }), 'utf8');
|
|
869
|
+
await writeFile(
|
|
870
|
+
join(walkup, 'svc/dist/dto.js'),
|
|
871
|
+
'class D {\n static _OPENAPI_METADATA_FACTORY() {\n return { a: () => require("../../packages/none/dist/lib/x") };\n }\n}\n',
|
|
872
|
+
'utf8',
|
|
873
|
+
);
|
|
874
|
+
await expectFailure('relative workspace target without package.json but with an ancestor that has one', () =>
|
|
875
|
+
run([join(walkup, 'svc/dist')]),
|
|
876
|
+
);
|
|
877
|
+
|
|
799
878
|
// 5. End-to-end RED — a DOUBLY-nested literal. One `String.replace` pass
|
|
800
879
|
// does not re-scan its own replacement, so the rewrite leaves a literal
|
|
801
880
|
// that is still a leak. This is the case the old script shipped
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-instance concurrency a component declares, read from its manifest.
|
|
3
|
+
*
|
|
4
|
+
* `xema.components[].requirements.scaling.concurrency.maximumPerInstance` is a
|
|
5
|
+
* REQUIRED positive integer in the kernel schema. For a service it is the
|
|
6
|
+
* maximum concurrent requests; for a worker, the maximum concurrent activities
|
|
7
|
+
* per instance. The SDK descriptors carry it as a required field, so the
|
|
8
|
+
* generator refuses a component without it instead of emitting a descriptor
|
|
9
|
+
* that guesses a limit.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @returns {number} the declared positive integer; throws naming manifest and component. */
|
|
13
|
+
export function readMaximumPerInstance(component, manifestRel) {
|
|
14
|
+
const value = component?.requirements?.scaling?.concurrency?.maximumPerInstance;
|
|
15
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`[xema-service-bootstrap] ${manifestRel} component "${component?.key}" must declare ` +
|
|
18
|
+
'`requirements.scaling.concurrency.maximumPerInstance` as a positive integer ' +
|
|
19
|
+
`(found ${JSON.stringify(value)}). It is the component's per-instance concurrency limit.`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The Nexus handler budget of a worker component:
|
|
27
|
+
* `requirements.scaling.concurrency.maximumNexusOperationsPerInstance`, its
|
|
28
|
+
* own term beside `maximumPerInstance`. 0 for a component that provides no
|
|
29
|
+
* Nexus service (the kernel schema refuses the field there). A component that
|
|
30
|
+
* DOES provide one must declare it as a positive integer: there is no default,
|
|
31
|
+
* so the generator refuses it instead of emitting a descriptor that guesses.
|
|
32
|
+
*
|
|
33
|
+
* @returns {number} the declared positive integer, or 0 when no Nexus is provided.
|
|
34
|
+
*/
|
|
35
|
+
export function readMaximumNexusOperationsPerInstance(component, manifestRel) {
|
|
36
|
+
const provides = component?.protocol?.nexus?.provides;
|
|
37
|
+
if (!Array.isArray(provides) || provides.length === 0) {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
const value = component?.requirements?.scaling?.concurrency?.maximumNexusOperationsPerInstance;
|
|
41
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`[xema-service-bootstrap] ${manifestRel} component "${component?.key}" provides a Nexus service and must declare ` +
|
|
44
|
+
'`requirements.scaling.concurrency.maximumNexusOperationsPerInstance` as a positive integer ' +
|
|
45
|
+
`(found ${JSON.stringify(value)}). It is the component's per-instance Nexus handler limit; there is no default.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile optional, generic service protocol metadata for a generated
|
|
3
|
+
* BiomeServiceDescriptor. Protocol identities and operations remain open
|
|
4
|
+
* strings, while endpoint transports are the kernel contract's closed enum.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ENDPOINT_PROTOCOL_MEMBERS = new Map([
|
|
8
|
+
['event-hub', 'EventHub'],
|
|
9
|
+
['grpc', 'Grpc'],
|
|
10
|
+
['http', 'Http'],
|
|
11
|
+
['mcp', 'Mcp'],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const ENUM_EXPRESSION = Symbol('service-endpoint-protocol-expression');
|
|
15
|
+
|
|
16
|
+
function enumExpression(member) {
|
|
17
|
+
return { [ENUM_EXPRESSION]: `ServiceEndpointProtocol.${member}` };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderTypescriptValue(value, depth = 0) {
|
|
21
|
+
if (value && typeof value === 'object' && ENUM_EXPRESSION in value) {
|
|
22
|
+
return value[ENUM_EXPRESSION];
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
if (value.length === 0) return '[]';
|
|
26
|
+
const indentation = ' '.repeat(depth);
|
|
27
|
+
const entries = value
|
|
28
|
+
.map(
|
|
29
|
+
(entry) =>
|
|
30
|
+
`${' '.repeat(depth + 1)}${renderTypescriptValue(entry, depth + 1)}`,
|
|
31
|
+
)
|
|
32
|
+
.join(',\n');
|
|
33
|
+
return `[\n${entries}\n${indentation}]`;
|
|
34
|
+
}
|
|
35
|
+
if (value && typeof value === 'object') {
|
|
36
|
+
const entries = Object.entries(value);
|
|
37
|
+
if (entries.length === 0) return '{}';
|
|
38
|
+
const indentation = ' '.repeat(depth);
|
|
39
|
+
const properties = entries
|
|
40
|
+
.map(
|
|
41
|
+
([key, entry]) =>
|
|
42
|
+
`${' '.repeat(depth + 1)}${JSON.stringify(key)}: ${renderTypescriptValue(entry, depth + 1)}`,
|
|
43
|
+
)
|
|
44
|
+
.join(',\n');
|
|
45
|
+
return `{\n${properties}\n${indentation}}`;
|
|
46
|
+
}
|
|
47
|
+
const literal = JSON.stringify(value);
|
|
48
|
+
if (literal === undefined) {
|
|
49
|
+
throw new TypeError(
|
|
50
|
+
'xema.ships.apis[].protocols must contain JSON values.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return literal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function compileEndpointProtocols(protocols, protocolIndex) {
|
|
57
|
+
if (!Array.isArray(protocols)) {
|
|
58
|
+
throw new TypeError(
|
|
59
|
+
`xema.ships.apis[].protocols[${protocolIndex}].endpointProtocols must be an array.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return protocols.map((protocol, endpointIndex) => {
|
|
63
|
+
const member = ENDPOINT_PROTOCOL_MEMBERS.get(protocol);
|
|
64
|
+
if (member === undefined) {
|
|
65
|
+
throw new TypeError(
|
|
66
|
+
`xema.ships.apis[].protocols[${protocolIndex}].endpointProtocols[${endpointIndex}] ` +
|
|
67
|
+
`has unknown ServiceEndpointProtocol ${JSON.stringify(protocol)}; expected one of ` +
|
|
68
|
+
`${[...ENDPOINT_PROTOCOL_MEMBERS.keys()].join(' | ')}.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return enumExpression(member);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderServiceProtocols(protocols) {
|
|
76
|
+
if (
|
|
77
|
+
protocols === undefined ||
|
|
78
|
+
(Array.isArray(protocols) && protocols.length === 0)
|
|
79
|
+
) {
|
|
80
|
+
return { field: '', imports: [] };
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(protocols)) {
|
|
83
|
+
throw new TypeError('xema.ships.apis[].protocols must be an array.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let importsEndpointProtocol = false;
|
|
87
|
+
const compiled = protocols.map((protocol, protocolIndex) => {
|
|
88
|
+
if (
|
|
89
|
+
protocol === null ||
|
|
90
|
+
typeof protocol !== 'object' ||
|
|
91
|
+
Array.isArray(protocol)
|
|
92
|
+
) {
|
|
93
|
+
return protocol;
|
|
94
|
+
}
|
|
95
|
+
if (!Object.hasOwn(protocol, 'endpointProtocols')) return protocol;
|
|
96
|
+
const endpointProtocols = compileEndpointProtocols(
|
|
97
|
+
protocol.endpointProtocols,
|
|
98
|
+
protocolIndex,
|
|
99
|
+
);
|
|
100
|
+
if (endpointProtocols.length > 0) importsEndpointProtocol = true;
|
|
101
|
+
return { ...protocol, endpointProtocols };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
field: `\n protocols: ${renderTypescriptValue(compiled, 1)},`,
|
|
106
|
+
imports: importsEndpointProtocol ? ['ServiceEndpointProtocol'] : [],
|
|
107
|
+
};
|
|
108
|
+
}
|