155 lines
6.7 KiB
TypeScript
155 lines
6.7 KiB
TypeScript
/**
|
|
* Minimal ZIP writer for Lambda deployment packages.
|
|
*
|
|
* `CreateFunction`'s `Code.ZipFile` must be a real ZIP archive — passing raw
|
|
* source text makes the runtime reject the call with "Handler file not found in
|
|
* deployment package". Nothing in the dependency tree can build one, and the
|
|
* archives here are a single small source file, so entries are written with the
|
|
* STORED method (no compression): that needs only a CRC-32 and keeps this to a
|
|
* format-exact, dependency-free implementation.
|
|
*
|
|
* Spec: PKWARE APPNOTE 6.3.x, sections 4.3.7 (local header), 4.3.12 (central
|
|
* directory) and 4.3.16 (end of central directory).
|
|
*/
|
|
|
|
const CRC32_TABLE = buildCrc32Table()
|
|
|
|
function buildCrc32Table(): Uint32Array {
|
|
const table = new Uint32Array(256)
|
|
for (let i = 0; i < 256; i += 1) {
|
|
let value = i
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
|
}
|
|
table[i] = value >>> 0
|
|
}
|
|
return table
|
|
}
|
|
|
|
export function crc32(bytes: Uint8Array): number {
|
|
let crc = 0xffffffff
|
|
for (const byte of bytes) {
|
|
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8)
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0
|
|
}
|
|
|
|
export interface ZipEntry {
|
|
name: string
|
|
content: string | Uint8Array
|
|
}
|
|
|
|
/** Build a ZIP archive containing the given files, stored uncompressed. */
|
|
export function createZipArchive(entries: ZipEntry[]): Uint8Array {
|
|
const encoder = new TextEncoder()
|
|
const localParts: Uint8Array[] = []
|
|
const centralParts: Uint8Array[] = []
|
|
let offset = 0
|
|
let localSize = 0
|
|
|
|
for (const entry of entries) {
|
|
const nameBytes = encoder.encode(entry.name)
|
|
const data = typeof entry.content === 'string' ? encoder.encode(entry.content) : entry.content
|
|
const checksum = crc32(data)
|
|
|
|
const localHeader = new Uint8Array(30 + nameBytes.length)
|
|
const localView = new DataView(localHeader.buffer)
|
|
localView.setUint32(0, 0x04034b50, true) // local file header signature
|
|
localView.setUint16(4, 20, true) // version needed to extract (2.0)
|
|
localView.setUint16(6, 0, true) // general purpose bit flag
|
|
localView.setUint16(8, 0, true) // compression method: STORED
|
|
localView.setUint16(10, 0, true) // last mod time — fixed for reproducibility
|
|
localView.setUint16(12, 0x0021, true) // last mod date: 1980-01-01, the epoch ZIP allows
|
|
localView.setUint32(14, checksum, true)
|
|
localView.setUint32(18, data.length, true) // compressed size
|
|
localView.setUint32(22, data.length, true) // uncompressed size
|
|
localView.setUint16(26, nameBytes.length, true)
|
|
localView.setUint16(28, 0, true) // extra field length
|
|
localHeader.set(nameBytes, 30)
|
|
|
|
const centralHeader = new Uint8Array(46 + nameBytes.length)
|
|
const centralView = new DataView(centralHeader.buffer)
|
|
centralView.setUint32(0, 0x02014b50, true) // central directory signature
|
|
centralView.setUint16(4, 20, true) // version made by
|
|
centralView.setUint16(6, 20, true) // version needed to extract
|
|
centralView.setUint16(8, 0, true)
|
|
centralView.setUint16(10, 0, true) // STORED
|
|
centralView.setUint16(12, 0, true)
|
|
centralView.setUint16(14, 0x0021, true)
|
|
centralView.setUint32(16, checksum, true)
|
|
centralView.setUint32(20, data.length, true)
|
|
centralView.setUint32(24, data.length, true)
|
|
centralView.setUint16(28, nameBytes.length, true)
|
|
centralView.setUint16(30, 0, true) // extra field length
|
|
centralView.setUint16(32, 0, true) // file comment length
|
|
centralView.setUint16(34, 0, true) // disk number start
|
|
centralView.setUint16(36, 0, true) // internal file attributes
|
|
centralView.setUint32(38, 0o100644 << 16, true) // external attrs: regular file, rw-r--r--
|
|
centralView.setUint32(42, offset, true) // relative offset of local header
|
|
centralHeader.set(nameBytes, 46)
|
|
|
|
localParts.push(localHeader, data)
|
|
centralParts.push(centralHeader)
|
|
offset += localHeader.length + data.length
|
|
localSize += localHeader.length + data.length
|
|
}
|
|
|
|
const centralSize = centralParts.reduce((total, part) => total + part.length, 0)
|
|
const endRecord = new Uint8Array(22)
|
|
const endView = new DataView(endRecord.buffer)
|
|
endView.setUint32(0, 0x06054b50, true) // end of central directory signature
|
|
endView.setUint16(4, 0, true) // this disk number
|
|
endView.setUint16(6, 0, true) // disk with central directory
|
|
endView.setUint16(8, entries.length, true)
|
|
endView.setUint16(10, entries.length, true)
|
|
endView.setUint32(12, centralSize, true)
|
|
endView.setUint32(16, localSize, true) // central directory offset
|
|
endView.setUint16(20, 0, true) // comment length
|
|
|
|
return concat([...localParts, ...centralParts, endRecord])
|
|
}
|
|
|
|
/** Reads one uncompressed file from the simple ZIP packages created by this console. */
|
|
export function readStoredZipEntry(archive: Uint8Array, name: string): string | null {
|
|
const decoder = new TextDecoder()
|
|
let offset = 0
|
|
while (offset + 30 <= archive.length && new DataView(archive.buffer, archive.byteOffset + offset).getUint32(0, true) === 0x04034b50) {
|
|
const view = new DataView(archive.buffer, archive.byteOffset + offset)
|
|
const compression = view.getUint16(8, true)
|
|
const size = view.getUint32(22, true)
|
|
const nameLength = view.getUint16(26, true)
|
|
const extraLength = view.getUint16(28, true)
|
|
const fileName = decoder.decode(archive.slice(offset + 30, offset + 30 + nameLength))
|
|
const dataOffset = offset + 30 + nameLength + extraLength
|
|
if (dataOffset + size > archive.length) return null
|
|
if (fileName === name) {
|
|
if (compression !== 0) return null
|
|
return decoder.decode(archive.slice(dataOffset, dataOffset + size))
|
|
}
|
|
offset = dataOffset + size
|
|
}
|
|
return null
|
|
}
|
|
|
|
function concat(parts: Uint8Array[]): Uint8Array {
|
|
const total = parts.reduce((sum, part) => sum + part.length, 0)
|
|
const out = new Uint8Array(total)
|
|
let cursor = 0
|
|
for (const part of parts) {
|
|
out.set(part, cursor)
|
|
cursor += part.length
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Derive the deployment-package filename Lambda will look for from a handler
|
|
* string. `index.handler` means the exported `handler` lives in `index.js`;
|
|
* `src/app.run` means `run` lives in `src/app.js`.
|
|
*/
|
|
export function handlerFileName(handler: string, runtime: string): string {
|
|
const modulePath = handler.split('.').slice(0, -1).join('.') || 'index'
|
|
const extension = runtime.startsWith('python') ? 'py' : runtime.startsWith('ruby') ? 'rb' : 'js'
|
|
return `${modulePath}.${extension}`
|
|
}
|