Skip to content

Commit 4efefce

Browse files
committed
feat(core): drop superjson, ship built-in ESM rich-type serializer
Replaces `superjson` with a pure-ESM, dependency-free serializer in `@webjskit/core` (`packages/core/src/serialize.js`). Tagged-inline wire format, async sync API. Single async `stringify`/`serialize` so AI agents can't pick the wrong variant; `parse`/`deserialize` stay sync. Wire surface mirrors React Server Actions (the de-facto reference for RPC payloads), plus webjs niceties: Date, BigInt, Map, Set, Error, undefined, registered Symbols, NaN/Infinity/-Infinity/-0, TypedArrays (Int8/Uint8/.../Float64), ArrayBuffer, DataView, Blob, File, FormData, reference cycles + shared refs. Skips superjson's class-instance preservation, custom transformer registry, and local Symbols — features webjs apps don't need that add API surface AI agents can misuse. Why drop superjson: - It ships CJS, requiring esbuild to bundle for the browser at first request. With our own ESM serializer the dev server stops bundling at runtime. - Two transitive deps (`copy-anything`, `is-what`) gone. - One less dimension of API surface (`registerClass`, `registerCustom`). Other changes: - `packages/core/src/serialize.js` — new module (~430 lines). - `packages/core/index.js` — exports `stringify`/`parse`/ `serialize`/`deserialize`. - `packages/core/src/rich-fetch.js` — uses local serializer. - `packages/server/src/serializer.js` — `Serializer.serialize` is now async; default uses `@webjskit/core`'s `stringify`. - `packages/server/src/actions.js` — `rpcResponse()` async; action stub generator emits `@webjskit/core` import + `await stringify`. - `packages/server/src/json.js` — `json()` async (rich path needs to await Blob bytes). - `packages/server/src/dev.js` — drops `serveBundledSuperjson` and its esbuild bundling call. - `packages/server/src/importmap.js` — drops superjson entry. - `packages/server/src/vendor.js` — drops superjson from BUILTIN. - `packages/server/package.json` — removes `superjson` dep. Tests: - New `test/serialize.test.js` (37 tests): primitives, special numbers, undefined, BigInt, Date, Map, Set, Error, Symbols, cycles, typed arrays, key collision protection, Blob/File/ FormData round-trip, realistic mixed payload. - Updated `test/actions.test.js`, `test/json-negotiation.test.js`, `test/rich-fetch.test.js`, `test/vendor.test.js`, `test/lazy-loading.test.js`, `test/dev-handler.test.js`, `test/e2e.test.mjs`, `test/browser/e2e/blog.test.js`, `test/serializer.test.js` to use the new serializer + async API. - 678 unit + 36 e2e + 21 browser tests all pass. Documentation: - README.md, AGENTS.md, server README, website hero copy. - docs pages: server-actions, typescript, api-routes, backend-only, architecture, database, configuration, deployment, ai-first. Versions: - `@webjskit/core`: 0.2.0 → 0.3.0 (new public exports) - `@webjskit/server`: 0.2.1 → 0.3.0 (removed dep, async json/serialize) - `@webjskit/cli`: 0.2.1 → 0.3.0 (server pin updated)
1 parent 9249987 commit 4efefce

37 files changed

Lines changed: 992 additions & 196 deletions

AGENTS.md

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,9 @@ inspired by NextJs, Lit, and Rails.
197197
- **Server actions with rich types.** Any file ending `.server.js` / `.server.ts`
198198
(or starting with `'use server'`) exports functions the client imports and
199199
calls directly — the import is rewritten into an RPC stub. The RPC wire uses
200-
**superjson**, so `Date`, `Map`, `Set`, `BigInt`, `undefined`, `URL`, `RegExp`
201-
round-trip as their real types.
200+
webjs's built-in ESM serializer, so `Date`, `Map`, `Set`, `BigInt`,
201+
`undefined`, `Error`, `TypedArray`, `ArrayBuffer`, `Blob`, `File`, `FormData`,
202+
registered Symbols, and reference cycles all round-trip as their real types.
202203
- **Server-file source is unreachable from the browser (framework invariant).**
203204
The HTTP layer independently re-verifies every JS/TS request against the
204205
server-file predicate (filename suffix OR `'use server'` directive) before
@@ -256,7 +257,7 @@ node_modules/@webjskit/
256257
csrf.js ← double-submit CSRF protection
257258
websocket.js ← WS route upgrade + attachWebSocket
258259
broadcast.js ← broadcast() for fan-out messaging
259-
serializer.js ← pluggable wire format (superjson default)
260+
serializer.js ← pluggable wire format (webjs built-in default)
260261
check.js ← convention validator (webjs check)
261262
vendor.js ← auto-bundle npm deps for browser
262263
module-graph.js ← dependency graph for transitive preloads
@@ -363,7 +364,7 @@ import { html, css, WebComponent, render, renderToString } from '@webjskit/core'
363364
| `repeat(items, k, t)` | Keyed list directive — `${repeat(items, it => it.id, it => html\`...\`)}`. Preserves element identity / focus when items reorder. |
364365
| `Suspense({fallback, children})` | Streaming boundary — server flushes `fallback` immediately, streams `children` (a Promise<TemplateResult>) when it resolves. |
365366
| `connectWS(url, handlers)` | Client-side WebSocket with auto-reconnect, JSON parse/stringify, queued sends. |
366-
| `richFetch<T>(url, init?)` | Client-side fetch that adds `Accept: application/vnd.webjs+json`, encodes plain-object bodies via superjson, and decodes responses with rich types. |
367+
| `richFetch<T>(url, init?)` | Client-side fetch that adds `Accept: application/vnd.webjs+json`, encodes plain-object bodies via webjs's built-in serializer, and decodes responses with rich types. |
367368

368369
### Directives — `import { … } from '@webjskit/core/directives'`
369370

@@ -1438,12 +1439,15 @@ const r = await createPost({ title, body });
14381439
if (r.success) r.data.title; // ← PostFormatted.title: string
14391440
```
14401441
1441-
**Runtime reality matches the types** because the RPC wire is superjson:
1442-
a `Date` on the server is a `Date` on the client, a `Map` is a `Map`, a
1443-
`BigInt` is a `BigInt`. Supported types: everything superjson handles
1444-
(Date, Map, Set, BigInt, undefined, URL, RegExp, Error, Decimal, plus
1445-
any custom transformer you register). Class instances come through as
1446-
plain objects — prototypes are lost, methods don't survive.
1442+
**Runtime reality matches the types** because the RPC wire uses webjs's
1443+
built-in ESM serializer: a `Date` on the server is a `Date` on the client,
1444+
a `Map` is a `Map`, a `BigInt` is a `BigInt`. Supported types: `Date`,
1445+
`Map`, `Set`, `BigInt`, `Error`, `undefined`, `NaN`/`Infinity`/`-0`,
1446+
`TypedArray` (Int8/Uint8/.../Float64), `ArrayBuffer`, `DataView`, `Blob`,
1447+
`File`, `FormData`, `Symbol.for(...)` registered symbols, and reference
1448+
cycles / shared refs. Class instances come through as plain objects —
1449+
prototypes are lost, methods don't survive (matching React Server
1450+
Actions' behavior).
14471451
14481452
### API routes — opt in via content negotiation
14491453
@@ -1466,12 +1470,12 @@ export async function GET() {
14661470
import { richFetch } from '@webjskit/core';
14671471
const posts = await richFetch<Post[]>('/api/posts');
14681472
// posts[0].createdAt is a Date here (richFetch sends
1469-
// Accept: application/vnd.webjs+json and superjson-parses the response).
1473+
// Accept: application/vnd.webjs+json and parses the rich response).
14701474
```
14711475
14721476
The `json()` helper reads the in-flight Request via the AsyncLocalStorage
14731477
context:
1474-
- `Accept: application/vnd.webjs+json`superjson-encoded response,
1478+
- `Accept: application/vnd.webjs+json` → encoded with the webjs serializer,
14751479
`Content-Type: application/vnd.webjs+json`, `Vary: Accept` for
14761480
correct shared-cache keying.
14771481
- Otherwise → plain JSON with `Content-Type: application/json`.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ TypeScript with zero build step, real SSR with Declarative Shadow DOM.
1515
- **No build step you run.** `.ts` files served directly. The dev server transforms TypeScript via esbuild for both server-side imports (SSR) and browser-bound modules (hydration) — same transformer for both, ~1ms/file, cached by mtime. Full TS feature support (enums, decorators, parameter properties — anything esbuild handles). Edit, refresh, done.
1616
- **Web components, light DOM by default.** Pages and components render as light DOM so global CSS and Tailwind utilities apply directly — no `::part`, no `:host`, no CSS-var plumbing. Shadow DOM is opt-in (`static shadow = true`) when you need scoped styles or real `<slot>` projection. Both modes SSR fully, no hydration runtime.
1717
- **Tailwind CSS by default.** The scaffold ships with the Tailwind browser runtime + `@theme` design tokens. Prefer hand-written CSS? Opt out entirely — the framework works just as well with vanilla CSS when you follow the wrapper-scoping convention (`.page-<route>`, `.layout-<name>`, component-tag scoped). Full recipe in the [Styling docs](./docs/app/docs/styling/page.ts).
18-
- **Full-stack type safety.** Import a `.server.ts` function from a component — TypeScript sees the real signature. superjson on the wire preserves `Date`, `Map`, `Set`, `BigInt`.
18+
- **Full-stack type safety.** Import a `.server.ts` function from a component — TypeScript sees the real signature. webjs's built-in ESM serializer on the wire preserves `Date`, `Map`, `Set`, `BigInt`, `TypedArray`, `Blob`, `File`, `FormData`, and reference cycles.
1919
- **Server-file source is unreachable from the browser.** Framework invariant: any file ending `.server.{js,ts}` or starting with `'use server'` is always served as an RPC stub, never its real source. Enforced in the HTTP layer with regression tests.
2020
- **NextJs-style routing.** `page.ts`, `layout.ts`, `route.ts`, `error.ts`, `middleware.ts`, `[params]`, `(groups)`, `_private`. Layouts persist across navigations.
2121
- **Client router.** Turbo-Drive-style link interception. Shadow-DOM-aware via `composedPath()`. Layouts stay mounted, only page content swaps. No white flash.
@@ -171,7 +171,7 @@ Pre-1.0. 632 unit tests (96.6% line coverage, 87.5% branch, 93.6% function),
171171
36 puppeteer e2e tests, 27 WTR browser tests. Key features:
172172

173173
- **Core:** SSR with DSD (opt-in) + light-DOM hydration (default), fine-grained client renderer, `repeat()`, `Suspense()`, client router with `composedPath()` for shadow DOM, mixed-attribute interpolation, MutationObserver upgrade safety net
174-
- **Data:** server actions + superjson (Date/Map/Set/BigInt survive the wire), `expose()` for REST, `json()` + `richFetch()` for content-negotiated APIs, `cache()` for server-side query caching with TTL + `invalidate()`
174+
- **Data:** server actions with webjs's built-in serializer (Date/Map/Set/BigInt/TypedArray/Blob/File/FormData/cycles survive the wire), `expose()` for REST, `json()` + `richFetch()` for content-negotiated APIs, `cache()` for server-side query caching with TTL + `invalidate()`
175175
- **Server:** file router, per-segment middleware, `rateLimit()`, WebSockets + `broadcast()`, CSRF, compression, HTTP/2, 103 Early Hints, health probes, graceful shutdown, `Session` class with `SessionStorage` (cookie or store-backed), NextAuth-style `createAuth()` (Credentials, Google, GitHub)
176176
- **DX:** TypeScript with zero build, `AGENTS.md` contract, `CLAUDE.md`, live reload in dev, optional esbuild bundle for prod, `@webjskit/ts-plugin` for tsserver — tag-name and CSS-class-name go-to-definition inside `html\`\`` templates.
177177

docs/app/docs/ai-first/page.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ modules/posts/queries/get-post.server.ts → exports getPost()</pre>
7070
export async function createPost(
7171
input: { title: string; body: string }
7272
): Promise&lt;ActionResult&lt;PostFormatted&gt;&gt; { ... }</pre>
73-
<p>The TypeScript signature IS the API contract. No separate schema file, no OpenAPI spec, no GraphQL SDL. The client component imports the function, TypeScript checks the types, and superjson preserves them on the wire. An AI agent can:</p>
73+
<p>The TypeScript signature IS the API contract. No separate schema file, no OpenAPI spec, no GraphQL SDL. The client component imports the function, TypeScript checks the types, and webjs's built-in serializer preserves them on the wire. An AI agent can:</p>
7474
<ol>
7575
<li>Read the function signature to understand the API.</li>
7676
<li>Modify the function and know every call site that breaks (via tsc).</li>
@@ -134,7 +134,7 @@ Convention validator ✅ webjs check ❌ none ❌ none
134134
File = function ✅ one/file ⚠️ varies ❌ free-form
135135
No build transforms ✅ none ❌ SWC/webpack ✅ none
136136
Explicit server bound. ✅ .server.ts ⚠️ 'use srv' n/a
137-
Typed RPC (no schema) ✅ superjson ⚠️ Flight ❌ manual
137+
Typed RPC (no schema) ✅ rich types ⚠️ Flight ❌ manual
138138
Autonomous mode ✅ defaults ❌ n/a ❌ n/a</pre>
139139
140140
<h2>The AGENTS.md File</h2>

docs/app/docs/api-routes/page.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export async function POST(req: Request) {
9797
<h2>json() Helper -- Content Negotiation</h2>
9898
<p>The <code>json()</code> helper from <code>@webjskit/server</code> adds smart content negotiation. It inspects the incoming request's <code>Accept</code> header and responds accordingly:</p>
9999
<ul>
100-
<li>If the client sent <code>Accept: application/vnd.webjs+json</code> (e.g. via <code>richFetch()</code>), the response is encoded with <strong>superjson</strong> so that <code>Date</code>, <code>Map</code>, <code>Set</code>, and <code>BigInt</code> survive the round trip.</li>
100+
<li>If the client sent <code>Accept: application/vnd.webjs+json</code> (e.g. via <code>richFetch()</code>), the response is encoded with the <strong>webjs serializer</strong> so that <code>Date</code>, <code>Map</code>, <code>Set</code>, <code>BigInt</code>, <code>TypedArray</code>, <code>Blob</code>, <code>File</code>, <code>FormData</code>, and reference cycles all survive the round trip.</li>
101101
<li>Otherwise, the response is plain <code>application/json</code> -- standard for curl, mobile apps, and third-party consumers.</li>
102102
</ul>
103103
<pre>// app/api/posts/route.ts
@@ -109,7 +109,7 @@ export async function GET() {
109109
});
110110
return json(posts);
111111
// External client: plain JSON, createdAt is an ISO string
112-
// richFetch client: superjson, createdAt is a real Date object
112+
// richFetch client: rich types, createdAt is a real Date object
113113
}
114114
115115
export async function POST(req: Request) {
@@ -120,7 +120,7 @@ export async function POST(req: Request) {
120120
<p>The helper reads the in-flight Request from an <code>AsyncLocalStorage</code> context set up by the request pipeline, so you do not need to pass the request explicitly.</p>
121121
122122
<h2>readBody() -- Parsing Rich Request Bodies</h2>
123-
<p>The <code>readBody()</code> helper from <code>@webjskit/server</code> is the inverse of <code>json()</code>. It parses the request body as superjson when the client sent the <code>application/vnd.webjs+json</code> content type, and as plain JSON otherwise:</p>
123+
<p>The <code>readBody()</code> helper from <code>@webjskit/server</code> is the inverse of <code>json()</code>. It parses the request body with the webjs rich serializer when the client sent the <code>application/vnd.webjs+json</code> content type, and as plain JSON otherwise:</p>
124124
<pre>import { json, readBody } from '@webjskit/server';
125125
126126
export async function POST(req: Request) {
@@ -132,7 +132,7 @@ export async function POST(req: Request) {
132132
}</pre>
133133
134134
<h2>richFetch() -- Typed Client Calls</h2>
135-
<p>On the client side, <code>richFetch()</code> from <code>webjs</code> is a drop-in replacement for <code>fetch()</code> that enables the superjson round trip:</p>
135+
<p>On the client side, <code>richFetch()</code> from <code>webjs</code> is a drop-in replacement for <code>fetch()</code> that enables the rich-type round trip:</p>
136136
<pre>import { richFetch } from '@webjskit/core';
137137
138138
// GET with rich types
@@ -143,7 +143,7 @@ const posts = await richFetch('/api/posts');
143143
const newPost = await richFetch('/api/posts', {
144144
method: 'POST',
145145
body: { title: 'Hello', publishAt: new Date(2026, 5, 1) },
146-
// body is automatically superjson-stringified
146+
// body is automatically encoded with the rich serializer
147147
// Content-Type is set to application/vnd.webjs+json
148148
});
149149
@@ -158,8 +158,8 @@ try {
158158
<p><code>richFetch</code> automatically:</p>
159159
<ul>
160160
<li>Sets <code>Accept: application/vnd.webjs+json</code> on outgoing requests</li>
161-
<li>If <code>body</code> is a plain object (not FormData, Blob, ArrayBuffer, or string), stringifies it with superjson and sets the content type</li>
162-
<li>Parses the response with superjson when the server responds with the vendor content type, or with plain <code>JSON.parse</code> otherwise</li>
161+
<li>If <code>body</code> is a plain object (not FormData, Blob, ArrayBuffer, or string), encodes it with the webjs serializer and sets the content type</li>
162+
<li>Parses the response with the webjs serializer when the server responds with the vendor content type, or with plain <code>JSON.parse</code> otherwise</li>
163163
<li>Throws an <code>Error</code> with <code>.status</code> and <code>.body</code> properties for non-2xx responses</li>
164164
</ul>
165165
@@ -354,8 +354,8 @@ export async function DELETE(_req: Request, { params }: Ctx) {
354354
<li>Handler signature: <code>(req: Request, { params }) =&gt; Response | object</code></li>
355355
<li>Dynamic params via <code>[slug]</code> folder names, catch-all via <code>[...rest]</code></li>
356356
<li>Return a <code>Response</code> for full control, or return a plain object for auto-JSON</li>
357-
<li><code>json()</code> from <code>@webjskit/server</code> provides content negotiation (plain JSON vs superjson)</li>
358-
<li><code>readBody()</code> parses incoming superjson or JSON based on content type</li>
357+
<li><code>json()</code> from <code>@webjskit/server</code> provides content negotiation (plain JSON vs webjs rich JSON)</li>
358+
<li><code>readBody()</code> parses incoming rich-format or plain JSON based on content type</li>
359359
<li><code>richFetch()</code> on the client for typed API calls with rich types</li>
360360
<li>Export <code>WS</code> from the same <code>route.ts</code> for WebSocket support</li>
361361
<li>Per-segment <code>middleware.ts</code> applies to all routes underneath</li>

docs/app/docs/architecture/page.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export default function Architecture() {
2929
<li><code>expose()</code> — tag an action for REST exposure</li>
3030
<li><code>notFound()</code> / <code>redirect()</code> — navigation sentinels</li>
3131
<li><code>connectWS()</code> — auto-reconnecting WebSocket client</li>
32-
<li><code>richFetch()</code> — superjson-aware fetch wrapper</li>
32+
<li><code>richFetch()</code> — rich-type-aware fetch wrapper</li>
33+
<li><code>stringify()</code> / <code>parse()</code> — webjs's built-in serializer (Date, Map, Set, BigInt, TypedArray, Blob, File, FormData, cycles)</li>
3334
</ul>
3435
3536
<h3>@webjskit/server</h3>

docs/app/docs/backend-only/page.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export function WS(ws: WebSocket, req: Request, { params }: { params: Record&lt;
188188
<p>WebSocket endpoints coexist with HTTP handlers in the same <code>route.ts</code>. The second argument is a <code>Request</code> object from the upgrade handshake, so you can read cookies, headers, and query params for auth.</p>
189189
190190
<h2>Content-Negotiated JSON</h2>
191-
<p>Use the <code>json()</code> helper from <code>@webjskit/server</code> and the <code>richFetch()</code> client helper from <code>webjs</code> for superjson-encoded responses that preserve <code>Date</code>, <code>Map</code>, <code>Set</code>, and <code>BigInt</code>:</p>
191+
<p>Use the <code>json()</code> helper from <code>@webjskit/server</code> and the <code>richFetch()</code> client helper from <code>webjs</code> for rich-encoded responses that preserve <code>Date</code>, <code>Map</code>, <code>Set</code>, <code>BigInt</code>, <code>TypedArray</code>, <code>Blob</code>, <code>File</code>, <code>FormData</code>, and reference cycles:</p>
192192
<pre>// app/api/events/route.ts
193193
import { json } from '@webjskit/server';
194194
@@ -204,7 +204,7 @@ const events = await richFetch('/api/events');
204204
205205
// External client (curl, Postman) gets plain JSON automatically
206206
// curl http://localhost:3000/api/events</pre>
207-
<p>The <code>json()</code> helper reads the <code>Accept</code> header. If the client sent <code>Accept: application/vnd.webjs+json</code> (as <code>richFetch</code> does), the response is superjson-encoded. Otherwise, plain <code>application/json</code>. The <code>Vary: Accept</code> header is set automatically.</p>
207+
<p>The <code>json()</code> helper reads the <code>Accept</code> header. If the client sent <code>Accept: application/vnd.webjs+json</code> (as <code>richFetch</code> does), the response is encoded with the webjs serializer. Otherwise, plain <code>application/json</code>. The <code>Vary: Accept</code> header is set automatically.</p>
208208
<p>For reading request bodies with the same content negotiation, use <code>readBody(req)</code> from <code>@webjskit/server</code>.</p>
209209
210210
<h2>Health Probes, Graceful Shutdown, Compression</h2>
@@ -258,7 +258,7 @@ fastify.listen({ port: 3000 });</pre>
258258
<li><strong>File-based routing</strong> — no manual <code>app.get()</code> / <code>app.post()</code> registration. Drop a <code>route.ts</code> in a folder and it is live.</li>
259259
<li><strong>Nested middleware</strong> — middleware scoped to route subtrees, not global or per-route.</li>
260260
<li><strong>TypeScript first</strong> — no build step, no compilation, no config. <code>.ts</code> files run directly.</li>
261-
<li><strong>superjson wire format</strong>rich types survive <code>Date</code>/<code>Map</code>/<code>Set</code>/<code>BigInt</code> serialisation.</li>
261+
<li><strong>Rich wire format</strong>webjs's built-in serializer round-trips <code>Date</code>/<code>Map</code>/<code>Set</code>/<code>BigInt</code>/<code>TypedArray</code>/<code>Blob</code>/<code>File</code>/<code>FormData</code> and reference cycles.</li>
262262
<li><strong>WebSocket support</strong> — export a <code>WS</code> function from a route file, no separate setup.</li>
263263
<li><strong>Health probes</strong> — built-in, zero config.</li>
264264
<li><strong>expose()</strong> — turn server functions into REST endpoints with validation and CORS.</li>

docs/app/docs/configuration/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const resp = await app.handle(new Request('http://x/api/hello'));
100100
<li><strong>Routing conventions</strong><code>page.ts</code>, <code>layout.ts</code>, <code>route.ts</code>, <code>middleware.ts</code>, <code>error.ts</code>, <code>not-found.ts</code> are the file names. No aliases.</li>
101101
<li><strong>Shadow DOM by default</strong> — components use shadow DOM unless <code>static shadow = false</code>. No global opt-out.</li>
102102
<li><strong>CSRF on server actions</strong> — always on for <code>/__webjs/action/*</code> RPC. Can't disable.</li>
103-
<li><strong>Import map</strong> — auto-generated. Maps <code>webjs</code> and <code>superjson</code> to framework-served URLs.</li>
103+
<li><strong>Import map</strong> — auto-generated. Maps <code>@webjskit/core</code> sub-paths to framework-served URLs and any bare npm imports your client code uses to vendor bundles.</li>
104104
</ul>
105105
`;
106106
}

0 commit comments

Comments
 (0)