Skip to content

Commit 571ed52

Browse files
build(em): add vite as an option
Adds `vite` alongside `react-scripts` as a method for running and building Election Manager. To use it, run `pnpm start:vite` or `pnpm build:vite`. These will soon replace the `react-scripts` method. This took more effort than the equivalent changes for the other frontends. In particular, I had to replace zip-stream with zip.js. I tested it a fair amount, but it's possible there's some difference I haven't accounted for. The reason for the change is that zip-stream uses `readable-streams`, which is a NPM-land implementation of streams from NodeJS. The `readable-streams` package had a circular dependency that rollup chokes on: nodejs/readable-stream#348. While it seems to be fixed in the latest version, the dependency that uses it is not compatible with the latest version so I cannot simply replace all `readable-stream` copies with the newest one. Switching the ZIP library turned out to be simpler.
1 parent e90cf6d commit 571ed52

File tree

12 files changed

+351
-53
lines changed

12 files changed

+351
-53
lines changed

frontends/election-manager/.eslintignore

+2
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@
66
/prodserver
77
/src/**/*.js
88
*.d.ts
9+
*.config.js
10+
*.config.ts

frontends/election-manager/index.html

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="utf-8" />
6+
<link rel="icon" href="/favicon.ico" />
7+
<meta name="viewport" content="width=device-width, initial-scale=1" />
8+
<meta name="theme-color" content="#000000" />
9+
<meta name="description" content="Another election tool from VotingWorks" />
10+
<link rel="stylesheet" href="/fonts/helvetica-neue.css" />
11+
<title>VotingWorks VxAdmin</title>
12+
</head>
13+
14+
<body>
15+
<div id="root"></div>
16+
<script type="module" src="./src/index.tsx"></script>
17+
</body>
18+
19+
</html>

frontends/election-manager/package.json

+14-1
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
"scripts": {
1111
"type-check": "tsc --build",
1212
"build": "tsc --build && react-scripts build",
13+
"build:vite": "vite build",
1314
"build:watch": "tsc --build --watch",
1415
"eject": "react-scripts eject",
1516
"format": "prettier '**/*.+(css|graphql|json|less|md|mdx|sass|scss|yaml|yml)' --write",
1617
"lint": "pnpm type-check && eslint . && pnpm stylelint:run",
1718
"lint:fix": "pnpm type-check && eslint . --fix && pnpm stylelint:run:fix",
1819
"start": "react-scripts start",
20+
"start:vite": "vite",
1921
"stylelint:run": "stylelint 'src/**/*.{js,jsx,ts,tsx}' && stylelint 'src/**/*.css' --config .stylelintrc-css.js",
2022
"stylelint:run:fix": "stylelint 'src/**/*.{js,jsx,ts,tsx}' --fix && stylelint 'src/**/*.css' --config .stylelintrc-css.js --fix",
2123
"test": "is-ci test:coverage test:watch",
@@ -92,13 +94,17 @@
9294
"@votingworks/types": "workspace:*",
9395
"@votingworks/ui": "workspace:*",
9496
"@votingworks/utils": "workspace:*",
97+
"@zip.js/zip.js": "^2.4.12",
9598
"array-unique": "^0.3.2",
99+
"assert": "^2.0.0",
96100
"base64-js": "^1.3.1",
101+
"browserify-zlib": "^0.2.0",
97102
"buffer": "^6.0.3",
98103
"canvas": "2.9.1",
99104
"dashify": "^2.0.0",
100105
"debug": "^4.3.2",
101106
"dompurify": "^2.0.12",
107+
"events": "^3.3.0",
102108
"fetch-mock": "^9.10.7",
103109
"history": "^4.10.1",
104110
"http-proxy-middleware": "1.0.6",
@@ -111,6 +117,7 @@
111117
"node-fetch": "^2.6.0",
112118
"normalize.css": "^8.0.1",
113119
"pagedjs": "^0.1.40",
120+
"path": "^0.12.7",
114121
"pdfjs-dist": "2.4.456",
115122
"pluralize": "^8.0.0",
116123
"react": "^17.0.1",
@@ -119,16 +126,20 @@
119126
"react-router-dom": "^5.2.0",
120127
"react-scripts": "4.0.1",
121128
"react-textarea-autosize": "^8.2.0",
129+
"setimmediate": "^1.0.5",
130+
"stream-browserify": "^3.0.0",
122131
"styled-components": "^5.2.1",
123132
"typescript": "4.6.3",
124133
"use-interval": "^1.2.1",
134+
"util": "^0.12.4",
125135
"zip-stream": "^3.0.1",
126136
"zod": "3.14.4"
127137
},
128138
"devDependencies": {
129139
"@codemod/parser": "^1.0.6",
130140
"@testing-library/jest-dom": "^5.16.4",
131141
"@types/base64-js": "^1.3.0",
142+
"@types/connect": "^3.4.35",
132143
"@types/debug": "^4.1.6",
133144
"@types/history": "^4.7.8",
134145
"@types/kiosk-browser": "workspace:*",
@@ -154,6 +165,7 @@
154165
"eslint-plugin-react": "^7.18.3",
155166
"eslint-plugin-react-hooks": "^4.2.0",
156167
"eslint-plugin-vx": "workspace:*",
168+
"express": "^4.18.1",
157169
"is-ci-cli": "^2.1.2",
158170
"jest": "^27.3.1",
159171
"jest-environment-jsdom-sixteen": "^1.0.3",
@@ -166,7 +178,8 @@
166178
"stylelint-config-palantir": "^4.0.1",
167179
"stylelint-config-prettier": "^8.0.1",
168180
"stylelint-config-styled-components": "^0.1.1",
169-
"stylelint-processor-styled-components": "^1.10.0"
181+
"stylelint-processor-styled-components": "^1.10.0",
182+
"vite": "^2.9.9"
170183
},
171184
"vx": {
172185
"isBundled": true,

frontends/election-manager/prodserver/setupProxy.js

+11-5
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,22 @@ const express = require('express');
1111
const { createProxyMiddleware: proxy } = require('http-proxy-middleware');
1212
const { dirname, join } = require('path');
1313

14+
/**
15+
* @param {import('connect').Server} app
16+
*/
1417
module.exports = function (app) {
1518
app.use(proxy('/card', { target: 'http://localhost:3001/' }));
1619
app.use(proxy('/convert', { target: 'http://localhost:3003/' }));
1720
app.use(proxy('/admin', { target: 'http://localhost:3004/' }));
1821

19-
app.get('/machine-config', (req, res) => {
20-
res.json({
21-
machineId: process.env.VX_MACHINE_ID || '0000',
22-
codeVersion: process.env.VX_CODE_VERSION || 'dev',
23-
});
22+
app.use('/machine-config', (req, res) => {
23+
res.setHeader('Content-Type', 'application/json');
24+
res.end(
25+
JSON.stringify({
26+
machineId: process.env.VX_MACHINE_ID || '0000',
27+
codeVersion: process.env.VX_CODE_VERSION || 'dev',
28+
})
29+
);
2430
});
2531

2632
const pdfjsDistBuildPath = dirname(

frontends/election-manager/src/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import './polyfills';
12
import React from 'react';
23
import ReactDom from 'react-dom';
34
import './i18n';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Provides polyfills needed for this application and its dependencies.
3+
*/
4+
5+
/* istanbul ignore file */
6+
import { Buffer } from 'buffer';
7+
import 'setimmediate';
8+
9+
globalThis.global = globalThis;
10+
globalThis.Buffer = Buffer;
11+
globalThis.process ??= {} as unknown as typeof process;
12+
13+
process.nextTick = setImmediate;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// This file exists to serve as a stub for the `glob` module.
2+
// See `vite.config.ts` under `resolve.alias` for the configuration.
3+
4+
export {};

frontends/election-manager/src/utils/downloadable_archive.test.ts

+10-12
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { fakeKiosk } from '@votingworks/test-utils';
2-
import { Buffer } from 'buffer';
32
import { fakeFileWriter } from '../../test/helpers/fake_file_writer';
43
import { DownloadableArchive } from './downloadable_archive';
54

65
// https://en.wikipedia.org/wiki/List_of_file_signatures
7-
const ZIP_MAGIC_BYTES = Buffer.of(0x50, 0x4b, 0x03, 0x04);
8-
const EMPTY_ZIP_MAGIC_BYTES = Buffer.of(0x50, 0x4b, 0x05, 0x06);
6+
const ZIP_MAGIC_BYTES = [0x50, 0x4b, 0x03, 0x04];
7+
const EMPTY_ZIP_MAGIC_BYTES = [0x50, 0x4b, 0x05, 0x06];
98

109
test('file prompt fails', async () => {
1110
const kiosk = fakeKiosk();
@@ -39,9 +38,8 @@ test('empty zip file when user is prompted for file location', async () => {
3938
await archive.end();
4039
expect(fileWriter.chunks).not.toHaveLength(0);
4140

42-
const firstChunk = fileWriter.chunks[0] as Buffer;
43-
expect(firstChunk).toBeInstanceOf(Buffer);
44-
expect(firstChunk.slice(0, EMPTY_ZIP_MAGIC_BYTES.length)).toEqual(
41+
const firstChunk = fileWriter.chunks[0] as Uint8Array;
42+
expect(Array.from(firstChunk.slice(0, EMPTY_ZIP_MAGIC_BYTES.length))).toEqual(
4543
EMPTY_ZIP_MAGIC_BYTES
4644
);
4745
});
@@ -63,9 +61,8 @@ test('empty zip file when file is saved directly and passes path to kiosk proper
6361
});
6462
expect(kiosk.writeFile).toHaveBeenCalledWith('/path/to/folder/file.zip');
6563

66-
const firstChunk = fileWriter.chunks[0] as Buffer;
67-
expect(firstChunk).toBeInstanceOf(Buffer);
68-
expect(firstChunk.slice(0, EMPTY_ZIP_MAGIC_BYTES.length)).toEqual(
64+
const firstChunk = fileWriter.chunks[0] as Uint8Array;
65+
expect(Array.from(firstChunk.slice(0, EMPTY_ZIP_MAGIC_BYTES.length))).toEqual(
6966
EMPTY_ZIP_MAGIC_BYTES
7067
);
7168
});
@@ -83,9 +80,10 @@ test('zip file containing a file', async () => {
8380
await archive.end();
8481
expect(fileWriter.chunks).not.toHaveLength(0);
8582

86-
const firstChunk = fileWriter.chunks[0] as Buffer;
87-
expect(firstChunk).toBeInstanceOf(Buffer);
88-
expect(firstChunk.slice(0, ZIP_MAGIC_BYTES.length)).toEqual(ZIP_MAGIC_BYTES);
83+
const firstChunk = fileWriter.chunks[0] as Uint8Array;
84+
expect(Array.from(firstChunk.slice(0, ZIP_MAGIC_BYTES.length))).toEqual(
85+
ZIP_MAGIC_BYTES
86+
);
8987
});
9088

9189
test('passes options to kiosk.saveAs', async () => {
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,43 @@
1-
import { assert, deferred } from '@votingworks/utils';
2-
import ZipStream from 'zip-stream';
1+
/* eslint-disable max-classes-per-file */
2+
import { assert } from '@votingworks/utils';
3+
import { Buffer } from 'buffer';
34
import path from 'path';
5+
import { configure, Uint8ArrayReader, ZipWriter, Writer } from '@zip.js/zip.js';
6+
7+
configure({ useWebWorkers: false });
8+
9+
/**
10+
* Forwards data from a `ZipWriter` to a kiosk-browser file writer.
11+
*/
12+
class KioskBrowserZipFileWriter extends Writer {
13+
constructor(private readonly fileWriter: KioskBrowser.FileWriter) {
14+
super();
15+
}
16+
17+
/**
18+
* Called whenever there is new data to write to the zip file.
19+
*/
20+
async writeUint8Array(array: Uint8Array): Promise<void> {
21+
await super.writeUint8Array(array);
22+
await this.fileWriter.write(array);
23+
}
24+
25+
/**
26+
* This function is required by the ZipWriter interface, but we ignore its
27+
* return value. It is called when closing the zip file.
28+
*/
29+
async getData(): Promise<Uint8Array> {
30+
return Promise.resolve(Uint8Array.of());
31+
}
32+
}
433

534
/**
635
* Provides support for downloading a Zip archive of files. Requires
736
* the page is running inside `kiosk-browser` and that it is configured such
837
* that the executing host is allowed to use the `saveAs` API.
938
*/
1039
export class DownloadableArchive {
11-
private zip?: ZipStream;
12-
private endPromise?: Promise<void>;
40+
private writer?: ZipWriter;
1341

1442
constructor(private readonly kiosk = window.kiosk) {}
1543

@@ -30,13 +58,7 @@ export class DownloadableArchive {
3058
throw new Error('could not begin download; no file was chosen');
3159
}
3260

33-
let endResolve: () => void;
34-
this.endPromise = new Promise((resolve) => {
35-
endResolve = resolve;
36-
});
37-
this.zip = new ZipStream()
38-
.on('data', (chunk) => fileWriter.write(chunk))
39-
.on('end', () => fileWriter.end().then(endResolve));
61+
this.prepareZip(fileWriter);
4062
}
4163

4264
/**
@@ -57,42 +79,38 @@ export class DownloadableArchive {
5779
throw new Error('could not begin download; an error occurred');
5880
}
5981

60-
const { promise: endPromise, resolve: endResolve } = deferred<void>();
61-
this.endPromise = endPromise;
62-
this.zip = new ZipStream()
63-
.on('data', (chunk) => fileWriter.write(chunk))
64-
.on('end', () => fileWriter.end().then(endResolve));
82+
this.prepareZip(fileWriter);
83+
}
84+
85+
/**
86+
* Prepares the zip archive for writing to the given file writer.
87+
*/
88+
private prepareZip(fileWriter: KioskBrowser.FileWriter): void {
89+
this.writer = new ZipWriter(new KioskBrowserZipFileWriter(fileWriter));
6590
}
6691

6792
/**
6893
* Writes a file to the archive, resolves when complete.
6994
*/
70-
async file(
71-
name: string,
72-
data: Parameters<ZipStream['entry']>[0]
73-
): Promise<void> {
74-
const { zip } = this;
95+
async file(name: string, data: string | Buffer): Promise<void> {
96+
const { writer } = this;
7597

76-
if (!zip) {
98+
if (!writer) {
7799
throw new Error('cannot call file() before begin()');
78100
}
79101

80-
return new Promise((resolve, reject) => {
81-
zip.entry(data, { name }, (err) => (err ? reject(err) : resolve()));
82-
});
102+
await writer.add(name, new Uint8ArrayReader(Buffer.from(data)));
83103
}
84104

85105
/**
86106
* Finishes the zip archive and ends the download.
87107
*/
88108
async end(): Promise<void> {
89-
if (!this.zip) {
109+
if (!this.writer) {
90110
throw new Error('cannot call end() before begin()');
91111
}
92112

93-
this.zip.finalize();
94-
await this.endPromise;
95-
this.zip = undefined;
96-
this.endPromise = undefined;
113+
await this.writer.close();
114+
this.writer = undefined;
97115
}
98116
}

0 commit comments

Comments
 (0)