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

evolit

v0.4.3

Published

A convention-driven application framework for LitSX and web components.

Readme

evolit

evolit is a convention-driven application framework built around LitSX and web components.

This repository now contains the first framework MVP:

  • file-based routing from app/
  • nested layout composition
  • server rendering through @litsx/ssr
  • a small evolit CLI with init, dev, build, and start
  • on-demand compilation of authored .jsx modules through @litsx/compiler
  • a starter template for generating new sites

MVP Scope

The current implementation is intentionally narrow.

It focuses on the core contract that matters first:

  • route modules live in app/**/page.*
  • layout modules live in app/**/layout.*
  • page and layout modules export a default async function
  • pages receive { params, searchParams, navigationContext, request }
  • layouts receive { children, params, searchParams, navigationContext, request }
  • SSR document rendering is delegated to @litsx/ssr

Supported authored module extensions:

  • .js
  • .jsx
  • .ts
  • .tsx
  • .mjs

Internal imports

Imports beginning with @/ resolve from the application root in server and browser graphs. This keeps internal imports stable when modules move between route directories:

import FeatureCard from "@/app/components/feature-card";
import { formatPrice } from "@/lib/format-price";

Generated applications declare "@/*": ["./*"] in jsconfig.json, so editors and typechecking use the same convention. An explicit @/* mapping in jsconfig.json or tsconfig.json takes priority when an application needs a different source root.

Interpolated dynamic imports

Relative dynamic imports may use a template literal when every possible module can be discovered from the literal path at build time:

const module = await import(`./templates/${locale}/${name}.tsx`);

Evolit treats each interpolation as one wildcard that matches a single path segment. The example above discovers ./templates/*/*.tsx, compiles every matching module, and emits a finite runtime dispatcher whose cases preserve the original source specifiers. Interpolations never cross a /, and there is no framework-imposed candidate limit.

The template must be relative, end in a supported authored module extension, and match at least one module during compilation. A runtime value that was not among the discovered candidates rejects the import. Development watches the matched directory tree, so adding a new candidate invalidates the affected graph. Arbitrary computed specifiers that do not use this template-literal form remain outside the statically discoverable graph.

Package CSS and static assets

Applications can import stylesheets exposed through package exports in the same way as local stylesheets:

import "@scope/design-system/tokens.css";
import "@scope/design-system/theme.css";

Evolit resolves package subpaths with ESM import conditions, emits exported CSS through the route static-asset pipeline, and adds the resulting stylesheet URLs to the rendered document. Relative @import rules and url(...) references inside package CSS are emitted and rewritten from their location within node_modules. Bare package imports that resolve to JavaScript continue to use the shared vendor runtime; hydration metadata uses that same canonical package URL so an external component is evaluated only once. CSS and other static assets do not enter vendor chunks.

Packages declared by the application remain package dependencies even when a workspace manager links them to sources elsewhere in a monorepo. Evolit uses the declared package name and its browser exports subpath as the public identity; the physical symlink or real filesystem path is used only to verify package ownership and is never exposed as an __unmanaged__ client module. During evolit build, a package reached by the production SSR graph that is declared only in devDependencies produces a warning. Its package identity is preserved, but applications should move it to dependencies or keep development dependencies installed in the production runtime.

Commands

yarn install
npx -p evolit@alpha evolit my-site

Additional commands:

cd my-site
yarn install
yarn dev

Framework commands inside a generated site:

yarn build
yarn start

Template

The framework ships a starter at templates/default.

evolit <directory> copies that template, writes a site package.json, and leaves the app ready to run. evolit init <directory> is supported as an explicit equivalent. The npx -p … evolit form keeps the CLI arguments unambiguous across npm versions.

Architecture

The current runtime is split into a few small layers:

  • src/app-discovery.js: scans app/ and builds the route table
  • src/compiler.js: compiles LitSX-authored modules into .evolit/
  • src/render.js: resolves route modules and builds the route render tree
  • src/ssr-adapter.js: internal boundary around @litsx/ssr
  • src/server.js: serves HTTP requests
  • src/scaffold.js: creates new site projects from templates
  • src/cli.js: framework entrypoint

Client Navigation

Evolit hydrates a small browser router automatically for SSR page documents. It requests a route delta, replaces only the changed route segment, and keeps parent layouts mounted when possible. Plain <a> elements remain valid SSR HTML; same-origin links are progressively intercepted after hydration.

Use useNavigation() inside a LitSX browser component for imperative navigation and pending UI:

import { useNavigation } from "evolit/navigation";

export default function CollectionControls() {
  const navigation = useNavigation();

  function changeSort(event) {
    const searchParams = new URLSearchParams(window.location.search);
    searchParams.delete("page");
    searchParams.delete("skip");
    searchParams.set("sort", event.target.value);
    navigation.push(navigation.createHref("/explore/home-garden", searchParams));
  }

  return <select onChange={changeSort} disabled={navigation.status === "pending"}>…</select>;
}

The hook returns { status, url, pendingUrl, error, context, push, replace, replaceContext, refresh, createHref }:

  • push(target, { scroll: false }) adds a browser-history entry. Pass scroll: false to keep the current viewport position.
  • replace(target, { scroll: false }) updates the current entry, useful for visual-only query state. It accepts the same scroll option.
  • push(target, { context }) and replace(target, { context }) attach JSON-safe transient state to that history entry and make it available to the destination page and layouts as their navigationContext prop during SSR.
  • replaceContext(contextOrUpdater) updates only the current history entry and hook state. It does not change the URL or fetch a route delta.
  • refresh() bypasses the client delta cache for the current URL.
  • createHref(pathname, searchParams) creates a relative internal URL. It accepts standard URLSearchParams, preserving repeated keys such as facet=brand&facet=material.

createHref can also be imported directly from evolit/navigation; it is browser-free and safe to share with server-evaluated route code. useNavigation() itself is browser-only and must only run from a connected client component.

Navigation context is intended for compact UI state that must survive back/forward navigation but does not belong in the public URL. It must be a JSON-safe object and is limited to 8 KiB after UTF-8 serialization. Evolit includes it in browser and response-cache identity. Segment caching and delta identity track only the top-level context keys each layout or page reads, so unrelated context changes preserve mounted layouts while consumers still update. Invalid context is rejected at the client boundary; malformed context received by the server is ignored.

Client components can also read the active route state with browser-only hooks:

import { useParams, useSearchParams } from "evolit/navigation";

const { slug = [] } = useParams();
const searchParams = useSearchParams();
const selectedFacets = searchParams.getAll("facet");

Both hooks update after client navigation. useParams() returns a read-only snapshot; the URLSearchParams from useSearchParams() is a local snapshot, so build a new href and navigate to it to update the URL.

Development refresh

In development, each browser subscribes its active URL over the Evolit WebSocket. After source invalidation, the server renders the fresh SSR representation once per request context and pushes the resulting delta directly to its subscribers. No follow-up browser request is needed. Server-only module and static-asset changes update only the affected route segment while preserving the document, scroll position, and persistent layouts. Changes to a hydrated client boundary rebuild its browser artifact and update the stable Evolit development proxy registered for that tag. Existing instances receive the new implementation without redefining the Custom Element or replacing the document. Invalid deltas and failed hot updates still fall back to a document reload.

Progressive links and forms

Links work without JavaScript. With JavaScript, Evolit intercepts ordinary same-origin left-clicks. Set data-evolit-navigation="false" on a link to keep native navigation.

Internal <form method="get"> elements are treated the same way: their successful controls become URLSearchParams and navigate through a delta. Without JavaScript the browser submits the exact same GET form normally. Evolit intentionally does not intercept POST, file-upload, external, targeted, or opted-out forms.

Navigation cache and document updates

The browser cache is scoped to browser-history entries, not a global URL map. Going back or forward can reuse the delta for that exact entry; opening a new branch after going back discards its known forward branch. This avoids an unbounded catalog cache in a long-lived tab.

  • dynamic routes are never cached in the browser.
  • revalidate entries remain reusable only until their route TTL expires.
  • static entries remain reusable while their history entry exists in the current tab session.

Each delta also synchronizes route <title>, route-specific <head> markup, managed styles and module preloads, html/body attributes, scroll position, hash targets, and focus. If a response cannot be represented as an Evolit delta —for example a 404 from another adapter— navigation falls back to a normal document load.

Route Cache Policies

Route modules can export a routeConfig object with a cache policy:

export const routeConfig = {
  cache: "dynamic",
};
export const routeConfig = {
  cache: "static",
};
export const routeConfig = {
  cache: { revalidate: 300 },
};

evolit currently normalizes those policies to:

  • dynamic: render on every request
  • static: prerender in build and serve from the response cache in start
  • revalidate: cache the HTML response for N seconds and regenerate on expiry

Pages without routeConfig.cache default to { revalidate: 60 }. This caches a normal SSR render by pathname and query string while keeping content fresh without requiring a cache declaration on every catalog or CMS page. Declare cache: "static" for fully static pages or cache: "dynamic" when a page must always render per request.

The same semantics work in local development and in production runtimes. Only the backing cache store changes.

The default cache key includes the pathname and query string, so params and searchParams are cacheable by URL. Reading the request prop, request headers, cookies, or requestUrl() makes the completed render dynamic; it is never stored in the HTML response cache. routeConfig.cache is the sole authority for HTML caching; setting a Cache-Control response header does not alter that policy.

Request APIs

Server pages and layouts can access the active Web Request context through evolit/server:

import {
  cookies,
  getRouteState,
  headers,
  notFound,
  permanentRedirect,
  redirect,
  requestUrl,
  responseHeaders,
} from "evolit/server";

export default async function AccountPage() {
  const { params, searchParams, navigationContext } = getRouteState();

  if (!cookies().has("session")) {
    redirect("/sign-in");
  }

  responseHeaders().set("x-account-page", "1");
  return `<p>${headers().get("user-agent")} ${requestUrl().pathname} ${params.account ?? ""}</p>`;
}

cookies() can read, set, and delete cookies; mutations are emitted as Set-Cookie response headers. redirect() and permanentRedirect() end rendering with 307 and 308 responses, and notFound() renders a 404. Reading headers, cookies, or the request URL makes the completed render dynamic, so it is not stored by the route response cache.

getRouteState() is the request-scoped server counterpart for reading the active route from nested server components and helpers that do not receive route props directly. It returns the current { url, params, searchParams, navigationContext } as a read-only snapshot. It deliberately has no push, replace, refresh, pending state, or history mutation methods: those belong to the browser useNavigation() API. Redirects and notFound() remain separate server control-flow functions. Reading getRouteState().url has the same dynamic-rendering semantics as requestUrl(); reading params and searchParams participates in the normal segment-cache key tracking.

LitSX compiler integrations

evolit.config.js can configure the native LitSX compiler and register build-tool-neutral LitSX integrations. The same declaration is used by development, SSR, hydration, production builds and the standalone runtime:

import { litsxUnoCss } from "@litsx/unocss";
import { defineEvolitConfig } from "evolit/litsx";

export default defineEvolitConfig({
  litsx: {
    compiler: {
      sourceMaps: true,
    },
    integrations: [litsxUnoCss()],
  },
});

This is the complete Evolit + UnoCSS setup. The application does not coordinate compiler plugins, virtual modules, generation, finalization or global CSS links, and it does not need Vite or PostCSS. @litsx/unocss remains the owner of candidate extraction and CSS generation; Evolit only executes its neutral LitSX lifecycle.

Tailwind CSS uses the same neutral integration contract, without an Evolit adapter or application owned Vite/PostCSS pipeline:

import { litsxTailwind } from "@litsx/tailwind";
import { defineEvolitConfig } from "evolit/litsx";

export default defineEvolitConfig({
  litsx: {
    compiler: { sourceMaps: true },
    integrations: [
      litsxTailwind({ integration: { entry: "./tailwind.css" } }),
    ],
  },
});
/* tailwind.css */
@import "tailwindcss" source(none);

@theme {
  --color-brand: oklch(62% 0.18 255);
}

source(none) is recommended because LitSX owns candidate discovery and routes each finite class to its component. @litsx/tailwind owns Tailwind compilation, virtual modules, dependency invalidation, stale-candidate removal and the single document stylesheet.

compiler accepts the public TransformLitsxOptions surface except the values Evolit must own for correct server and browser graphs. Evolit always supplies the current filename, selects ssr per target and forces native lowering with reactCompat: false; compiler.sourceMaps can override the existing per-pipeline default without changing applications that omit litsx. Authoring and output plugins contributed by the application and integrations are composed in declaration order rather than replacing framework invariants.

An integration is a descriptor with a unique name and create(context) method. Each call to create belongs to exactly one development server, build or standalone runtime, so mutable caches must live on the returned instance, never on the descriptor. An instance can contribute:

  • compiler: native compiler options, including authoring and output plugins.
  • resolveModule: sources for integration-owned virtual modules in both server and browser graphs.
  • processModule: post-processing, observable dependencies and preliminary outputs for a compiled module.
  • finalize: graph-wide assets, styles or modules after every reachable module is known.
  • invalidate and forget: configuration/dependency refresh and removal of stale module state.
  • dispose: idempotent cleanup when the owning server, build or runtime closes.

Declared outputs use stable IDs unique within the pipeline and one of asset, module or style. A module may expose a safe virtual: specifier; a style with document: true is content-hashed, included in the client asset manifest and linked once in the document. Evolit publishes a generation only after every integration finalizes successfully, so a failed generation cannot expose a partial mix of old and new assets.

In development, dependencies reported by compiler plugins and lifecycle hooks are watched together with application modules. Invalidating a dependency advances the integration generation, evicts affected compiled modules and calls forget for modules that leave the graph. Configuration reload therefore removes obsolete results instead of accumulating them. Hook failures identify the integration, lifecycle phase and relevant module in development; production errors keep the same context while redacting project-local absolute paths.

For native Shadow DOM, @litsx/unocss serializes component preflight inside each Declarative Shadow Root, preserves authored Component.styles before generated utilities, and emits the document theme/custom-property layers once. Those document variables inherit through Shadow Roots and the browser hydrates the server result without switching to react-compat.

@litsx/tailwind follows the same ordering and ownership rules: Shadow Root preflight, authored Component.styles, then component-owned utilities. Its global.css output contains theme/custom properties, document preflight, Tailwind property registrations and light-DOM utilities exactly once. Component utilities are not copied into that document asset.

Server Setup

Server integrations that need one-time application setup can be registered with server.setup. A module specifier is compiled as part of the server graph, so the setup may be authored in JavaScript or TypeScript:

// evolit.config.js
export default {
  server: {
    setup: "./src/server/setup.ts",
  },
};

The module exports setup (or a default function). Evolit invokes it once per runtime and once before build-time prerendering with { projectRoot, mode }. It may return a cleanup function or { dispose() }; runtime cleanup runs from runtime.close().

When @litsx/urql is installed, Evolit opens its SSR resource around the whole route render and supplies { request, responseHeaders }. Request-derived URQL configuration therefore stays isolated per render, extracted SSR data is read before the scope closes, and integration response headers are included before the response is cached.

Extensions

Optional integrations are configured explicitly in evolit.config.js. Core only coordinates their request and browser-navigation lifecycles: it has no knowledge of tenants, locales, catalogues, or message formats. Plugins run in declaration order. Each request hook receives the URL produced by the preceding hook; a rewrite continues the chain, while a redirect or Response stops it.

// evolit.config.js
import { defineEvolitPlugin } from "evolit/extensions";

export default {
  plugins: [defineEvolitPlugin({
    name: "tenant-prefix",
    onRequest({ pathname, headers, set }) {
      const tenant = headers().get("x-tenant") ?? "public";
      set("tenant", tenant);
      if (pathname === "/shop") return { rewrite: `/tenants/${tenant}/shop` };
    },
    client: {
      module: "@example/evolit-tenant/client",
      options: { prefix: "/tenants" },
    },
  })],
};

onRequest is server-only. It can read the Request, headers, cookies and URL, set JSON-serializable request values, rewrite internally, or return { redirect, status? } / a Web Response. Reading request-bound data marks the request dynamic. Server components and handlers read the values without prop drilling:

import { getRequestContext } from "evolit/server";

export default async function TenantPage() {
  const { tenant } = getRequestContext();
  return `<main>Tenant: ${tenant}</main>`;
}

The client.module is a bare package specifier and is the only extension code included in the browser build. It must export a named navigation object (or default) with optional hooks:

// @example/evolit-tenant/client
export const navigation = {
  // Synchronous and idempotent: it may run for generated links and intercepted links.
  transformUrl({ url, options }) {
    return url.startsWith(options.prefix) ? url : `${options.prefix}${url}`;
  },
  async beforeNavigate({ url }) {
    if (url.endsWith("/blocked")) return false;
  },
  afterNavigate({ from, url }) {
    // Browser-only analytics or state synchronization.
  },
};

transformUrl runs for createHref() and before a SPA navigation; beforeNavigate can transform or cancel a navigation, and afterNavigate runs after its delta is applied. Hooks are sequential and later hooks receive the URL returned by earlier hooks. Browser modules must not import server code. Request values are isolated with async request context and are discarded when rendering ends.

Route Boundaries

not-found.jsx and error.jsx are resolved from the current route directory up to app/. The nearest file wins and its output is wrapped by the route layouts. A root app/not-found.jsx also handles unmatched URLs; without one, evolit returns its minimal built-in 404 document.

// app/blog/error.jsx
export default async function BlogError({ error }) {
  return `<p>Could not load this post: ${error.message}</p>`;
}

Boundaries always bypass the route response cache. loading.jsx is intentionally not supported yet: it requires an end-to-end streaming document transport rather than an HTML-string fallback. In production, error.jsx receives a generic error with an opaque digest; the original error is reported only on the server.

Route Handlers

route.js, route.ts, and the other supported JS/TS module variants expose HTTP endpoints from app/. Export a function named after each supported method and return a standard Web Response:

// app/api/status/route.js
import { cookies, responseHeaders } from "evolit/server";

export async function POST(request, { params, searchParams }) {
  const body = await request.json();
  cookies().set("last-action", "status", { httpOnly: true });
  responseHeaders().set("x-api-version", "1");
  return Response.json({ params, searchParams, body });
}

Handlers receive a Web Request and { params, searchParams }. They are always dynamic, bypass the HTML response cache, and return 405 Method Not Allowed with Allow when the requested method is not exported. A handler and page.* cannot coexist in the same segment.

Dynamic Route Prerendering

Dynamic routes can opt into build-time prerendering by exporting generateStaticParams() from layouts and/or pages:

export async function generateStaticParams() {
  return [
    { slug: "guide" },
    { slug: "changelog" },
  ];
}

For nested dynamic routes, parent layouts and child pages compose through params, following the same general model as the Next.js App Router:

export async function generateStaticParams({ params }) {
  return params.category === "books"
    ? [{ slug: "guide" }]
    : [];
}

evolit build uses those params to prefill the route cache for concrete pathnames. Paths that were not prerendered still fall back to normal runtime rendering and cache population according to the route cache policy.

Response Cache Adapters

By default, evolit uses:

  • in dev: an in-memory response cache
  • in start: a filesystem-backed response cache under .evolit/build/route-cache

That runtime is configurable through evolit.config.js:

export default {
  responseCache: {
    async createStore({ projectRoot, mode, defaultStore }) {
      return defaultStore;
    },
    createKey({ request, routeResult }) {
      return new URL(request.url).pathname;
    },
  },
};

For monorepos, development watches only the application project by default. Additional source trees can be opted into explicitly; generated output and dependency directories below every root remain ignored:

export default {
  development: {
    managedSourceRoots: ["../packages/design-system/src"],
  },
};

Imports outside these roots remain unmanaged and produce the existing development warning.

Evolit normally discovers browser boundaries from the static module graph and reconciles that inventory with the components actually emitted by SSR. A genuinely computed import cannot be enumerated at build time, so production applications can declare only those exceptional modules:

export default {
  clientBoundaries: ["./src/components/dynamic-card.jsx", "@acme/ui/product-card"],
};

These modules are materialized as build artifacts, but are not automatically imported or preloaded. They reach the browser only when an SSR hydration root references them.

The framework also exports ObjectStorageResponseCacheStore, which is intended for object-store backends such as S3. A concrete app can wire it to the AWS SDK without pulling AWS dependencies into evolit itself.

Example shape:

import { ObjectStorageResponseCacheStore } from "evolit";
import {
  DeleteObjectCommand,
  GetObjectCommand,
  PutObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: process.env.AWS_REGION });
const bucket = process.env.EVOLIT_CACHE_BUCKET;

export default {
  responseCache: {
    async createStore() {
      return new ObjectStorageResponseCacheStore({
        prefix: "routes",
        async getObject(key) {
          try {
            const result = await s3.send(new GetObjectCommand({
              Bucket: bucket,
              Key: key,
            }));
            return await result.Body.transformToString();
          } catch (error) {
            if (error?.name === "NoSuchKey") {
              return null;
            }
            throw error;
          }
        },
        async putObject(key, value) {
          await s3.send(new PutObjectCommand({
            Bucket: bucket,
            Key: key,
            Body: value,
            ContentType: "application/json; charset=utf-8",
          }));
        },
        async deleteObject(key) {
          await s3.send(new DeleteObjectCommand({
            Bucket: bucket,
            Key: key,
          }));
        },
      });
    },
    createKey({ request }) {
      return new URL(request.url).pathname;
    },
  },
};

That is the intended path toward deployments where:

  • CloudFront fronts public traffic
  • S3 stores prerendered/static route responses and cacheable regenerated HTML
  • Lambda renders only on cache misses or revalidation events

Deployment Manifests

evolit build emits deploy-routes.json as a platform-neutral routing contract. Version 2 contains separate collections for HTML routes and HTTP handlers:

{
  "version": 2,
  "routes": [
    { "pathname": "/news/:slug", "cache": { "revalidate": 60 } }
  ],
  "handlers": [
    {
      "pathname": "/api/echo/:slug",
      "methods": ["GET", "POST"],
      "runtime": "server",
      "cache": "dynamic"
    }
  ]
}

The framework owns this manifest schema. A hosting-specific adapter can use it to route static assets and cached HTML to object storage, while always sending declared handlers to server compute.

SSR Status

evolit now renders route/layout trees through @litsx/ssr.

The current integration uses renderDocument(...) to produce the final HTML document for each route. That gives the framework:

  • LitSX-authored SSR rendering
  • framework-owned metadata to document-shell mapping
  • LitSX-owned hydratable module registration through @litsx/ssr/hydration
  • a stable internal adapter boundary for future evolution

The remaining SSR work in evolit is mostly framework-specific:

  • client asset pipeline and public client URL resolution
  • minimal framework bootstrap assembly around LitSX hydration primitives
  • asset resolution into public browser URLs
  • richer metadata and head management
  • streaming responses

Next Steps

This MVP is enough to validate the direction. The likely next milestones are:

  • dev HMR instead of request-time recompilation
  • data loading primitives
  • middleware and rewrite rules
  • public asset directory support
  • hosting-specific deployment adapters