0

Build Chrome Extension tích hợp với Netlify API — những gì mình học được khi làm HTML Deployer

TL;DR: Bài này ghi lại những vấn đề kỹ thuật thực tế mình gặp khi build HTML Deployer — một Chrome Extension giúp detect HTML từ ChatGPT/Claude/Gemini rồi deploy thẳng lên Netlify, GitHub Pages, FTP hoặc self-host. Từ DOM detection, content script isolation, cho đến FTP trong browser context. Không có gì glamorous, chủ yếu là debug và đọc docs.


Bối cảnh: tại sao lại build cái này?

Mình thấy một flow rất phổ biến: người dùng ask ChatGPT hoặc Claude tạo HTML → copy code → mở VSCode → tạo file → kéo lên Netlify → chờ → copy URL.

Bước quan trọng nhất là bước cuối — nhưng lại là bước tốn thời gian nhất và không liên quan gì đến công việc thực sự.

HTML Deployer giải quyết đúng một điểm đó: đặt nút Deploy ngay bên cạnh code block AI tạo ra, không cần rời tab.

Nghe đơn giản. Thực ra không đơn giản chút nào.


Vấn đề 1: Detect HTML code block trong DOM của ChatGPT/Claude/Gemini

Đây là phần tốn thời gian nhất.

Ba platform này có DOM structure khác nhau hoàn toàn, và quan trọng hơn — DOM của chúng thay đổi thường xuyên vì họ deploy liên tục.

Cách tiếp cận ban đầu (sai)

Mình ban đầu dùng CSS selector cứng:

// Ý tưởng ban đầu — KHÔNG làm vậy
const codeBlocks = document.querySelectorAll('pre > code.language-html');

Cách này chết ngay sau lần đầu tiên ChatGPT update UI.

Cách tiếp cận thực tế

Thay vì selector cứng, mình chuyển sang detect theo nội dungheuristic, kết hợp với MutationObserver để theo dõi DOM thay đổi:

// Đơn giản hóa — ý tưởng chính
function isLikelyHtmlBlock(element) {
  const text = element.textContent.trim();
  return (
    text.startsWith('<!DOCTYPE') ||
    text.startsWith('<html') ||
    (text.includes('<body') && text.includes('</body>'))
  );
}

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType === Node.ELEMENT_NODE) {
        scanForHtmlBlocks(node);
      }
    }
  }
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

MutationObserver quan trọng vì các AI platform đều render streaming — code block xuất hiện dần dần, không phải một lúc.

Bài học: Đừng couple logic vào DOM structure cụ thể. Couple vào nội dung hoặc semantic thì bền hơn nhiều.


Vấn đề 2: Content Script Isolation và tại sao extension không thể "nhìn thấy" trang bình thường

Chrome Extension có execution environment tách biệt hoàn toàn với JavaScript của trang web. Content script chạy trong một "isolated world" — nghĩa là:

  • Content script có thể đọc/sửa DOM
  • Content script KHÔNG thể access window object của trang
  • Content script KHÔNG thể gọi hàm JavaScript của trang

Điều này quan trọng khi mình cần inject nút Deploy vào UI của ChatGPT mà không conflict với React của họ.

Inject UI element an toàn

// Tạo container riêng thay vì can thiệp vào DOM của trang
function injectDeployButton(targetCodeBlock) {
  // Kiểm tra đã inject chưa để tránh duplicate
  if (targetCodeBlock.dataset.htmlDeployerInjected) return;

  const btn = document.createElement('button');
  btn.className = 'html-deployer-btn';
  btn.textContent = 'Deploy';
  btn.addEventListener('click', () => {
    handleDeployClick(targetCodeBlock.textContent);
  });

  // Wrap vào container riêng, không modify trực tiếp DOM của trang
  const wrapper = document.createElement('div');
  wrapper.className = 'html-deployer-wrapper';
  wrapper.appendChild(btn);

  targetCodeBlock.parentElement.insertAdjacentElement('afterend', wrapper);
  targetCodeBlock.dataset.htmlDeployerInjected = 'true';
}

dataset.htmlDeployerInjected là cách đơn giản để tránh inject nhiều lần khi MutationObserver fire nhiều lần trên cùng một element.

Giao tiếp giữa Content Script và Extension

Content script không thể gọi Netlify API trực tiếp vì CORS. Mọi network request phải đi qua background service worker:

// Trong content script
chrome.runtime.sendMessage({
  type: 'DEPLOY_TO_NETLIFY',
  payload: { htmlContent, filename }
}, (response) => {
  if (response.success) {
    showSuccessUI(response.url);
  }
});

// Trong background service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'DEPLOY_TO_NETLIFY') {
    deployToNetlify(message.payload)
      .then(url => sendResponse({ success: true, url }))
      .catch(err => sendResponse({ success: false, error: err.message }));
    return true; // Quan trọng: return true để giữ channel async
  }
});

Dòng return true ở cuối listener rất dễ quên nhưng quan trọng — nếu không có nó, sendResponse trong async callback sẽ không hoạt động vì message channel đã đóng.


Vấn đề 3: Tích hợp Netlify Deploy API

Netlify có API deploy khá clean. Cơ bản nhất là deploy một file HTML đơn lẻ:

async function deployToNetlify(htmlContent, filename, accessToken) {
  // Bước 1: Tạo deploy mới
  const deployRes = await fetch('https://api.netlify.com/api/v1/sites/{site_id}/deploys', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/zip'
    },
    body: await createZipBuffer(htmlContent, filename)
  });

  const deploy = await deployRes.json();

  // Bước 2: Poll cho đến khi deploy xong
  return await pollDeployStatus(deploy.id, accessToken);
}

async function pollDeployStatus(deployId, accessToken) {
  const maxAttempts = 30;
  for (let i = 0; i < maxAttempts; i++) {
    await sleep(2000);

    const res = await fetch(`https://api.netlify.com/api/v1/deploys/${deployId}`, {
      headers: { 'Authorization': `Bearer ${accessToken}` }
    });
    const deploy = await res.json();

    if (deploy.state === 'ready') return deploy.ssl_url || deploy.url;
    if (deploy.state === 'error') throw new Error(deploy.error_message);
  }
  throw new Error('Deploy timeout');
}

Vấn đề mình gặp: Netlify yêu cầu upload dưới dạng ZIP, không phải file HTML trực tiếp. Mình phải dùng thư viện JSZip để tạo ZIP trong browser:

async function createZipBuffer(htmlContent, filename) {
  const JSZip = (await import('./vendor/jszip.min.js')).default;
  const zip = new JSZip();
  zip.file(filename || 'index.html', htmlContent);
  return await zip.generateAsync({ type: 'arraybuffer' });
}

Import dynamic trong service worker Chrome Extension có một số gotcha — cần khai báo đúng trong manifest và đảm bảo path resolve được.


Vấn đề 4: FTP trong browser context

Đây là phần khó nhất và ít tài liệu nhất.

Browser không có native FTP support. Không có Web FTP API. Không thể dùng Node.js ftp package trong extension.

Giải pháp: Self-hosted Host Agent

Thay vì cố nhồi FTP vào extension, mình thiết kế một thin PHP agent chạy trên server của người dùng. Extension gọi HTTP POST đến agent, agent xử lý FTP locally:

<?php
// host-agent.php — chạy trên server người dùng
header('Access-Control-Allow-Origin: *'); // Chỉ dùng nội bộ
header('Content-Type: application/json');

$data = json_decode(file_get_contents('php://input'), true);

if (!isset($data['html'], $data['filename'], $data['ftp'])) {
    http_response_code(400);
    echo json_encode(['error' => 'Missing required fields']);
    exit;
}

$ftp = ftp_connect($data['ftp']['host']);
ftp_login($ftp, $data['ftp']['user'], $data['ftp']['pass']);
ftp_pasv($ftp, true);

$tmpFile = tempnam(sys_get_temp_dir(), 'hd_');
file_put_contents($tmpFile, $data['html']);

$remotePath = rtrim($data['ftp']['path'], '/') . '/' . $data['filename'];
$success = ftp_put($ftp, $remotePath, $tmpFile, FTP_ASCII);

ftp_close($ftp);
unlink($tmpFile);

echo json_encode([
    'success' => $success,
    'url' => $data['ftp']['base_url'] . '/' . $data['filename']
]);

Extension sau đó POST lên agent này:

async function deployViaFtpAgent(htmlContent, filename, agentConfig) {
  const res = await fetch(agentConfig.endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      html: htmlContent,
      filename: filename,
      ftp: {
        host: agentConfig.ftpHost,
        user: agentConfig.ftpUser,
        pass: agentConfig.ftpPass,
        path: agentConfig.ftpPath,
        base_url: agentConfig.baseUrl
      }
    })
  });
  return await res.json();
}

Lợi ích của approach này:

  • FTP credential không cần lưu trong extension (tuỳ chọn)
  • Agent chạy trong network nội bộ của server, không expose ra internet
  • Dễ audit và maintain

Nhược điểm:

  • Người dùng phải tự upload agent lên hosting
  • Thêm một bước setup ban đầu

Đây là trade-off mình chấp nhận vì security > convenience trong trường hợp này.


Vấn đề 5: Persist state an toàn trong Extension

Extension có chrome.storage.localchrome.storage.sync. Mình dùng local cho:

  • FTP credentials (không sync lên cloud)
  • Deploy history
  • Target configurations
// Wrapper đơn giản để tránh lặp code
const Storage = {
  async get(key) {
    return new Promise(resolve => {
      chrome.storage.local.get(key, result => resolve(result[key]));
    });
  },
  async set(key, value) {
    return new Promise(resolve => {
      chrome.storage.local.set({ [key]: value }, resolve);
    });
  }
};

// Lưu deploy history
async function saveDeployRecord(record) {
  const history = await Storage.get('deployHistory') || [];
  history.unshift({ ...record, timestamp: Date.now() });

  // Giữ tối đa 50 records
  if (history.length > 50) history.splice(50);

  await Storage.set('deployHistory', history);
}

Quan trọng: Không bao giờ log credential vào console, kể cả trong development. Habit này tiết kiệm nhiều rắc rối.


Manifest V3 — những thứ khác Manifest V2

HTML Deployer build trên Manifest V3. Một số điểm cần lưu ý nếu bạn quen với MV2:

Background script → Service Worker: Service worker không persistent, có thể bị terminate bất cứ lúc nào. Tất cả state phải lưu vào chrome.storage, không dùng biến global.

webRequestBlocking bị remove: MV3 không cho phép block/modify network request theo cách cũ. Không ảnh hưởng HTML Deployer nhưng cần biết nếu bạn build extension phức tạp hơn.

executeScript API thay đổi: Cú pháp khác một chút so với MV2.


Kết

Những thứ mình học được từ project này:

Thứ nhất, DOM fragility là vấn đề thật khi build extension cho platform không phải của mình. Cần có strategy để detect theo nội dung thay vì selector.

Thứ hai, message passing giữa content script và background không khó nhưng dễ quên return true trong async listener.

Thứ ba, FTP trong browser là không thể — giải pháp thực tế là thin agent. Đây cũng là pattern có thể áp dụng cho nhiều trường hợp khác khi browser không support một protocol nào đó.

Thứ tư, MV3 service worker không persistent — design state management từ đầu với assumption này, đừng để đến lúc debug mới biết.

HTML Deployer hiện có trên Chrome Web Store, link trên backrun.co/html-deployer. Nếu bạn đang build extension tương tự hoặc có câu hỏi kỹ thuật về các vấn đề trên, comment bên dưới — mình sẵn sàng nói thêm.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí