This commit is contained in:
zjcqoo 2021-09-05 17:58:19 +08:00
commit d8a1f899dc
7 changed files with 979 additions and 0 deletions

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# Web2Img
Web2Img is a tool to bundle your web files into a single image, and extract them via Service Worker at runtime.
You can use image hosting sites as free CDNs to save bandwidth costs.
![](assets/img/intro.png)
## Example
Demo: [https://fanhtml5.github.io](https://fanhtml5.github.io)
Target Files: https://github.com/fanhtml5/fanhtml5.github.io (only 2 files)
Source Files: https://github.com/fanhtml5/test-site
## Tool
web: https://etherdream.github.io/web2img/ or https://etherdream.com/web2img/
cli: TODO...
## FAQ
Q: Is free CDN safe?
A: Yes, the program will verify the data integrity.
----
Q: Is free CDN stable?
A: Not sure, but you can provide multiple URLs to improve stability.
----
Q: Can any free CDN be used?
A: No, CDN must enable CORS, allow empty referrer and "null" origin (or real value).
----
Q: Would it be better to optimize the image before uploading?
A: If the server will re-encode the image, it make no difference.
----
Q: Why use `404.html`?
A: It's an easy way to intercept any path.
----
Q: How to update files?
A: Just overwrite `x.js`, the client polls this file every 2 minutes.
----
Q: What if the browser doesn't support Service Wokrer?
A: Unfortunately, the page can't be displayed. You can add a fallback in `404.html`.
----
Q: Will new features be added?
A: This project is just an experiment in the past, now there is a new project named [freecdn](https://github.com/EtherDream/freecdn), which is much more powerful. (better docs will be released soon)
## License
MIT

62
assets/css/main.css Normal file
View File

@ -0,0 +1,62 @@
body, input, textarea {
font-family: monospace;
font-size: 15px;
}
.intro {
max-width: 100%;
}
textarea {
width: 100%;
}
#txtIgnoredFile.bad {
color: red;
}
#divDropZone {
border: 1px dashed #aaa;
padding: 1em 0;
font-size: 2em;
text-align: center;
color: #aaa;
user-select: none;
margin-bottom: 0.1em;
}
#txtConf {
height: 15em;
tab-size: 4;
}
#txtConf.fold {
height: 1.2em;
}
#txtConfWarn, #txtUrlsWarn, #txtCodeWarn {
color: red;
}
#imgPreview {
max-width: 400px;
min-width: 200px;
max-height: 200px;
min-height: 100px;
image-rendering: pixelated;
margin: .5em 0;
}
#txtUrls {
height: 5em;
}
#txtHtml {
height: 1.2em;
}
#txtJs {
height: 6em;
}
#txtJs.fold {
height: 1.2em;
}

BIN
assets/img/intro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

515
assets/js/main.js Normal file
View File

@ -0,0 +1,515 @@
function bytesToImgData(bytes) {
const nPixel = Math.ceil(bytes.length / 3)
const side = Math.ceil(Math.sqrt(nPixel))
const u32 = new Uint32Array(side * side)
// out of bounds => 0
for (let i = 0, j = 0; i < nPixel; i++, j += 3) {
u32[i] = // 0xAABBGGRR
bytes[j + 0] << 0 | // R
bytes[j + 1] << 8 | // G
bytes[j + 2] << 16 | // B
0xff000000 // A (255)
}
u32.fill(0xff000000, nPixel)
const u8 = new Uint8ClampedArray(u32.buffer)
return new ImageData(u8, side, side)
}
let hashExp
function canvasToBlob(canvas) {
return new Promise(resolve => {
canvas.toBlob(blob => {
resolve(blob)
})
})
}
let canvas
async function pack() {
const bytes = bundle()
const imgData = bytesToImgData(bytes)
console.log(imgData.width + '*' + imgData.height, 'bytes:', bytes.length)
hashExp = await sha256(imgData.data)
canvas = document.createElement('canvas')
canvas.width = imgData.width
canvas.height = imgData.height
const ctx = canvas.getContext('2d')
ctx.putImageData(imgData, 0, 0)
const blob = await canvasToBlob(canvas)
imgPreview.src = URL.createObjectURL(blob)
divPreview.hidden = false
const {width, height} = canvas
const size = blob.size.toLocaleString()
imgPreview.title = `${width}*${height} [${size} Bytes]`
}
let previewWin
self.onmessage = function(e) {
if (e.source === previewWin && e.data === 'GET_PREVIEW_DATA') {
const imgDataUrl = canvas.toDataURL()
previewWin.postMessage(imgDataUrl, '*')
}
}
function getPreviewFile() {
if (confMap['index.html']) {
return ''
}
const files = Object.keys(confMap)
const html = files.find(v => v.endsWith('.html'))
if (html) {
return html
}
return files[0]
}
btnPreview.onclick = function() {
const rand = (Math.random() * 0xffffffff >>> 0).toString(36)
const site = `https://web2img-preview-${rand}.etherdream.com/`
previewWin = open(site + getPreviewFile())
}
function verifyConf(str) {
const conf = parseJson(str)
if (!conf || typeof conf !== 'object') {
return 'invalid conf'
}
const pairs = Object.entries(conf)
if (pairs.length === 0) {
return 'empty conf'
}
for (const [path, headers] of pairs) {
const data = dataMap[path]
if (!data) {
return `file not found: ${path}`
}
if (typeof headers !== 'object') {
return `invalid header type: ${path}`
}
if (headers['content-length'] != data.length) {
headers['content-length'] = data.length
console.warn('fix content-length:', path)
}
try {
var res = new Response('', {headers})
} catch (err) {
return `invalid headers: ${path}`
}
for (const k in headers) {
if (!res.headers.has(k)) {
return `unsupported header: ${k} (${path})`
}
}
conf[path] = Object.fromEntries(res.headers)
}
confMap = conf
return ''
}
let lastConf
txtConf.onchange = async function() {
if (lastConf === this.value) {
return
}
lastConf = this.value
const err = verifyConf(lastConf)
showConfWarn(err)
if (err) {
return
}
await pack()
}
function bundle() {
const confStr = JSON.stringify(confMap) + '\r'
const confBin = strToBytes(confStr)
const bufs = [confBin]
for (const path of Object.keys(confMap)) {
const data = dataMap[path]
bufs.push(data)
}
return concatBufs(bufs)
}
//
// utils
//
function strToBytes(str) {
return new TextEncoder().encode(str)
}
function bytesToB64(bytes) {
return btoa(String.fromCharCode.apply(null, bytes))
}
function parseJson(str) {
try {
return JSON.parse(str)
} catch (err) {
}
}
function parseUrl(url) {
try {
return new URL(url)
} catch (err) {
}
}
function parseRegExp(str) {
try {
return RegExp(str)
} catch (err) {
}
}
function concatBufs(bufs) {
let size = 0
for (const buf of bufs) {
size += buf.length
}
const ret = new Uint8Array(size)
let pos = 0
for (const v of bufs) {
ret.set(v, pos)
pos += v.length
}
return ret
}
async function sha256(bytes) {
const buf = await crypto.subtle.digest('SHA-256', bytes)
return new Uint8Array(buf)
}
function isArrayEqual(b1, b2) {
if (b1.length !== b2.length) {
return false
}
for (let i = 0; i < b1.length; i++) {
if (b1[i] !== b2[i]) {
return false
}
}
return true
}
function showConfUI() {
const confStr = JSON.stringify(confMap, null, '\t')
if (confStr === '{}') {
return
}
txtConf.value = confStr
txtConf.classList.remove('fold')
txtConf.readOnly = false
txtUrls.value = ''
txtJs.value = ''
txtJs.classList.add('fold')
optPrivacy.disabled = true
chkMinify.disabled = true
pack()
}
function showConfWarn(msg) {
txtConfWarn.textContent = msg
}
function showUrlsWarn(msg) {
txtUrlsWarn.textContent = msg
}
function showCodeWarn(msg) {
txtCodeWarn.textContent = msg
}
function verifyImage(url) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = async () => {
if (!hashExp) {
resolve()
return
}
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
const imgData = ctx.getImageData(0, 0, img.width, img.height)
const hashGot = await sha256(imgData.data)
if (isArrayEqual(hashGot, hashExp)) {
resolve()
} else {
reject('hash incorrect')
}
}
img.onerror = () => {
reject('failed to load')
}
img.crossOrigin = true
img.referrerPolicy = 'no-referrer'
img.src = url
})
}
async function verifyUrls(urls) {
for (const url of urls) {
const urlObj = parseUrl(url)
if (!urlObj || !/^https?:/.test(urlObj.protocol)) {
return `invalid url: ${url}`
}
try {
await verifyImage(url)
} catch(err) {
return `${err}: ${url}`
}
}
return ''
}
let tmplCode
let imgUrls
async function genCode() {
try {
const res = await fetch('assets/js/tmpl.js')
tmplCode = await res.text()
} catch (err) {
showCodeWarn('failed to load code')
return
}
const privacy = optPrivacy.options[optPrivacy.selectedIndex].value
const hashStr = hashExp ? bytesToB64(hashExp) : ''
const urlsStr = imgUrls.join("', '")
let js = `\
var HASH = '${hashStr}'
var URLS = ['${urlsStr}']
var PRIVACY = ${privacy}
${tmplCode}
`
if (chkMinify.checked) {
try {
const ret = await Terser.minify(js, {
enclose: true,
compress: {
global_defs: {
RELEASE: 1,
},
}
})
js = ret.code
} catch (err) {
showCodeWarn(err.message)
return
}
}
txtJs.value = js
}
txtUrls.onchange = async function() {
const urls = this.value.split(/\s+/).filter(Boolean)
const err = await verifyUrls(urls)
showUrlsWarn(err)
if (err) {
return
}
if (urls.length === 0) {
txtJs.value = ''
return
}
imgUrls = urls
txtJs.classList.remove('fold')
optPrivacy.disabled = false
chkMinify.disabled = false
await genCode()
}
optPrivacy.onchange = function() {
genCode()
}
chkMinify.onchange = function() {
genCode()
}
const MAX_BUNDLE_SIZE = 1024 * 1024 * 20
let dataMap = {}
let confMap = {}
let totalSize = 0
async function addFile(file, path) {
if (checkIgnore(path)) {
return
}
if (totalSize + file.size > MAX_BUNDLE_SIZE) {
return
}
totalSize += file.size
const data = await readFileData(file)
dataMap[path] = data
confMap[path] = {
'content-type': file.type || 'application/octet-stream',
'content-length': file.size,
}
}
let isReading = false
let ignoreReg
function checkIgnore(path) {
for (const s of path.split('/')) {
if (ignoreReg.test(s)) {
return true
}
}
return false
}
txtIgnore.onchange = function() {
ignoreReg = parseRegExp(this.value || '^$')
if (!ignoreReg) {
this.classList.add('bad')
return
}
this.classList.remove('bad')
}
txtIgnore.onchange()
function startReading() {
isReading = true
totalSize = 0
dataMap = {}
confMap = {}
}
async function fileDialogHandler() {
if (isReading) {
return
}
if (this.files.length === 0) {
return
}
startReading()
for (const file of this.files) {
const path = stripRootDir(file.webkitRelativePath)
await addFile(file, path)
}
showConfUI()
isReading = false
}
divDropZone.onclick = function() {
const fileDialog = document.createElement('input')
fileDialog.type = 'file'
fileDialog.webkitdirectory = true
fileDialog.onchange = fileDialogHandler
fileDialog.click()
}
divDropZone.ondragover = function(e) {
e.stopPropagation()
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
divDropZone.ondrop = async function(e) {
e.stopPropagation()
e.preventDefault()
if (isReading) {
return
}
const {items} = e.dataTransfer
if (items.length !== 1) {
return
}
const entry = items[0].webkitGetAsEntry()
if (!entry.isDirectory) {
return
}
startReading()
await traverseDir(entry)
showConfUI()
isReading = false
}
async function traverseDir(entry) {
const entires = await getDirEntries(entry)
for (const entry of entires) {
if (entry.isDirectory) {
await traverseDir(entry)
} else {
const file = await entryToFile(entry)
const path = stripRootDir(entry.fullPath)
await addFile(file, path)
}
}
}
function stripRootDir(path) {
return path.replace(/^\/?[^/]+\//, '')
}
function getDirEntries(entry) {
return new Promise((resolve, reject) => {
entry.createReader().readEntries(entries => {
resolve(entries)
}, err => {
reject(err)
})
})
}
function entryToFile(entry) {
return new Promise((resolve, reject) => {
entry.file(ret => {
resolve(ret)
}, err => {
reject(err)
})
})
}
function readFileData(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const u8 = new Uint8Array(reader.result)
resolve(u8)
}
reader.onerror = () => {
reject()
}
reader.readAsArrayBuffer(file)
})
}

242
assets/js/tmpl.js Normal file
View File

@ -0,0 +1,242 @@
function pageEnv() {
var container = document.documentElement
function showErr(msg) {
container.innerHTML = msg
}
function reload() {
location.reload()
}
var currentScript = document.currentScript
var jsUrl = currentScript.src
var rootPath
if (jsUrl) {
var sw = navigator.serviceWorker
if (!sw) {
showErr('Error: Service Worker is not supported')
return
}
var swPending = sw.register(jsUrl).catch(function(err) {
showErr(err.message)
})
rootPath = getRootPath(jsUrl)
} else {
rootPath = currentScript.dataset.root
}
function parseImgBuf(buf) {
if (!buf) {
loadNextUrl()
return
}
crypto.subtle.digest('SHA-256', buf).then(function(digest) {
var hashBin = new Uint8Array(digest)
var hashB64 = btoa(String.fromCharCode.apply(null, hashBin))
if (typeof HASH !== 'undefined' && HASH !== hashB64) {
loadNextUrl()
return
}
var bytes = decode1Px3Bytes(buf)
caches.delete('.web2img').then(function() {
caches.open('.web2img').then(function(cache) {
unpack(bytes, cache).then(function() {
if (swPending) {
swPending.then(reload)
} else {
reload()
}
})
})
})
})
}
// run in iframe
var loadImg = function(e) {
var img = new Image()
img.onload = function() {
var canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
var ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
var imgData = ctx.getImageData(0, 0, img.width, img.height)
var buf = imgData.data.buffer
// inline
if (typeof PRIVACY === 'undefined' || PRIVACY === 2) {
parent.postMessage(buf, '*', [buf])
} else {
parseImgBuf(buf)
}
}
img.onerror = function() {
if (typeof PRIVACY === 'undefined' || PRIVACY === 2) {
parent.postMessage('', '*')
} else {
parseImgBuf()
}
}
if (typeof PRIVACY !== 'undefined' && PRIVACY === 1) {
img.referrerPolicy = 'no-referrer'
}
img.crossOrigin = 1
img.src = e.data
}
if (PRIVACY === 2) {
// hide origin header
var iframe = document.createElement('iframe')
if (typeof RELEASE !== 'undefined') {
iframe.src = 'data:text/html,<script>onmessage=' + loadImg + '</script>'
} else {
iframe.src = 'data:text/html;base64,' + btoa('<script>onmessage=' + loadImg + '</script>')
}
iframe.style.display = 'none'
iframe.onload = loadNextUrl
container.appendChild(iframe)
var iframeWin = iframe.contentWindow
self.onmessage = function(e) {
if (e.source === iframeWin) {
parseImgBuf(e.data)
}
}
} else {
loadNextUrl()
}
function loadNextUrl() {
var url = URLS.shift()
if (!url) {
return
}
if (PRIVACY === 2) {
iframeWin.postMessage(url, '*')
} else {
loadImg({data: url})
}
}
function decode1Px3Bytes(pixelBuf) {
var u32 = new Uint32Array(pixelBuf)
var out = new Uint8Array(u32.length * 3)
var p = 0
u32.forEach(function(rgba) {
out[p++] = rgba
out[p++] = rgba >> 8
out[p++] = rgba >> 16
})
return out
}
function unpack(bytes, cache) {
var confEnd = bytes.indexOf(13) // '\r'
var confBin = bytes.subarray(0, confEnd)
var confStr = new TextDecoder().decode(confBin)
var confObj = JSON.parse(confStr)
var offset = confEnd + 1
var pendings = []
for (var file in confObj) {
var headers = confObj[file]
headers['cache-control'] = 'max-age=60'
var len = headers['content-length']
var bin = bytes.subarray(offset, offset + len)
var req = new Request(rootPath + file)
var res = new Response(bin, {
headers: headers
})
pendings.push(
cache.put(req, res)
)
offset += len
}
return Promise.all(pendings)
}
}
function swEnv() {
var jsUrl = location.href
var rootPath = getRootPath(jsUrl)
var hasUpdate
var currJs
// check update
setInterval(function() {
var p = 'cache' in Request.prototype
? fetch(jsUrl, {cache: 'no-cache'})
: fetch(jsUrl + '?t=' + Date.now())
p.then(function(res) {
res.text().then(function(js) {
if (currJs !== js) {
if (currJs) {
hasUpdate = 1
console.log('update')
}
currJs = js
}
})
})
}, 1000 * 120)
onfetch = function(e) {
var req = e.request
if (req.url.indexOf(rootPath)) {
// url not starts with rootPath
return
}
var res
if (hasUpdate && req.mode === 'navigate') {
var html = '<script data-root="' + rootPath + '">' + currJs + '</script>'
res = new Response(html, {
headers: {
'content-type': 'text/html'
}
})
hasUpdate = 0
} else {
var path = new URL(req.url).pathname
.replace(/\/{2,}/g, '/')
.replace(/\/$/, '/index.html')
res = caches.open('.web2img').then(function(cache) {
return cache.match(path).then(function(res) {
return res || cache.match(rootPath + '404.html').then(function(res) {
return res || new Response('file not found: ' + path, {
status: 404
})
})
})
})
}
e.respondWith(res)
}
}
function getRootPath(url) {
// e.g.
// 'https://mysite.com/'
// 'https://xx.github.io/path/to/'
return url.split('?')[0].replace(/[^/]+$/, '')
}
if (self.document) {
pageEnv()
} else {
swEnv()
}

83
index.html Normal file
View File

@ -0,0 +1,83 @@
<!doctype html>
<html>
<head>
<title>Web2Img</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="assets/css/main.css">
<base target="_blank">
</head>
<body>
<h1>Web2Img</h1>
<div>
<p>Web2Img is a tool to bundle your web files into a single image, and extract them via Service Worker at runtime.</p>
<p>You can use image hosting sites as free CDNs to save bandwidth costs.</p>
</div>
<div>
<img class="intro" src="assets/img/intro.png">
</div>
<h2>Example</h2>
<div>
Demo: <a href="https://fanhtml5.github.io">fanhtml5.github.io</a>
[<a href="https://github.com/fanhtml5/fanhtml5.github.io">Build</a>]
[<a href="https://github.com/fanhtml5/test-site">Dev</a>]
</div>
<h2>Step1</h2>
<div id="divDropZone" title="Click | Drag & Drop">Select WebSite Directory</div>
<div>
Ignore: <input id="txtIgnore" value="^\." placeholder="ignore nothing">
</div>
<h2>Step2</h2>
<div>
<textarea id="txtConf" class="fold" readOnly placeholder="Edit headers Or Remove files"></textarea>
<div id="txtConfWarn"></div>
</div>
<div id="divPreview" hidden>
<img id="imgPreview">
<div>
<button id="btnPreview">Local Preview</button>
</div>
</div>
<h2>Step3</h2>
<div>
<textarea id="txtUrls" placeholder="Upload Image And Paste URLs"></textarea>
</div>
<div id="txtUrlsWarn"></div>
<h2>Save</h2>
<div>
404.html
</div>
<div>
<textarea id="txtHtml" readonly><script src=/x.js></script></textarea>
</div>
<div>
x.js
</div>
<div>
<textarea id="txtJs" readonly class="fold"></textarea>
</div>
<div id="txtCodeWarn"></div>
<div>
Privacy:
<select id="optPrivacy" disabled>
<option value="0">Preserve All</option>
<option value="1">Hide Referrer</option>
<option value="2" selected>Hide Referrer & Origin</option>
</select>
<input id="chkMinify" type="checkbox" checked disabled>
<label for="chkMinify">Minify</label>
</div>
<h2>About</h2>
<div>
GitHub: <a href="https://github.com/EtherDream/web2img">web2img</a>
</div>
<script src="libs/terser/terser.min.js"></script>
<script src="assets/js/main.js"></script>
</body>
</html>

1
libs/terser/terser.min.js vendored Normal file

File diff suppressed because one or more lines are too long