logo
Hello World!

PatchKit Upload Widget

May 30, 2025 (Updated: July 5, 2025)
javascriptexpresspatchkit

Widget for creating launcher builds using PatchKit services with drag & drop upload and automatic platform detection.

The project was created at the end of 2022 in response to the need of some clients to whitelabel PatchKit services. The widget allows creating a ready-to-download Launcher build without using the platform's administration panel.

This is actually three projects in one:

  • pkWidget-interface_minimal: Simplified version showing how to use the API
  • pkWidget-interface_extended: Full-featured widget with polished interface
  • pkWidget-server: Example Node.js server ensuring API key security

All implementations are starter examples that developers can customize and extend with additional functionality.

The interface design was created by Daniel Łukucia, while the animations are my interpretation. The widget was also used as a demo of the platform's functionality and can be found on the PatchKit Demo page.



The widget implements a three-step launcher creation process:

  1. Upload: File validation → Platform detection → Chunked upload
  2. Processing: Application creation → Content processing → Progress tracking
  3. Publishing: Version publishing → Publication tracking → Download link

  • Drag & drop file upload: Native support for dragging ZIP archives
  • Client-side file validation:
    • Corrupted ZIP archive detection
    • ASCII character validation (32-127, excluding backslash)
    • File size control (10GB limit)
  • Intelligent platform detection:
    • Windows
    • macOS
    • Linux
  • File chunking with retry logic:
    • File splitting into configurable chunks
    • Automatic retry on connection errors
    • Progress tracking with chunk-level precision
  • Security: JWT authorization, server-side API key protection
  • Responsive interface: Smooth fade/collapse animations, visual feedback

Vanilla JavaScript (ES6), zip.js, Express.js, CSS3

The implementation was created in vanilla JavaScript, which was a project requirement. The solution needed to be universal and understandable for every developer, regardless of the frontend frameworks they use. While modern libraries greatly facilitate application development, writing in vanilla JS allowed for creating a lightweight and independent solution.

javascript
// Project structure
pkWidget-interface_extended/
├── src/pkWidget/
│   ├── main.js              // Main widget class
│   ├── Components.js        // Component registry
│   ├── engine.js            // Business logic (validate, detectPlatforms, upload, process)
│   ├── utils.js             // Helper functions (ZIP processing, platform detection)
│   ├── request.js           // API communication
│   ├── config.js            // Configuration (limits, endpoints)
│   ├── Alert.js             // Notification system
│   └── components/
│       ├── Component.js     // Base class with animations
│       ├── FileInput.js     // File handling and drag & drop
│       ├── StepProgress.js  // Progress indicator
│       ├── Progress.js      // Upload progress bar
│       ├── PlatformSelect.js// Platform selection
│       └── Button.js        // Button components
// Project structure
pkWidget-interface_extended/
├── src/pkWidget/
│   ├── main.js              // Main widget class
│   ├── Components.js        // Component registry
│   ├── engine.js            // Business logic (validate, detectPlatforms, upload, process)
│   ├── utils.js             // Helper functions (ZIP processing, platform detection)
│   ├── request.js           // API communication
│   ├── config.js            // Configuration (limits, endpoints)
│   ├── Alert.js             // Notification system
│   └── components/
│       ├── Component.js     // Base class with animations
│       ├── FileInput.js     // File handling and drag & drop
│       ├── StepProgress.js  // Progress indicator
│       ├── Progress.js      // Upload progress bar
│       ├── PlatformSelect.js// Platform selection
│       └── Button.js        // Button components

For ZIP archive processing, the zip.js library was used, which allows:

  • File structure reading: Archive content analysis without full extraction
  • Asynchronous processing: Non-blocking operations on large archives
  • Integrity validation: Corrupted archive detection
  • Metadata access: Information about size, file count, directory structure

A simple Node.js server with Express.js acts as a proxy between the widget and PatchKit API. This is an example implementation that developers can extend with additional functionality:

  • API key protection: API key stored server-side (not in browser)
  • PatchKit API proxy: Request forwarding (/upload, /createApp, /process, /publish, /fetchApp)
  • JWT token retrieval: Server retrieves JWT tokens from PatchKit API for uploads
  • Basic CORS handling: Default cors() configuration without additional restrictions

Possible extensions: rate limiting, advanced error handling, logging, request validation, domain-specific CORS configuration.

I created a class-based component system containing their properties and methods. Individual components extend the Component class, which contains universal methods for rendering, animation, and component destruction.

javascript
export default class Component {
  constructor(className) {
    this.componentDiv = document.createElement('div');
    this.componentDiv.className = 'widgetComponent';
    this.div = document.createElement('div');
    this.componentDiv.appendChild(this.div);
    this.div.className = className;
  }

  appendTo = (parentDiv) => parentDiv.appendChild(this.componentDiv);

  prependTo = (parentDiv) => parentDiv.prepend(this.componentDiv);

  remove = () => this.componentDiv.parentNode.removeChild(this.componentDiv);

  setOpacity(div, opacity = 1, time = 1) {
    if (!div) return;

    div.style.transitionProperty = 'opacity';
    div.style.transitionDuration = `${time}s`;

    div.style.opacity = opacity;

    return new Promise((resolve) => setTimeout(() => resolve(), time*1000));
  }
  
  collapse(state, time = 1) {
    this.setOpacity(this.componentDiv, 1, 0);

    const initialHeight = !state ? 0 : this.div.getBoundingClientRect().height;
    this.componentDiv.style.height = `${initialHeight}px`;

    this.componentDiv.style.transitionProperty = 'height';
    this.componentDiv.style.transitionTimingFunction = 'cubic-bezier(0, 0.5, 0, 1)';
    this.componentDiv.style.transitionDuration = `${time}s`;

    const height = state ? 0 : this.div.getBoundingClientRect().height;
    this.componentDiv.style.height = `${height}px`;

    state
    ? this.fadeOutContent(time/2)
    : this.fadeInContent(time/2);

    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }

  fadeIn = async (time = 1) => {
    await this.setOpacity(this.componentDiv, 0, 0);
    return this.setOpacity(this.componentDiv, 1, time);
  }

  fadeOut = async (time = 1) => {
    await this.setOpacity(this.componentDiv, 1, 0);
    return this.setOpacity(this.componentDiv, 0, time);
  }

  fadeInContent(time = 1) {
    const content = [...this.div.children].filter((child) => child.className !== 'dashed' && child.className !== 'file');
    content.forEach((childDiv) => this.setOpacity(childDiv, 1, time));
    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }

  fadeOutContent(time = 1) {
    const content = [...this.div.children].filter((child) => child.className !== 'dashed' && child.className !== 'file');
    content.forEach((childDiv) => this.setOpacity(childDiv, 0, time));
    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }
}
export default class Component {
  constructor(className) {
    this.componentDiv = document.createElement('div');
    this.componentDiv.className = 'widgetComponent';
    this.div = document.createElement('div');
    this.componentDiv.appendChild(this.div);
    this.div.className = className;
  }

  appendTo = (parentDiv) => parentDiv.appendChild(this.componentDiv);

  prependTo = (parentDiv) => parentDiv.prepend(this.componentDiv);

  remove = () => this.componentDiv.parentNode.removeChild(this.componentDiv);

  setOpacity(div, opacity = 1, time = 1) {
    if (!div) return;

    div.style.transitionProperty = 'opacity';
    div.style.transitionDuration = `${time}s`;

    div.style.opacity = opacity;

    return new Promise((resolve) => setTimeout(() => resolve(), time*1000));
  }
  
  collapse(state, time = 1) {
    this.setOpacity(this.componentDiv, 1, 0);

    const initialHeight = !state ? 0 : this.div.getBoundingClientRect().height;
    this.componentDiv.style.height = `${initialHeight}px`;

    this.componentDiv.style.transitionProperty = 'height';
    this.componentDiv.style.transitionTimingFunction = 'cubic-bezier(0, 0.5, 0, 1)';
    this.componentDiv.style.transitionDuration = `${time}s`;

    const height = state ? 0 : this.div.getBoundingClientRect().height;
    this.componentDiv.style.height = `${height}px`;

    state
    ? this.fadeOutContent(time/2)
    : this.fadeInContent(time/2);

    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }

  fadeIn = async (time = 1) => {
    await this.setOpacity(this.componentDiv, 0, 0);
    return this.setOpacity(this.componentDiv, 1, time);
  }

  fadeOut = async (time = 1) => {
    await this.setOpacity(this.componentDiv, 1, 0);
    return this.setOpacity(this.componentDiv, 0, time);
  }

  fadeInContent(time = 1) {
    const content = [...this.div.children].filter((child) => child.className !== 'dashed' && child.className !== 'file');
    content.forEach((childDiv) => this.setOpacity(childDiv, 1, time));
    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }

  fadeOutContent(time = 1) {
    const content = [...this.div.children].filter((child) => child.className !== 'dashed' && child.className !== 'file');
    content.forEach((childDiv) => this.setOpacity(childDiv, 0, time));
    return new Promise((resolve) => setTimeout(() => resolve(), time * 1000));
  }
}

Example of how the Button component extends the Component class:

javascript
import Component from "./Component.js";

export default class Button extends Component {
  constructor(className, html, onClick) {
    super(className);
    this.onClick = onClick;
    this.div.innerHTML = html;
    this.div.addEventListener('click', () => this.onClick && this.onClick());
  }
}
import Component from "./Component.js";

export default class Button extends Component {
  constructor(className, html, onClick) {
    super(className);
    this.onClick = onClick;
    this.div.innerHTML = html;
    this.div.addEventListener('click', () => this.onClick && this.onClick());
  }
}

All components are available through the Components object, which initializes them and allows easy access to each one.

javascript
import FileInput from "./components/FileInput.js";
import PlatformSelect from "./components/PlatformSelect.js";
import StepProgress from './components/StepProgress.js';
import Progress from './components/Progress.js';
import Div from './components/Div.js';
import Button from './components/Button.js';

class Components {
  constructor() {
    this.components = [];
  }

  add = (name, obj) => this.components.push({ name, obj });

  get = (name) => this.components.find((elem) => elem.name === name)?.obj;
  
  getAll() {
    const result = {};
    this.components.forEach((elem) => result[elem.name] = elem.obj);
    return result;
  }

  init(pkWidget) {
    this.add('stepProgress', new StepProgress({
      steps: [
        { name: '1', description: 'Upload game archive' },
        { name: '2', description: 'Processing & Publishing' },
        { name: '3', description: 'Download the launcher' },
      ],
    }));
    this.add('fileInputLabel', new Div('fileInputLabel', '<span>Uploaded file</span>'));
    this.add('fileInput', new FileInput());
    this.add('platformSelectLabel', new Div('platformSelectLabel', '<span>Detected platform</span>'));
    this.add('platformSelect', new PlatformSelect());
    this.add('processButton', new Button('processButton', '<div class="btn">Process<i class="icon icon-chevron-down"></i></div>'));
    this.add('progress', new Progress());
  }
}

export default new Components();
import FileInput from "./components/FileInput.js";
import PlatformSelect from "./components/PlatformSelect.js";
import StepProgress from './components/StepProgress.js';
import Progress from './components/Progress.js';
import Div from './components/Div.js';
import Button from './components/Button.js';

class Components {
  constructor() {
    this.components = [];
  }

  add = (name, obj) => this.components.push({ name, obj });

  get = (name) => this.components.find((elem) => elem.name === name)?.obj;
  
  getAll() {
    const result = {};
    this.components.forEach((elem) => result[elem.name] = elem.obj);
    return result;
  }

  init(pkWidget) {
    this.add('stepProgress', new StepProgress({
      steps: [
        { name: '1', description: 'Upload game archive' },
        { name: '2', description: 'Processing & Publishing' },
        { name: '3', description: 'Download the launcher' },
      ],
    }));
    this.add('fileInputLabel', new Div('fileInputLabel', '<span>Uploaded file</span>'));
    this.add('fileInput', new FileInput());
    this.add('platformSelectLabel', new Div('platformSelectLabel', '<span>Detected platform</span>'));
    this.add('platformSelect', new PlatformSelect());
    this.add('processButton', new Button('processButton', '<div class="btn">Process<i class="icon icon-chevron-down"></i></div>'));
    this.add('progress', new Progress());
  }
}

export default new Components();

  • FileInput: Drag & drop handling and file validation
  • StepProgress: Three-step progress indicator (Upload → Processing → Download)
  • PlatformSelect: Detected application platform selection
  • Progress: Progress tracking with animated bar
  • Button: Universal button component with animations
  • Div: Basic container with animation system

The widget implements three main views corresponding to process stages:

  • Display of FileInput component
  • Drag & drop handling and file validation
  • Platform detection and transition to summary

  • File details presentation
  • Platform selection from detected options
  • Processing start button

  • Processing and publication progress tracking
  • Status message display
  • Process cancellation option

Transitions between views occur with smooth fade/collapse animations, ensuring consistent user experience.

Without Redux or Zustand, I had to create my own state management system:

  • Manual DOM manipulation: Direct creation, modification, and removal of elements
  • Event-driven architecture: Inter-component communication through callbacks
  • Centralized management: Main pkWidget class coordinates state of all components
  • CSS animations: Using transition and keyframes for smooth transitions

The widget implements detailed file validation before upload:

javascript
const isCharCodeCorrect = (charCode) => 
  charCode >= 32 && charCode < 127 && charCode !== 92;

const noSpecialCharsInString = (str) => {
  for (const i in str) 
    if (!isCharCodeCorrect(str.charCodeAt(i)))
      return [false, str];
  return [true];
};
const isCharCodeCorrect = (charCode) => 
  charCode >= 32 && charCode < 127 && charCode !== 92;

const noSpecialCharsInString = (str) => {
  for (const i in str) 
    if (!isCharCodeCorrect(str.charCodeAt(i)))
      return [false, str];
  return [true];
};

The system checks every character in file names, accepting only ASCII characters in the 32-127 range, excluding backslash (code 92). This ensures compatibility with different operating systems.

  • Corrupted ZIP detection: Archive structure verification before processing
  • Size control: 10GB limit for single archive
  • Content validation: Executable file availability check

javascript
// Proxy server - config.js
export default {
  api_key: '', // PUT YOUR API KEY HERE
  port: 3000,
  pkEndpoint: 'https://api2.patchkit.net/1',
};
// Proxy server - config.js
export default {
  api_key: '', // PUT YOUR API KEY HERE
  port: 3000,
  pkEndpoint: 'https://api2.patchkit.net/1',
};

API keys are stored exclusively server-side in the Node.js server, which acts as a proxy between the widget and PatchKit API.

  • Token retrieval: Server retrieves JWT tokens from PatchKit API for each upload
  • Limited lifetime: Tokens are valid only during upload duration
  • Chunk authorization: Each chunk uses the same JWT token in Authorization header

  • Content-Range headers: Chunk integrity verification on PatchKit API side
  • Retry logic: Automatic retry attempts on network errors (configurable limit)
  • Rate limiting: PatchKit API side limitations (example proxy server doesn't implement own limits)

Problem: Files uploaded by users must be validated on the frontend. Applications can be very large - some modern games are even several dozen GB. We don't want users to wait several minutes for upload and processing, only to find out at the end that the file is incorrect.

Solution: Using the zip.js library to analyze archive structure without full extraction:

javascript
export const getEntriesFromZip = async (archive) => {
  const blob = new zip.BlobReader(archive);
  const reader = new zip.ZipReader(blob);
  
  let entries;
  try {
    entries = await reader.getEntries();
  } catch(err) {
    // Handle corrupted archives
  }
  
  return entries;
}
export const getEntriesFromZip = async (archive) => {
  const blob = new zip.BlobReader(archive);
  const reader = new zip.ZipReader(blob);
  
  let entries;
  try {
    entries = await reader.getEntries();
  } catch(err) {
    // Handle corrupted archives
  }
  
  return entries;
}

Problem: Detecting application platform based solely on file extensions is insufficient. Executable files for Windows have .exe, .x86, or .x86_64 extensions, but for macOS and Linux they often have no extension at all.

Solution: Implementation of detection through magic number analysis in file headers:

javascript
const _findExeArr = async (entries) => {
  if (!entries) return;
  
  const getBytesFromFile = async (entry, length) => {
    const data = await entry.getData(new zip.TextWriter());
    return data.slice(0, length);
  }
  const hasAnyExtension = ({ filename }) => filename.split('/').pop().includes('.');
  const hasSpecifiedExtension = ({ filename }, extensions) => extensions.some((extension) => filename.split('/').pop().includes(extension));
  const isFolder = ({ filename }) => filename[filename.length - 1] === '/';
  const isTooDeep = ({ filename }) => config.exeSearchDepth && filename.split('/').length > config.exeSearchDepth;

  const isOSX = (entry) => entry.filename.split('/').some((part) => part.includes('.app'));
  const isWin32 = (bytes) => {
    const index = bytes.indexOf('PE');
    return bytes.includes('MZ') && index && bytes[index + 4] === 'L';
  }
  const isWin64 = (bytes) => {
    const index = bytes.indexOf('PE');
    return bytes.includes('MZ') && index && bytes[index + 4] === 'd';
  }
  const isLin32 = (bytes) => {
    return bytes.includes('ELF') && bytes[28] === '4';
  }
  const isLin64 = (bytes) => {
    return bytes.includes('ELF') && bytes[18] === '>';
  }
  let osxDetected = false;
  
  for (let i=0; i < config.exeSearchDepth; i++) {
    if (isOSX(entries[i])) {
      osxDetected = true;
      return([{
        platform: 'osx',
        path: entries[i].filename.substring(0, entries[i].filename.length-1),
        name: entries[i].filename.split('.')[i].split('/').pop(),
      }]);
    }
  }
  if (!osxDetected) {
    const filteredEntries = entries.filter((entry) => 
      !isTooDeep(entry)
      && (hasSpecifiedExtension(entry, ['.exe', '.x86', '.x86_64'])
      || (!hasAnyExtension(entry) && !isFolder(entry)))
    );

    return filteredEntries.map(async (entry) => {
      const bytes = await getBytesFromFile(entry, 400);
      const detectedPlatform =
        isWin32(bytes)
        ? 'win32'
        : isWin64(bytes)
          ? 'win64'
          : isLin32(bytes)
            ? 'lin32'
            : isLin64(bytes)
              ? 'lin64'
              : undefined

      return (
        detectedPlatform
        && {
          platform: detectedPlatform,
          path: entry.filename,
          name: entry.filename.split('/').pop().split('.')[0],
        }
      )
    })
  }
}
const _findExeArr = async (entries) => {
  if (!entries) return;
  
  const getBytesFromFile = async (entry, length) => {
    const data = await entry.getData(new zip.TextWriter());
    return data.slice(0, length);
  }
  const hasAnyExtension = ({ filename }) => filename.split('/').pop().includes('.');
  const hasSpecifiedExtension = ({ filename }, extensions) => extensions.some((extension) => filename.split('/').pop().includes(extension));
  const isFolder = ({ filename }) => filename[filename.length - 1] === '/';
  const isTooDeep = ({ filename }) => config.exeSearchDepth && filename.split('/').length > config.exeSearchDepth;

  const isOSX = (entry) => entry.filename.split('/').some((part) => part.includes('.app'));
  const isWin32 = (bytes) => {
    const index = bytes.indexOf('PE');
    return bytes.includes('MZ') && index && bytes[index + 4] === 'L';
  }
  const isWin64 = (bytes) => {
    const index = bytes.indexOf('PE');
    return bytes.includes('MZ') && index && bytes[index + 4] === 'd';
  }
  const isLin32 = (bytes) => {
    return bytes.includes('ELF') && bytes[28] === '4';
  }
  const isLin64 = (bytes) => {
    return bytes.includes('ELF') && bytes[18] === '>';
  }
  let osxDetected = false;
  
  for (let i=0; i < config.exeSearchDepth; i++) {
    if (isOSX(entries[i])) {
      osxDetected = true;
      return([{
        platform: 'osx',
        path: entries[i].filename.substring(0, entries[i].filename.length-1),
        name: entries[i].filename.split('.')[i].split('/').pop(),
      }]);
    }
  }
  if (!osxDetected) {
    const filteredEntries = entries.filter((entry) => 
      !isTooDeep(entry)
      && (hasSpecifiedExtension(entry, ['.exe', '.x86', '.x86_64'])
      || (!hasAnyExtension(entry) && !isFolder(entry)))
    );

    return filteredEntries.map(async (entry) => {
      const bytes = await getBytesFromFile(entry, 400);
      const detectedPlatform =
        isWin32(bytes)
        ? 'win32'
        : isWin64(bytes)
          ? 'win64'
          : isLin32(bytes)
            ? 'lin32'
            : isLin64(bytes)
              ? 'lin64'
              : undefined

      return (
        detectedPlatform
        && {
          platform: detectedPlatform,
          path: entry.filename,
          name: entry.filename.split('/').pop().split('.')[0],
        }
      )
    })
  }
}
javascript
const isWin32 = (bytes) => {
  const index = bytes.indexOf('PE');
  return bytes.includes('MZ') && index && bytes[index + 4] === 'L';
}

const isWin64 = (bytes) => {
  const index = bytes.indexOf('PE');
  return bytes.includes('MZ') && index && bytes[index + 4] === 'd';
}

const isLin32 = (bytes) => {
  return bytes.includes('ELF') && bytes[28] === '4';
}

const isLin64 = (bytes) => {
  return bytes.includes('ELF') && bytes[18] === '>';
}
const isWin32 = (bytes) => {
  const index = bytes.indexOf('PE');
  return bytes.includes('MZ') && index && bytes[index + 4] === 'L';
}

const isWin64 = (bytes) => {
  const index = bytes.indexOf('PE');
  return bytes.includes('MZ') && index && bytes[index + 4] === 'd';
}

const isLin32 = (bytes) => {
  return bytes.includes('ELF') && bytes[28] === '4';
}

const isLin64 = (bytes) => {
  return bytes.includes('ELF') && bytes[18] === '>';
}

The system first checks for .app folders for macOS, then analyzes the first 400 bytes of potential executable files looking for characteristic signatures.

Problem: Uploading large files (up to 10GB) requires a mechanism for splitting into smaller parts and handling network errors with automatic retry attempts.

Solution: Chunking system with configurable retry attempts and precise progress tracking:

javascript
const chunkUploadRetries = config.chunkUploadRetries || 0;
let totalChunks = undefined;
let loadedChunks = 0;
let failedChunks = 0;
let wasProgressWarned = false;

export const chunkedFileUpload = (callbacks = {}, start = 0) => {
  if (!uploadData.url || !uploadData.file || !uploadData.jwt || totalChunks <= 0) {
    console.warn('Upload data is not set properly');
    return;
  }

  if (loadedChunks >= totalChunks) {
    totalChunks = 0;
    loadedChunks = 0;
    callbacks.success && callbacks.success();
    return;
  }

  const newChunk = _createChunk(uploadData.file, start);
  let end = loadedChunks * config.chunkSize - 1;
  if (loadedChunks === totalChunks) {
    end = uploadData.file.size - 1;
  }

  const chunkUploadSuccess = () => {
    failedChunks = 0;
    chunkedFileUpload(callbacks, start + config.chunkSize);
  };
  const chunkUploadError = () => {
    if (failedChunks === chunkUploadRetries) {
      failedChunks = 0;
      callbacks.error && callbacks.error();
      return;
    }

    failedChunks++;
    console.warn(`Chunk upload error. Retrying... (${failedChunks}/${chunkUploadRetries})`);
    _uploadChunk(uploadData.file.size, newChunk, start, end, { 
      success: chunkUploadSuccess, 
      error: chunkUploadError 
    });
  }

  _uploadChunk(uploadData.file.size, newChunk, start, end, {
    progress: callbacks.progress,
    success: chunkUploadSuccess,
    error: chunkUploadError,
  });
}

const _createChunk = (file, start) => {
  loadedChunks++;
  const end = Math.min(start + config.chunkSize, file.size);
  const chunk = file.slice(start, end);
  const chunkForm = new FormData()
  chunkForm.append('chunk', chunk, file.name);

  return chunkForm;
}

const _uploadChunk = (fileSize, chunkForm, start, end, callbacks = {}) => {
  sendRequest(
    {
      method: 'POST',
      url: uploadData.url,
      data: chunkForm,
      headers: {
        'Content-Range': `bytes ${start}-${end}/${fileSize}`,
        Authorization: `Bearer ${uploadData.jwt}`,
      }
    },
    {
      progress: (e) => {
        if (e.lengthComputable) {
          const chunkPercentComplete = e.loaded / e.total * 100;
          const totalPercentComplete = (loadedChunks - 1) / totalChunks * 100 + chunkPercentComplete / totalChunks;
          const loaded = (loadedChunks - 1) * config.chunkSize + e.loaded;

          callbacks.progress && callbacks.progress({
            chunkPercentComplete: chunkPercentComplete.toFixed(2),
            totalPercentComplete: totalPercentComplete.toFixed(2),
            loaded: loaded,
            total: uploadData.file.size,
          });
        } else {
          if (!wasProgressWarned) {
            console.warn('Content-Length not responded. Unable to track progress');
            wasProgressWarned = true;
          }
        }
      },
      success: callbacks.success,
      error: callbacks.error,
    }
  );
}
const chunkUploadRetries = config.chunkUploadRetries || 0;
let totalChunks = undefined;
let loadedChunks = 0;
let failedChunks = 0;
let wasProgressWarned = false;

export const chunkedFileUpload = (callbacks = {}, start = 0) => {
  if (!uploadData.url || !uploadData.file || !uploadData.jwt || totalChunks <= 0) {
    console.warn('Upload data is not set properly');
    return;
  }

  if (loadedChunks >= totalChunks) {
    totalChunks = 0;
    loadedChunks = 0;
    callbacks.success && callbacks.success();
    return;
  }

  const newChunk = _createChunk(uploadData.file, start);
  let end = loadedChunks * config.chunkSize - 1;
  if (loadedChunks === totalChunks) {
    end = uploadData.file.size - 1;
  }

  const chunkUploadSuccess = () => {
    failedChunks = 0;
    chunkedFileUpload(callbacks, start + config.chunkSize);
  };
  const chunkUploadError = () => {
    if (failedChunks === chunkUploadRetries) {
      failedChunks = 0;
      callbacks.error && callbacks.error();
      return;
    }

    failedChunks++;
    console.warn(`Chunk upload error. Retrying... (${failedChunks}/${chunkUploadRetries})`);
    _uploadChunk(uploadData.file.size, newChunk, start, end, { 
      success: chunkUploadSuccess, 
      error: chunkUploadError 
    });
  }

  _uploadChunk(uploadData.file.size, newChunk, start, end, {
    progress: callbacks.progress,
    success: chunkUploadSuccess,
    error: chunkUploadError,
  });
}

const _createChunk = (file, start) => {
  loadedChunks++;
  const end = Math.min(start + config.chunkSize, file.size);
  const chunk = file.slice(start, end);
  const chunkForm = new FormData()
  chunkForm.append('chunk', chunk, file.name);

  return chunkForm;
}

const _uploadChunk = (fileSize, chunkForm, start, end, callbacks = {}) => {
  sendRequest(
    {
      method: 'POST',
      url: uploadData.url,
      data: chunkForm,
      headers: {
        'Content-Range': `bytes ${start}-${end}/${fileSize}`,
        Authorization: `Bearer ${uploadData.jwt}`,
      }
    },
    {
      progress: (e) => {
        if (e.lengthComputable) {
          const chunkPercentComplete = e.loaded / e.total * 100;
          const totalPercentComplete = (loadedChunks - 1) / totalChunks * 100 + chunkPercentComplete / totalChunks;
          const loaded = (loadedChunks - 1) * config.chunkSize + e.loaded;

          callbacks.progress && callbacks.progress({
            chunkPercentComplete: chunkPercentComplete.toFixed(2),
            totalPercentComplete: totalPercentComplete.toFixed(2),
            loaded: loaded,
            total: uploadData.file.size,
          });
        } else {
          if (!wasProgressWarned) {
            console.warn('Content-Length not responded. Unable to track progress');
            wasProgressWarned = true;
          }
        }
      },
      success: callbacks.success,
      error: callbacks.error,
    }
  );
}

Problem: Implementing a complex interface with three views, animations, and state management without using React or Vue required creating a custom system.

Solution: Event-driven architecture with centralized component management:

javascript
// View transitions with animations
async setView(viewName, data) {
  switch(viewName) {
    case View.file:
      this.C.fileInput.onChange = async (file) => {
        // Validation and transition to summary
      };
      await this.C.fileInput.fadeIn();
      break;
      
    case View.summary:
      await this.cleanupView(View.summary);
      // Component configuration for summary view
      break;
  }
}
// View transitions with animations
async setView(viewName, data) {
  switch(viewName) {
    case View.file:
      this.C.fileInput.onChange = async (file) => {
        // Validation and transition to summary
      };
      await this.C.fileInput.fadeIn();
      break;
      
    case View.summary:
      await this.cleanupView(View.summary);
      // Component configuration for summary view
      break;
  }
}

The system uses component classes with built-in animation methods (fadeIn, fadeOut, collapse) and centralized management through the Components registry.

PatchKit Upload Widget is a tool that automates the launcher build creation process for applications, enabling whitelabeling of PatchKit services. The project consists of three complementary parts: a simplified demonstration version, a full-featured widget, and a secure proxy server.

Vanilla JavaScript in the framework era: The conscious decision to use pure JavaScript instead of React or Vue allowed creating a universal solution that can be integrated with any environment.

Advanced platform detection: Implementation of a platform recognition system through magic numbers in executable file headers, going beyond simple extension checking.

Reliable chunking system: Creation of a file chunking system with retry logic, enabling upload of files up to 10GB with automatic network error handling.

Security architecture: Implementation of a simple proxy server protecting API keys and retrieving JWT tokens from PatchKit API.

This project was a return to web development roots for me. Writing in vanilla JavaScript in the era of React and TypeScript prevalence was a unique challenge that made me aware of the evolution web application development has undergone.

The greatest satisfaction came from solving the platform detection problem through magic numbers. Analysis of the first bytes of executable files and implementation of PE, ELF, and .app structure recognition logic required deeper understanding of binary file formats.

The lack of framework forced me to think through every aspect of architecture - from state management to animation system. The result is a lightweight, efficient solution that works everywhere without additional dependencies.

©2025 BatStack