|
| 1 | +--- |
| 2 | +title: 'Create an extension with a template' |
| 3 | +menuTitle: 'Create an extension with a template' |
| 4 | +description: 'Learn how to to create a k6 extension that handles ascii85 encoding using the xk6-example GitHub repository and GitHub Codespaces, along with best practices.' |
| 5 | +weight: 200 |
| 6 | +--- |
| 7 | + |
| 8 | +# Create an extension with a template |
| 9 | + |
| 10 | +This guide explains a step-by-step process for creating a k6 extension using the GitHub k6 extension template repository. |
| 11 | + |
| 12 | +In this guide, you’ll learn how to: |
| 13 | + |
| 14 | +- Create a GitHub repository using the k6 extension template repository. |
| 15 | +- Create a TypeScript declaration file to document your API. |
| 16 | +- Create [ascii85](https://en.wikipedia.org/wiki/Ascii85) encoding and decoding implementation. |
| 17 | +- Build a k6 binary with the extension. |
| 18 | +- Use the custom k6 binary to run a test. |
| 19 | +- Best practices for creating tests, checking for security vulnerabilities, and static analysis for your extension. |
| 20 | + |
| 21 | +For this guide, you’ll implement two functions that handle ascii85 encoding, which is a feature that’s not natively supported by k6. This will be implemented using Go. |
| 22 | + |
| 23 | +## Before you begin |
| 24 | + |
| 25 | +To follow along, you’ll need: |
| 26 | + |
| 27 | +- A [GitHub account](https://docs.github.com/en/get-started/start-your-journey/creating-an-account-on-github). |
| 28 | + |
| 29 | +Having a GitHub account simplifies the process of developing k6 extensions, which the guide will cover. [GitHub Codespaces](https://github.com/features/codespaces) provides a streamlined development experience for k6 extensions, reducing the need for local setup. |
| 30 | + |
| 31 | +## Create a GitHub repository |
| 32 | + |
| 33 | +The first step is to create a GitHub repository using the [grafana/xk6-example](https://github.com/grafana/xk6-example) template repository. This can be done interactively in a browser by clicking [here](https://github.com/new?template_name=xk6-example&template_owner=grafana). Name the repository "xk6-example-ascii85", and set the visibility to **Public**. |
| 34 | + |
| 35 | +Alternatively, use the [GitHub CLI](https://cli.github.com/) to create the repository. |
| 36 | + |
| 37 | +```bash |
| 38 | +gh repo create -p grafana/xk6-example -d "Experimental k6 extension" --public xk6-example-ascii85 |
| 39 | +``` |
| 40 | + |
| 41 | +## Create a codespace |
| 42 | + |
| 43 | +GitHub Codespaces is a GitHub feature that lets you create and use a fully configured development environment in the cloud. |
| 44 | + |
| 45 | +To create a GitHub codespace: |
| 46 | + |
| 47 | +- Go to the xk6-example-ascii85 repository you created in the previous step. |
| 48 | +- On the repository page, click the green **Code** button and then select **Codespaces** from the dropdown menu. |
| 49 | +- Click **Create new codespace**. |
| 50 | + |
| 51 | +Once the codespace is ready, it will open in your browser as a Visual Studio Code-like environment, letting you begin working on your project with the repository code already checked out. |
| 52 | + |
| 53 | +Alternatively, use the [GitHub CLI](https://cli.github.com/) to create the codespace, replacing `USER` with your GitHub username: |
| 54 | + |
| 55 | +```bash |
| 56 | +gh codespace create --repo USER/xk6-example-ascii85 --web |
| 57 | +``` |
| 58 | + |
| 59 | +## API declaration |
| 60 | + |
| 61 | +This step is optional but recommended. It is a good practice to document the API of the k6 extension before implementing it. |
| 62 | + |
| 63 | +Create a TypeScript declaration file named `index.d.ts` and add the following code: |
| 64 | + |
| 65 | +```typescript |
| 66 | +/** |
| 67 | + * **Example ascii85 encoding for k6** |
| 68 | + * |
| 69 | + * @module example_ascii85 |
| 70 | + */ |
| 71 | + |
| 72 | +export as namespace example_ascii85; |
| 73 | + |
| 74 | +/** |
| 75 | + * ascii85encode returns the ASCII85 encoding of src. |
| 76 | + * |
| 77 | + * @param src The input to encode. |
| 78 | + */ |
| 79 | +export declare function encode(src: ArrayBuffer): string; |
| 80 | + |
| 81 | +/** |
| 82 | + * ascii85decode returns the decoded bytes represented by the string str. |
| 83 | + * |
| 84 | + * @param str The string to decode. |
| 85 | + */ |
| 86 | +export declare function decode(str: string): ArrayBuffer; |
| 87 | +``` |
| 88 | + |
| 89 | +## Add encoding and decoding functions |
| 90 | + |
| 91 | +The `encode()` function's implementation is straightforward, as the k6 runtime handles all type conversions. The Go standard `ascii85` package provides the ASCII85 encoding implementation, requiring only a parameter for its use. |
| 92 | + |
| 93 | +Add the following function to the `module.go` file. The `ascii85` package import will be added automatically by the IDE. |
| 94 | + |
| 95 | +```go |
| 96 | +func (*module) encode(data []byte) string { |
| 97 | + dst := make([]byte, ascii85.MaxEncodedLen(len(data))) |
| 98 | + n := ascii85.Encode(dst, data) |
| 99 | + |
| 100 | + return string(dst[:n]) |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +The `decode()` function should return an `ArrayBuffer`, which requires type conversion by the JavaScript runtime. The `sobek.ArrayBuffer` go struct corresponds to the JavaScript `ArrayBuffer`, so an instance of it must be returned. Refer to the [sobek.Runtime#ExportTo()](https://pkg.go.dev/github.com/grafana/sobek#Runtime.ExportTo) documentation for mapping details. |
| 105 | + |
| 106 | +Add the following function to the `module.go` file: |
| 107 | + |
| 108 | +```go |
| 109 | +func (m *module) decode(str string) (sobek.ArrayBuffer, error) { |
| 110 | + dst := make([]byte, len(str)) |
| 111 | + |
| 112 | + n, _, err := ascii85.Decode(dst, []byte(str), true) |
| 113 | + if err != nil { |
| 114 | + return sobek.ArrayBuffer{}, err |
| 115 | + } |
| 116 | + |
| 117 | + return m.vu.Runtime().NewArrayBuffer(dst[:n]), nil |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +To make the `encode()` and `decode()` functions usable within the JavaScript runtime, you have to export them. Add them to the exported symbols in the `module.go` file. |
| 122 | + |
| 123 | +```go |
| 124 | +func (m *module) Exports() modules.Exports { |
| 125 | + return modules.Exports{ |
| 126 | + Named: map[string]any{ |
| 127 | + "encode": m.encode, |
| 128 | + "decode": m.decode, |
| 129 | + }, |
| 130 | + } |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +The Go implementation of the extension is complete. |
| 135 | + |
| 136 | +## Build a custom k6 binary |
| 137 | + |
| 138 | +To use the `xk6-example-ascii85` extension, a custom k6 build must be created using the `xk6 build` subcommand. |
| 139 | + |
| 140 | +```bash |
| 141 | +xk6 build --with github.com/USER/xk6-example-ascii85=. |
| 142 | +``` |
| 143 | + |
| 144 | +Replace `USER` with your GitHub username. |
| 145 | + |
| 146 | +This command creates a custom k6 executable in the current folder. |
| 147 | + |
| 148 | +## Run a test with the custom k6 binary |
| 149 | + |
| 150 | +To showcase the extension's functionality, create a JavaScript file named `script.js` and add the following code to it: |
| 151 | + |
| 152 | +```js |
| 153 | +import { encode } from 'k6/x/example_ascii85'; |
| 154 | + |
| 155 | +export default function () { |
| 156 | + console.log(encode(new Uint8Array([72, 101, 108, 108, 111, 33]).buffer)); // 87cURD]o |
| 157 | +} |
| 158 | +``` |
| 159 | + |
| 160 | +And then run the script using the custom k6 binary: |
| 161 | + |
| 162 | +```bash |
| 163 | +./k6 run script.js |
| 164 | +``` |
| 165 | + |
| 166 | +The script outputs `87cURD]o` to the console. This string is the ascii85 encoded representation of `Hello!`. |
| 167 | + |
| 168 | +## Best practices |
| 169 | + |
| 170 | +### Create a smoke test |
| 171 | + |
| 172 | +For initial verification before writing comprehensive integration tests, you can create a basic smoke test in `test/smoke.test.js`. |
| 173 | + |
| 174 | +```js |
| 175 | +import { encode, decode } from 'k6/x/example_ascii85'; |
| 176 | +import { check } from 'k6'; |
| 177 | + |
| 178 | +export const options = { |
| 179 | + thresholds: { |
| 180 | + checks: ['rate==1'], |
| 181 | + }, |
| 182 | +}; |
| 183 | + |
| 184 | +export default function () { |
| 185 | + const bytes = new Uint8Array([72, 101, 108, 108, 111, 33]).buffer; |
| 186 | + |
| 187 | + check(encode(bytes), { |
| 188 | + encoded: (str) => str == '87cURD]o', |
| 189 | + reverse: (str) => equal(bytes, decode(str)), |
| 190 | + }); |
| 191 | +} |
| 192 | + |
| 193 | +const equal = (a, b) => new Uint8Array(a).toString() === new Uint8Array(b).toString(); |
| 194 | +``` |
| 195 | + |
| 196 | +This test ensures the correctness of ascii85 encoding and decoding. It uses a fixed `Hello!` string as a test case for both encoding and decoding processes. |
| 197 | + |
| 198 | +### Create Go module tests |
| 199 | + |
| 200 | +Go tests offer the quickest method for verifying extension implementations. Standard unit testing practices apply. For a module-level integration test example, refer to the module_test.go file. This setup facilitates comprehensive integration testing between the Go implementation and the JavaScript runtime. |
| 201 | + |
| 202 | +```go |
| 203 | +package example_ascii85 |
| 204 | + |
| 205 | +import ( |
| 206 | + _ "embed" |
| 207 | + "testing" |
| 208 | + |
| 209 | + "github.com/stretchr/testify/require" |
| 210 | + "go.k6.io/k6/js/modulestest" |
| 211 | +) |
| 212 | + |
| 213 | +func Test_module(t *testing.T) { //nolint:tparallel |
| 214 | + t.Parallel() |
| 215 | + |
| 216 | + runtime := modulestest.NewRuntime(t) |
| 217 | + err := runtime.SetupModuleSystem(map[string]any{importPath: new(rootModule)}, nil, nil) |
| 218 | + require.NoError(t, err) |
| 219 | + |
| 220 | + _, err = runtime.RunOnEventLoop(`let mod = require("` + importPath + `")`) |
| 221 | + require.NoError(t, err) |
| 222 | + |
| 223 | + tests := []struct { |
| 224 | + name string |
| 225 | + check string |
| 226 | + }{ |
| 227 | + // Add your test cases here |
| 228 | + // Example: {name: "myFunc()", check: `mod.myFunc() == expectedValue`}, |
| 229 | + { |
| 230 | + name: "encode()", |
| 231 | + check: `mod.encode(new Uint8Array([72, 101, 108, 108, 111, 33]).buffer) == "87cURD]o"`, |
| 232 | + }, |
| 233 | + } |
| 234 | + for _, tt := range tests { //nolint:paralleltest |
| 235 | + t.Run(tt.name, func(t *testing.T) { |
| 236 | + got, err := runtime.RunOnEventLoop(tt.check) |
| 237 | + |
| 238 | + require.NoError(t, err) |
| 239 | + require.True(t, got.ToBoolean()) |
| 240 | + }) |
| 241 | + } |
| 242 | +} |
| 243 | +``` |
| 244 | + |
| 245 | +The provided test code creates an extension instance and integrates it into the JavaScript runtime, accessible as `mod`. The JavaScript code defining the test is then executed within the JavaScript runtime's event loop. |
| 246 | + |
| 247 | +### Generate API documentation |
| 248 | + |
| 249 | +You can generate HTML API documentation from the `index.d.ts` API declaration file using [TypeDoc](https://typedoc.org/). To do this, run the following command that creates the extension API documentation from the `index.d.ts` file and saves it in the `build/docs` directory. |
| 250 | + |
| 251 | +```bash |
| 252 | +bun x typedoc --out build/docs |
| 253 | +``` |
| 254 | + |
| 255 | +### Security and vulnerability |
| 256 | + |
| 257 | +Ensure the Go source code of your k6 extension is checked for security vulnerabilities using the [gosec](https://github.com/securego/gosec) tool. Like any Go project, security scanning is crucial for your extension's codebase. |
| 258 | + |
| 259 | +```bash |
| 260 | +gosec -quiet ./... |
| 261 | +``` |
| 262 | + |
| 263 | +Generally, extensions rely on external Go module dependencies. It is advisable to use the [govulncheck](https://github.com/golang/vuln) tool to identify potential vulnerabilities within these dependencies. |
| 264 | + |
| 265 | +```bash |
| 266 | +govulncheck ./... |
| 267 | +``` |
| 268 | + |
| 269 | +Security and vulnerability checks are a requirement for registering the extension in the [k6 Extension Registry](https://registry.k6.io). |
| 270 | + |
| 271 | +### Static analysis |
| 272 | + |
| 273 | +Analyzing the Go source code of your k6 extension statically can proactively identify subtle bugs. [golangci-lint](https://golangci-lint.run/) is a popular static code analysis tool that even can be used without configuration. |
| 274 | + |
| 275 | +```bash |
| 276 | +golangci-lint run ./... |
| 277 | +``` |
| 278 | + |
| 279 | +## Reference |
| 280 | + |
| 281 | +The complete Go source code (`module.go`) for the extension implementation is provided for reference. |
| 282 | + |
| 283 | +```go |
| 284 | +// Package example_ascii85 contains the xk6-example-ascii85 extension. |
| 285 | +package example_ascii85 |
| 286 | + |
| 287 | +import ( |
| 288 | + "encoding/ascii85" |
| 289 | + |
| 290 | + "github.com/grafana/sobek" |
| 291 | + "go.k6.io/k6/js/modules" |
| 292 | +) |
| 293 | + |
| 294 | +type rootModule struct{} |
| 295 | + |
| 296 | +func (*rootModule) NewModuleInstance(vu modules.VU) modules.Instance { |
| 297 | + return &module{vu} |
| 298 | +} |
| 299 | + |
| 300 | +type module struct { |
| 301 | + vu modules.VU |
| 302 | +} |
| 303 | + |
| 304 | +func (m *module) Exports() modules.Exports { |
| 305 | + return modules.Exports{ |
| 306 | + Named: map[string]any{ |
| 307 | + "encode": m.encode, |
| 308 | + "decode": m.decode, |
| 309 | + }, |
| 310 | + } |
| 311 | +} |
| 312 | + |
| 313 | +func (*module) encode(data []byte) string { |
| 314 | + dst := make([]byte, ascii85.MaxEncodedLen(len(data))) |
| 315 | + n := ascii85.Encode(dst, data) |
| 316 | + |
| 317 | + return string(dst[:n]) |
| 318 | +} |
| 319 | + |
| 320 | +func (m *module) decode(str string) (sobek.ArrayBuffer, error) { |
| 321 | + dst := make([]byte, len(str)) |
| 322 | + |
| 323 | + n, _, err := ascii85.Decode(dst, []byte(str), true) |
| 324 | + if err != nil { |
| 325 | + return sobek.ArrayBuffer{}, err |
| 326 | + } |
| 327 | + |
| 328 | + return m.vu.Runtime().NewArrayBuffer(dst[:n]), nil |
| 329 | +} |
| 330 | + |
| 331 | +var _ modules.Module = (*rootModule)(nil) |
| 332 | +``` |
| 333 | + |
| 334 | +In addition, `register.go` contains the registration of the extension with the k6 runtime. |
| 335 | + |
| 336 | +```go |
| 337 | +package example_ascii85 |
| 338 | + |
| 339 | +import "go.k6.io/k6/js/modules" |
| 340 | + |
| 341 | +const importPath = "k6/x/example_ascii85" |
| 342 | + |
| 343 | +func init() { |
| 344 | + modules.Register(importPath, new(rootModule)) |
| 345 | +} |
| 346 | +``` |
| 347 | + |
| 348 | +### Reference to the JavaScript runtime |
| 349 | + |
| 350 | +In the k6 runtime, each VU (data structure representing a virtual user) has a dedicated JavaScript runtime instance, which can be accessed with the `Runtime()` function. |
| 351 | + |
| 352 | +```go |
| 353 | +m.vu.Runtime() |
| 354 | +``` |
| 355 | + |
| 356 | +## Additional resources |
| 357 | + |
| 358 | +- [k6 go API documentation](https://pkg.go.dev/go.k6.io/k6) |
| 359 | +- [k6 JavaScript engine documentation](https://pkg.go.dev/github.com/grafana/sobek) |
| 360 | +- [xk6 - k6 extension development toolbox](https://github.com/grafana/xk6) |
0 commit comments