Skip to content

Commit 41a61bb

Browse files
committed
feat(server): split .server.ts source-protection from 'use server' RPC marker
The two markers are now complementary instead of interchangeable: .server.{js,ts} path-level boundary: file router refuses to serve the source to the browser. 'use server' semantic opt-in: file exports register as RPC-callable from client code. Concrete behaviours: - .server.ts WITH 'use server' server action: source-protected + browser-side imports rewritten to RPC stubs that POST to /__webjs/action/<hash>/<fn>. - .server.ts WITHOUT 'use server' server-only utility: source- protected + browser-side imports get a throw-at-load stub that errors with a clear message. - 'use server' WITHOUT .server.ts ignored (a separate lint rule flags it). File serves to the browser as plain TS source. Implementation surface: actions.js isServerFile() becomes a synchronous path-only check. Add hasUseServerDirective() + isServerAction(). Add serveServerOnlyStub() for the throw-at-load case. buildActionIndex only registers RPC routes when both markers are present. dev.js File-serving handler dispatches between RPC stub and server-only stub based on hasUseServerDirective(). Source protection still triggers on .server.ts alone. Test updates assert the new behaviour: server-file guardrail tests cover all four combinations of extension/directive presence; action tests' fixtures now include 'use server' where they expect RPC behaviour.
1 parent e128aa5 commit 41a61bb

6 files changed

Lines changed: 209 additions & 70 deletions

File tree

packages/server/src/actions.js

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,33 @@ async function rpcResponse(payload, init = {}) {
2828
/**
2929
* Server-actions subsystem.
3030
*
31-
* A "server action" is an async function defined in:
32-
* - any file ending in `.server.js`, OR
33-
* - any .js file whose first non-empty, non-comment line is `'use server'`.
31+
* Two complementary markers describe server-side files:
32+
*
33+
* - `.server.{js,ts,mts,mjs}` extension: file is **server-only**. The
34+
* file router refuses to serve its source to the browser. This is
35+
* the path-level boundary.
36+
* - `'use server'` directive at the top: file's exports are
37+
* **RPC-callable** from client code. This is the semantic opt-in.
38+
*
39+
* The two together (`.server.ts` AND `'use server'`) define a server
40+
* action: source-protected AND RPC-exposed. The extension alone marks
41+
* a server-only utility (source-protected, NOT RPC-exposed: browser
42+
* imports get an error stub that throws at load). The directive alone
43+
* (no extension) does nothing: a `webjs check` lint rule
44+
* (`use-server-needs-extension`) flags it because the file is served
45+
* to the browser as plain source and the directive is silently
46+
* ignored.
3447
*
3548
* The server:
36-
* 1. Scans the app tree on boot, building a map of { hash -> absFile }.
37-
* 2. Serves a generated ES-module stub when the browser imports the file URL.
38-
* 3. Exposes POST endpoints at /__webjs/action/:hash/:fn that run the real function.
39-
* 4. If an exported function was wrapped in `expose('METHOD /path', fn)`, also
40-
* registers it as a first-class REST endpoint.
49+
* 1. Scans the app tree on boot, classifying server files into
50+
* RPC-callable actions vs. server-only utilities.
51+
* 2. Serves a generated ES-module stub when the browser imports
52+
* the file URL (an RPC stub for actions, a throw-at-load stub
53+
* for server-only utilities).
54+
* 3. Exposes POST endpoints at /__webjs/action/:hash/:fn for
55+
* RPC-callable actions only.
56+
* 4. If an exported function was wrapped in `expose('METHOD /path', fn)`,
57+
* also registers it as a first-class REST endpoint.
4158
*
4259
* @typedef {{
4360
* method: string,
@@ -74,7 +91,18 @@ export async function buildActionIndex(appDir, dev) {
7491
const httpRoutes = [];
7592

7693
for await (const file of walk(appDir, (p) => /\.m?[jt]s$/.test(p))) {
77-
if (!(await isServerFile(file))) continue;
94+
// Path-level: only `.server.{ts,js,mts,mjs}` files are server-only.
95+
// A bare `'use server'` directive without the extension is a lint
96+
// violation (use-server-needs-extension) and the file is treated as
97+
// plain browser code: no source protection, no RPC registration.
98+
if (!isServerFile(file)) continue;
99+
// Semantic-level: only files that ALSO have `'use server'` are
100+
// RPC-callable. `.server.ts` without the directive is server-only
101+
// (still source-protected by the file router) but its exports are
102+
// NOT registered as RPC endpoints. The browser-side import gets a
103+
// throw-at-load stub via `serveServerOnlyStub` instead.
104+
if (!(await hasUseServerDirective(file))) continue;
105+
78106
const h = hashFile(file);
79107
hashToFile.set(h, file);
80108
fileToHash.set(file, h);
@@ -109,9 +137,32 @@ export function hashFile(file) {
109137
return createHash('sha256').update(file).digest('hex').slice(0, 10);
110138
}
111139

112-
/** @param {string} file */
113-
export async function isServerFile(file) {
114-
if (/\.server\.m?[jt]s$/.test(file)) return true;
140+
/**
141+
* Predicate: file is server-only (source-protected, never served as
142+
* source to the browser). True for `.server.{js,ts,mts,mjs}` files.
143+
* Synchronous, name-only check, the path-level boundary.
144+
*
145+
* The `'use server'` directive without the extension does NOT make a
146+
* file server-only: a `webjs check` lint rule
147+
* (`use-server-needs-extension`) flags that pattern instead, and the
148+
* file is treated as plain browser code.
149+
*
150+
* @param {string} file
151+
* @returns {boolean}
152+
*/
153+
export function isServerFile(file) {
154+
return /\.server\.m?[jt]s$/.test(file);
155+
}
156+
157+
/**
158+
* Predicate: file has the `'use server'` directive in its first 5 lines.
159+
* Semantic-level marker: when paired with `.server.ts`, registers the
160+
* file's exports as RPC-callable from client code.
161+
*
162+
* @param {string} file
163+
* @returns {Promise<boolean>}
164+
*/
165+
export async function hasUseServerDirective(file) {
115166
try {
116167
const text = await readFile(file, 'utf8');
117168
const head = text.split('\n').slice(0, 5).join('\n');
@@ -121,6 +172,19 @@ export async function isServerFile(file) {
121172
}
122173
}
123174

175+
/**
176+
* Predicate: file is a server action (server-only + RPC-callable).
177+
* True when both markers are present: `.server.{js,ts}` extension AND
178+
* `'use server'` directive.
179+
*
180+
* @param {string} file
181+
* @returns {Promise<boolean>}
182+
*/
183+
export async function isServerAction(file) {
184+
if (!isServerFile(file)) return false;
185+
return await hasUseServerDirective(file);
186+
}
187+
124188
/**
125189
* @param {ActionIndex} idx
126190
* @param {string} urlPath - a browser-visible URL path like `/actions/foo.server.js`
@@ -130,6 +194,27 @@ export function resolveServerModule(idx, urlPath) {
130194
return idx.fileToHash.has(abs) ? abs : null;
131195
}
132196

197+
/**
198+
* Generate a throw-at-load stub for a server-only file (a `.server.ts`
199+
* file WITHOUT a `'use server'` directive). When a browser-side module
200+
* imports this file, the stub throws synchronously at module load time
201+
* with a clear error pointing at the file, so the developer immediately
202+
* sees that server-only code can't be reached from the browser.
203+
*
204+
* @param {string} relPath path relative to appDir for the error message
205+
* @returns {string} JavaScript module source
206+
*/
207+
export function serveServerOnlyStub(relPath) {
208+
const msg =
209+
`Cannot import "${relPath}" from browser code. ` +
210+
`This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
211+
`Either add 'use server' at the top of the file to expose its exports as RPC, ` +
212+
`or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead.`;
213+
return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
214+
throw new Error(${JSON.stringify(msg)});
215+
`;
216+
}
217+
133218
/**
134219
* Serve the generated client stub for a server module.
135220
* @param {ActionIndex} idx

packages/server/src/dev.js

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ import { handleApi } from './api.js';
4343
import {
4444
buildActionIndex,
4545
serveActionStub,
46+
serveServerOnlyStub,
4647
invokeAction,
4748
matchExposedAction,
4849
matchAllAtPath,
4950
invokeExposedAction,
5051
buildPreflightResponse,
5152
withCors,
5253
isServerFile,
54+
hasUseServerDirective,
5355
hashFile,
5456
} from './actions.js';
5557
import { defaultLogger } from './logger.js';
@@ -453,26 +455,34 @@ async function handleCore(req, ctx) {
453455
}
454456
}
455457
if (abs.startsWith(appDir) && (await exists(abs))) {
456-
// Server-file guardrail: a file is server-only if its name matches
457-
// `.server.{js,ts,mjs,mts}` OR the source starts with `'use server'`.
458-
// Such files MUST NEVER be served as source to the browser: they
459-
// contain secrets, DB queries, and privileged logic. Always return a
460-
// generated RPC stub instead.
458+
// Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
459+
// MUST NEVER be served as source to the browser. The extension is
460+
// the path-level boundary; we re-verify it on every request (not
461+
// just the action-index snapshot taken at boot) so files created
462+
// after boot, FS races, or developer error never punch through.
461463
//
462-
// We re-verify via `isServerFile(abs)` on every request (not just the
463-
// action-index snapshot taken at boot). This catches files created
464-
// after boot, files that flipped their `'use server'` status, or any
465-
// race between scan completion and request: the guardrail is an
466-
// independent check, not a cache lookup.
467-
if (await isServerFile(abs)) {
468-
// Lazily ensure the index knows about this file so serveActionStub
469-
// can mint a stable hash and function list.
470-
if (!state.actionIndex.fileToHash.has(abs)) {
471-
const h = hashFile(abs);
472-
state.actionIndex.fileToHash.set(abs, h);
473-
state.actionIndex.hashToFile.set(h, abs);
464+
// What the browser gets depends on the file's `'use server'` status:
465+
// - With `'use server'` => server action: a generated RPC stub
466+
// whose exports POST to /__webjs/action/:hash/:fn.
467+
// - Without `'use server'` => server-only utility: a stub that
468+
// throws at module load with a clear error. The file's source
469+
// never reaches the browser either way.
470+
if (isServerFile(abs)) {
471+
if (await hasUseServerDirective(abs)) {
472+
// Lazily ensure the index knows about this file so serveActionStub
473+
// can mint a stable hash and function list.
474+
if (!state.actionIndex.fileToHash.has(abs)) {
475+
const h = hashFile(abs);
476+
state.actionIndex.fileToHash.set(abs, h);
477+
state.actionIndex.hashToFile.set(h, abs);
478+
}
479+
const stub = await serveActionStub(state.actionIndex, abs);
480+
return new Response(stub, {
481+
headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
482+
});
474483
}
475-
const stub = await serveActionStub(state.actionIndex, abs);
484+
const relPath = relative(appDir, abs);
485+
const stub = serveServerOnlyStub(relPath);
476486
return new Response(stub, {
477487
headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
478488
});

test/actions.test.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ async function scaffold(files) {
2626

2727
test('webjs wire format round-trips Date / Map / BigInt across invokeAction', async () => {
2828
const dir = await scaffold({
29-
'actions/rich.server.js': `
29+
'actions/rich.server.js': `'use server';
3030
export async function now() { return new Date(1234567890000); }
3131
export async function bag() {
3232
return { big: 9007199254740993n, set: new Set(['a','b']), map: new Map([[1, 'one']]) };
@@ -59,24 +59,28 @@ test('webjs wire format round-trips Date / Map / BigInt across invokeAction', as
5959
}
6060
});
6161

62-
test('detects *.server.js and "use server" pragma files', async () => {
62+
test('isServerFile is path-only: .server.* yes, anything else no', async () => {
6363
const dir = await scaffold({
6464
'actions/a.server.js': 'export const hello = async () => 1',
6565
'actions/b.js': `'use server';\nexport const bye = async () => 2`,
6666
'actions/c.js': 'export const plain = () => 3',
6767
});
6868
try {
69-
assert.equal(await isServerFile(join(dir, 'actions/a.server.js')), true);
70-
assert.equal(await isServerFile(join(dir, 'actions/b.js')), true);
71-
assert.equal(await isServerFile(join(dir, 'actions/c.js')), false);
69+
// .server.{js,ts} extension is the only path-level marker.
70+
assert.equal(isServerFile(join(dir, 'actions/a.server.js')), true);
71+
// 'use server' WITHOUT the extension is no longer server-only. The
72+
// lint rule `use-server-needs-extension` flags it instead; the file
73+
// serves to the browser as plain source.
74+
assert.equal(isServerFile(join(dir, 'actions/b.js')), false);
75+
assert.equal(isServerFile(join(dir, 'actions/c.js')), false);
7276
} finally {
7377
await rm(dir, { recursive: true, force: true });
7478
}
7579
});
7680

7781
test('stubs server module and invokes action by hash/fn', async () => {
7882
const dir = await scaffold({
79-
'actions/math.server.js': `
83+
'actions/math.server.js': `'use server';
8084
export async function add(a, b) { return a + b; }
8185
export async function mul(a, b) { return a * b; }
8286
`,

test/dev-handler.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,7 @@ test('handle: POST to /__webjs/action/<hash>/<fn> invokes the action', async ()
563563
`import { html } from ${JSON.stringify(HTML_URL)};\n` +
564564
`export default function P() { return html\`<p>ok</p>\`; }\n`,
565565
'actions.server.js':
566+
`'use server';\n` +
566567
`export async function double(n) { return n * 2; }\n`,
567568
});
568569
const app = await createRequestHandler({ appDir, dev: true });
@@ -609,6 +610,7 @@ test('handle: expose()d action is reachable by method+path', async () => {
609610
`import { html } from ${JSON.stringify(HTML_URL)};\n` +
610611
`export default function P() { return html\`<p>ok</p>\`; }\n`,
611612
'api.server.js':
613+
`'use server';\n` +
612614
`import { expose } from ${JSON.stringify(pathToFileURL(
613615
resolve(__dirname, '../packages/core/index.js'),
614616
).toString())};\n` +
@@ -626,6 +628,7 @@ test('handle: OPTIONS preflight on expose()d action with cors returns CORS heade
626628
`import { html } from ${JSON.stringify(HTML_URL)};\n` +
627629
`export default function P() { return html\`<p>ok</p>\`; }\n`,
628630
'api.server.js':
631+
`'use server';\n` +
629632
`import { expose } from ${JSON.stringify(pathToFileURL(
630633
resolve(__dirname, '../packages/core/index.js'),
631634
).toString())};\n` +
@@ -648,6 +651,7 @@ test('handle: OPTIONS at a path with exposed actions but no CORS → plain allow
648651
`import { html } from ${JSON.stringify(HTML_URL)};\n` +
649652
`export default function P() { return html\`<p>ok</p>\`; }\n`,
650653
'api.server.js':
654+
`'use server';\n` +
651655
`import { expose } from ${JSON.stringify(pathToFileURL(
652656
resolve(__dirname, '../packages/core/index.js'),
653657
).toString())};\n` +
@@ -667,6 +671,7 @@ test('handle: POST /__webjs/action without CSRF → 403', async () => {
667671
`import { html } from ${JSON.stringify(HTML_URL)};\n` +
668672
`export default function P() { return html\`<p>ok</p>\`; }\n`,
669673
'actions.server.js':
674+
`'use server';\n` +
670675
`export async function noop() { return 1; }\n`,
671676
});
672677
const app = await createRequestHandler({ appDir, dev: true });

test/expose.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ async function scaffold(files) {
5454
test('action scanner discovers expose()d routes and invokes them over HTTP', async () => {
5555
// Use a relative import so the scaffolded module can find webjs via the workspace.
5656
const dir = await scaffold({
57-
'actions/math.server.js': `
57+
'actions/math.server.js': `'use server';
5858
import { expose } from '@webjskit/core';
5959
export const add = expose('POST /api/add', async ({ a, b }) => a + b);
6060
export const get = expose('GET /api/value/:id', async ({ id }) => ({ id: Number(id) }));
@@ -99,7 +99,7 @@ test('action scanner discovers expose()d routes and invokes them over HTTP', asy
9999

100100
test('validate hook rejects bad input with 400 before handler runs', async () => {
101101
const dir = await scaffold({
102-
'actions/guarded.server.js': `
102+
'actions/guarded.server.js': `'use server';
103103
import { expose } from '@webjskit/core';
104104
let called = 0;
105105
export const make = expose(

0 commit comments

Comments
 (0)