Skip to content

Commit

Permalink
build(em): add vite as an option
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
eventualbuddha committed Jun 7, 2022
1 parent e90cf6d commit 571ed52
Show file tree
Hide file tree
Showing 12 changed files with 351 additions and 53 deletions.
2 changes: 2 additions & 0 deletions frontends/election-manager/.eslintignore
Expand Up @@ -6,3 +6,5 @@
/prodserver
/src/**/*.js
*.d.ts
*.config.js
*.config.ts
19 changes: 19 additions & 0 deletions frontends/election-manager/index.html
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Another election tool from VotingWorks" />
<link rel="stylesheet" href="/fonts/helvetica-neue.css" />
<title>VotingWorks VxAdmin</title>
</head>

<body>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
</body>

</html>
15 changes: 14 additions & 1 deletion frontends/election-manager/package.json
Expand Up @@ -10,12 +10,14 @@
"scripts": {
"type-check": "tsc --build",
"build": "tsc --build && react-scripts build",
"build:vite": "vite build",
"build:watch": "tsc --build --watch",
"eject": "react-scripts eject",
"format": "prettier '**/*.+(css|graphql|json|less|md|mdx|sass|scss|yaml|yml)' --write",
"lint": "pnpm type-check && eslint . && pnpm stylelint:run",
"lint:fix": "pnpm type-check && eslint . --fix && pnpm stylelint:run:fix",
"start": "react-scripts start",
"start:vite": "vite",
"stylelint:run": "stylelint 'src/**/*.{js,jsx,ts,tsx}' && stylelint 'src/**/*.css' --config .stylelintrc-css.js",
"stylelint:run:fix": "stylelint 'src/**/*.{js,jsx,ts,tsx}' --fix && stylelint 'src/**/*.css' --config .stylelintrc-css.js --fix",
"test": "is-ci test:coverage test:watch",
Expand Down Expand Up @@ -92,13 +94,17 @@
"@votingworks/types": "workspace:*",
"@votingworks/ui": "workspace:*",
"@votingworks/utils": "workspace:*",
"@zip.js/zip.js": "^2.4.12",
"array-unique": "^0.3.2",
"assert": "^2.0.0",
"base64-js": "^1.3.1",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"canvas": "2.9.1",
"dashify": "^2.0.0",
"debug": "^4.3.2",
"dompurify": "^2.0.12",
"events": "^3.3.0",
"fetch-mock": "^9.10.7",
"history": "^4.10.1",
"http-proxy-middleware": "1.0.6",
Expand All @@ -111,6 +117,7 @@
"node-fetch": "^2.6.0",
"normalize.css": "^8.0.1",
"pagedjs": "^0.1.40",
"path": "^0.12.7",
"pdfjs-dist": "2.4.456",
"pluralize": "^8.0.0",
"react": "^17.0.1",
Expand All @@ -119,16 +126,20 @@
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.1",
"react-textarea-autosize": "^8.2.0",
"setimmediate": "^1.0.5",
"stream-browserify": "^3.0.0",
"styled-components": "^5.2.1",
"typescript": "4.6.3",
"use-interval": "^1.2.1",
"util": "^0.12.4",
"zip-stream": "^3.0.1",
"zod": "3.14.4"
},
"devDependencies": {
"@codemod/parser": "^1.0.6",
"@testing-library/jest-dom": "^5.16.4",
"@types/base64-js": "^1.3.0",
"@types/connect": "^3.4.35",
"@types/debug": "^4.1.6",
"@types/history": "^4.7.8",
"@types/kiosk-browser": "workspace:*",
Expand All @@ -154,6 +165,7 @@
"eslint-plugin-react": "^7.18.3",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-vx": "workspace:*",
"express": "^4.18.1",
"is-ci-cli": "^2.1.2",
"jest": "^27.3.1",
"jest-environment-jsdom-sixteen": "^1.0.3",
Expand All @@ -166,7 +178,8 @@
"stylelint-config-palantir": "^4.0.1",
"stylelint-config-prettier": "^8.0.1",
"stylelint-config-styled-components": "^0.1.1",
"stylelint-processor-styled-components": "^1.10.0"
"stylelint-processor-styled-components": "^1.10.0",
"vite": "^2.9.9"
},
"vx": {
"isBundled": true,
Expand Down
16 changes: 11 additions & 5 deletions frontends/election-manager/prodserver/setupProxy.js
Expand Up @@ -11,16 +11,22 @@ const express = require('express');
const { createProxyMiddleware: proxy } = require('http-proxy-middleware');
const { dirname, join } = require('path');

/**
* @param {import('connect').Server} app
*/
module.exports = function (app) {
app.use(proxy('/card', { target: 'http://localhost:3001/' }));
app.use(proxy('/convert', { target: 'http://localhost:3003/' }));
app.use(proxy('/admin', { target: 'http://localhost:3004/' }));

app.get('/machine-config', (req, res) => {
res.json({
machineId: process.env.VX_MACHINE_ID || '0000',
codeVersion: process.env.VX_CODE_VERSION || 'dev',
});
app.use('/machine-config', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
machineId: process.env.VX_MACHINE_ID || '0000',
codeVersion: process.env.VX_CODE_VERSION || 'dev',
})
);
});

const pdfjsDistBuildPath = dirname(
Expand Down
1 change: 1 addition & 0 deletions frontends/election-manager/src/index.tsx
@@ -1,3 +1,4 @@
import './polyfills';
import React from 'react';
import ReactDom from 'react-dom';
import './i18n';
Expand Down
13 changes: 13 additions & 0 deletions frontends/election-manager/src/polyfills.ts
@@ -0,0 +1,13 @@
/**
* Provides polyfills needed for this application and its dependencies.
*/

/* istanbul ignore file */
import { Buffer } from 'buffer';
import 'setimmediate';

globalThis.global = globalThis;
globalThis.Buffer = Buffer;
globalThis.process ??= {} as unknown as typeof process;

process.nextTick = setImmediate;
4 changes: 4 additions & 0 deletions frontends/election-manager/src/stubs/glob.ts
@@ -0,0 +1,4 @@
// This file exists to serve as a stub for the `glob` module.
// See `vite.config.ts` under `resolve.alias` for the configuration.

export {};
22 changes: 10 additions & 12 deletions frontends/election-manager/src/utils/downloadable_archive.test.ts
@@ -1,11 +1,10 @@
import { fakeKiosk } from '@votingworks/test-utils';
import { Buffer } from 'buffer';
import { fakeFileWriter } from '../../test/helpers/fake_file_writer';
import { DownloadableArchive } from './downloadable_archive';

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

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

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

const firstChunk = fileWriter.chunks[0] as Buffer;
expect(firstChunk).toBeInstanceOf(Buffer);
expect(firstChunk.slice(0, EMPTY_ZIP_MAGIC_BYTES.length)).toEqual(
const firstChunk = fileWriter.chunks[0] as Uint8Array;
expect(Array.from(firstChunk.slice(0, EMPTY_ZIP_MAGIC_BYTES.length))).toEqual(
EMPTY_ZIP_MAGIC_BYTES
);
});
Expand All @@ -83,9 +80,10 @@ test('zip file containing a file', async () => {
await archive.end();
expect(fileWriter.chunks).not.toHaveLength(0);

const firstChunk = fileWriter.chunks[0] as Buffer;
expect(firstChunk).toBeInstanceOf(Buffer);
expect(firstChunk.slice(0, ZIP_MAGIC_BYTES.length)).toEqual(ZIP_MAGIC_BYTES);
const firstChunk = fileWriter.chunks[0] as Uint8Array;
expect(Array.from(firstChunk.slice(0, ZIP_MAGIC_BYTES.length))).toEqual(
ZIP_MAGIC_BYTES
);
});

test('passes options to kiosk.saveAs', async () => {
Expand Down
78 changes: 48 additions & 30 deletions frontends/election-manager/src/utils/downloadable_archive.ts
@@ -1,15 +1,43 @@
import { assert, deferred } from '@votingworks/utils';
import ZipStream from 'zip-stream';
/* eslint-disable max-classes-per-file */
import { assert } from '@votingworks/utils';
import { Buffer } from 'buffer';
import path from 'path';
import { configure, Uint8ArrayReader, ZipWriter, Writer } from '@zip.js/zip.js';

configure({ useWebWorkers: false });

/**
* Forwards data from a `ZipWriter` to a kiosk-browser file writer.
*/
class KioskBrowserZipFileWriter extends Writer {
constructor(private readonly fileWriter: KioskBrowser.FileWriter) {
super();
}

/**
* Called whenever there is new data to write to the zip file.
*/
async writeUint8Array(array: Uint8Array): Promise<void> {
await super.writeUint8Array(array);
await this.fileWriter.write(array);
}

/**
* This function is required by the ZipWriter interface, but we ignore its
* return value. It is called when closing the zip file.
*/
async getData(): Promise<Uint8Array> {
return Promise.resolve(Uint8Array.of());
}
}

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

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

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

let endResolve: () => void;
this.endPromise = new Promise((resolve) => {
endResolve = resolve;
});
this.zip = new ZipStream()
.on('data', (chunk) => fileWriter.write(chunk))
.on('end', () => fileWriter.end().then(endResolve));
this.prepareZip(fileWriter);
}

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

const { promise: endPromise, resolve: endResolve } = deferred<void>();
this.endPromise = endPromise;
this.zip = new ZipStream()
.on('data', (chunk) => fileWriter.write(chunk))
.on('end', () => fileWriter.end().then(endResolve));
this.prepareZip(fileWriter);
}

/**
* Prepares the zip archive for writing to the given file writer.
*/
private prepareZip(fileWriter: KioskBrowser.FileWriter): void {
this.writer = new ZipWriter(new KioskBrowserZipFileWriter(fileWriter));
}

/**
* Writes a file to the archive, resolves when complete.
*/
async file(
name: string,
data: Parameters<ZipStream['entry']>[0]
): Promise<void> {
const { zip } = this;
async file(name: string, data: string | Buffer): Promise<void> {
const { writer } = this;

if (!zip) {
if (!writer) {
throw new Error('cannot call file() before begin()');
}

return new Promise((resolve, reject) => {
zip.entry(data, { name }, (err) => (err ? reject(err) : resolve()));
});
await writer.add(name, new Uint8ArrayReader(Buffer.from(data)));
}

/**
* Finishes the zip archive and ends the download.
*/
async end(): Promise<void> {
if (!this.zip) {
if (!this.writer) {
throw new Error('cannot call end() before begin()');
}

this.zip.finalize();
await this.endPromise;
this.zip = undefined;
this.endPromise = undefined;
await this.writer.close();
this.writer = undefined;
}
}

0 comments on commit 571ed52

Please sign in to comment.