npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ascendenceai/cortena-extensions-shared-web

v0.4.0

Published

The web half of the extension boilerplate: URL-first list state, so view, filter, sort, page and selection survive navigation and reload (§8).

Readme

@ascendenceai/cortena-extensions-shared-web

The web half of the extension boilerplate. @ascendenceai/cortena-extensions-shared is the backend half: it has an Express peer, a Drizzle peer and a cortena-openapi binary, and its tarball is installed by extensions/*/functions. React does not belong in it, and no web app imports it today. So the browser code lives here, next to @ascendenceai/cortena-extensions-web-auth, with React as a peer and react-router as an optional one.

Today it carries §8 of the protocol: a list's view mode, filters, sort, page and selection survive navigation, Back, reload and being pasted into another tab.

pnpm add @ascendenceai/cortena-extensions-shared-web

The rule

The example that produced it: open Tasks, switch to List, open a task, close it — and you land on Board. One small bug, on every single interaction.

  • URL first. View mode, filters, sort, page and everything else that fits go in the search params, so the URL is the view. Nothing that survives a navigation lives in component state.
  • The list route owns the state. The detail opens as a nested route over the list (/tasks?view=list → /tasks/42?view=list), so the list is never unmounted and the URL is still shareable. A Sheet over the list is allowed; the nested route is the default.
  • Never navigate(-1). It breaks deep links, and after a delete it lands the user on the thing they just deleted. useReturnToList() builds a real href.
  • Only what cannot fit falls back to the browser. Selection can run to hundreds of ids, so it is kept per user, per route, in localStorage under cortena.list-state.<routeKey>, and the URL carries a short marker.

useListState(routeKey, schema)

import { useListState } from '@ascendenceai/cortena-extensions-shared-web';
import { z } from 'zod';

const listState = z.object({
  view: z.enum(['board', 'list']).default('board'),
  q: z.string().default(''),
  sort: z.string().default(''),                       // "name,-createdAt"
  page: z.coerce.number().int().min(0).default(0),    // zero-based, like TableQuery
  pageSize: z.coerce.number().int().min(1).max(200).default(50),
  selected: z.array(z.string()).default([]),
});

export function TasksPage() {
  const [state, setState] = useListState('tasks', listState, {
    store: { selected: 'local' },
  });
  …
}

setState merges: setState({ page: 0 }) leaves the filters where they are. It also takes an updater — setState((s) => ({ page: s.page + 1 })) — and a per-call history mode.

The schema is deliberately the same shape as TableQuery (§10), so a list's URL and its server query are one object rather than two that drift.

Options

| option | what it does | | --- | --- | | store | { selected: 'local' } keeps a field in localStorage instead of the URL. .meta({ store: 'local' }) on the field says the same thing where the zod version in use carries metadata. | | history | 'replace' (default) or 'push' for every write. Replace keeps a filter keystroke out of the history stack, so Back leaves the list rather than unwinding typing one character at a time. Pass { history: 'push' } on a call the user should be able to undo with Back. | | adapter | The router. Defaults to the provider's, then to window.location + history. | | storage | Injectable for tests; null turns persistence off. | | onInvalid | Called with the names of params the schema refused, for a log line. | | markers | Whether a local field leaves <key>=~<n> in the URL. On by default. |

The encoding

A list URL is a public interface, so the same state produces the same string in every build:

/tasks?view=list&q=alpha+beta&sort=name,-createdAt&page=2&selected=~3
  • A field equal to its default is absent — a clean URL is the default view, and a shared link carries only what the user actually changed.
  • Strings ride raw. Numbers are digits. Booleans are 1 / 0 (true, yes, on are read too, because people type those by hand).
  • Arrays are a comma list, an item's own comma escaped \,. This is the encoding §10's parseTableQuery already reads.
  • Anything else is JSON.
  • A field held in the browser carries only ~<n>, the count. Opening that link in another browser gives the field's default, not an error.
  • Keys come out in schema order, and any param the schema does not own is kept after them in the order it arrived.
  • A value the schema refuses falls back to that field's default and the rest of the URL survives — one stale link must never blank a screen. When the whole object is refused and the schema has a field with no default (a scoped list's orgId: z.string()), there is no valid empty state to fall back to, so the individually-valid fields are handed back and every one of them is reported to onInvalid. Decoding a URL never throws out of a render.

usePersistedViewState(routeKey, schema)

The half that has no business in a link — column visibility, density, panel widths. Per user, per route, in the browser store, with nothing at all in the URL.

Routers

Every Cortena extension web app is on react-router, so mount the adapter once, inside the router and above the routes:

import { ReactRouterListState } from '@ascendenceai/cortena-extensions-shared-web/react-router';

<BrowserRouter>
  <ReactRouterListState>
    <Routes>…</Routes>
  </ReactRouterListState>
</BrowserRouter>

Without a provider the hooks fall back to window.location + history, which works in an app with no router at all. Anything that can read a location and write one is an adapter — { pathname, search, push, replace, subscribe? } — so a test can pass a stub.

Opening a detail

<Route path="tasks" element={<TasksList />}>       {/* renders <Outlet /> */}
  <Route path=":taskId" element={<TaskDetail />} />
</Route>
import { Link } from 'react-router';
import { ListStateLink, useReturnToList } from '@ascendenceai/cortena-extensions-shared-web';

// in the list — the whole list state comes along
<ListStateLink to={`/tasks/${row.id}`} as={Link}>{row.title}</ListStateLink>

// in the detail — the way back, and the post-delete redirect
const back = useReturnToList();          // href, to, search, navigate()
<Button onClick={back.navigate}>Close</Button>
await deleteTask(id); back.navigate();

useReturnToList() with no argument goes up one path segment, which is the nested-route case. A detail that has to be a sibling route takes the origin in the link (<ListStateLink to="/task/42" params={{ from: '/my-tasks' }}>) and useReturnToList() reads ?from; or you pass the list path explicitly. What it never does is navigate(-1).

?from is attacker-controlled — it arrives in a link somebody else wrote — so it is honoured only when it is a same-origin path: one leading slash, not followed by another slash or a backslash (isSameOriginPath). //evil.com is a protocol-relative URL and /\evil.com is the same thing after the browser normalises the backslash; both look like paths to a startsWith('/') check and both leave the origin, which is an open redirect on a Close button. Anything that fails the check is dropped and the parent segment wins.

ListStateLink with no as renders a real anchor: middle-click and "open in a new tab" give a working URL, which is the entire point of holding the state there. A plain left click goes through the adapter, so the list is not torn down and rebuilt.

DataTable

DataTable's server source (§10) takes the same three fields this hook holds, so the list's URL and its query are one object:

const [state, setState] = useListState('tasks', listState, { store: { selected: 'local' } });

<DataTable
  dataSource={{ kind: 'server', fetch: (query, signal) => api.tasksTable(query, signal) }}
  query={{ q: state.q, sort: parseSort(state.sort), page: state.page, pageSize: state.pageSize }}
  onQueryChange={(query) => setState({ q: query.q ?? '', sort: formatSort(query.sort), page: query.page, pageSize: query.pageSize })}
  rowSelection={state.selected}
  onRowSelectionChange={(selected) => setState({ selected })}
/>
  • sort rides as name,-createdAt and parseTableQuery on the server reads exactly that, so parseSort / formatSort are the only translation, and a schema field of z.array(z.object({ id: z.string(), desc: z.boolean() })) removes even that at the cost of a JSON-encoded param.
  • Reset page: 0 in the same setState as a filter change; one write, one history entry, no flash of page 7 of a 2-page result.
  • Selection goes through the same hook, which is what keeps it out of the link.

Test recipe

src/__tests__ is the recipe, and it is meant to be copied into an extension: set the view, open a detail, close it, assert unchanged; press Back, assert the exact prior state; reload, assert unchanged; paste the URL into a new tab, assert the same view, filter, sort and page; and a URL the schema refuses falls back to the defaults rather than an empty screen. Audit rule P-09 checks the same thing from the outside.

Runtime configuration (§19.2, P-27)

// src/config.ts
import { z } from 'zod';
import { readRuntimeConfig } from '@ascendenceai/cortena-extensions-shared-web';

export const config = readRuntimeConfig(
  z.object({
    AUTH_SERVICE_URL: z.string().url(),
    API_BASE_URL: z.string().default('/v1'),
  }),
  // `pnpm dev` only — and `allowFallback` is what says so out loud.
  { fallback: { AUTH_SERVICE_URL: 'http://localhost:3200' }, allowFallback: import.meta.env.DEV },
);

The backend serves /runtime-config.js — one assignment to window.__CORTENA_RUNTIME_CONFIG__, read out of the pod's environment, which is the ConfigMap (serveRuntimeConfig in @ascendenceai/cortena-extensions-shared; the Helm snippet and the checksum/config annotation are in that package's README). index.html loads it before the module bundle.

Why a schema rather than reading the global directly: the failure it replaces is silent. A VITE_ variable that was never set becomes the string undefined in the bundle, and the first symptom is a fetch to undefined/v1/orgs/… three screens in, blamed on the screen. This throws at boot, naming the key and the ConfigMap it comes from.

Why fallback rather than reading import.meta.env: pnpm dev has no backend serving the file, so something has to fill the gap — but reading import.meta.env here would put the build-time substitution back, and it would work in production too, which is how it would survive review. A fallback is plainly a development default, in source, where a reviewer sees it, and the served file wins over it every time.

Why allowFallback is a second, separate option: an empty window.__CORTENA_RUNTIME_CONFIG__ is pnpm dev and a pod that lost its <script> tag, its ConfigMap or its serveRuntimeConfig route. A fallback alone cannot tell them apart, so the second case boots looking healthy and talks to localhost from a real browser. An empty global therefore throws unless the caller passes { allowFallback: true }. It gates only the empty case: a served file that is missing a key throws either way, because the deployment plainly meant to configure the app and got it wrong.