@prairielearn/compiled-assets 1.0.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.
@@ -0,0 +1,2 @@
1
+ warning package.json: No license field
2
+ $ tsc
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # `@prairielearn/compiled-assets`
2
+
3
+ This package enables the transpilation and bundling of client-side assets, namely JavaScript.
4
+
5
+ This tool is meant to produce many small, independent bundles that can then be included as needed on each page.
6
+
7
+ ## Usage
8
+
9
+ ### File structure
10
+
11
+ Create a directory of assets that you wish to bundle, e.g. `assets/`. Within that directory, create another directory `scripts/`. Any JavaScript or TypeScript files in the root of the `scripts/` directory will become a bundle that can be loaded on a page. For example, the following directory structure would produce bundles named `foo` and `bar`:
12
+
13
+ ```
14
+ ├── assets/
15
+ │ ├── scripts/
16
+ │ │ ├── foo.ts
17
+ │ │ └── bar.ts
18
+ ```
19
+
20
+ You can locate shared code in directories inside this directory. As long as those files aren't in the root of the `scripts/` directory, they won't become separate bundles.
21
+
22
+ ```
23
+ ├── assets/
24
+ │ ├── scripts/
25
+ | │ ├── lib/
26
+ | │ │ ├── shared-code.ts
27
+ | │ │ └── more-shared-code.ts
28
+ | │ ├── foo.ts
29
+ │ | └── bar.ts
30
+ ```
31
+
32
+ ### Application integration
33
+
34
+ Early in your application initialization process, initialize this library with the appropriate options:
35
+
36
+ ```ts
37
+ import * as compiledAssets from '@prairielearn/compiled-assets';
38
+
39
+ assets.init({
40
+ dev: process.env.NODE_ENV !== 'production',
41
+ sourceDirectory: './assets',
42
+ buildDirectory: './public/build',
43
+ publicPath: '/build/',
44
+ });
45
+ ```
46
+
47
+ Then, add the request handler. The path at which you mount it should match the `publicPath` that was configured above.
48
+
49
+ ```ts
50
+ const app = express();
51
+
52
+ app.use('/build/', assets.handler());
53
+ ```
54
+
55
+ To include a bundle on your page, you can use the `compiledScriptTag` or `compiledScriptPath` functions. The name of the bundle passed to this function is the filename of your bundle within the `scripts` directory.
56
+
57
+ ```ts
58
+ import { html } from '@prairielearn/html';
59
+ import { compiledScriptTag, compiledScriptPath } from '@prairielearn/compiled-assets';
60
+
61
+ router.get(() => {
62
+ return html`
63
+ <html>
64
+ <head>
65
+ ${compiledScriptTag('foo.ts')}
66
+ <script src="${compiledScriptPath('bar.ts')}"></script>
67
+ </head>
68
+ </body>
69
+ Hello, world.
70
+ </body>
71
+ </html>
72
+ `;
73
+ });
74
+ ```
75
+
76
+ For legacy PrairieLearn code that uses EJS, you can use the `compiled_script_tag` function that's made available to EJS templates via `res.locals`:
77
+
78
+ ```ejs
79
+ <head>
80
+ <%- compiled_script_tag('foo.ts') %>
81
+ </head>
82
+ ```
83
+
84
+ ### Building assets for production
85
+
86
+ For production usage, assets must be precompiled with the `compiled-assets build` command. Note that the source directory and build directory should match the values provided to `assets.init`.
87
+
88
+ ```sh
89
+ $ compiled-assets build ./assets ./public/build
90
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
+ const pretty_bytes_1 = __importDefault(require("pretty-bytes"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const util_1 = require("util");
11
+ const zlib_1 = __importDefault(require("zlib"));
12
+ const commander_1 = require("commander");
13
+ const index_js_1 = require("./index.js");
14
+ const gzip = (0, util_1.promisify)(zlib_1.default.gzip);
15
+ const brotli = (0, util_1.promisify)(zlib_1.default.brotliCompress);
16
+ commander_1.program.command('build <source> <destination>').action(async (source, destination) => {
17
+ const metafile = await (0, index_js_1.build)(source, destination);
18
+ // Write gzip and brotli versions of the output files. Record size information
19
+ // so we can show it to the user.
20
+ const compressedSizes = {};
21
+ await Promise.all(Object.keys(metafile.outputs).map(async (outputPath) => {
22
+ const contents = await fs_extra_1.default.readFile(outputPath);
23
+ const gzipCompressed = await gzip(contents);
24
+ const brotliCompressed = await brotli(contents);
25
+ await fs_extra_1.default.writeFile(`${outputPath}.gz`, gzipCompressed);
26
+ await fs_extra_1.default.writeFile(`${outputPath}.br`, brotliCompressed);
27
+ compressedSizes[outputPath] = {
28
+ gzip: gzipCompressed.length,
29
+ brotli: brotliCompressed.length,
30
+ };
31
+ }));
32
+ // Format the output into an object that we can pass to `console.table`.
33
+ const results = {};
34
+ Object.entries(metafile.outputs).forEach(([outputPath, meta]) => {
35
+ if (!meta.entryPoint)
36
+ return;
37
+ results[path_1.default.basename(meta.entryPoint)] = {
38
+ 'Output file': path_1.default.basename(outputPath),
39
+ Size: (0, pretty_bytes_1.default)(meta.bytes),
40
+ 'Size (gzip)': (0, pretty_bytes_1.default)(compressedSizes[outputPath].gzip),
41
+ 'Size (brotli)': (0, pretty_bytes_1.default)(compressedSizes[outputPath].brotli),
42
+ };
43
+ });
44
+ console.table(results);
45
+ });
46
+ commander_1.program.parseAsync(process.argv).catch((err) => {
47
+ console.error(err);
48
+ process.exit(1);
49
+ });
50
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";;;;;;AACA,wDAA0B;AAC1B,gEAAuC;AACvC,gDAAwB;AACxB,+BAAiC;AACjC,gDAAwB;AACxB,yCAAoC;AAEpC,yCAAmC;AAEnC,MAAM,IAAI,GAAG,IAAA,gBAAS,EAAC,cAAI,CAAC,IAAI,CAAC,CAAC;AAClC,MAAM,MAAM,GAAG,IAAA,gBAAS,EAAC,cAAI,CAAC,cAAc,CAAC,CAAC;AAE9C,mBAAO,CAAC,OAAO,CAAC,8BAA8B,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE;IACnF,MAAM,QAAQ,GAAG,MAAM,IAAA,gBAAK,EAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAElD,8EAA8E;IAC9E,iCAAiC;IACjC,MAAM,eAAe,GAA2C,EAAE,CAAC;IACnE,MAAM,OAAO,CAAC,GAAG,CACf,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE;QACrD,MAAM,QAAQ,GAAG,MAAM,kBAAE,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;QAC/C,MAAM,cAAc,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC5C,MAAM,gBAAgB,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;QAChD,MAAM,kBAAE,CAAC,SAAS,CAAC,GAAG,UAAU,KAAK,EAAE,cAAc,CAAC,CAAC;QACvD,MAAM,kBAAE,CAAC,SAAS,CAAC,GAAG,UAAU,KAAK,EAAE,gBAAgB,CAAC,CAAC;QACzD,eAAe,CAAC,UAAU,CAAC,GAAG;YAC5B,IAAI,EAAE,cAAc,CAAC,MAAM;YAC3B,MAAM,EAAE,gBAAgB,CAAC,MAAM;SAChC,CAAC;IACJ,CAAC,CAAC,CACH,CAAC;IAEF,wEAAwE;IACxE,MAAM,OAAO,GAAwB,EAAE,CAAC;IACxC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,EAAE;QAC9D,IAAI,CAAC,IAAI,CAAC,UAAU;YAAE,OAAO;QAC7B,OAAO,CAAC,cAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,GAAG;YACxC,aAAa,EAAE,cAAI,CAAC,QAAQ,CAAC,UAAU,CAAC;YACxC,IAAI,EAAE,IAAA,sBAAW,EAAC,IAAI,CAAC,KAAK,CAAC;YAC7B,aAAa,EAAE,IAAA,sBAAW,EAAC,eAAe,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC;YAC5D,eAAe,EAAE,IAAA,sBAAW,EAAC,eAAe,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC;SACjE,CAAC;IACJ,CAAC,CAAC,CAAC;IACH,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;AACzB,CAAC,CAAC,CAAC;AAEH,mBAAO,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IAC7C,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACnB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -0,0 +1,23 @@
1
+ import type { RequestHandler } from 'express';
2
+ import esbuild from 'esbuild';
3
+ import { HtmlSafeString } from '@prairielearn/html';
4
+ export interface CompiledAssetsOptions {
5
+ /**
6
+ * Whether the app is running in dev mode. If dev modde is enabled, then
7
+ * assets will be built on the fly as they're requested. Otherwise, assets
8
+ * should have been pre-compiled to the `buildDirectory` directory.
9
+ */
10
+ dev?: boolean;
11
+ /** Root directory of assets. */
12
+ sourceDirectory?: string;
13
+ /** Directory where the built assets will be output to. */
14
+ buildDirectory?: string;
15
+ /** The path that assets will be served from, e.g. `/build/`. */
16
+ publicPath?: string;
17
+ }
18
+ export declare function init(newOptions: Partial<CompiledAssetsOptions>): void;
19
+ export declare function assertConfigured(): void;
20
+ export declare function handler(): RequestHandler;
21
+ export declare function compiledScriptPath(sourceFile: string): string;
22
+ export declare function compiledScriptTag(sourceFile: string): HtmlSafeString;
23
+ export declare function build(sourceDirectory: string, buildDirectory: string): Promise<esbuild.Metafile>;
package/dist/index.js ADDED
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.build = exports.compiledScriptTag = exports.compiledScriptPath = exports.handler = exports.assertConfigured = exports.init = void 0;
7
+ const express_static_gzip_1 = __importDefault(require("express-static-gzip"));
8
+ const esbuild_1 = __importDefault(require("esbuild"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const globby_1 = __importDefault(require("globby"));
11
+ const fs_extra_1 = __importDefault(require("fs-extra"));
12
+ const html_1 = require("@prairielearn/html");
13
+ const DEFAULT_OPTIONS = {
14
+ dev: process.env.NODE_ENV !== 'production',
15
+ sourceDirectory: './assets',
16
+ buildDirectory: './public/build',
17
+ publicPath: '/build/',
18
+ };
19
+ let options = { ...DEFAULT_OPTIONS };
20
+ function init(newOptions) {
21
+ options = {
22
+ ...DEFAULT_OPTIONS,
23
+ ...newOptions,
24
+ };
25
+ if (!options.publicPath.endsWith('/')) {
26
+ options.publicPath += '/';
27
+ }
28
+ }
29
+ exports.init = init;
30
+ function assertConfigured() {
31
+ if (!options) {
32
+ throw new Error('@prairielearn/compiled-assets was not configured');
33
+ }
34
+ }
35
+ exports.assertConfigured = assertConfigured;
36
+ function handler() {
37
+ assertConfigured();
38
+ if (!options?.dev) {
39
+ // We're running in production: serve all assets from the build directory.
40
+ // Set headers to cache for as long as possible, since the assets will
41
+ // include content hashes in their filenames.
42
+ return (0, express_static_gzip_1.default)(options?.buildDirectory, {
43
+ enableBrotli: true,
44
+ // Prefer Brotli if the client supports it.
45
+ orderPreference: ['br'],
46
+ serveStatic: {
47
+ maxAge: '31557600',
48
+ immutable: true,
49
+ },
50
+ });
51
+ }
52
+ // We're running in dev mode, so we need to boot up ESBuild to start building
53
+ // and watching our assets.
54
+ return function (req, res) {
55
+ // Strip leading slash from `req.url`.
56
+ let assetPath = req.url;
57
+ if (assetPath.startsWith('/')) {
58
+ assetPath = assetPath.slice(1);
59
+ }
60
+ const resolvedSourceDirectory = path_1.default.resolve(options?.sourceDirectory);
61
+ const resolvedAssetPath = path_1.default.resolve(resolvedSourceDirectory, assetPath);
62
+ if (!resolvedAssetPath.startsWith(resolvedSourceDirectory)) {
63
+ // Probably path traversal.
64
+ res.status(404).send('Not found');
65
+ return;
66
+ }
67
+ // esbuild should be fast enough that we can just build everything on the
68
+ // fly as it's requested! This is probably just for prototyping though. We
69
+ // should use some kind of caching to ensure that local dev stays fast.
70
+ esbuild_1.default
71
+ .build({
72
+ entryPoints: [resolvedAssetPath],
73
+ target: 'es6',
74
+ format: 'iife',
75
+ sourcemap: 'inline',
76
+ bundle: true,
77
+ write: false,
78
+ })
79
+ .then((buildResult) => {
80
+ res
81
+ .setHeader('Content-Type', 'application/javascript; charset=UTF-8')
82
+ .status(200)
83
+ .send(buildResult.outputFiles[0].text);
84
+ }, (buildError) => {
85
+ res.status(500).send(buildError.message);
86
+ });
87
+ };
88
+ }
89
+ exports.handler = handler;
90
+ let cachedScriptsManifest = null;
91
+ function readScriptsManifest() {
92
+ assertConfigured();
93
+ if (!cachedScriptsManifest) {
94
+ const manifestPath = path_1.default.join(options.buildDirectory, 'scripts', 'manifest.json');
95
+ cachedScriptsManifest = fs_extra_1.default.readJSONSync(manifestPath);
96
+ }
97
+ return cachedScriptsManifest;
98
+ }
99
+ function compiledScriptPath(sourceFile) {
100
+ assertConfigured();
101
+ if (options.dev) {
102
+ return options.publicPath + 'scripts/' + sourceFile;
103
+ }
104
+ const scriptsManifest = readScriptsManifest();
105
+ const scriptPath = scriptsManifest[sourceFile];
106
+ if (!scriptPath) {
107
+ throw new Error(`Unknown script: ${sourceFile}`);
108
+ }
109
+ return options.publicPath + 'scripts/' + scriptPath;
110
+ }
111
+ exports.compiledScriptPath = compiledScriptPath;
112
+ function compiledScriptTag(sourceFile) {
113
+ return (0, html_1.html) `<script src="${compiledScriptPath(sourceFile)}"></script>`;
114
+ }
115
+ exports.compiledScriptTag = compiledScriptTag;
116
+ async function build(sourceDirectory, buildDirectory) {
117
+ // Remove existing assets to ensure that no stale assets are left behind.
118
+ await fs_extra_1.default.remove(buildDirectory);
119
+ const scriptsSourceRoot = path_1.default.resolve(sourceDirectory, 'scripts');
120
+ const scriptsBuildRoot = path_1.default.resolve(buildDirectory, 'scripts');
121
+ await fs_extra_1.default.ensureDir(scriptsBuildRoot);
122
+ const files = await (0, globby_1.default)(path_1.default.join(scriptsSourceRoot, '*.{js,jsx,ts,tsx}'));
123
+ const buildResult = await esbuild_1.default.build({
124
+ entryPoints: files,
125
+ target: 'es6',
126
+ format: 'iife',
127
+ sourcemap: 'linked',
128
+ bundle: true,
129
+ minify: true,
130
+ entryNames: '[name]-[hash]',
131
+ outdir: scriptsBuildRoot,
132
+ metafile: true,
133
+ });
134
+ // Write asset manifest so that we can map from "input" names to built names
135
+ // at runtime.
136
+ const { metafile } = buildResult;
137
+ const manifest = {};
138
+ Object.entries(metafile.outputs).forEach(([outputPath, meta]) => {
139
+ if (!meta.entryPoint)
140
+ return;
141
+ const entryPath = path_1.default.basename(meta.entryPoint);
142
+ const assetPath = path_1.default.basename(outputPath);
143
+ manifest[entryPath] = assetPath;
144
+ });
145
+ const manifestPath = path_1.default.join(scriptsBuildRoot, 'manifest.json');
146
+ await fs_extra_1.default.writeJSON(manifestPath, manifest);
147
+ return metafile;
148
+ }
149
+ exports.build = build;
150
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const tmp_promise_1 = __importDefault(require("tmp-promise"));
7
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const get_port_1 = __importDefault(require("get-port"));
10
+ const chai_1 = require("chai");
11
+ const express_1 = __importDefault(require("express"));
12
+ const node_fetch_1 = __importDefault(require("node-fetch"));
13
+ const index_1 = require("./index");
14
+ async function testProject(options) {
15
+ await tmp_promise_1.default.withDir(async (dir) => {
16
+ const scriptsRoot = path_1.default.join(dir.path, 'assets', 'scripts');
17
+ await fs_extra_1.default.ensureDir(scriptsRoot);
18
+ const jsScriptPath = path_1.default.join(scriptsRoot, 'foo.js');
19
+ const tsScriptPath = path_1.default.join(scriptsRoot, 'bar.ts');
20
+ fs_extra_1.default.writeFile(jsScriptPath, 'console.log("foo")');
21
+ fs_extra_1.default.writeFile(tsScriptPath, 'interface Foo {};\n\nconsole.log("bar")');
22
+ if (!options.dev) {
23
+ await (0, index_1.build)(path_1.default.join(dir.path, 'assets'), path_1.default.join(dir.path, 'public', 'build'));
24
+ }
25
+ (0, index_1.init)({
26
+ sourceDirectory: path_1.default.join(dir.path, 'assets'),
27
+ buildDirectory: path_1.default.join(dir.path, 'public', 'build'),
28
+ publicPath: '/build',
29
+ ...options,
30
+ });
31
+ const port = await (0, get_port_1.default)();
32
+ const app = (0, express_1.default)();
33
+ app.use('/build', (0, index_1.handler)());
34
+ const server = app.listen(port);
35
+ try {
36
+ const res = await (0, node_fetch_1.default)(`http://localhost:${port}${(0, index_1.compiledScriptPath)('foo.js')}`);
37
+ chai_1.assert.isTrue(res.ok);
38
+ chai_1.assert.match(await res.text(), /console\.log\("foo"\)/);
39
+ }
40
+ finally {
41
+ server.close();
42
+ }
43
+ }, {
44
+ unsafeCleanup: true,
45
+ });
46
+ }
47
+ describe('compiled-assets', () => {
48
+ it('works in dev mode', async () => {
49
+ await testProject({ dev: true });
50
+ });
51
+ it('works in prod mode', async () => {
52
+ await testProject({ dev: false });
53
+ });
54
+ });
55
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":";;;;;AAAA,8DAA8B;AAC9B,wDAA0B;AAC1B,gDAAwB;AACxB,wDAA+B;AAC/B,+BAA8B;AAC9B,sDAA8B;AAC9B,4DAA+B;AAE/B,mCAA0F;AAE1F,KAAK,UAAU,WAAW,CAAC,OAA8B;IACvD,MAAM,qBAAG,CAAC,OAAO,CACf,KAAK,EAAE,GAAG,EAAE,EAAE;QACZ,MAAM,WAAW,GAAG,cAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC;QAC7D,MAAM,kBAAE,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC;QAEhC,MAAM,YAAY,GAAG,cAAI,CAAC,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;QACtD,MAAM,YAAY,GAAG,cAAI,CAAC,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;QACtD,kBAAE,CAAC,SAAS,CAAC,YAAY,EAAE,oBAAoB,CAAC,CAAC;QACjD,kBAAE,CAAC,SAAS,CAAC,YAAY,EAAE,yCAAyC,CAAC,CAAC;QAEtE,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE;YAChB,MAAM,IAAA,aAAK,EAAC,cAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,EAAE,cAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;SACpF;QAED,IAAA,YAAI,EAAC;YACH,eAAe,EAAE,cAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC;YAC9C,cAAc,EAAE,cAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC;YACtD,UAAU,EAAE,QAAQ;YACpB,GAAG,OAAO;SACX,CAAC,CAAC;QAEH,MAAM,IAAI,GAAG,MAAM,IAAA,kBAAO,GAAE,CAAC;QAC7B,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;QACtB,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAA,eAAO,GAAE,CAAC,CAAC;QAC7B,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAEhC,IAAI;YACF,MAAM,GAAG,GAAG,MAAM,IAAA,oBAAK,EAAC,oBAAoB,IAAI,GAAG,IAAA,0BAAkB,EAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;YACnF,aAAM,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YACtB,aAAM,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,EAAE,uBAAuB,CAAC,CAAC;SACzD;gBAAS;YACR,MAAM,CAAC,KAAK,EAAE,CAAC;SAChB;IACH,CAAC,EACD;QACE,aAAa,EAAE,IAAI;KACpB,CACF,CAAC;AACJ,CAAC;AAED,QAAQ,CAAC,iBAAiB,EAAE,GAAG,EAAE;IAC/B,EAAE,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;QACjC,MAAM,WAAW,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;IACnC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oBAAoB,EAAE,KAAK,IAAI,EAAE;QAClC,MAAM,WAAW,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@prairielearn/compiled-assets",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "bin": {
6
+ "compiled-assets": "dist/cli.js"
7
+ },
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "dev": "tsc --watch --preserveWatchOutput",
11
+ "test": "mocha --no-config --require ts-node/register src/index.test.ts"
12
+ },
13
+ "dependencies": {
14
+ "@prairielearn/html": "^2.1.1",
15
+ "commander": "^9.4.1",
16
+ "esbuild": "^0.15.16",
17
+ "express": "^4.18.2",
18
+ "express-static-gzip": "^2.1.7",
19
+ "fs-extra": "^11.1.0",
20
+ "globby": "^11.1.0",
21
+ "pretty-bytes": "^5.1.1",
22
+ "tmp-promise": "^3.0.3"
23
+ },
24
+ "devDependencies": {
25
+ "@prairielearn/tsconfig": "*",
26
+ "chai": "^4.3.7",
27
+ "get-port": "^5.1.1",
28
+ "mocha": "^10.1.0",
29
+ "node-fetch": "^2.6.7",
30
+ "ts-node": "^10.9.1",
31
+ "typescript": "^4.9.3"
32
+ }
33
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs-extra';
3
+ import prettyBytes from 'pretty-bytes';
4
+ import path from 'path';
5
+ import { promisify } from 'util';
6
+ import zlib from 'zlib';
7
+ import { program } from 'commander';
8
+
9
+ import { build } from './index.js';
10
+
11
+ const gzip = promisify(zlib.gzip);
12
+ const brotli = promisify(zlib.brotliCompress);
13
+
14
+ program.command('build <source> <destination>').action(async (source, destination) => {
15
+ const metafile = await build(source, destination);
16
+
17
+ // Write gzip and brotli versions of the output files. Record size information
18
+ // so we can show it to the user.
19
+ const compressedSizes: Record<string, Record<string, number>> = {};
20
+ await Promise.all(
21
+ Object.keys(metafile.outputs).map(async (outputPath) => {
22
+ const contents = await fs.readFile(outputPath);
23
+ const gzipCompressed = await gzip(contents);
24
+ const brotliCompressed = await brotli(contents);
25
+ await fs.writeFile(`${outputPath}.gz`, gzipCompressed);
26
+ await fs.writeFile(`${outputPath}.br`, brotliCompressed);
27
+ compressedSizes[outputPath] = {
28
+ gzip: gzipCompressed.length,
29
+ brotli: brotliCompressed.length,
30
+ };
31
+ })
32
+ );
33
+
34
+ // Format the output into an object that we can pass to `console.table`.
35
+ const results: Record<string, any> = {};
36
+ Object.entries(metafile.outputs).forEach(([outputPath, meta]) => {
37
+ if (!meta.entryPoint) return;
38
+ results[path.basename(meta.entryPoint)] = {
39
+ 'Output file': path.basename(outputPath),
40
+ Size: prettyBytes(meta.bytes),
41
+ 'Size (gzip)': prettyBytes(compressedSizes[outputPath].gzip),
42
+ 'Size (brotli)': prettyBytes(compressedSizes[outputPath].brotli),
43
+ };
44
+ });
45
+ console.table(results);
46
+ });
47
+
48
+ program.parseAsync(process.argv).catch((err) => {
49
+ console.error(err);
50
+ process.exit(1);
51
+ });
@@ -0,0 +1,60 @@
1
+ import tmp from 'tmp-promise';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import getPort from 'get-port';
5
+ import { assert } from 'chai';
6
+ import express from 'express';
7
+ import fetch from 'node-fetch';
8
+
9
+ import { init, compiledScriptPath, handler, CompiledAssetsOptions, build } from './index';
10
+
11
+ async function testProject(options: CompiledAssetsOptions) {
12
+ await tmp.withDir(
13
+ async (dir) => {
14
+ const scriptsRoot = path.join(dir.path, 'assets', 'scripts');
15
+ await fs.ensureDir(scriptsRoot);
16
+
17
+ const jsScriptPath = path.join(scriptsRoot, 'foo.js');
18
+ const tsScriptPath = path.join(scriptsRoot, 'bar.ts');
19
+ fs.writeFile(jsScriptPath, 'console.log("foo")');
20
+ fs.writeFile(tsScriptPath, 'interface Foo {};\n\nconsole.log("bar")');
21
+
22
+ if (!options.dev) {
23
+ await build(path.join(dir.path, 'assets'), path.join(dir.path, 'public', 'build'));
24
+ }
25
+
26
+ init({
27
+ sourceDirectory: path.join(dir.path, 'assets'),
28
+ buildDirectory: path.join(dir.path, 'public', 'build'),
29
+ publicPath: '/build',
30
+ ...options,
31
+ });
32
+
33
+ const port = await getPort();
34
+ const app = express();
35
+ app.use('/build', handler());
36
+ const server = app.listen(port);
37
+
38
+ try {
39
+ const res = await fetch(`http://localhost:${port}${compiledScriptPath('foo.js')}`);
40
+ assert.isTrue(res.ok);
41
+ assert.match(await res.text(), /console\.log\("foo"\)/);
42
+ } finally {
43
+ server.close();
44
+ }
45
+ },
46
+ {
47
+ unsafeCleanup: true,
48
+ }
49
+ );
50
+ }
51
+
52
+ describe('compiled-assets', () => {
53
+ it('works in dev mode', async () => {
54
+ await testProject({ dev: true });
55
+ });
56
+
57
+ it('works in prod mode', async () => {
58
+ await testProject({ dev: false });
59
+ });
60
+ });
package/src/index.ts ADDED
@@ -0,0 +1,183 @@
1
+ import type { RequestHandler } from 'express';
2
+ import expressStaticGzip from 'express-static-gzip';
3
+ import esbuild from 'esbuild';
4
+ import path from 'path';
5
+ import globby from 'globby';
6
+ import fs from 'fs-extra';
7
+ import { html, HtmlSafeString } from '@prairielearn/html';
8
+
9
+ const DEFAULT_OPTIONS = {
10
+ dev: process.env.NODE_ENV !== 'production',
11
+ sourceDirectory: './assets',
12
+ buildDirectory: './public/build',
13
+ publicPath: '/build/',
14
+ };
15
+
16
+ export interface CompiledAssetsOptions {
17
+ /**
18
+ * Whether the app is running in dev mode. If dev modde is enabled, then
19
+ * assets will be built on the fly as they're requested. Otherwise, assets
20
+ * should have been pre-compiled to the `buildDirectory` directory.
21
+ */
22
+ dev?: boolean;
23
+ /** Root directory of assets. */
24
+ sourceDirectory?: string;
25
+ /** Directory where the built assets will be output to. */
26
+ buildDirectory?: string;
27
+ /** The path that assets will be served from, e.g. `/build/`. */
28
+ publicPath?: string;
29
+ }
30
+
31
+ let options: Required<CompiledAssetsOptions> = { ...DEFAULT_OPTIONS };
32
+
33
+ export function init(newOptions: Partial<CompiledAssetsOptions>): void {
34
+ options = {
35
+ ...DEFAULT_OPTIONS,
36
+ ...newOptions,
37
+ };
38
+ if (!options.publicPath.endsWith('/')) {
39
+ options.publicPath += '/';
40
+ }
41
+ }
42
+
43
+ export function assertConfigured(): void {
44
+ if (!options) {
45
+ throw new Error('@prairielearn/compiled-assets was not configured');
46
+ }
47
+ }
48
+
49
+ export function handler(): RequestHandler {
50
+ assertConfigured();
51
+
52
+ if (!options?.dev) {
53
+ // We're running in production: serve all assets from the build directory.
54
+ // Set headers to cache for as long as possible, since the assets will
55
+ // include content hashes in their filenames.
56
+ return expressStaticGzip(options?.buildDirectory, {
57
+ enableBrotli: true,
58
+ // Prefer Brotli if the client supports it.
59
+ orderPreference: ['br'],
60
+ serveStatic: {
61
+ maxAge: '31557600',
62
+ immutable: true,
63
+ },
64
+ });
65
+ }
66
+
67
+ // We're running in dev mode, so we need to boot up ESBuild to start building
68
+ // and watching our assets.
69
+ return function (req, res) {
70
+ // Strip leading slash from `req.url`.
71
+ let assetPath = req.url;
72
+ if (assetPath.startsWith('/')) {
73
+ assetPath = assetPath.slice(1);
74
+ }
75
+
76
+ const resolvedSourceDirectory = path.resolve(options?.sourceDirectory);
77
+ const resolvedAssetPath = path.resolve(resolvedSourceDirectory, assetPath);
78
+
79
+ if (!resolvedAssetPath.startsWith(resolvedSourceDirectory)) {
80
+ // Probably path traversal.
81
+ res.status(404).send('Not found');
82
+ return;
83
+ }
84
+
85
+ // esbuild should be fast enough that we can just build everything on the
86
+ // fly as it's requested! This is probably just for prototyping though. We
87
+ // should use some kind of caching to ensure that local dev stays fast.
88
+ esbuild
89
+ .build({
90
+ entryPoints: [resolvedAssetPath],
91
+ target: 'es6',
92
+ format: 'iife',
93
+ sourcemap: 'inline',
94
+ bundle: true,
95
+ write: false,
96
+ })
97
+ .then(
98
+ (buildResult) => {
99
+ res
100
+ .setHeader('Content-Type', 'application/javascript; charset=UTF-8')
101
+ .status(200)
102
+ .send(buildResult.outputFiles[0].text);
103
+ },
104
+ (buildError: Error) => {
105
+ res.status(500).send(buildError.message);
106
+ }
107
+ );
108
+ };
109
+ }
110
+
111
+ let cachedScriptsManifest: Record<string, string> | null = null;
112
+ function readScriptsManifest(): Record<string, string> {
113
+ assertConfigured();
114
+
115
+ if (!cachedScriptsManifest) {
116
+ const manifestPath = path.join(options.buildDirectory, 'scripts', 'manifest.json');
117
+ cachedScriptsManifest = fs.readJSONSync(manifestPath) as Record<string, string>;
118
+ }
119
+
120
+ return cachedScriptsManifest;
121
+ }
122
+
123
+ export function compiledScriptPath(sourceFile: string): string {
124
+ assertConfigured();
125
+
126
+ if (options.dev) {
127
+ return options.publicPath + 'scripts/' + sourceFile;
128
+ }
129
+
130
+ const scriptsManifest = readScriptsManifest();
131
+ const scriptPath = scriptsManifest[sourceFile];
132
+ if (!scriptPath) {
133
+ throw new Error(`Unknown script: ${sourceFile}`);
134
+ }
135
+
136
+ return options.publicPath + 'scripts/' + scriptPath;
137
+ }
138
+
139
+ export function compiledScriptTag(sourceFile: string): HtmlSafeString {
140
+ return html`<script src="${compiledScriptPath(sourceFile)}"></script>`;
141
+ }
142
+
143
+ export async function build(
144
+ sourceDirectory: string,
145
+ buildDirectory: string
146
+ ): Promise<esbuild.Metafile> {
147
+ // Remove existing assets to ensure that no stale assets are left behind.
148
+ await fs.remove(buildDirectory);
149
+
150
+ const scriptsSourceRoot = path.resolve(sourceDirectory, 'scripts');
151
+ const scriptsBuildRoot = path.resolve(buildDirectory, 'scripts');
152
+ await fs.ensureDir(scriptsBuildRoot);
153
+
154
+ const files = await globby(path.join(scriptsSourceRoot, '*.{js,jsx,ts,tsx}'));
155
+ const buildResult = await esbuild.build({
156
+ entryPoints: files,
157
+ target: 'es6',
158
+ format: 'iife',
159
+ sourcemap: 'linked',
160
+ bundle: true,
161
+ minify: true,
162
+ entryNames: '[name]-[hash]',
163
+ outdir: scriptsBuildRoot,
164
+ metafile: true,
165
+ });
166
+
167
+ // Write asset manifest so that we can map from "input" names to built names
168
+ // at runtime.
169
+ const { metafile } = buildResult;
170
+ const manifest: Record<string, string> = {};
171
+ Object.entries(metafile.outputs).forEach(([outputPath, meta]) => {
172
+ if (!meta.entryPoint) return;
173
+
174
+ const entryPath = path.basename(meta.entryPoint);
175
+ const assetPath = path.basename(outputPath);
176
+
177
+ manifest[entryPath] = assetPath;
178
+ });
179
+ const manifestPath = path.join(scriptsBuildRoot, 'manifest.json');
180
+ await fs.writeJSON(manifestPath, manifest);
181
+
182
+ return metafile;
183
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "@prairielearn/tsconfig",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["mocha"],
7
+ },
8
+ }