Miscellaneous JavaScript/TypeScript helpers used by Reykjavík City's web dev teams.
This library is split up into multiple individual modules to help keep your bundles slim and aid tree-shaking.
npm install @reykjavik/webtools
yarn add @reykjavik/webtools
bun add @reykjavik/webtools
Contents:
-
@reykjavik/webtools/http
-
@reykjavik/webtools/fixIcelandicLocale
-
@reykjavik/webtools/async
-
@reykjavik/webtools/errorhandling
-
@reykjavik/webtools/SiteImprove
-
@reykjavik/webtools/CookieHubConsent
-
@reykjavik/webtools/vanillaExtract
- Framework Specific Tools
- Contributing
- Changelog
Various framework agnostic helpers for leveraging HTTP magic.
All the web-related HTTP status codes are exported with human-readable names and a short JSDoc comment:
HTTP_200_OK
HTTP_303_SeeOther
HTTP_304_NotModified
HTTP_307_TemporaryRedirect
HTTP_308_PermanentRedirect
HTTP_400_BadRequest
HTTP_401_Unauthorized
HTTP_403_Forbidden
HTTP_404_NotFound
HTTP_418_ImATeapot
HTTP_500_InternalServerError
- ...ad nauseum.
These make your code more readable and less prone to accidental mistakes:
import { HTTP_200_OK, HTTP_404_NotFound } from '@reykjavik/webtools/http';
console.log(HTTP_200_OK); // 200
console.log(HTTP_404_NotFound); // 404
These type unions are useful when writing HTTP helper functions and error handlers, etc.
Union Types for the more commonly occurrring HTTP Status codes:
-
HTTP_INFO
(100, 101) -
HTTP_SUCCESS
(200, 201, 202) -
HTTP_REDIRECTION
(301, 302, 303, 304, 307, 308)-
HTTP_NOTMODIFIED
(304)
-
-
HTTP_ERROR
(400, 404, 410, 401, 403, 500)-
HTTP_CLIENT_ERROR
(400, 404, 410, 401, 403)-
HTTP_NOT_FOUND
(400, 404, 410) -
HTTP_BANNED
(401, 403)
-
-
HTTP_SERVER_ERROR
(500)
-
It also offers more complete union types, including all the esoteric status codes, are also available:
-
HTTP_STATUS
(all the status-codes!)-
HTTP_INFO_ALL
(1**) -
HTTP_SUCCESS_ALL
(2**) -
HTTP_REDIRECTION_ALL
(3**) -
HTTP_ERROR_ALL
(4** and 5**)-
HTTP_CLIENT_ERROR_ALL
(4**) -
HTTP_SERVER_ERROR_ALL
(5**)
-
-
Syntax:
cacheConrol(response: ServerResponse | Response | Map<string, string> | { res: ServerResponse | Response }, ttlCfg: TTLConfig, eTag?: string|number): void
Use this function to quickly set the Cache-Control
header with a max-age=
on a HTTP response (or a Map
object representing response headers).
import { cacheControl } from '@reykjavik/webtools/http';
// ...then inside an API handler
// or a framework's data loader function
cacheControl(res, '4h');
// ...then set statusCode and send data
The directives private
and immutable
are used by by default.
Use the optional eTag
parameter if you intend to
handle conditional requests.
Syntax:
cacheControlHeaders(ttlCfg: TTLConfig, eTag?: string|number): Record<string, string>
Similar to the cacheControl
helper, but returns an
plain object with the headers for use in situations where HeadersInit
object
are expected.
import { cacheControlHeaders } from '@reykjavik/webtools/http';
const response = new Response('Hello, World!', {
headers: cacheControlHeaders('4h'),
});
import type { TTLConfig } from '@reykjavik/webtools/http';
const myTTL1: TTLConfig = '4s';
const myTTL2: TTLConfig = { maxAge: '4s' };
The ttlCfg
parameter is either a bare TTL
(max-age) value:
-
number
— seconds -
"${number}${'s'|'m'|'h'|'d'|'w'}"
— gets converted to seconds
…one of these TTLKeywords
:
-
"permanent"
— an alias formaxAge: '365d'
-
"no-cache"
— disables caching -
"unset"
— removes the header altogether
…or a more complex TTLConfig
object with the following properties:
TTLConfig.maxAge: TTL | TTLKeywords
(required)
Sets the max-age=
directive. See above definitions
(NOTE: Second values of zero or less get converted to "no-cache"
.)
TTLConfig.staleWhileRevalidate?: TTL
If set to a positive value then stale-while-revalidate=
is added to the
response header
TTLConfig.staleIfError?: TTL
If set to a positive value then stale-if-error=
is added to the response
header
TTLConfig.publ?: boolean
Sets the response caching as "public", instead of the default "private"
TTLConfig.stability?: 'revalidate' | 'immutable' | 'normal'
Allows setting a "must-revalidate" flag instead of the default "immutable". A
value of "normal"
omits the flagging and falls back to HTTP's default
behavior.
Syntax:
toSec(ttl: number | `${number}${'s'|'m'|'h'|'d'|'w'}`): number
Converts a TTL
(max-age) value into seconds. Returns 0
for bad and/or
negative input values.
import type { toSec, TTL } from '@reykjavik/webtools/http';
const ttl: TTL = '2h';
const ttlSec = toSec(ttl);
Syntax:
toSec(duration: number | `${number}${'s'|'m'|'h'|'d'|'w'}`): number
Converts a TTL
(duration) value into milliseconds. Returns 0
for bad
and/or negative input values.
import type { toMs, TTL } from '@reykjavik/webtools/http';
const ttl: TTL = '2h';
const ttlSec = toMs(ttl);
As of early 2024, Google Chrome still does not support the Icelandic locale
is
/is-IS
in any way. Meanwhile other browsers have supported it for over a
decade.
This module patches the following methods/classes by substituting the is
locale with da
(Danish) and apply a few post-hoc fixes to their return
values.
-
Intl.Collator
andString.prototype.localeCompare
(*) -
Intl.NumberFormat
andNumber.prototype.toLocaleString
(*) -
Intl.DateTimeFormat
andDate.prototype.toLocaleString
,.toLocaleDateString
, and.toLocaleTimeString
(*) Intl.RelativeDateFormat
Intl.PluralRules
Intl.ListFormat
(*) The results are quite usable, but not entirely perfect. The limitations/caveats are listed below.
To apply the patch, simply "side-effect import" this module at the top of your app's entry point:
import '@reykjavik/webtools/fixIcelandicLocale';
// Then continue with your day and use `localeCompare` and other Intl.* methods
// as you normally would. (See "limitations" below.)
(NOTE The patch is only applied in engines that fail a simple feature detection test.)
Intl.Collator
and localeCompare
:
- It sorts initial letters correctly but in the rest of the string, it
incorrectly treats
ð
andd
as the same letter (most of the time), and lumps the acute-accented charactersá
,é
,í
,ó
,ú
andý
in with their non-accented counterparts.
Intl.NumberFormat
and toLocaleString
:
- The
style: "unit"
option is not supported and prints units in Danish. (Soo many units and unit-variants…) - The
currencyDisplay: "name"
option is not supported and prints the currency's full name in Danish.
Intl.DateTimeFormat
and toLocaleDateString
:
- The
month: 'narrow'
andweekday: 'narrow'
options are not supported, and print the corresponding Danish initials. - For
timeZoneName
the values"long"
,"shortGeneric"
and"longGeneric"
will appear in Danish. - The
timeStyle: 'full'
option prints the timezone names in Danish - The
dayPeriod
option has a couple of slight mismatches, at 5 am and 12 noon.
We eagerly accept bugfixes, additions, etc. to this module!
Contains a few small helpers for working with async functions and promises.
Syntax:
promiseAllObject<T extends PlainObj>(promisesMap: T>): Promise<{ [K in keyof T]: Awaited<T[K]>; }>
A variation of Promise.all()
that accepts an object with named promises and
returns a same-shaped object with the resolved values.
import { promiseAllObject } from '@reykjavik/webtools/async';
const { user, posts } = await promiseAllObject({
user: fetchUser(),
posts: fetchPosts(),
});
Syntax: maxWait(timeout: number, promises: Array<any>): Promise<void>
Syntax:
maxWait<T extends PlainObj>(timeout: number, promises: T): Promise<{ [K in keyof T]: PromiseSettledResult<T[K]> } | undefined }>
This somewhat esoteric helper resolves soon when all of the passed promises
have settled (resolved or rejected), OR after timeout
milliseconds —
whichever comes first.
If an object is passed, the resolved value will be an object with the same
keys, and any settled values in a PromiseSettledResult
object, and
undefined
for any promises that didn't settle in time.
import { maxWait } from '@reykjavik/webtools/async';
const user = fetchUser(); // Promise<User>
const posts = fetchPosts(); // Promise<Array<Post>>
// Array of promises resolves to void
await maxWait(500, [user, posts]);
// Object of promises resolves to an object with any resolved values at that time
const { user, posts } = await maxWait(500, { user, posts });
console.log(user?.value); // undefined | User
console.log(posts?.value); // undefined | Array<Post>
console.log(posts?.status); // 'fulfilled' | 'rejected'
console.log(posts?.reason); // undefined | unknown
A small set of lightweight tools for handling errors and promises in a safer, more structured, FP-ish way.
Errors are always the first return value to promote early, explicit error handling.
Syntax: asError(maybeError: unknown): ErrorFromPayload
Guarantees that a caught (catch (e)
) value of unknown
type, is indeed an
Error
instance.
If the input is an Error
instance, it is returned as-is. If the input is
something else it is wrapped in a new ErrorFromPayload
instance, and the
original value is stored in as a payload
property.
import { asError, type ErrorFromPayload } from '@reykjavik/webtools/errorhandling';
const theError = new Error('Something went wrong');
try {
throw theError;
} catch (err) {
const error = asError(theError);
console.error(error === theError); // true
console.error('patload' in error); // false
}
const someObject = ['Something went wrong'];
try {
throw someObject;
} catch (err) {
const error = asError(someObject);
console.error(error === someObject); // false
console.error(error.message === someObject.join(',')); // false
console.error(error instanceOf ErrorFromPayload); // true
console.error(error.payload === someObject); // true
}
Singleton object with the following small methods for creating, mapping or
handling ResultTupleObj
instances:
Result.Success
Result.Fail
Result.catch
Result.map
Result.throw
Syntax: ResultTuple<ResultType, OptionalErrorType>
(Also aliased as Result.Tuple
)
Simple bare-bones discriminated tuple type for a [error, result]
pair.
import { type ResultTuple } from '@reykjavik/webtools/errorhandling';
declare const myResult: ResultTuple<string, Error>;
const [error, result] = myResult;
// (One of these two is always `undefined`)
if (error) {
// Here `error` is an Error instance
console.error(error.message);
} else {
// Here `result` is guaranteed to be a string
console.log(result);
}
Syntax: ResultTupleObj<ResultType, OptionalErrorType>
(Also aliased as Result.TupleObj
)
Discriminated tuple type for a [error, result]
pair (same as ResultTuple
)
but with named properties error
and result
attached for dev convenience.
import { type ResultTuple } from '@reykjavik/webtools/errorhandling';
declare const myResult: ResultTuple<string, Error>;
const [error, result] = myResult;
// (One of these two is always `undefined`)
if (error) {
// Here `error` is an Error instance
console.error(error.message);
} else {
// Here `result` is guaranteed to be a string
console.log(result);
}
// But `myResults` also has named properties, for convenience
if (myResult.error) {
// Here `myResult.error` is an Error instance
console.error(myResult.error.message);
} else {
// Here `myResult.result` is a string
console.log(myResult.result);
}
Syntax: Result.catch<T, Err>(callback: () => T): ResultTupleObj<T, Err>
Syntax:
Result.catch<T, Err>(promise: Promise<T>): Promise<ResultTupleObj<T, Err>>
Error handling utility that wraps a promise or a callback function.
Catches errors and returns a ResultTupleObj
— a nice discriminated
[error, results]
tuple with the result
and error
also attached as named
properties.
Works on both promises and sync callback functions.
import { Result } from '@reykjavik/webtools/errorhandling';
// Callback:
const [error, fooObject] = Result.catch(() => getFooSyncMayThrow());
// Promise:
const [error, fooObject] = await Result.catch(getFooPromiseMayThrow());
// Example of object property access:
const fooQuery = await Result.catch(getFooPromiseMayThrow());
if (fooQuery.error) {
console.log(fooQuery.error === fooQuery[0]); // true
throw fooQuery.error;
}
console.log(fooQuery.result === fooQuery[1]); // true
fooQuery.result; // Guaranteed to be defined
This function acts as the inverse of Result.throw()
.
Syntax:
Result.map<T, T2, E>(result: ResultTuple<T, E>, mapResult: (resultValue: T) => T2): ResultTuple<T2, E>
Helper to map a ResultTuple
-like object to a new ResultTupleObj
object,
applying a transformation function to the result, but retaining the error
as-is.
import { Result } from '@reykjavik/webtools/errorhandling';
const getStrLength = (str: string) => str.length;
const resultTuple =
Math.random() < 0.5 ? [new Error('Fail')] : [undefined, 'Hello!'];
const [error, mappedResult] = Result.map(resultTuple, getStrLength);
if (result) {
console.log(result); // 6
}
Syntax: Result.Success<T>(result: T): ResultTuple<T>
Factory for creating a successful ResultTupleObj
.
import { Result } from '@reykjavik/webtools/errorhandling';
const happyResult: Result.SuccessObj<string> =
Result.Success('My result value');
console.log(happyResult.error); // undefined
console.log(happyResult[0]); // undefined
console.log(happyResult.result); // 'My result value'
console.log(happyResult[1]); // 'My result value'
Syntax: Result.Fail<E extends Error>(err: T): ResultTuple<unknown, Err>
Factory for creating a failed ResultTupleObj
.
import { Result } from '@reykjavik/webtools/errorhandling';
const happyResult: Result.FailObj<string> = Result.Fail(new Error('Oh no!'));
console.log(happyResult.error.message); // 'Oh no!'
console.log(happyResult[0].message); // 'Oh no!'
console.log(happyResult.result); // undefined
console.log(happyResult[1]); // undefined
Syntax: Result.throw<T>(result: ResultTuple<T>): T
Unwraps a discriminated ResultTuple
-like [error, result]
tuple and throws
if there's an error, but returns the result otherwise.
import { Result } from '@reykjavik/webtools/errorhandling';
try {
const fooResults = Result.throw(await getFooResultsTuple());
} catch (fooError) {
// Do something with the error from `getFooResultsTuple()`
}
This function acts as the inverse of Result.catch()
.
Contains React helpers for loading SiteImprove's analytics scripts, and
perform page-view and custom event tracking in applications with client-side
(pushState
) routing.
A component for loading a SiteImprove analytics script and set up page-view tracking across URL routes.
It also automatically logs all out-bound link clicks, to match the behavior of the vanilla SiteImprove script.
Props:
The Component's props have detailed JSDoc comments (displayed in your code editor), but there's a brief summary:
-
accountId?: string
— Your SiteImprove account ID. (alternative toscriptUrl
prop). -
scriptUrl?: string
— The full SiteImprove analytics script URL. (alternative toaccountId
prop). -
hasConsented?: boolean
— Manual GDPR 'analytics' consent flag. Allows hard opt-out, but defers toCookieHubProvider
values if they are available. -
onLoad?: (e: unknown) => void
— Fires when the script has loaded. -
onError?: (e: unknown) => void
— Fires if loading the script failed.
Example usage somewhere in your application:
import { SiteImprove } from '@reykjavik/webtools/SiteImprove';
const siteImproveAccountId = '[ACCOUNT_ID]'; // e.g. "7654321"
// ...then inside your main App component
<SiteImprove accountId={siteImproveAccountId} />;
In dev mode it does NOT load the SiteImprove script and merely logs page-view events to the console.
Syntax:
pingSiteImprove(category: string, action: string, label?: string): void
A small helper for tracking custom UI events and reporting them to SiteImrove.
It safely manages GDPR consent, so you can use it unconditionally.
import { pingSiteImprove } from '@reykjavik/webtools/SiteImprove';
const handleSubmit = () => {
// perform submit action...
if (success) {
pingSiteImprove('application', 'add_new');
}
};
Syntax: pingSiteImproveOutbound(ourl: string): void
A small helper for reporting to SiteImrove when the user is programmatically being sent to a different URL/resource.
import { pingSiteImproveOutbound } from '@reykjavik/webtools/SiteImprove';
const handleSubmit = () => {
// perform submit action...
if (success) {
const fileUrl = '/download/report.pdf';
pingSiteImproveOutbound(fileUrl);
document.location.href = fileUrl;
}
};
Contains React helpers for loading CookieHub's consent manager and reading users' consent values.
This context provider component loads and initialises the CookieHub consent management script and sets up a React state object with the relevant user consent flags.
Wrap this provider around your component tree, and then use the
useCookieHubConsent()
hook to retrieve live consent
information, whereever you wish to set GDPR-affected cookies or perform any
sort of tracking/logging.
import { CookieHubProvider } from '@reykjavik/webtools/CookieHubConsent';
import { AnalyticsStuff } from '../components/AnalyticsStuff';
// Maybe move this to an Env variable, or something...
const cookiehubAccountId = '[ACCOUNT_ID]'; // e.g. "a4b3c2d1"
export default function App() {
return (
<CookieHubProvider accountId={cookiehubAccountId}>
<div>
<p>...my App UI...</p>
<AnalyticsStuff />
</div>
</CookieHubProvider>
);
}
Props:
The Component's props have detailed JSDoc comments (displayed in your code editor), but there's a brief summary:
-
accountId?: string
— Your CookieHub account ID. (alternative toscriptUrl
prop). -
scriptUrl?: string
— The full CookieHub embed script URL. (alternative toaccountId
prop). -
options?: CookieHubOptions
— Raw CookieHub options object that gets used when the script initializes. -
onError?: OnErrorEventHandlerNonNull
— Fires if loading the script failed.
Syntax: useCookieHubConsent(): Record<CookieHubCategory, boolean>
Returns up-to-date cookie consent boolean
flags. For use in React components
or hook functions.
import { useCookieHubConsent } from '@reykjavik/webtools/CookieHubConsent';
export const AnalyticsStuff = (props) => {
const consent = useCookieHubConsent();
if (!consent.analytics) {
return null;
}
// Perform analytics...
};
If the CookieHubProvider
is missing from the VDOM tree above your component,
this hook will return an empty object.
Contains helpers for writing vanilla-extract styles using plain CSS styntax.
This provides an "escape hatch" into regular CSS, when you're willing to trade local type-safety for access to the full features and expressiveness of real CSS. (Background info.)
Syntax:
vanillaClass(css: string | ((className: string, classNameSelector: string) => string)): string
Syntax:
vanillaClass(debugId: string, css: string | ((className: string, classNameSelector: string) => string)): string
Returns a scoped cssClassName styled with free-form CSS. This function is a
thin wrapper around vanilla-extract's style
function.
When you pass it a string, all &&
tokens are automatically replaced with the
selector for the auto-generated class-name. Note that in such cases EVERY
style property must be wrapped in a selector block.
To opt out of the &&
replacement, use the callback function signature.
// someFile.css.ts
import { vanillaClass } from '@reykjavik/webtools/vanillaExtract';
// Simple class selector block
export const myClass = vanillaClass(`
background-color: #ccc;
padding: .5em 1em;
`);
// With && tokens that get replaced with the generated class-name
export const myClasWithAmp = vanillaClass(`
&& {
background-color: #ccc;
padding: .5em 1em;
}
&& > strong {
color: #c00;
}
`);
// Passing a function to get the generated class-name for
// more complex styles.
export const myOtherClass = vanillaClass(
(classNameRaw, classNameSelector) => `
${classNameSelector} {
background-color: #ccc;
padding: .5em 1em;
}
[class="${classNameRaw}"] > strong {
color: #c00;
}
@media (min-width: 800px) {
${classNameSelector} {
background-color: #eee;
}
}
/* NOTE: '&&' tokens returned from a callback function are NOT replaced */
&& { will-not-be: interpolated; }
`
);
// With a human readable debugId
export const humanReadableClass = vanillaClass(
'HumanReadable',
`
border: 1px dashed hotpink;
cursor: pointer;
`
);
(NOTE: The dot-prefixed &&
pattern is chosen to not conflict with the bare
&
token in modern nested CSS.)
Syntax: vanillaGlobal(css: string): void
Inserts free-form CSS as a vanilla-extract globalStyle
.
// someFile.css.ts
import { vanillaGlobal } from '@reykjavik/webtools/vanillaExtract';
vanillaGlobal(`
body {
background-color: rebeccapurple;
}
`);
Syntax: vanillaProps(css: string): GlobalStyleRule
Returns an object that can be safely spread into a vanilla-extract style object, to inject free-form CSS properties (or nested blocks).
// someFile.css.ts
import { style } from '@vanilla-extract/css';
import { vanillaProps } from '@reykjavik/webtools/vanillaExtract';
const myStyle = style({
color: 'darksalmon',
// ...other style props...
...vanillaProps(`
/* Plain CSS that's injected into the "myStyle" style block */
border-bottom: 1px solid red;
color: ${theme.color.primary}; /* I can still use typesafe values */
random-css-prop-normally-rejected-by-vanilla-extract: 'YOLO!';
`),
});
See README-remix.md for helpers and components specifically designed for use in Remix.run projects.
See README-nextjs.md for helpers and components specifically designed for use in Next.js projects.
This project uses the Bun runtime for development (tests, build, etc.)
PRs are welcome!
See CHANGELOG.md