Skip to content

Commit 1b20340

Browse files
authored
refactor(dev): flatten compiler and use promise-based error handling (#6088)
1 parent 37ff8b1 commit 1b20340

25 files changed

+480
-440
lines changed

packages/remix-dev/channel.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1-
export type WriteChannel<T> = {
2-
write: (data: T) => void;
3-
};
4-
export type ReadChannel<T> = {
5-
read: () => Promise<T>;
1+
type Resolve<V> = (value: V | PromiseLike<V>) => void;
2+
type Reject = (reason?: any) => void;
3+
4+
type Result<V, E = unknown> = { ok: true; value: V } | { ok: false; error: E };
5+
6+
export type Type<V, E = unknown> = {
7+
ok: (value: V) => void;
8+
err: (reason?: any) => void;
9+
result: Promise<Result<V, E>>;
610
};
7-
export type Channel<T> = WriteChannel<T> & ReadChannel<T>;
811

9-
export const createChannel = <T>(): Channel<T> => {
10-
let promiseResolve: (value: T) => void;
12+
export const create = <V, E = unknown>(): Type<V> => {
13+
let _resolve: Resolve<{ ok: true; value: V }>;
14+
let _reject: Reject;
1115

12-
let promise = new Promise<T>((resolve) => {
13-
promiseResolve = resolve;
14-
});
16+
let promise: Promise<Result<V, E>> = new Promise<Result<V, E>>(
17+
(resolve, reject) => {
18+
_resolve = resolve;
19+
_reject = reject;
20+
}
21+
).catch((error) => ({ ok: false, error } as const));
1522

1623
return {
17-
write: promiseResolve!,
18-
read: () => promise,
24+
ok: (value: V) => _resolve!({ ok: true, value } as const),
25+
err: _reject!,
26+
result: promise,
1927
};
2028
};

packages/remix-dev/cli/commands.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -168,18 +168,19 @@ export async function build(
168168
let start = Date.now();
169169
let config = await readConfig(remixRoot);
170170
fse.emptyDirSync(config.assetsBuildDirectory);
171-
await compiler.build({
172-
config,
173-
options: {
174-
mode,
175-
sourcemap,
176-
onWarning: warnOnce,
177-
onCompileFailure: (failure) => {
178-
compiler.logCompileFailure(failure);
179-
throw Error();
171+
await compiler
172+
.build({
173+
config,
174+
options: {
175+
mode,
176+
sourcemap,
177+
onWarning: warnOnce,
180178
},
181-
},
182-
});
179+
})
180+
.catch((thrown) => {
181+
compiler.logThrown(thrown);
182+
process.exit(1);
183+
});
183184

184185
console.log(`built in ${prettyMs(Date.now() - start)}`);
185186
}

packages/remix-dev/compiler/assets/index.ts

Lines changed: 0 additions & 60 deletions
This file was deleted.
Lines changed: 108 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,122 @@
1-
import { createChannel } from "../channel";
2-
import { type Manifest } from "../manifest";
3-
import * as AssetsCompiler from "./assets";
1+
import * as path from "path";
2+
43
import type { Context } from "./context";
5-
import * as ServerCompiler from "./server";
4+
import * as CSS from "./css";
5+
import * as JS from "./js";
6+
import * as Server from "./server";
7+
import * as Channel from "../channel";
8+
import type { Manifest } from "../manifest";
9+
import { create as createManifest, write as writeManifest } from "./manifest";
610

711
type Compiler = {
8-
compile: () => Promise<Manifest | undefined>;
9-
dispose: () => void;
12+
compile: () => Promise<Manifest>;
13+
cancel: () => Promise<void>;
14+
dispose: () => Promise<void>;
1015
};
1116

1217
export let create = async (ctx: Context): Promise<Compiler> => {
18+
// channels _should_ be scoped to a build, not a compiler
19+
// but esbuild doesn't have an API for passing build-specific arguments for rebuilds
20+
// so instead use a mutable reference (`channels`) that is compiler-scoped
21+
// and gets reset on each build
1322
let channels = {
14-
manifest: createChannel<Manifest>(),
23+
cssBundleHref: undefined as unknown as Channel.Type<string | undefined>,
24+
manifest: undefined as unknown as Channel.Type<Manifest>,
25+
};
26+
27+
let subcompiler = {
28+
css: await CSS.createCompiler(ctx),
29+
js: await JS.createCompiler(ctx, channels),
30+
server: await Server.createCompiler(ctx, channels),
1531
};
1632

17-
let assets = await AssetsCompiler.create(ctx, channels);
18-
let server = await ServerCompiler.create(ctx, channels);
33+
let compile = async () => {
34+
let hasThrown = false;
35+
let cancelAndThrow = async (error: unknown) => {
36+
// An earlier error from a failed task has already been thrown; ignore this error.
37+
// Safe to cast as `never` here as subsequent errors are only thrown from canceled tasks.
38+
if (hasThrown) return undefined as never;
39+
40+
// resolve channels with error so that downstream tasks don't hang waiting for results from upstream tasks
41+
channels.cssBundleHref.err();
42+
channels.manifest.err();
43+
44+
// optimization: cancel tasks
45+
subcompiler.css.cancel();
46+
subcompiler.js.cancel();
47+
subcompiler.server.cancel();
48+
49+
// Only throw the first error encountered during compilation
50+
// otherwise subsequent errors will be unhandled and will crash the compiler.
51+
// `try`/`catch` won't handle subsequent errors either, so that isn't a viable alternative.
52+
// `Promise.all` _could_ be used, but the resulting promise chaining is complex and hard to follow.
53+
hasThrown = true;
54+
throw error;
55+
};
56+
57+
// reset channels
58+
channels.cssBundleHref = Channel.create();
59+
channels.manifest = Channel.create();
60+
61+
// kickoff compilations in parallel
62+
let tasks = {
63+
css: subcompiler.css.compile().catch(cancelAndThrow),
64+
js: subcompiler.js.compile().catch(cancelAndThrow),
65+
server: subcompiler.server.compile().catch(cancelAndThrow),
66+
};
67+
68+
// keep track of manually written artifacts
69+
let writes: {
70+
cssBundle?: Promise<void>;
71+
manifest?: Promise<void>;
72+
server?: Promise<void>;
73+
} = {};
74+
75+
// css compilation
76+
let css = await tasks.css;
77+
78+
// css bundle
79+
let cssBundleHref =
80+
css.bundle &&
81+
ctx.config.publicPath +
82+
path.relative(
83+
ctx.config.assetsBuildDirectory,
84+
path.resolve(css.bundle.path)
85+
);
86+
channels.cssBundleHref.ok(cssBundleHref);
87+
if (css.bundle) {
88+
writes.cssBundle = CSS.writeBundle(ctx, css.outputFiles);
89+
}
90+
91+
// js compilation (implicitly writes artifacts/js)
92+
// TODO: js task should not return metafile, but rather js assets
93+
let { metafile, hmr } = await tasks.js;
94+
95+
// artifacts/manifest
96+
let manifest = await createManifest({
97+
config: ctx.config,
98+
cssBundleHref,
99+
metafile,
100+
hmr,
101+
});
102+
channels.manifest.ok(manifest);
103+
writes.manifest = writeManifest(ctx.config, manifest);
104+
105+
// server compilation
106+
let serverFiles = await tasks.server;
107+
// artifacts/server
108+
writes.server = Server.write(ctx.config, serverFiles);
109+
110+
await Promise.all(Object.values(writes));
111+
return manifest;
112+
};
19113
return {
20-
compile: async () => {
21-
channels.manifest = createChannel();
22-
try {
23-
let [manifest] = await Promise.all([
24-
assets.compile(),
25-
server.compile(),
26-
]);
27-
28-
return manifest;
29-
} catch (error: unknown) {
30-
ctx.options.onCompileFailure?.(error as Error);
31-
return undefined;
32-
}
114+
compile,
115+
cancel: async () => {
116+
await Promise.all(Object.values(subcompiler).map((sub) => sub.cancel()));
33117
},
34-
dispose: () => {
35-
assets.dispose();
36-
server.dispose();
118+
dispose: async () => {
119+
await Promise.all(Object.values(subcompiler).map((sub) => sub.dispose()));
37120
},
38121
};
39122
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as path from "path";
2+
import * as fse from "fs-extra";
3+
import type * as esbuild from "esbuild";
4+
import postcss from "postcss";
5+
import postcssDiscardDuplicates from "postcss-discard-duplicates";
6+
7+
import type { Context } from "../context";
8+
9+
export let write = async (ctx: Context, outputFiles: esbuild.OutputFile[]) => {
10+
let cssBundleFile = outputFiles.find((outputFile) =>
11+
isBundle(ctx, outputFile, ".css")
12+
);
13+
if (!cssBundleFile) return;
14+
15+
let cssBundlePath = cssBundleFile.path;
16+
17+
let { css, map } = await postcss([
18+
// We need to discard duplicate rules since "composes"
19+
// in CSS Modules can result in duplicate styles
20+
postcssDiscardDuplicates(),
21+
]).process(cssBundleFile.text, {
22+
from: cssBundlePath,
23+
to: cssBundlePath,
24+
map: ctx.options.sourcemap && {
25+
prev: outputFiles.find((outputFile) =>
26+
isBundle(ctx, outputFile, ".css.map")
27+
)?.text,
28+
inline: false,
29+
annotation: false,
30+
sourcesContent: true,
31+
},
32+
});
33+
34+
await fse.ensureDir(path.dirname(cssBundlePath));
35+
36+
await Promise.all([
37+
fse.writeFile(cssBundlePath, css),
38+
ctx.options.mode !== "production" && map
39+
? fse.writeFile(`${cssBundlePath}.map`, map.toString()) // Write our updated source map rather than esbuild's
40+
: null,
41+
...outputFiles
42+
.filter((outputFile) => !/\.(css|js|map)$/.test(outputFile.path))
43+
.map(async (asset) => {
44+
await fse.ensureDir(path.dirname(asset.path));
45+
await fse.writeFile(asset.path, asset.contents);
46+
}),
47+
]);
48+
};
49+
50+
export let isBundle = (
51+
ctx: Context,
52+
outputFile: esbuild.OutputFile,
53+
extension: ".css" | ".css.map"
54+
): boolean => {
55+
return (
56+
path.dirname(outputFile.path) === ctx.config.assetsBuildDirectory &&
57+
path.basename(outputFile.path).startsWith("css-bundle") &&
58+
outputFile.path.endsWith(extension)
59+
);
60+
};

0 commit comments

Comments
 (0)