diff --git a/packages/@jsii/kernel/src/kernel.ts b/packages/@jsii/kernel/src/kernel.ts index 8a8e0eb50f..0939152902 100644 --- a/packages/@jsii/kernel/src/kernel.ts +++ b/packages/@jsii/kernel/src/kernel.ts @@ -1,4 +1,5 @@ import * as spec from '@jsii/spec'; +import { loadAssemblyFromPath } from '@jsii/spec'; import * as cp from 'child_process'; import * as fs from 'fs-extra'; import * as os from 'os'; @@ -114,13 +115,12 @@ export class Kernel { } // read .jsii metadata from the root of the package - const jsiiMetadataFile = path.join(packageDir, spec.SPEC_FILE_NAME); - if (!fs.pathExistsSync(jsiiMetadataFile)) { - throw new Error( - `Package tarball ${req.tarball} must have a file named ${spec.SPEC_FILE_NAME} at the root`, - ); + let assmSpec; + try { + assmSpec = loadAssemblyFromPath(packageDir); + } catch (e: any) { + throw new Error(`Error for package tarball ${req.tarball}: ${e.message}`); } - const assmSpec = fs.readJsonSync(jsiiMetadataFile) as spec.Assembly; // load the module and capture it's closure const closure = this._execute( diff --git a/packages/@jsii/runtime/test/kernel-host.test.ts b/packages/@jsii/runtime/test/kernel-host.test.ts index 53d821e400..1c9087b623 100644 --- a/packages/@jsii/runtime/test/kernel-host.test.ts +++ b/packages/@jsii/runtime/test/kernel-host.test.ts @@ -1,5 +1,6 @@ import { api } from '@jsii/kernel'; import * as spec from '@jsii/spec'; +import { loadAssemblyFromPath } from '@jsii/spec'; import * as child from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -86,12 +87,9 @@ function loadRequest(library: string): api.LoadRequest { }; function loadAssembly(): spec.Assembly { - const assemblyFile = path.resolve( - require.resolve(`${library}/package.json`), - '..', - '.jsii', + return loadAssemblyFromPath( + path.resolve(require.resolve(`${library}/package.json`), '..'), ); - return JSON.parse(fs.readFileSync(assemblyFile, { encoding: 'utf-8' })); } function packageLibrary(target: string): void { diff --git a/packages/@scope/jsii-calc-base-of-base/.npmignore b/packages/@scope/jsii-calc-base-of-base/.npmignore index 5ae704cff8..2f2ad5326e 100644 --- a/packages/@scope/jsii-calc-base-of-base/.npmignore +++ b/packages/@scope/jsii-calc-base-of-base/.npmignore @@ -4,8 +4,9 @@ *.tgz -# Include .jsii +# Include .jsii and .jsii.gz !.jsii +!.jsii.gz # Exclude jsii outdir diff --git a/packages/@scope/jsii-calc-base/.npmignore b/packages/@scope/jsii-calc-base/.npmignore index 5ae704cff8..2f2ad5326e 100644 --- a/packages/@scope/jsii-calc-base/.npmignore +++ b/packages/@scope/jsii-calc-base/.npmignore @@ -4,8 +4,9 @@ *.tgz -# Include .jsii +# Include .jsii and .jsii.gz !.jsii +!.jsii.gz # Exclude jsii outdir diff --git a/packages/@scope/jsii-calc-lib/.npmignore b/packages/@scope/jsii-calc-lib/.npmignore index 5ae704cff8..2f2ad5326e 100644 --- a/packages/@scope/jsii-calc-lib/.npmignore +++ b/packages/@scope/jsii-calc-lib/.npmignore @@ -4,8 +4,9 @@ *.tgz -# Include .jsii +# Include .jsii and .jsii.gz !.jsii +!.jsii.gz # Exclude jsii outdir diff --git a/packages/jsii-calc/.npmignore b/packages/jsii-calc/.npmignore index 8eec677f1c..b3913a2580 100644 --- a/packages/jsii-calc/.npmignore +++ b/packages/jsii-calc/.npmignore @@ -3,8 +3,9 @@ !*.d.ts *.tgz -# Include .jsii +# Include .jsii and .jsii.gz !.jsii +!.jsii.gz # Exclude jsii outdir diff --git a/packages/jsii-diff/bin/jsii-diff.ts b/packages/jsii-diff/bin/jsii-diff.ts index 398b9d61b4..e52bb85911 100644 --- a/packages/jsii-diff/bin/jsii-diff.ts +++ b/packages/jsii-diff/bin/jsii-diff.ts @@ -215,7 +215,7 @@ type LoadAssemblyResult = { requested: string; resolved: string } & ( async function loadPackageNameFromAssembly( options: LoadOptions, ): Promise { - const JSII_ASSEMBLY_FILE = '.jsii'; + const JSII_ASSEMBLY_FILE = spec.SPEC_FILE_NAME; if (!(await fs.pathExists(JSII_ASSEMBLY_FILE))) { throw new Error( `No NPM package name given and no ${JSII_ASSEMBLY_FILE} file in the current directory. Please specify a package name.`, diff --git a/packages/jsii-pacmak/lib/npm-modules.ts b/packages/jsii-pacmak/lib/npm-modules.ts index 8de84393ed..81f2820c83 100644 --- a/packages/jsii-pacmak/lib/npm-modules.ts +++ b/packages/jsii-pacmak/lib/npm-modules.ts @@ -150,7 +150,11 @@ async function updateNpmIgnore( ); } - includePattern('Include .jsii', spec.SPEC_FILE_NAME); + includePattern( + 'Include .jsii and .jsii.gz', + spec.SPEC_FILE_NAME, + spec.SPEC_FILE_NAME_COMPRESSED, + ); if (modified) { await fs.writeFile(npmIgnorePath, `${lines.join('\n')}\n`); diff --git a/packages/jsii-reflect/lib/type-system.ts b/packages/jsii-reflect/lib/type-system.ts index 0b696ebe0d..65286e0d0a 100644 --- a/packages/jsii-reflect/lib/type-system.ts +++ b/packages/jsii-reflect/lib/type-system.ts @@ -1,4 +1,4 @@ -import * as jsii from '@jsii/spec'; +import { getAssemblyFile, loadAssemblyFromFile } from '@jsii/spec'; import * as fs from 'fs-extra'; import * as path from 'path'; @@ -109,10 +109,7 @@ export class TypeSystem { // Load the assembly, but don't recurse if we already have an assembly with the same name. // Validation is not an insignificant time sink, and loading IS insignificant, so do a // load without validation first. This saves about 2/3rds of processing time. - const asm = await this.loadAssembly( - path.join(moduleDirectory, '.jsii'), - false, - ); + const asm = this.loadAssembly(getAssemblyFile(moduleDirectory), false); if (this.includesAssembly(asm.name)) { const existing = this.findAssembly(asm.name); if (existing.version !== asm.version) { @@ -154,11 +151,11 @@ export class TypeSystem { } } - public async loadFile( + public loadFile( file: string, options: { isRoot?: boolean; validate?: boolean } = {}, ) { - const assembly = await this.loadAssembly(file, options.validate !== false); + const assembly = this.loadAssembly(file, options.validate !== false); return this.addAssembly(assembly, options); } @@ -308,12 +305,9 @@ export class TypeSystem { * @param file Assembly file to load * @param validate Whether to validate the assembly or just assume it matches the schema */ - private async loadAssembly(file: string, validate = true) { - const spec = JSON.parse(await fs.readFile(file, { encoding: 'utf-8' })); - const ass = validate - ? jsii.validateAssembly(spec) - : (spec as jsii.Assembly); - return new Assembly(this, ass); + private loadAssembly(file: string, validate = true) { + const contents = loadAssemblyFromFile(file, validate); + return new Assembly(this, contents); } private addRoot(asm: Assembly) { diff --git a/packages/jsii-rosetta/lib/commands/coverage.ts b/packages/jsii-rosetta/lib/commands/coverage.ts index d223e41262..0021b96163 100644 --- a/packages/jsii-rosetta/lib/commands/coverage.ts +++ b/packages/jsii-rosetta/lib/commands/coverage.ts @@ -5,7 +5,7 @@ import { formatLocation } from '../snippet'; export async function checkCoverage(assemblyLocations: readonly string[]): Promise { logging.info(`Loading ${assemblyLocations.length} assemblies`); - const assemblies = await loadAssemblies(assemblyLocations, false); + const assemblies = loadAssemblies(assemblyLocations, false); const snippets = Array.from(await allTypeScriptSnippets(assemblies, true)); diff --git a/packages/jsii-rosetta/lib/commands/extract.ts b/packages/jsii-rosetta/lib/commands/extract.ts index 949c2007e7..81ffc3749a 100644 --- a/packages/jsii-rosetta/lib/commands/extract.ts +++ b/packages/jsii-rosetta/lib/commands/extract.ts @@ -94,7 +94,7 @@ export async function extractSnippets( const only = options.only ?? []; logging.info(`Loading ${assemblyLocations.length} assemblies`); - const assemblies = await loadAssemblies(assemblyLocations, options.validateAssemblies ?? false); + const assemblies = loadAssemblies(assemblyLocations, options.validateAssemblies ?? false); let snippets = Array.from(await allTypeScriptSnippets(assemblies, options.loose)); if (only.length > 0) { @@ -170,7 +170,7 @@ export async function extractSnippets( const output = options.trimCache ? new LanguageTablet() : await LanguageTablet.fromOptionalFile(options.cacheToFile); - output.addTablet(translator.tablet); + output.addTablets(translator.tablet); await output.save(options.cacheToFile); } diff --git a/packages/jsii-rosetta/lib/commands/infuse.ts b/packages/jsii-rosetta/lib/commands/infuse.ts index 183977b3d8..3fb1e86434 100644 --- a/packages/jsii-rosetta/lib/commands/infuse.ts +++ b/packages/jsii-rosetta/lib/commands/infuse.ts @@ -66,7 +66,7 @@ export async function infuse(assemblyLocations: string[], options?: InfuseOption } // Load tablet file and assemblies - const assemblies = await loadAssemblies(assemblyLocations, false); + const assemblies = loadAssemblies(assemblyLocations, false); const defaultTablets = await loadAllDefaultTablets(assemblies); const availableTranslations = new LanguageTablet(); diff --git a/packages/jsii-rosetta/lib/commands/transliterate.ts b/packages/jsii-rosetta/lib/commands/transliterate.ts index 45daec7333..f6538840b1 100644 --- a/packages/jsii-rosetta/lib/commands/transliterate.ts +++ b/packages/jsii-rosetta/lib/commands/transliterate.ts @@ -1,5 +1,5 @@ -import { Assembly, Docs, SPEC_FILE_NAME, Type, TypeKind } from '@jsii/spec'; -import { readJson, writeJson } from 'fs-extra'; +import { Assembly, Docs, SPEC_FILE_NAME, Type, TypeKind, loadAssemblyFromPath } from '@jsii/spec'; +import { writeJson } from 'fs-extra'; import { resolve } from 'path'; import { TargetLanguage } from '../languages'; @@ -98,8 +98,7 @@ export async function transliterateAssembly( for (const [location, loadAssembly] of assemblies.entries()) { for (const language of targetLanguages) { const now = new Date().getTime(); - // eslint-disable-next-line no-await-in-loop - const result = await loadAssembly(); + const result = loadAssembly(); if (result.targets?.[targetName(language)] == null) { // This language is not supported by the assembly, so we skip it... @@ -149,16 +148,16 @@ async function loadAssemblies( const result = new Map(); for (const directory of directories) { - const loader = () => readJson(resolve(directory, SPEC_FILE_NAME)); + const loader = () => loadAssemblyFromPath(directory); // eslint-disable-next-line no-await-in-loop - await rosetta.addAssembly(await loader(), directory); + await rosetta.addAssembly(loader(), directory); result.set(directory, loader); } return result; } -type AssemblyLoader = () => Promise>; +type AssemblyLoader = () => Mutable; function transliterateType(type: Type, rosetta: RosettaTabletReader, language: TargetLanguage): void { transliterateDocs({ api: 'type', fqn: type.fqn }, type.docs); diff --git a/packages/jsii-rosetta/lib/commands/trim-cache.ts b/packages/jsii-rosetta/lib/commands/trim-cache.ts index de87328489..4a8be800bd 100644 --- a/packages/jsii-rosetta/lib/commands/trim-cache.ts +++ b/packages/jsii-rosetta/lib/commands/trim-cache.ts @@ -18,7 +18,7 @@ export interface TrimCacheOptions { export async function trimCache(options: TrimCacheOptions): Promise { logging.info(`Loading ${options.assemblyLocations.length} assemblies`); - const assemblies = await loadAssemblies(options.assemblyLocations, false); + const assemblies = loadAssemblies(options.assemblyLocations, false); const snippets = Array.from(await allTypeScriptSnippets(assemblies)); diff --git a/packages/jsii-rosetta/lib/jsii/assemblies.ts b/packages/jsii-rosetta/lib/jsii/assemblies.ts index f9edbb06a6..a488f8e111 100644 --- a/packages/jsii-rosetta/lib/jsii/assemblies.ts +++ b/packages/jsii-rosetta/lib/jsii/assemblies.ts @@ -1,4 +1,5 @@ import * as spec from '@jsii/spec'; +import { loadAssemblyFromFile, loadAssemblyFromPath, getAssemblyFile, writeAssembly } from '@jsii/spec'; import * as crypto from 'crypto'; import * as fs from 'fs-extra'; import * as path from 'path'; @@ -55,35 +56,28 @@ export interface LoadedAssembly { /** * Load assemblies by filename or directory */ -export async function loadAssemblies( +export function loadAssemblies( assemblyLocations: readonly string[], validateAssemblies: boolean, -): Promise { - return Promise.all(assemblyLocations.map(loadAssembly)); +): readonly LoadedAssembly[] { + return assemblyLocations.map(loadAssembly); - async function loadAssembly(location: string): Promise { - const stat = await fs.stat(location); + function loadAssembly(location: string): LoadedAssembly { + const stat = fs.statSync(location); if (stat.isDirectory()) { - return loadAssembly(path.join(location, '.jsii')); + return loadAssembly(getAssemblyFile(location)); } const directory = path.dirname(location); const pjLocation = path.join(directory, 'package.json'); - const [assembly, packageJson] = await Promise.all([ - loadAssemblyFromFile(location, validateAssemblies), - (await fs.pathExists(pjLocation)) ? fs.readJSON(pjLocation, { encoding: 'utf-8' }) : Promise.resolve(undefined), - ]); + const assembly = loadAssemblyFromFile(location, validateAssemblies); + const packageJson = fs.pathExistsSync(pjLocation) ? fs.readJSONSync(pjLocation, { encoding: 'utf-8' }) : undefined; return { assembly, directory, packageJson }; } } -async function loadAssemblyFromFile(filename: string, validate: boolean): Promise { - const contents = await fs.readJSON(filename, { encoding: 'utf-8' }); - return validate ? spec.validateAssembly(contents) : (contents as spec.Assembly); -} - /** * Load the default tablets for every assembly, if available * @@ -236,12 +230,12 @@ export async function allTypeScriptSnippets( * Replaces the file where the original assembly file *should* be found with a new assembly file. * Recalculates the fingerprint of the assembly to avoid tampering detection. */ -export async function replaceAssembly(assembly: spec.Assembly, directory: string): Promise { - const fileName = path.join(directory, '.jsii'); - await fs.writeJson(fileName, _fingerprint(assembly), { - encoding: 'utf8', - spaces: 2, - }); +export function replaceAssembly( + assembly: spec.Assembly, + directory: string, + { compress = false }: { compress?: boolean } = {}, +) { + writeAssembly(directory, _fingerprint(assembly), { compress }); } /** @@ -296,24 +290,23 @@ export function findTypeLookupAssembly(startingDirectory: string): TypeLookupAss } function loadLookupAssembly(directory: string): TypeLookupAssembly | undefined { - const assemblyFile = path.join(directory, '.jsii'); - if (!fs.pathExistsSync(assemblyFile)) { + try { + const packageJson = fs.readJSONSync(path.join(directory, 'package.json'), { encoding: 'utf-8' }); + const assembly: spec.Assembly = loadAssemblyFromPath(directory); + const symbolIdMap = mkDict([ + ...Object.values(assembly.types ?? {}).map((type) => [type.symbolId ?? '', type.fqn] as const), + ...Object.entries(assembly.submodules ?? {}).map(([fqn, mod]) => [mod.symbolId ?? '', fqn] as const), + ]); + + return { + packageJson, + assembly, + directory, + symbolIdMap, + }; + } catch { return undefined; } - - const packageJson = fs.readJSONSync(path.join(directory, 'package.json'), { encoding: 'utf-8' }); - const assembly: spec.Assembly = fs.readJSONSync(assemblyFile, { encoding: 'utf-8' }); - const symbolIdMap = mkDict([ - ...Object.values(assembly.types ?? {}).map((type) => [type.symbolId ?? '', type.fqn] as const), - ...Object.entries(assembly.submodules ?? {}).map(([fqn, mod]) => [mod.symbolId ?? '', fqn] as const), - ]); - - return { - packageJson, - assembly, - directory, - symbolIdMap, - }; } function findPackageJsonLocation(currentPath: string): string | undefined { diff --git a/packages/jsii-rosetta/test/commands/extract.test.ts b/packages/jsii-rosetta/test/commands/extract.test.ts index f85ce17322..1c2b5db23a 100644 --- a/packages/jsii-rosetta/test/commands/extract.test.ts +++ b/packages/jsii-rosetta/test/commands/extract.test.ts @@ -1,3 +1,4 @@ +import { SPEC_FILE_NAME_COMPRESSED } from '@jsii/spec'; import * as fs from 'fs-extra'; import { compileJsiiForTest } from 'jsii'; import * as path from 'path'; @@ -72,6 +73,49 @@ test('extract samples from test assembly', async () => { expect(tablet.snippetKeys.length).toEqual(1); }); +test('extract works from compressed test assembly', async () => { + const compressedAssembly = TestJsiiModule.fromSource( + { + 'index.ts': ` + export class ClassA { + public someMethod() { + } + } + export class ClassB { + public anotherMethod() { + } + } + `, + 'README.md': DUMMY_README, + }, + { + name: 'my_assembly', + jsii: DUMMY_JSII_CONFIG, + }, + { + compressAssembly: true, + }, + ); + + try { + // assert that assembly is zipped + expect(fs.existsSync(path.join(compressedAssembly.moduleDirectory, SPEC_FILE_NAME_COMPRESSED))).toBeTruthy(); + + // behavior is as expected + const cacheToFile = path.join(compressedAssembly.moduleDirectory, 'test.tabl.json'); + await extract.extractSnippets([compressedAssembly.moduleDirectory], { + cacheToFile, + ...defaultExtractOptions, + }); + + const tablet = new LanguageTablet(); + await tablet.load(cacheToFile); + expect(tablet.snippetKeys.length).toEqual(1); + } finally { + compressedAssembly.cleanup(); + } +}); + describe('with cache file', () => { let cacheTabletFile: string; beforeEach(async () => { @@ -233,7 +277,7 @@ describe('with cache file', () => { assembly.assembly.types!['my_assembly.ClassB'].docs = { example: 'ClassB.anotherMethod();', }; - await assembly.updateAssembly(); + assembly.updateAssembly(); const translationFunction = jest.fn().mockResolvedValue({ diagnostics: [], translatedSnippets: [] }); @@ -428,7 +472,7 @@ test('extract and infuse in one command', async () => { expect(locations).toContain('type'); expect(locations).toContain('moduleReadme'); - const assemblies = await loadAssemblies([assembly.moduleDirectory], false); + const assemblies = loadAssemblies([assembly.moduleDirectory], false); const types = assemblies[0].assembly.types; // infuse works as expected @@ -478,7 +522,7 @@ describe('infused examples', () => { // Nothing like this should happen in practice infusedAssembly.assembly.types!['my_assembly.ClassA'].docs!.custom!.exampleMetadata = 'infused fixture=myfix.ts-fixture'; - await infusedAssembly.updateAssembly(); + infusedAssembly.updateAssembly(); // Expect to return cached snippet regardless of change // No compilation should happen @@ -495,7 +539,7 @@ describe('infused examples', () => { test('skip loose mode', async () => { // Remove infused for now and add lit metadata that should fail infusedAssembly.assembly.types!['my_assembly.ClassA'].docs!.custom!.exampleMetadata = 'lit=integ.test.ts'; - await infusedAssembly.updateAssembly(); + infusedAssembly.updateAssembly(); const cacheToFile = path.join(infusedAssembly.moduleDirectory, 'test.tabl.json'); @@ -509,7 +553,7 @@ describe('infused examples', () => { // Add infused to metadata and update assembly infusedAssembly.assembly.types!['my_assembly.ClassA'].docs!.custom!.exampleMetadata = 'lit=integ.test.ts infused'; - await infusedAssembly.updateAssembly(); + infusedAssembly.updateAssembly(); // Expect same function call to succeed now await extract.extractSnippets([infusedAssembly.moduleDirectory], { diff --git a/packages/jsii-rosetta/test/commands/infuse.test.ts b/packages/jsii-rosetta/test/commands/infuse.test.ts index d0a20ea54b..28cfffb5c7 100644 --- a/packages/jsii-rosetta/test/commands/infuse.test.ts +++ b/packages/jsii-rosetta/test/commands/infuse.test.ts @@ -1,4 +1,4 @@ -import * as spec from '@jsii/spec'; +import { loadAssemblyFromPath } from '@jsii/spec'; import * as fs from 'fs-extra'; import * as path from 'path'; @@ -61,7 +61,7 @@ afterEach(() => assembly.cleanup()); test('examples are added in the assembly', async () => { await infuse([assembly.moduleDirectory]); - const assemblies = await loadAssemblies([assembly.moduleDirectory], false); + const assemblies = loadAssemblies([assembly.moduleDirectory], false); const types = assemblies[0].assembly.types; expect(types).toBeDefined(); expect(types!['my_assembly.ClassA'].docs?.example).toBeDefined(); @@ -71,7 +71,7 @@ test('infuse copies example metadata', async () => { await infuse([assembly.moduleDirectory]); // THEN: the metadata that used to be on the README snippet is also on the class example - const updatedAssembly = (await fs.readJson(path.join(assembly.moduleDirectory, '.jsii'))) as spec.Assembly; + const updatedAssembly = loadAssemblyFromPath(assembly.moduleDirectory); const typeDocs = updatedAssembly.types?.['my_assembly.ClassA']?.docs; expect(typeDocs?.custom?.exampleMetadata).toEqual('some=metadata infused'); diff --git a/packages/jsii-rosetta/test/commands/transliterate.test.ts b/packages/jsii-rosetta/test/commands/transliterate.test.ts index b7358984a5..585fe66c47 100644 --- a/packages/jsii-rosetta/test/commands/transliterate.test.ts +++ b/packages/jsii-rosetta/test/commands/transliterate.test.ts @@ -1,4 +1,4 @@ -import { Assembly, SPEC_FILE_NAME } from '@jsii/spec'; +import { Assembly, SPEC_FILE_NAME, writeAssembly } from '@jsii/spec'; import * as fs from 'fs-extra'; import * as jsii from 'jsii'; import * as path from 'path'; @@ -15,7 +15,7 @@ jest.setTimeout(60_000); const targets = Object.values(TargetLanguage).reduce((tgt, lang) => { tgt[targetName(lang)] = { phony: true }; return tgt; -}, {} as Record); +}, {} as Record); test('single assembly, all languages', () => withTemporaryDirectory(async (tmpDir) => { @@ -1623,3 +1623,109 @@ export class ClassName implements IInterface { } }); })); + +test('transliterate works with zipped assembly files', async () => + withTemporaryDirectory(async (tmpDir) => { + // GIVEN + const compilationResult = jsii.compileJsiiForTest({ + 'README.md': ` +# README +\`\`\`ts +const object: IInterface = new ClassName('this', 1337, { foo: 'bar' }); +object.property = EnumType.OPTION_A; +object.methodCall(); +ClassName.staticMethod(EnumType.OPTION_B); +\`\`\` +`, + 'index.ts': ` +/** + * @example new ClassName('this', 1337, { property: EnumType.OPTION_B }); + */ +export enum EnumType { + /** + * @example new ClassName('this', 1337, { property: EnumType.OPTION_A }); + */ + OPTION_A = 1, + /** + * @example new ClassName('this', 1337, { property: EnumType.OPTION_B }); + */ + OPTION_B = 2, +} +export interface IInterface { + /** + * A property value. + * + * @example + * iface.property = EnumType.OPTION_B; + */ + property: EnumType; + /** + * An instance method call. + * + * @example + * iface.methodCall(); + */ + methodCall(): void; +} +export interface ClassNameProps { + readonly property?: EnumType; + readonly foo?: string; +} +export class ClassName implements IInterface { + /** + * A static method. It can be invoked easily. + * + * @example ClassName.staticMethod(); + */ + public static staticMethod(_enm?: EnumType): void { + // ... + } + public property: EnumType; + /** + * Create a new instance of ClassName. + * + * @example new ClassName('this', 1337, { property: EnumType.OPTION_B }); + */ + public constructor(_this: string, _elite: number, props: ClassNameProps) { + this.property = props.property ?? EnumType.OPTION_A; + } + public methodCall(): void { + // ... + } +}`, + }); + + writeAssembly( + tmpDir, + { + ...compilationResult.assembly, + targets: { ...targets }, + }, + { compress: true }, + ); + for (const [file, content] of Object.entries(compilationResult.files)) { + fs.writeFileSync(path.resolve(tmpDir, file), content, 'utf-8'); + } + fs.mkdirSync(path.resolve(tmpDir, 'rosetta')); + fs.writeFileSync( + path.resolve(tmpDir, 'rosetta', 'default.ts-fixture'), + `import { EnumType, IInterface, ClassName } from '.';\ndeclare const iface: IInterface\n/// here`, + 'utf-8', + ); + + // WHEN + // create outdir + const outdir = path.resolve(tmpDir, 'out'); + fs.mkdirSync(outdir); + + await expect( + transliterateAssembly([tmpDir], Object.values(TargetLanguage), { + strict: true, + outdir, + }), + ).resolves.not.toThrow(); + + Object.values(TargetLanguage).forEach((lang) => { + expect(fs.statSync(path.join(outdir, `${SPEC_FILE_NAME}.${lang}`)).isFile()).toBe(true); + }); + })); diff --git a/packages/jsii-rosetta/test/testutil.ts b/packages/jsii-rosetta/test/testutil.ts index 93a90c7606..a54efa47ba 100644 --- a/packages/jsii-rosetta/test/testutil.ts +++ b/packages/jsii-rosetta/test/testutil.ts @@ -1,4 +1,4 @@ -import * as spec from '@jsii/spec'; +import { Assembly, writeAssembly } from '@jsii/spec'; import * as fs from 'fs-extra'; import { PackageInfo, compileJsiiForTest, TestWorkspace } from 'jsii'; import * as os from 'os'; @@ -15,6 +15,13 @@ import { export type MultipleSources = { [key: string]: string; 'index.ts': string }; +export interface TestJsiiModuleOptions { + /** + * Whether or not to compress the assembly + */ + readonly compressAssembly?: boolean; +} + /** * Compile a jsii module from source, and produce an environment in which it is available as a module */ @@ -22,20 +29,26 @@ export class TestJsiiModule { public static fromSource( source: string | MultipleSources, packageInfo: Partial & { name: string; main?: string; types?: string }, + options: TestJsiiModuleOptions = {}, ): TestJsiiModule { - const asm = compileJsiiForTest(source, (pi) => { - Object.assign(pi, packageInfo); + const asm = compileJsiiForTest(source, { + packageJson: packageInfo, + compressAssembly: options.compressAssembly, }); const ws = TestWorkspace.create(); ws.addDependency(asm); - return new TestJsiiModule(asm.assembly, ws); + return new TestJsiiModule(asm.assembly, ws, asm.compressAssembly === true); } public readonly moduleDirectory: string; public readonly workspaceDirectory: string; - private constructor(public readonly assembly: spec.Assembly, public readonly workspace: TestWorkspace) { + private constructor( + public readonly assembly: Assembly, + public readonly workspace: TestWorkspace, + private readonly compressAssembly: boolean, + ) { this.moduleDirectory = workspace.dependencyDir(assembly.name); this.workspaceDirectory = workspace.rootDirectory; } @@ -80,8 +93,8 @@ export class TestJsiiModule { /** * Update the file to reflect the latest changes to the assembly object. */ - public async updateAssembly() { - await fs.writeJSON(path.join(this.moduleDirectory, '.jsii'), this.assembly); + public updateAssembly() { + writeAssembly(this.moduleDirectory, this.assembly, { compress: this.compressAssembly }); } public cleanup() { diff --git a/packages/jsii/bin/jsii.ts b/packages/jsii/bin/jsii.ts index 482466c7ab..11a8d5d7d5 100644 --- a/packages/jsii/bin/jsii.ts +++ b/packages/jsii/bin/jsii.ts @@ -70,6 +70,11 @@ const warningTypes = Object.keys(enabledWarnings); type: 'string', default: 'tsconfig.json', desc: 'Name of the typescript configuration file to generate with compiler settings', + }) + .option('compress-assembly', { + type: 'boolean', + default: false, + desc: 'Emit a compressed version of the assembly', }), ) .option('verbose', { @@ -113,6 +118,7 @@ const warningTypes = Object.keys(enabledWarnings); stripDeprecatedAllowListFile: argv['strip-deprecated'], addDeprecationWarnings: argv['add-deprecation-warnings'], generateTypeScriptConfig: argv['generate-tsconfig'], + compressAssembly: argv['compress-assembly'], }); const emitResult = argv.watch ? await compiler.watch() : compiler.emit(); diff --git a/packages/jsii/lib/assembler.ts b/packages/jsii/lib/assembler.ts index 2e311c7cf2..1c08b77907 100644 --- a/packages/jsii/lib/assembler.ts +++ b/packages/jsii/lib/assembler.ts @@ -44,6 +44,7 @@ export class Assembler implements Emitter { private readonly mainFile: string; private readonly tscRootDir?: string; + private readonly compressAssembly?: boolean; private readonly _typeChecker: ts.TypeChecker; @@ -108,6 +109,8 @@ export class Assembler implements Emitter { ); } + this.compressAssembly = options.compressAssembly; + const dts = projectInfo.types; let mainFile = dts.replace(/\.d\.ts(x?)$/, '.ts$1'); @@ -276,14 +279,16 @@ export class Assembler implements Emitter { const validator = new Validator(this.projectInfo, assembly); const validationResult = validator.emit(); if (!validationResult.emitSkipped) { + const zipped = writeAssembly( + this.projectInfo.projectRoot, + _fingerprint(assembly), + { compress: this.compressAssembly ?? false }, + ); LOG.trace( - `Emitting assembly: ${chalk.blue( + `${zipped ? 'Zipping' : 'Emitting'} assembly: ${chalk.blue( path.join(this.projectInfo.projectRoot, SPEC_FILE_NAME), )}`, ); - writeAssembly(this.projectInfo.projectRoot, _fingerprint(assembly), { - compress: false, - }); } try { @@ -2758,6 +2763,13 @@ export interface AssemblerOptions { * @default false */ readonly addDeprecationWarnings?: boolean; + + /** + * Whether to compress the assembly. + * + * @default false + */ + readonly compressAssembly?: boolean; } interface SubmoduleSpec { diff --git a/packages/jsii/lib/compiler.ts b/packages/jsii/lib/compiler.ts index dc81f74a6b..9d0a22a1f5 100644 --- a/packages/jsii/lib/compiler.ts +++ b/packages/jsii/lib/compiler.ts @@ -60,6 +60,11 @@ export interface CompilerOptions { * @default "tsconfig.json" */ generateTypeScriptConfig?: string; + /** + * Whether to compress the assembly + * @default false + */ + compressAssembly?: boolean; } export interface TypescriptConfig { @@ -248,6 +253,7 @@ export class Compiler implements Emitter { stripDeprecated: this.options.stripDeprecated, stripDeprecatedAllowListFile: this.options.stripDeprecatedAllowListFile, addDeprecationWarnings: this.options.addDeprecationWarnings, + compressAssembly: this.options.compressAssembly, }); try { diff --git a/packages/jsii/lib/helpers.ts b/packages/jsii/lib/helpers.ts index 102ef133c5..d600ba9ad6 100644 --- a/packages/jsii/lib/helpers.ts +++ b/packages/jsii/lib/helpers.ts @@ -7,7 +7,7 @@ */ import * as spec from '@jsii/spec'; -import { PackageJson } from '@jsii/spec'; +import { PackageJson, loadAssemblyFromPath, writeAssembly } from '@jsii/spec'; import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; @@ -47,6 +47,7 @@ export interface HelperCompilationResult { * The generated assembly */ readonly assembly: spec.Assembly; + /** * Generated .js/.d.ts file(s) */ @@ -56,6 +57,11 @@ export interface HelperCompilationResult { * The packageInfo used */ readonly packageJson: PackageJson; + + /** + * Whether to compress the assembly file + */ + readonly compressAssembly: boolean; } /** @@ -116,7 +122,7 @@ export function compileJsiiForTest( if (errors.length > 0 || emitResult.emitSkipped) { throw new Error('There were compiler errors'); } - const assembly = fs.readJsonSync('.jsii', { encoding: 'utf-8' }); + const assembly = loadAssemblyFromPath(process.cwd(), false); const files: Record = {}; for (const filename of Object.keys(source)) { @@ -141,7 +147,13 @@ export function compileJsiiForTest( } } - return { assembly, files, packageJson } as HelperCompilationResult; + return { + assembly, + files, + packageJson, + compressAssembly: + isOptionsObject(options) && options.compressAssembly ? true : false, + } as HelperCompilationResult; }); } @@ -205,6 +217,7 @@ function makeProjectInfo( const { projectInfo } = loadProjectInfo(path.resolve(process.cwd(), '.')); return { projectInfo, packageJson }; } + export interface TestCompilationOptions { /** * The directory in which we write and compile the files @@ -224,6 +237,13 @@ export interface TestCompilationOptions { * @default - Use some default values */ readonly packageJson?: Partial; + + /** + * Whether to compress the assembly file. + * + * @default false + */ + readonly compressAssembly?: boolean; } function isOptionsObject( @@ -287,7 +307,9 @@ export class TestWorkspace { ); fs.ensureDirSync(modDir); - fs.writeJsonSync(path.join(modDir, '.jsii'), dependencyAssembly.assembly); + writeAssembly(modDir, dependencyAssembly.assembly, { + compress: dependencyAssembly.compressAssembly, + }); fs.writeJsonSync( path.join(modDir, 'package.json'), dependencyAssembly.packageJson, @@ -296,9 +318,7 @@ export class TestWorkspace { for (const [fileName, fileContents] of Object.entries( dependencyAssembly.files, )) { - // eslint-disable-next-line no-await-in-loop fs.ensureDirSync(path.dirname(path.join(modDir, fileName))); - // eslint-disable-next-line no-await-in-loop fs.writeFileSync(path.join(modDir, fileName), fileContents); } } diff --git a/packages/jsii/lib/project-info.ts b/packages/jsii/lib/project-info.ts index d94259c15d..154260328e 100644 --- a/packages/jsii/lib/project-info.ts +++ b/packages/jsii/lib/project-info.ts @@ -1,4 +1,5 @@ import * as spec from '@jsii/spec'; +import { getAssemblyFile, loadAssemblyFromFile } from '@jsii/spec'; import * as fs from 'fs-extra'; import * as log4js from 'log4js'; import * as path from 'path'; @@ -361,10 +362,9 @@ class DependencyResolver { return this.cache.get(jsiiFile)!; } - // eslint-disable-next-line no-await-in-loop - const assembly = this.loadAssembly(jsiiFile); - // Continue loading any dependencies declared in the asm + const assembly = loadAssemblyFromFile(jsiiFile); + // Continue loading any dependencies declared in the asm const resolvedDependencies = assembly.dependencies ? this.discoverDependencyTree( path.dirname(jsiiFile), @@ -379,17 +379,6 @@ class DependencyResolver { this.cache.set(jsiiFile, depInfo); return depInfo; } - - /** - * Load a JSII filename and validate it; cached to avoid redundant loads of the same JSII assembly - */ - private loadAssembly(jsiiFileName: string): spec.Assembly { - try { - return fs.readJsonSync(jsiiFileName); - } catch (e: any) { - throw new Error(`Error loading ${jsiiFileName}: ${e}`); - } - } } function _required(value: T, message: string): T { @@ -443,7 +432,7 @@ function _tryResolveAssembly( searchPath: string, ): string { if (localPackage) { - const result = path.join(localPackage, '.jsii'); + const result = getAssemblyFile(localPackage); if (!fs.existsSync(result)) { throw new Error(`Assembly does not exist: ${result}`); } @@ -451,7 +440,7 @@ function _tryResolveAssembly( } try { const dependencyDir = findDependencyDirectory(mod, searchPath); - return path.join(dependencyDir, '.jsii'); + return getAssemblyFile(dependencyDir); } catch (e: any) { throw new Error( `Unable to locate jsii assembly for "${mod}". If this module is not jsii-enabled, it must also be declared under bundledDependencies: ${e}`, diff --git a/packages/jsii/test/compiler.test.ts b/packages/jsii/test/compiler.test.ts index 05244d9fa2..162c6bf79a 100644 --- a/packages/jsii/test/compiler.test.ts +++ b/packages/jsii/test/compiler.test.ts @@ -1,5 +1,11 @@ +import { + loadAssemblyFromPath, + SPEC_FILE_NAME, + SPEC_FILE_NAME_COMPRESSED, +} from '@jsii/spec'; import { ensureDirSync, + existsSync, mkdtempSync, removeSync, writeFileSync, @@ -82,9 +88,7 @@ describe(Compiler, () => { compilationComplete: (emitResult) => { try { expect(emitResult.emitSkipped).toBeFalsy(); - const output = readFileSync(join(sourceDir, '.jsii'), { - encoding: 'utf-8', - }); + const output = JSON.stringify(loadAssemblyFromPath(sourceDir)); if (firstCompilation) { firstCompilation = false; expect(output).toContain('"MarkerA"'); @@ -138,7 +142,7 @@ describe(Compiler, () => { compiler.emit(); - const assembly = readJsonSync(join(sourceDir, '.jsii'), 'utf-8'); + const assembly = loadAssemblyFromPath(sourceDir); expect(assembly.metadata).toEqual( expect.objectContaining({ tscRootDir: rootDir, @@ -174,6 +178,67 @@ describe(Compiler, () => { removeSync(sourceDir); } }); + + describe('compressed assembly option', () => { + test('creates a gzipped assembly file', () => { + const sourceDir = mkdtempSync(join(tmpdir(), 'jsii-tmpdir')); + + try { + writeFileSync(join(sourceDir, 'index.ts'), 'export class MarkerA {}'); + + const compiler = new Compiler({ + projectInfo: _makeProjectInfo(sourceDir, 'index.d.ts'), + compressAssembly: true, + }); + + compiler.emit(); + + expect( + existsSync(join(sourceDir, SPEC_FILE_NAME_COMPRESSED)), + ).toBeTruthy(); + } finally { + removeSync(sourceDir); + } + }); + + test('creates file equivalent to uncompressed file', () => { + const uncompressedSourceDir = mkdtempSync(join(tmpdir(), 'jsii-tmpdir')); + const compressedSourceDir = mkdtempSync(join(tmpdir(), 'jsii-tmpdir-2')); + + try { + const fileContents = 'export class MarkerA {}'; + writeFileSync(join(uncompressedSourceDir, 'index.ts'), fileContents); + writeFileSync(join(compressedSourceDir, 'index.ts'), fileContents); + + const uncompressedJsiiCompiler = new Compiler({ + projectInfo: _makeProjectInfo(uncompressedSourceDir, 'index.d.ts'), + }); + const compressedJsiiCompiler = new Compiler({ + projectInfo: _makeProjectInfo(compressedSourceDir, 'index.d.ts'), + compressAssembly: true, + }); + + uncompressedJsiiCompiler.emit(); + compressedJsiiCompiler.emit(); + + // The files we expect are there + expect( + existsSync(join(uncompressedSourceDir, SPEC_FILE_NAME)), + ).toBeTruthy(); + expect( + existsSync(join(compressedSourceDir, SPEC_FILE_NAME_COMPRESSED)), + ).toBeTruthy(); + + const uncompressedJsii = loadAssemblyFromPath(uncompressedSourceDir); + const compressedJsii = loadAssemblyFromPath(compressedSourceDir); + + expect(compressedJsii).toEqual(uncompressedJsii); + } finally { + removeSync(uncompressedSourceDir); + removeSync(compressedSourceDir); + } + }); + }); }); function _makeProjectInfo(sourceDir: string, types: string): ProjectInfo { diff --git a/packages/jsii/test/project-info.test.ts b/packages/jsii/test/project-info.test.ts index a2340d291a..0f3920dbdf 100644 --- a/packages/jsii/test/project-info.test.ts +++ b/packages/jsii/test/project-info.test.ts @@ -1,4 +1,5 @@ import * as spec from '@jsii/spec'; +import { writeAssembly } from '@jsii/spec'; import * as clone from 'clone'; import * as fs from 'fs-extra'; import * as os from 'os'; @@ -59,6 +60,40 @@ describe('loadProjectInfo', () => { ]); })); + test('loads valid project (with zipped assembly)', () => + _withTestProject( + (projectRoot) => { + const { projectInfo: info } = loadProjectInfo(projectRoot); + expect(info.name).toBe(BASE_PROJECT.name); + expect(info.version).toBe(BASE_PROJECT.version); + expect(info.description).toBe(BASE_PROJECT.description); + expect(info.license).toBe(BASE_PROJECT.license); + expect(_stripUndefined(info.author)).toEqual({ + ...BASE_PROJECT.author, + roles: ['author'], + }); + expect(info.main).toBe(BASE_PROJECT.main); + expect(info.types).toBe(BASE_PROJECT.types); + expect(info.homepage).toBe(undefined); + expect(info.repository?.type).toBe('git'); + expect(info.repository?.url).toBe(BASE_PROJECT.repository.url); + expect(info.targets).toEqual({ + ...BASE_PROJECT.jsii.targets, + js: { npm: BASE_PROJECT.name }, + }); + expect(info.dependencies).toEqual({ + [TEST_DEP_ASSEMBLY.name]: + BASE_PROJECT.dependencies[TEST_DEP_ASSEMBLY.name], + }); + expect(info.dependencyClosure).toEqual([ + TEST_DEP_ASSEMBLY, + TEST_DEP_DEP_ASSEMBLY, + ]); + }, + undefined, + true /* compress assembly */, + )); + test('loads valid project (UNLICENSED)', () => _withTestProject( (projectRoot) => { @@ -294,6 +329,7 @@ const TEST_DEP_DEP_ASSEMBLY: spec.Assembly = { function _withTestProject( cb: (projectRoot: string) => T, gremlin?: (packageInfo: any) => void, + compressAssembly = false, ): T { const tmpdir = fs.mkdtempSync( path.join(os.tmpdir(), path.basename(__filename)), @@ -322,7 +358,9 @@ function _withTestProject( const jsiiTestDep = path.join(tmpdir, 'node_modules', 'jsii-test-dep'); writeNpmPackageSkeleton(jsiiTestDep); - fs.writeJsonSync(path.join(jsiiTestDep, '.jsii'), TEST_DEP_ASSEMBLY); + writeAssembly(jsiiTestDep, TEST_DEP_ASSEMBLY, { + compress: compressAssembly, + }); const jsiiTestDepDep = path.join( jsiiTestDep, 'node_modules', @@ -330,7 +368,9 @@ function _withTestProject( ); writeNpmPackageSkeleton(jsiiTestDepDep); - fs.writeJsonSync(path.join(jsiiTestDepDep, '.jsii'), TEST_DEP_DEP_ASSEMBLY); + writeAssembly(jsiiTestDepDep, TEST_DEP_DEP_ASSEMBLY, { + compress: compressAssembly, + }); return cb(tmpdir); } finally { diff --git a/packages/jsii/test/submodules.test.ts b/packages/jsii/test/submodules.test.ts index beeedc4bc5..988589a8d6 100644 --- a/packages/jsii/test/submodules.test.ts +++ b/packages/jsii/test/submodules.test.ts @@ -1,6 +1,5 @@ import * as spec from '@jsii/spec'; -import * as fs from 'fs-extra'; -import * as path from 'path'; +import { writeAssembly, loadAssemblyFromPath } from '@jsii/spec'; import { sourceToAssemblyHelper, @@ -262,15 +261,15 @@ test('will detect types from submodules even if the symbol identifier table is m ws.addDependency(makeDependencyWithSubmodule()); // Strip the symbolidentifiers from the assembly - const asmFile = path.join(ws.dependencyDir('testpkg'), '.jsii'); - const asm: spec.Assembly = fs.readJsonSync(asmFile); + const asmDir = ws.dependencyDir('testpkg'); + const asm: spec.Assembly = loadAssemblyFromPath(asmDir, false); for (const mod of Object.values(asm.submodules ?? {})) { delete mod.symbolId; } for (const type of Object.values(asm.types ?? {})) { delete type.symbolId; } - fs.writeJsonSync(asmFile, asm); + writeAssembly(asmDir, asm); // We can still use those types if we have a full-library import compileJsiiForTest(