Serving private Composer packages with serverless Cloudflare Workers and R2 storage

As a PHP developer, you may be familiar with the need to share private libraries across projects without exposing them to the public. In this post, I will guide you through the simple yet powerful alternative that leverages Cloudflare’s serverless platform. I’ve created a solution that bypasses the need for a package repository server altogether, offering a streamlined and secure method for package distribution.

Cloudflare Workers for Private Package Distribution

Cloudflare Workers is the serverless platform on Cloudflare. Let’s delve into the Cloudflare Workers code that powers our private Composer package distribution:

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  const url = new URL(request.url);

  if (url.pathname === '/packages.json') {
    // Dynamically generate the packages.json data for Composer
    const packagesJson = {
      'available-packages': [
        'daext/interlinks-manager'
      ],
      'packages': {
        'daext/interlinks-manager': {
          '1.33': {
            'name': 'daext/interlinks-manager',
            'version': '1.33',
            'type': 'wordpress-plugin',
            'dist': {
              'url': url.protocol + '//' + url.host + '/files/interlinks-manager-1.33.zip',
              'type': 'zip'
            }
          }
        }
      }
    };

    return new Response(JSON.stringify(packagesJson), {
      headers: { 'Content-Type': 'application/json' }
    });
  }

  // Code to handle other paths will go here...
  
  return new Response('Not found', { status: 404 });
}

In this script, when a request for /packages.json is received, we respond with a JSON object that Composer uses to understand what packages are available and where to retrieve them.

Package Storage with Cloudflare R2

Cloudflare R2 Storage is an S3-compatible storage solution that offers a generous free tier. Here’s how the relevant part of our Worker script handles requests to files stored in R2:

if (url.pathname.startsWith('/files/')) {
  // Extract the filename from the URL
  const objectKey = url.pathname.replace(new RegExp('^/files/'), '');

  try {
    // Attempt to retrieve the file from the R2 bucket
    let file = await R2_BUCKET.get(objectKey);

    if (!file) {
      return new Response('File not found', { status: 404 });
    }

    // If the file is found, return the file content to the client
    return new Response(file.body, {
      headers: {
        'Content-Type': 'application/octet-stream',
        'Content-Disposition': `attachment; filename="${encodeURIComponent(objectKey)}"`
      }
    });
  } catch (error) {
    console.error(`Error fetching file: ${error}`);
    return new Response('Error fetching file', { status: 500 });
  }
}

Harnessing Cloudflare KV for Robust Authentication

Ensure that only authorized users can access your packages. Our Cloudflare Worker script uses Cloudflare Workers KV as a robust storage system for storing credentials.

// Helper function to validate the username/password against the KV store
async function isValidBasicAuth(authHeader) {
    const encodedCredentials = authHeader.split(' ')[1];
    const decodedCredentials = atob(encodedCredentials);
    const [username, password] = decodedCredentials.split(':');

    return password === await AUTH.get(username);
}

Configuring Composer to use our Serverless Package Repository

Here’s what you need to add to your local composer.json to add the custom repository:

{
    "repositories": [
        {
            "type": "composer",
            "url": "https://your-worker.yourdomain.com",
            "only": [
                "daext/interlinks-manager"
            ]
        }
    ],
    "require": {
        "daext/interlinks-manager": "1.33"
    }
}

To securely transmit your credentials to the worker, create an auth.json file:

{
    "http-basic": {
        "your-worker.yourdomain.com": {
            "username": "your-username",
            "password": "your-password"
        }
    }
}