In the previous chapter, we built a script that compiles its own dependencies. To share this on CPAN, we want a standard module structure.

Most modern Perl developers use Module::Build to manage the installation of their distributions. Module::Build can build an XS based module automatically but has no idea how to build with Affix::Build. To work around this, we must create a custom builder class to hook into the build process and compile our C library exactly when the user runs ./Build.

We're going to use Minilla to mint our dists. A complete copy of this demo can be found here on github.

1. The Setup

Initialize your project:

minil new Acme-Image-Stb
cd Acme-Image-Stb
mkdir builder

2. Configuration (minil.toml)

We need to tell Minilla two things:

  1. Use Module::Build (instead of Module::Build::Tiny).
  2. Use our custom class (builder::MyBuilder) to drive the process.

Edit minil.toml:

name = "Acme-Image-Stb"
badges = ["github-actions/test.yml"]
module_maker="ModuleBuild"
static_install = "auto"

[build]
build_class = "builder::MyBuilder"

3. The Custom Builder (builder/MyBuilder.pm)

This is where the magic happens. We subclass Module::Build and override the ACTION_code method. This method runs when the user types ./Build.

We use Affix::Build to build the library and place it directly into the staging directory (blib/arch), ensuring it gets installed correctly.

package builder::MyBuilder;
use v5.40;
use parent 'Module::Build';
use Affix::Build;
use HTTP::Tiny;
use Path::Tiny;
use Config;

sub ACTION_code ($self) {
    unless ( defined $self->config_data('lib') ) {
        say 'Building embedded C library...';

        # Setup source directory
        my $src_dir = path('_src');
        $src_dir->mkpath;

        # Download headers if missing. In reality, you would probably just bundle the headers with the dist.
        # But this isn't reality.
        my $http = HTTP::Tiny->new;
        for my $file (qw(stb_image.h stb_image_resize2.h stb_image_write.h)) {
            my $path = $src_dir->child($file);
            next if $path->exists;
            say "  Fetching $file...";
            my $res = $http->get("https://raw.githubusercontent.com/nothings/stb/master/$file");
            die "Failed to download $file" unless $res->{success};
            $path->spew_raw( $res->{content} );
        }

        # Determine output path
        # We want the DLL to end up in: blib/arch/auto/Acme/Image/Stb/stb.so.xx.xx
        # This ensures it is installed in the architecture-specific library path.
        my $dist_name = $self->dist_name;    # "Acme-Image-Stb"
        my @parts     = split /-/, $dist_name;
        my $arch_dir  = path( $self->blib, 'arch', 'auto', @parts );
        $arch_dir->mkpath;

        # Compile with Affix
        my $c = Affix::Build->new(
            version   => $self->dist_version,
            name      => 'stb',
            build_dir => $arch_dir,
            flags     => { cflags => "-I$src_dir -O3", ldflags => ( $^O eq 'MSWin32' ? '-Wl,--export-all-symbols' : '' ) }
        );
        $c->add( \<<~'C', lang => 'c' );
        #if defined(_WIN32)
          #define STBIDEF __declspec(dllexport)
          #define STBIWDEF __declspec(dllexport)
          #define STBIRDEF __declspec(dllexport)
        #else
          #define STBIDEF __attribute__((visibility("default")))
          #define STBIWDEF __attribute__((visibility("default")))
          #define STBIRDEF __attribute__((visibility("default")))
        #endif

        #define STB_IMAGE_IMPLEMENTATION
        #define STB_IMAGE_WRITE_IMPLEMENTATION
        #define STB_IMAGE_RESIZE_IMPLEMENTATION

        #include "stb_image.h"
        #include "stb_image_resize2.h"
        #include "stb_image_write.h"
    C
        my $lib_file = $c->link;
        say "  Compiled: $lib_file";
        $self->config_data( lib => $lib_file->basename );
    }

    # Run standard build steps (copying .pm files to blib/)
    $self->SUPER::ACTION_code;
}
1;

4. Dependencies (cpanfile)

We need to add our build tools to the configure phase.

requires 'perl', '5.040';
requires 'Affix', 'v1.0.3';

on 'configure' => sub {
    requires 'Module::Build';
    requires 'HTTP::Tiny';
    requires 'Path::Tiny';
    requires 'Affix::Build', 'v1.0.3';
};

on 'test' => sub {
    requires 'Test2::V0';
};

5. The Module (lib/Acme/Image/Stb.pm)

Since we installed the library into blib/arch/auto/..., we can't just look in the current directory. We need to look where Perl installs architecture-specific files (XS modules usually live here).

We use a helper to scan @INC.

package Acme::Image::Stb 0.01 {
    use v5.40;
    use Affix qw[:all];
    use Carp  qw[croak];
    use Config;
    use Acme::Image::Stb::ConfigData;
    use parent 'Exporter';
    our %EXPORT_TAGS = ( internals => [qw[stbi_load stbi_write_png stbir_resize_uint8_linear]], core => [qw[load_and_resize]] );
    $EXPORT_TAGS{all} = [ our @EXPORT_OK = sort map {@$_} values %EXPORT_TAGS ];

    # Locate Library
    # We look for: .../auto/Image/Stb/stb.so (or .dll)
    my $lib_name = Acme::Image::Stb::ConfigData->config('lib');
    my $lib_path;
    for my $dir (@INC) {
        my $check = "$dir/auto/Acme/Image/Stb/$lib_name";
        if ( -e $check ) {
            $lib_path = $check;
            last;
        }
    }
    croak "Could not find compiled library '$lib_name' in \@INC" unless $lib_path;
    my $lib = Affix::load_library($lib_path);

    # Bindings
    use constant STBIR_RGBA => 4;
    affix $lib, 'stbi_load',                 [ String, Pointer [Int], Pointer [Int], Pointer [Int], Int ] => Buffer;
    affix $lib, 'stbi_write_png',            [ String, Int, Int, Int, Buffer, Int ]                       => Int;
    affix $lib, 'stbir_resize_uint8_linear', [ Buffer, Int, Int, Int, Buffer, Int, Int, Int, Int ]        => Buffer;

    # API
    sub load_and_resize ( $input, $output, $scale ) {
        my ( $w, $h, $ch ) = ( 0, 0, 0 );

        # Load
        my $img = stbi_load( $input, \$w, \$h, \$ch, 4 );
        return undef if is_null($img);

        # Calculate
        my $nw      = int( $w * $scale );
        my $nh      = int( $h * $scale );
        my $out_buf = "\0" x ( $nw * $nh * 4 );

        # Resize
        my $res = stbir_resize_uint8_linear( $img, $w, $h, 0, $out_buf, $nw, $nh, 0, STBIR_RGBA );
        return undef if is_null($res);

        # Save
        stbi_write_png( $output, $nw, $nh, 4, $out_buf, 0 );
    }
}
1;
__END__

=pod

=encoding utf-8

=head1 NAME

Acme::Image::Stb - It's new $module

=head1 SYNOPSIS

    use Acme::Image::Stb;

=head1 DESCRIPTION

Acme::Image::Stb is ...

=head1 LICENSE

Copyright (C) You.

This library might be free software; you may or may not be able to redistribute it
and/or modify it under the same terms of the author.

=head1 AUTHOR

You E<lt>yournamehere@cpan.orgE<gt>

=cut

6. Development Workflow

With this setup, the standard Minilla commands work perfectly, but now they trigger a C compiler in the background.

# Install dependencies
$ cpanm --installdeps .

# Build and test
$ minil test

# Make the dist
$ minil dist

Kitchen Reminders

By subclassing Module::Build, we have seamlessly integrated Affix::Build into the standard Perl toolchain.

  1. Transparency: Users installing via cpanm won't know you aren't using XS. It just works.
  2. Correctness: By targeting blib/arch/auto, we respect Perl's directory structure for binary binaries. This demo does it in a rather unsophisticated way...
  3. Flexibility: You can add logic to MyBuilder.pm to detect system libraries (via pkg-config or xrepo) and only compile the bundled version if the system version is missing.

Pat yourself on the back. You have now created a binary distribution ready for CPAN without writing a single line of XS.