Skip to content

Commit

Permalink
refactor(libraries): type safe local storage json (#6455)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored Feb 4, 2025
1 parent f6d2aee commit 6924a1a
Show file tree
Hide file tree
Showing 16 changed files with 160 additions and 66 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-lies-raise.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 6 additions & 5 deletions packages/web/app/src/components/target/explorer/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,7 +28,7 @@ type SchemaExplorerContextType = {
refreshResolvedPeriod(): void;
};

const defaultPeriod = {
const defaultPeriod: Period = {
from: 'now-7d',
to: 'now',
};
Expand Down Expand Up @@ -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<Period>(
const [period, setPeriod] = useLocalStorageJson(
'hive:schema-explorer:period-1',
defaultPeriod,
Period.default(defaultPeriod),
);
const [resolvedPeriod, setResolvedPeriod] = useState<Period>(() => resolveRange(period));

Expand Down
11 changes: 9 additions & 2 deletions packages/web/app/src/components/ui/changelog/changelog.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,8 +20,14 @@ export function Changelog(props: { changes: Changelog[] }): ReactElement {

function ChangelogPopover(props: { changes: Changelog[] }) {
const [isOpen, toggle] = useToggle();
const [displayDot, setDisplayDot] = useLocalStorageJson<boolean>('hive:changelog:dot', false);
const [readChanges, setReadChanges] = useLocalStorageJson<string[]>('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(() => {
Expand Down
10 changes: 6 additions & 4 deletions packages/web/app/src/lib/date-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Period>;

export type DurationUnit = 'y' | 'M' | 'w' | 'd' | 'h' | 'm';
export const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm'];
Expand Down
68 changes: 60 additions & 8 deletions packages/web/app/src/lib/hooks/use-local-storage-json.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,75 @@
import { useCallback, useState } from 'react';
import { z } from 'zod';
import { Kit } from '../kit';

export function useLocalStorageJson<T>(key: string, defaultValue: T) {
const [value, setValue] = useState<T>(() => {
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<z.infer<$Schema>>(() => {
// 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<z.ZodType>
? [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>,
];
18 changes: 13 additions & 5 deletions packages/web/app/src/lib/kit/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
5 changes: 5 additions & 0 deletions packages/web/app/src/lib/kit/index_.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './never';
export * from './types/headers';
export * from './helpers';
export * from './json';
export * from './zod-helpers';
19 changes: 19 additions & 0 deletions packages/web/app/src/lib/kit/json.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Primitive>;
export const isPrimitive = ZodHelpers.createTypeGuard(Primitive);

export const Value: z.ZodType<Value> = 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<Object> = z.record(Value);
export type Object = { [key: string]: Value };
export const isObject = ZodHelpers.createTypeGuard(Object);
}
12 changes: 10 additions & 2 deletions packages/web/app/src/lib/kit/never.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
/**
* 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 });
};

/**
* 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 });
Expand Down
15 changes: 15 additions & 0 deletions packages/web/app/src/lib/kit/zod-helpers.ts
Original file line number Diff line number Diff line change
@@ -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<z.ZodType> => {
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;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Expand Down
29 changes: 0 additions & 29 deletions packages/web/app/src/lib/preflight-sandbox/json.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
}

/**
Expand Down Expand Up @@ -108,7 +108,7 @@ async function execute(args: WorkerEvents.Incoming.EventData): Promise<void> {
};

function getValidEnvVariable(value: unknown) {
if (isJSONPrimitive(value)) {
if (Kit.Json.isPrimitive(value)) {
return value;
}
consoleApi.warn(
Expand Down
9 changes: 4 additions & 5 deletions packages/web/app/src/lib/preflight-sandbox/shared-types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-namespace */

import { Kit } from '../kit';
import { JSONPrimitive } from './json';

type _MessageEvent<T> = MessageEvent<T>;

Expand Down Expand Up @@ -48,7 +47,7 @@ export namespace IFrameEvents {
export interface Result {
type: Event.result;
runId: string;
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
request: {
headers: Kit.Headers.Encoded;
};
Expand Down Expand Up @@ -93,7 +92,7 @@ export namespace IFrameEvents {
type: Event.run;
id: string;
script: string;
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
}

export interface Abort {
Expand Down Expand Up @@ -149,7 +148,7 @@ export namespace WorkerEvents {

export interface Result {
type: Event.result;
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
request: {
headers: Kit.Headers.Encoded;
};
Expand Down Expand Up @@ -187,7 +186,7 @@ export namespace WorkerEvents {
export interface Run {
type: Event.run;
script: string;
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/web/app/src/pages/target-laboratory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -259,7 +259,7 @@ function Save(props: {

function substituteVariablesInHeaders(
headers: Record<string, string>,
environmentVariables: Record<string, JSONPrimitive>,
environmentVariables: Record<string, Kit.Json.Primitive>,
) {
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => {
Expand Down
1 change: 1 addition & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 6924a1a

Please sign in to comment.