@hypequery/cli 1.5.0 → 1.7.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/README.md +27 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -5
- package/dist/commands/deployment.d.ts +3 -0
- package/dist/commands/deployment.d.ts.map +1 -1
- package/dist/commands/deployment.js +145 -10
- package/dist/utils/deployment-bundle.d.ts +19 -0
- package/dist/utils/deployment-bundle.d.ts.map +1 -0
- package/dist/utils/deployment-bundle.js +322 -0
- package/dist/utils/deployment-runtime-artifact.d.ts +9 -0
- package/dist/utils/deployment-runtime-artifact.d.ts.map +1 -0
- package/dist/utils/deployment-runtime-artifact.js +142 -0
- package/dist/utils/load-api.d.ts +1 -0
- package/dist/utils/load-api.d.ts.map +1 -1
- package/dist/utils/load-api.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -138,35 +138,48 @@ semantic keys such as `dataset:orders`.
|
|
|
138
138
|
|
|
139
139
|
### `hypequery deployment:build`
|
|
140
140
|
|
|
141
|
-
Builds deployment
|
|
142
|
-
|
|
141
|
+
Builds a closed deployment bundle for an exported HypeQuery API. The bundle
|
|
142
|
+
contains canonical deployment metadata, every referenced runtime artifact, and
|
|
143
|
+
a manifest that binds their exact bytes and identities.
|
|
143
144
|
|
|
144
145
|
```bash
|
|
145
|
-
npx hypequery deployment:build analytics/api.ts
|
|
146
|
-
--runtime-artifact 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
146
|
+
npx hypequery deployment:build analytics/api.ts
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
The
|
|
150
|
-
|
|
149
|
+
The default output is `analytics/hypequery-deployment/`. Named Serve handlers
|
|
150
|
+
are bundled into a Node runtime artifact automatically. Dataset-only APIs do
|
|
151
|
+
not produce a runtime artifact. For a separately built Node or Python runtime,
|
|
152
|
+
provide both its expected digest and file path:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npx hypequery deployment:build analytics/api.ts \
|
|
156
|
+
--runtime python \
|
|
157
|
+
--runtime-artifact <sha256> \
|
|
158
|
+
--runtime-file dist/runtime.pyz
|
|
159
|
+
```
|
|
151
160
|
|
|
152
161
|
Options:
|
|
153
162
|
|
|
154
|
-
- `--output <
|
|
163
|
+
- `--bundle-output <directory>`: default `analytics/hypequery-deployment`
|
|
155
164
|
- `--runtime <runtime>`: `node` (default) or `python`
|
|
156
|
-
- `--runtime-artifact <sha256>`: lowercase SHA-256 of
|
|
165
|
+
- `--runtime-artifact <sha256>`: lowercase SHA-256 of a prebuilt runtime artifact
|
|
166
|
+
- `--runtime-file <path>`: bytes for the prebuilt runtime artifact
|
|
157
167
|
- `--entrypoint-prefix <prefix>`: default `queries`
|
|
158
|
-
- `--hash-output <path>`: default `<output>.sha256`
|
|
159
168
|
|
|
160
|
-
The
|
|
161
|
-
|
|
169
|
+
The compatibility options `--output`, `--runtime-output`, and `--hash-output`
|
|
170
|
+
still emit the earlier metadata files instead of a complete bundle. They cannot
|
|
171
|
+
be combined with `--bundle-output`.
|
|
162
172
|
|
|
163
173
|
### `hypequery deployment:validate`
|
|
164
174
|
|
|
165
|
-
|
|
166
|
-
|
|
175
|
+
Verifies a complete deployment bundle before returning any contained metadata.
|
|
176
|
+
Verification rejects missing or undeclared files, symbolic links, path
|
|
177
|
+
traversal, byte-length or hash mismatches, deployment identity mismatches, and
|
|
178
|
+
runtime files not referenced by the deployment. Legacy deployment JSON files
|
|
179
|
+
are still accepted for metadata-only validation.
|
|
167
180
|
|
|
168
181
|
```bash
|
|
169
|
-
npx hypequery deployment:validate analytics/hypequery-deployment
|
|
182
|
+
npx hypequery deployment:validate analytics/hypequery-deployment
|
|
170
183
|
```
|
|
171
184
|
|
|
172
185
|
## Non-interactive Setup
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AA2JD,OAAO,EAAE,OAAO,EAAE,CAAC"}
|
package/dist/cli.js
CHANGED
|
@@ -111,10 +111,13 @@ program
|
|
|
111
111
|
}));
|
|
112
112
|
program
|
|
113
113
|
.command('deployment:build <api>')
|
|
114
|
-
.description('Build a
|
|
114
|
+
.description('Build a verified deployment bundle')
|
|
115
|
+
.option('--bundle-output <directory>', 'Bundle directory (default: analytics/hypequery-deployment)')
|
|
115
116
|
.option('-o, --output <path>', 'Output JSON file (default: analytics/hypequery-deployment.json)')
|
|
116
117
|
.option('--runtime <runtime>', 'Runtime for non-portable handlers: node or python (default: node)')
|
|
117
|
-
.option('--runtime-artifact <sha256>', '
|
|
118
|
+
.option('--runtime-artifact <sha256>', 'Use a prebuilt runtime artifact with this SHA-256 identity')
|
|
119
|
+
.option('--runtime-file <path>', 'Include the bytes for a prebuilt runtime artifact')
|
|
120
|
+
.option('--runtime-output <path>', 'Bundled Node runtime path (default: beside deployment JSON)')
|
|
118
121
|
.option('--entrypoint-prefix <prefix>', 'Runtime entrypoint prefix (default: queries)')
|
|
119
122
|
.option('--hash-output <path>', 'Deployment identity sidecar path (default: <output>.sha256)')
|
|
120
123
|
.action(runCommand(async (api, options) => {
|
|
@@ -122,7 +125,7 @@ program
|
|
|
122
125
|
}));
|
|
123
126
|
program
|
|
124
127
|
.command('deployment:validate <artifact>')
|
|
125
|
-
.description('
|
|
128
|
+
.description('Verify a deployment bundle or validate a legacy deployment JSON file')
|
|
126
129
|
.action(runCommand(async (artifact) => {
|
|
127
130
|
await validateDeploymentCommand(artifact);
|
|
128
131
|
}));
|
|
@@ -156,8 +159,8 @@ program.on('--help', () => {
|
|
|
156
159
|
console.log(' hypequery generate:types --output analytics/schema.ts');
|
|
157
160
|
console.log(' hypequery generate:datasets');
|
|
158
161
|
console.log(' hypequery generate:manifest analytics/api.ts --output analytics/hypequery-manifest.json');
|
|
159
|
-
console.log(' hypequery deployment:build analytics/api.ts
|
|
160
|
-
console.log(' hypequery deployment:validate analytics/hypequery-deployment
|
|
162
|
+
console.log(' hypequery deployment:build analytics/api.ts');
|
|
163
|
+
console.log(' hypequery deployment:validate analytics/hypequery-deployment');
|
|
161
164
|
console.log('');
|
|
162
165
|
console.log('Docs: https://hypequery.com/docs');
|
|
163
166
|
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { type ProtocolDeploymentContract } from '@hypequery/protocol';
|
|
2
2
|
export interface BuildDeploymentOptions {
|
|
3
|
+
bundleOutput?: string;
|
|
3
4
|
output?: string;
|
|
4
5
|
runtime?: 'node' | 'python';
|
|
5
6
|
runtimeArtifact?: string;
|
|
7
|
+
runtimeFile?: string;
|
|
8
|
+
runtimeOutput?: string;
|
|
6
9
|
entrypointPrefix?: string;
|
|
7
10
|
hashOutput?: string;
|
|
8
11
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../src/commands/deployment.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../src/commands/deployment.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,qBAAqB,CAAC;AAiB7B,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAoDD,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,0BAA0B,CAAC,CAwIrC;AAED,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,OAAO,CAAC,0BAA0B,CAAC,CA2ErC"}
|
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { prepareProtocolDeploymentContract, } from '@hypequery/protocol';
|
|
5
|
+
import { writeDeploymentBundle, verifyDeploymentBundle, readDeploymentRuntimeFile, } from '../utils/deployment-bundle.js';
|
|
6
|
+
import { buildNodeRuntimeArtifact, getDeploymentRuntimeEntrypoints, } from '../utils/deployment-runtime-artifact.js';
|
|
4
7
|
import { loadApiModule } from '../utils/load-api.js';
|
|
5
8
|
import { logger } from '../utils/logger.js';
|
|
6
9
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
|
-
|
|
10
|
+
const DEFAULT_BUNDLE_OUTPUT = 'analytics/hypequery-deployment';
|
|
11
|
+
function runtimeName(options) {
|
|
12
|
+
const runtime = options.runtime ?? 'node';
|
|
13
|
+
if (runtime !== 'node' && runtime !== 'python') {
|
|
14
|
+
throw new Error('--runtime must be either node or python.');
|
|
15
|
+
}
|
|
16
|
+
return runtime;
|
|
17
|
+
}
|
|
18
|
+
function configuredRuntimeArtifact(options, runtime) {
|
|
8
19
|
if (options.runtimeArtifact === undefined)
|
|
9
20
|
return undefined;
|
|
10
21
|
if (!SHA256_PATTERN.test(options.runtimeArtifact)) {
|
|
11
22
|
throw new Error('--runtime-artifact must be a lowercase 64-character SHA-256 digest.');
|
|
12
23
|
}
|
|
13
|
-
const runtime = options.runtime ?? 'node';
|
|
14
|
-
if (runtime !== 'node' && runtime !== 'python') {
|
|
15
|
-
throw new Error('--runtime must be either node or python.');
|
|
16
|
-
}
|
|
17
24
|
return {
|
|
18
25
|
runtime,
|
|
19
26
|
artifactSha256: options.runtimeArtifact,
|
|
@@ -22,23 +29,113 @@ function runtimeArtifact(options) {
|
|
|
22
29
|
: {}),
|
|
23
30
|
};
|
|
24
31
|
}
|
|
32
|
+
function assertDistinctOutputPaths(paths) {
|
|
33
|
+
const seen = new Map();
|
|
34
|
+
for (const [label, value] of Object.entries(paths)) {
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
const resolved = path.resolve(value);
|
|
38
|
+
const existing = seen.get(resolved);
|
|
39
|
+
if (existing) {
|
|
40
|
+
throw new Error(`${label} must use a different path from ${existing}.`);
|
|
41
|
+
}
|
|
42
|
+
seen.set(resolved, label);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
25
45
|
export async function buildDeploymentCommand(apiPath, options = {}) {
|
|
26
46
|
if (!apiPath) {
|
|
27
47
|
throw new Error('Missing API module path.\n\n'
|
|
28
48
|
+ 'Usage: hypequery deployment:build analytics/api.ts');
|
|
29
49
|
}
|
|
30
|
-
const
|
|
50
|
+
const runtime = runtimeName(options);
|
|
51
|
+
const configuredArtifact = configuredRuntimeArtifact(options, runtime);
|
|
52
|
+
const legacyOutputRequested = options.output !== undefined
|
|
53
|
+
|| options.hashOutput !== undefined
|
|
54
|
+
|| options.runtimeOutput !== undefined;
|
|
55
|
+
if (options.bundleOutput !== undefined && legacyOutputRequested) {
|
|
56
|
+
throw new Error('--bundle-output cannot be combined with --output, --hash-output, or --runtime-output.');
|
|
57
|
+
}
|
|
58
|
+
const bundleOutput = options.bundleOutput
|
|
59
|
+
?? (legacyOutputRequested ? undefined : DEFAULT_BUNDLE_OUTPUT);
|
|
60
|
+
if (options.runtimeFile !== undefined && configuredArtifact === undefined) {
|
|
61
|
+
throw new Error('--runtime-file requires --runtime-artifact.');
|
|
62
|
+
}
|
|
63
|
+
if (options.runtimeFile !== undefined && bundleOutput === undefined) {
|
|
64
|
+
throw new Error('--runtime-file is only supported when building a deployment bundle.');
|
|
65
|
+
}
|
|
66
|
+
if (configuredArtifact && bundleOutput !== undefined && options.runtimeFile === undefined) {
|
|
67
|
+
throw new Error('A complete deployment bundle requires --runtime-file with a prebuilt --runtime-artifact.');
|
|
68
|
+
}
|
|
69
|
+
if (configuredArtifact && options.runtimeOutput !== undefined) {
|
|
70
|
+
throw new Error('--runtime-output cannot be used with a prebuilt --runtime-artifact.');
|
|
71
|
+
}
|
|
72
|
+
const outputPath = options.output ?? 'analytics/hypequery-deployment.json';
|
|
73
|
+
const hashOutputPath = options.hashOutput ?? `${outputPath}.sha256`;
|
|
74
|
+
if (options.runtimeOutput !== undefined) {
|
|
75
|
+
assertDistinctOutputPaths({
|
|
76
|
+
'--output': outputPath,
|
|
77
|
+
'--hash-output': hashOutputPath,
|
|
78
|
+
'--runtime-output': options.runtimeOutput,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
31
81
|
const api = await loadApiModule(apiPath);
|
|
32
82
|
if (typeof api.deploymentContract !== 'function') {
|
|
33
83
|
throw new Error(`Invalid API module: ${apiPath}\n\n`
|
|
34
84
|
+ 'The exported API must provide deploymentContract(). '
|
|
35
85
|
+ 'Upgrade @hypequery/serve and export the value returned by createAPI() or serve().');
|
|
36
86
|
}
|
|
87
|
+
let builtArtifact;
|
|
88
|
+
let artifact = configuredArtifact;
|
|
89
|
+
if (!artifact) {
|
|
90
|
+
const runtimeEntrypoints = getDeploymentRuntimeEntrypoints(api);
|
|
91
|
+
if (runtimeEntrypoints.length > 0) {
|
|
92
|
+
if (runtime !== 'node') {
|
|
93
|
+
throw new Error('Automatic runtime artifact builds currently support Node only. '
|
|
94
|
+
+ 'Provide --runtime-artifact for Python deployments.');
|
|
95
|
+
}
|
|
96
|
+
builtArtifact = await buildNodeRuntimeArtifact(apiPath, runtimeEntrypoints, options.entrypointPrefix);
|
|
97
|
+
artifact = {
|
|
98
|
+
runtime: 'node',
|
|
99
|
+
artifactSha256: builtArtifact.artifactSha256,
|
|
100
|
+
entrypointPrefix: builtArtifact.entrypointPrefix,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
37
104
|
const contract = api.deploymentContract(artifact ? { runtimeArtifact: artifact } : {});
|
|
38
105
|
const prepared = prepareProtocolDeploymentContract(contract);
|
|
39
106
|
const { canonical, contract: validated, identity: digest } = prepared;
|
|
40
|
-
|
|
41
|
-
|
|
107
|
+
if (bundleOutput !== undefined) {
|
|
108
|
+
const runtimeFiles = [];
|
|
109
|
+
if (builtArtifact) {
|
|
110
|
+
runtimeFiles.push({
|
|
111
|
+
runtime: 'node',
|
|
112
|
+
sha256: builtArtifact.artifactSha256,
|
|
113
|
+
bytes: builtArtifact.bytes,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else if (configuredArtifact && options.runtimeFile) {
|
|
117
|
+
const bytes = await readDeploymentRuntimeFile(options.runtimeFile);
|
|
118
|
+
const actualSha256 = createHash('sha256').update(bytes).digest('hex');
|
|
119
|
+
if (actualSha256 !== configuredArtifact.artifactSha256) {
|
|
120
|
+
throw new Error(`Prebuilt runtime artifact SHA-256 mismatch: ${options.runtimeFile}\n\n`
|
|
121
|
+
+ `Expected: ${configuredArtifact.artifactSha256}\nActual: ${actualSha256}`);
|
|
122
|
+
}
|
|
123
|
+
runtimeFiles.push({ runtime, sha256: actualSha256, bytes });
|
|
124
|
+
}
|
|
125
|
+
const bundle = await writeDeploymentBundle(bundleOutput, prepared, runtimeFiles);
|
|
126
|
+
logger.success(`Deployment bundle written to ${bundle.directory}`);
|
|
127
|
+
logger.info(`Bundle identity: ${bundle.identity}`);
|
|
128
|
+
logger.info(`Deployment identity: ${digest}`);
|
|
129
|
+
return validated;
|
|
130
|
+
}
|
|
131
|
+
const runtimeOutputPath = builtArtifact
|
|
132
|
+
? options.runtimeOutput ?? path.join(path.dirname(outputPath), 'hypequery-runtime.mjs')
|
|
133
|
+
: undefined;
|
|
134
|
+
assertDistinctOutputPaths({
|
|
135
|
+
'--output': outputPath,
|
|
136
|
+
'--hash-output': hashOutputPath,
|
|
137
|
+
'--runtime-output': runtimeOutputPath,
|
|
138
|
+
});
|
|
42
139
|
const identitySidecar = [
|
|
43
140
|
'# Hypequery deployment identity v1; not a file checksum or sha256sum input.',
|
|
44
141
|
'# SHA-256(UTF-8("hypequery:deployment:v1") || 0x00 || RFC 8785 canonical bytes); '
|
|
@@ -48,8 +145,16 @@ export async function buildDeploymentCommand(apiPath, options = {}) {
|
|
|
48
145
|
].join('\n');
|
|
49
146
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
50
147
|
await mkdir(path.dirname(hashOutputPath), { recursive: true });
|
|
148
|
+
if (runtimeOutputPath && builtArtifact) {
|
|
149
|
+
await mkdir(path.dirname(runtimeOutputPath), { recursive: true });
|
|
150
|
+
await writeFile(runtimeOutputPath, builtArtifact.bytes);
|
|
151
|
+
}
|
|
51
152
|
await writeFile(outputPath, `${canonical}\n`, 'utf8');
|
|
52
153
|
await writeFile(hashOutputPath, identitySidecar, 'utf8');
|
|
154
|
+
if (runtimeOutputPath && builtArtifact) {
|
|
155
|
+
logger.success(`Runtime artifact written to ${runtimeOutputPath}`);
|
|
156
|
+
logger.info(`Runtime artifact SHA-256: ${builtArtifact.artifactSha256}`);
|
|
157
|
+
}
|
|
53
158
|
logger.success(`Deployment contract written to ${outputPath}`);
|
|
54
159
|
logger.info(`Identity: ${digest}`);
|
|
55
160
|
return validated;
|
|
@@ -57,7 +162,37 @@ export async function buildDeploymentCommand(apiPath, options = {}) {
|
|
|
57
162
|
export async function validateDeploymentCommand(artifactPath) {
|
|
58
163
|
if (!artifactPath) {
|
|
59
164
|
throw new Error('Missing deployment artifact path.\n\n'
|
|
60
|
-
+ 'Usage: hypequery deployment:validate analytics/hypequery-deployment
|
|
165
|
+
+ 'Usage: hypequery deployment:validate analytics/hypequery-deployment');
|
|
166
|
+
}
|
|
167
|
+
let artifactStat;
|
|
168
|
+
try {
|
|
169
|
+
artifactStat = await stat(artifactPath);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (!(typeof error === 'object' && error !== null && 'code' in error
|
|
173
|
+
&& error.code === 'ENOENT')) {
|
|
174
|
+
throw new Error(`Cannot inspect deployment input: ${artifactPath}\n\n`
|
|
175
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (artifactStat?.isDirectory()) {
|
|
179
|
+
try {
|
|
180
|
+
const bundle = await verifyDeploymentBundle(artifactPath);
|
|
181
|
+
const contract = bundle.contract;
|
|
182
|
+
logger.success(`Valid deployment bundle: ${artifactPath}`);
|
|
183
|
+
logger.info(`${contract.datasets.length} datasets, ${contract.queries.length} queries, `
|
|
184
|
+
+ `${contract.artifacts.length} runtime artifacts`);
|
|
185
|
+
logger.info(`Bundle identity: ${bundle.identity}`);
|
|
186
|
+
logger.info(`Deployment identity: ${bundle.manifest.deployment.identity}`);
|
|
187
|
+
return contract;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
throw new Error(`Invalid deployment bundle: ${artifactPath}\n\n`
|
|
191
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (artifactStat && !artifactStat.isFile()) {
|
|
195
|
+
throw new Error(`Deployment input must be a regular JSON file or bundle directory: ${artifactPath}`);
|
|
61
196
|
}
|
|
62
197
|
let input;
|
|
63
198
|
try {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type PreparedProtocolDeploymentContract, type ProtocolDeploymentBundleManifest, type ProtocolDeploymentContract } from '@hypequery/protocol';
|
|
2
|
+
export declare const DEPLOYMENT_BUNDLE_MANIFEST = "bundle.json";
|
|
3
|
+
export declare const DEPLOYMENT_BUNDLE_CONTRACT = "deployment.json";
|
|
4
|
+
export interface DeploymentBundleRuntimeFile {
|
|
5
|
+
readonly runtime: 'node' | 'python';
|
|
6
|
+
readonly sha256: string;
|
|
7
|
+
readonly bytes: Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
export interface WrittenDeploymentBundle {
|
|
10
|
+
readonly directory: string;
|
|
11
|
+
readonly manifest: ProtocolDeploymentBundleManifest;
|
|
12
|
+
readonly identity: string;
|
|
13
|
+
readonly contract: ProtocolDeploymentContract;
|
|
14
|
+
}
|
|
15
|
+
export type VerifiedDeploymentBundle = WrittenDeploymentBundle;
|
|
16
|
+
export declare function writeDeploymentBundle(outputDirectory: string, prepared: PreparedProtocolDeploymentContract, runtimeFiles: readonly DeploymentBundleRuntimeFile[]): Promise<WrittenDeploymentBundle>;
|
|
17
|
+
export declare function readDeploymentRuntimeFile(filePath: string): Promise<Uint8Array>;
|
|
18
|
+
export declare function verifyDeploymentBundle(bundleDirectory: string): Promise<VerifiedDeploymentBundle>;
|
|
19
|
+
//# sourceMappingURL=deployment-bundle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deployment-bundle.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-bundle.ts"],"names":[],"mappings":"AAaA,OAAO,EAIL,KAAK,kCAAkC,EAEvC,KAAK,gCAAgC,EACrC,KAAK,0BAA0B,EAChC,MAAM,qBAAqB,CAAC;AAG7B,eAAO,MAAM,0BAA0B,gBAAgB,CAAC;AACxD,eAAO,MAAM,0BAA0B,oBAAoB,CAAC;AAE5D,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,QAAQ,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,gCAAgC,CAAC;IACpD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,0BAA0B,CAAC;CAC/C;AAED,MAAM,MAAM,wBAAwB,GAAG,uBAAuB,CAAC;AA+F/D,wBAAsB,qBAAqB,CACzC,eAAe,EAAE,MAAM,EACvB,QAAQ,EAAE,kCAAkC,EAC5C,YAAY,EAAE,SAAS,2BAA2B,EAAE,GACnD,OAAO,CAAC,uBAAuB,CAAC,CA6ElC;AAgDD,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAMrF;AA+FD,wBAAsB,sBAAsB,CAC1C,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,wBAAwB,CAAC,CA+DnC"}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants, lstat, mkdir, mkdtemp, open, readdir, rename, rm, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS, prepareProtocolDeploymentBundleManifest, prepareProtocolDeploymentContract, } from '@hypequery/protocol';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
export const DEPLOYMENT_BUNDLE_MANIFEST = 'bundle.json';
|
|
7
|
+
export const DEPLOYMENT_BUNDLE_CONTRACT = 'deployment.json';
|
|
8
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
9
|
+
const utf8Encoder = new TextEncoder();
|
|
10
|
+
function sha256(bytes) {
|
|
11
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
function runtimeArtifactPath(runtime, digest) {
|
|
14
|
+
return `artifacts/${digest}.${runtime === 'node' ? 'mjs' : 'pyz'}`;
|
|
15
|
+
}
|
|
16
|
+
function errorCode(error) {
|
|
17
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
18
|
+
? String(error.code)
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
async function existingVerifiedBundle(outputDirectory) {
|
|
22
|
+
try {
|
|
23
|
+
await lstat(outputDirectory);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (errorCode(error) === 'ENOENT')
|
|
27
|
+
return false;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await verifyDeploymentBundle(outputDirectory);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new Error('Refusing to replace an existing path that is not a valid deployment bundle: '
|
|
35
|
+
+ `${outputDirectory}\n\n`
|
|
36
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function validateRuntimeFiles(prepared, files) {
|
|
41
|
+
requireClosedContractArtifactSet(prepared.contract);
|
|
42
|
+
const declared = new Map(prepared.contract.artifacts.map(artifact => [artifact.artifactSha256, artifact.runtime]));
|
|
43
|
+
const supplied = new Map();
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
if (sha256(file.bytes) !== file.sha256) {
|
|
46
|
+
throw new Error(`Runtime artifact bytes do not match SHA-256 ${file.sha256}.`);
|
|
47
|
+
}
|
|
48
|
+
if (declared.get(file.sha256) !== file.runtime) {
|
|
49
|
+
throw new Error(`Runtime artifact ${file.sha256} is not declared by the deployment contract as ${file.runtime}.`);
|
|
50
|
+
}
|
|
51
|
+
if (supplied.has(file.sha256)) {
|
|
52
|
+
throw new Error(`Duplicate runtime artifact bytes supplied for ${file.sha256}.`);
|
|
53
|
+
}
|
|
54
|
+
if (file.bytes.byteLength < 1
|
|
55
|
+
|| file.bytes.byteLength > DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxArtifactBytes) {
|
|
56
|
+
throw new Error(`Runtime artifact ${file.sha256} exceeds the bundle byte limits.`);
|
|
57
|
+
}
|
|
58
|
+
supplied.set(file.sha256, file.runtime);
|
|
59
|
+
}
|
|
60
|
+
for (const [digest, runtime] of declared) {
|
|
61
|
+
if (supplied.get(digest) !== runtime) {
|
|
62
|
+
throw new Error(`Deployment bundle is missing ${runtime} runtime artifact ${digest}.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Object.freeze([...files].sort((left, right) => {
|
|
66
|
+
const leftPath = runtimeArtifactPath(left.runtime, left.sha256);
|
|
67
|
+
const rightPath = runtimeArtifactPath(right.runtime, right.sha256);
|
|
68
|
+
return leftPath < rightPath ? -1 : leftPath > rightPath ? 1 : 0;
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
function requireClosedContractArtifactSet(contract) {
|
|
72
|
+
const referenced = new Map();
|
|
73
|
+
for (const query of contract.queries) {
|
|
74
|
+
if (query.implementation.kind === 'runtime-reference') {
|
|
75
|
+
referenced.set(query.implementation.artifactSha256, query.implementation.runtime);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (referenced.size !== contract.artifacts.length) {
|
|
79
|
+
throw new Error('Deployment contract contains missing or unreferenced runtime artifacts.');
|
|
80
|
+
}
|
|
81
|
+
for (const artifact of contract.artifacts) {
|
|
82
|
+
if (referenced.get(artifact.artifactSha256) !== artifact.runtime) {
|
|
83
|
+
throw new Error(`Deployment runtime artifact ${artifact.artifactSha256} is not referenced by a named query.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function writeDeploymentBundle(outputDirectory, prepared, runtimeFiles) {
|
|
88
|
+
const destination = path.resolve(outputDirectory);
|
|
89
|
+
if (destination === path.parse(destination).root) {
|
|
90
|
+
throw new Error('The deployment bundle output cannot be a filesystem root.');
|
|
91
|
+
}
|
|
92
|
+
const files = validateRuntimeFiles(prepared, runtimeFiles);
|
|
93
|
+
const deploymentBytes = utf8Encoder.encode(`${prepared.canonical}\n`);
|
|
94
|
+
const manifestInput = {
|
|
95
|
+
kind: 'hypequery-deployment-bundle',
|
|
96
|
+
version: 1,
|
|
97
|
+
deployment: {
|
|
98
|
+
path: DEPLOYMENT_BUNDLE_CONTRACT,
|
|
99
|
+
identity: prepared.identity,
|
|
100
|
+
sha256: sha256(deploymentBytes),
|
|
101
|
+
byteLength: deploymentBytes.byteLength,
|
|
102
|
+
},
|
|
103
|
+
artifacts: files.map(file => ({
|
|
104
|
+
runtime: file.runtime,
|
|
105
|
+
path: runtimeArtifactPath(file.runtime, file.sha256),
|
|
106
|
+
sha256: file.sha256,
|
|
107
|
+
byteLength: file.bytes.byteLength,
|
|
108
|
+
})),
|
|
109
|
+
};
|
|
110
|
+
const preparedManifest = prepareProtocolDeploymentBundleManifest(manifestInput);
|
|
111
|
+
const replaceExisting = await existingVerifiedBundle(destination);
|
|
112
|
+
const parent = path.dirname(destination);
|
|
113
|
+
await mkdir(parent, { recursive: true });
|
|
114
|
+
const staging = await mkdtemp(path.join(parent, `.${path.basename(destination)}.tmp-`));
|
|
115
|
+
try {
|
|
116
|
+
if (files.length > 0)
|
|
117
|
+
await mkdir(path.join(staging, 'artifacts'));
|
|
118
|
+
await writeFile(path.join(staging, DEPLOYMENT_BUNDLE_CONTRACT), deploymentBytes, { flag: 'wx' });
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
await writeFile(path.join(staging, runtimeArtifactPath(file.runtime, file.sha256)), file.bytes, { flag: 'wx' });
|
|
121
|
+
}
|
|
122
|
+
await writeFile(path.join(staging, DEPLOYMENT_BUNDLE_MANIFEST), `${preparedManifest.canonical}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
123
|
+
if (!replaceExisting) {
|
|
124
|
+
await rename(staging, destination);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const backup = path.join(parent, `.${path.basename(destination)}.previous-${randomUUID()}`);
|
|
128
|
+
await rename(destination, backup);
|
|
129
|
+
try {
|
|
130
|
+
await rename(staging, destination);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
await rename(backup, destination);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await rm(backup, { recursive: true });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
logger.warn(`Deployment bundle was replaced, but its previous backup could not be removed: ${backup}`
|
|
141
|
+
+ ` (${error instanceof Error ? error.message : String(error)})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
await rm(staging, { force: true, recursive: true });
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
directory: destination,
|
|
151
|
+
manifest: preparedManifest.manifest,
|
|
152
|
+
identity: preparedManifest.identity,
|
|
153
|
+
contract: prepared.contract,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async function readBoundedAbsoluteRegularFile(absolutePath, displayPath, maximum) {
|
|
157
|
+
let handle;
|
|
158
|
+
try {
|
|
159
|
+
const initialStat = await lstat(absolutePath);
|
|
160
|
+
if (initialStat.isSymbolicLink()) {
|
|
161
|
+
throw new Error(`Bundle entry must not be a symbolic link: ${displayPath}`);
|
|
162
|
+
}
|
|
163
|
+
if (!initialStat.isFile()) {
|
|
164
|
+
throw new Error(`Bundle entry is not a regular file: ${displayPath}`);
|
|
165
|
+
}
|
|
166
|
+
if (initialStat.size < 1 || initialStat.size > maximum) {
|
|
167
|
+
throw new Error(`Bundle entry exceeds its byte limit: ${displayPath}`);
|
|
168
|
+
}
|
|
169
|
+
handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
170
|
+
const stat = await handle.stat();
|
|
171
|
+
if (!stat.isFile())
|
|
172
|
+
throw new Error(`Bundle entry is not a regular file: ${displayPath}`);
|
|
173
|
+
if (stat.size < 1 || stat.size > maximum) {
|
|
174
|
+
throw new Error(`Bundle entry exceeds its byte limit: ${displayPath}`);
|
|
175
|
+
}
|
|
176
|
+
return await handle.readFile();
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (errorCode(error) === 'ELOOP') {
|
|
180
|
+
throw new Error(`Bundle entry must not be a symbolic link: ${displayPath}`);
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await handle?.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function readBoundedRegularFile(root, relativePath, maximum) {
|
|
189
|
+
return readBoundedAbsoluteRegularFile(path.join(root, ...relativePath.split('/')), relativePath, maximum);
|
|
190
|
+
}
|
|
191
|
+
export async function readDeploymentRuntimeFile(filePath) {
|
|
192
|
+
return readBoundedAbsoluteRegularFile(path.resolve(filePath), filePath, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxArtifactBytes);
|
|
193
|
+
}
|
|
194
|
+
function expectedDirectories(files) {
|
|
195
|
+
const result = new Set();
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
const segments = file.split('/');
|
|
198
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
199
|
+
result.add(segments.slice(0, index).join('/'));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
async function verifyExactEntries(root, manifest) {
|
|
205
|
+
const expectedFiles = new Set([
|
|
206
|
+
DEPLOYMENT_BUNDLE_MANIFEST,
|
|
207
|
+
manifest.deployment.path,
|
|
208
|
+
...manifest.artifacts.map(artifact => artifact.path),
|
|
209
|
+
]);
|
|
210
|
+
const expectedDirectoryPaths = expectedDirectories([...expectedFiles]);
|
|
211
|
+
const seenFiles = new Set();
|
|
212
|
+
const seenDirectories = new Set();
|
|
213
|
+
async function visit(relativeDirectory) {
|
|
214
|
+
const absoluteDirectory = relativeDirectory
|
|
215
|
+
? path.join(root, ...relativeDirectory.split('/'))
|
|
216
|
+
: root;
|
|
217
|
+
const names = await readdir(absoluteDirectory);
|
|
218
|
+
names.sort();
|
|
219
|
+
for (const name of names) {
|
|
220
|
+
const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
221
|
+
const stat = await lstat(path.join(absoluteDirectory, name));
|
|
222
|
+
if (stat.isSymbolicLink()) {
|
|
223
|
+
throw new Error(`Bundle entries must not be symbolic links: ${relativePath}`);
|
|
224
|
+
}
|
|
225
|
+
if (stat.isDirectory()) {
|
|
226
|
+
if (!expectedDirectoryPaths.has(relativePath)) {
|
|
227
|
+
throw new Error(`Deployment bundle contains an undeclared directory: ${relativePath}`);
|
|
228
|
+
}
|
|
229
|
+
seenDirectories.add(relativePath);
|
|
230
|
+
await visit(relativePath);
|
|
231
|
+
}
|
|
232
|
+
else if (stat.isFile()) {
|
|
233
|
+
if (!expectedFiles.has(relativePath)) {
|
|
234
|
+
throw new Error(`Deployment bundle contains an undeclared file: ${relativePath}`);
|
|
235
|
+
}
|
|
236
|
+
seenFiles.add(relativePath);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
throw new Error(`Bundle entry is not a regular file or directory: ${relativePath}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await visit('');
|
|
244
|
+
const missingFiles = [...expectedFiles].filter(file => !seenFiles.has(file)).sort();
|
|
245
|
+
if (missingFiles.length > 0) {
|
|
246
|
+
throw new Error(`Deployment bundle is missing files: ${missingFiles.join(', ')}`);
|
|
247
|
+
}
|
|
248
|
+
const missingDirectories = [...expectedDirectoryPaths]
|
|
249
|
+
.filter(directory => !seenDirectories.has(directory))
|
|
250
|
+
.sort();
|
|
251
|
+
if (missingDirectories.length > 0) {
|
|
252
|
+
throw new Error(`Deployment bundle is missing directories: ${missingDirectories.join(', ')}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function verifyFile(bytes, file) {
|
|
256
|
+
if (bytes.byteLength !== file.byteLength) {
|
|
257
|
+
throw new Error(`Bundle entry byte length does not match the manifest: ${file.path}`);
|
|
258
|
+
}
|
|
259
|
+
if (sha256(bytes) !== file.sha256) {
|
|
260
|
+
throw new Error(`Bundle entry SHA-256 does not match the manifest: ${file.path}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function verifyRuntimeReferences(contract, artifacts) {
|
|
264
|
+
requireClosedContractArtifactSet(contract);
|
|
265
|
+
const declared = new Map(contract.artifacts.map(artifact => [artifact.artifactSha256, artifact.runtime]));
|
|
266
|
+
if (declared.size !== artifacts.length) {
|
|
267
|
+
throw new Error('Bundle runtime artifacts do not match the deployment contract.');
|
|
268
|
+
}
|
|
269
|
+
for (const artifact of artifacts) {
|
|
270
|
+
if (declared.get(artifact.sha256) !== artifact.runtime) {
|
|
271
|
+
throw new Error(`Bundle artifact ${artifact.sha256} does not match a deployment runtime reference.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
export async function verifyDeploymentBundle(bundleDirectory) {
|
|
276
|
+
const root = path.resolve(bundleDirectory);
|
|
277
|
+
const rootStat = await lstat(root);
|
|
278
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
279
|
+
throw new Error(`Deployment bundle path must be a regular directory: ${bundleDirectory}`);
|
|
280
|
+
}
|
|
281
|
+
const manifestBytes = await readBoundedRegularFile(root, DEPLOYMENT_BUNDLE_MANIFEST, 1024 * 1024);
|
|
282
|
+
let input;
|
|
283
|
+
try {
|
|
284
|
+
input = JSON.parse(utf8Decoder.decode(manifestBytes));
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
throw new Error(`Invalid deployment bundle manifest: ${path.join(root, DEPLOYMENT_BUNDLE_MANIFEST)}\n\n`
|
|
288
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
289
|
+
}
|
|
290
|
+
const preparedManifest = prepareProtocolDeploymentBundleManifest(input);
|
|
291
|
+
const expectedManifestBytes = utf8Encoder.encode(`${preparedManifest.canonical}\n`);
|
|
292
|
+
if (!Buffer.from(manifestBytes).equals(Buffer.from(expectedManifestBytes))) {
|
|
293
|
+
throw new Error('Deployment bundle manifest must contain canonical JSON followed by one newline.');
|
|
294
|
+
}
|
|
295
|
+
await verifyExactEntries(root, preparedManifest.manifest);
|
|
296
|
+
const deployment = preparedManifest.manifest.deployment;
|
|
297
|
+
const deploymentBytes = await readBoundedRegularFile(root, deployment.path, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxDeploymentBytes);
|
|
298
|
+
verifyFile(deploymentBytes, deployment);
|
|
299
|
+
let contractInput;
|
|
300
|
+
try {
|
|
301
|
+
contractInput = JSON.parse(utf8Decoder.decode(deploymentBytes));
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
throw new Error(`Invalid deployment JSON in bundle: ${deployment.path}\n\n`
|
|
305
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
306
|
+
}
|
|
307
|
+
const preparedContract = prepareProtocolDeploymentContract(contractInput);
|
|
308
|
+
if (preparedContract.identity !== deployment.identity) {
|
|
309
|
+
throw new Error('Deployment identity does not match the bundle manifest.');
|
|
310
|
+
}
|
|
311
|
+
for (const artifact of preparedManifest.manifest.artifacts) {
|
|
312
|
+
const bytes = await readBoundedRegularFile(root, artifact.path, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxArtifactBytes);
|
|
313
|
+
verifyFile(bytes, artifact);
|
|
314
|
+
}
|
|
315
|
+
verifyRuntimeReferences(preparedContract.contract, preparedManifest.manifest.artifacts);
|
|
316
|
+
return Object.freeze({
|
|
317
|
+
directory: root,
|
|
318
|
+
manifest: preparedManifest.manifest,
|
|
319
|
+
identity: preparedManifest.identity,
|
|
320
|
+
contract: preparedContract.contract,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface NodeRuntimeArtifact {
|
|
2
|
+
readonly bytes: Uint8Array;
|
|
3
|
+
readonly artifactSha256: string;
|
|
4
|
+
readonly entrypointPrefix: string;
|
|
5
|
+
readonly runtimeEntrypoints: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function getDeploymentRuntimeEntrypoints(input: unknown): readonly string[];
|
|
8
|
+
export declare function buildNodeRuntimeArtifact(apiPath: string, runtimeEntrypoints: readonly string[], entrypointPrefix?: string): Promise<NodeRuntimeArtifact>;
|
|
9
|
+
//# sourceMappingURL=deployment-runtime-artifact.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deployment-runtime-artifact.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-runtime-artifact.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,kBAAkB,EAAE,SAAS,MAAM,EAAE,CAAC;CAChD;AASD,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,MAAM,EAAE,CA6BjF;AA6CD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,MAAM,EACf,kBAAkB,EAAE,SAAS,MAAM,EAAE,EACrC,gBAAgB,SAAY,GAC3B,OAAO,CAAC,mBAAmB,CAAC,CAmD9B"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseProtocolIdentifier, parseProtocolQualifiedIdentifier, } from '@hypequery/protocol';
|
|
4
|
+
import { build } from 'esbuild';
|
|
5
|
+
import { findNearestTsconfig } from './load-api.js';
|
|
6
|
+
const deploymentBuildSourceSymbol = Symbol.for('hypequery.deployment-build-source.v1');
|
|
7
|
+
function executableQuery(query) {
|
|
8
|
+
return typeof query === 'function'
|
|
9
|
+
|| (typeof query === 'object'
|
|
10
|
+
&& query !== null
|
|
11
|
+
&& typeof query.run === 'function');
|
|
12
|
+
}
|
|
13
|
+
export function getDeploymentRuntimeEntrypoints(input) {
|
|
14
|
+
if (typeof input !== 'object' || input === null) {
|
|
15
|
+
throw new Error('Invalid API module: deployment build metadata is unavailable.');
|
|
16
|
+
}
|
|
17
|
+
const api = input;
|
|
18
|
+
const source = api[deploymentBuildSourceSymbol];
|
|
19
|
+
if (source?.version !== 1 || !Array.isArray(source.runtimeEntrypoints)) {
|
|
20
|
+
throw new Error('The exported API does not expose deployment build metadata. '
|
|
21
|
+
+ 'Upgrade @hypequery/serve before building runtime artifacts.');
|
|
22
|
+
}
|
|
23
|
+
const names = source.runtimeEntrypoints.map((name) => {
|
|
24
|
+
try {
|
|
25
|
+
return parseProtocolIdentifier(name);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error(`Invalid runtime entrypoint name: ${String(name)}`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
if (new Set(names).size !== names.length) {
|
|
32
|
+
throw new Error('Deployment build metadata contains duplicate runtime entrypoints.');
|
|
33
|
+
}
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
if (!executableQuery(api.queries?.[name]?.query)) {
|
|
36
|
+
throw new Error(`Serve query "${name}" does not expose an executable runtime handler.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze([...names].sort());
|
|
40
|
+
}
|
|
41
|
+
function projectRelativeImport(apiPath) {
|
|
42
|
+
const resolved = path.resolve(process.cwd(), apiPath);
|
|
43
|
+
const relative = path.relative(process.cwd(), resolved);
|
|
44
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
45
|
+
throw new Error('The API module must be inside the current project for deterministic bundling.');
|
|
46
|
+
}
|
|
47
|
+
const portable = relative.split(path.sep).join('/');
|
|
48
|
+
return {
|
|
49
|
+
resolved,
|
|
50
|
+
specifier: portable.startsWith('.') ? portable : `./${portable}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function runtimeEntrySource(apiSpecifier, runtimeEntrypoints, entrypointPrefix) {
|
|
54
|
+
const prefix = parseProtocolQualifiedIdentifier(entrypointPrefix).split('.');
|
|
55
|
+
const names = JSON.stringify(runtimeEntrypoints);
|
|
56
|
+
let registryExpression = 'runtimeEntries';
|
|
57
|
+
for (let index = prefix.length - 1; index >= 1; index -= 1) {
|
|
58
|
+
registryExpression = `Object.freeze({ ${prefix[index]}: ${registryExpression} })`;
|
|
59
|
+
}
|
|
60
|
+
return [
|
|
61
|
+
`import * as apiModule from ${JSON.stringify(apiSpecifier)};`,
|
|
62
|
+
'const api = apiModule.api ?? Reflect.get(apiModule, "default");',
|
|
63
|
+
'if (!api || typeof api !== "object") throw new Error("Runtime API export is unavailable.");',
|
|
64
|
+
'const resolveQuery = (name) => {',
|
|
65
|
+
' const query = api.queries?.[name]?.query;',
|
|
66
|
+
' if (typeof query === "function") return query;',
|
|
67
|
+
' if (query && typeof query === "object" && typeof query.run === "function") {',
|
|
68
|
+
' return query.run.bind(query);',
|
|
69
|
+
' }',
|
|
70
|
+
' throw new Error(`Runtime entrypoint ${name} is unavailable.`);',
|
|
71
|
+
'};',
|
|
72
|
+
`const runtimeEntries = Object.freeze(Object.fromEntries(${names}.map(name => [name, resolveQuery(name)])));`,
|
|
73
|
+
`export const ${prefix[0]} = ${registryExpression};`,
|
|
74
|
+
'',
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
export async function buildNodeRuntimeArtifact(apiPath, runtimeEntrypoints, entrypointPrefix = 'queries') {
|
|
78
|
+
if (runtimeEntrypoints.length === 0) {
|
|
79
|
+
throw new Error('Cannot build a runtime artifact without Serve query entrypoints.');
|
|
80
|
+
}
|
|
81
|
+
const names = getValidatedEntrypointNames(runtimeEntrypoints);
|
|
82
|
+
const { resolved, specifier } = projectRelativeImport(apiPath);
|
|
83
|
+
let source;
|
|
84
|
+
try {
|
|
85
|
+
source = runtimeEntrySource(specifier, names, entrypointPrefix);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error(`Invalid runtime entrypoint prefix: ${entrypointPrefix}`);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const result = await build({
|
|
92
|
+
stdin: {
|
|
93
|
+
contents: source,
|
|
94
|
+
loader: 'ts',
|
|
95
|
+
resolveDir: process.cwd(),
|
|
96
|
+
sourcefile: 'hypequery-runtime-entry.ts',
|
|
97
|
+
},
|
|
98
|
+
absWorkingDir: process.cwd(),
|
|
99
|
+
bundle: true,
|
|
100
|
+
charset: 'utf8',
|
|
101
|
+
format: 'esm',
|
|
102
|
+
legalComments: 'none',
|
|
103
|
+
logLevel: 'warning',
|
|
104
|
+
minify: true,
|
|
105
|
+
platform: 'node',
|
|
106
|
+
sourcemap: false,
|
|
107
|
+
target: ['node18'],
|
|
108
|
+
treeShaking: true,
|
|
109
|
+
tsconfig: await findNearestTsconfig(resolved) ?? undefined,
|
|
110
|
+
write: false,
|
|
111
|
+
});
|
|
112
|
+
const output = result.outputFiles?.find(file => file.path.endsWith('.js'))
|
|
113
|
+
?? result.outputFiles?.[0];
|
|
114
|
+
if (!output)
|
|
115
|
+
throw new Error('esbuild produced no output');
|
|
116
|
+
const bytes = output.contents;
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
bytes,
|
|
119
|
+
artifactSha256: createHash('sha256').update(bytes).digest('hex'),
|
|
120
|
+
entrypointPrefix,
|
|
121
|
+
runtimeEntrypoints: names,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw new Error(`Failed to build the Node runtime artifact from ${apiPath}.\n`
|
|
126
|
+
+ `Original error: ${error instanceof Error ? error.message : String(error)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function getValidatedEntrypointNames(input) {
|
|
130
|
+
const names = input.map((name) => {
|
|
131
|
+
try {
|
|
132
|
+
return parseProtocolIdentifier(name);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new Error(`Invalid runtime entrypoint name: ${name}`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (new Set(names).size !== names.length) {
|
|
139
|
+
throw new Error('Runtime entrypoint names must be unique.');
|
|
140
|
+
}
|
|
141
|
+
return Object.freeze([...names].sort());
|
|
142
|
+
}
|
package/dist/utils/load-api.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare function loadApiModule(modulePath: string): Promise<any>;
|
|
2
2
|
export declare function loadModule(modulePath: string): Promise<any>;
|
|
3
|
+
export declare function findNearestTsconfig(filePath: string): Promise<string | null>;
|
|
3
4
|
//# sourceMappingURL=load-api.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load-api.d.ts","sourceRoot":"","sources":["../../src/utils/load-api.ts"],"names":[],"mappings":"AAaA,wBAAsB,aAAa,CAAC,UAAU,EAAE,MAAM,gBA2DrD;AAED,wBAAsB,UAAU,CAAC,UAAU,EAAE,MAAM,gBAkClD"}
|
|
1
|
+
{"version":3,"file":"load-api.d.ts","sourceRoot":"","sources":["../../src/utils/load-api.ts"],"names":[],"mappings":"AAaA,wBAAsB,aAAa,CAAC,UAAU,EAAE,MAAM,gBA2DrD;AAED,wBAAsB,UAAU,CAAC,UAAU,EAAE,MAAM,gBAkClD;AA0JD,wBAAsB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,0BA8BzD"}
|
package/dist/utils/load-api.js
CHANGED
|
@@ -217,7 +217,7 @@ async function bundleTypeScriptModule(entryPath) {
|
|
|
217
217
|
`Original error: ${error?.message ?? error}`);
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
|
-
async function findNearestTsconfig(filePath) {
|
|
220
|
+
export async function findNearestTsconfig(filePath) {
|
|
221
221
|
let dir = path.dirname(filePath);
|
|
222
222
|
const visited = [];
|
|
223
223
|
while (dir) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Command-line interface for hypequery",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"open": "^10.0.0",
|
|
20
20
|
"ora": "^8.0.1",
|
|
21
21
|
"prompts": "^2.4.2",
|
|
22
|
-
"@hypequery/protocol": "0.
|
|
22
|
+
"@hypequery/protocol": "0.4.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@hypequery/clickhouse": "*",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"typescript": "^5.7.3",
|
|
38
38
|
"vitest": "^3.2.6",
|
|
39
39
|
"@hypequery/clickhouse": "2.4.0",
|
|
40
|
-
"@hypequery/serve": "0.
|
|
40
|
+
"@hypequery/serve": "0.13.1"
|
|
41
41
|
},
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|