diff --git a/.changeset/tall-lies-raise.md b/.changeset/tall-lies-raise.md new file mode 100644 index 0000000000..478cda3e3f --- /dev/null +++ b/.changeset/tall-lies-raise.md @@ -0,0 +1,5 @@ +--- +'hive': patch +--- + +A minor defect in Laboratory has been fixed that previously caused the application to crash when local storage was in a particular state. diff --git a/packages/web/app/src/components/target/explorer/provider.tsx b/packages/web/app/src/components/target/explorer/provider.tsx index dc98e836b8..ff40835d26 100644 --- a/packages/web/app/src/components/target/explorer/provider.tsx +++ b/packages/web/app/src/components/target/explorer/provider.tsx @@ -8,7 +8,8 @@ import { useState, } from 'react'; import { startOfDay } from 'date-fns'; -import { resolveRange, type Period } from '@/lib/date-math'; +import { z } from 'zod'; +import { Period, resolveRange } from '@/lib/date-math'; import { subDays } from '@/lib/date-time'; import { useLocalStorageJson } from '@/lib/hooks'; import { UTCDate } from '@date-fns/utc'; @@ -27,7 +28,7 @@ type SchemaExplorerContextType = { refreshResolvedPeriod(): void; }; -const defaultPeriod = { +const defaultPeriod: Period = { from: 'now-7d', to: 'now', }; @@ -56,11 +57,11 @@ export function SchemaExplorerProvider({ children }: { children: ReactNode }): R const [isArgumentListCollapsed, setArgumentListCollapsed] = useLocalStorageJson( 'hive:schema-explorer:collapsed', - true, + z.boolean().default(true), ); - const [period, setPeriod] = useLocalStorageJson( + const [period, setPeriod] = useLocalStorageJson( 'hive:schema-explorer:period-1', - defaultPeriod, + Period.default(defaultPeriod), ); const [resolvedPeriod, setResolvedPeriod] = useState(() => resolveRange(period)); diff --git a/packages/web/app/src/components/ui/changelog/changelog.tsx b/packages/web/app/src/components/ui/changelog/changelog.tsx index 2ccd9aaf0c..df710671a3 100644 --- a/packages/web/app/src/components/ui/changelog/changelog.tsx +++ b/packages/web/app/src/components/ui/changelog/changelog.tsx @@ -1,5 +1,6 @@ import { ReactElement, useCallback, useEffect } from 'react'; import { format } from 'date-fns/format'; +import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useLocalStorageJson, useToggle } from '@/lib/hooks'; @@ -19,8 +20,14 @@ export function Changelog(props: { changes: Changelog[] }): ReactElement { function ChangelogPopover(props: { changes: Changelog[] }) { const [isOpen, toggle] = useToggle(); - const [displayDot, setDisplayDot] = useLocalStorageJson('hive:changelog:dot', false); - const [readChanges, setReadChanges] = useLocalStorageJson('hive:changelog:read', []); + const [displayDot, setDisplayDot] = useLocalStorageJson( + 'hive:changelog:dot', + z.boolean().default(false), + ); + const [readChanges, setReadChanges] = useLocalStorageJson( + 'hive:changelog:read', + z.array(z.string()).default([]), + ); const hasNewChanges = props.changes.some(change => !readChanges.includes(change.href)); useEffect(() => { diff --git a/packages/web/app/src/lib/date-math.ts b/packages/web/app/src/lib/date-math.ts index 0b9d6f52c0..64d0b66a75 100644 --- a/packages/web/app/src/lib/date-math.ts +++ b/packages/web/app/src/lib/date-math.ts @@ -3,12 +3,14 @@ * @source https://github.com/grafana/grafana/blob/411c89012febe13323e4b8aafc8d692f4460e680/packages/grafana-data/src/datetime/datemath.ts#L1C1-L208C2 */ import { add, format, formatISO, parse as parseDate, sub, type Duration } from 'date-fns'; +import { z } from 'zod'; import { UTCDate } from '@date-fns/utc'; -export type Period = { - from: string; - to: string; -}; +export const Period = z.object({ + from: z.string(), + to: z.string(), +}); +export type Period = z.infer; export type DurationUnit = 'y' | 'M' | 'w' | 'd' | 'h' | 'm'; export const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm']; diff --git a/packages/web/app/src/lib/hooks/use-local-storage-json.ts b/packages/web/app/src/lib/hooks/use-local-storage-json.ts index 54c99c04bc..97bd5a4465 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage-json.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage-json.ts @@ -1,23 +1,75 @@ import { useCallback, useState } from 'react'; +import { z } from 'zod'; +import { Kit } from '../kit'; -export function useLocalStorageJson(key: string, defaultValue: T) { - const [value, setValue] = useState(() => { - const json = localStorage.getItem(key); +export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInput<$Schema>) { + const [key, schema, manualDefaultValue] = args as any as Args<$Schema>; + // The parameter types will force the user to give a manual default + // if their given Zod schema does not have default. + // + // We resolve that here because in the event of a Zod parse failure, we fallback + // to the default value, meaning we are needing a reference to the Zod default outside + // of the regular parse process. + // + const defaultValue = + manualDefaultValue !== undefined + ? manualDefaultValue + : Kit.ZodHelpers.isDefaultType(schema) + ? (schema._def.defaultValue() as z.infer<$Schema>) + : Kit.never(); + + const [value, setValue] = useState>(() => { + // Note: `null` is returned for missing values. However Zod only kicks in + // default values for `undefined`, not `null`. However-however, this is ok, + // because we manually pre-compute+return the default value, thus we don't + // rely on Zod's behaviour. If that changes this should have `?? undefined` + // added. + const storedValue = localStorage.getItem(key); + + if (!storedValue) { + return defaultValue; + } + + // todo: Some possible improvements: + // - Monitor json/schema parse failures. + // - Let caller choose an error strategy: 'return' / 'default' / 'throw' try { - const result = json ? JSON.parse(json) : defaultValue; - return result; - } catch (_) { + return schema.parse(JSON.parse(storedValue)); + } catch (error) { + if (error instanceof SyntaxError) { + console.warn(`useLocalStorageJson: JSON parsing failed for key "${key}"`, error); + } else if (error instanceof z.ZodError) { + console.warn(`useLocalStorageJson: Schema validation failed for key "${key}"`, error); + } else { + Kit.neverCatch(error); + } return defaultValue; } }); const set = useCallback( - (value: T) => { + (value: z.infer<$Schema>) => { localStorage.setItem(key, JSON.stringify(value)); setValue(value); }, - [setValue], + [key], ); return [value, set] as const; } + +type ArgsInput<$Schema extends z.ZodType> = + $Schema extends z.ZodDefault + ? [key: string, schema: ArgsInputGuardZodJsonSchema<$Schema>] + : [key: string, schema: ArgsInputGuardZodJsonSchema<$Schema>, defaultValue: z.infer<$Schema>]; + +type ArgsInputGuardZodJsonSchema<$Schema extends z.ZodType> = + z.infer<$Schema> extends Kit.Json.Value + ? $Schema + : 'Error: Your Zod schema is or contains a type that is not valid JSON.'; + +type Args<$Schema extends z.ZodType> = [ + key: string, + schema: $Schema, + defaultValue?: z.infer<$Schema>, +]; diff --git a/packages/web/app/src/lib/kit/index.ts b/packages/web/app/src/lib/kit/index.ts index 87c64a4bfd..99ad012ff5 100644 --- a/packages/web/app/src/lib/kit/index.ts +++ b/packages/web/app/src/lib/kit/index.ts @@ -1,6 +1,14 @@ -// eslint-disable-next-line import/no-self-import -export * as Kit from './index'; +// Storybook (or the version we are using) +// is using a version of Babel that does not +// support re-exporting as namespaces: +// +// export * as Kit from './index'; +// +// So we have to re-export everything manually +// and incur an additional index_ file for it +// too: -export * from './never'; -export * from './types/headers'; -export * from './helpers'; +import * as Kit from './index_'; + +// eslint-disable-next-line unicorn/prefer-export-from +export { Kit }; diff --git a/packages/web/app/src/lib/kit/index_.ts b/packages/web/app/src/lib/kit/index_.ts new file mode 100644 index 0000000000..187aed2b21 --- /dev/null +++ b/packages/web/app/src/lib/kit/index_.ts @@ -0,0 +1,5 @@ +export * from './never'; +export * from './types/headers'; +export * from './helpers'; +export * from './json'; +export * from './zod-helpers'; diff --git a/packages/web/app/src/lib/kit/json.ts b/packages/web/app/src/lib/kit/json.ts new file mode 100644 index 0000000000..108012f2db --- /dev/null +++ b/packages/web/app/src/lib/kit/json.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { ZodHelpers } from './zod-helpers'; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Json { + export const Primitive = z.union([z.string(), z.number(), z.boolean(), z.null()]); + export type Primitive = z.infer; + export const isPrimitive = ZodHelpers.createTypeGuard(Primitive); + + export const Value: z.ZodType = z.lazy(() => + z.union([Primitive, z.array(Value), z.record(Value)]), + ); + export type Value = Primitive | { [key: string]: Value } | Value[]; + export const isValue = ZodHelpers.createTypeGuard(Value); + + export const Object: z.ZodType = z.record(Value); + export type Object = { [key: string]: Value }; + export const isObject = ZodHelpers.createTypeGuard(Object); +} diff --git a/packages/web/app/src/lib/kit/never.ts b/packages/web/app/src/lib/kit/never.ts index 1b7a40c724..d96bf4f2fe 100644 --- a/packages/web/app/src/lib/kit/never.ts +++ b/packages/web/app/src/lib/kit/never.ts @@ -1,6 +1,14 @@ +/** + * This case of thrown value is impossible. + * If it happens, then that means there is a defect in our code. + */ +export const neverCatch = (value: unknown): never => { + never({ type: 'catch', value }); +}; + /** * This case is impossible. - * If it happens, then that means there is a bug in our code. + * If it happens, then that means there is a defect in our code. */ export const neverCase = (value: never): never => { never({ type: 'case', value }); @@ -8,7 +16,7 @@ export const neverCase = (value: never): never => { /** * This code cannot be reached. - * If it is reached, then that means there is a bug in our code. + * If it is reached, then that means there is a defect in our code. */ export const never: (context?: object) => never = context => { throw new Error('Something that should be impossible happened', { cause: context }); diff --git a/packages/web/app/src/lib/kit/zod-helpers.ts b/packages/web/app/src/lib/kit/zod-helpers.ts new file mode 100644 index 0000000000..fff3fe9837 --- /dev/null +++ b/packages/web/app/src/lib/kit/zod-helpers.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace ZodHelpers { + export const isDefaultType = (zodType: z.ZodType): zodType is z.ZodDefault => { + return 'defaultValue' in zodType._def; + }; + + export const createTypeGuard = + <$Schema extends z.ZodType, $Value = z.infer<$Schema>>(schema: $Schema) => + (value: unknown): value is $Value => { + const result = schema.safeParse(value); + return result.success; + }; +} diff --git a/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx index b4eca68b72..05039ed020 100644 --- a/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx +++ b/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx @@ -12,6 +12,7 @@ import { clsx } from 'clsx'; import { PowerIcon } from 'lucide-react'; import type { editor } from 'monaco-editor'; import { useMutation } from 'urql'; +import { z } from 'zod'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -155,7 +156,7 @@ export function usePreflightScript(args: { const target = useFragment(PreflightScript_TargetFragment, args.target); const [isPreflightScriptEnabled, setIsPreflightScriptEnabled] = useLocalStorageJson( 'hive:laboratory:isPreflightScriptEnabled', - false, + z.boolean().default(false), ); const [environmentVariables, setEnvironmentVariables] = useLocalStorage( 'hive:laboratory:environment', diff --git a/packages/web/app/src/lib/preflight-sandbox/json.ts b/packages/web/app/src/lib/preflight-sandbox/json.ts deleted file mode 100644 index 0e472f8e02..0000000000 --- a/packages/web/app/src/lib/preflight-sandbox/json.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type JSONPrimitive = boolean | null | string | number; -export type JSONObject = { [key: string]: JSONValue }; -export type JSONValue = JSONPrimitive | JSONValue[] | JSONObject; - -function isJSONValue(value: unknown): value is JSONValue { - return ( - (Array.isArray(value) && value.every(isJSONValue)) || - isJSONObject(value) || - isJSONPrimitive(value) - ); -} - -export function isJSONObject(value: unknown): value is JSONObject { - return ( - typeof value === 'object' && - !!value && - !Array.isArray(value) && - Object.values(value).every(isJSONValue) - ); -} - -export function isJSONPrimitive(value: unknown): value is JSONPrimitive { - return ( - typeof value === 'boolean' || - typeof value === 'number' || - typeof value === 'string' || - value === null - ); -} diff --git a/packages/web/app/src/lib/preflight-sandbox/preflight-script-worker.ts b/packages/web/app/src/lib/preflight-sandbox/preflight-script-worker.ts index 4b9d53f6e7..f5f92df8b7 100644 --- a/packages/web/app/src/lib/preflight-sandbox/preflight-script-worker.ts +++ b/packages/web/app/src/lib/preflight-sandbox/preflight-script-worker.ts @@ -1,14 +1,14 @@ import CryptoJS from 'crypto-js'; import CryptoJSPackageJson from 'crypto-js/package.json'; +import { Kit } from '../kit'; import { ALLOWED_GLOBALS } from './allowed-globals'; -import { isJSONPrimitive, JSONPrimitive } from './json'; import { LogMessage, WorkerEvents } from './shared-types'; interface WorkerData { request: { headers: Headers; }; - environmentVariables: Record; + environmentVariables: Record; } /** @@ -108,7 +108,7 @@ async function execute(args: WorkerEvents.Incoming.EventData): Promise { }; function getValidEnvVariable(value: unknown) { - if (isJSONPrimitive(value)) { + if (Kit.Json.isPrimitive(value)) { return value; } consoleApi.warn( diff --git a/packages/web/app/src/lib/preflight-sandbox/shared-types.ts b/packages/web/app/src/lib/preflight-sandbox/shared-types.ts index ca86490346..25d63738e8 100644 --- a/packages/web/app/src/lib/preflight-sandbox/shared-types.ts +++ b/packages/web/app/src/lib/preflight-sandbox/shared-types.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-namespace */ import { Kit } from '../kit'; -import { JSONPrimitive } from './json'; type _MessageEvent = MessageEvent; @@ -48,7 +47,7 @@ export namespace IFrameEvents { export interface Result { type: Event.result; runId: string; - environmentVariables: Record; + environmentVariables: Record; request: { headers: Kit.Headers.Encoded; }; @@ -93,7 +92,7 @@ export namespace IFrameEvents { type: Event.run; id: string; script: string; - environmentVariables: Record; + environmentVariables: Record; } export interface Abort { @@ -149,7 +148,7 @@ export namespace WorkerEvents { export interface Result { type: Event.result; - environmentVariables: Record; + environmentVariables: Record; request: { headers: Kit.Headers.Encoded; }; @@ -187,7 +186,7 @@ export namespace WorkerEvents { export interface Run { type: Event.run; script: string; - environmentVariables: Record; + environmentVariables: Record; } } diff --git a/packages/web/app/src/pages/target-laboratory.tsx b/packages/web/app/src/pages/target-laboratory.tsx index c563202898..29ab2ccd72 100644 --- a/packages/web/app/src/pages/target-laboratory.tsx +++ b/packages/web/app/src/pages/target-laboratory.tsx @@ -59,7 +59,7 @@ import 'graphiql/style.css'; import '@graphiql/plugin-explorer/style.css'; import { PromptManager, PromptProvider } from '@/components/ui/prompt'; import { useRedirect } from '@/lib/access/common'; -import { JSONPrimitive } from '@/lib/preflight-sandbox/json'; +import { Kit } from '@/lib/kit'; const explorer = explorerPlugin(); @@ -259,7 +259,7 @@ function Save(props: { function substituteVariablesInHeaders( headers: Record, - environmentVariables: Record, + environmentVariables: Record, ) { return Object.fromEntries( Object.entries(headers).map(([key, value]) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 809b879a47..3c7964f68c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3908,6 +3908,7 @@ packages: '@fastify/vite@6.0.7': resolution: {integrity: sha512-+dRo9KUkvmbqdmBskG02SwigWl06Mwkw8SBDK1zTNH6vd4DyXbRvI7RmJEmBkLouSU81KTzy1+OzwHSffqSD6w==} + bundledDependencies: [] '@floating-ui/core@1.2.6': resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==}