voyageai-cli 1.20.6 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -26
- package/README.md +130 -2
- package/package.json +3 -2
- package/src/cli.js +10 -0
- package/src/commands/bug.js +249 -0
- package/src/commands/eval.js +420 -10
- package/src/commands/generate.js +220 -0
- package/src/commands/playground.js +93 -0
- package/src/commands/purge.js +271 -0
- package/src/commands/refresh.js +322 -0
- package/src/commands/scaffold.js +217 -0
- package/src/lib/codegen.js +339 -0
- package/src/lib/explanations.js +155 -0
- package/src/lib/scaffold-structure.js +114 -0
- package/src/lib/templates/nextjs/README.md.tpl +106 -0
- package/src/lib/templates/nextjs/env.example.tpl +8 -0
- package/src/lib/templates/nextjs/layout.jsx.tpl +29 -0
- package/src/lib/templates/nextjs/lib-mongo.js.tpl +111 -0
- package/src/lib/templates/nextjs/lib-voyage.js.tpl +103 -0
- package/src/lib/templates/nextjs/package.json.tpl +33 -0
- package/src/lib/templates/nextjs/page-search.jsx.tpl +147 -0
- package/src/lib/templates/nextjs/route-ingest.js.tpl +114 -0
- package/src/lib/templates/nextjs/route-search.js.tpl +97 -0
- package/src/lib/templates/nextjs/theme.js.tpl +84 -0
- package/src/lib/templates/python/README.md.tpl +145 -0
- package/src/lib/templates/python/app.py.tpl +221 -0
- package/src/lib/templates/python/chunker.py.tpl +127 -0
- package/src/lib/templates/python/env.example.tpl +12 -0
- package/src/lib/templates/python/mongo_client.py.tpl +125 -0
- package/src/lib/templates/python/requirements.txt.tpl +10 -0
- package/src/lib/templates/python/voyage_client.py.tpl +124 -0
- package/src/lib/templates/vanilla/README.md.tpl +156 -0
- package/src/lib/templates/vanilla/client.js.tpl +103 -0
- package/src/lib/templates/vanilla/connection.js.tpl +126 -0
- package/src/lib/templates/vanilla/env.example.tpl +11 -0
- package/src/lib/templates/vanilla/ingest.js.tpl +231 -0
- package/src/lib/templates/vanilla/package.json.tpl +31 -0
- package/src/lib/templates/vanilla/retrieval.js.tpl +100 -0
- package/src/lib/templates/vanilla/search-api.js.tpl +175 -0
- package/src/lib/templates/vanilla/server.js.tpl +81 -0
- package/src/lib/zip.js +130 -0
- package/src/playground/index.html +708 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RAG Retrieval Module
|
|
3
|
+
* Generated by vai v{{vaiVersion}} on {{generatedAt}}
|
|
4
|
+
*
|
|
5
|
+
* Model: {{model}}
|
|
6
|
+
* Database: {{db}}.{{collection}}
|
|
7
|
+
* Reranking: {{#if rerank}}enabled ({{rerankModel}}){{else}}disabled{{/if}}
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { embed{{#if rerank}}, rerank{{/if}} } = require('./client');
|
|
11
|
+
const { vectorSearch } = require('./connection');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Retrieve relevant documents for a query.
|
|
15
|
+
*
|
|
16
|
+
* Pipeline:
|
|
17
|
+
* 1. Embed the query using Voyage AI
|
|
18
|
+
* 2. Vector search in MongoDB Atlas
|
|
19
|
+
{{#if rerank}}
|
|
20
|
+
* 3. Rerank results using Voyage AI
|
|
21
|
+
{{/if}}
|
|
22
|
+
*
|
|
23
|
+
* @param {string} query - User's query
|
|
24
|
+
* @param {object} options - Retrieval options
|
|
25
|
+
* @param {number} options.limit - Final number of results (default: 5)
|
|
26
|
+
* @param {number} options.candidates - Initial candidates for reranking (default: 20)
|
|
27
|
+
* @param {object} options.filter - MongoDB pre-filter
|
|
28
|
+
* @returns {Promise<Array<{text: string, score: number, metadata: object}>>}
|
|
29
|
+
*/
|
|
30
|
+
async function retrieve(query, options = {}) {
|
|
31
|
+
const limit = options.limit || 5;
|
|
32
|
+
{{#if rerank}}
|
|
33
|
+
const candidates = options.candidates || 20;
|
|
34
|
+
{{/if}}
|
|
35
|
+
|
|
36
|
+
// Step 1: Embed the query
|
|
37
|
+
const { embeddings } = await embed(query, { inputType: 'query' });
|
|
38
|
+
const queryEmbedding = embeddings[0];
|
|
39
|
+
|
|
40
|
+
// Step 2: Vector search
|
|
41
|
+
{{#if rerank}}
|
|
42
|
+
const searchResults = await vectorSearch(queryEmbedding, {
|
|
43
|
+
limit: candidates,
|
|
44
|
+
filter: options.filter,
|
|
45
|
+
});
|
|
46
|
+
{{else}}
|
|
47
|
+
const searchResults = await vectorSearch(queryEmbedding, {
|
|
48
|
+
limit,
|
|
49
|
+
filter: options.filter,
|
|
50
|
+
});
|
|
51
|
+
{{/if}}
|
|
52
|
+
|
|
53
|
+
if (searchResults.length === 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
{{#if rerank}}
|
|
58
|
+
// Step 3: Rerank for better relevance
|
|
59
|
+
const documents = searchResults.map(r => r.document.text);
|
|
60
|
+
const { results: reranked } = await rerank(query, documents, { topK: limit });
|
|
61
|
+
|
|
62
|
+
// Map reranked results back to full documents
|
|
63
|
+
return reranked.map(r => ({
|
|
64
|
+
text: searchResults[r.index].document.text,
|
|
65
|
+
score: r.relevanceScore,
|
|
66
|
+
metadata: searchResults[r.index].document.metadata,
|
|
67
|
+
}));
|
|
68
|
+
{{else}}
|
|
69
|
+
return searchResults.map(r => ({
|
|
70
|
+
text: r.document.text,
|
|
71
|
+
score: r.score,
|
|
72
|
+
metadata: r.document.metadata,
|
|
73
|
+
}));
|
|
74
|
+
{{/if}}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format retrieved documents as context for an LLM prompt.
|
|
79
|
+
* @param {Array<{text: string, score: number}>} results - Retrieved documents
|
|
80
|
+
* @param {object} options - Formatting options
|
|
81
|
+
* @param {boolean} options.includeScores - Include relevance scores (default: false)
|
|
82
|
+
* @param {string} options.separator - Separator between documents (default: '\n\n---\n\n')
|
|
83
|
+
* @returns {string} Formatted context string
|
|
84
|
+
*/
|
|
85
|
+
function formatContext(results, options = {}) {
|
|
86
|
+
const separator = options.separator || '\n\n---\n\n';
|
|
87
|
+
|
|
88
|
+
return results.map((r, i) => {
|
|
89
|
+
let text = r.text;
|
|
90
|
+
if (options.includeScores) {
|
|
91
|
+
text = `[Score: ${r.score.toFixed(3)}]\n${text}`;
|
|
92
|
+
}
|
|
93
|
+
return text;
|
|
94
|
+
}).join(separator);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
retrieve,
|
|
99
|
+
formatContext,
|
|
100
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Search API
|
|
3
|
+
* Generated by vai v{{vaiVersion}} on {{generatedAt}}
|
|
4
|
+
*
|
|
5
|
+
* Express router with endpoints:
|
|
6
|
+
* - POST /api/search - Semantic search
|
|
7
|
+
* - POST /api/ingest - Document ingestion
|
|
8
|
+
* - GET /api/health - Health check
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const express = require('express');
|
|
12
|
+
const { retrieve, formatContext } = require('./retrieval');
|
|
13
|
+
const { ingestFile, ingestDirectory } = require('./ingest');
|
|
14
|
+
const { connect, close } = require('./connection');
|
|
15
|
+
|
|
16
|
+
const router = express.Router();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* POST /api/search
|
|
20
|
+
*
|
|
21
|
+
* Request body:
|
|
22
|
+
* {
|
|
23
|
+
* "query": "What is vector search?",
|
|
24
|
+
* "limit": 5,
|
|
25
|
+
* "includeContext": true
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* Response:
|
|
29
|
+
* {
|
|
30
|
+
* "results": [...],
|
|
31
|
+
* "context": "...",
|
|
32
|
+
* "meta": { "model": "...", "took": 123 }
|
|
33
|
+
* }
|
|
34
|
+
*/
|
|
35
|
+
router.post('/search', async (req, res) => {
|
|
36
|
+
const start = Date.now();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const { query, limit = 5, includeContext = false, filter } = req.body;
|
|
40
|
+
|
|
41
|
+
if (!query || typeof query !== 'string') {
|
|
42
|
+
return res.status(400).json({ error: 'Query is required' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const results = await retrieve(query, { limit, filter });
|
|
46
|
+
|
|
47
|
+
const response = {
|
|
48
|
+
results: results.map(r => ({
|
|
49
|
+
text: r.text,
|
|
50
|
+
score: r.score,
|
|
51
|
+
metadata: r.metadata,
|
|
52
|
+
})),
|
|
53
|
+
meta: {
|
|
54
|
+
model: '{{model}}',
|
|
55
|
+
{{#if rerank}}
|
|
56
|
+
rerankModel: '{{rerankModel}}',
|
|
57
|
+
{{/if}}
|
|
58
|
+
took: Date.now() - start,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (includeContext) {
|
|
63
|
+
response.context = formatContext(results);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
res.json(response);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error('Search error:', err);
|
|
69
|
+
res.status(500).json({ error: err.message });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* POST /api/ingest
|
|
75
|
+
*
|
|
76
|
+
* Request body (for text):
|
|
77
|
+
* {
|
|
78
|
+
* "text": "Document content...",
|
|
79
|
+
* "metadata": { "source": "api", "title": "..." }
|
|
80
|
+
* }
|
|
81
|
+
*
|
|
82
|
+
* Request body (for file path):
|
|
83
|
+
* {
|
|
84
|
+
* "path": "./docs/guide.md"
|
|
85
|
+
* }
|
|
86
|
+
*/
|
|
87
|
+
router.post('/ingest', async (req, res) => {
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const { text, metadata, path: filePath } = req.body;
|
|
92
|
+
|
|
93
|
+
if (filePath) {
|
|
94
|
+
const result = await ingestFile(filePath);
|
|
95
|
+
return res.json({
|
|
96
|
+
success: true,
|
|
97
|
+
...result,
|
|
98
|
+
took: Date.now() - start,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!text || typeof text !== 'string') {
|
|
103
|
+
return res.status(400).json({ error: 'Text or path is required' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// For API-provided text, we need to chunk, embed, and store inline
|
|
107
|
+
const { embed } = require('./client');
|
|
108
|
+
const { insertDocuments } = require('./connection');
|
|
109
|
+
const { chunkText } = require('./ingest');
|
|
110
|
+
|
|
111
|
+
const chunks = chunkText(text);
|
|
112
|
+
const { embeddings, usage } = await embed(chunks, { inputType: 'document' });
|
|
113
|
+
|
|
114
|
+
const documents = chunks.map((chunk, i) => ({
|
|
115
|
+
text: chunk,
|
|
116
|
+
embedding: embeddings[i],
|
|
117
|
+
metadata: {
|
|
118
|
+
...metadata,
|
|
119
|
+
source: 'api',
|
|
120
|
+
chunkIndex: i,
|
|
121
|
+
totalChunks: chunks.length,
|
|
122
|
+
},
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
await insertDocuments(documents);
|
|
126
|
+
|
|
127
|
+
res.json({
|
|
128
|
+
success: true,
|
|
129
|
+
chunks: chunks.length,
|
|
130
|
+
tokens: usage.total_tokens,
|
|
131
|
+
took: Date.now() - start,
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('Ingest error:', err);
|
|
135
|
+
res.status(500).json({ error: err.message });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* GET /api/health
|
|
141
|
+
*/
|
|
142
|
+
router.get('/health', async (req, res) => {
|
|
143
|
+
try {
|
|
144
|
+
await connect();
|
|
145
|
+
res.json({
|
|
146
|
+
status: 'healthy',
|
|
147
|
+
model: '{{model}}',
|
|
148
|
+
database: '{{db}}',
|
|
149
|
+
collection: '{{collection}}',
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
res.status(503).json({
|
|
153
|
+
status: 'unhealthy',
|
|
154
|
+
error: err.message,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
module.exports = router;
|
|
160
|
+
|
|
161
|
+
// Standalone server if run directly
|
|
162
|
+
if (require.main === module) {
|
|
163
|
+
const app = express();
|
|
164
|
+
app.use(express.json());
|
|
165
|
+
app.use('/api', router);
|
|
166
|
+
|
|
167
|
+
const PORT = process.env.PORT || 3000;
|
|
168
|
+
|
|
169
|
+
app.listen(PORT, () => {
|
|
170
|
+
console.log(`Search API running on http://localhost:${PORT}`);
|
|
171
|
+
console.log(` POST /api/search - Semantic search`);
|
|
172
|
+
console.log(` POST /api/ingest - Document ingestion`);
|
|
173
|
+
console.log(` GET /api/health - Health check`);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voyage AI RAG Server
|
|
3
|
+
* Generated by vai v{{vaiVersion}} on {{generatedAt}}
|
|
4
|
+
*
|
|
5
|
+
* Endpoints:
|
|
6
|
+
* - POST /api/search - Semantic search with optional reranking
|
|
7
|
+
* - POST /api/ingest - Document ingestion
|
|
8
|
+
* - GET /api/health - Health check
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
require('dotenv').config();
|
|
12
|
+
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const searchRouter = require('./lib/search-api');
|
|
15
|
+
|
|
16
|
+
const app = express();
|
|
17
|
+
const PORT = process.env.PORT || 3000;
|
|
18
|
+
|
|
19
|
+
// Middleware
|
|
20
|
+
app.use(express.json({ limit: '10mb' }));
|
|
21
|
+
|
|
22
|
+
// CORS for development
|
|
23
|
+
app.use((req, res, next) => {
|
|
24
|
+
res.header('Access-Control-Allow-Origin', '*');
|
|
25
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
26
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
27
|
+
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
|
28
|
+
next();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// API routes
|
|
32
|
+
app.use('/api', searchRouter);
|
|
33
|
+
|
|
34
|
+
// Root endpoint
|
|
35
|
+
app.get('/', (req, res) => {
|
|
36
|
+
res.json({
|
|
37
|
+
name: '{{projectName}}',
|
|
38
|
+
description: 'Voyage AI RAG API',
|
|
39
|
+
model: '{{model}}',
|
|
40
|
+
database: '{{db}}',
|
|
41
|
+
collection: '{{collection}}',
|
|
42
|
+
endpoints: {
|
|
43
|
+
search: 'POST /api/search',
|
|
44
|
+
ingest: 'POST /api/ingest',
|
|
45
|
+
health: 'GET /api/health',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Error handler
|
|
51
|
+
app.use((err, req, res, next) => {
|
|
52
|
+
console.error('Server error:', err);
|
|
53
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.listen(PORT, () => {
|
|
57
|
+
console.log(`
|
|
58
|
+
🚀 Voyage AI RAG Server
|
|
59
|
+
|
|
60
|
+
Model: {{model}}
|
|
61
|
+
Database: {{db}}.{{collection}}
|
|
62
|
+
{{#if rerank}}
|
|
63
|
+
Reranking: {{rerankModel}}
|
|
64
|
+
{{/if}}
|
|
65
|
+
|
|
66
|
+
Endpoints:
|
|
67
|
+
POST /api/search - Semantic search
|
|
68
|
+
POST /api/ingest - Document ingestion
|
|
69
|
+
GET /api/health - Health check
|
|
70
|
+
|
|
71
|
+
Running on http://localhost:${PORT}
|
|
72
|
+
`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Graceful shutdown
|
|
76
|
+
process.on('SIGTERM', async () => {
|
|
77
|
+
console.log('Shutting down...');
|
|
78
|
+
const { close } = require('./lib/connection');
|
|
79
|
+
await close();
|
|
80
|
+
process.exit(0);
|
|
81
|
+
});
|
package/src/lib/zip.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal ZIP file creator for text files.
|
|
5
|
+
* Creates uncompressed (STORE) ZIP archives - perfect for scaffolded code.
|
|
6
|
+
* No external dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a ZIP file from an array of file entries.
|
|
11
|
+
* @param {Array<{name: string, content: string}>} files - Files to include
|
|
12
|
+
* @returns {Buffer} - ZIP file as a Buffer
|
|
13
|
+
*/
|
|
14
|
+
function createZip(files) {
|
|
15
|
+
const entries = [];
|
|
16
|
+
let offset = 0;
|
|
17
|
+
|
|
18
|
+
// Process each file
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const content = Buffer.from(file.content, 'utf8');
|
|
21
|
+
const name = Buffer.from(file.name, 'utf8');
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xFFFF;
|
|
24
|
+
const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xFFFF;
|
|
25
|
+
const crc = crc32(content);
|
|
26
|
+
|
|
27
|
+
// Local file header (30 bytes + filename)
|
|
28
|
+
const localHeader = Buffer.alloc(30 + name.length);
|
|
29
|
+
localHeader.writeUInt32LE(0x04034b50, 0); // Local file header signature
|
|
30
|
+
localHeader.writeUInt16LE(20, 4); // Version needed (2.0)
|
|
31
|
+
localHeader.writeUInt16LE(0, 6); // General purpose bit flag
|
|
32
|
+
localHeader.writeUInt16LE(0, 8); // Compression method (0 = store)
|
|
33
|
+
localHeader.writeUInt16LE(dosTime, 10); // Last mod time
|
|
34
|
+
localHeader.writeUInt16LE(dosDate, 12); // Last mod date
|
|
35
|
+
localHeader.writeUInt32LE(crc, 14); // CRC-32
|
|
36
|
+
localHeader.writeUInt32LE(content.length, 18); // Compressed size
|
|
37
|
+
localHeader.writeUInt32LE(content.length, 22); // Uncompressed size
|
|
38
|
+
localHeader.writeUInt16LE(name.length, 26); // Filename length
|
|
39
|
+
localHeader.writeUInt16LE(0, 28); // Extra field length
|
|
40
|
+
name.copy(localHeader, 30);
|
|
41
|
+
|
|
42
|
+
entries.push({
|
|
43
|
+
name,
|
|
44
|
+
content,
|
|
45
|
+
crc,
|
|
46
|
+
dosTime,
|
|
47
|
+
dosDate,
|
|
48
|
+
headerOffset: offset,
|
|
49
|
+
localHeader,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
offset += localHeader.length + content.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build central directory
|
|
56
|
+
const centralDir = [];
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const cdHeader = Buffer.alloc(46 + entry.name.length);
|
|
59
|
+
cdHeader.writeUInt32LE(0x02014b50, 0); // Central directory signature
|
|
60
|
+
cdHeader.writeUInt16LE(20, 4); // Version made by
|
|
61
|
+
cdHeader.writeUInt16LE(20, 6); // Version needed
|
|
62
|
+
cdHeader.writeUInt16LE(0, 8); // General purpose bit flag
|
|
63
|
+
cdHeader.writeUInt16LE(0, 10); // Compression method
|
|
64
|
+
cdHeader.writeUInt16LE(entry.dosTime, 12); // Last mod time
|
|
65
|
+
cdHeader.writeUInt16LE(entry.dosDate, 14); // Last mod date
|
|
66
|
+
cdHeader.writeUInt32LE(entry.crc, 16); // CRC-32
|
|
67
|
+
cdHeader.writeUInt32LE(entry.content.length, 20); // Compressed size
|
|
68
|
+
cdHeader.writeUInt32LE(entry.content.length, 24); // Uncompressed size
|
|
69
|
+
cdHeader.writeUInt16LE(entry.name.length, 28); // Filename length
|
|
70
|
+
cdHeader.writeUInt16LE(0, 30); // Extra field length
|
|
71
|
+
cdHeader.writeUInt16LE(0, 32); // Comment length
|
|
72
|
+
cdHeader.writeUInt16LE(0, 34); // Disk number start
|
|
73
|
+
cdHeader.writeUInt16LE(0, 36); // Internal file attributes
|
|
74
|
+
cdHeader.writeUInt32LE(0, 38); // External file attributes
|
|
75
|
+
cdHeader.writeUInt32LE(entry.headerOffset, 42); // Relative offset of local header
|
|
76
|
+
entry.name.copy(cdHeader, 46);
|
|
77
|
+
centralDir.push(cdHeader);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const centralDirBuffer = Buffer.concat(centralDir);
|
|
81
|
+
const centralDirOffset = offset;
|
|
82
|
+
const centralDirSize = centralDirBuffer.length;
|
|
83
|
+
|
|
84
|
+
// End of central directory record (22 bytes)
|
|
85
|
+
const eocd = Buffer.alloc(22);
|
|
86
|
+
eocd.writeUInt32LE(0x06054b50, 0); // EOCD signature
|
|
87
|
+
eocd.writeUInt16LE(0, 4); // Disk number
|
|
88
|
+
eocd.writeUInt16LE(0, 6); // Disk with central directory
|
|
89
|
+
eocd.writeUInt16LE(entries.length, 8); // Entries on this disk
|
|
90
|
+
eocd.writeUInt16LE(entries.length, 10); // Total entries
|
|
91
|
+
eocd.writeUInt32LE(centralDirSize, 12); // Central directory size
|
|
92
|
+
eocd.writeUInt32LE(centralDirOffset, 16); // Central directory offset
|
|
93
|
+
eocd.writeUInt16LE(0, 20); // Comment length
|
|
94
|
+
|
|
95
|
+
// Combine all parts
|
|
96
|
+
const parts = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
parts.push(entry.localHeader);
|
|
99
|
+
parts.push(entry.content);
|
|
100
|
+
}
|
|
101
|
+
parts.push(centralDirBuffer);
|
|
102
|
+
parts.push(eocd);
|
|
103
|
+
|
|
104
|
+
return Buffer.concat(parts);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* CRC-32 calculation (IEEE polynomial).
|
|
109
|
+
*/
|
|
110
|
+
const crcTable = (() => {
|
|
111
|
+
const table = new Uint32Array(256);
|
|
112
|
+
for (let i = 0; i < 256; i++) {
|
|
113
|
+
let c = i;
|
|
114
|
+
for (let j = 0; j < 8; j++) {
|
|
115
|
+
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
116
|
+
}
|
|
117
|
+
table[i] = c;
|
|
118
|
+
}
|
|
119
|
+
return table;
|
|
120
|
+
})();
|
|
121
|
+
|
|
122
|
+
function crc32(buffer) {
|
|
123
|
+
let crc = 0xFFFFFFFF;
|
|
124
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
125
|
+
crc = crcTable[(crc ^ buffer[i]) & 0xFF] ^ (crc >>> 8);
|
|
126
|
+
}
|
|
127
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { createZip };
|