Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{
"preset": "angular",
"releaseRules": [
{ "breaking": true, "release": "major" },
{ "breaking": true, "release": "minor" },
{ "revert": true, "release": "patch" },
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
Expand Down
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
---
title: Juniper
last_verified: 2026-09-09
---

# Juniper

[![JSR](https://jsr.io/badges/@udibo/juniper)](https://jsr.io/@udibo/juniper)
Expand All @@ -27,6 +22,8 @@ seamless full-stack development experience.
initial page loads and SEO benefits.
- **Data Loading and Actions** - Fetch data with loaders and handle form
submissions with actions, on either server or client.
- **Tagged JSON Transport** - One encoding for hydration, data requests, and
streamed deferred promises, with shared custom type and error registration.
- **Hot Reload** - See changes instantly during development.
- **TypeScript First** - Full TypeScript support with type-safe route
parameters, loader data, and action data.
Expand Down
11 changes: 0 additions & 11 deletions deno.lock

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

18 changes: 17 additions & 1 deletion docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,11 @@ export function ErrorBoundary({
Errors thrown on the server are serialized for the client. Juniper handles this
automatically, but you can customize the representation of registered error
types. See [serializable values](state-management.md#serializable-values) for
supported data types, numeric normalization, and custom type registration.
supported data types and custom type registration. Data-request errors use the
same tagged JSON codec and `X-Juniper: data` marker as successful data.
Unexpected server failures remain sanitized outside development, and
`HttpError.exposedMessage` determines the message sent to the browser, including
deferred rejections.

### Custom Error Serialization

Expand Down Expand Up @@ -257,6 +261,18 @@ Import this shared module from the root **client** route so registration occurs
on the server and in the browser before hydration data is deserialized. An
import only from `routes/main.ts` never registers the browser-side decoder.

Unknown registered error names throw during decoding. On a deferred stream, only
the affected promise rejects. Error envelopes keep the registered name in
`__errorType` and serializer output in `data`; an output property named
`__errorType` remains data and cannot choose a different deserializer. Output is
processed recursively and may contain supported values, promises, or registered
types. A serializer whose output matches its own `is` predicate throws. The
first matching error registration wins.

Non-Error thrown values use a `null` error type in the envelope, leaving every
string name available for registered errors. A missing string name always
throws, including a custom registration named `Unknown`.

```typescript
// routes/main.tsx
import "@/errors/custom.ts";
Expand Down
10 changes: 5 additions & 5 deletions docs/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,11 @@ export async function action({

Actions can return data, redirects, or throw redirects. Action data is
automatically serialized when sent to the client. JSON-shaped data, `undefined`,
`Date`, and `Error` have built-in handling; `bigint` values are accepted with
the numeric normalization described in
[serializable values](state-management.md#serializable-values). Promise values
can defer data. Register other classes with `registerType` or convert them to
plain data before returning them.
`Date`, `Error`, and `bigint` have built-in handling. The same tagged JSON codec
carries action data in hydration and fetcher responses. Promise values can defer
data through an NDJSON stream. See
[How Values Travel](state-management.md#how-values-travel). Register other
classes with `registerType` or convert them to plain data before returning them.

```typescript
// Return data (available in component via actionData)
Expand Down
11 changes: 6 additions & 5 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,12 @@ Export `publicEnvKeys` from the root **server** module, `routes/main.ts`, to
allowlist additional environment values in hydration data. See
[configuration](configuration.md#public-environment-variables).

Juniper serializes JSON-shaped data, `undefined`, `Date`, and `Error`. It also
accepts `bigint` and promise values, with numeric and deferred-data behavior
described in [serializable values](state-management.md#serializable-values).
Register other classes with `registerType`; unregistered objects do not retain
their class identity.
Juniper carries JSON-shaped data, `undefined`, `Date`, `Error`, `bigint`, and
promises through one tagged JSON codec. Client data responses carry
`X-Juniper: data`: settled values use JSON and deferred values stream as NDJSON.
See [How Values Travel](state-management.md#how-values-travel). Register other
classes with `registerType`; unregistered objects do not retain their class
identity.

### Layout Wrapper Pattern

Expand Down
51 changes: 44 additions & 7 deletions docs/state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,10 +306,8 @@ loader/action data. See [error handling](error-handling.md#error-serialization)
for the distinction between thrown errors, returned error data, and custom error
serializers.

`bigint` values are accepted, but the underlying numeric encoding can change
their JavaScript type: `123n` decodes as the number `123`, while integers beyond
the safe-number range remain `bigint`. Send a decimal string or register a
wrapper type if the consumer requires an exact `bigint` contract.
`bigint` values keep their JavaScript type, including small values such as
`123n`. `NaN`, `Infinity`, `-Infinity`, and `-0` also round-trip unchanged.

`Map`, `Set`, `RegExp`, and `URL` are **not** built-in round-trip types. Without
registration, objects are reduced to their enumerable own properties; typical
Expand Down Expand Up @@ -341,9 +339,48 @@ import "@/serialization/url.ts";

The route can then return a URL instance in loader data. Give each registration
a unique name and import the module wherever standalone server code needs it.
Use synchronous serializers that return simple, browser-safe values; do not
assume nested promises or other custom instances in their output will be
processed recursively.
Serializer output is processed recursively: it can contain `Date`, `undefined`,
promises, and other registered types. A serializer whose output matches its own
`is` predicate throws. Avoid cycles between serializers or in their data.

Registered `is` predicates run before the Array and Date checks. When several
registrations match a value, the first registration wins. Duplicate names throw.
An unknown registered type or error name throws during decoding; in a deferred
resolution, only that promise rejects and other resolutions continue. Import
every registration on both sides. Development hydration includes sorted
registration names, and the browser logs names missing from its registry.

#### How Values Travel

Juniper uses tagged JSON text for document hydration, client data requests, and
deferred resolutions. Values JSON cannot express use string tags of the form
`{"$t": tag, "v": value}`. The tags are `undefined`, `number`, `bigint`, `Date`,
`object`, `pending`, `promise`, `rejected`, `type`, and `error`; unknown tags
throw. Plain objects with an own `$t` or `__proto__` key use escaped entry
lists, including inside serializer output, so their keys remain ordinary data.

The first document load embeds one tagged hydration value containing
loader/action data, errors, context, and public environment values. Application
registrations do not transform public environment strings or diagnostic
registration names; these still travel inside the same tagged value. Every `<`,
U+2028, and U+2029 in that script is written as a `\u` escape. Deploy server
code and browser assets from the same build. If their hydration formats do not
match, Juniper uses a guarded document reload.

Client navigations and fetchers receive settled data as `application/json` with
a UTF-8 `Content-Length`. Deferred data uses `application/x-ndjson`: the first
line contains tagged data with pending placeholders, followed by lines shaped as
`{"id":"p0","status":"resolved","value":...}` or
`{"id":"p0","status":"rejected","error":...}`. Each value or error uses the same
codec. Resolutions arrive as they become ready, including nested promises. The
stream respects consumer back-pressure and stops on request cancellation.

Both response kinds, including data-request errors, carry `X-Juniper: data`. Use
this marker to identify framework data in middleware; JSON content type also
occurs on ordinary API responses and redirect envelopes. Deferred streams carry
`Cache-Control: no-transform` and must remain uncompressed at the origin so
buffering does not delay individual resolutions. Settled JSON can use normal
HTTP compression. The client uses one text-line decoder for both response kinds.

## React Context

Expand Down
12 changes: 6 additions & 6 deletions example/routes/features/data/server-deferred.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export default function ServerDeferredDataDemo({
</h2>
<p className="text-slate-300 mb-6 leading-relaxed">
Server loaders can also return promises for deferred data. The promises
are serialized using CBOR and streamed to the client for progressive
hydration. This demonstrates the full server-to-client data flow with
{" "}
are serialized using tagged JSON and streamed to the client for
progressive hydration. This demonstrates the full server-to-client data
flow with{" "}
<code className="px-2 py-1 bg-slate-700 rounded text-emerald-400">
Suspense
</code>{" "}
Expand Down Expand Up @@ -104,13 +104,13 @@ export default function ServerDeferredDataDemo({
Fast data is included in the initial HTML response
</li>
<li>
Promises are serialized using CBOR with custom tags
As promises resolve, the server streams HTML for each section
</li>
<li>
Client hydrates immediately with Suspense fallbacks
The client hydrates after the document's tagged JSON data is ready
</li>
<li>
As server promises resolve, data streams to the client
Later client data requests stream tagged JSON resolutions as NDJSON
</li>
</ol>
</div>
Expand Down
23 changes: 16 additions & 7 deletions scripts/doc-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,26 @@ describe("public documentation gate", () => {
);
});

it("tolerates the named private Builder methods but rejects making one public without JSDoc", async () => {
it("keeps private identifiers out of public docs and rejects undocumented methods", async () => {
await using project = await fixture({ ".": "./build.ts" }, {
"build.ts":
"/** Fixture API. @module */\n/** Builds the fixture. */\nexport class Builder {\n private collectWatchPaths(): void {}\n}\n",
"/** Fixture API. @module */\n/** Builds the fixture. */\nexport class Builder {\n #collectWatchPaths(): void {}\n}\n",
});
assertEquals((await runGate(project.config)).success, true);
await Deno.writeTextFile(
join(project.directory, "build.ts"),
"/** Fixture API. @module */\n/** Builds the fixture. */\nexport class Builder {\n collectWatchPaths(): void {}\n}\n",
);
assertEquals((await runGate(project.config)).success, false);
for (const name of ["collectWatchPaths", "isPathIgnored"]) {
for (const access of ["private ", ""]) {
await Deno.writeTextFile(
join(project.directory, "build.ts"),
`/** Fixture API. @module */\n/** Builds the fixture. */\nexport class Builder {\n ${access}${name}(): void {}\n}\n`,
);
const result = await runGate(project.config);
assertEquals(result.success, false, `${access}${name}`);
assertStringIncludes(
new TextDecoder().decode(result.stderr),
"error[missing-jsdoc]",
);
}
}
});

it("does not hide a plain fatal error beside an allowed external reference", async () => {
Expand Down
21 changes: 7 additions & 14 deletions scripts/doc-lint.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* Checks every published entrypoint for undocumented API and optionally checks
* its JSDoc examples. Known external-type diagnostics are scoped to their file,
* public symbol, and referenced type; the named private Builder methods are
* exempt only while their source declarations remain private.
* public symbol, and referenced type.
* @module
*/
import { fromFileUrl, relative, resolve, toFileUrl } from "@std/path";
Expand Down Expand Up @@ -85,7 +84,7 @@ function splitDiagnostics(
return { blocks, fatal, counts };
}

async function isTolerated(block: string, sourceDir: string): Promise<boolean> {
function isTolerated(block: string, sourceDir: string): boolean {
const location = block.match(/^\s*-->\s+(.+):(\d+):(\d+)\s*$/m);
if (!location) return false;
const file = location[1].startsWith("file:")
Expand All @@ -102,25 +101,19 @@ async function isTolerated(block: string, sourceDir: string): Promise<boolean> {
.test(file.replaceAll("\\", "/")) &&
routerReferences.has(reference);
}
if (!block.startsWith("error[missing-jsdoc]:") || localFile !== "build.ts") {
return false;
}
const sourceLine =
(await Deno.readTextFile(file)).split(/\r?\n/)[Number(location[2]) - 1];
return /^\s*private\s+(?:async\s+)?(?:collectWatchPaths|isPathIgnored)\s*\(/
.test(sourceLine ?? "");
return false;
}

/** Classifies one doc invocation; only the named package/type exceptions can pass a lint failure. */
export async function assessDocLint(
export function assessDocLint(
code: number,
stderr: string,
sourceDir: string,
): Promise<{ passed: boolean; violations: string[] }> {
): { passed: boolean; violations: string[] } {
const { blocks, fatal, counts } = splitDiagnostics(stderr);
const violations = [...fatal];
for (const block of blocks) {
if (!await isTolerated(block, sourceDir)) violations.push(block);
if (!isTolerated(block, sourceDir)) violations.push(block);
}
if (
code !== 0 &&
Expand Down Expand Up @@ -162,7 +155,7 @@ export async function lintDocumentation(
stderr: "piped",
}).output();
const stderr = new TextDecoder().decode(result.stderr);
const assessment = await assessDocLint(result.code, stderr, sourceDir);
const assessment = assessDocLint(result.code, stderr, sourceDir);
if (!assessment.passed) {
console.error(stderr);
console.error(
Expand Down
31 changes: 13 additions & 18 deletions src/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { delay } from "@std/async/delay";
import {
deserializeError,
deserializeHydrationData,
deserializeLoaderData,
deserializeStreamingLoaderData,
type HydrationData,
type SerializedHydrationData,
Expand Down Expand Up @@ -341,6 +340,16 @@ function scheduleDocumentNavigation(
return promise;
}

export function reloadUnsupportedHydration(version: number): Promise<never> {
if (!shouldReload(BUILD_SKEW_RELOAD_KEY)) {
throw new Error(`Unsupported hydration data version: ${version}`);
}
return scheduleDocumentNavigation(
BUILD_SKEW_RELOAD_KEY,
() => globalThis.location.reload(),
);
}

async function fetchServerData(
request: Request,
method: "GET" | "POST",
Expand Down Expand Up @@ -379,29 +388,15 @@ async function fetchServerData(
}
}

const contentType = response.headers.get("Content-Type");

if (contentType === "application/cbor-stream") {
if (!response.ok) {
const buffer = await response.arrayBuffer();
const deserialized = deserializeLoaderData(new Uint8Array(buffer));
throw deserializeError(deserialized as Record<string, unknown>);
}
return await deserializeStreamingLoaderData(response);
}

if (contentType === "application/cbor") {
const buffer = await response.arrayBuffer();
const deserialized = deserializeLoaderData(new Uint8Array(buffer));

const responseType = response.headers.get("X-Juniper");
if (responseType === "data") {
const deserialized = await deserializeStreamingLoaderData(response);
if (!response.ok) {
throw deserializeError(deserialized as Record<string, unknown>);
}

return deserialized;
}

const responseType = response.headers.get("X-Juniper");
if (responseType === "redirect") {
const redirectData = await response.json();
request.signal.throwIfAborted();
Expand Down
Loading
Loading