@checkstack/ui 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,16 @@ export {
3
3
  type CodeEditorProps,
4
4
  type CodeEditorLanguage,
5
5
  type TemplateProperty,
6
+ type ShellEnvVar,
6
7
  } from "./CodeEditor";
7
8
 
8
9
  export {
9
10
  generateTypeDefinitions,
10
11
  type GenerateTypesOptions,
11
12
  } from "./generateTypeDefinitions";
13
+
14
+ export {
15
+ healthcheckScriptContext,
16
+ integrationScriptContext,
17
+ type ScriptEditorContext,
18
+ } from "./scriptContext";
@@ -0,0 +1,62 @@
1
+ import type { Monaco } from "@monaco-editor/react";
2
+
3
+ /**
4
+ * Lazy-load the bundled `@types/node` + `bun-types` declarations and mount
5
+ * them into a Monaco TypeScript service.
6
+ *
7
+ * The bundle lives at [generated/stdlib-types.json](./generated/stdlib-types.json)
8
+ * and is produced by `bun run generate:monaco-types`. It's ~3 MB of upstream
9
+ * `.d.ts` content, which is why we **dynamically import** it: bundlers
10
+ * (Vite, Rspack, …) split the JSON into its own chunk so it never blocks the
11
+ * initial frontend load. The chunk is fetched the first time the user opens
12
+ * an inline-script editor and cached for the rest of the session.
13
+ *
14
+ * Each file is registered at its real `node_modules/...` virtual path, so
15
+ * Monaco resolves cross-file `/// <reference>` directives just like the
16
+ * canonical TypeScript service would on disk.
17
+ */
18
+
19
+ type StdlibBundle = Record<string, string>;
20
+
21
+ let bundlePromise: Promise<StdlibBundle> | undefined;
22
+
23
+ function loadBundle(): Promise<StdlibBundle> {
24
+ if (!bundlePromise) {
25
+ bundlePromise = import("./generated/stdlib-types.json").then(
26
+ (mod) => mod.default as StdlibBundle,
27
+ );
28
+ }
29
+ return bundlePromise;
30
+ }
31
+
32
+ const installedFor = new WeakSet<object>();
33
+
34
+ /**
35
+ * Register the bundled stdlib types with the given Monaco instance.
36
+ *
37
+ * Safe to call multiple times — the work is gated on a per-Monaco
38
+ * `WeakSet` so we only `addExtraLib` once per instance, even across many
39
+ * editor mounts.
40
+ *
41
+ * @returns true once the stdlib is installed (or was already).
42
+ */
43
+ export async function ensureMonacoStdlib(monaco: Monaco): Promise<boolean> {
44
+ if (installedFor.has(monaco)) return true;
45
+
46
+ const bundle = await loadBundle();
47
+
48
+ // TypeScript and JavaScript share a single TS service in Monaco, but each
49
+ // service tracks its own extra-libs. Register against both so the same
50
+ // bundle covers either language mode.
51
+ for (const defaults of [
52
+ monaco.languages.typescript.typescriptDefaults,
53
+ monaco.languages.typescript.javascriptDefaults,
54
+ ]) {
55
+ for (const [path, content] of Object.entries(bundle)) {
56
+ defaults.addExtraLib(content, `file:///${path}`);
57
+ }
58
+ }
59
+
60
+ installedFor.add(monaco);
61
+ return true;
62
+ }
@@ -0,0 +1,118 @@
1
+ /// <reference types="vite/client" />
2
+ /**
3
+ * Eager, module-load-time Monaco bootstrap. Three things happen here,
4
+ * in this exact order, before any `<CodeEditor>` instance can mount:
5
+ *
6
+ * 1. Bundle the per-language web workers via Vite's `?worker` import
7
+ * suffix and install them via `MonacoEnvironment.getWorker`.
8
+ * Without locally-bundled workers, the loader's default CDN path
9
+ * sometimes fails silently (CORS on the worker scripts) and the
10
+ * TS service falls back to no-language-service mode.
11
+ *
12
+ * 2. Configure the TypeScript language service — compiler options,
13
+ * diagnostics options, eager-model-sync. The TS service in
14
+ * monaco-editor is a singleton that initialises lazily the FIRST
15
+ * time it sees a TS/JS model. Doing this config inside a
16
+ * per-mount `onMount` (the previous shape) meant a shell editor
17
+ * mounted first would start the service with defaults, and the
18
+ * later TS editor's config arrived too late.
19
+ *
20
+ * 3. Tell `@monaco-editor/react`'s loader to use our locally-bundled
21
+ * Monaco instance via `loader.config({ monaco })`. Without this
22
+ * the loader pulls Monaco from a CDN at runtime — which defeats
23
+ * the worker setup above.
24
+ *
25
+ * This file is imported as a SIDE EFFECT from MonacoEditor.tsx, so the
26
+ * setup runs on the first import of CodeEditor regardless of which
27
+ * language ends up being mounted first.
28
+ */
29
+ import * as monaco from "monaco-editor";
30
+ import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
31
+ import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
32
+ import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
33
+ import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
34
+ import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
35
+ import { loader } from "@monaco-editor/react";
36
+
37
+ // ─── 1. Workers ──────────────────────────────────────────────────────────
38
+
39
+ interface MonacoEnvironmentLike {
40
+ getWorker(workerId: string, label: string): Worker;
41
+ }
42
+
43
+ (
44
+ globalThis as unknown as { MonacoEnvironment: MonacoEnvironmentLike }
45
+ ).MonacoEnvironment = {
46
+ getWorker(_workerId: string, label: string): Worker {
47
+ switch (label) {
48
+ case "json": {
49
+ return new jsonWorker();
50
+ }
51
+ case "css":
52
+ case "scss":
53
+ case "less": {
54
+ return new cssWorker();
55
+ }
56
+ case "html":
57
+ case "handlebars":
58
+ case "razor": {
59
+ return new htmlWorker();
60
+ }
61
+ case "typescript":
62
+ case "javascript": {
63
+ return new tsWorker();
64
+ }
65
+ default: {
66
+ return new editorWorker();
67
+ }
68
+ }
69
+ },
70
+ };
71
+
72
+ // ─── 2. TypeScript language service ──────────────────────────────────────
73
+ //
74
+ // In monaco-editor 0.55 the canonical access path is `monaco.typescript`
75
+ // (the older `monaco.languages.typescript` is marked
76
+ // `{ deprecated: true }` in the type defs and emits a tombstone object).
77
+ // Both still resolve to the same singleton at runtime; we use the new
78
+ // path for type-safety.
79
+
80
+ const tsDefaults = monaco.typescript.typescriptDefaults;
81
+ const jsDefaults = monaco.typescript.javascriptDefaults;
82
+
83
+ for (const defaults of [tsDefaults, jsDefaults]) {
84
+ defaults.setCompilerOptions({
85
+ target: monaco.typescript.ScriptTarget.ESNext,
86
+ module: monaco.typescript.ModuleKind.ESNext,
87
+ moduleResolution: monaco.typescript.ModuleResolutionKind.NodeJs,
88
+ lib: ["esnext"],
89
+ // Pulls the bundled @types/node + bun-types declarations into the
90
+ // ambient scope, so `process`, `Buffer`, the `Bun` global, etc. are
91
+ // typed without the user needing any `/// <reference>`.
92
+ types: ["node", "bun-types"],
93
+ allowNonTsExtensions: false,
94
+ noEmit: true,
95
+ strict: true,
96
+ esModuleInterop: true,
97
+ });
98
+
99
+ // Suppress diagnostics that don't apply to our script context:
100
+ // - 1108: "A 'return' statement can only be used within a function body"
101
+ // The runtime wraps legacy scripts in an async IIFE, so a
102
+ // top-level `return X;` is valid in the user's source.
103
+ defaults.setDiagnosticsOptions({
104
+ diagnosticCodesToIgnore: [1108],
105
+ });
106
+
107
+ // Eagerly push models to the TS worker the moment they're created so
108
+ // diagnostics + completions are available on the first keystroke.
109
+ defaults.setEagerModelSync(true);
110
+ }
111
+
112
+ // ─── 3. Loader ───────────────────────────────────────────────────────────
113
+ //
114
+ // Must be the LAST step: once a `<Editor>` mounts, `loader.init()` is
115
+ // called and config is locked. Pointing at our local `monaco` import
116
+ // bypasses the loader's default AMD/CDN path entirely.
117
+
118
+ loader.config({ monaco });
@@ -0,0 +1,280 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ healthcheckScriptContext,
4
+ integrationScriptContext,
5
+ _internals,
6
+ } from "./scriptContext";
7
+ import type { JsonSchemaProperty } from "../DynamicForm/types";
8
+
9
+ describe("healthcheckScriptContext", () => {
10
+ it("emits both the global and module-export declarations of defineHealthCheck", () => {
11
+ const ctx = healthcheckScriptContext({});
12
+ // Module declaration is what `import { defineHealthCheck } from
13
+ // "@checkstack/healthcheck"` resolves to.
14
+ expect(ctx.typeDefinitions).toContain('declare module "@checkstack/healthcheck"');
15
+ expect(ctx.typeDefinitions).toContain("HealthCheckScriptResult");
16
+ // Global declaration is what makes Monaco autocomplete `defineHea…`
17
+ // _without_ requiring the user to type the import first (Monaco 0.55
18
+ // doesn't expose `includeCompletionsForModuleExports`).
19
+ expect(ctx.typeDefinitions).toMatch(/declare function defineHealthCheck/);
20
+ });
21
+
22
+ it("typed `context.config` reflects the supplied collector schema", () => {
23
+ const ctx = healthcheckScriptContext({
24
+ collectorConfigSchema: {
25
+ type: "object",
26
+ properties: {
27
+ host: { type: "string", description: "Target hostname" },
28
+ port: { type: "integer" },
29
+ enabled: { type: "boolean" },
30
+ },
31
+ required: ["host", "port"],
32
+ },
33
+ });
34
+
35
+ // Property names + descriptions should land in the generated context block.
36
+ expect(ctx.typeDefinitions).toContain("declare const context");
37
+ expect(ctx.typeDefinitions).toContain("readonly host: string");
38
+ expect(ctx.typeDefinitions).toContain("readonly port: number");
39
+ expect(ctx.typeDefinitions).toContain("readonly enabled?: boolean");
40
+ expect(ctx.typeDefinitions).toContain("Target hostname");
41
+ });
42
+
43
+ it("falls back to a generic `Record<string, unknown>` config when no schema is given", () => {
44
+ const ctx = healthcheckScriptContext({});
45
+ // We ALWAYS declare `context` so the function-arg form
46
+ // `defineHealthCheck(ctx => …)` is properly typed too — without a
47
+ // schema, both the global and the function param fall back to a
48
+ // generic config shape.
49
+ expect(ctx.typeDefinitions).toContain("declare const context");
50
+ expect(ctx.typeDefinitions).toContain("Record<string, unknown>");
51
+ expect(ctx.typeDefinitions).toContain("HealthCheckScriptResult");
52
+ expect(ctx.typeDefinitions).toContain("HealthCheckScriptContext");
53
+ });
54
+
55
+ it("types the `defineHealthCheck` callback parameter from the schema (not `unknown`)", () => {
56
+ // Regression guard: the previous version had `(ctx: unknown) => …`,
57
+ // so `ctx.config.host` produced "'ctx' is of type 'unknown'". The
58
+ // callback param must reference the shared HealthCheckScriptContext
59
+ // type that's also used for the global `context`.
60
+ const ctx = healthcheckScriptContext({
61
+ collectorConfigSchema: {
62
+ type: "object",
63
+ properties: { host: { type: "string" } },
64
+ required: ["host"],
65
+ },
66
+ });
67
+ expect(ctx.typeDefinitions).toMatch(
68
+ /\(ctx:\s*HealthCheckScriptContext\)/,
69
+ );
70
+ // The shared context type carries the schema-derived field.
71
+ expect(ctx.typeDefinitions).toContain("readonly host: string");
72
+ });
73
+
74
+ it("exposes inline TS + shell starters", () => {
75
+ const ctx = healthcheckScriptContext({});
76
+ expect(ctx.starterTemplates.typescript).toBeDefined();
77
+ expect(ctx.starterTemplates.javascript).toBeDefined();
78
+ expect(ctx.starterTemplates.shell).toBeDefined();
79
+ expect(ctx.starterTemplates.typescript).toContain('import { loadavg } from "node:os"');
80
+ expect(ctx.starterTemplates.typescript).toContain("defineHealthCheck");
81
+ // Portable load-average read via `uptime` (works on Linux + macOS).
82
+ expect(ctx.starterTemplates.shell).toContain("uptime");
83
+ expect(ctx.starterTemplates.shell).toContain("load average");
84
+ expect(ctx.starterTemplates.shell).toContain("awk");
85
+ // Guard against a Linux-only regression: /proc/loadavg doesn't
86
+ // exist on macOS, so the starter must not depend on it.
87
+ expect(ctx.starterTemplates.shell).not.toContain("/proc/loadavg");
88
+ });
89
+
90
+ it("exposes the safe-env-vars whitelist for shell completion", () => {
91
+ const ctx = healthcheckScriptContext({});
92
+ const names = ctx.shellEnvVars.map((v) => v.name);
93
+ // Sanity-check a few entries from the whitelist that scripts will
94
+ // most often actually reference.
95
+ expect(names).toContain("PATH");
96
+ expect(names).toContain("HOME");
97
+ expect(names).toContain("TZ");
98
+ // Integration-only vars must NOT leak into the healthcheck context.
99
+ expect(names).not.toContain("EVENT_ID");
100
+ expect(names).not.toContain("PAYLOAD_TITLE");
101
+ });
102
+ });
103
+
104
+ describe("integrationScriptContext", () => {
105
+ it("emits both the global and module-export declarations of defineIntegration", () => {
106
+ const ctx = integrationScriptContext({});
107
+ expect(ctx.typeDefinitions).toContain('declare module "@checkstack/integration"');
108
+ expect(ctx.typeDefinitions).toContain("IntegrationScriptResult");
109
+ // Global form — analogous to defineHealthCheck.
110
+ expect(ctx.typeDefinitions).toMatch(/declare function defineIntegration/);
111
+ });
112
+
113
+ it("typed `context.event.payload` reflects the supplied payload schema", () => {
114
+ const ctx = integrationScriptContext({
115
+ eventPayloadSchema: {
116
+ type: "object",
117
+ properties: {
118
+ title: { type: "string" },
119
+ severity: { type: "string", enum: ["low", "high"] },
120
+ },
121
+ required: ["title"],
122
+ },
123
+ });
124
+
125
+ expect(ctx.typeDefinitions).toContain("readonly title: string");
126
+ // Enum should narrow to the literal union.
127
+ expect(ctx.typeDefinitions).toContain('"low" | "high"');
128
+ expect(ctx.typeDefinitions).toContain("declare const context");
129
+ expect(ctx.typeDefinitions).toContain("eventId");
130
+ });
131
+
132
+ it("types the `defineIntegration` callback parameter from the schema (not `unknown`)", () => {
133
+ // Regression guard for the screenshot the user sent: typing
134
+ // `context.event.eventId` inside `defineIntegration(async (context)
135
+ // => …)` produced "'context' is of type 'unknown'" because the
136
+ // virtual module declared the param as `unknown`. Must reference
137
+ // the shared `IntegrationScriptContext` type instead.
138
+ const ctx = integrationScriptContext({
139
+ eventPayloadSchema: {
140
+ type: "object",
141
+ properties: { title: { type: "string" } },
142
+ required: ["title"],
143
+ },
144
+ });
145
+ expect(ctx.typeDefinitions).toMatch(
146
+ /\(ctx:\s*IntegrationScriptContext\)/,
147
+ );
148
+ // The shared context type carries the schema-derived payload field.
149
+ expect(ctx.typeDefinitions).toContain("readonly title: string");
150
+ // And the always-present event metadata.
151
+ expect(ctx.typeDefinitions).toContain("readonly eventId: string");
152
+ expect(ctx.typeDefinitions).toContain("readonly deliveryId: string");
153
+ });
154
+
155
+ it("falls back to `Record<string, unknown>` payload when no schema is given", () => {
156
+ const ctx = integrationScriptContext({});
157
+ expect(ctx.typeDefinitions).toContain("declare const context");
158
+ expect(ctx.typeDefinitions).toContain("IntegrationScriptContext");
159
+ expect(ctx.typeDefinitions).toContain("Record<string, unknown>");
160
+ });
161
+
162
+ it("flattens nested payload schemas into PAYLOAD_* shell env-var hints", () => {
163
+ const ctx = integrationScriptContext({
164
+ eventPayloadSchema: {
165
+ type: "object",
166
+ properties: {
167
+ title: { type: "string", description: "Incident title" },
168
+ severity: { type: "string" },
169
+ metadata: {
170
+ type: "object",
171
+ properties: {
172
+ region: { type: "string" },
173
+ host: { type: "string" },
174
+ },
175
+ },
176
+ },
177
+ },
178
+ });
179
+
180
+ const names = ctx.shellEnvVars.map((v) => v.name);
181
+ // Top-level scalars are flattened with the PAYLOAD_ prefix.
182
+ expect(names).toContain("PAYLOAD_TITLE");
183
+ expect(names).toContain("PAYLOAD_SEVERITY");
184
+ // Nested objects are recursively flattened with underscore-joined keys.
185
+ expect(names).toContain("PAYLOAD_METADATA_REGION");
186
+ expect(names).toContain("PAYLOAD_METADATA_HOST");
187
+ // The intermediate object key itself is NOT emitted (only its leaves).
188
+ expect(names).not.toContain("PAYLOAD_METADATA");
189
+
190
+ const title = ctx.shellEnvVars.find((v) => v.name === "PAYLOAD_TITLE");
191
+ expect(title?.description).toBe("Incident title");
192
+ });
193
+
194
+ it("always includes the platform-injected core integration env vars", () => {
195
+ const ctx = integrationScriptContext({});
196
+ const names = ctx.shellEnvVars.map((v) => v.name);
197
+ expect(names).toContain("EVENT_ID");
198
+ expect(names).toContain("EVENT_TIMESTAMP");
199
+ expect(names).toContain("DELIVERY_ID");
200
+ expect(names).toContain("SUBSCRIPTION_ID");
201
+ expect(names).toContain("SUBSCRIPTION_NAME");
202
+ // Safe-vars are also still present.
203
+ expect(names).toContain("PATH");
204
+ });
205
+
206
+ it("exposes inline TS + shell starters that reference the right variables", () => {
207
+ const ctx = integrationScriptContext({});
208
+ expect(ctx.starterTemplates.typescript).toContain("defineIntegration");
209
+ expect(ctx.starterTemplates.typescript).toContain("context.event.eventId");
210
+ expect(ctx.starterTemplates.shell).toContain("$EVENT_ID");
211
+ expect(ctx.starterTemplates.shell).toContain("$PAYLOAD_TITLE");
212
+ });
213
+
214
+ it("falls back gracefully when the payload schema isn't available", () => {
215
+ const ctx = integrationScriptContext({});
216
+ // No `PAYLOAD_*` hints yet, but the core vars and starters are usable.
217
+ const payloadVars = ctx.shellEnvVars.filter((v) =>
218
+ v.name.startsWith("PAYLOAD_"),
219
+ );
220
+ expect(payloadVars).toHaveLength(0);
221
+ expect(ctx.starterTemplates.typescript).toBeDefined();
222
+ expect(ctx.starterTemplates.shell).toBeDefined();
223
+ });
224
+ });
225
+
226
+ describe("flattenSchemaToEnvVars (internal)", () => {
227
+ const { flattenSchemaToEnvVars } = _internals;
228
+
229
+ it("returns an empty list for non-object schemas", () => {
230
+ expect(flattenSchemaToEnvVars({ type: "string" })).toEqual([]);
231
+ expect(flattenSchemaToEnvVars({ type: "number" })).toEqual([]);
232
+ });
233
+
234
+ it("normalises field names: uppercases and replaces dots/dashes", () => {
235
+ const schema: JsonSchemaProperty = {
236
+ type: "object",
237
+ properties: {
238
+ "user.email": { type: "string" },
239
+ "first-name": { type: "string" },
240
+ "field!with$weird?chars": { type: "string" },
241
+ },
242
+ };
243
+
244
+ const names = flattenSchemaToEnvVars(schema).map((v) => v.name);
245
+ expect(names).toContain("PAYLOAD_USER_EMAIL");
246
+ expect(names).toContain("PAYLOAD_FIRST_NAME");
247
+ // Special characters are stripped, not preserved.
248
+ expect(names).toContain("PAYLOAD_FIELDWITHWEIRDCHARS");
249
+ });
250
+
251
+ it("respects a custom prefix for nested flattening", () => {
252
+ const schema: JsonSchemaProperty = {
253
+ type: "object",
254
+ properties: {
255
+ title: { type: "string" },
256
+ },
257
+ };
258
+
259
+ const result = flattenSchemaToEnvVars(schema, "EVENT");
260
+ expect(result[0]?.name).toBe("EVENT_TITLE");
261
+ });
262
+
263
+ it("includes example placeholders sized to the property type", () => {
264
+ const schema: JsonSchemaProperty = {
265
+ type: "object",
266
+ properties: {
267
+ name: { type: "string" },
268
+ count: { type: "number" },
269
+ on: { type: "boolean" },
270
+ },
271
+ };
272
+
273
+ const byName = Object.fromEntries(
274
+ flattenSchemaToEnvVars(schema).map((v) => [v.name, v]),
275
+ );
276
+ expect(byName.PAYLOAD_NAME?.example).toBe("<string>");
277
+ expect(byName.PAYLOAD_COUNT?.example).toBe("<number>");
278
+ expect(byName.PAYLOAD_ON?.example).toBe("<true|false>");
279
+ });
280
+ });