@webjsdev/mcp 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/resources/AGENTS.md +19 -18
- package/resources/agent-docs/built-ins.md +10 -8
- package/resources/agent-docs/components.md +3 -1
- package/resources/agent-docs/configuration.md +31 -0
- package/resources/agent-docs/framework-dev.md +1 -1
- package/resources/agent-docs/recipes.md +41 -40
- package/resources/agent-docs/testing.md +46 -1
- package/resources/agent-docs/typescript.md +7 -6
- package/src/mcp-docs.js +4 -2
- package/src/mcp.js +3 -3
- package/resources/agent-docs/ssr-partial-nav-design.md +0 -214
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The webjs Model Context Protocol server: live app introspection (routes / actions / components / check) plus the framework knowledge layer (docs, recipes, source) for AI coding agents",
|
|
6
6
|
"bin": {
|
package/resources/AGENTS.md
CHANGED
|
@@ -14,10 +14,9 @@ reference there.
|
|
|
14
14
|
| `agent-docs/components.md` | WebComponent deep-dive (controllers, hooks, light/shadow DOM, slots) |
|
|
15
15
|
| `agent-docs/styling.md` | Tailwind helpers + vanilla-CSS opt-out conventions |
|
|
16
16
|
| `agent-docs/built-ins.md` | Auth, sessions, env vars, caching (cache(), HTTP cache, asset-hash, conditional GET), rate-limit, broadcast, file storage |
|
|
17
|
-
| `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps) + observability |
|
|
17
|
+
| `agent-docs/configuration.md` | The `package.json` `"webjs"` block (security headers, CSP, redirects, trailing-slash, basePath, ingress caps, dev/start task orchestration) + observability |
|
|
18
18
|
| `agent-docs/advanced.md` | Suspense streaming, performance, bundling, client router (prefetch, frames, view transitions, stream actions), WebSockets |
|
|
19
19
|
| `agent-docs/typescript.md` | TS at runtime + full-stack type safety |
|
|
20
|
-
| `agent-docs/deployment.md` | Production, runtime targets, embedded use |
|
|
21
20
|
| `agent-docs/service-worker.md` | The opt-in progressive-enhancement service worker (`public/sw.js`) |
|
|
22
21
|
| `agent-docs/testing.md` | Unit, browser, convention validation, the `handle()` test harness (`@webjsdev/server/testing`) |
|
|
23
22
|
| `agent-docs/framework-dev.md` | Monorepo dev (only when editing webjs itself): commands, repo-health git config, changelog flow, dev error overlay |
|
|
@@ -40,7 +39,7 @@ Claude Code enforces step 1 via `.claude/hooks/guard-branch-context.sh`. Other a
|
|
|
40
39
|
|
|
41
40
|
### Skills are routed deterministically, never skipped
|
|
42
41
|
|
|
43
|
-
A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. Tests in `test/hooks/route-skills.test.mjs
|
|
42
|
+
A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
|
|
44
43
|
|
|
45
44
|
### Autonomous mode (sandbox / bypass permissions)
|
|
46
45
|
|
|
@@ -78,7 +77,7 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f
|
|
|
78
77
|
|
|
79
78
|
An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive `static properties`, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but webjs ships its own no-build implementation under `packages/core/src/`. Decorators are the one lit exception (invariant 10); use `declare` + `static properties`.
|
|
80
79
|
|
|
81
|
-
- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped
|
|
80
|
+
- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `agent-docs/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target.
|
|
82
81
|
- **SSR + CSR by default.** Pages are server-rendered HTML; components render light DOM by default, shadow DOM opt-in via `static shadow = true` with DSD SSR.
|
|
83
82
|
- **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `<a>` navigates, `<form>` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `<form>` + server action would do.
|
|
84
83
|
- **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `agent-docs/components.md`.
|
|
@@ -101,7 +100,7 @@ A page/layout module still **loads** in the browser for its top-level side effec
|
|
|
101
100
|
|
|
102
101
|
`route.{js,ts}` is the one routing file that is NOT isomorphic: a server-only HTTP handler (named `GET` / `POST` exports), the webjs equivalent of a Next route handler. It never ships to the client.
|
|
103
102
|
|
|
104
|
-
**`.server.{js,ts}` is the one server boundary, an RPC + source-protection mechanism, NOT an RSC component.** With `'use server'` exports are RPC-callable (the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`); without it the file is a server-only utility whose browser import **throws at module load** (
|
|
103
|
+
**`.server.{js,ts}` is the one server boundary, an RPC + source-protection mechanism, NOT an RSC component.** With `'use server'` exports are RPC-callable (the browser import becomes a stub POSTing to `/__webjs/action/<hash>/<fn>`); without it the file is a server-only utility whose browser import **throws at module load** (the DB driver, secrets, `node:*`, hashing). Consequence: **never import a no-`'use server'` util directly into a page, layout, or component** (it works at SSR but the client stub crashes on load); use it inside `'use server'` actions, `route.{js,ts}`, or `middleware`, and reach it from a page by importing a `'use server'` action (whose RPC stub loads safely client-side). This boundary, not a component annotation, is how a dependency is kept off the client (a date library used only during SSR belongs in `lib/format.server.ts`). See `agent-docs/components.md`.
|
|
105
104
|
|
|
106
105
|
---
|
|
107
106
|
|
|
@@ -135,7 +134,7 @@ lib/ app-wide code (lib/*.server.js infra, lib/utils/ bro
|
|
|
135
134
|
modules/<feature>/ feature-scoped: actions/ (mutations), queries/ (reads), components/, utils/, types.js
|
|
136
135
|
components/*.js SHARED presentational primitives
|
|
137
136
|
public/* static assets, served at /<name>
|
|
138
|
-
|
|
137
|
+
db/*.server.{js,ts} data layer (Drizzle: schema, columns, connection)
|
|
139
138
|
```
|
|
140
139
|
|
|
141
140
|
Every file is a plain ES module.
|
|
@@ -259,7 +258,7 @@ One custom element per file; call `Class.register('tag')` at module top level. S
|
|
|
259
258
|
## Modules architecture (preferred for non-trivial apps)
|
|
260
259
|
|
|
261
260
|
- **`modules/<feature>/actions/*.server.{js,ts}`** mutations, **`queries/*.server.{js,ts}`** reads (one function per file), **`components/*.{js,ts}`** feature-owned components (shared UI in top-level `components/`), **`utils/*.{js,ts}`** pure helpers (no `'use server'`, no DB), **`types.{js,ts}`** typedefs.
|
|
262
|
-
- **`lib/`** cross-cutting: `lib/*.server.{js,ts}` server-only infra (
|
|
261
|
+
- **`lib/`** cross-cutting: `lib/*.server.{js,ts}` server-only infra (session, hashing; the DB connection lives in `db/connection.server.ts`), `lib/utils/*` browser-safe helpers, `lib/*.ts` app-wide values.
|
|
263
262
|
|
|
264
263
|
### The `ActionResult<T>` envelope
|
|
265
264
|
|
|
@@ -294,7 +293,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
294
293
|
|
|
295
294
|
> Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.com/docs/troubleshooting) is keyed by symptom (the throw-at-load server import, the backtick-in-template 500, the TypeScript strip failure, the SSR browser-global crash, the missing-frame swap) and maps each back to the invariant and the `webjs check` rule below.
|
|
296
295
|
|
|
297
|
-
1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing
|
|
296
|
+
1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`better-sqlite3` / `pg`), `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
|
|
298
297
|
2. **Every `*.server.{js,ts}` file with `'use server'` exports must be `async` functions returning serializer-safe values.** Args and results round-trip via webjs's wire. Files without `'use server'` (server-only utilities) can export anything, including singletons.
|
|
299
298
|
3. **Custom element tag names must contain a hyphen** (HTML spec). Pass the tag to `Class.register('tag-name')`, not a static field. Any short-string quote works: `'tag-name'`, `"tag-name"`, or `` `tag-name` `` (single-line, no interpolation).
|
|
300
299
|
4. **Event (`@`), property (`.`), boolean (`?`) holes in `html` must be unquoted**, e.g. `@click=${fn}`, never `@click="${fn}"`.
|
|
@@ -311,17 +310,17 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
311
310
|
|
|
312
311
|
## Scaffolding
|
|
313
312
|
|
|
314
|
-
Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules,
|
|
313
|
+
Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
|
|
315
314
|
|
|
316
|
-
Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (
|
|
315
|
+
Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. Docs at https://docs.webjs.com.
|
|
317
316
|
|
|
318
317
|
---
|
|
319
318
|
|
|
320
319
|
## CLI reference
|
|
321
320
|
|
|
322
321
|
```sh
|
|
323
|
-
webjs dev [--port N]
|
|
324
|
-
webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2)
|
|
322
|
+
webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch on Node, bun --hot on Bun). --no-hot runs in-process. Runs webjs.dev.before + webjs.dev.parallel (#550)
|
|
323
|
+
webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550)
|
|
325
324
|
webjs test [--server] [--browser] [--watch]
|
|
326
325
|
webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
|
|
327
326
|
webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check
|
|
@@ -329,19 +328,19 @@ webjs doctor # project-health checklist; non-zero exit on
|
|
|
329
328
|
webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
|
|
330
329
|
webjs typecheck [tsc args...] # the project's own tsc --noEmit
|
|
331
330
|
webjs create <name> [--template api|saas]
|
|
332
|
-
webjs db <
|
|
331
|
+
webjs db <generate|migrate|push|studio|seed> # wraps drizzle-kit (+ runs db/seed.server.ts)
|
|
333
332
|
webjs ui init | add <names...> | list | view <name>
|
|
334
333
|
webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json
|
|
335
334
|
```
|
|
336
335
|
|
|
337
|
-
`--from PROVIDER` accepts `jspm` (default), `jsdelivr`, `unpkg`, `skypack` and is persisted in the pin file. `PORT` is honoured when `--port` is absent; `webjs dev` emits `routes.d.ts` automatically. Running this repo's own apps (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`):
|
|
336
|
+
`--from PROVIDER` accepts `jspm` (default), `jsdelivr`, `unpkg`, `skypack` and is persisted in the pin file. `PORT` is honoured when `--port` is absent; `webjs dev` emits `routes.d.ts` automatically. Running this repo's own apps (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`): `cd` in and use **its** `npm run dev` / `npm start`; as of #550 a bare `webjs dev` / `webjs start` is equivalent (the per-app orchestration moved into the `webjs.dev` / `webjs.start` tasks config, which the primitive runs), so the npm scripts are now just thin aliases.
|
|
338
337
|
|
|
339
338
|
---
|
|
340
339
|
|
|
341
340
|
## Environment, server config, caching, observability
|
|
342
341
|
|
|
343
342
|
- **Env vars.** `process.env.X` reads are server-only; `WEBJS_PUBLIC_`-prefixed names are exposed in the browser via an inline `<script>` (no build); `NODE_ENV` is defined both sides. See `agent-docs/built-ins.md`.
|
|
344
|
-
- **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256),
|
|
343
|
+
- **The `package.json` `"webjs"` block.** Security headers (on by default, per-path `webjs.headers` overrides), CSP (opt-in nonce, `webjs.csp`), declarative `webjs.redirects` (#254), `webjs.trailingSlash` (#255), `webjs.basePath` (#256), ingress caps (`maxBodyBytes` / `maxMultipartBytes` / server timeouts), and dev/start task orchestration (`webjs.dev.before` / `webjs.dev.parallel` / `webjs.start.before`, #550, the orchestration `webjs dev`/`start` run so they match `npm run dev`/`start`). Type it with `WebjsConfig` + the JSON Schema. See `agent-docs/configuration.md`.
|
|
345
344
|
- **Caching + file storage** (`agent-docs/built-ins.md`). HTTP `Cache-Control`, the `cache()` query helper + `revalidateTag`, the server HTML response cache (`export const revalidate` + `revalidatePath`, #241), content-hash asset URLs (`?v=`, #243), conditional GET (ETag, #240), and `FileStore` + `diskStore` (streaming, traversal-safe, signed URLs, S3-pluggable, #247).
|
|
346
345
|
- **Observability** (`agent-docs/configuration.md`). Access log, `requestId()` + `X-Request-Id`, the `onError` APM hook, `GET /__webjs/version` (#239).
|
|
347
346
|
|
|
@@ -386,11 +385,13 @@ export default async function User({ params }: { params: { id: string } }) {
|
|
|
386
385
|
```ts
|
|
387
386
|
// modules/users/actions/update-profile.server.ts
|
|
388
387
|
'use server';
|
|
389
|
-
import {
|
|
388
|
+
import { eq } from 'drizzle-orm';
|
|
389
|
+
import { db } from '../../../db/connection.server.ts';
|
|
390
|
+
import { users } from '../../../db/schema.server.ts';
|
|
390
391
|
export async function updateProfile(input: { name: string }) {
|
|
391
392
|
const name = String(input?.name || '').trim();
|
|
392
393
|
if (!name) return { success: false, error: 'name required', status: 400 };
|
|
393
|
-
const row = await
|
|
394
|
+
const [row] = await db.update(users).set({ name }).where(eq(users.id, me.id)).returning();
|
|
394
395
|
return { success: true, data: row };
|
|
395
396
|
}
|
|
396
397
|
```
|
|
@@ -406,4 +407,4 @@ Not in v1. Do not implement as part of other tasks:
|
|
|
406
407
|
- **Bundling and per-route code splitting.** webjs is **no-build** (the Rails 7 + importmap model); prod perf comes from HTTP/2 multiplex + `<link rel="modulepreload">` hints, not concatenation. **Do not propose a bundler or `webjs build`.**
|
|
407
408
|
- **Vite-grade HMR with state preservation.** Custom elements only `define` once, so full reload is necessary; data reloads are near-instant via `fs.watch` to SSE.
|
|
408
409
|
- **React Server Components Flight.** Server actions + `Suspense` streaming cover the need.
|
|
409
|
-
- **Edge-runtime bundling / full portability** (
|
|
410
|
+
- **Edge-runtime bundling / full portability** (deployment guidance lives in the docs site at `/docs/deployment`), **i18n, image optimization** (layer libraries on top).
|
|
@@ -13,7 +13,7 @@ layer to debug.
|
|
|
13
13
|
```js
|
|
14
14
|
// route handler
|
|
15
15
|
export async function GET() {
|
|
16
|
-
const posts = await
|
|
16
|
+
const posts = await db.query.posts.findMany();
|
|
17
17
|
return new Response(JSON.stringify(posts), {
|
|
18
18
|
headers: {
|
|
19
19
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
|
|
@@ -51,10 +51,10 @@ until the TTL expires:
|
|
|
51
51
|
// modules/posts/queries/list-posts.server.ts
|
|
52
52
|
'use server';
|
|
53
53
|
import { cache } from '@webjsdev/server';
|
|
54
|
-
import {
|
|
54
|
+
import { db } from '../../../db/connection.server.ts';
|
|
55
55
|
|
|
56
56
|
export const listPosts = cache(
|
|
57
|
-
async () =>
|
|
57
|
+
async () => db.query.posts.findMany({ orderBy: { createdAt: 'desc' } }),
|
|
58
58
|
{ key: 'posts', ttl: 60 }
|
|
59
59
|
);
|
|
60
60
|
```
|
|
@@ -65,12 +65,12 @@ wrapper), add `tags`. It is either a static `string[]` or a function
|
|
|
65
65
|
|
|
66
66
|
```ts
|
|
67
67
|
export const postById = cache(
|
|
68
|
-
async (id: string) =>
|
|
68
|
+
async (id: string) => db.query.posts.findFirst({ where: { id } }),
|
|
69
69
|
{ key: 'post', ttl: 300, tags: (id) => ['post:' + id] } // per-entity tag
|
|
70
70
|
);
|
|
71
71
|
|
|
72
72
|
export const listPosts = cache(
|
|
73
|
-
async () =>
|
|
73
|
+
async () => db.query.posts.findMany(),
|
|
74
74
|
{ key: 'posts', ttl: 60, tags: ['posts'] } // static tag
|
|
75
75
|
);
|
|
76
76
|
```
|
|
@@ -83,9 +83,11 @@ no import of the wrapper):
|
|
|
83
83
|
// modules/comments/actions/create-comment.server.ts
|
|
84
84
|
'use server';
|
|
85
85
|
import { revalidateTag, revalidatePath } from '@webjsdev/server';
|
|
86
|
+
import { db } from '../../../db/connection.server.ts';
|
|
87
|
+
import { comments } from '../../../db/schema.server.ts';
|
|
86
88
|
|
|
87
89
|
export async function createComment(input) {
|
|
88
|
-
await
|
|
90
|
+
await db.insert(comments).values(input);
|
|
89
91
|
await revalidateTag('post:' + input.postId); // postById(postId) recomputes
|
|
90
92
|
await revalidateTag('posts'); // listPosts recomputes
|
|
91
93
|
await revalidatePath('/blog'); // also evict the cached HTML
|
|
@@ -163,7 +165,7 @@ Evict on a write with `revalidatePath`:
|
|
|
163
165
|
import { revalidatePath } from '@webjsdev/server';
|
|
164
166
|
|
|
165
167
|
export async function publishPost(input) {
|
|
166
|
-
// ... persist via
|
|
168
|
+
// ... persist via Drizzle ...
|
|
167
169
|
await revalidatePath('/blog'); // next /blog request re-renders fresh
|
|
168
170
|
return { success: true };
|
|
169
171
|
}
|
|
@@ -248,7 +250,7 @@ export const { auth, signIn, signOut, handlers } = createAuth({
|
|
|
248
250
|
providers: [
|
|
249
251
|
Credentials({
|
|
250
252
|
async authorize(credentials) {
|
|
251
|
-
const user = await
|
|
253
|
+
const user = await db.query.users.findFirst({ where: { email: credentials.email } });
|
|
252
254
|
if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null;
|
|
253
255
|
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
254
256
|
},
|
|
@@ -116,7 +116,9 @@ class Report extends WebComponent {
|
|
|
116
116
|
|
|
117
117
|
**Elision (#474).** A **bare** `async render()` (no other client signal, light DOM) is **elided** like any display-only component: the SSR'd data is the complete first paint, so the framework drops the module and the redundant on-hydration re-fetch (a common content / docs leaf shape). It ships only when it ALSO carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship. `static shadow = true` ships because Declarative Shadow DOM attaches only during HTML parsing, so a streamed (`<webjs-suspense>`) or soft-navigated shadow component arrives via a JS DOM insertion and needs its module to re-run `attachShadow`. `static refresh = true` is the explicit opt-in to keep the stale-while-revalidate on-load re-fetch that eliding drops (moot for request-stable data, the default; reach for it only when fresh-on-load matters). The only behaviour eliding removes is that on-load refresh; the first paint is byte-identical, verified by the differential-elision guard.
|
|
118
118
|
|
|
119
|
-
**SSR action seeding (#472).** For a SHIPPING async component (one that hydrates because it carries an interactivity signal), the on-hydration re-fetch is eliminated without eliding the module. Each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page as one `<script type="application/json" id="__webjs-seeds">` block, keyed by the action's hash + function name + serialized args. The generated client RPC stub reads that seed on its FIRST call: a hit resolves synchronously (no network, no hydration flicker), a later refetch or arg-change misses (consume-once) and goes to RPC as normal. So `const u = await getUser(this.id)` runs once, server-side, and its result is reused on the client's first render. The capture is a transparent server-side facade over the `'use server'` module (a synchronous
|
|
119
|
+
**SSR action seeding (#472).** For a SHIPPING async component (one that hydrates because it carries an interactivity signal), the on-hydration re-fetch is eliminated without eliding the module. Each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page as one `<script type="application/json" id="__webjs-seeds">` block, keyed by the action's hash + function name + serialized args. The generated client RPC stub reads that seed on its FIRST call: a hit resolves synchronously (no network, no hydration flicker), a later refetch or arg-change misses (consume-once) and goes to RPC as normal. So `const u = await getUser(this.id)` runs once, server-side, and its result is reused on the client's first render. The capture is a transparent server-side facade over the `'use server'` module (a synchronous load hook wraps each export in a recording `Proxy`), so there is **no source transform and no build step**: the browser source tab and the on-disk files are byte-unchanged, and the RPC stub the browser fetches is the same shape as before (it just consults the seed first). The install mechanism is runtime-neutral (#529): Node uses `module.registerHooks`, Bun uses a `Bun.plugin` `onLoad` (Bun has no `module.registerHooks`), and both emit the identical seed, so a page seeds the same on either runtime. Fail-open by construction: a key miss is always a normal RPC, never wrong data. Default on; opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0` (then the client re-fetches on hydration, hidden by stale-while-revalidate as before). A soft navigation carries the seed too: the client router ingests the incoming page's payload (`applySwap` -> `scanSeeds`) before its components hydrate, so a navigated async component also skips the refetch. Streamed regions (`<webjs-suspense>`) are NOT seeded (their data resolves after the first flush), so a slow boundary keeps the stale-while-revalidate refetch.
|
|
120
|
+
|
|
121
|
+
**Interactivity during a COLD on-hydration re-fetch (#528).** Seeding matters for more than a wasted round trip. When a SHIPPING async component DOES re-fetch on hydration (seeding off via `WEBJS_SEED=0`, a `static refresh = true` leaf, or a seed miss), its FIRST client commit is deferred until that re-fetch resolves, and that commit is the step that binds the component's event listeners. So for the fetch's duration the SSR'd markup is visible but NOT yet interactive: an `@click` does nothing until the data lands (the window equals the action's latency, not a constant). Seeding erases this by resolving the first render synchronously, so the component hydrates and binds its listeners immediately, which is the default and works on both Node and Bun (#529, seeding is no longer Node-only). The component is fully reactive once that first commit lands: this is purely a cold-start window, NOT a lost signal subscription (a bump after the commit re-renders normally). To keep an async leaf interactive from the first paint regardless, give it a synchronous first render (read request-known data from a prop / the constructor) and move the async work to a signal-driven `Task` or an `@event` handler.
|
|
120
122
|
|
|
121
123
|
### Streaming a slow region with `<webjs-suspense>` (#471)
|
|
122
124
|
|
|
@@ -208,6 +208,37 @@ Returns JSON describing the live build, alongside the `/__webjs/health` and `/__
|
|
|
208
208
|
|
|
209
209
|
---
|
|
210
210
|
|
|
211
|
+
## Dev/start task orchestration: `webjs.dev` / `webjs.start` (#550)
|
|
212
|
+
|
|
213
|
+
`webjs dev` and `webjs start` run an app's per-environment orchestration
|
|
214
|
+
themselves, read from the `package.json` `"webjs"` block, so the framework
|
|
215
|
+
primitive is not a degraded run versus `npm run dev` / `npm start`. The npm
|
|
216
|
+
scripts become thin aliases (`"dev": "webjs dev"`), and both forms behave
|
|
217
|
+
identically. This replaces the old `predev` / `prestart` npm hooks +
|
|
218
|
+
`concurrently` watchers (which a bare `webjs dev` silently skipped, #452).
|
|
219
|
+
|
|
220
|
+
```jsonc
|
|
221
|
+
"webjs": {
|
|
222
|
+
"dev": {
|
|
223
|
+
"before": ["webjs db migrate"], // one-shot, runs to completion first
|
|
224
|
+
"parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"] // long-lived, runs alongside the server
|
|
225
|
+
},
|
|
226
|
+
"start": {
|
|
227
|
+
"before": ["webjs db migrate"] // one-shot, before serving
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- **`before`** (dev and start): commands run sequentially to completion BEFORE the server boots (the old `predev` / `prestart`: `webjs db migrate`, a registry copy). A non-zero exit ABORTS the boot with a clear message, so a failed migration never serves stale code/schema.
|
|
233
|
+
- **`parallel`** (dev only): long-lived child processes that run ALONGSIDE the server (the old `concurrently` watchers: the Tailwind CLI `--watch`). They are spawned once in the parent (not on every hot-reload restart) and TORN DOWN on exit (SIGINT / SIGTERM / server exit), so a watcher cannot leak past the dev server.
|
|
234
|
+
- Each command runs through a shell, so a normal command line works. An empty / absent block means `webjs dev` / `start` run unchanged, so a plain app with no Tailwind/DB needs no config.
|
|
235
|
+
- The scaffold uses the Tailwind browser runtime (no CSS build step), so it ships only `dev.before` / `start.before` (the Drizzle migration apply); an app that adds the Tailwind CLI puts its `--watch` under `webjs.dev.parallel`. The in-repo apps (`examples/blog`, `website`, `docs`, ui-website) show the Tailwind `parallel` watcher pattern.
|
|
236
|
+
- **Prod note:** `before` runs at boot, so `webjs start` runs `webjs db migrate` in-process to apply pending migrations. Drizzle has no client-codegen step (the schema IS the types, inferred at compile time), so there is nothing to run at image-BUILD time. Authoring a new migration from a schema change is a dev-time `webjs db generate`, committed to source control, not a boot step. So `CMD ["npm", "start"]` and `CMD ["webjs", "start"]` are equivalent.
|
|
237
|
+
|
|
238
|
+
Read by `readAppTasks` in `packages/cli/lib/app-tasks.js` (pure, unit-tested); orchestrated in `packages/cli/bin/webjs.js` (`runBeforeSteps` / `startParallelTasks`).
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
211
242
|
## Server port resolution (`--port` / `PORT` / `.env`) (#447)
|
|
212
243
|
|
|
213
244
|
Both `webjs dev` and `webjs start` resolve the listen port with a single
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Framework development (editing webjs itself)
|
|
2
2
|
|
|
3
|
-
Read this only when editing the webjs monorepo (this repo), not a scaffolded app. The repo is buildless: `packages/` is plain `.js` with JSDoc (never add `.ts` there); TypeScript is fine in `examples/`, `docs/`, `website/`. Each in-repo app (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`) is run
|
|
3
|
+
Read this only when editing the webjs monorepo (this repo), not a scaffolded app. The repo is buildless: `packages/` is plain `.js` with JSDoc (never add `.ts` there); TypeScript is fine in `examples/`, `docs/`, `website/`. Each in-repo app (`website/`, `docs/`, `examples/blog/`, `packages/ui/packages/website/`) is run from its OWN dir via `npm run dev` / `npm start`; as of #550 a bare `webjs dev` / `webjs start` is equivalent (each app's per-environment orchestration, the Tailwind watcher, `webjs db migrate`, the registry copy, moved into its `webjs.dev` / `webjs.start` tasks config, which `webjs dev`/`start` run). The sections below cover the repo-health git config, the changelog flow, and the dev error overlay.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -13,41 +13,36 @@ schema with the real domain models, then build features on top. This is the
|
|
|
13
13
|
transition agents most often get wrong, so it is the first recipe.
|
|
14
14
|
|
|
15
15
|
> **Two non-negotiables.** NEVER leave the example `User` model in
|
|
16
|
-
> `schema.
|
|
17
|
-
> it). NEVER persist app data in JSON files (`data/todos.json`,
|
|
18
|
-
> a module-scope array or `Map`, or in `localStorage`. Those
|
|
19
|
-
> reload and cannot scale. Every piece of stored data is a
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// prisma/schema.prisma
|
|
26
|
-
model Post {
|
|
27
|
-
id String @id @default(cuid())
|
|
28
|
-
title String
|
|
29
|
-
body String
|
|
30
|
-
published Boolean @default(false)
|
|
31
|
-
createdAt DateTime @default(now())
|
|
32
|
-
}
|
|
33
|
-
```
|
|
16
|
+
> `db/schema.server.ts` if the app does not actually have users (delete or
|
|
17
|
+
> replace it). NEVER persist app data in JSON files (`data/todos.json`,
|
|
18
|
+
> `db.json`), in a module-scope array or `Map`, or in `localStorage`. Those
|
|
19
|
+
> reset on every reload and cannot scale. Every piece of stored data is a
|
|
20
|
+
> Drizzle table.
|
|
21
|
+
|
|
22
|
+
1. **Edit `db/schema.server.ts`** to the real domain. Replace the example
|
|
23
|
+
`User` model with the models the app needs (the helpers come from
|
|
24
|
+
`db/columns.server.ts`).
|
|
34
25
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
26
|
+
```ts
|
|
27
|
+
// db/schema.server.ts
|
|
28
|
+
import { table, pk, text, bool, createdAt, index } from './columns.server.ts';
|
|
29
|
+
export const posts = table('posts', {
|
|
30
|
+
id: pk(),
|
|
31
|
+
title: text().notNull(),
|
|
32
|
+
body: text().notNull(),
|
|
33
|
+
published: bool().notNull().default(false),
|
|
34
|
+
createdAt: createdAt(),
|
|
35
|
+
}, (t) => [index(t.createdAt)]);
|
|
36
|
+
```
|
|
40
37
|
|
|
41
|
-
2. **
|
|
42
|
-
|
|
38
|
+
2. **Generate + migrate.** Drizzle has no codegen; `generate` turns the schema
|
|
39
|
+
into SQL, `migrate` applies it:
|
|
43
40
|
|
|
44
41
|
```sh
|
|
45
|
-
npm run db:
|
|
42
|
+
npm run db:generate # schema -> SQL migration (drizzle-kit)
|
|
43
|
+
npm run db:migrate # apply it to the dev SQLite database
|
|
46
44
|
```
|
|
47
45
|
|
|
48
|
-
This creates the migration, applies it to the dev SQLite database, and
|
|
49
|
-
regenerates the Prisma client.
|
|
50
|
-
|
|
51
46
|
3. **Generate one query and one action per operation**, one exported function
|
|
52
47
|
per file, named after the file, under the feature module. Reads go in
|
|
53
48
|
`queries/`, mutations in `actions/`. Both are `.server.ts` with
|
|
@@ -56,27 +51,28 @@ transition agents most often get wrong, so it is the first recipe.
|
|
|
56
51
|
```ts
|
|
57
52
|
// modules/posts/queries/list-posts.server.ts
|
|
58
53
|
'use server';
|
|
59
|
-
import {
|
|
54
|
+
import { db } from '../../../db/connection.server.ts';
|
|
60
55
|
export async function listPosts() {
|
|
61
|
-
return
|
|
56
|
+
return db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' } });
|
|
62
57
|
}
|
|
63
58
|
```
|
|
64
59
|
|
|
65
60
|
```ts
|
|
66
61
|
// modules/posts/actions/create-post.server.ts
|
|
67
62
|
'use server';
|
|
68
|
-
import {
|
|
63
|
+
import { db } from '../../../db/connection.server.ts';
|
|
64
|
+
import { posts } from '../../../db/schema.server.ts';
|
|
69
65
|
export async function createPost(input: { title: string; body: string }) {
|
|
70
66
|
const title = String(input?.title || '').trim();
|
|
71
67
|
if (!title) return { success: false, error: 'title required', status: 400 };
|
|
72
|
-
const post = await
|
|
68
|
+
const [post] = await db.insert(posts).values({ title, body: String(input?.body || '') }).returning();
|
|
73
69
|
return { success: true, data: post };
|
|
74
70
|
}
|
|
75
71
|
```
|
|
76
72
|
|
|
77
73
|
4. **Wire it into a page** by calling the query (the page runs on the server,
|
|
78
74
|
so it imports the `.server` query directly and awaits it). Never import
|
|
79
|
-
|
|
75
|
+
the DB driver into a page; reach the database through the query.
|
|
80
76
|
|
|
81
77
|
```ts
|
|
82
78
|
// app/posts/page.ts
|
|
@@ -118,11 +114,13 @@ export default async function User({ params }: { params: { id: string } }) {
|
|
|
118
114
|
```ts
|
|
119
115
|
// modules/users/actions/update-profile.server.ts
|
|
120
116
|
'use server';
|
|
121
|
-
import {
|
|
117
|
+
import { eq } from 'drizzle-orm';
|
|
118
|
+
import { db } from '../../../db/connection.server.ts';
|
|
119
|
+
import { users } from '../../../db/schema.server.ts';
|
|
122
120
|
export async function updateProfile(input: { name: string }) {
|
|
123
121
|
const name = String(input?.name || '').trim();
|
|
124
122
|
if (!name) return { success: false, error: 'name required', status: 400 };
|
|
125
|
-
const row = await
|
|
123
|
+
const [row] = await db.update(users).set({ name }).where(eq(users.id, me.id)).returning();
|
|
126
124
|
return { success: true, data: row };
|
|
127
125
|
}
|
|
128
126
|
```
|
|
@@ -143,7 +141,8 @@ validator is a plain function (or a three-line zod adapter).
|
|
|
143
141
|
```ts
|
|
144
142
|
// modules/posts/actions/create-post.server.ts
|
|
145
143
|
'use server';
|
|
146
|
-
import {
|
|
144
|
+
import { db } from '../../../db/connection.server.ts';
|
|
145
|
+
import { posts } from '../../../db/schema.server.ts';
|
|
147
146
|
|
|
148
147
|
// the validator: receives the action's FIRST argument
|
|
149
148
|
export const validate = (input: any) => {
|
|
@@ -157,7 +156,7 @@ export const validate = (input: any) => {
|
|
|
157
156
|
|
|
158
157
|
// the action body: runs ONLY when validation passes
|
|
159
158
|
export async function createPost(input: { title: string; body: string }) {
|
|
160
|
-
const row = await
|
|
159
|
+
const [row] = await db.insert(posts).values(input).returning();
|
|
161
160
|
return { success: true, data: row };
|
|
162
161
|
}
|
|
163
162
|
```
|
|
@@ -410,7 +409,9 @@ the user-supplied filename as a key; `generateKey` makes an opaque one.
|
|
|
410
409
|
// modules/avatar/actions/save-avatar.server.ts
|
|
411
410
|
'use server';
|
|
412
411
|
import { getFileStore, generateKey } from '@webjsdev/server';
|
|
413
|
-
import {
|
|
412
|
+
import { eq } from 'drizzle-orm';
|
|
413
|
+
import { db } from '../../../db/connection.server.ts';
|
|
414
|
+
import { users } from '../../../db/schema.server.ts';
|
|
414
415
|
|
|
415
416
|
export async function saveAvatar(file: File) {
|
|
416
417
|
const key = generateKey(file.name); // <uuid>.<ext>, safe
|
|
@@ -419,7 +420,7 @@ export async function saveAvatar(file: File) {
|
|
|
419
420
|
await getFileStore().delete(key);
|
|
420
421
|
return { success: false, fieldErrors: { avatar: 'Max 5 MB' }, status: 422 };
|
|
421
422
|
}
|
|
422
|
-
await
|
|
423
|
+
await db.update(users).set({ avatarKey: key }).where(eq(users.id, 'me'));
|
|
423
424
|
return { success: true, data: { key, contentType } };
|
|
424
425
|
}
|
|
425
426
|
```
|
|
@@ -108,6 +108,51 @@ cross-package suite (`packages/*/test/` + `test/`). They do NOT walk the in-repo
|
|
|
108
108
|
apps' own test dirs (`website/test/`, `examples/blog/test/`), so those run from
|
|
109
109
|
each app's OWN `webjs test` script, not the root runner.
|
|
110
110
|
|
|
111
|
+
### The Bun test matrix (#509)
|
|
112
|
+
|
|
113
|
+
webjs runs on Node 24+ or Bun (#508). The **Node suite (`npm test`) is the
|
|
114
|
+
source of truth**; a separate, additive **Bun matrix** re-runs the
|
|
115
|
+
runtime-sensitive suite under Bun to catch the long tail of cross-runtime
|
|
116
|
+
incompatibilities (a `node:*` API Bun implements differently, a crypto/stream
|
|
117
|
+
edge case, an error-message-format quirk).
|
|
118
|
+
|
|
119
|
+
- `node scripts/run-bun-tests.js` (needs `bun` on PATH; `BUN=…` overrides) runs
|
|
120
|
+
the `node:test` files under `test/`, `packages/core/test/`, and
|
|
121
|
+
`packages/server/test/` (excluding `browser/`, `e2e/`, the network-bound
|
|
122
|
+
`vendor/`) file by file via `bun test <file>`, and CLASSIFIES each result:
|
|
123
|
+
**pass**, **skip(node-only)** (a documented file that asserts Node-only
|
|
124
|
+
behavior or trips a Bun-test-runner quirk, listed with a reason in the
|
|
125
|
+
script's `DENYLIST`, its Bun behavior covered elsewhere), **skip(harness)**
|
|
126
|
+
(a Bun `node:test` compat gap, auto-detected by error signature),
|
|
127
|
+
**skip(env)** (needs Redis / a DOM), and **genuine fail** (a real Bun
|
|
128
|
+
failure, which fails the job). Set `WEBJS_BUN_TESTS=<substr,…>` to scope a
|
|
129
|
+
local run.
|
|
130
|
+
- CI runs it in the `bun` job alongside `test/bun/smoke.mjs` (#508) and
|
|
131
|
+
`test/bun/listener.mjs` (#511, the listener-shell parity).
|
|
132
|
+
- Two cross-runtime test scripts also run under BOTH runtimes: `test/bun/smoke.mjs`
|
|
133
|
+
(boot + SSR + TS strip + a server-action RPC) and `test/bun/listener.mjs`
|
|
134
|
+
(`startServer` over a real socket: SSR + route + SSE + WebSocket). Plain assert
|
|
135
|
+
scripts (not `node:test`) so the same file runs identically on each runtime.
|
|
136
|
+
|
|
137
|
+
**Dev hot-reload of server modules (cross-runtime, #514).** webjs's dev server
|
|
138
|
+
re-imports a `route.ts` / `.server.ts` / page module per request with a
|
|
139
|
+
`?t=<timestamp>` query cache-bust to pick up edits. Node honors that query and
|
|
140
|
+
re-imports, so under `node --watch` an edit is picked up live. Bun's ESM loader
|
|
141
|
+
IGNORES the query string and exposes no module-eviction API, so that mechanism
|
|
142
|
+
alone leaves a server-side edit stale on Bun. The CLI closes the gap by
|
|
143
|
+
re-execing `webjs dev` under `bun --hot` on Bun (vs `node --watch` on Node),
|
|
144
|
+
whose file-watching cache invalidation makes the next re-import fresh with no
|
|
145
|
+
restart (`Bun.serve` is reused across hot reloads, so the listener is not
|
|
146
|
+
duplicated). The cross-runtime test `test/bun/dev-hot-reload.mjs` proves the
|
|
147
|
+
edit-picked-up behaviour on BOTH runtimes (and runs as a dedicated CI step on
|
|
148
|
+
Bun). The `api` dev-cache-bust UNIT test asserts the bare server-level `?t=`
|
|
149
|
+
mechanism directly (no supervisor), which Bun ignores by design, so it stays on
|
|
150
|
+
the Bun-matrix denylist; the user-facing hot reload it underpins is covered on
|
|
151
|
+
Bun by the dev-hot-reload script instead. A `--no-hot` flag opts out of the
|
|
152
|
+
supervisor on either runtime (run the server in-process, edits need a manual
|
|
153
|
+
restart). Component / page / layout SOURCE edits already hot-reload on both
|
|
154
|
+
runtimes (the served `.ts`/`.js` is read from disk per request, not imported).
|
|
155
|
+
|
|
111
156
|
### In-repo app tests in CI (#342)
|
|
112
157
|
|
|
113
158
|
Each in-repo app (`website`, `examples/blog`) carries its own test suite under
|
|
@@ -116,7 +161,7 @@ its `test/` dir and runs it through its own `webjs test` script (the website's
|
|
|
116
161
|
not discover these, so a dedicated `.github/workflows/ci.yml` job, **In-repo app
|
|
117
162
|
tests (website + blog)**, runs `npm test --workspace=@webjsdev/website` and
|
|
118
163
|
`npm test --workspace=@webjsdev/example-blog` (with Playwright installed for the
|
|
119
|
-
website browser tests and the
|
|
164
|
+
website browser tests and the SQLite DB prepared for the blog, the same setup
|
|
120
165
|
the `unit` + `e2e` jobs use). It is a required status check, so a regression in
|
|
121
166
|
an app's tests gates the merge. The app test dirs are not walked by the root
|
|
122
167
|
runner, so the framework-package tests never double-run. `docs` and the
|
|
@@ -6,12 +6,13 @@ run is part of the user-visible workflow, no separate build step:
|
|
|
6
6
|
|
|
7
7
|
- **Editor** (VS Code) runs the TypeScript language server continuously. Red-squiggle on wrong types, including non-erasable syntax (see below).
|
|
8
8
|
- **CI** (optional) runs `tsc --noEmit` against `tsconfig.json`. Type-check only. Also catches non-erasable syntax via `erasableSyntaxOnly`.
|
|
9
|
-
- **Dev + prod server** (runtime, both directions):
|
|
9
|
+
- **Dev + prod server** (runtime, both directions): the runtime's TypeScript type-stripping handles server-side `.ts` imports automatically (on Node, `process.features.typescript === 'strip'`; Bun runs `.ts` natively). Browser-bound `.ts` requests go through the pluggable stripper in `packages/server/src/ts-strip.js` (#508) on the dev server, which performs whitespace replacement: every (line, column) in the source maps to the same position in the stripped output, so no sourcemap needs to be shipped and stack traces are byte-exact. The transform is cached by mtime (~microseconds per cache hit). Implementation backing: on **Node 24+** it is the built-in `module.stripTypeScriptTypes` (which itself wraps the [`amaro`](https://github.com/nodejs/amaro) package, SWC's WASM TypeScript transform in position-preserving strip-only mode); on **Bun** (no such built-in) it is `amaro` loaded directly, an `optionalDependency` of `@webjsdev/server`, producing byte-identical output. `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend. Edge runtimes without a filesystem are a separate, later target.
|
|
10
10
|
|
|
11
11
|
## TypeScript feature support: erasable only
|
|
12
12
|
|
|
13
|
-
The framework
|
|
14
|
-
which only supports **erasable
|
|
13
|
+
The framework strips with Node 24+'s built-in `module.stripTypeScriptTypes`
|
|
14
|
+
(or `amaro` on Bun, the same engine), which only supports **erasable
|
|
15
|
+
TypeScript**: type annotations,
|
|
15
16
|
`interface`, `type`, `declare`, generics, `import type`, `as` casts,
|
|
16
17
|
and `satisfies`. Non-erasable syntax is rejected.
|
|
17
18
|
|
|
@@ -62,15 +63,15 @@ off so you catch the configuration drift before runtime.
|
|
|
62
63
|
|
|
63
64
|
## Import convention
|
|
64
65
|
|
|
65
|
-
Use explicit `.ts` extensions in imports.
|
|
66
|
-
|
|
66
|
+
Use explicit `.ts` extensions in imports. The runtime's type-stripping
|
|
67
|
+
and the dev server's HTTP handler both key on the
|
|
67
68
|
file URL ending in `.ts` / `.mts`. For mixed codebases, `.js` imports
|
|
68
69
|
that point at a `.ts` sibling also resolve in the dev server. Still
|
|
69
70
|
prefer explicit `.ts`.
|
|
70
71
|
|
|
71
72
|
```ts
|
|
72
73
|
// modules/posts/queries/list-posts.server.ts
|
|
73
|
-
import {
|
|
74
|
+
import { db } from '../../../db/connection.server.js'; // .js specifier resolves the .ts sibling
|
|
74
75
|
import { formatPost } from '../utils/slugify.ts'; // TS file
|
|
75
76
|
```
|
|
76
77
|
|
package/src/mcp-docs.js
CHANGED
|
@@ -333,11 +333,13 @@ const PROMPT_BODIES = {
|
|
|
333
333
|
'```ts',
|
|
334
334
|
'// modules/<feature>/actions/<name>.server.ts',
|
|
335
335
|
"'use server';",
|
|
336
|
-
"import {
|
|
336
|
+
"import { db } from '../../../db/connection.server.ts';",
|
|
337
|
+
"import { things } from '../../../db/schema.server.ts';",
|
|
337
338
|
'export async function doThing(input: { name: string }) {',
|
|
338
339
|
" const name = String(input?.name || '').trim();",
|
|
339
340
|
" if (!name) return { success: false, error: 'name required', status: 400 };",
|
|
340
|
-
'
|
|
341
|
+
' const [row] = await db.insert(things).values({ name }).returning();',
|
|
342
|
+
' return { success: true, data: row };',
|
|
341
343
|
'}',
|
|
342
344
|
'```',
|
|
343
345
|
'',
|
package/src/mcp.js
CHANGED
|
@@ -155,7 +155,7 @@ const TOOL_DEFS = [
|
|
|
155
155
|
/**
|
|
156
156
|
* Lexically extract the names exported from a module source. Recognises the
|
|
157
157
|
* common forms a server-action / route file uses without LOADING the module
|
|
158
|
-
* (loading would run its top-level side effects:
|
|
158
|
+
* (loading would run its top-level side effects: DB init, connections).
|
|
159
159
|
*
|
|
160
160
|
* export async function foo() {} export function foo() {}
|
|
161
161
|
* export const foo = ... export let/var foo = ...
|
|
@@ -216,7 +216,7 @@ export function extractRouteMethods(src) {
|
|
|
216
216
|
/**
|
|
217
217
|
* Lexically extract the HTTP-verb action config declared as reserved sibling
|
|
218
218
|
* exports in a `'use server'` file (#488). Reads source text only (no module
|
|
219
|
-
* load) so there are no DB-init
|
|
219
|
+
* load) so there are no DB-init side effects.
|
|
220
220
|
*
|
|
221
221
|
* Returned shape:
|
|
222
222
|
* {
|
|
@@ -343,7 +343,7 @@ export function makeToolRunners(deps) {
|
|
|
343
343
|
|
|
344
344
|
async list_actions(appDir) {
|
|
345
345
|
// buildActionIndex is a pure file -> hash mapping that imports no module,
|
|
346
|
-
// so this stays truly read-only (no
|
|
346
|
+
// so this stays truly read-only (no DB init, and no stray stdout
|
|
347
347
|
// from a loaded module corrupting the JSON-RPC channel). The RPC hash is
|
|
348
348
|
// over the file path only, so no module load is needed.
|
|
349
349
|
const idx = await buildActionIndex(appDir, false);
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
# SSR partial navigation: design note
|
|
2
|
-
|
|
3
|
-
**Status:** SHIPPED (feature/nested-layout-partial-swap, 2026-05-16).
|
|
4
|
-
The mechanism described below is implemented and tested. This document
|
|
5
|
-
is preserved as the design record. Runtime reference for callers lives
|
|
6
|
-
in `agent-docs/advanced.md` (Client router section) and the framework
|
|
7
|
-
API table in `AGENTS.md`.
|
|
8
|
-
|
|
9
|
-
**Motivating bug (resolved):** ui-website docs sidenav lost scroll on
|
|
10
|
-
every link click because the docs layout sat 2 levels deep under the
|
|
11
|
-
root layout, beyond `findLayoutShell`'s body-direct-child probe.
|
|
12
|
-
**Previous workaround (now deleted):** `app/docs/layout.ts` saved /
|
|
13
|
-
restored `.docs-sidenav` `scrollTop` via `sessionStorage` on every
|
|
14
|
-
`webjs:navigate` event. Removed in the same PR as the framework fix.
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
**What actually shipped vs. what's below:**
|
|
19
|
-
- The recommendation in this doc was `<webjs-frame>` as the primary
|
|
20
|
-
primitive. During design discussion the decision evolved to make
|
|
21
|
-
layout-marker discovery **auto-derived from folder structure**, so
|
|
22
|
-
layout authors write nothing. `<webjs-frame>` ships as the escape
|
|
23
|
-
hatch for non-layout partial-swap regions (rare).
|
|
24
|
-
- The marker format is `<!--wj:children:<segment-path>-->` comment
|
|
25
|
-
pairs (Remix v3 lineage), not the `<webjs-frame>`-element approach
|
|
26
|
-
sketched below.
|
|
27
|
-
- Wire-byte optimization, snapshot cache, keyed DOM diff with live-
|
|
28
|
-
attribute preservation, and per-segment `<template id="wj-loading:...">`
|
|
29
|
-
cloning all shipped in the same PR (originally deferred as v2+).
|
|
30
|
-
|
|
31
|
-
The original `<webjs-frame>`-centric sketch below is preserved as
|
|
32
|
-
historical context.
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
## Goal
|
|
37
|
-
|
|
38
|
-
Preserve the DOM of any layout, at any depth, across same-origin navigations. Re-render only the deepest segment that actually changed.
|
|
39
|
-
|
|
40
|
-
## Non-goals
|
|
41
|
-
|
|
42
|
-
- Per-segment data fetching (Remix v3 `<Frame src>` style). Wire model stays one SSR response per nav.
|
|
43
|
-
- React-style reconciler with full keyed-DOM diff inside the swap region. Out of scope for v1, can come later.
|
|
44
|
-
- Parallel routes / intercepting routes (Next.js feature). Separate design.
|
|
45
|
-
|
|
46
|
-
## Background: how the four references handle this
|
|
47
|
-
|
|
48
|
-
| Framework | Mechanism | Wire format | Scope decided by |
|
|
49
|
-
|---|---|---|---|
|
|
50
|
-
| **Turbo** | `<turbo-frame id="X">` (flat DOM element) | full HTML response, server may optimize via `Turbo-Frame: X` header | Innermost enclosing `<turbo-frame>` of the click (`closest()`) |
|
|
51
|
-
| **Remix v3** | `<!--rmx:f:id-->...<!--/rmx:f-->` comment markers + per-frame `src` | per-frame HTML or `<template id>` streams | Author-declared `<Frame name="...">` + `rmx-target` on link |
|
|
52
|
-
| **Next.js App Router** | Recursive `FlightRouterState` tuple + per-segment `CacheNode` tree | RSC Flight (`react-server-dom-webpack`) | Server walks the tree, returns from divergence point |
|
|
53
|
-
| **Lit Labs** | `Routes` controller with `outlet()` + child controllers via `RoutesConnectedEvent` | full template re-render (no partial scoping) | N/A (full subtree re-render every nav) |
|
|
54
|
-
|
|
55
|
-
**Closest fit to webjs's current router:** Turbo. webjs already mirrors Turbo Drive (link interception, body swap, `pushState`, `data-no-router` ≡ `data-turbo="false"`).
|
|
56
|
-
|
|
57
|
-
## Recommendation
|
|
58
|
-
|
|
59
|
-
Adopt a Turbo-style frame primitive: `<webjs-frame id="...">`. Layouts that want partial-swap behavior wrap their replaceable region:
|
|
60
|
-
|
|
61
|
-
```ts
|
|
62
|
-
// app/docs/layout.ts
|
|
63
|
-
import { html } from '@webjsdev/core';
|
|
64
|
-
import { sidenav } from './sidenav.ts';
|
|
65
|
-
|
|
66
|
-
export default function DocsLayout({ children }) {
|
|
67
|
-
return html`
|
|
68
|
-
<div class="docs-grid">
|
|
69
|
-
${sidenav()}
|
|
70
|
-
<webjs-frame id="docs-content">${children}</webjs-frame>
|
|
71
|
-
</div>
|
|
72
|
-
`;
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### Algorithm: `packages/core/src/router-client.js` delta
|
|
77
|
-
|
|
78
|
-
Existing `findLayoutShell(body)` stays as a fallback. Add `findActiveFrame(linkEl)`:
|
|
79
|
-
|
|
80
|
-
```js
|
|
81
|
-
function findActiveFrame(linkEl) {
|
|
82
|
-
// Walk up through shadow boundaries and into light DOM via composedPath at call site.
|
|
83
|
-
const frame = linkEl.closest('webjs-frame');
|
|
84
|
-
return frame ? frame.id : null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function navigate(url, event) {
|
|
88
|
-
const frameId = event ? findActiveFrame(event.target) : null;
|
|
89
|
-
|
|
90
|
-
const res = await fetch(url, {
|
|
91
|
-
headers: frameId ? { 'X-Webjs-Frame': frameId } : {},
|
|
92
|
-
});
|
|
93
|
-
if (!res.headers.get('content-type')?.startsWith('text/html')) {
|
|
94
|
-
// existing fallback: full nav
|
|
95
|
-
window.location.href = url;
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const html = await res.text();
|
|
100
|
-
const incoming = Document.parseHTMLUnsafe(html);
|
|
101
|
-
|
|
102
|
-
// 1. Frame path: preferred if active frame exists in both.
|
|
103
|
-
if (frameId) {
|
|
104
|
-
const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
105
|
-
const source = incoming.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
106
|
-
if (target && source) {
|
|
107
|
-
target.replaceChildren(...source.childNodes);
|
|
108
|
-
mergeHead(incoming.head);
|
|
109
|
-
runFrameScripts(target);
|
|
110
|
-
customElements.upgrade(target);
|
|
111
|
-
history.pushState({}, '', url);
|
|
112
|
-
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url, frameId } }));
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 2. Existing layout-shell path (one level deep).
|
|
118
|
-
const shell = findLayoutShell(document.body);
|
|
119
|
-
const incomingShell = shell ? findLayoutShell(incoming.body) : null;
|
|
120
|
-
if (shell && incomingShell && shellsMatch(shell, incomingShell)) {
|
|
121
|
-
swapShellContent(shell, incomingShell);
|
|
122
|
-
/* existing path... */
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// 3. Full body swap (existing fallback).
|
|
127
|
-
document.body.replaceChildren(...incoming.body.childNodes);
|
|
128
|
-
/* existing path... */
|
|
129
|
-
}
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
That's the entire detection delta: a `querySelector` keyed by the active frame's id, with the existing logic preserved as fallback.
|
|
133
|
-
|
|
134
|
-
### `<webjs-frame>` element: ~30 lines
|
|
135
|
-
|
|
136
|
-
```js
|
|
137
|
-
// packages/core/src/webjs-frame.js
|
|
138
|
-
import { WebComponent, html } from './index.js';
|
|
139
|
-
|
|
140
|
-
export class WebjsFrame extends WebComponent {
|
|
141
|
-
static properties = { id: { type: String, reflect: true } };
|
|
142
|
-
render() { return html`<slot></slot>`; }
|
|
143
|
-
}
|
|
144
|
-
WebjsFrame.register('webjs-frame');
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Light DOM (default): no shadow boundary, no slot mechanics. The element exists purely as a swap anchor with an addressable `id`. Children are normal light-DOM children that the router replaces via `replaceChildren`.
|
|
148
|
-
|
|
149
|
-
### Server side: `X-Webjs-Frame` request header (optional optimization)
|
|
150
|
-
|
|
151
|
-
When set, the SSR pipeline can return only the matching frame's HTML wrapped in a minimal stub document, skipping the rest of the layout chain. Wire is still plain HTML (no new format). v1 ships **without** this optimization. Full SSR response, client extracts what it needs. The header is forward-compat for the perf pass.
|
|
152
|
-
|
|
153
|
-
### Head merging
|
|
154
|
-
|
|
155
|
-
Same as today's `mergeHead`: replace `<title>`, merge `<meta>` tags by `name`/`property`, append new `<link>`/`<style>` elements, dedupe.
|
|
156
|
-
|
|
157
|
-
### Script handling inside the swap region
|
|
158
|
-
|
|
159
|
-
Re-execute `<script>` elements that match the existing one-level-shell path's `runScripts` logic. Idempotent registration via `Class.register()` makes this safe. The framework already handles `customElements.define` collisions.
|
|
160
|
-
|
|
161
|
-
## Edge cases
|
|
162
|
-
|
|
163
|
-
| Case | Behavior |
|
|
164
|
-
|---|---|
|
|
165
|
-
| Click on a link inside `<webjs-frame>` but `data-no-router` | Full browser navigation (existing semantics) |
|
|
166
|
-
| Click on a link *outside* any frame, both pages share a `findLayoutShell` match | Falls through to existing layout-shell path |
|
|
167
|
-
| Frame in old page but not in new (route change leaves the layout tree) | Frame lookup fails → fall to layout-shell or full body swap. Correct. |
|
|
168
|
-
| Nested `<webjs-frame>`s | Innermost wins: `closest('webjs-frame')` returns the nearest enclosing frame. Mirrors Turbo behavior. |
|
|
169
|
-
| Form submission inside a frame | Same. POST response gets the same frame-extract treatment. (Implement in form-submit path alongside link-click.) |
|
|
170
|
-
| Hash-fragment-only navigation | Existing behavior. No fetch, browser handles. |
|
|
171
|
-
| `data-frame="_top"` on a link | Escapes the enclosing frame, full nav. (Turbo precedent.) |
|
|
172
|
-
|
|
173
|
-
## What this fixes
|
|
174
|
-
|
|
175
|
-
- **ui-website docs sidenav scroll**: docs layout wraps content in `<webjs-frame id="docs-content">`. Sidenav lives *outside* the frame. Navigation between `/docs/components/a` → `/docs/components/b` only swaps frame children. The sidenav DOM is untouched, and `<aside>` scroll position is preserved natively. The `sessionStorage` workaround in `app/docs/layout.ts` can be deleted.
|
|
176
|
-
- **Any nested-layout app**: the same primitive works whether the partial-swap region is 2, 3, or 5 levels deep.
|
|
177
|
-
- **Mixed layouts**: pages that don't opt-in fall through to the existing one-level shell detection or full body swap. No regression risk.
|
|
178
|
-
|
|
179
|
-
## What's deliberately deferred (future passes)
|
|
180
|
-
|
|
181
|
-
1. **Keyed `data-key` DOM diff inside the frame.** Adopt Remix v3's `diff-dom.ts` algorithm to preserve input values, `<details>` open state, popover state, scroll positions on inner scroll containers across nav. Today's `replaceChildren` is coarse, but fine for v1 since the frame *itself* is preserved (outer scroll, sidenav, etc.).
|
|
182
|
-
2. **`X-Webjs-Frame` server optimization** to avoid re-rendering layouts the client already has.
|
|
183
|
-
3. **Server-pushed partial updates** (turbo-stream equivalent) via `<webjs-stream action="replace" target="...">`. Separate feature, useful for SSE/WebSocket-driven UI.
|
|
184
|
-
4. **Frame-scoped error boundaries.** If a frame fetch 5xxs, render only the frame's `error.ts`, not the whole page.
|
|
185
|
-
|
|
186
|
-
## Implementation plan
|
|
187
|
-
|
|
188
|
-
1. **`packages/core/src/webjs-frame.js`**: new file, the custom element.
|
|
189
|
-
2. **`packages/core/index.js`**: export `WebjsFrame`. Re-export `<webjs-frame>` via auto-registration import (so any app that imports `@webjsdev/core` gets it).
|
|
190
|
-
3. **`packages/core/src/router-client.js`**: add `findActiveFrame()`, frame-swap branch in `navigate()`. Preserve existing `findLayoutShell` and full-body fallback.
|
|
191
|
-
4. **`packages/core/src/router-client.js` (form path)**: apply the same frame-extract to form submit responses.
|
|
192
|
-
5. **`packages/server/src/dev.js`**: accept `X-Webjs-Frame` header in dev mode (no-op for v1 but adds the request signal for telemetry).
|
|
193
|
-
6. **Tests:**
|
|
194
|
-
- `packages/core/test/routing/router-client.test.js`: frame detection, querySelector with various ids, fallback when source frame missing.
|
|
195
|
-
- `test/e2e/nested-layout-partial-swap.test.mjs`: load `/docs/components/a`, scroll sidenav, click `/docs/components/b`, assert scroll preserved AND only frame children swapped.
|
|
196
|
-
7. **Docs:**
|
|
197
|
-
- `AGENTS.md`: add `<webjs-frame>` to the public API table.
|
|
198
|
-
- `agent-docs/advanced.md`: new "Frames" section under the client-router doc.
|
|
199
|
-
- `docs/` app: a new docs page showing the pattern.
|
|
200
|
-
8. **`packages/cli/templates/`**: none for v1 (frames are opt-in, no scaffold change needed).
|
|
201
|
-
9. **ui-website cleanup**: remove the `sessionStorage` workaround in `app/docs/layout.ts` after the frame lands and tests prove scroll preservation.
|
|
202
|
-
|
|
203
|
-
## Open questions
|
|
204
|
-
|
|
205
|
-
1. **Should `<webjs-frame>` ship a `src=""` attribute** for lazy loading like turbo-frame does? Probably yes eventually, but not for v1. The motivating use case is layout-scope, not lazy data.
|
|
206
|
-
2. **Should there be a sub-element registry** (something like `data-layout="docs"` as a shorthand)? Keep it explicit for v1: one mechanism. Consider sugar later.
|
|
207
|
-
3. **`X-Webjs-Frame` header naming.** `Webjs-Frame` (like `Turbo-Frame`) is shorter. Either works, and v1 implements neither for response routing so the bikeshed is deferred.
|
|
208
|
-
|
|
209
|
-
## References
|
|
210
|
-
|
|
211
|
-
- Turbo source: `frame_controller.js:132-148` (response parse), `frame_renderer.js:5-16` (swap), `link_interceptor.js:48` (innermost-frame rule)
|
|
212
|
-
- Remix v3 source: `packages/component/src/lib/frame.ts:1134-1146` (comment markers), `diff-dom.ts:124-162` (live-attr preservation list)
|
|
213
|
-
- Next.js source: `packages/next/src/client/components/router-reducer/ppr-navigations.ts:230,251,292,354,486` (cache reuse vs create), `walk-tree-with-flight-router-state.tsx:106-112` (server-side tree walk)
|
|
214
|
-
- webjs current: `packages/core/src/router-client.js` (`findLayoutShell`, single body-children scan)
|