@specific.dev/spectest 0.92.0 → 0.92.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/dist/daemon.js +27 -97
- package/dist/harness/certificate.d.ts +15 -0
- package/dist/harness/certificate.js +67 -0
- package/dist/harness/service-graph.d.ts +3 -0
- package/dist/harness/service-graph.js +19 -9
- package/dist/index.d.ts +4 -5
- package/package.json +1 -1
- package/src/daemon.ts +27 -108
- package/src/harness/certificate.test.ts +31 -0
- package/src/harness/certificate.ts +73 -0
- package/src/harness/eval-typecheck.test.ts +28 -0
- package/src/harness/fetch.test.ts +28 -0
- package/src/harness/service-graph.test.ts +31 -1
- package/src/harness/service-graph.ts +23 -0
- package/src/index.ts +10 -7
package/dist/daemon.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
1
2
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
3
|
import { waitForAll } from "./harness/concurrency.js";
|
|
3
4
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
|
|
@@ -39,7 +40,7 @@ import { isDesktopApp } from "./desktop.js";
|
|
|
39
40
|
// exact-then-longest-suffix rule and the one-label certificate rule are
|
|
40
41
|
// each easy to restate subtly differently.
|
|
41
42
|
import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
42
|
-
import {
|
|
43
|
+
import { startServices } from "./harness/service-graph.js";
|
|
43
44
|
import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
|
|
44
45
|
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
45
46
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
@@ -1453,29 +1454,6 @@ async function withContainerPostMortem(name, err) {
|
|
|
1453
1454
|
return err;
|
|
1454
1455
|
}
|
|
1455
1456
|
}
|
|
1456
|
-
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1457
|
-
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
1458
|
-
function validateServiceGraph(services) {
|
|
1459
|
-
return validateGraph(services);
|
|
1460
|
-
}
|
|
1461
|
-
async function startServices(services, startOne) {
|
|
1462
|
-
const byName = validateServiceGraph(services);
|
|
1463
|
-
const started = new Map();
|
|
1464
|
-
const start = (svc) => {
|
|
1465
|
-
const existing = started.get(svc.name);
|
|
1466
|
-
if (existing)
|
|
1467
|
-
return existing;
|
|
1468
|
-
const p = (async () => {
|
|
1469
|
-
await Promise.all((svc.dependsOn ?? [])
|
|
1470
|
-
.filter((d) => byName.has(d))
|
|
1471
|
-
.map((d) => start(byName.get(d))));
|
|
1472
|
-
await startOne(svc);
|
|
1473
|
-
})();
|
|
1474
|
-
started.set(svc.name, p);
|
|
1475
|
-
return p;
|
|
1476
|
-
};
|
|
1477
|
-
await Promise.all(services.map(start));
|
|
1478
|
-
}
|
|
1479
1457
|
// ────────────────────────────────────────────────────────────────────────
|
|
1480
1458
|
// Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
|
|
1481
1459
|
//
|
|
@@ -1625,67 +1603,6 @@ function buildIngress(project) {
|
|
|
1625
1603
|
});
|
|
1626
1604
|
}
|
|
1627
1605
|
}
|
|
1628
|
-
/**
|
|
1629
|
-
* Generate a leaf cert + key for one ingress route (fake or service
|
|
1630
|
-
* proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
|
|
1631
|
-
* hostname the route answers to, so a client connecting with TLS
|
|
1632
|
-
* verifies cleanly regardless of which hostname it used. Shells out
|
|
1633
|
-
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
1634
|
-
* bookworm ships 3.0.x).
|
|
1635
|
-
*
|
|
1636
|
-
* `label` is a short tag baked into the cert Subject CN and the temp
|
|
1637
|
-
* file names — only used for diagnostics, not for TLS verification.
|
|
1638
|
-
*/
|
|
1639
|
-
async function generateHostCert(label, hostnames) {
|
|
1640
|
-
const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
|
|
1641
|
-
const keyPath = path.join("/tmp", `${id}.key`);
|
|
1642
|
-
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
1643
|
-
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
1644
|
-
const args = [
|
|
1645
|
-
"req",
|
|
1646
|
-
"-newkey",
|
|
1647
|
-
"rsa:2048",
|
|
1648
|
-
"-nodes",
|
|
1649
|
-
"-keyout",
|
|
1650
|
-
keyPath,
|
|
1651
|
-
"-out",
|
|
1652
|
-
crtPath,
|
|
1653
|
-
"-x509",
|
|
1654
|
-
"-CA",
|
|
1655
|
-
CA_PATH,
|
|
1656
|
-
"-CAkey",
|
|
1657
|
-
CA_KEY_PATH,
|
|
1658
|
-
"-days",
|
|
1659
|
-
"3650",
|
|
1660
|
-
"-subj",
|
|
1661
|
-
`/CN=spectest-${label}`,
|
|
1662
|
-
"-addext",
|
|
1663
|
-
`subjectAltName=${sans}`,
|
|
1664
|
-
"-addext",
|
|
1665
|
-
"basicConstraints=CA:FALSE",
|
|
1666
|
-
"-addext",
|
|
1667
|
-
"extendedKeyUsage=serverAuth",
|
|
1668
|
-
"-addext",
|
|
1669
|
-
"keyUsage=digitalSignature,keyEncipherment",
|
|
1670
|
-
];
|
|
1671
|
-
const r = await shx("openssl", args, 30_000);
|
|
1672
|
-
if (r.code !== 0) {
|
|
1673
|
-
throw new Error(`openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`);
|
|
1674
|
-
}
|
|
1675
|
-
try {
|
|
1676
|
-
const [cert, key] = await Promise.all([
|
|
1677
|
-
fs.readFile(crtPath, "utf8"),
|
|
1678
|
-
fs.readFile(keyPath, "utf8"),
|
|
1679
|
-
]);
|
|
1680
|
-
return { cert, key };
|
|
1681
|
-
}
|
|
1682
|
-
finally {
|
|
1683
|
-
await Promise.all([
|
|
1684
|
-
fs.unlink(keyPath).catch(() => { }),
|
|
1685
|
-
fs.unlink(crtPath).catch(() => { }),
|
|
1686
|
-
]);
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
1606
|
/** Resolve the spectest-net bridge gateway IP — the address containers
|
|
1690
1607
|
* use to reach the VM host. Asks dockerd via the docker CLI; cached for
|
|
1691
1608
|
* the daemon's life because the network is recreated only on reload. */
|
|
@@ -1719,7 +1636,10 @@ async function bridgeGatewayIp() {
|
|
|
1719
1636
|
* that don't pass through as primitives.
|
|
1720
1637
|
*/
|
|
1721
1638
|
const NATIVE_FETCH = globalThis.fetch.bind(globalThis);
|
|
1722
|
-
/**
|
|
1639
|
+
/** Mint ingress and container certificates with the environment CA. */
|
|
1640
|
+
function generateHostCert(label, hostnames) {
|
|
1641
|
+
return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
|
|
1642
|
+
}
|
|
1723
1643
|
async function startIngress() {
|
|
1724
1644
|
const hasIngress = FAKES.size > 0 ||
|
|
1725
1645
|
LOWERED.proxies.length > 0 ||
|
|
@@ -2942,10 +2862,14 @@ async function bootstrap(prepared) {
|
|
|
2942
2862
|
// hook's — into the boot log, so GET /boot-log can explain a slow or
|
|
2943
2863
|
// finished boot. Restored in the finally at the end.
|
|
2944
2864
|
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
2865
|
+
// One dispatcher for the whole bootstrap: service hooks run concurrently.
|
|
2866
|
+
// Only each hook's async scope opts into wrapped fetch responses.
|
|
2867
|
+
const restoreFetch = installFetchWrapper();
|
|
2945
2868
|
try {
|
|
2946
2869
|
return await bootstrapInner(prepared);
|
|
2947
2870
|
}
|
|
2948
2871
|
finally {
|
|
2872
|
+
restoreFetch();
|
|
2949
2873
|
restoreBootLog();
|
|
2950
2874
|
}
|
|
2951
2875
|
}
|
|
@@ -3111,22 +3035,28 @@ async function bootstrapInner(prepared, identity) {
|
|
|
3111
3035
|
await waitForAll([...builds.map(task => prep.get(task.name)), (async () => {
|
|
3112
3036
|
if (svc.setup) {
|
|
3113
3037
|
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3114
|
-
const
|
|
3038
|
+
const fetchScope = createInstrumentationScope(false);
|
|
3115
3039
|
try {
|
|
3116
|
-
await
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3040
|
+
await runInstrumented(fetchScope, async () => {
|
|
3041
|
+
const helpers = await ensureHelpers(svc.name, svc);
|
|
3042
|
+
await svc.setup({
|
|
3043
|
+
name: svc.name,
|
|
3044
|
+
helpers,
|
|
3045
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3046
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3047
|
+
images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
|
|
3048
|
+
gateway: await bridgeGatewayIp(),
|
|
3049
|
+
caBundle: await ensureCaBundle(),
|
|
3050
|
+
} },
|
|
3051
|
+
});
|
|
3125
3052
|
});
|
|
3126
3053
|
}
|
|
3127
3054
|
catch (err) {
|
|
3128
3055
|
throw await withContainerPostMortem(svc.name, err);
|
|
3129
3056
|
}
|
|
3057
|
+
finally {
|
|
3058
|
+
fetchScope.active = false;
|
|
3059
|
+
}
|
|
3130
3060
|
}
|
|
3131
3061
|
})()]);
|
|
3132
3062
|
for (const task of builds)
|
|
@@ -4013,7 +3943,7 @@ async function spectestContext(scope = {}) {
|
|
|
4013
3943
|
readProjectFileBytes: async (p) => new Uint8Array(await fs.readFile(resolveProjectPath(p))),
|
|
4014
3944
|
exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
|
|
4015
3945
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
4016
|
-
// the duration of a test / eval / project setup, so a context built
|
|
3946
|
+
// the duration of bootstrap / a test / eval / project setup, so a context built
|
|
4017
3947
|
// before it lands still gets the wrapped one.
|
|
4018
3948
|
fetch: ((input, init) => globalThis.fetch(input, init)),
|
|
4019
3949
|
svc: handles,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a leaf cert + key for one ingress route (fake or service
|
|
3
|
+
* proxy), signed by the in-VM root CA. SANs cover every
|
|
4
|
+
* hostname the route answers to, so a client connecting with TLS
|
|
5
|
+
* verifies cleanly regardless of which hostname it used. Shells out
|
|
6
|
+
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
7
|
+
* bookworm ships 3.0.x).
|
|
8
|
+
*
|
|
9
|
+
* `label` is a diagnostic tag baked into the cert Subject CN and the temp
|
|
10
|
+
* file names — only used for diagnostics, not for TLS verification.
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateHostCert(label: string, hostnames: string[], caPath: string, caKeyPath: string): Promise<{
|
|
13
|
+
cert: string;
|
|
14
|
+
key: string;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* Generate a leaf cert + key for one ingress route (fake or service
|
|
9
|
+
* proxy), signed by the in-VM root CA. SANs cover every
|
|
10
|
+
* hostname the route answers to, so a client connecting with TLS
|
|
11
|
+
* verifies cleanly regardless of which hostname it used. Shells out
|
|
12
|
+
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
13
|
+
* bookworm ships 3.0.x).
|
|
14
|
+
*
|
|
15
|
+
* `label` is a diagnostic tag baked into the cert Subject CN and the temp
|
|
16
|
+
* file names — only used for diagnostics, not for TLS verification.
|
|
17
|
+
*/
|
|
18
|
+
export async function generateHostCert(label, hostnames, caPath, caKeyPath) {
|
|
19
|
+
// CN is diagnostic only; SANs retain the full hostnames. Bound the label
|
|
20
|
+
// for both OpenSSL's 64-byte CN limit and filesystem component limits.
|
|
21
|
+
const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
|
|
22
|
+
const id = `${commonName}-${randomUUID()}`;
|
|
23
|
+
const keyPath = path.join("/tmp", `${id}.key`);
|
|
24
|
+
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
25
|
+
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
26
|
+
const args = [
|
|
27
|
+
"req",
|
|
28
|
+
"-newkey",
|
|
29
|
+
"rsa:2048",
|
|
30
|
+
"-nodes",
|
|
31
|
+
"-keyout",
|
|
32
|
+
keyPath,
|
|
33
|
+
"-out",
|
|
34
|
+
crtPath,
|
|
35
|
+
"-x509",
|
|
36
|
+
"-CA",
|
|
37
|
+
caPath,
|
|
38
|
+
"-CAkey",
|
|
39
|
+
caKeyPath,
|
|
40
|
+
"-days",
|
|
41
|
+
"3650",
|
|
42
|
+
"-subj",
|
|
43
|
+
`/CN=${commonName}`,
|
|
44
|
+
"-addext",
|
|
45
|
+
`subjectAltName=${sans}`,
|
|
46
|
+
"-addext",
|
|
47
|
+
"basicConstraints=CA:FALSE",
|
|
48
|
+
"-addext",
|
|
49
|
+
"extendedKeyUsage=serverAuth",
|
|
50
|
+
"-addext",
|
|
51
|
+
"keyUsage=digitalSignature,keyEncipherment",
|
|
52
|
+
];
|
|
53
|
+
try {
|
|
54
|
+
await execFileAsync("openssl", args, { timeout: 30_000 });
|
|
55
|
+
const [cert, key] = await Promise.all([
|
|
56
|
+
fs.readFile(crtPath, "utf8"),
|
|
57
|
+
fs.readFile(keyPath, "utf8"),
|
|
58
|
+
]);
|
|
59
|
+
return { cert, key };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await Promise.all([
|
|
63
|
+
fs.unlink(keyPath).catch(() => { }),
|
|
64
|
+
fs.unlink(crtPath).catch(() => { }),
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -27,3 +27,6 @@ export declare function validateServiceGraph<T extends GraphNode>(services: read
|
|
|
27
27
|
* graph is walkable at all.
|
|
28
28
|
*/
|
|
29
29
|
export declare function topologicalOrder<T extends GraphNode>(services: readonly T[]): T[];
|
|
30
|
+
/** Start each service after its dependencies finish setup. Drain all branches
|
|
31
|
+
* before reporting failure so teardown cannot race containers or hooks. */
|
|
32
|
+
export declare function startServices<T extends GraphNode>(services: readonly T[], startOne: (svc: T) => Promise<void>): Promise<void>;
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* The `dependsOn` graph: validation, and the traversal order bring-up
|
|
3
|
-
* uses.
|
|
4
|
-
*
|
|
5
|
-
* Ported out of `daemon.ts`. Validation runs before anything starts, so
|
|
6
|
-
* the DAG runner can assume a clean graph — an unknown dependency or a
|
|
7
|
-
* cycle must be a clear error at load, not a container that never becomes
|
|
8
|
-
* ready and eventually times out with no explanation.
|
|
9
|
-
*/
|
|
1
|
+
import { waitForAll } from "./concurrency.js";
|
|
10
2
|
/**
|
|
11
3
|
* Check the graph and return the name→node map used to walk it.
|
|
12
4
|
*
|
|
@@ -90,3 +82,21 @@ export function topologicalOrder(services) {
|
|
|
90
82
|
visit(s.name);
|
|
91
83
|
return out;
|
|
92
84
|
}
|
|
85
|
+
/** Start each service after its dependencies finish setup. Drain all branches
|
|
86
|
+
* before reporting failure so teardown cannot race containers or hooks. */
|
|
87
|
+
export async function startServices(services, startOne) {
|
|
88
|
+
const byName = validateServiceGraph(services);
|
|
89
|
+
const started = new Map();
|
|
90
|
+
const start = (svc) => {
|
|
91
|
+
const existing = started.get(svc.name);
|
|
92
|
+
if (existing)
|
|
93
|
+
return existing;
|
|
94
|
+
const pending = (async () => {
|
|
95
|
+
await Promise.all((svc.dependsOn ?? []).map(name => start(byName.get(name))));
|
|
96
|
+
await startOne(svc);
|
|
97
|
+
})();
|
|
98
|
+
started.set(svc.name, pending);
|
|
99
|
+
return pending;
|
|
100
|
+
};
|
|
101
|
+
await waitForAll(services.map(start));
|
|
102
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -590,11 +590,10 @@ export interface ServiceDefinition<H extends Record<string, any> = Record<string
|
|
|
590
590
|
* the typecheck on any service declaring a hook, while the same literal
|
|
591
591
|
* written inline passed. */
|
|
592
592
|
export type ServicesMap = Record<string, ServiceDefinition<any>>;
|
|
593
|
-
/** Awaited return type of a service's
|
|
594
|
-
*
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
} ? Awaited<R> : never;
|
|
593
|
+
/** Awaited return type of a service's helpers factory. Check key presence
|
|
594
|
+
* before removing optionality: annotations keep `helpers?`, while plain
|
|
595
|
+
* service literals with no factory must stay out of ctx.svc. */
|
|
596
|
+
type HelpersOf<D> = "helpers" extends keyof D ? [NonNullable<D["helpers"]>] extends [never] ? never : NonNullable<D["helpers"]> extends (...args: any[]) => infer R ? Awaited<R> : never : never;
|
|
598
597
|
/**
|
|
599
598
|
* Per-service handles derived from a concrete services map. Only
|
|
600
599
|
* services that ship a `helpers` factory appear here; `ctx.svc.<name>`
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
1
2
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
3
|
import { waitForAll } from "./harness/concurrency.js";
|
|
3
4
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
@@ -89,7 +90,7 @@ import {
|
|
|
89
90
|
serviceDockerignore as composeServiceDockerignore,
|
|
90
91
|
unionDockerignore,
|
|
91
92
|
} from "./harness/build-context.js";
|
|
92
|
-
import {
|
|
93
|
+
import { startServices } from "./harness/service-graph.js";
|
|
93
94
|
import {
|
|
94
95
|
casesMetadata as catalogueCases,
|
|
95
96
|
groupsMetadata as catalogueGroups,
|
|
@@ -1859,35 +1860,6 @@ async function withContainerPostMortem(name: string, err: unknown): Promise<unkn
|
|
|
1859
1860
|
}
|
|
1860
1861
|
}
|
|
1861
1862
|
|
|
1862
|
-
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1863
|
-
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
1864
|
-
function validateServiceGraph(services: NamedService[]): Map<string, NamedService> {
|
|
1865
|
-
return validateGraph(services);
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
async function startServices(
|
|
1869
|
-
services: NamedService[],
|
|
1870
|
-
startOne: (svc: NamedService) => Promise<void>,
|
|
1871
|
-
): Promise<void> {
|
|
1872
|
-
const byName = validateServiceGraph(services);
|
|
1873
|
-
const started = new Map<string, Promise<void>>();
|
|
1874
|
-
const start = (svc: NamedService): Promise<void> => {
|
|
1875
|
-
const existing = started.get(svc.name);
|
|
1876
|
-
if (existing) return existing;
|
|
1877
|
-
const p = (async () => {
|
|
1878
|
-
await Promise.all(
|
|
1879
|
-
(svc.dependsOn ?? [])
|
|
1880
|
-
.filter((d) => byName.has(d))
|
|
1881
|
-
.map((d) => start(byName.get(d)!)),
|
|
1882
|
-
);
|
|
1883
|
-
await startOne(svc);
|
|
1884
|
-
})();
|
|
1885
|
-
started.set(svc.name, p);
|
|
1886
|
-
return p;
|
|
1887
|
-
};
|
|
1888
|
-
await Promise.all(services.map(start));
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
1863
|
// ────────────────────────────────────────────────────────────────────────
|
|
1892
1864
|
// Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
|
|
1893
1865
|
//
|
|
@@ -2057,72 +2029,6 @@ function buildIngress(project: Project): void {
|
|
|
2057
2029
|
}
|
|
2058
2030
|
}
|
|
2059
2031
|
|
|
2060
|
-
/**
|
|
2061
|
-
* Generate a leaf cert + key for one ingress route (fake or service
|
|
2062
|
-
* proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
|
|
2063
|
-
* hostname the route answers to, so a client connecting with TLS
|
|
2064
|
-
* verifies cleanly regardless of which hostname it used. Shells out
|
|
2065
|
-
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
2066
|
-
* bookworm ships 3.0.x).
|
|
2067
|
-
*
|
|
2068
|
-
* `label` is a short tag baked into the cert Subject CN and the temp
|
|
2069
|
-
* file names — only used for diagnostics, not for TLS verification.
|
|
2070
|
-
*/
|
|
2071
|
-
async function generateHostCert(
|
|
2072
|
-
label: string,
|
|
2073
|
-
hostnames: string[],
|
|
2074
|
-
): Promise<{ cert: string; key: string }> {
|
|
2075
|
-
const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
|
|
2076
|
-
const keyPath = path.join("/tmp", `${id}.key`);
|
|
2077
|
-
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
2078
|
-
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
2079
|
-
const args = [
|
|
2080
|
-
"req",
|
|
2081
|
-
"-newkey",
|
|
2082
|
-
"rsa:2048",
|
|
2083
|
-
"-nodes",
|
|
2084
|
-
"-keyout",
|
|
2085
|
-
keyPath,
|
|
2086
|
-
"-out",
|
|
2087
|
-
crtPath,
|
|
2088
|
-
"-x509",
|
|
2089
|
-
"-CA",
|
|
2090
|
-
CA_PATH,
|
|
2091
|
-
"-CAkey",
|
|
2092
|
-
CA_KEY_PATH,
|
|
2093
|
-
"-days",
|
|
2094
|
-
"3650",
|
|
2095
|
-
"-subj",
|
|
2096
|
-
`/CN=spectest-${label}`,
|
|
2097
|
-
"-addext",
|
|
2098
|
-
`subjectAltName=${sans}`,
|
|
2099
|
-
"-addext",
|
|
2100
|
-
"basicConstraints=CA:FALSE",
|
|
2101
|
-
"-addext",
|
|
2102
|
-
"extendedKeyUsage=serverAuth",
|
|
2103
|
-
"-addext",
|
|
2104
|
-
"keyUsage=digitalSignature,keyEncipherment",
|
|
2105
|
-
];
|
|
2106
|
-
const r = await shx("openssl", args, 30_000);
|
|
2107
|
-
if (r.code !== 0) {
|
|
2108
|
-
throw new Error(
|
|
2109
|
-
`openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`,
|
|
2110
|
-
);
|
|
2111
|
-
}
|
|
2112
|
-
try {
|
|
2113
|
-
const [cert, key] = await Promise.all([
|
|
2114
|
-
fs.readFile(crtPath, "utf8"),
|
|
2115
|
-
fs.readFile(keyPath, "utf8"),
|
|
2116
|
-
]);
|
|
2117
|
-
return { cert, key };
|
|
2118
|
-
} finally {
|
|
2119
|
-
await Promise.all([
|
|
2120
|
-
fs.unlink(keyPath).catch(() => {}),
|
|
2121
|
-
fs.unlink(crtPath).catch(() => {}),
|
|
2122
|
-
]);
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
2032
|
/** Resolve the spectest-net bridge gateway IP — the address containers
|
|
2127
2033
|
* use to reach the VM host. Asks dockerd via the docker CLI; cached for
|
|
2128
2034
|
* the daemon's life because the network is recreated only on reload. */
|
|
@@ -2165,7 +2071,11 @@ type Route = IngressRoute<FakeRuntime>;
|
|
|
2165
2071
|
*/
|
|
2166
2072
|
const NATIVE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis);
|
|
2167
2073
|
|
|
2168
|
-
/**
|
|
2074
|
+
/** Mint ingress and container certificates with the environment CA. */
|
|
2075
|
+
function generateHostCert(label: string, hostnames: string[]): Promise<{ cert: string; key: string }> {
|
|
2076
|
+
return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2169
2079
|
async function startIngress(): Promise<void> {
|
|
2170
2080
|
const hasIngress =
|
|
2171
2081
|
FAKES.size > 0 ||
|
|
@@ -3519,9 +3429,13 @@ async function bootstrap(prepared?: PreparedImages): Promise<BootstrapTimings> {
|
|
|
3519
3429
|
// hook's — into the boot log, so GET /boot-log can explain a slow or
|
|
3520
3430
|
// finished boot. Restored in the finally at the end.
|
|
3521
3431
|
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
3432
|
+
// One dispatcher for the whole bootstrap: service hooks run concurrently.
|
|
3433
|
+
// Only each hook's async scope opts into wrapped fetch responses.
|
|
3434
|
+
const restoreFetch = installFetchWrapper();
|
|
3522
3435
|
try {
|
|
3523
3436
|
return await bootstrapInner(prepared);
|
|
3524
3437
|
} finally {
|
|
3438
|
+
restoreFetch();
|
|
3525
3439
|
restoreBootLog();
|
|
3526
3440
|
}
|
|
3527
3441
|
}
|
|
@@ -3695,20 +3609,25 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3695
3609
|
await waitForAll([...builds.map(task => prep.get(task.name)!), (async () => {
|
|
3696
3610
|
if (svc.setup) {
|
|
3697
3611
|
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3698
|
-
const
|
|
3612
|
+
const fetchScope = createInstrumentationScope(false);
|
|
3699
3613
|
try {
|
|
3700
|
-
await
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3614
|
+
await runInstrumented(fetchScope, async () => {
|
|
3615
|
+
const helpers = await ensureHelpers(svc.name, svc);
|
|
3616
|
+
await svc.setup!({
|
|
3617
|
+
name: svc.name,
|
|
3618
|
+
helpers,
|
|
3619
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3620
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3621
|
+
images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
|
|
3622
|
+
gateway: await bridgeGatewayIp(),
|
|
3623
|
+
caBundle: await ensureCaBundle(),
|
|
3624
|
+
} },
|
|
3625
|
+
});
|
|
3709
3626
|
});
|
|
3710
3627
|
} catch (err) {
|
|
3711
3628
|
throw await withContainerPostMortem(svc.name, err);
|
|
3629
|
+
} finally {
|
|
3630
|
+
fetchScope.active = false;
|
|
3712
3631
|
}
|
|
3713
3632
|
}
|
|
3714
3633
|
})()]);
|
|
@@ -4857,7 +4776,7 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
|
|
|
4857
4776
|
exec: (service, command, opts) =>
|
|
4858
4777
|
componentExec(service, command, opts, execTimeoutMs),
|
|
4859
4778
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
4860
|
-
// the duration of a test / eval / project setup, so a context built
|
|
4779
|
+
// the duration of bootstrap / a test / eval / project setup, so a context built
|
|
4861
4780
|
// before it lands still gets the wrapped one.
|
|
4862
4781
|
fetch: ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
|
|
4863
4782
|
globalThis.fetch(input, init)) as unknown as SpectestContext["fetch"],
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { X509Certificate } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { generateHostCert } from "./certificate.js";
|
|
7
|
+
|
|
8
|
+
test("long ingress names retain their full SAN while the diagnostic CN stays bounded", async () => {
|
|
9
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-leaf-"));
|
|
10
|
+
const ca = join(dir, "ca.crt");
|
|
11
|
+
const key = join(dir, "ca.key");
|
|
12
|
+
try {
|
|
13
|
+
const root = Bun.spawnSync(["openssl", "req", "-x509", "-newkey", "rsa:2048",
|
|
14
|
+
"-nodes", "-days", "1", "-subj", "/CN=test-ca", "-keyout", key, "-out", ca]);
|
|
15
|
+
expect(root.exitCode, root.stderr.toString()).toBe(0);
|
|
16
|
+
// Long enough to exceed both the former CN and temporary filename limits.
|
|
17
|
+
const host = `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(61)}`;
|
|
18
|
+
const { cert, key: leafKey } = await generateHostCert(host, [host, "*.example.test"], ca, key);
|
|
19
|
+
const leaf = new X509Certificate(cert);
|
|
20
|
+
expect(leaf.subject.length).toBeLessThanOrEqual(67); // "CN=" + 64 bytes
|
|
21
|
+
expect(leaf.checkHost(host)).toBe(host);
|
|
22
|
+
expect(leaf.checkHost("api.example.test")).toBe("*.example.test");
|
|
23
|
+
expect(leafKey).toContain("PRIVATE KEY");
|
|
24
|
+
const file = join(dir, "leaf.crt");
|
|
25
|
+
await writeFile(file, cert);
|
|
26
|
+
const verified = Bun.spawnSync(["openssl", "verify", "-CAfile", ca, "-verify_hostname", host, file]);
|
|
27
|
+
expect(verified.exitCode, verified.stderr.toString()).toBe(0);
|
|
28
|
+
} finally {
|
|
29
|
+
await rm(dir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}, 10_000);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a leaf cert + key for one ingress route (fake or service
|
|
11
|
+
* proxy), signed by the in-VM root CA. SANs cover every
|
|
12
|
+
* hostname the route answers to, so a client connecting with TLS
|
|
13
|
+
* verifies cleanly regardless of which hostname it used. Shells out
|
|
14
|
+
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
15
|
+
* bookworm ships 3.0.x).
|
|
16
|
+
*
|
|
17
|
+
* `label` is a diagnostic tag baked into the cert Subject CN and the temp
|
|
18
|
+
* file names — only used for diagnostics, not for TLS verification.
|
|
19
|
+
*/
|
|
20
|
+
export async function generateHostCert(
|
|
21
|
+
label: string,
|
|
22
|
+
hostnames: string[],
|
|
23
|
+
caPath: string,
|
|
24
|
+
caKeyPath: string,
|
|
25
|
+
): Promise<{ cert: string; key: string }> {
|
|
26
|
+
// CN is diagnostic only; SANs retain the full hostnames. Bound the label
|
|
27
|
+
// for both OpenSSL's 64-byte CN limit and filesystem component limits.
|
|
28
|
+
const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
|
|
29
|
+
const id = `${commonName}-${randomUUID()}`;
|
|
30
|
+
const keyPath = path.join("/tmp", `${id}.key`);
|
|
31
|
+
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
32
|
+
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
33
|
+
const args = [
|
|
34
|
+
"req",
|
|
35
|
+
"-newkey",
|
|
36
|
+
"rsa:2048",
|
|
37
|
+
"-nodes",
|
|
38
|
+
"-keyout",
|
|
39
|
+
keyPath,
|
|
40
|
+
"-out",
|
|
41
|
+
crtPath,
|
|
42
|
+
"-x509",
|
|
43
|
+
"-CA",
|
|
44
|
+
caPath,
|
|
45
|
+
"-CAkey",
|
|
46
|
+
caKeyPath,
|
|
47
|
+
"-days",
|
|
48
|
+
"3650",
|
|
49
|
+
"-subj",
|
|
50
|
+
`/CN=${commonName}`,
|
|
51
|
+
"-addext",
|
|
52
|
+
`subjectAltName=${sans}`,
|
|
53
|
+
"-addext",
|
|
54
|
+
"basicConstraints=CA:FALSE",
|
|
55
|
+
"-addext",
|
|
56
|
+
"extendedKeyUsage=serverAuth",
|
|
57
|
+
"-addext",
|
|
58
|
+
"keyUsage=digitalSignature,keyEncipherment",
|
|
59
|
+
];
|
|
60
|
+
try {
|
|
61
|
+
await execFileAsync("openssl", args, { timeout: 30_000 });
|
|
62
|
+
const [cert, key] = await Promise.all([
|
|
63
|
+
fs.readFile(crtPath, "utf8"),
|
|
64
|
+
fs.readFile(keyPath, "utf8"),
|
|
65
|
+
]);
|
|
66
|
+
return { cert, key };
|
|
67
|
+
} finally {
|
|
68
|
+
await Promise.all([
|
|
69
|
+
fs.unlink(keyPath).catch(() => {}),
|
|
70
|
+
fs.unlink(crtPath).catch(() => {}),
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -50,6 +50,34 @@ function run(command: string, args: string[], timeout: number): Promise<{ code:
|
|
|
50
50
|
const opts = () => ({ appDir, file, projectEntry, typecheckDir, run });
|
|
51
51
|
const query = 'const rows = await ctx.svc.supabase.sql<{ analyzed: boolean }>`SELECT true AS analyzed`;';
|
|
52
52
|
|
|
53
|
+
test("annotated services keep helper types while literals without helpers stay absent", async () => {
|
|
54
|
+
await fs.writeFile(projectEntry, `
|
|
55
|
+
import { defineEnvironment, type ServiceDefinition } from "@specific.dev/spectest";
|
|
56
|
+
interface Helpers { value(): number }
|
|
57
|
+
const annotated: ServiceDefinition<Helpers> = {
|
|
58
|
+
image: "example:latest", helpers: async () => ({ value: () => 42 }),
|
|
59
|
+
};
|
|
60
|
+
const checked = { image: "example:latest", helpers: () => ({ value: () => 42 }) } satisfies ServiceDefinition<Helpers>;
|
|
61
|
+
const env = defineEnvironment({ name: "helpers", services: {
|
|
62
|
+
annotated, checked, plain: { image: "example:latest" },
|
|
63
|
+
absent: { image: "example:latest", helpers: undefined },
|
|
64
|
+
} });
|
|
65
|
+
export default env.project([]);
|
|
66
|
+
`);
|
|
67
|
+
await fs.writeFile(file, `
|
|
68
|
+
const a: number = ctx.svc.annotated.value();
|
|
69
|
+
const b: number = ctx.svc.checked.value();
|
|
70
|
+
// @ts-expect-error plain services have no handle
|
|
71
|
+
ctx.svc.plain;
|
|
72
|
+
// @ts-expect-error explicitly absent helpers have no handle either
|
|
73
|
+
ctx.svc.absent;
|
|
74
|
+
// @ts-expect-error helper return type must not widen to any
|
|
75
|
+
const wrong: string = ctx.svc.annotated.value();
|
|
76
|
+
export default a + b;
|
|
77
|
+
`);
|
|
78
|
+
expect(await checkEval(opts())).toMatchObject({ status: "ok", errors: [], totalErrors: 0 });
|
|
79
|
+
}, 30_000);
|
|
80
|
+
|
|
53
81
|
test("eval receives the default-exported project's service/fake types and globals", async () => {
|
|
54
82
|
await fs.writeFile(file, `${query}
|
|
55
83
|
const ready: boolean = rows[0].analyzed.unwrap();
|
|
@@ -3,6 +3,7 @@ import { expect as assertValue } from "../index.js";
|
|
|
3
3
|
import { readRaw, readTag, type SpectestFetch, type WrappedResponse } from "../inspect.js";
|
|
4
4
|
import { recordFake, startRecording, stopRecording, type HttpEvent } from "../recorder.js";
|
|
5
5
|
import { installFetchWrapper, isTransportError } from "./fetch.js";
|
|
6
|
+
import { startServices } from "./service-graph.js";
|
|
6
7
|
import {
|
|
7
8
|
bindInstrumentationScope,
|
|
8
9
|
createInstrumentationScope,
|
|
@@ -356,4 +357,31 @@ describe("fetch execution scopes", () => {
|
|
|
356
357
|
setup.active = false;
|
|
357
358
|
}
|
|
358
359
|
});
|
|
360
|
+
|
|
361
|
+
test("parallel service setup scopes keep wrapping after a sibling fails", async () => {
|
|
362
|
+
const gate = deferred();
|
|
363
|
+
const slowStarted = deferred();
|
|
364
|
+
const setup = startServices([{ name: "bad" }, { name: "slow" }], async ({ name }) => {
|
|
365
|
+
const hookScope = createInstrumentationScope(false);
|
|
366
|
+
try {
|
|
367
|
+
await runInstrumented(hookScope, async () => {
|
|
368
|
+
if (name === "bad") { await slowStarted.promise; throw new Error("failed hook"); }
|
|
369
|
+
slowStarted.resolve();
|
|
370
|
+
await gate.promise;
|
|
371
|
+
const res = await fetch(url("/service-setup")) as unknown as WrappedResponse;
|
|
372
|
+
expect(res.status.unwrap()).toBe(200);
|
|
373
|
+
expect((await res.json<{ value: number }>()).value.unwrap()).toBe(42);
|
|
374
|
+
await runUninstrumented(async () => {
|
|
375
|
+
expect((await fetch(url("/fake"))).status).toBe(200);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
} finally { hookScope.active = false; }
|
|
379
|
+
});
|
|
380
|
+
const outcome = setup.catch(error => error);
|
|
381
|
+
await slowStarted.promise;
|
|
382
|
+
expect((await fetch(url("/background"))).status).toBe(200);
|
|
383
|
+
gate.resolve();
|
|
384
|
+
expect((await outcome).message).toBe("failed hook");
|
|
385
|
+
expect(stopRecording()).toEqual([]);
|
|
386
|
+
});
|
|
359
387
|
});
|
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { topologicalOrder, validateServiceGraph } from "./service-graph";
|
|
3
|
+
import { startServices, topologicalOrder, validateServiceGraph } from "./service-graph";
|
|
4
4
|
|
|
5
5
|
const svc = (name: string, dependsOn?: string[]) => ({ name, dependsOn });
|
|
6
6
|
|
|
7
|
+
test("bootstrap waits for sibling hooks after a failure and never starts failed dependents", async () => {
|
|
8
|
+
let release!: () => void;
|
|
9
|
+
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
10
|
+
const started: string[] = [];
|
|
11
|
+
let completed = false;
|
|
12
|
+
let settled = false;
|
|
13
|
+
const failed = new Error("setup failed");
|
|
14
|
+
const result = startServices([svc("bad"), svc("slow"), svc("child", ["bad"])], async ({ name }) => {
|
|
15
|
+
started.push(name);
|
|
16
|
+
if (name === "bad") throw failed;
|
|
17
|
+
if (name === "slow") { await gate; completed = true; }
|
|
18
|
+
}).then(() => { settled = true; return undefined; }, error => { settled = true; return error; });
|
|
19
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
20
|
+
expect(started).toEqual(["bad", "slow"]);
|
|
21
|
+
expect(settled).toBe(false);
|
|
22
|
+
release();
|
|
23
|
+
const error = await result;
|
|
24
|
+
expect(completed).toBe(true);
|
|
25
|
+
expect(error instanceof AggregateError ? error.errors : [error]).toContain(failed);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("shared dependencies complete setup once before either dependent starts", async () => {
|
|
29
|
+
const started: string[] = [];
|
|
30
|
+
await startServices([svc("a", ["db"]), svc("b", ["db"]), svc("db")], async ({ name }) => {
|
|
31
|
+
await Promise.resolve();
|
|
32
|
+
started.push(name);
|
|
33
|
+
});
|
|
34
|
+
expect(started).toEqual(["db", "a", "b"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
7
37
|
describe("validateServiceGraph", () => {
|
|
8
38
|
test("returns a name→service map for a clean graph", () => {
|
|
9
39
|
const map = validateServiceGraph([svc("db"), svc("api", ["db"])]);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { waitForAll } from "./concurrency.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The `dependsOn` graph: validation, and the traversal order bring-up
|
|
3
5
|
* uses.
|
|
@@ -95,3 +97,24 @@ export function topologicalOrder<T extends GraphNode>(services: readonly T[]): T
|
|
|
95
97
|
for (const s of services) visit(s.name);
|
|
96
98
|
return out;
|
|
97
99
|
}
|
|
100
|
+
|
|
101
|
+
/** Start each service after its dependencies finish setup. Drain all branches
|
|
102
|
+
* before reporting failure so teardown cannot race containers or hooks. */
|
|
103
|
+
export async function startServices<T extends GraphNode>(
|
|
104
|
+
services: readonly T[],
|
|
105
|
+
startOne: (svc: T) => Promise<void>,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const byName = validateServiceGraph(services);
|
|
108
|
+
const started = new Map<string, Promise<void>>();
|
|
109
|
+
const start = (svc: T): Promise<void> => {
|
|
110
|
+
const existing = started.get(svc.name);
|
|
111
|
+
if (existing) return existing;
|
|
112
|
+
const pending = (async () => {
|
|
113
|
+
await Promise.all((svc.dependsOn ?? []).map(name => start(byName.get(name)!)));
|
|
114
|
+
await startOne(svc);
|
|
115
|
+
})();
|
|
116
|
+
started.set(svc.name, pending);
|
|
117
|
+
return pending;
|
|
118
|
+
};
|
|
119
|
+
await waitForAll(services.map(start));
|
|
120
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -796,13 +796,16 @@ export interface ServiceDefinition<
|
|
|
796
796
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
797
797
|
export type ServicesMap = Record<string, ServiceDefinition<any>>;
|
|
798
798
|
|
|
799
|
-
/** Awaited return type of a service's
|
|
800
|
-
*
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
helpers
|
|
804
|
-
|
|
805
|
-
|
|
799
|
+
/** Awaited return type of a service's helpers factory. Check key presence
|
|
800
|
+
* before removing optionality: annotations keep `helpers?`, while plain
|
|
801
|
+
* service literals with no factory must stay out of ctx.svc. */
|
|
802
|
+
type HelpersOf<D> = "helpers" extends keyof D
|
|
803
|
+
? [NonNullable<D["helpers"]>] extends [never]
|
|
804
|
+
? never
|
|
805
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
806
|
+
: NonNullable<D["helpers"]> extends (...args: any[]) => infer R
|
|
807
|
+
? Awaited<R>
|
|
808
|
+
: never
|
|
806
809
|
: never;
|
|
807
810
|
|
|
808
811
|
/**
|