Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache busting config reloading by using virtual files #204

Merged
merged 10 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/odd-apricots-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"vscode-apollo": patch
---

Fix a bug where config file changes could not be picked up
8 changes: 8 additions & 0 deletions sampleWorkspace/localSchema/apollo.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
client: {
service: {
name: "localSchema",
localSchemaFile: "./starwarsSchema.graphql",
},
},
};
Comment on lines +1 to +8
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need one "TypeScript, but CommonJS" config for testing, and the rover config seemed like a better candidate for yaml anyways

4 changes: 0 additions & 4 deletions sampleWorkspace/localSchema/apollo.config.yaml

This file was deleted.

3 changes: 0 additions & 3 deletions sampleWorkspace/rover/apollo.config.js

This file was deleted.

2 changes: 2 additions & 0 deletions sampleWorkspace/rover/apollo.config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
rover:
profile: default
1 change: 1 addition & 0 deletions src/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ async function main() {
"src/extension.ts",
"src/language-server/server.ts",
"src/language-server/config/config.ts",
"src/language-server/config/cache-busting-resolver.js",
],
bundle: true,
format: "cjs",
Expand Down
7 changes: 5 additions & 2 deletions src/language-server/config/__tests__/loadConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,18 @@ Object {
expect(config?.client?.service).toEqual("hello");
});

it("loads config from a cjs file", async () => {
// we skip these tests because ts-jest transpiles every `import` down to a `require` call,
// which messes up all the importing anyways.
// we have to rely on our E2E tests to ensure that config files resolve correctly
it.skip("loads config from a cjs file", async () => {
writeFilesToDir(dir, {
"apollo.config.cjs": `module.exports = {"client": {"service": "hello"} }`,
});
const config = await loadConfig({ configPath: dirPath });
expect(config?.client?.service).toEqual("hello");
});

it("loads config from a mjs file", async () => {
it.skip("loads config from a mjs file", async () => {
writeFilesToDir(dir, {
"apollo.config.mjs": `export default {"client": {"service": "hello"} }`,
});
Expand Down
65 changes: 65 additions & 0 deletions src/language-server/config/cache-busting-resolver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @ts-check
const { pathToFileURL } = require("node:url");

/** @import { ResolveContext, ResolutionResult, LoadResult, ImportContext } from "./cache-busting-resolver.types" */

/**
* @param {string} specifier
* @returns {string}
*/
function bustFileName(specifier) {
const url = pathToFileURL(specifier);
url.pathname = url.pathname + "." + Date.now() + ".js";
return url.toString();
}

/**
*
* @param {string} specifier
* @param {ResolveContext} context
* @param {(specifier: string,context: ResolveContext) => Promise<ResolutionResult>} nextResolve
* @returns {Promise<ResolutionResult>}
*/
async function resolve(specifier, context, nextResolve) {
if (context.importAttributes.as !== "cachebust") {
Copy link

@weiatresvu weiatresvu Oct 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was ⁠context.importAssertions. So, in some older versions of Node.js, context.importAttributes will be undefined. Unfortunately, Cursor is using Node.js 20.9.0 in their latest version.

See, https://nodejs.org/api/module.html#hooks:~:text=v21.0.0%2C%20v20.10.0%2C%20v18.19.0

return nextResolve(specifier, context);
}
if (context.importAttributes.format) {
// no need to resolve at all, we have all necessary information
return {
url: bustFileName(specifier),
format: context.importAttributes.format,
importAttributes: context.importAttributes,
shortCircuit: true,
};
}
const result = await nextResolve(specifier, context);
return {
...result,
url: bustFileName(result.url),
importAttributes: context.importAttributes,
};
}

/**
*
* @param {string} url
* @param {ImportContext} context
* @param {(url: string, context: ImportContext) => Promise<LoadResult>} nextLoad
* @returns {Promise<LoadResult>}
*/
async function load(url, context, nextLoad) {
if (context.importAttributes.as !== "cachebust") {
return nextLoad(url, context);
}
return {
format: context.format || "module",
shortCircuit: true,
source: context.importAttributes.contents,
};
}

module.exports = {
resolve,
load,
};
45 changes: 45 additions & 0 deletions src/language-server/config/cache-busting-resolver.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { pathToFileURL } from "node:url";

export type ImportAttributes =
| {
as: "cachebust";
contents: string;
format?: Format;
}
| { as?: undefined };

type Format =
| "builtin"
| "commonjs"
| "json"
| "module"
| "wasm"
| null
| undefined;

export interface ResolveContext {
conditions: string[];
importAttributes: ImportAttributes;
parentURL?: string;
}

export interface ImportContext {
conditions: string[];
importAttributes: ImportAttributes;
format: Format;
}

export interface ResolutionResult {
format: Format;
importAttributes?: ImportAttributes;
shortCircuit?: boolean;
url: string;
}

export interface LoadResult {
format: Format;
shortCircuit?: boolean;
source: string;
}

export {};
7 changes: 5 additions & 2 deletions src/language-server/config/loadConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { getServiceFromKey } from "./utils";
import { URI } from "vscode-uri";
import { Debug } from "../utilities";
import { loadTs } from "./loadTsConfig";
import { loadJs, loadTs } from "./loadTsConfig";

// config settings
const MODULE_NAME = "apollo";
Expand Down Expand Up @@ -49,7 +49,10 @@ export async function loadConfig({
searchPlaces: supportedConfigFileNames,
loaders: {
...defaultLoaders,
[".ts"]: loadTs,
".ts": loadTs,
".mjs": loadJs,
".cjs": loadJs,
".js": loadJs,
},
});

Expand Down
84 changes: 55 additions & 29 deletions src/language-server/config/loadTsConfig.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { Loader, defaultLoaders } from "cosmiconfig";
import { Loader } from "cosmiconfig";
import { dirname } from "node:path";
import { rm, writeFile } from "node:fs/promises";
import typescript from "typescript";

import { pathToFileURL } from "node:url";
import { register } from "node:module";
import { ImportAttributes } from "./cache-busting-resolver.types";
// implementation based on /~https://github.com/cosmiconfig/cosmiconfig/blob/a5a842547c13392ebb89a485b9e56d9f37e3cbd3/src/loaders.ts
// Copyright (c) 2015 David Clark licensed MIT. Full license can be found here:
// /~https://github.com/cosmiconfig/cosmiconfig/blob/a5a842547c13392ebb89a485b9e56d9f37e3cbd3/LICENSE

if (process.env.JEST_WORKER_ID === undefined) {
register(
pathToFileURL(require.resolve("./config/cache-busting-resolver.js")),
);
} else {
register(pathToFileURL(require.resolve("./cache-busting-resolver.js")));
}

export const loadTs: Loader = async function loadTs(filepath, content) {
try {
return await load(filepath, content, ".vscode-extension-ignore.mjs", {
return await load(filepath, content, "module", {
module: typescript.ModuleKind.ES2022,
});
} catch (error) {
Expand All @@ -18,7 +27,7 @@ export const loadTs: Loader = async function loadTs(filepath, content) {
// [ERROR] ReferenceError: module is not defined in ES module scope
error.message.includes("module is not defined")
) {
return await load(filepath, content, ".vscode-extension-ignore.cjs", {
return await load(filepath, content, "commonjs", {
module: typescript.ModuleKind.CommonJS,
});
} else {
Expand All @@ -30,36 +39,39 @@ export const loadTs: Loader = async function loadTs(filepath, content) {
async function load(
filepath: string,
content: string,
extension: string,
type: "module" | "commonjs",
compilerOptions: Partial<import("typescript").CompilerOptions>,
) {
const compiledFilepath = `${filepath}${extension}`;
let transpiledContent;

try {
try {
const config = resolveTsConfig(dirname(filepath)) ?? {};
config.compilerOptions = {
...config.compilerOptions,
const config = resolveTsConfig(dirname(filepath)) ?? {};
config.compilerOptions = {
...config.compilerOptions,

moduleResolution: typescript.ModuleResolutionKind.Bundler,
target: typescript.ScriptTarget.ES2022,
noEmit: false,
...compilerOptions,
};
transpiledContent = typescript.transpileModule(
content,
config,
).outputText;
await writeFile(compiledFilepath, transpiledContent);
} catch (error: any) {
error.message = `TypeScript Error in ${filepath}:\n${error.message}`;
throw error;
}
// eslint-disable-next-line @typescript-eslint/return-await
return await defaultLoaders[".js"](compiledFilepath, transpiledContent);
} finally {
await rm(compiledFilepath, { force: true });
moduleResolution: typescript.ModuleResolutionKind.Bundler,
target: typescript.ScriptTarget.ES2022,
noEmit: false,
...compilerOptions,
};
transpiledContent = typescript.transpileModule(content, config).outputText;
} catch (error: any) {
error.message = `TypeScript Error in ${filepath}:\n${error.message}`;
throw error;
}
// eslint-disable-next-line @typescript-eslint/return-await
const imported = await import(
filepath,
//@ts-ignore
{
with: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As these are object keys, what happens if you pass this in as an object with both the with and assert property?

Copy link
Contributor

@yesmeck yesmeck Oct 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems fine to pass both with and assert to the import function. But I found we won't be able to access the format key from importAssertions. importAssertions only includes two fields despite we setting other keys on the assert object.
CleanShot 2024-10-13 at 15 12 01@2x

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm :/
Maybe we need to switch to as: 'cachebust:module' and as: 'cachebust:commonjs' then - or does it maybe work with a different key name than format? Maybe that is in some way reserved?

as: "cachebust",
contents: transpiledContent,
format: type,
} satisfies ImportAttributes,
}
Comment on lines +66 to +72
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be passed to our custom resolver, the file path will be largely ignored.

);
return imported.default;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -78,3 +90,17 @@ function resolveTsConfig(directory: string): any {
}
return;
}

export const loadJs: Loader = async function loadJs(filepath, contents) {
return (
await import(
filepath, // @ts-ignore
{
with: {
as: "cachebust",
contents,
} satisfies ImportAttributes,
}
)
).default;
};
3 changes: 0 additions & 3 deletions src/language-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,6 @@ documents.onDidClose(
connection.onDidChangeWatchedFiles((params) => {
const handledByProject = new Map<GraphQLProject, FileEvent[]>();
for (const { uri, type } of params.changes) {
if (uri.match(/vscode-extension-ignore.[mc]?js$/)) {
continue;
}
Comment on lines -195 to -197
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer required since we don't create files on disk anymore

const fsPath = URI.parse(uri).fsPath;
const fileName = basename(fsPath);
if (
Expand Down