@xemahq/biome-bundle-pack 0.0.0-stage → 0.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.
- package/LICENSE +175 -0
- package/README.md +15 -2
- package/package.json +52 -4
- package/src/biome-content.mjs +49 -0
- package/src/bundle-archive.mjs +140 -0
- package/src/bundle-archive.test.mjs +97 -0
- package/src/bundle-build-source.mjs +58 -0
- package/src/bundle-build-source.test.mjs +53 -0
- package/src/bundle-content.mjs +30 -0
- package/src/bundle-manifest.mjs +27 -0
- package/src/bundle-unpack.mjs +114 -0
- package/src/bundle-unpack.test.mjs +63 -0
- package/src/canonical-json.mjs +9 -0
- package/src/host-provided.mjs +160 -0
- package/src/host-provided.test.mjs +128 -0
- package/src/member-required-env.mjs +32 -0
- package/src/pack-directory.mjs +148 -0
- package/src/pack-directory.test.mjs +144 -0
- package/src/seal-bundle.mjs +38 -0
- package/src/stage.mjs +60 -0
- package/src/stage.test.mjs +36 -0
- package/src/target-platform.mjs +35 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// `packBiomeDirectory`: a BUILT biome directory packed as one content-addressed bundle, the same bundle format the
|
|
2
|
+
// repository pipeline writes (`seal-bundle.mjs`), so a host loads either alike. The user builds; this stages and packs.
|
|
3
|
+
//
|
|
4
|
+
// The directory is one standalone project: its `xema-biome.json` (the biome manifest), `package.json` and `pnpm-lock.yaml`
|
|
5
|
+
// at its root, its build outputs where its components' entrypoints name them. Staging copies it (minus `node_modules` and
|
|
6
|
+
// `.git`) outside the directory, installs its production closure for the target platform from the frozen lockfile and cuts
|
|
7
|
+
// the host-provided packages out (`stage.mjs`). What is recorded:
|
|
8
|
+
// - members: every service and worker component, `entry` the module its entrypoint names (`node-module`) or `dist/main.js`;
|
|
9
|
+
// a member that owns a database key and a `prisma/migrations` directory carries those migrations;
|
|
10
|
+
// - hostAbi: the versions of the host-provided packages the biome's own install resolved. A biome whose install resolves
|
|
11
|
+
// no host-provided SDK or no kernel contracts declares no host ABI and is refused; there is no override;
|
|
12
|
+
// - build: `deriveBiomeBuildFacts` over the biome's own manifest;
|
|
13
|
+
// - buildSource: the git checkout's origin and HEAD (a dirty tree is fine: the content digest names the actual bytes),
|
|
14
|
+
// or `buildSource` when given; a directory that is no git checkout is refused unless it is given.
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join, posix, relative, resolve } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { parseBiomeManifest, isServerBiomeManifest, BiomeComponentEntrypointKind, BiomeComponentKind, BiomeComponentProtocolKind } from '@xemahq/contracts/biome';
|
|
21
|
+
import { KERNEL_CONTRACTS_PACKAGE, deriveBiomeBuildFacts } from '@xemahq/contracts/biome-bundle';
|
|
22
|
+
import { NODE_COMPOSED_HOST_ABI_ID, hostProvidedPackages } from '@xemahq/host-abi-node';
|
|
23
|
+
|
|
24
|
+
import { copyBiomeContent } from './biome-content.mjs';
|
|
25
|
+
import { resolveBuildSource } from './bundle-build-source.mjs';
|
|
26
|
+
import { memberEntryFacts } from './member-required-env.mjs';
|
|
27
|
+
import { sealBundle } from './seal-bundle.mjs';
|
|
28
|
+
import { stageBundle, standaloneInstall } from './stage.mjs';
|
|
29
|
+
import { currentPlatform, platformArch } from './target-platform.mjs';
|
|
30
|
+
|
|
31
|
+
export const BIOME_MANIFEST_FILE = 'xema-biome.json';
|
|
32
|
+
const LOCKFILE = 'pnpm-lock.yaml';
|
|
33
|
+
const DEFAULT_ENTRY = 'dist/main.js';
|
|
34
|
+
const MIGRATIONS_DIRECTORY = 'prisma/migrations';
|
|
35
|
+
const PRISMA_MIGRATIONS_ENGINE = 'prisma';
|
|
36
|
+
const NOT_COPIED = new Set(['node_modules', '.git']);
|
|
37
|
+
const WORK_PREFIX = 'xema-pack-dir-';
|
|
38
|
+
|
|
39
|
+
/** `exec` for the build source's git calls: spawnSync-shaped `{ status, stdout }`; a command that cannot start is an error. */
|
|
40
|
+
function gitExec(file, args, { cwd }) {
|
|
41
|
+
const result = spawnSync(file, args, { cwd, encoding: 'utf8' });
|
|
42
|
+
if (result.error) {throw new Error(`cannot run ${file}: ${result.error.message}`, { cause: result.error });}
|
|
43
|
+
return { status: result.status ?? 1, stdout: result.stdout };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The module a component's entrypoint names, relative to the directory: `node-module` names it (a missing extension is `.js`); everything else runs `dist/main.js`. */
|
|
47
|
+
export function memberEntry(component) {
|
|
48
|
+
const { entrypoint } = component;
|
|
49
|
+
if (entrypoint.kind !== BiomeComponentEntrypointKind.NodeModule) {return DEFAULT_ENTRY;}
|
|
50
|
+
return posix.extname(entrypoint.module) === '' ? `${entrypoint.module}.js` : entrypoint.module;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function memberComponents(manifest) {
|
|
54
|
+
return manifest.xema.components.filter((component) => component.kind === BiomeComponentKind.Service || component.kind === BiomeComponentKind.Worker);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The migrations a member ships: only a service that owns a database key and a `prisma/migrations` directory with SQL in every migration. */
|
|
58
|
+
function memberMigrations({ stage, component }) {
|
|
59
|
+
const owns = component.protocol.kind === BiomeComponentProtocolKind.Http && component.protocol.databaseKey !== undefined;
|
|
60
|
+
const directory = join(stage, MIGRATIONS_DIRECTORY);
|
|
61
|
+
if (!owns || !existsSync(directory)) {return undefined;}
|
|
62
|
+
const names = readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
if (!existsSync(join(directory, name, 'migration.sql'))) {throw new Error(`${MIGRATIONS_DIRECTORY}/${name} has no migration.sql`);}
|
|
65
|
+
}
|
|
66
|
+
return names.length === 0 ? undefined : { engine: PRISMA_MIGRATIONS_ENGINE, path: MIGRATIONS_DIRECTORY };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readManifest(dir) {
|
|
70
|
+
const file = join(dir, BIOME_MANIFEST_FILE);
|
|
71
|
+
if (!existsSync(file)) {throw new Error(`${dir} has no ${BIOME_MANIFEST_FILE}: it is not a biome directory`);}
|
|
72
|
+
const manifest = parseBiomeManifest(JSON.parse(readFileSync(file, 'utf8')));
|
|
73
|
+
if (!isServerBiomeManifest(manifest)) {throw new Error(`${file} is a ${manifest.xema.target} biome: only a server biome is packed as a bundle`);}
|
|
74
|
+
return manifest;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `{ id, kernelContracts, identityPackages }` from the versions the biome's own install resolved; refuses a biome that resolves no host ABI. */
|
|
78
|
+
function hostAbiOf({ hostVersions, hostAbiId }) {
|
|
79
|
+
const kernelContracts = hostVersions[KERNEL_CONTRACTS_PACKAGE];
|
|
80
|
+
const identityPackages = Object.fromEntries(Object.entries(hostVersions).filter(([name]) => name !== KERNEL_CONTRACTS_PACKAGE));
|
|
81
|
+
if (kernelContracts === undefined || Object.keys(identityPackages).length === 0) {
|
|
82
|
+
throw new Error(`the biome declares no host ABI: its install resolves ${Object.keys(hostVersions).join(', ') || 'none of the host-provided packages'}, but a bundle for the ${hostAbiId} host needs ${KERNEL_CONTRACTS_PACKAGE} and the host SDK among its dependencies`);
|
|
83
|
+
}
|
|
84
|
+
return { id: hostAbiId, kernelContracts, identityPackages };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function copyDirectory({ dir, stage, out }) {
|
|
88
|
+
const skipped = resolve(out);
|
|
89
|
+
cpSync(dir, stage, {
|
|
90
|
+
recursive: true,
|
|
91
|
+
verbatimSymlinks: true,
|
|
92
|
+
filter: (source) => !NOT_COPIED.has(relative(dir, source).split('/')[0]) && resolve(source) !== skipped,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Pack the built biome directory `dir` as one bundle under `out`. `platform` is `linux/amd64` or `linux/arm64` (default: this
|
|
98
|
+
* machine's arch). Returns `{ biome, members, archiveDigest, contentDigest, directory, archive }`.
|
|
99
|
+
* TEST SEAMS: `install` replaces the production install, `exec` the git calls of the build source, `captureMember` the capture of each
|
|
100
|
+
* member's entry (`({ packageRoot, entryPath }) => { requiredEnvVars: string[], kind: 'http'|'worker' }`, read from the user's own installed build in `dir`).
|
|
101
|
+
*/
|
|
102
|
+
export async function packBiomeDirectory({ dir, out, platform = currentPlatform(), buildSource, hostAbiId = NODE_COMPOSED_HOST_ABI_ID, install, exec = gitExec, captureMember = memberEntryFacts, log = () => undefined }) {
|
|
103
|
+
const manifest = readManifest(dir);
|
|
104
|
+
const arch = platformArch(platform);
|
|
105
|
+
const components = memberComponents(manifest);
|
|
106
|
+
if (components.length === 0) {throw new Error(`${join(dir, BIOME_MANIFEST_FILE)} declares no service or worker component: there is nothing to pack`);}
|
|
107
|
+
if (!existsSync(join(dir, LOCKFILE))) {
|
|
108
|
+
throw new Error(`${dir} has no ${LOCKFILE}: a biome directory is packed from its frozen lockfile (run pnpm install in it, and commit the lockfile)`);
|
|
109
|
+
}
|
|
110
|
+
const source = resolveBuildSource({ root: dir, explicit: buildSource, exec, allowDirty: true });
|
|
111
|
+
const work = mkdtempSync(join(tmpdir(), WORK_PREFIX));
|
|
112
|
+
try {
|
|
113
|
+
const stage = join(work, 'bundle');
|
|
114
|
+
mkdirSync(stage);
|
|
115
|
+
copyDirectory({ dir, stage, out });
|
|
116
|
+
const entries = components.map((component) => ({ component, entry: memberEntry(component) }));
|
|
117
|
+
const missing = entries.filter(({ entry }) => !existsSync(join(stage, entry)));
|
|
118
|
+
if (missing.length > 0) {
|
|
119
|
+
throw new Error(`build the biome first: ${missing.map(({ component, entry }) => `${component.key} runs ${entry}, which does not exist`).join('; ')}`);
|
|
120
|
+
}
|
|
121
|
+
const { hostVersions } = await stageBundle({
|
|
122
|
+
stage,
|
|
123
|
+
memberDirectories: ['.'],
|
|
124
|
+
hostProvided: hostProvidedPackages(hostAbiId),
|
|
125
|
+
install: install ?? standaloneInstall({ arch }),
|
|
126
|
+
});
|
|
127
|
+
const contributionKinds = copyBiomeContent({ root: dir, stage, manifests: [BIOME_MANIFEST_FILE] });
|
|
128
|
+
const members = entries
|
|
129
|
+
.map(({ component, entry }) => ({ member: component.key, entry, migrations: memberMigrations({ stage, component }), ...captureMember({ packageRoot: dir, entryPath: join(dir, entry) }) }))
|
|
130
|
+
.sort((a, b) => (a.member < b.member ? -1 : 1));
|
|
131
|
+
const sealed = sealBundle({
|
|
132
|
+
stage,
|
|
133
|
+
out,
|
|
134
|
+
manifest: {
|
|
135
|
+
biome: manifest.xema.id,
|
|
136
|
+
members,
|
|
137
|
+
buildSource: source,
|
|
138
|
+
hostAbi: hostAbiOf({ hostVersions, hostAbiId }),
|
|
139
|
+
contributionKinds,
|
|
140
|
+
build: deriveBiomeBuildFacts(manifest),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
log(`packed ${manifest.xema.id} (${members.length} member(s)): ${sealed.archiveDigest}`);
|
|
144
|
+
return { biome: manifest.xema.id, members: members.map(({ member }) => member), ...sealed };
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(work, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test, { after } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { BiomeBundleManifestSchema } from '@xemahq/contracts/biome-bundle';
|
|
8
|
+
|
|
9
|
+
import { memberEntryFacts } from './member-required-env.mjs';
|
|
10
|
+
import { memberEntry, packBiomeDirectory } from './pack-directory.mjs';
|
|
11
|
+
|
|
12
|
+
const AUDIT = path.join(import.meta.dirname, '..', '..', '..', '..', 'biomes', 'audit', 'xema-biome.json');
|
|
13
|
+
const SOURCE = `acme/biome@${'a'.repeat(40)}`;
|
|
14
|
+
const temps = [];
|
|
15
|
+
after(() => {
|
|
16
|
+
for (const dir of temps) {fs.rmSync(dir, { recursive: true, force: true });}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function temp(prefix) {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
21
|
+
temps.push(dir);
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A built biome directory: the audit biome's manifest, `dist/main.js`, a package.json and a lockfile. */
|
|
26
|
+
function biomeDirectory({ lockfile = true, built = true } = {}) {
|
|
27
|
+
const dir = temp('xema-biome-dir-');
|
|
28
|
+
fs.copyFileSync(AUDIT, path.join(dir, 'xema-biome.json'));
|
|
29
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'acme-biome', version: '1.0.0' }));
|
|
30
|
+
if (lockfile) {fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n");}
|
|
31
|
+
if (built) {
|
|
32
|
+
fs.mkdirSync(path.join(dir, 'dist'));
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'dist', 'main.js'), 'module.exports = {};\n');
|
|
34
|
+
}
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The install seam: a pnpm tree holding the host-provided packages and one ordinary dependency. */
|
|
39
|
+
function fakeInstall({ withHost = true } = {}) {
|
|
40
|
+
return ({ treeRoot }) => {
|
|
41
|
+
const entries = [['left-pad', '1.3.0'], ...(withHost ? [['@xemahq/contracts', '3.2.1'], ['@xemahq/xema-service-nest', '0.9.0'], ['@nestjs/common', '11.0.0']] : [])];
|
|
42
|
+
for (const [name, version] of entries) {
|
|
43
|
+
const pkg = path.join(treeRoot, 'node_modules', '.pnpm', `${name.replace('/', '+')}@${version}`, 'node_modules', name);
|
|
44
|
+
fs.mkdirSync(pkg, { recursive: true });
|
|
45
|
+
fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name, version }));
|
|
46
|
+
fs.mkdirSync(path.join(treeRoot, 'node_modules', path.dirname(name)), { recursive: true });
|
|
47
|
+
const link = path.join(treeRoot, 'node_modules', name);
|
|
48
|
+
fs.symlinkSync(path.relative(path.dirname(link), pkg), link, 'dir');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pack = (dir, options = {}) => packBiomeDirectory({ dir, out: temp('xema-out-'), platform: 'linux/amd64', buildSource: SOURCE, install: fakeInstall(), captureMember: () => ({ requiredEnvVars: ['JWT_ISSUER', 'DATABASE_URL'], kind: 'http' }), ...options });
|
|
54
|
+
|
|
55
|
+
test('a built biome directory packs to a bundle the contracts accept, carrying its build facts and host ABI', async () => {
|
|
56
|
+
const packed = await pack(biomeDirectory());
|
|
57
|
+
assert.equal(packed.biome, 'audit');
|
|
58
|
+
assert.deepEqual(packed.members, ['audit-log-api']);
|
|
59
|
+
assert.match(packed.archiveDigest, /^sha256:[0-9a-f]{64}$/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('the manifest names the members, the host ABI the install resolved and the build facts', async () => {
|
|
63
|
+
const out = temp('xema-out-');
|
|
64
|
+
const packed = await pack(biomeDirectory(), { out });
|
|
65
|
+
const manifest = BiomeBundleManifestSchema.parse(JSON.parse(fs.readFileSync(path.join(out, packed.directory, 'xema-bundle.json'), 'utf8')));
|
|
66
|
+
assert.deepEqual(manifest.members, [{ member: 'audit-log-api', entry: 'dist/main.js', requiredEnvVars: ['JWT_ISSUER', 'DATABASE_URL'], kind: 'http' }]);
|
|
67
|
+
assert.deepEqual(manifest.hostAbi, { id: 'node-composed', kernelContracts: '3.2.1', identityPackages: { '@nestjs/common': '11.0.0', '@xemahq/xema-service-nest': '0.9.0' } });
|
|
68
|
+
assert.deepEqual(manifest.build.services, ['audit-log-api']);
|
|
69
|
+
assert.equal(manifest.buildSource.repository, 'acme/biome');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('host-provided packages are cut out of the bundle and ordinary ones stay', async () => {
|
|
73
|
+
const out = temp('xema-out-');
|
|
74
|
+
const packed = await pack(biomeDirectory(), { out });
|
|
75
|
+
const modules = path.join(out, packed.directory, 'node_modules');
|
|
76
|
+
assert.ok(fs.existsSync(path.join(modules, 'left-pad')));
|
|
77
|
+
assert.ok(!fs.existsSync(path.join(modules, '@xemahq/contracts')));
|
|
78
|
+
assert.ok(!fs.existsSync(path.join(modules, '@nestjs/common')));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('the same directory packs to the same digest', async () => {
|
|
82
|
+
const dir = biomeDirectory();
|
|
83
|
+
assert.equal((await pack(dir)).archiveDigest, (await pack(dir)).archiveDigest);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a directory without a lockfile is refused', async () => {
|
|
87
|
+
await assert.rejects(pack(biomeDirectory({ lockfile: false })), /has no pnpm-lock\.yaml/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('an unbuilt directory is refused, naming the missing entry', async () => {
|
|
91
|
+
await assert.rejects(pack(biomeDirectory({ built: false })), /build the biome first: audit-log-api runs dist\/main\.js/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a biome whose install resolves no host ABI is refused', async () => {
|
|
95
|
+
await assert.rejects(pack(biomeDirectory(), { install: fakeInstall({ withHost: false }) }), /declares no host ABI/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a directory that is no git checkout is refused unless a build source is given', async () => {
|
|
99
|
+
await assert.rejects(pack(biomeDirectory(), { buildSource: undefined, exec: () => ({ status: 128, stdout: '' }) }), /not a git checkout/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a dirty checkout is packed: the content digest names the bytes', async () => {
|
|
103
|
+
const exec = (file, args) => ({ status: 0, stdout: args[0] === 'rev-parse' ? 'b'.repeat(40) : args[0] === 'remote' ? 'git@github.com:acme/biome.git' : ' M dist/main.js\n' });
|
|
104
|
+
const out = temp('xema-out-');
|
|
105
|
+
const packed = await pack(biomeDirectory(), { buildSource: undefined, exec, out });
|
|
106
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(out, packed.directory, 'xema-bundle.json'), 'utf8'));
|
|
107
|
+
assert.deepEqual(manifest.buildSource, { repository: 'acme/biome', commit: 'b'.repeat(40) });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('memberEntry reads a node-module entrypoint and defaults to dist/main.js', () => {
|
|
111
|
+
assert.equal(memberEntry({ entrypoint: { kind: 'node-module', module: 'dist/worker' } }), 'dist/worker.js');
|
|
112
|
+
assert.equal(memberEntry({ entrypoint: { kind: 'node-module', module: 'dist/worker.js' } }), 'dist/worker.js');
|
|
113
|
+
assert.equal(memberEntry({ entrypoint: { kind: 'oci-default' } }), 'dist/main.js');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a worker entry is packed with kind "worker", and the manifest the contracts accept carries it', async () => {
|
|
117
|
+
const out = temp('xema-out-');
|
|
118
|
+
const packed = await pack(biomeDirectory(), { out, captureMember: () => ({ requiredEnvVars: ['TEMPORAL_ADDRESS'], kind: 'worker' }) });
|
|
119
|
+
const manifest = BiomeBundleManifestSchema.parse(JSON.parse(fs.readFileSync(path.join(out, packed.directory, 'xema-bundle.json'), 'utf8')));
|
|
120
|
+
assert.deepEqual(manifest.members, [{ member: 'audit-log-api', entry: 'dist/main.js', requiredEnvVars: ['TEMPORAL_ADDRESS'], kind: 'worker' }]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** A package whose installed `@xemahq/xema-service-nest` captures `captured` for any entry. */
|
|
124
|
+
function memberWithSdk(captured) {
|
|
125
|
+
const root = temp('xema-member-');
|
|
126
|
+
const sdk = path.join(root, 'node_modules', '@xemahq', 'xema-service-nest');
|
|
127
|
+
fs.mkdirSync(sdk, { recursive: true });
|
|
128
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'member' }));
|
|
129
|
+
fs.writeFileSync(path.join(sdk, 'package.json'), JSON.stringify({ name: '@xemahq/xema-service-nest', main: 'index.js' }));
|
|
130
|
+
fs.writeFileSync(path.join(sdk, 'index.js'), `exports.readServiceEntry = () => (${JSON.stringify(captured)});\n`);
|
|
131
|
+
return root;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('one capture yields the required names and the kind of the entry', () => {
|
|
135
|
+
const worker = memberWithSdk({ entry: { kind: 'worker', options: { requiredEnvVars: ['B_NAME', 'A_NAME', 'A_NAME'] } }, envWrites: {} });
|
|
136
|
+
assert.deepEqual(memberEntryFacts({ packageRoot: worker, entryPath: 'main.js' }), { requiredEnvVars: ['A_NAME', 'B_NAME'], kind: 'worker' });
|
|
137
|
+
const http = memberWithSdk({ entry: { kind: 'service', options: {} }, envWrites: {} });
|
|
138
|
+
assert.deepEqual(memberEntryFacts({ packageRoot: http, entryPath: 'main.js' }), { requiredEnvVars: [], kind: 'http' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('an entry whose captured kind is unknown is refused, never read as http', () => {
|
|
142
|
+
const unknown = memberWithSdk({ entry: { kind: 'cron', options: {} }, envWrites: {} });
|
|
143
|
+
assert.throws(() => memberEntryFacts({ packageRoot: unknown, entryPath: 'main.js' }), /unknown captured entry kind "cron"/);
|
|
144
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Seal a staged bundle tree: write its manifest (the content digest covers every file but the manifest), archive it
|
|
2
|
+
// canonically and write `<out>/<hex>/` and `<out>/<hex>.tgz`, `<hex>` being the sha256 of the archive bytes.
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { BIOME_BUNDLE_MANIFEST_FILE } from '@xemahq/contracts/biome-bundle';
|
|
7
|
+
|
|
8
|
+
import { archiveDigest, canonicalTar, gzipCanonical } from './bundle-archive.mjs';
|
|
9
|
+
import { bundleContentDigest } from './bundle-content.mjs';
|
|
10
|
+
import { bundleManifest } from './bundle-manifest.mjs';
|
|
11
|
+
|
|
12
|
+
/** Write `bytes` to `path` through a sibling file and a rename, so a concurrent reader never sees half of it. */
|
|
13
|
+
function writeAtomic(path, bytes) {
|
|
14
|
+
const partial = `${path}.partial-${process.pid}`;
|
|
15
|
+
writeFileSync(partial, bytes);
|
|
16
|
+
renameSync(partial, path);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `manifest` is `bundleManifest`'s input without `contentDigest`. Returns `{ archiveDigest, contentDigest, directory, archive }`
|
|
21
|
+
* (`directory` and `archive` are names under `out`).
|
|
22
|
+
*/
|
|
23
|
+
export function sealBundle({ stage, out, manifest }) {
|
|
24
|
+
const contentDigest = bundleContentDigest(stage);
|
|
25
|
+
writeFileSync(join(stage, BIOME_BUNDLE_MANIFEST_FILE), bundleManifest({ ...manifest, contentDigest }));
|
|
26
|
+
const archiveBytes = gzipCanonical(canonicalTar(stage));
|
|
27
|
+
const digest = archiveDigest(archiveBytes);
|
|
28
|
+
const directory = digest.slice('sha256:'.length);
|
|
29
|
+
const archive = `${directory}.tgz`;
|
|
30
|
+
mkdirSync(out, { recursive: true });
|
|
31
|
+
if (!existsSync(join(out, directory))) {
|
|
32
|
+
const partial = join(out, `${directory}.partial-${process.pid}`);
|
|
33
|
+
cpSync(stage, partial, { recursive: true, verbatimSymlinks: true });
|
|
34
|
+
renameSync(partial, join(out, directory));
|
|
35
|
+
}
|
|
36
|
+
writeAtomic(join(out, archive), archiveBytes);
|
|
37
|
+
return { archiveDigest: digest, contentDigest, directory, archive };
|
|
38
|
+
}
|
package/src/stage.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// `stageBundle`: the ONE staging of a bundle's tree, used by the repository pipeline and by the `xema` CLI. The tree
|
|
2
|
+
// already holds the biome's build outputs; staging installs its production closure, drops what a host must not receive
|
|
3
|
+
// twice and returns the versions of the host-provided packages it resolved.
|
|
4
|
+
// 1. `install({ treeRoot })`: the caller's production install (the repository pipeline runs its workspace re-resolve,
|
|
5
|
+
// `installProduction` below installs a standalone project);
|
|
6
|
+
// 2. pnpm's own bookkeeping goes (it records the runner's store path and timestamps, which differ per host);
|
|
7
|
+
// 3. the host-provided versions are read from the install, then the host-provided packages are cut out
|
|
8
|
+
// (`host-provided.mjs`).
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { excludeHostProvided, pnpmEntryName } from './host-provided.mjs';
|
|
14
|
+
import { platformInstallFlags } from './target-platform.mjs';
|
|
15
|
+
|
|
16
|
+
const PNPM_STATE_FILES = Object.freeze(['.modules.yaml', '.pnpm-workspace-state-v1.json']);
|
|
17
|
+
|
|
18
|
+
/** The version of each `hostProvided` package the install put in `<treeRoot>/node_modules/.pnpm`: `{ <name>: <version> }`, sorted by name. */
|
|
19
|
+
export function installedHostVersions({ treeRoot, hostProvided }) {
|
|
20
|
+
const pnpmDir = join(treeRoot, 'node_modules', '.pnpm');
|
|
21
|
+
const versions = {};
|
|
22
|
+
if (!existsSync(pnpmDir)) {return versions;}
|
|
23
|
+
for (const entry of readdirSync(pnpmDir).sort()) {
|
|
24
|
+
const name = pnpmEntryName(entry);
|
|
25
|
+
if (name === undefined || !hostProvided.includes(name)) {continue;}
|
|
26
|
+
const manifest = join(pnpmDir, entry, 'node_modules', name, 'package.json');
|
|
27
|
+
if (existsSync(manifest)) {versions[name] ??= JSON.parse(readFileSync(manifest, 'utf8')).version;}
|
|
28
|
+
}
|
|
29
|
+
return Object.fromEntries(Object.entries(versions).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Stage the tree at `stage` for a bundle whose members live in `memberDirectories` (paths relative to `stage`).
|
|
34
|
+
* Returns `{ hostVersions, exclusion }`.
|
|
35
|
+
*/
|
|
36
|
+
export async function stageBundle({ stage, memberDirectories, hostProvided, install }) {
|
|
37
|
+
await install({ treeRoot: stage });
|
|
38
|
+
for (const state of PNPM_STATE_FILES) {rmSync(join(stage, 'node_modules', state), { force: true });}
|
|
39
|
+
const hostVersions = installedHostVersions({ treeRoot: stage, hostProvided });
|
|
40
|
+
const exclusion = excludeHostProvided({ treeRoot: stage, memberDirectories, hostProvided });
|
|
41
|
+
return { hostVersions, exclusion };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, stdio: 'inherit' });
|
|
45
|
+
|
|
46
|
+
/** The production install of a standalone project for `linux/<arch>` as `[file, ...args]`: frozen lockfile, no scripts. */
|
|
47
|
+
export const productionInstallCommand = (arch) => [
|
|
48
|
+
'pnpm', 'install', '--prod', '--prefer-offline', '--ignore-scripts', '--frozen-lockfile', ...platformInstallFlags(arch),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The install of a standalone biome project (its own `package.json` and `pnpm-lock.yaml` at `treeRoot`). `--prefer-offline`,
|
|
53
|
+
* not `--offline`: a developer's store may be cold. The lockfile is frozen, so what is installed is what it pins.
|
|
54
|
+
*/
|
|
55
|
+
export function standaloneInstall({ arch, exec = defaultExec }) {
|
|
56
|
+
return ({ treeRoot }) => {
|
|
57
|
+
const [file, ...args] = productionInstallCommand(arch);
|
|
58
|
+
exec(file, args, { cwd: treeRoot });
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { productionInstallCommand, stageBundle } from './stage.mjs';
|
|
8
|
+
import { platformInstallFlags } from './target-platform.mjs';
|
|
9
|
+
|
|
10
|
+
test('the standalone install is frozen, scriptless and selected for the target platform', () => {
|
|
11
|
+
const command = productionInstallCommand('arm64');
|
|
12
|
+
for (const word of ['--prod', '--frozen-lockfile', '--ignore-scripts', '--prefer-offline', '--os=linux', '--cpu=arm64', '--libc=glibc']) {assert.ok(command.includes(word), word);}
|
|
13
|
+
assert.ok(!command.includes('--offline'));
|
|
14
|
+
assert.deepEqual(platformInstallFlags('amd64'), ['--os=linux', '--cpu=x64', '--libc=glibc']);
|
|
15
|
+
assert.throws(() => platformInstallFlags('riscv'), /no target platform/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('stageBundle drops pnpm state and reports the host versions it cut out', async () => {
|
|
19
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-stage-'));
|
|
20
|
+
try {
|
|
21
|
+
const pkg = path.join(stage, 'node_modules', '.pnpm', '@nestjs+common@11.0.0', 'node_modules', '@nestjs', 'common');
|
|
22
|
+
fs.mkdirSync(pkg, { recursive: true });
|
|
23
|
+
fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ version: '11.0.0' }));
|
|
24
|
+
const result = await stageBundle({
|
|
25
|
+
stage,
|
|
26
|
+
memberDirectories: ['.'],
|
|
27
|
+
hostProvided: ['@nestjs/common'],
|
|
28
|
+
install: ({ treeRoot }) => fs.writeFileSync(path.join(treeRoot, 'node_modules', '.modules.yaml'), 'x'),
|
|
29
|
+
});
|
|
30
|
+
assert.deepEqual(result.hostVersions, { '@nestjs/common': '11.0.0' });
|
|
31
|
+
assert.ok(!fs.existsSync(path.join(stage, 'node_modules', '.modules.yaml')));
|
|
32
|
+
assert.ok(!fs.existsSync(path.join(stage, 'node_modules', '.pnpm', '@nestjs+common@11.0.0')));
|
|
33
|
+
} finally {
|
|
34
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The target platforms a bundle's install is selected for, keyed by Docker's `TARGETARCH`. Loads only `node:` builtins.
|
|
2
|
+
// A TWIN of the arch rows in repo-build-tooling's `service-image-platform.mjs` (`IMAGE_PLATFORMS`): that file is mounted
|
|
3
|
+
// into the build image beside the in-image entries, which may import only relative modules, so it cannot import this
|
|
4
|
+
// package. `service-image-platform-twin.test.mjs` in repo-build-tooling runs the same vectors through both copies; change
|
|
5
|
+
// both together.
|
|
6
|
+
|
|
7
|
+
/** `node` is Node's `process.arch`, what pnpm's `--cpu` takes; `libc` is the runtime base image's. */
|
|
8
|
+
export const TARGET_PLATFORMS = Object.freeze({
|
|
9
|
+
amd64: Object.freeze({ node: 'x64', libc: 'glibc' }),
|
|
10
|
+
arm64: Object.freeze({ node: 'arm64', libc: 'glibc' }),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** `linux/<arch>` → `<arch>`; refuses any platform this recipe does not build. */
|
|
14
|
+
export function platformArch(platform) {
|
|
15
|
+
const match = /^linux\/([a-z0-9]+)$/.exec(String(platform ?? ''));
|
|
16
|
+
if (match === null || !Object.hasOwn(TARGET_PLATFORMS, match[1])) {
|
|
17
|
+
throw new Error(`--platform must be one of ${Object.keys(TARGET_PLATFORMS).map((a) => `linux/${a}`).join(', ')}, got '${platform}'`);
|
|
18
|
+
}
|
|
19
|
+
return match[1];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The `linux/<arch>` of this Node process; refuses an arch with no row. */
|
|
23
|
+
export function currentPlatform(nodeArch = process.arch) {
|
|
24
|
+
const arch = Object.keys(TARGET_PLATFORMS).find((key) => TARGET_PLATFORMS[key].node === nodeArch);
|
|
25
|
+
if (arch === undefined) {throw new Error(`Node arch '${nodeArch}' is not a platform bundles are built for`);}
|
|
26
|
+
return `linux/${arch}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The pnpm `--os/--cpu/--libc` words that select the packages for `linux/<arch>`. */
|
|
30
|
+
export function platformInstallFlags(arch) {
|
|
31
|
+
if (!Object.hasOwn(TARGET_PLATFORMS, arch)) {
|
|
32
|
+
throw new Error(`no target platform for arch '${arch}'; known: ${Object.keys(TARGET_PLATFORMS).join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
return ['--os=linux', `--cpu=${TARGET_PLATFORMS[arch].node}`, `--libc=${TARGET_PLATFORMS[arch].libc}`];
|
|
35
|
+
}
|