Skip to content

Commit dd7fb18

Browse files
mmaiettaBlackHole1
andauthored
feat: add in-memory API for streaming files to destination asar (#360)
* feat: adding `async function createPackageFromStreams(dest: string, filestreams: Filestream[])` to allow streaming files directly from in-memory into the asar. The stream order is used as the insertion order. Consolidated logic for generating Pickle `out` `WriteableStream` and creating unpacked symlinks * Refactor Type definitions for handling streaming to asar * Update src/filesystem.ts Co-authored-by: Kevin Cui <[email protected]> --------- Co-authored-by: Kevin Cui <[email protected]>
1 parent 74b6181 commit dd7fb18

File tree

9 files changed

+371
-49
lines changed

9 files changed

+371
-49
lines changed

.mocharc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ module.exports = {
44
file: './test/mocha.setup.js', // setup file before everything else loads
55
'forbid-only': process.env.CI ?? false, // make sure no `test.only` is merged into `main`
66
reporter: 'spec',
7+
spec: 'test/**/*-spec.js',
78
};

src/asar.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
FilesystemLinkEntry,
1010
} from './filesystem';
1111
import * as disk from './disk';
12-
import { crawl as crawlFilesystem, determineFileType } from './crawlfs';
12+
import { CrawledFileType, crawl as crawlFilesystem, determineFileType } from './crawlfs';
1313
import { IOptions } from './types/glob';
1414

1515
/**
@@ -176,7 +176,13 @@ export async function createPackageFromFiles(
176176
options.unpackDir,
177177
);
178178
files.push({ filename, unpack: shouldUnpack });
179-
return filesystem.insertFile(filename, shouldUnpack, file, options);
179+
return filesystem.insertFile(
180+
filename,
181+
() => fs.createReadStream(filename),
182+
shouldUnpack,
183+
file,
184+
options,
185+
);
180186
case 'link':
181187
shouldUnpack = shouldUnpackPath(
182188
path.relative(src, filename),
@@ -209,6 +215,101 @@ export async function createPackageFromFiles(
209215
return next(names.shift());
210216
}
211217

218+
export type AsarStream = {
219+
/**
220+
Relative path to the file or directory from within the archive
221+
*/
222+
path: string;
223+
/**
224+
Function that returns a read stream for a file.
225+
Note: this is called multiple times per "file", so a new NodeJS.ReadableStream needs to be created each time
226+
*/
227+
streamGenerator: () => NodeJS.ReadableStream;
228+
/**
229+
Whether the file/link should be unpacked
230+
*/
231+
unpacked: boolean;
232+
stat: CrawledFileType['stat'];
233+
};
234+
export type AsarDirectory = Pick<AsarStream, 'path' | 'unpacked'> & {
235+
type: 'directory';
236+
};
237+
export type AsarSymlinkStream = AsarStream & {
238+
type: 'link';
239+
symlink: string;
240+
};
241+
export type AsarFileStream = AsarStream & {
242+
type: 'file';
243+
};
244+
export type AsarStreamType = AsarDirectory | AsarFileStream | AsarSymlinkStream;
245+
246+
/**
247+
* Create an ASAR archive from a list of streams.
248+
*
249+
* @param dest - Archive filename (& path).
250+
* @param streams - List of streams to be piped in-memory into asar filesystem. Insertion order is preserved.
251+
*/
252+
export async function createPackageFromStreams(dest: string, streams: AsarStreamType[]) {
253+
// We use an ambiguous root `src` since we're piping directly from a stream and the `filePath` for the stream is already relative to the src/root
254+
const src = '.';
255+
256+
const filesystem = new Filesystem(src);
257+
const files: disk.BasicStreamArray = [];
258+
const links: disk.BasicStreamArray = [];
259+
260+
const handleFile = async function (stream: AsarStreamType) {
261+
const { path: destinationPath, type } = stream;
262+
const filename = path.normalize(destinationPath);
263+
switch (type) {
264+
case 'directory':
265+
filesystem.insertDirectory(filename, stream.unpacked);
266+
break;
267+
case 'file':
268+
files.push({
269+
filename,
270+
streamGenerator: stream.streamGenerator,
271+
link: undefined,
272+
mode: stream.stat.mode,
273+
unpack: stream.unpacked,
274+
});
275+
return filesystem.insertFile(filename, stream.streamGenerator, stream.unpacked, {
276+
type: 'file',
277+
stat: stream.stat,
278+
});
279+
case 'link':
280+
links.push({
281+
filename,
282+
streamGenerator: stream.streamGenerator,
283+
link: stream.symlink,
284+
mode: stream.stat.mode,
285+
unpack: stream.unpacked,
286+
});
287+
filesystem.insertLink(filename, stream.unpacked, stream.symlink);
288+
break;
289+
}
290+
return Promise.resolve();
291+
};
292+
293+
const insertsDone = async function () {
294+
await fs.mkdirp(path.dirname(dest));
295+
return disk.streamFilesystem(dest, filesystem, { files, links });
296+
};
297+
298+
const streamQueue = streams.slice();
299+
300+
const next = async function (stream?: AsarStreamType) {
301+
if (!stream) {
302+
return insertsDone();
303+
}
304+
305+
await handleFile(stream);
306+
307+
return next(streamQueue.shift());
308+
};
309+
310+
return next(streamQueue.shift());
311+
}
312+
212313
export function statFile(
213314
archivePath: string,
214315
filename: string,
@@ -322,6 +423,7 @@ export default {
322423
createPackage,
323424
createPackageWithOptions,
324425
createPackageFromFiles,
426+
createPackageFromStreams,
325427
statFile,
326428
getRawHeader,
327429
listPackage,

src/crawlfs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const glob = promisify(_glob);
1010

1111
export type CrawledFileType = {
1212
type: 'file' | 'directory' | 'link';
13-
stat: Stats;
13+
stat: Pick<Stats, 'mode' | 'size'>;
1414
transformed?: {
1515
path: string;
1616
stat: Stats;

src/disk.ts

Lines changed: 89 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import fs from './wrapped-fs';
33
import { Pickle } from './pickle';
44
import { Filesystem, FilesystemFileEntry } from './filesystem';
55
import { CrawledFileType } from './crawlfs';
6+
import { Stats } from 'fs';
7+
import { promisify } from 'util';
8+
import * as stream from 'stream';
9+
10+
const pipeline = promisify(stream.pipeline);
611

712
let filesystemCache: Record<string, Filesystem | undefined> = Object.create(null);
813

@@ -19,12 +24,10 @@ async function copyFile(dest: string, src: string, filename: string) {
1924
}
2025

2126
async function streamTransformedFile(
22-
originalFilename: string,
27+
stream: NodeJS.ReadableStream,
2328
outStream: NodeJS.WritableStream,
24-
transformed: CrawledFileType['transformed'],
2529
) {
2630
return new Promise<void>((resolve, reject) => {
27-
const stream = fs.createReadStream(transformed ? transformed.path : originalFilename);
2831
stream.pipe(outStream, { end: false });
2932
stream.on('error', reject);
3033
stream.on('end', () => resolve());
@@ -35,15 +38,29 @@ export type InputMetadata = {
3538
[property: string]: CrawledFileType;
3639
};
3740

38-
export type BasicFilesArray = { filename: string; unpack: boolean }[];
39-
40-
export type FilesystemFilesAndLinks = { files: BasicFilesArray; links: BasicFilesArray };
41+
export type BasicFilesArray = {
42+
filename: string;
43+
unpack: boolean;
44+
}[];
45+
46+
export type BasicStreamArray = {
47+
filename: string;
48+
streamGenerator: () => NodeJS.ReadableStream; // this is called multiple times per file
49+
mode: Stats['mode'];
50+
unpack: boolean;
51+
link: string | undefined; // only for symlinks, should refactor as part of larger project refactor in follow-up PR
52+
}[];
53+
54+
export type FilesystemFilesAndLinks<T extends BasicFilesArray | BasicStreamArray> = {
55+
files: T;
56+
links: T;
57+
};
4158

4259
const writeFileListToStream = async function (
4360
dest: string,
4461
filesystem: Filesystem,
4562
out: NodeJS.WritableStream,
46-
lists: FilesystemFilesAndLinks,
63+
lists: FilesystemFilesAndLinks<BasicFilesArray>,
4764
metadata: InputMetadata,
4865
) {
4966
const { files, links } = lists;
@@ -53,50 +70,55 @@ const writeFileListToStream = async function (
5370
const filename = path.relative(filesystem.getRootPath(), file.filename);
5471
await copyFile(`${dest}.unpacked`, filesystem.getRootPath(), filename);
5572
} else {
56-
await streamTransformedFile(file.filename, out, metadata[file.filename].transformed);
73+
const transformed = metadata[file.filename].transformed;
74+
const stream = fs.createReadStream(transformed ? transformed.path : file.filename);
75+
await streamTransformedFile(stream, out);
5776
}
5877
}
59-
const unpackedSymlinks = links.filter((f) => f.unpack);
60-
for (const file of unpackedSymlinks) {
78+
for (const file of links.filter((f) => f.unpack)) {
6179
// the symlink needs to be recreated outside in .unpacked
6280
const filename = path.relative(filesystem.getRootPath(), file.filename);
6381
const link = await fs.readlink(file.filename);
64-
// if symlink is within subdirectories, then we need to recreate dir structure
65-
await fs.mkdirp(path.join(`${dest}.unpacked`, path.dirname(filename)));
66-
// create symlink within unpacked dir
67-
await fs.symlink(link, path.join(`${dest}.unpacked`, filename)).catch(async (error) => {
68-
if (error.code === 'EPERM' && error.syscall === 'symlink') {
69-
throw new Error(
70-
'Could not create symlinks for unpacked assets. On Windows, consider activating Developer Mode to allow non-admin users to create symlinks by following the instructions at https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development.',
71-
);
72-
}
73-
throw error;
74-
});
82+
await createSymlink(dest, filename, link);
7583
}
7684
return out.end();
7785
};
7886

7987
export async function writeFilesystem(
8088
dest: string,
8189
filesystem: Filesystem,
82-
lists: FilesystemFilesAndLinks,
90+
lists: FilesystemFilesAndLinks<BasicFilesArray>,
8391
metadata: InputMetadata,
8492
) {
85-
const headerPickle = Pickle.createEmpty();
86-
headerPickle.writeString(JSON.stringify(filesystem.getHeader()));
87-
const headerBuf = headerPickle.toBuffer();
93+
const out = await createFilesystemWriteStream(filesystem, dest);
94+
return writeFileListToStream(dest, filesystem, out, lists, metadata);
95+
}
8896

89-
const sizePickle = Pickle.createEmpty();
90-
sizePickle.writeUInt32(headerBuf.length);
91-
const sizeBuf = sizePickle.toBuffer();
97+
export async function streamFilesystem(
98+
dest: string,
99+
filesystem: Filesystem,
100+
lists: FilesystemFilesAndLinks<BasicStreamArray>,
101+
) {
102+
const out = await createFilesystemWriteStream(filesystem, dest);
92103

93-
const out = fs.createWriteStream(dest);
94-
await new Promise<void>((resolve, reject) => {
95-
out.on('error', reject);
96-
out.write(sizeBuf);
97-
return out.write(headerBuf, () => resolve());
98-
});
99-
return writeFileListToStream(dest, filesystem, out, lists, metadata);
104+
const { files, links } = lists;
105+
for await (const file of files) {
106+
// the file should not be packed into archive
107+
if (file.unpack) {
108+
const targetFile = path.join(`${dest}.unpacked`, file.filename);
109+
await fs.mkdirp(path.dirname(targetFile));
110+
const writeStream = fs.createWriteStream(targetFile, { mode: file.mode });
111+
await pipeline(file.streamGenerator(), writeStream);
112+
} else {
113+
await streamTransformedFile(file.streamGenerator(), out);
114+
}
115+
}
116+
117+
for (const file of links.filter((f) => f.unpack && f.link)) {
118+
// the symlink needs to be recreated outside in .unpacked
119+
await createSymlink(dest, file.filename, file.link!);
120+
}
121+
return out.end();
100122
}
101123

102124
export interface FileRecord extends FilesystemFileEntry {
@@ -187,3 +209,35 @@ export function readFileSync(filesystem: Filesystem, filename: string, info: Fil
187209
}
188210
return buffer;
189211
}
212+
213+
async function createFilesystemWriteStream(filesystem: Filesystem, dest: string) {
214+
const headerPickle = Pickle.createEmpty();
215+
headerPickle.writeString(JSON.stringify(filesystem.getHeader()));
216+
const headerBuf = headerPickle.toBuffer();
217+
218+
const sizePickle = Pickle.createEmpty();
219+
sizePickle.writeUInt32(headerBuf.length);
220+
const sizeBuf = sizePickle.toBuffer();
221+
222+
const out = fs.createWriteStream(dest);
223+
await new Promise<void>((resolve, reject) => {
224+
out.on('error', reject);
225+
out.write(sizeBuf);
226+
return out.write(headerBuf, () => resolve());
227+
});
228+
return out;
229+
}
230+
231+
async function createSymlink(dest: string, filepath: string, link: string) {
232+
// if symlink is within subdirectories, then we need to recreate dir structure
233+
await fs.mkdirp(path.join(`${dest}.unpacked`, path.dirname(filepath)));
234+
// create symlink within unpacked dir
235+
await fs.symlink(link, path.join(`${dest}.unpacked`, filepath)).catch(async (error) => {
236+
if (error.code === 'EPERM' && error.syscall === 'symlink') {
237+
throw new Error(
238+
'Could not create symlinks for unpacked assets. On Windows, consider activating Developer Mode to allow non-admin users to create symlinks by following the instructions at https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development.',
239+
);
240+
}
241+
throw error;
242+
});
243+
}

src/filesystem.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export class Filesystem {
108108

109109
async insertFile(
110110
p: string,
111+
streamGenerator: () => NodeJS.ReadableStream,
111112
shouldUnpack: boolean,
112113
file: CrawledFileType,
113114
options: {
@@ -119,7 +120,7 @@ export class Filesystem {
119120
if (shouldUnpack || dirNode.unpacked) {
120121
node.size = file.stat.size;
121122
node.unpacked = true;
122-
node.integrity = await getFileIntegrity(p);
123+
node.integrity = await getFileIntegrity(streamGenerator());
123124
return Promise.resolve();
124125
}
125126

@@ -130,9 +131,8 @@ export class Filesystem {
130131
const tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'asar-'));
131132
const tmpfile = path.join(tmpdir, path.basename(p));
132133
const out = fs.createWriteStream(tmpfile);
133-
const readStream = fs.createReadStream(p);
134134

135-
await pipeline(readStream, transformed, out);
135+
await pipeline(streamGenerator(), transformed, out);
136136
file.transformed = {
137137
path: tmpfile,
138138
stat: await fs.lstat(tmpfile),
@@ -149,18 +149,14 @@ export class Filesystem {
149149

150150
node.size = size;
151151
node.offset = this.offset.toString();
152-
node.integrity = await getFileIntegrity(p);
152+
node.integrity = await getFileIntegrity(streamGenerator());
153153
if (process.platform !== 'win32' && file.stat.mode & 0o100) {
154154
node.executable = true;
155155
}
156156
this.offset += BigInt(size);
157157
}
158158

159-
insertLink(p: string, shouldUnpack: boolean) {
160-
const symlink = fs.readlinkSync(p);
161-
// /var => /private/var
162-
const parentPath = fs.realpathSync(path.dirname(p));
163-
const link = path.relative(fs.realpathSync(this.src), path.join(parentPath, symlink));
159+
insertLink(p: string, shouldUnpack: boolean, link: string = this.resolveLink(p)) {
164160
if (link.startsWith('..')) {
165161
throw new Error(`${p}: file "${link}" links out of the package`);
166162
}
@@ -173,6 +169,14 @@ export class Filesystem {
173169
return link;
174170
}
175171

172+
private resolveLink(p: string) {
173+
const symlink = fs.readlinkSync(p);
174+
// /var/tmp => /private/var
175+
const parentPath = fs.realpathSync(path.dirname(p));
176+
const link = path.relative(fs.realpathSync(this.src), path.join(parentPath, symlink));
177+
return link;
178+
}
179+
176180
listFiles(options?: { isPack: boolean }) {
177181
const files: string[] = [];
178182

0 commit comments

Comments
 (0)