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

Add package.json js-plugins support #4389

Merged

Conversation

devinrsmith
Copy link
Member

@devinrsmith devinrsmith commented Aug 28, 2023

This allows the additional configuration of js-plugins via the configuration properties deephaven.jsPlugins.<x>=<packageRoot>, with the expectation that <packageRoot>/package.json exists. This should make some use cases easier, as it's now possible to configure js-plugins without the need to create a manifest.json.

This re-introduces some code that was originally part of #2908; namely, an on-disk zip filesystem is used as the installable state for js-plugins.

In support of #4405

@devinrsmith devinrsmith self-assigned this Aug 28, 2023
@devinrsmith devinrsmith changed the title Add multi-homed, package.json JS plugin support Add package.json JS plugin support Aug 29, 2023
@devinrsmith devinrsmith changed the title Add package.json JS plugin support Add package.json js-plugins support Aug 29, 2023
@devinrsmith
Copy link
Member Author

Here's how I tested:

FROM node:lts-slim as build
ARG PACKAGE 
RUN set -eux; \
        npm pack ${PACKAGE}; \
        tar --touch --extract --file *.tgz

FROM scratch
COPY --link --from=build /package /
docker build --build-arg PACKAGE=@deephaven/js-plugin-table-example --output type=local,dest=/tmp/example .
docker build --build-arg PACKAGE=@deephaven/js-plugin-plotly --output type=local,dest=/tmp/plotly .
docker build --build-arg PACKAGE=@deephaven/js-plugin-matplotlib --output type=local,dest=/tmp/matplotlib .

and then started the server up a variety of different configurations:

START_OPTS="-Ddeephaven.jsPlugins.foo=/tmp/example -Ddeephaven.jsPlugins.bar=/tmp/plotly -Ddeephaven.jsPlugins.baz=/tmp/matplotlib"

and then also mixed it in with the existing way of configuring plugins via deephaven.jsPlugins.resourceBase and <configDir>/js-plugins/

@devinrsmith devinrsmith added the feature request New feature or request label Aug 29, 2023
@devinrsmith devinrsmith marked this pull request as ready for review August 29, 2023 23:05
@devinrsmith devinrsmith added this to the September 2023 milestone Aug 30, 2023
@devinrsmith
Copy link
Member Author

The latest commit re-introduces io.deephaven.plugin.js.JsPlugin; this does make the PR larger, but ultimately makes the structure cleaner with respect to how the system provides js plugins from Configuration. Note: there is no python-side plumbing, but there doesn't need to be at this time. This can be committed standalone, with python-side support coming after.

chipkent
chipkent previously approved these changes Sep 6, 2023
Copy link
Member

@chipkent chipkent left a comment

Choose a reason for hiding this comment

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

Approved the python. I did not review the rest.

@@ -170,6 +171,16 @@ public JettyBackedGrpcServer(
// Wire up the provided grpc filter
context.addFilter(new FilterHolder(filter), "/*", EnumSet.noneOf(DispatcherType.class));

final JsPluginsZipFilesystem fs;
try {
fs = JsPlugins.initJsPlugins(Configuration.getInstance());
Copy link
Member

Choose a reason for hiding this comment

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

this pattern (making the current class obtain the config instance rather than letting initJsPlugins call it) makes it look like you anticipate multiple instances of JsPlugins to be built from different configs, but I think by its nature we will only have one JsPlugins instance at a time (or at least per-server), since there can only be one absolute path called /js-plugins/manifest.json?

It seems to push us towards a "better" pattern of injecting configuration, but our existing Configuration type inherently is global, and it requires a bit of boilerplate to achieve this, with no gain that i can see?

Copy link
Member Author

Choose a reason for hiding this comment

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

In the latest version, I actually am injecting Configuration.

Copy link
Member

Choose a reason for hiding this comment

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

Right, I'm suggesting not bothering with that - more source churn, minimal payoff (since Configuration isn't mockable, can only have One True Instance, etc), and just referencing Configuration.getInstance() once where it is read

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay, will undo the Configuration injection.

import io.deephaven.plugin.Plugin;

/**
* A js plugin.
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't really add much (except as a "there is javadoc" checkbox") - the linked items below are a little dry too, leading one to take apart the code or read the PR's comments rather than gain understanding of it directly. Can you beef these up, at least from the perspective of 'this is an interface that deliberately hides the implementation from you, here's how to use it, what to do with it, etc'?

Copy link
Member Author

Choose a reason for hiding this comment

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

Let me know what you think about the latest. Kind of punting some specifics to the deephaven-plugins repo.

}

private void init() throws IOException {
try (final FileSystem fs = FileSystems.newFileSystem(filesystem, Map.of("create", "true"))) {
Copy link
Member

Choose a reason for hiding this comment

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

i think this method should be synchronized too, since it is going to modify the zip file in place. It isn't technically necessary, but it seems odd to me to let it be possible to call it again.

that said, why create a new filesystem each time? filesystems are threadsafe, or is there an issue with needing to explicitly close so jetty can read it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Will sync the init.

I think the zip filesystem does need to be closed to actually write out the contents. The reason being is that the whole .zip file is replaced with a new one; and you can imagine that if you are writing multiple files, anything else is very inefficient:

        try (final FileSystem zipfs = FileSystems.newFileSystem(...)) {
            for (int i = 0; i < 100; ++i) {
              Files.writeString(fs.getPath("f" + i + ".txt"), "some content");
            }
        }

That said, I'm going to dig in more to verify and will add comment in code.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep: verified FileSystem#close is necessary.

.distributionPath(srcEntry.main());
CopyHelper.copyRecursive(srcDist, destDist);
entries.add(srcEntry);
writeManifest(fs);
Copy link
Member

Choose a reason for hiding this comment

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

If I understand this correctly, each copyFrom call re-opens the zip file, appends new files to it, then appends a new manifest central directory entry to it, and writes a new toc at the end of the file, and closes it.

The reason for doing this is that we then pass the path to this zip directly to jetty, so it can open it to use as its resource base, and serve files from it. Jetty prefers to not serve from zips, but we disable that to avoid unpacking to /tmp and risk something cleaning out that directory. So we're finding already-unpacked contents on disk so we can pack it and tell jetty to server it still-packed...

Tradeoffs:

  • the contents are immutable (changes won't break the server), but the contents are immutable (harder to develop live)
  • extra copy on disk (deleted on exit, assuming non-crash exit)
  • contents served from zip (shouldn't be a big deal, assuming good caching, small files)
  • contents ostensibly filtered to only what is expected to be served

We don't support reading zips/tarballs (i.e. jars, wheels, npm packages), and so always already have an unpacked structure on disk somewhere, and we require (i think) that the unpacked structure matches what we will zip (though we only copy/serve a subset of what was in that directory). Why not point to those existing unpacked dirs, with some filtering for "you only need to see these files"?

Copy link
Member Author

Choose a reason for hiding this comment

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

Jetty prefers to not serve from zips, but we disable that

Is this documented somewhere? I don't think we need to disable anything to make jetty work with zips...

Why not point to those existing unpacked dirs, with some filtering for "you only need to see these files"?

This might be possible, but it's somewhat dependent on being able to configure jetty later in our startup lifecycle (or, get the plugin information earlier in our lifecycle) than we currently are.

Right now, the zip represents the single-point of entry that we can later add to via plugins:

        // Wire up /js-plugins/*
        context.addServlet(zipFsServlet, "/js-plugins/*");

If we were to allow plugins to add to them, it could look something like:

        WebAppContext context = new WebAppContext(null, "/js-plugins/<pluginName>", null, null, null, new ErrorPageErrorHandler(), NO_SESSIONS);
        context.setBaseResource(createResource(<pluginPath>));
        addHandler.accept(context);

And then we'd need to create an immutable manifest.json resource as well.

I think it would be a tricky change to the order of when things are created during startup, ala io.deephaven.server.runner.DeephavenApiServer#run.

Copy link
Member Author

Choose a reason for hiding this comment

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

Of course, there may be ways we could use a Servlet and add later w/ with uncopied plugin Resources, but I don't really know the specific jetty machinations to see if that would be possible.

* @return the distribution path
*/
public final Path distributionPath(String main) {
return path().resolve(main).getParent();
Copy link
Member

Choose a reason for hiding this comment

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

I'm not at all sure that this is correct, except for projects that follow the convention that you're used to. That is, two cases this doesn't do what I think you want (but docs are light, so I can't be sure):

  • nested directories, like dist/js/index.js (unsure how common this is) - the other files in the dist/ output dir will be missed this way (for example in this contrived case, a dist/css/main.css could exist)
  • no dist dir, like mylibrary.min.js (this is decently common, where the .min.js file is generated, and just lives adjacent to the mylibrary.js file - now the whole dir, including package.json, possibly .git/ dir will be copied/served

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay; I think we can copy the full contents over as is, and remove this assumption.

chipkent
chipkent previously approved these changes Sep 26, 2023
Copy link
Member

@chipkent chipkent left a comment

Choose a reason for hiding this comment

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

Approved python. Have not reviewed the rest.

jnumainville
jnumainville previously approved these changes Sep 28, 2023
.stream()
.map(Registration.class::cast)
.collect(Collectors.toSet());
return Set.copyOf(jsPluginsPackageRoots());
Copy link
Member

Choose a reason for hiding this comment

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

why not apply to the other unnecessary streams above too?

Copy link
Member Author

Choose a reason for hiding this comment

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

The above aren't streams, but Optionals that turn into Set.of(Element) or Set.of().

Copy link
Member

Choose a reason for hiding this comment

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

providesConfigDirRegistration and providesResourceBaseRegistration, the two methods above this?

Copy link
Member Author

Choose a reason for hiding this comment

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

Correct. @IntoSet might be more appropriate if there is a way to ignore null or optional return values, but I don't suspect that would work...

Copy link
Member

Choose a reason for hiding this comment

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

Ah got it, thanks for clarifying.

@devinrsmith devinrsmith merged commit 87ee5ec into deephaven:main Oct 5, 2023
10 checks passed
@devinrsmith devinrsmith deleted the multi-homed-package-js-plugin branch October 5, 2023 15:24
@github-actions github-actions bot locked and limited conversation to collaborators Oct 5, 2023
@deephaven-internal
Copy link
Contributor

Labels indicate documentation is required. Issues for documentation have been opened:

How-to: https://github.com/deephaven/deephaven.io/issues/3272
Reference: https://github.com/deephaven/deephaven.io/issues/3271

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
DocumentationNeeded feature request New feature or request ReleaseNotesNeeded Release notes are needed
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants