Hyperlinkv0.8.0-beta.28

Modules & Boundaries

How the code is split into modules, what each one exposes, and how the public and browser-safe boundary is kept. Covers module layout, the public/internal line, and build & tree-shaking.

A public module is one file with flat exports

src

One public module = one file in a role folder (usually src/), consumed as import * as Name. Members are flat top-level export const / function / type — never grouped under an object.

// consumer
import * as QueueHyperlink from "hyperlink-ts/QueueHyperlink"
QueueHyperlink.Tag        // pulls zero engine code
QueueHyperlink.serve      // pulls the engine only when used

The filename matches what it exports

src

The filename is the name of its primary export. Usually thats a PascalCase namespace (QueueHyperlink.ts), but its camelCase when the modules export is a value — a layer, an effect, a helper (Effects internal/cache.ts). No orphan files that export nothing of that name.

Banned: *Contract, *Namespace, and object-engine files — a monorepo-wide search of Effect finds zero *Contract files. Name by role/noun, like Effects RpcServer / RpcClient / RpcGroup.

// ❌ bad — orphan name; exports nothing called QueueContract
// src/QueueContract.ts

// ✅ good — named for its export
// src/QueueHyperlink.ts   (exports the QueueHyperlink namespace)

Never an object-as-namespace for the public surface

src

Effect has no export const Effect = { … }; Effect.ts is flat export const succeed, etc. Match it — an object engine defeats member-level tree-shaking.

// ❌ bad — object-as-namespace
export const QueueHyperlink = { Tag, make, layer, serve }

// ✅ good — flat exports, re-exported once from the barrel (src/index.ts)
export * as QueueHyperlink from "./QueueHyperlink"

Associated types attach in the same file

src

Type-level helpers live beside their value via export declare namespace Name { … } — as Effect does in HashMap.ts — not a separate *Types.ts. The declare namespace merges with the value namespace and the primary type under one name, so a module is a value, a type, and a home for member types at once.

// src/Hyperlink.ts
export const client = /* … */
export declare namespace Hyperlink {
  export type ServiceOf<S> = /* … */
}

A data-type module names its type after the module

src

When a module is a single data type, the type takes the modules own name, so it reads Module.Module<…>Effect.Effect<A, E, R>, Option.Option<A>, HashMap.HashMap<K, V>. The value namespace (the modules functions) and the type merge under that one name; consumers import * as Module and reach both.

Heavy implementation lives in an internal module with a matching name

src

The public Foo.ts is a thin re-export shell over an internal implementation whose filename mirrors it (Effect: Cache.tsinternal/cache.ts). Internal modules are camelCase, get no subpath, and are never imported by apps.

// src/QueueHyperlink.ts (public shell)
import { makeQueueEffect } from "./internal/queueHyperlink"   // engine implementation

Subpaths never resolve into internal/

src

hyperlink-ts/Name resolves to the public src/Name.ts, surfaced via the barrel export * as Name from "./Name" — one line per module. It must not resolve to src/internal/*.

Public is what apps import; internal is package-only

src

Public = a symbol an app imports, via hyperlink-ts, a documented subpath, or a bin entry. Internal (src/internal/) = package-only wiring: never exported from the barrel, no subpath, never imported by an app.

// ✅ public
import * as Process from "hyperlink-ts/Process"

// ❌ internal — apps must never reach here
import { makeQueueEffect } from "hyperlink-ts/internal/queueHyperlink"

Never split a namespace to escape file size

src

Size is not a reason to fan one namespace across public files. A namespace is always one public file, however large — the growth goes to internal/ (heavy implementation, per Module layout) or to a separate concern (below), never to a second public file for the same namespace.

A 15,000-line module is fine: Effects Effect.ts and Schema.ts are each ~15k lines in a single file.

A distinct concern becomes its own sibling namespace

src

When a separable concern grows, split it into a sibling module named by a shared prefix — its own file, namespace, and import — not sub-sections of a mega-namespace.

Schema sits beside SchemaAST, SchemaParser, SchemaIssue, SchemaGetter, SchemaTransformation, SchemaRepresentation; Rpc beside RpcClient, RpcServer, RpcGroup, RpcSchema. Related by name, independent as modules.

A domain family is a subdirectory with its own barrel and internal/

src

A group of related modules that ship as one import surface lives in a subdirectory that is a single subpath, with its own index.ts barrel and its own internal/.

unstable/rpc/ = Rpc.ts + RpcClient.ts + RpcServer.ts + … + index.ts + internal/, exported as the one subpath unstable/rpc. Each domain — rpc, sql, http, persistence, eventlog — is a self-contained folder.

Substrate gets its own home; consumer-specific wiring stays with the consumer

src

The placement test. Reusable substrate used by several engines gets its own module or family; wiring specific to one consumer stays in that consumers module or its internal/.

Persistence primitives shared across engines stand alone (as the persistence and eventlog domains do); an engines private wiring lives in its own internal/.

This decides where persistence code lives: the shared, type-agnostic spine is substrate (its own home, today src/internal/store/); a facet only one resource uses co-locates with that resource. Group facets under a store/ family only when theyre a reusable surface in their own right.

sideEffects: false + ESM is the foundation

src

Tree-shaking is what makes the package browser-safe, and it only works when the package promises the bundler theres nothing to run at import time. ESM modules, "sideEffects": false, and every optional peer (react, recharts, ink, sqlite, redis) externalized.

// package.json
{
  "type": "module",
  "sideEffects": false
}

No top-level side effects

src

This is the real leak. A bundler can drop an unused export, but it can not drop code that runs at module load — and that code drags its imports (the engine, node:*, better-sqlite3) into every bundle. Do work inside functions, never at module scope.

// ❌ bad — runs at import; can't be shaken out, so the engine ships to the browser
import { registerWorker } from "./internal/engine"
registerWorker(Mail)

// ✅ good — the engine is reached only when a function is called, which a browser never does
export const start = () => registerWorker(Mail)

Node-only modules are imported only from node-only code

src

A module a browser can reach must never import a node-only one — storage/sqlite, storage/redis, the HTTP server layers. Those pull node:* and native deps that have no place in a browser graph.

// ❌ bad — a browser-reachable module importing a node backend
import { layerProcessStore } from "hyperlink-ts/storage/sqlite"

// ✅ good — that import lives in the server entry, which the browser bundle never touches

Export each symbol exactly once

src

A symbol exported from two places (a re-export and a local export const of the same name) sends the type-checker into a multi-minute loop. One export site per symbol.

// ❌ bad — makeQueue exported twice
export { makeQueue } from "./internal/queue"
export const makeQueue = /* … */

// ✅ good — one home for the export
export { makeQueue } from "./internal/queue"

Prefer specific subpaths over the barrel in browser code

src

The root barrel is browser-safe, but it reaches the whole toolkit. In a browser bundle, import the one tag subpath you need — a smaller graph and a clearer boundary.

// ✅ better in a widget — just the tag
import * as QueueHyperlink from "hyperlink-ts/QueueHyperlink"

Keep contract and implementation separate — as a safeguard

src

Tree-shaking handles a module that exports both a tag and its layer, so this is not required. But keeping the tag in its own module makes the node boundary obvious and guards against an accidental top-level engine side effect slipping in beside the tag. A cheap safeguard, not a hard rule.

Verify by grepping the built bundle

src

Dont assume — check. Build, then grep the client bundle for node markers; if any show up, trace the import chain back to the module that pulled them.

pnpm build && grep -rqE 'node:|better-sqlite3' dist/web/ && echo "LEAK — trace the import" || echo "clean"
Edit this page on GitHub