# Build a Spyglass Lens
Spyglass lenses consist of two components: a frontend (which may be trivial) and a backend.
## Lens backend
Today, a lens backend must be linked in to the `deck` binary. As such, lenses must live under
[`prow/spyglass/lenses`](./lenses). Additionally lenses **must** be in a folder that matches the
name of the lens. The content of this folder will be served by `deck`, enabling you to reference
static content such as images, stylesheets, or scripts.
Inside your template you must implement the [`lenses.Lens` interface](https://godoc.org/k8s.io/test-infra/prow/spyglass/lenses#Lens).
An instance of the struct implementing the `lenses.Lens` interface must then be registered with
spyglass, by calling [`lenses.RegisterLens`](https://godoc.org/k8s.io/test-infra/prow/spyglass/lenses#RegisterLens).
A minimal example of a lens called `samplelens`, located at `lenses/samplelens`, might look like this:
```go
package samplelens
import (
"encoding/json"
"k8s.io/test-infra/prow/config"
"k8s.io/test-infra/prow/spyglass/lenses"
)
type Lens struct{}
func init() {
lenses.RegisterLens(Lens{})
}
// Config returns the lens's configuration.
func (lens Lens) Config() lenses.LensConfig {
return lenses.LensConfig{
Title: "Human Readable Lens",
Name: "samplelens", // remember: this *must* match the location of the lens (and thus package name)
Priority: 0,
}
}
// Header returns the content of
func (lens Lens) Header(artifacts []lenses.Artifact, resourceDir string, config json.RawMessage, spyglassConfig config.Spyglass) string {
return ""
}
func (lens Lens) Callback(artifacts []lenses.Artifact, resourceDir string, data string, config json.RawMessage, spyglassConfig config.Spyglass) string {
return ""
}
// Body returns the displayed HTML for the
func (lens Lens) Body(artifacts []lenses.Artifact, resourceDir string, data string, config json.RawMessage, spyglassConfig config.Spyglass) string {
return "Hi! I'm a lens!"
}
```
If you want to read resources included in your lens (such as templates), you can find them in the
provided `resourceDir`.
Finally, you will need to import your lens from `deck` in order to actually link it in. You can do
this by `import`ing it from [`prow/cmd/deck/main.go`](../cmd/deck/main.go), alongside the other lenses:
```go
import (
// ...
_ "k8s.io/test-infra/prow/spyglass/lenses/samplelens"
)
```
Finally, you will need to run `./hack/update-bazel.sh` from the test-infra root to update the bazel
files so your lens is built. You can then test it by running `./prow/cmd/deck/runlocal` and loading
a spyglass page.
## Lens frontend
The HTML generated by a lens can reference static assets that will be served by Deck on behalf of
your lens. Scripts and stylesheets can be referenced in the output of the `Header()` function (which
is inserted into the `` element). Relative references into your directory will work: spyglass
adds a `` tag that references the expected output directory.
Spyglass lenses have access to a `spyglass` global that [provides a number of APIs](#lens-apis) to interact with
your lens backend and the rest of the world. Your lens is rendered in a sandboxed iframe, so you
generally cannot interact without using these APIs.
We recommend writing lenses using TypeScript, and provide TypeScript declarations for the `spyglass`
APIs.
In order to build frontend resources in, you will need to specify them using Bazel. Assuming you had
a template called `template.html`, a typescript file called `sample.ts`, a stylesheet called
`style.css`, and an image called `magic.png`, you might add the following to your `BUILD.bazel`
(which should already have been generated by `./hack/update-bazel.sh` when writing your backend):
```python
# Note that the important parts are the `name` arguments, which you should not change unless
# you know what you're doing. You can change the filenames in `srcs` freely, as long as they
# match the ways you reference them in code.
load("//def:ts.bzl", "rollup_bundle", "ts_library")
ts_library(
name = "script",
srcs = ["sample.ts"],
deps = [
"//prow/spyglass/lenses:lens_api",
],
)
rollup_bundle(
name = "script_bundle",
entry_point = ":sample.ts",
deps = [
":script",
],
)
filegroup(
name = "template",
srcs = ["template.html"],
visibility = ["//visibility:public"],
)
filegroup(
name = "resources",
srcs = [
"style.css",
"magic.png",
":script_bundle.min",
],
visibility = ["//visibility:public"],
)
```
With this setup, you would reference your script in your HTML as `script_bundle.min.js`, like so:
```html
```
You also need to update the [spyglass `BUILD.bazel`](./BUILD.bazel) to have references to yours, in
particular adding your lens to the `templates` and `resources` filegroups.
### Lens APIs
Many Spyglass APIs are asynchronous, and so return a
[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises). We
recommend using `async`/`await` to use them, like this:
```
async function doStuff(): Promise {
const someStuff = await spyglass.request("");
}
```
We provide the following methods under `spyglass` in all lenses:
#### `spyglass.contentUpdated(): void`
`contentUpdated` should be called whenever you make changes to the content of the page. It signals
to the Spyglass host page that it needs to recalculate how your lens is displayed. It is not
necessary to call it on initial page load.
#### `spyglass.request(data: string): Promise`
`request` is used to call back to your lens's backend. Whatever `data` you provide will be provided
unmodified to your lens backend's `Callback()` method. `request` returns a Promise, which will
eventually be resolved with the string returned from `Callback()` (unless an error occurs, in which
case it will fail). We recommend, but do not require, that both strings be JSON-encoded.
#### `spyglass.updatePage(data: string): Promise`
`updatePage` calls your lens backend's `Body()` method again, passing in whatever `data` you
provide and shows a loading spinner. Once the call completes, the lens is re-displayed using the
newly-provided ``. Note that this does _not_ reload the lens, and so your script will keep
running. The returned promise resolves once the new content is ready.
#### `spyglass.requestPage(data: string): Promise`
`requestPage` calls your lens backend's `Body()` method again, passing in whatever `data` you
provide. Unlike `updatePage`, it does _not_ show a spinner, and does not change the page. Instead,
the returned promise will resolve with the newly-generated HTML.
#### `spyglass.makeFragmentLink(fragment: string): string`
`makeFragmentLink` returns a link to the top-level page that will cause your lens to receive the
specified `fragment` in `location.hash`, and no other lens on the page to receive any fragment.
This is useful when generating links for the user to copy to your content, but should _not_ be used
to perform direct navigation - instead, just update `location.hash`, and propagation will be
handled transparently.
If the provided `fragment` does not have a leading `#` one will be added, for consistency with the
behaviour of `location.hash`.
#### `spyglass.scrollTo(x: number, y: number): Promise`
`scrollTo` scrolls the parent Spyglass page such that the provided (x, y) document-relative
coordinate of your lens is visible. Note that we keep lenses at slightly under 100% page width, so
only y is currently meaningful.
### Special considerations
#### Sandboxing
Lenses are contained in sandboxed iframes in the parent page. The most notably restricted activity
is making XHR requests to Deck, which would be considered prohibited CORS requests. Lenses also
cannot directly interact with their parent window, outside of the provided APIs.
#### Links
We set a default `` with `href` set pointing in to your resource directory, and `target` set
to `_top`. This means that links will by default replace the entire spyglass page, which is usually
the intended effect. It also means that `src` or `href` HTML attributes are based in those
directories, which is usually what you want in this context.
#### Fragments / Anchor links
Fragment URLs (the part after the `#`) are supported fairly transparently, despite being in an iframe.
The parent page muxes all the lens's fragments and ensures that if the page is loaded, each lens
receives the fragment it expects. Changing your fragment will automatically update the parent page's
fragment. If the fragment matches the ID or name of an element, the page will scroll such that that
element is visible.
Anchor links (``) would usually not work well in conjunction with the ``
tag. To resolve this, we rewrite all links of this form to behave as expected both on page load and
on DOM modification. In most cases, this should be transparent. If you want users to copy links via
right click -> copy link, however, this will not work nicely. Instead, consider setting the `href`
attribute to something from `spyglass.makeFragmentLink`, but handling clicks by manually setting
`location.hash` to the desired fragment.