@webjsdev/server 0.7.2
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/README.md +51 -0
- package/index.js +29 -0
- package/package.json +43 -0
- package/src/actions.js +478 -0
- package/src/api.js +37 -0
- package/src/auth.js +431 -0
- package/src/broadcast.js +69 -0
- package/src/cache-fn.js +85 -0
- package/src/cache.js +187 -0
- package/src/check.js +878 -0
- package/src/component-scanner.js +164 -0
- package/src/context.js +62 -0
- package/src/csrf.js +95 -0
- package/src/dev.js +952 -0
- package/src/forwarded.js +59 -0
- package/src/fs-walk.js +28 -0
- package/src/importmap.js +40 -0
- package/src/json.js +64 -0
- package/src/logger.js +39 -0
- package/src/module-graph.js +141 -0
- package/src/rate-limit.js +105 -0
- package/src/router.js +280 -0
- package/src/serializer.js +86 -0
- package/src/session.js +336 -0
- package/src/ssr.js +1258 -0
- package/src/vendor.js +211 -0
- package/src/websocket.js +119 -0
package/src/dev.js
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import { createServer as createHttp1Server } from 'node:http';
|
|
2
|
+
import { stat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
5
|
+
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
|
|
6
|
+
import { createRequire, stripTypeScriptTypes } from 'node:module';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
// Server-side `.ts` imports are handled natively by Node 24+'s default
|
|
10
|
+
// type-stripping (`process.features.typescript === 'strip'`). No loader
|
|
11
|
+
// hook required. The browser-bound TypeScript request handler uses
|
|
12
|
+
// `module.stripTypeScriptTypes` for the same transform, so SSR and
|
|
13
|
+
// hydration produce identical JS.
|
|
14
|
+
//
|
|
15
|
+
// Runtime backing: Node ships `stripTypeScriptTypes` via the `amaro`
|
|
16
|
+
// package internally (wraps SWC's WASM TypeScript transform in a
|
|
17
|
+
// position-preserving strip-only mode). If the framework ever needs
|
|
18
|
+
// to run on Bun, Deno, or another runtime that does NOT expose the
|
|
19
|
+
// equivalent built-in, we will need to install `amaro` directly (or
|
|
20
|
+
// an equivalent: Sucrase preserves lines but not columns; SWC's
|
|
21
|
+
// strip-only also works). The fast-path `stripTs` helper would
|
|
22
|
+
// change one import line; the fallback path (esbuild) stays.
|
|
23
|
+
//
|
|
24
|
+
// Suppress the one-shot ExperimentalWarning that Node prints the
|
|
25
|
+
// first time `stripTypeScriptTypes` is called. The API is committed
|
|
26
|
+
// per Node 24's release notes; the warning is a holdover. We keep
|
|
27
|
+
// every other warning intact.
|
|
28
|
+
const _origEmitWarning = process.emitWarning.bind(process);
|
|
29
|
+
process.emitWarning = function (warning, type, code, ctor) {
|
|
30
|
+
const msg = warning && warning.message ? warning.message : String(warning);
|
|
31
|
+
if (
|
|
32
|
+
(type === 'ExperimentalWarning' || (warning && warning.name === 'ExperimentalWarning')) &&
|
|
33
|
+
msg.includes('stripTypeScriptTypes')
|
|
34
|
+
) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
return _origEmitWarning(warning, type, code, ctor);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
41
|
+
import { ssrPage, ssrNotFound } from './ssr.js';
|
|
42
|
+
import { handleApi } from './api.js';
|
|
43
|
+
import {
|
|
44
|
+
buildActionIndex,
|
|
45
|
+
serveActionStub,
|
|
46
|
+
serveServerOnlyStub,
|
|
47
|
+
invokeAction,
|
|
48
|
+
matchExposedAction,
|
|
49
|
+
matchAllAtPath,
|
|
50
|
+
invokeExposedAction,
|
|
51
|
+
buildPreflightResponse,
|
|
52
|
+
withCors,
|
|
53
|
+
isServerFile,
|
|
54
|
+
hasUseServerDirective,
|
|
55
|
+
hashFile,
|
|
56
|
+
} from './actions.js';
|
|
57
|
+
import { defaultLogger } from './logger.js';
|
|
58
|
+
import { withRequest } from './context.js';
|
|
59
|
+
import { attachWebSocket } from './websocket.js';
|
|
60
|
+
import { scanBareImports, vendorImportMapEntries, serveVendorBundle, clearVendorCache } from './vendor.js';
|
|
61
|
+
import { buildModuleGraph, transitiveDeps } from './module-graph.js';
|
|
62
|
+
import { primeComponentRegistry, findOrphanComponents } from './component-scanner.js';
|
|
63
|
+
|
|
64
|
+
/** PascalCase → kebab-case for a helpful diagnostic example tag name. */
|
|
65
|
+
function kebab(name) {
|
|
66
|
+
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
67
|
+
}
|
|
68
|
+
import { setVendorEntries } from './importmap.js';
|
|
69
|
+
import { urlFromRequest } from './forwarded.js';
|
|
70
|
+
|
|
71
|
+
const MIME = {
|
|
72
|
+
'.html': 'text/html; charset=utf-8',
|
|
73
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
74
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
75
|
+
'.ts': 'application/javascript; charset=utf-8',
|
|
76
|
+
'.mts': 'application/javascript; charset=utf-8',
|
|
77
|
+
'.css': 'text/css; charset=utf-8',
|
|
78
|
+
'.json': 'application/json; charset=utf-8',
|
|
79
|
+
'.svg': 'image/svg+xml',
|
|
80
|
+
'.png': 'image/png',
|
|
81
|
+
'.jpg': 'image/jpeg',
|
|
82
|
+
'.jpeg': 'image/jpeg',
|
|
83
|
+
'.gif': 'image/gif',
|
|
84
|
+
'.webp': 'image/webp',
|
|
85
|
+
'.ico': 'image/x-icon',
|
|
86
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Cache of stripped `.ts` / `.mts` source.
|
|
91
|
+
* Keyed by absolute file path. Entries expire when mtime changes.
|
|
92
|
+
* Capped at 500 entries to prevent unbounded memory growth in
|
|
93
|
+
* long-running production servers.
|
|
94
|
+
*
|
|
95
|
+
* Primary stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
|
|
96
|
+
* Position-preserving whitespace replacement. No sourcemap is
|
|
97
|
+
* emitted because every (line, column) maps to itself in the source.
|
|
98
|
+
*
|
|
99
|
+
* Fallback stripper: `esbuild.transform`. Triggered only when the
|
|
100
|
+
* primary path throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` (the file
|
|
101
|
+
* uses `enum`, `namespace`, parameter properties, or legacy
|
|
102
|
+
* decorators). Emits an inline sourcemap so DevTools can still
|
|
103
|
+
* resolve source positions for the regenerated JS. Mostly fires for
|
|
104
|
+
* third-party `.ts` files; user code is enforced erasable by
|
|
105
|
+
* `webjs check`.
|
|
106
|
+
*
|
|
107
|
+
* @type {Map<string, { mtimeMs: number, code: string, map: string | null }>}
|
|
108
|
+
*/
|
|
109
|
+
const TS_CACHE_MAX = 500;
|
|
110
|
+
const TS_CACHE = new Map();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a reusable, framework-agnostic request handler for a webjs app.
|
|
114
|
+
* The returned `handle(req)` takes a standard `Request` and resolves to a
|
|
115
|
+
* standard `Response`: suitable for Node http, Deno, Bun, Cloudflare Workers,
|
|
116
|
+
* or embedding inside an Express/Fastify app.
|
|
117
|
+
*
|
|
118
|
+
* @param {{
|
|
119
|
+
* appDir: string,
|
|
120
|
+
* dev?: boolean,
|
|
121
|
+
* logger?: import('./logger.js').Logger,
|
|
122
|
+
* onReload?: () => void,
|
|
123
|
+
* }} opts
|
|
124
|
+
*/
|
|
125
|
+
export async function createRequestHandler(opts) {
|
|
126
|
+
const appDir = resolve(opts.appDir);
|
|
127
|
+
const dev = !!opts.dev;
|
|
128
|
+
const logger = opts.logger || defaultLogger({ dev });
|
|
129
|
+
const coreDir = locateCoreDir(appDir);
|
|
130
|
+
|
|
131
|
+
// Scan for bare npm imports and register vendor import map entries.
|
|
132
|
+
const bareImports = await scanBareImports(appDir);
|
|
133
|
+
setVendorEntries(vendorImportMapEntries(bareImports));
|
|
134
|
+
|
|
135
|
+
// Build module dependency graph for transitive preload hints.
|
|
136
|
+
const moduleGraph = await buildModuleGraph(appDir);
|
|
137
|
+
|
|
138
|
+
// Scan for component classes and prime their module URLs into the
|
|
139
|
+
// core registry. SSR uses this for modulepreload hints without
|
|
140
|
+
// requiring authors to pass `import.meta.url` themselves.
|
|
141
|
+
await primeComponentRegistry(appDir);
|
|
142
|
+
|
|
143
|
+
// Dev-time guardrail: warn about any class extending WebComponent
|
|
144
|
+
// that isn't registered via customElements.define() in its own
|
|
145
|
+
// module. Without registration, <my-tag> elements silently stay as
|
|
146
|
+
// HTMLUnknownElement in the browser: a common early-stage footgun.
|
|
147
|
+
if (dev) {
|
|
148
|
+
const orphans = await findOrphanComponents(appDir);
|
|
149
|
+
for (const { className, file } of orphans) {
|
|
150
|
+
logger.warn?.(
|
|
151
|
+
`[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
|
|
152
|
+
`Add \`customElements.define('<tag-name>', ${className});\` at the bottom of the file ` +
|
|
153
|
+
`or <${kebab(className)}> tags won't upgrade in the browser.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const state = {
|
|
159
|
+
routeTable: await buildRouteTable(appDir),
|
|
160
|
+
actionIndex: await buildActionIndex(appDir, dev),
|
|
161
|
+
middleware: await loadMiddleware(appDir, dev, logger),
|
|
162
|
+
logger,
|
|
163
|
+
bareImports,
|
|
164
|
+
moduleGraph,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
async function rebuild() {
|
|
168
|
+
state.routeTable = await buildRouteTable(appDir);
|
|
169
|
+
state.actionIndex = await buildActionIndex(appDir, dev);
|
|
170
|
+
state.middleware = await loadMiddleware(appDir, dev, logger);
|
|
171
|
+
// Re-scan bare imports and module graph on rebuild
|
|
172
|
+
clearVendorCache();
|
|
173
|
+
state.bareImports = await scanBareImports(appDir);
|
|
174
|
+
setVendorEntries(vendorImportMapEntries(state.bareImports));
|
|
175
|
+
state.moduleGraph = await buildModuleGraph(appDir);
|
|
176
|
+
// Re-scan components in case a new file was added or a tag renamed.
|
|
177
|
+
await primeComponentRegistry(appDir);
|
|
178
|
+
if (dev) {
|
|
179
|
+
const orphans = await findOrphanComponents(appDir);
|
|
180
|
+
for (const { className, file } of orphans) {
|
|
181
|
+
logger.warn?.(
|
|
182
|
+
`[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
|
|
183
|
+
`Add \`customElements.define('<tag-name>', ${className});\` or <${kebab(className)}> tags won't upgrade.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
opts.onReload?.();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {Request} req */
|
|
191
|
+
function handle(req) {
|
|
192
|
+
return withRequest(req, async () => {
|
|
193
|
+
const next = () => handleCore(req, { state, appDir, coreDir, dev });
|
|
194
|
+
if (state.middleware) {
|
|
195
|
+
try {
|
|
196
|
+
return await state.middleware(req, next);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
logger.error('middleware threw', { err: String(e) });
|
|
199
|
+
return new Response('Server error', { status: 500 });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return next();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Lightweight lookup used by the HTTP layer to emit 103 Early Hints
|
|
208
|
+
* BEFORE running SSR: resolves a pathname to its page-route module URLs
|
|
209
|
+
* without loading them. Returns null for non-page paths.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} pathname
|
|
212
|
+
*/
|
|
213
|
+
function routeFor(pathname) {
|
|
214
|
+
const page = matchPage(state.routeTable, pathname);
|
|
215
|
+
if (!page) return null;
|
|
216
|
+
const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
|
|
217
|
+
let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
|
|
218
|
+
return rel.split('\\').join('/').replace(/^\/?/, '/');
|
|
219
|
+
});
|
|
220
|
+
return { moduleUrls };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
handle,
|
|
225
|
+
rebuild,
|
|
226
|
+
routeFor,
|
|
227
|
+
/** current route table getter: used by the WebSocket subsystem */
|
|
228
|
+
getRouteTable: () => state.routeTable,
|
|
229
|
+
appDir,
|
|
230
|
+
dev,
|
|
231
|
+
logger,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Start a webjs HTTP server. Thin wrapper around `createRequestHandler`.
|
|
237
|
+
*
|
|
238
|
+
* Speaks plain HTTP/1.1. TLS termination + HTTP/2 to the browser is
|
|
239
|
+
* expected to be handled by a reverse proxy (PaaS edge, nginx, Caddy,
|
|
240
|
+
* etc.) sitting in front of this process. See the deployment docs for
|
|
241
|
+
* the recommended topology.
|
|
242
|
+
*
|
|
243
|
+
* @param {{
|
|
244
|
+
* appDir: string,
|
|
245
|
+
* port?: number,
|
|
246
|
+
* dev?: boolean,
|
|
247
|
+
* compress?: boolean,
|
|
248
|
+
* logger?: import('./logger.js').Logger,
|
|
249
|
+
* }} opts
|
|
250
|
+
*/
|
|
251
|
+
export async function startServer(opts) {
|
|
252
|
+
const dev = !!opts.dev;
|
|
253
|
+
const port = opts.port ?? 3000;
|
|
254
|
+
// Compression default: on in prod, off in dev (cheaper to debug raw bytes).
|
|
255
|
+
const compress = opts.compress ?? !dev;
|
|
256
|
+
const logger = opts.logger || defaultLogger({ dev });
|
|
257
|
+
|
|
258
|
+
/** @type {Set<import('node:http').ServerResponse>} */
|
|
259
|
+
const sseClients = new Set();
|
|
260
|
+
const app = await createRequestHandler({
|
|
261
|
+
...opts,
|
|
262
|
+
logger,
|
|
263
|
+
onReload: () => {
|
|
264
|
+
for (const res of sseClients) {
|
|
265
|
+
try { res.write(`event: reload\ndata: now\n\n`); } catch {}
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (dev) {
|
|
271
|
+
const { watch } = await import('chokidar').catch(() => ({ watch: null }));
|
|
272
|
+
if (watch) {
|
|
273
|
+
const watcher = watch(app.appDir, {
|
|
274
|
+
ignored: [/node_modules/, /\.git/, /prisma\/(dev|migrations)/],
|
|
275
|
+
ignoreInitial: true,
|
|
276
|
+
});
|
|
277
|
+
const rebuild = debounce(() => app.rebuild(), 80);
|
|
278
|
+
watcher.on('all', rebuild);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// SSE keepalive: send a comment frame every 25s to defeat proxy idle timeouts.
|
|
283
|
+
// Cheap (no event listeners on the client side) and safe: comments are ignored.
|
|
284
|
+
const keepalive = setInterval(() => {
|
|
285
|
+
for (const res of sseClients) {
|
|
286
|
+
try { res.write(`: ka\n\n`); } catch {}
|
|
287
|
+
}
|
|
288
|
+
}, 25_000);
|
|
289
|
+
keepalive.unref();
|
|
290
|
+
|
|
291
|
+
const server = makeHttpServer(async (req, res) => {
|
|
292
|
+
try {
|
|
293
|
+
const url = urlFromRequest(req);
|
|
294
|
+
|
|
295
|
+
// SSE: handled specially; doesn't fit the req→Response model.
|
|
296
|
+
if (url.pathname === '/__webjs/events') {
|
|
297
|
+
if (!dev) { res.writeHead(404); res.end(); return; }
|
|
298
|
+
res.writeHead(200, {
|
|
299
|
+
'content-type': 'text/event-stream',
|
|
300
|
+
'cache-control': 'no-cache',
|
|
301
|
+
connection: 'keep-alive',
|
|
302
|
+
});
|
|
303
|
+
res.write(`event: hello\ndata: webjs\n\n`);
|
|
304
|
+
sseClients.add(res);
|
|
305
|
+
res.socket?.on('close', () => sseClients.delete(res));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 103 Early Hints: before running SSR, send preload hints for the
|
|
310
|
+
// page's module URLs so the browser can begin fetching them while
|
|
311
|
+
// the server is still computing the body. Skipped in dev (file churn
|
|
312
|
+
// would send stale URLs after rebuilds) and for non-GET/HEAD.
|
|
313
|
+
if (
|
|
314
|
+
!dev &&
|
|
315
|
+
(req.method === 'GET' || req.method === 'HEAD') &&
|
|
316
|
+
typeof res.writeEarlyHints === 'function'
|
|
317
|
+
) {
|
|
318
|
+
const match = app.routeFor(url.pathname);
|
|
319
|
+
if (match && match.moduleUrls.length) {
|
|
320
|
+
try {
|
|
321
|
+
res.writeEarlyHints({
|
|
322
|
+
link: match.moduleUrls.map((u) => `<${u}>; rel=modulepreload`),
|
|
323
|
+
});
|
|
324
|
+
} catch (e) {
|
|
325
|
+
logger.warn('writeEarlyHints failed', { err: String(e) });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const webReq = toWebRequest(req, url);
|
|
331
|
+
const resp = await app.handle(webReq);
|
|
332
|
+
await sendWebResponse(res, resp, req, { compress });
|
|
333
|
+
} catch (e) {
|
|
334
|
+
logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
|
|
335
|
+
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
|
|
336
|
+
res.end(dev && e instanceof Error ? `webjs error: ${e.stack}` : 'Internal server error');
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// WebSocket upgrade handling: any route.js that exports `WS` becomes a
|
|
341
|
+
// WebSocket endpoint at its URL.
|
|
342
|
+
attachWebSocket(server, () => app.getRouteTable(), { dev, logger });
|
|
343
|
+
|
|
344
|
+
server.listen(port, () => {
|
|
345
|
+
logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const shutdown = gracefulShutdown(server, sseClients, logger);
|
|
349
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
350
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
351
|
+
|
|
352
|
+
// Catch-all process handlers: log, but don't tear the process down on a
|
|
353
|
+
// single mishandled promise. Uncaught exceptions are different: state may be
|
|
354
|
+
// corrupted, so log + start an orderly shutdown rather than continuing.
|
|
355
|
+
installProcessHandlers(logger, () => shutdown('uncaughtException'));
|
|
356
|
+
|
|
357
|
+
return { server, close: () => new Promise((r) => server.close(() => r())) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The core request → response pipeline, minus middleware.
|
|
362
|
+
* @param {Request} req
|
|
363
|
+
* @param {{state: any, appDir: string, coreDir: string, dev: boolean}} ctx
|
|
364
|
+
*/
|
|
365
|
+
async function handleCore(req, ctx) {
|
|
366
|
+
const { state, appDir, coreDir, dev } = ctx;
|
|
367
|
+
const url = new URL(req.url);
|
|
368
|
+
// Decode percent-encoded characters so filesystem lookups match real
|
|
369
|
+
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
370
|
+
// `(marketing)` contain chars that browsers percent-encode in URLs
|
|
371
|
+
// (`%5B`, `%5D`, `%28`, `%29`). Without decoding, the server joins the
|
|
372
|
+
// encoded path with the app directory → file not found → 404 → no JS
|
|
373
|
+
// loads → no interactivity.
|
|
374
|
+
let path;
|
|
375
|
+
try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
|
|
376
|
+
const method = req.method.toUpperCase();
|
|
377
|
+
|
|
378
|
+
// Health / readiness probes for orchestrators (k8s, fly, etc.)
|
|
379
|
+
if (path === '/__webjs/health' || path === '/__webjs/ready') {
|
|
380
|
+
return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Dev live-reload client
|
|
384
|
+
if (path === '/__webjs/reload.js') {
|
|
385
|
+
if (!dev) return new Response('Not found', { status: 404 });
|
|
386
|
+
return new Response(RELOAD_CLIENT_JS, {
|
|
387
|
+
headers: { 'content-type': 'application/javascript; charset=utf-8' },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Core module: /__webjs/core/*
|
|
392
|
+
//
|
|
393
|
+
// ETag + ~1h max-age, NOT immutable. The URL path is un-versioned
|
|
394
|
+
// (`/__webjs/core/src/render-client.js` etc.), so bumping
|
|
395
|
+
// `@webjsdev/core` ships different bytes at the same URL. An
|
|
396
|
+
// `immutable` cache-control directive at an edge CDN (Cloudflare,
|
|
397
|
+
// Vercel, Fly) keeps the prior bytes pinned for up to a year, which
|
|
398
|
+
// silently bricks the next deploy: browsers load the old client
|
|
399
|
+
// renderer against a server emitting the new SSR shape, and any
|
|
400
|
+
// exports added in the bump (e.g., the slot.js entry points landed
|
|
401
|
+
// for 0.6.0) resolve to undefined in the cached file.
|
|
402
|
+
// Regression: 2026-05-20, ui.webjs.dev tier-2 components after
|
|
403
|
+
// @webjsdev/core 0.5.0 -> 0.6.0 republish.
|
|
404
|
+
if (path.startsWith('/__webjs/core/')) {
|
|
405
|
+
const rel = path.slice('/__webjs/core/'.length);
|
|
406
|
+
const abs = resolve(coreDir, rel);
|
|
407
|
+
if (!abs.startsWith(coreDir)) return new Response('forbidden', { status: 403 });
|
|
408
|
+
return fileResponse(abs, { dev, immutable: false });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Vendor bundles: /__webjs/vendor/<pkg>.js: generic auto-bundler
|
|
412
|
+
// (Vite-style optimizeDeps) for any bare npm import that webjs can't
|
|
413
|
+
// serve directly as ESM.
|
|
414
|
+
if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
|
|
415
|
+
const pkgName = decodeURIComponent(path.slice('/__webjs/vendor/'.length, -'.js'.length));
|
|
416
|
+
return serveVendorBundle(pkgName, appDir, dev);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Internal server-action RPC endpoint
|
|
420
|
+
const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
|
|
421
|
+
if (actMatch) {
|
|
422
|
+
if (method !== 'POST') return new Response('POST only', { status: 405 });
|
|
423
|
+
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// expose()d server actions (first-class REST), with optional CORS support.
|
|
427
|
+
if (method === 'OPTIONS') {
|
|
428
|
+
const allAtPath = matchAllAtPath(state.actionIndex, path);
|
|
429
|
+
if (allAtPath.length) {
|
|
430
|
+
const corsRoute = allAtPath.find((r) => r.cors);
|
|
431
|
+
const methods = [...new Set(allAtPath.map((r) => r.method))];
|
|
432
|
+
if (corsRoute) {
|
|
433
|
+
// Preflight: respond with cors headers + the union of methods at this path.
|
|
434
|
+
const preflight = buildPreflightResponse(corsRoute, req);
|
|
435
|
+
const newHeaders = new Headers(preflight.headers);
|
|
436
|
+
newHeaders.set('access-control-allow-methods', `${methods.join(', ')}, OPTIONS`);
|
|
437
|
+
return new Response(null, { status: preflight.status, headers: newHeaders });
|
|
438
|
+
}
|
|
439
|
+
return new Response(null, { status: 204, headers: { allow: `${methods.join(', ')}, OPTIONS` } });
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
const exposed = matchExposedAction(state.actionIndex, method, path);
|
|
443
|
+
if (exposed) {
|
|
444
|
+
const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req);
|
|
445
|
+
return withCors(resp, exposed.route, req);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Static: /public/*
|
|
450
|
+
if (path.startsWith('/public/') || path === '/favicon.ico') {
|
|
451
|
+
const p = path === '/favicon.ico' ? '/public/favicon.ico' : path;
|
|
452
|
+
const abs = join(appDir, p);
|
|
453
|
+
if (await exists(abs)) return fileResponse(abs, { dev, immutable: false });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// User source modules (served as ES modules, with action-file rewriting)
|
|
457
|
+
if (method === 'GET' && /\.(js|mjs|ts|mts|css|svg|png|jpg|jpeg|gif|webp|json|ico|txt)$/.test(path)) {
|
|
458
|
+
let abs = join(appDir, path);
|
|
459
|
+
// When the browser asks for `.js`, allow falling through to a sibling
|
|
460
|
+
// `.ts` (the TypeScript-with-"allowImportingTsExtensions: false" pattern).
|
|
461
|
+
if (!(await exists(abs)) && /\.js$/.test(abs)) {
|
|
462
|
+
const tsAbs = abs.replace(/\.js$/, '.ts');
|
|
463
|
+
if (await exists(tsAbs)) abs = tsAbs;
|
|
464
|
+
else {
|
|
465
|
+
const mtsAbs = abs.replace(/\.js$/, '.mts');
|
|
466
|
+
if (await exists(mtsAbs)) abs = mtsAbs;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (abs.startsWith(appDir) && (await exists(abs))) {
|
|
470
|
+
// Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
|
|
471
|
+
// MUST NEVER be served as source to the browser. The extension is
|
|
472
|
+
// the path-level boundary; we re-verify it on every request (not
|
|
473
|
+
// just the action-index snapshot taken at boot) so files created
|
|
474
|
+
// after boot, FS races, or developer error never punch through.
|
|
475
|
+
//
|
|
476
|
+
// What the browser gets depends on the file's `'use server'` status:
|
|
477
|
+
// - With `'use server'` => server action: a generated RPC stub
|
|
478
|
+
// whose exports POST to /__webjs/action/:hash/:fn.
|
|
479
|
+
// - Without `'use server'` => server-only utility: a stub that
|
|
480
|
+
// throws at module load with a clear error. The file's source
|
|
481
|
+
// never reaches the browser either way.
|
|
482
|
+
if (isServerFile(abs)) {
|
|
483
|
+
if (await hasUseServerDirective(abs)) {
|
|
484
|
+
// Lazily ensure the index knows about this file so serveActionStub
|
|
485
|
+
// can mint a stable hash and function list.
|
|
486
|
+
if (!state.actionIndex.fileToHash.has(abs)) {
|
|
487
|
+
const h = hashFile(abs);
|
|
488
|
+
state.actionIndex.fileToHash.set(abs, h);
|
|
489
|
+
state.actionIndex.hashToFile.set(h, abs);
|
|
490
|
+
}
|
|
491
|
+
const stub = await serveActionStub(state.actionIndex, abs);
|
|
492
|
+
return new Response(stub, {
|
|
493
|
+
headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
const relPath = relative(appDir, abs);
|
|
497
|
+
const stub = serveServerOnlyStub(relPath);
|
|
498
|
+
return new Response(stub, {
|
|
499
|
+
headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
// TypeScript source: esbuild-strip types, cache by mtime.
|
|
503
|
+
if (/\.m?ts$/.test(abs)) {
|
|
504
|
+
return tsResponse(abs, dev);
|
|
505
|
+
}
|
|
506
|
+
return fileResponse(abs, { dev, immutable: false });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
|
|
511
|
+
if (method === 'GET' && state.routeTable.metadataRoutes) {
|
|
512
|
+
const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path);
|
|
513
|
+
if (meta) {
|
|
514
|
+
try {
|
|
515
|
+
const mod = await import(pathToFileURL(meta.file).toString() + (dev ? `?t=${Date.now()}` : ''));
|
|
516
|
+
if (mod.default) {
|
|
517
|
+
const result = await mod.default();
|
|
518
|
+
// If the function returns a Response, use it directly.
|
|
519
|
+
if (result instanceof Response) return result;
|
|
520
|
+
// If it returns a string, determine content type from the URL path.
|
|
521
|
+
const ct = path.endsWith('.xml') ? 'application/xml; charset=utf-8'
|
|
522
|
+
: path.endsWith('.txt') ? 'text/plain; charset=utf-8'
|
|
523
|
+
: path.endsWith('.json') ? 'application/json; charset=utf-8'
|
|
524
|
+
: 'application/octet-stream';
|
|
525
|
+
return new Response(typeof result === 'string' ? result : JSON.stringify(result), {
|
|
526
|
+
headers: { 'content-type': ct, 'cache-control': dev ? 'no-cache' : 'public, max-age=3600' },
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
} catch (e) {
|
|
530
|
+
if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
|
|
531
|
+
return new Response('Internal error', { status: 500 });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// API route (route.js handler)
|
|
537
|
+
const api = matchApi(state.routeTable, path);
|
|
538
|
+
if (api) {
|
|
539
|
+
const handler = () => handleApi(api.route, api.params, req, dev);
|
|
540
|
+
return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Page route (only for GET/HEAD)
|
|
544
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
545
|
+
const page = matchPage(state.routeTable, path);
|
|
546
|
+
if (page) {
|
|
547
|
+
const handler = () => ssrPage(page.route, page.params, url, {
|
|
548
|
+
dev, appDir, req, moduleGraph: state.moduleGraph,
|
|
549
|
+
serverFiles: state.actionIndex.fileToHash,
|
|
550
|
+
});
|
|
551
|
+
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Fallback: content-negotiated 404
|
|
556
|
+
if (wantsJson(req, path)) {
|
|
557
|
+
return Response.json({ error: 'Not found', path }, { status: 404 });
|
|
558
|
+
}
|
|
559
|
+
return ssrNotFound(state.routeTable.notFound, { dev, appDir, req, url });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** @param {Request} req @param {string} path */
|
|
563
|
+
function wantsJson(req, path) {
|
|
564
|
+
const accept = req.headers.get('accept') || '';
|
|
565
|
+
if (accept.includes('application/json') && !accept.includes('text/html')) return true;
|
|
566
|
+
if (path.startsWith('/api/') || path.startsWith('/__webjs/')) return true;
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Chain segment-level middleware.js (outermost first) around a handler.
|
|
572
|
+
* Each middleware is `(req, next) => Response`. If any throws, log and 500.
|
|
573
|
+
*
|
|
574
|
+
* @param {Request} req
|
|
575
|
+
* @param {string[]} files absolute paths of middleware.js files, outermost → innermost
|
|
576
|
+
* @param {() => Promise<Response>} terminal
|
|
577
|
+
* @param {boolean} dev
|
|
578
|
+
*/
|
|
579
|
+
async function runWithSegmentMiddleware(req, files, terminal, dev) {
|
|
580
|
+
if (!files || !files.length) return terminal();
|
|
581
|
+
const handlers = [];
|
|
582
|
+
for (const f of files) {
|
|
583
|
+
try {
|
|
584
|
+
const url = pathToFileURL(f).toString();
|
|
585
|
+
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
586
|
+
const mod = await import(url + bust);
|
|
587
|
+
if (typeof mod.default === 'function') handlers.push(mod.default);
|
|
588
|
+
} catch {
|
|
589
|
+
// Bad middleware file: skip; top-level error handler will catch real problems.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
let i = 0;
|
|
593
|
+
const next = () => {
|
|
594
|
+
if (i >= handlers.length) return terminal();
|
|
595
|
+
const fn = handlers[i++];
|
|
596
|
+
return fn(req, next);
|
|
597
|
+
};
|
|
598
|
+
return next();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Load the optional top-level `middleware.js`.
|
|
603
|
+
* @param {string} appDir
|
|
604
|
+
* @param {boolean} dev
|
|
605
|
+
* @param {import('./logger.js').Logger} logger
|
|
606
|
+
*/
|
|
607
|
+
async function loadMiddleware(appDir, dev, logger) {
|
|
608
|
+
const file = join(appDir, 'middleware.js');
|
|
609
|
+
if (!(await exists(file))) return null;
|
|
610
|
+
const url = pathToFileURL(file).toString();
|
|
611
|
+
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
612
|
+
try {
|
|
613
|
+
const mod = await import(url + bust);
|
|
614
|
+
return typeof mod.default === 'function' ? mod.default : null;
|
|
615
|
+
} catch (e) {
|
|
616
|
+
logger.error('failed to load middleware.js', { err: String(e) });
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Install signal handlers that stop accepting new connections, close SSE
|
|
623
|
+
* clients, and exit once in-flight requests drain.
|
|
624
|
+
* @param {import('node:http').Server} server
|
|
625
|
+
* @param {Set<import('node:http').ServerResponse>} sseClients
|
|
626
|
+
* @param {import('./logger.js').Logger} logger
|
|
627
|
+
*/
|
|
628
|
+
/**
|
|
629
|
+
* Create a plain HTTP/1.1 server. webjs deploys are expected to sit
|
|
630
|
+
* behind a reverse proxy (PaaS edge, nginx, Caddy, etc.) that handles
|
|
631
|
+
* TLS termination and speaks HTTP/2 to clients: Node's http2 module
|
|
632
|
+
* doesn't need to be involved on the framework side.
|
|
633
|
+
*
|
|
634
|
+
* @param {(req: any, res: any) => void} handler
|
|
635
|
+
*/
|
|
636
|
+
function makeHttpServer(handler) {
|
|
637
|
+
return createHttp1Server(handler);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Install once-only process error handlers. Idempotent across multiple
|
|
642
|
+
* `startServer` calls in the same process.
|
|
643
|
+
*
|
|
644
|
+
* @param {import('./logger.js').Logger} logger
|
|
645
|
+
* @param {() => void} onFatal
|
|
646
|
+
*/
|
|
647
|
+
function installProcessHandlers(logger, onFatal) {
|
|
648
|
+
if (/** @type any */ (globalThis).__webjsProcHandlers) return;
|
|
649
|
+
/** @type any */ (globalThis).__webjsProcHandlers = true;
|
|
650
|
+
process.on('unhandledRejection', (reason) => {
|
|
651
|
+
logger.error('unhandledRejection', {
|
|
652
|
+
err: reason instanceof Error ? reason.stack || reason.message : String(reason),
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
process.on('uncaughtException', (err) => {
|
|
656
|
+
logger.error('uncaughtException', { err: err.stack || err.message });
|
|
657
|
+
// Begin orderly shutdown; process state may be corrupt.
|
|
658
|
+
try { onFatal(); } catch {}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function gracefulShutdown(server, sseClients, logger) {
|
|
663
|
+
let shuttingDown = false;
|
|
664
|
+
return (signal) => {
|
|
665
|
+
if (shuttingDown) return;
|
|
666
|
+
shuttingDown = true;
|
|
667
|
+
logger.info(`received ${signal}, shutting down`);
|
|
668
|
+
for (const res of sseClients) {
|
|
669
|
+
try { res.end(); } catch {}
|
|
670
|
+
}
|
|
671
|
+
sseClients.clear();
|
|
672
|
+
server.close((err) => {
|
|
673
|
+
if (err) {
|
|
674
|
+
logger.error('server close error', { err: String(err) });
|
|
675
|
+
process.exit(1);
|
|
676
|
+
}
|
|
677
|
+
logger.info('bye');
|
|
678
|
+
process.exit(0);
|
|
679
|
+
});
|
|
680
|
+
// Hard-fail after 10s if we can't drain.
|
|
681
|
+
setTimeout(() => {
|
|
682
|
+
logger.warn('shutdown timed out, forcing exit');
|
|
683
|
+
process.exit(1);
|
|
684
|
+
}, 10_000).unref();
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/* ------------ helpers ------------ */
|
|
689
|
+
|
|
690
|
+
/** @param {import('node:http').IncomingMessage} req @param {URL} url */
|
|
691
|
+
function toWebRequest(req, url) {
|
|
692
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
693
|
+
/** @type {Record<string,string>} */
|
|
694
|
+
const headers = {};
|
|
695
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
696
|
+
// Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`) -
|
|
697
|
+
// they're parsed separately into req.method / req.url and are rejected
|
|
698
|
+
// by the standard Headers class if we pass them through verbatim.
|
|
699
|
+
if (k.startsWith(':')) continue;
|
|
700
|
+
headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
|
|
701
|
+
}
|
|
702
|
+
let body;
|
|
703
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
704
|
+
body = new ReadableStream({
|
|
705
|
+
start(controller) {
|
|
706
|
+
req.on('data', (chunk) => controller.enqueue(chunk));
|
|
707
|
+
req.on('end', () => controller.close());
|
|
708
|
+
req.on('error', (e) => controller.error(e));
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
return new Request(url, /** @type any */ ({ method, headers, body, duplex: 'half' }));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* @param {import('node:http').ServerResponse} res
|
|
717
|
+
* @param {Response} webRes
|
|
718
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
719
|
+
* @param {{ compress?: boolean }} [opts]
|
|
720
|
+
*/
|
|
721
|
+
async function sendWebResponse(res, webRes, req, opts) {
|
|
722
|
+
/** @type {Record<string,string | string[]>} */
|
|
723
|
+
const headers = {};
|
|
724
|
+
// Preserve multi-value headers (Set-Cookie) via getSetCookie when available.
|
|
725
|
+
if (typeof /** @type any */ (webRes.headers).getSetCookie === 'function') {
|
|
726
|
+
const cookies = /** @type any */ (webRes.headers).getSetCookie();
|
|
727
|
+
if (cookies.length) headers['set-cookie'] = cookies;
|
|
728
|
+
}
|
|
729
|
+
webRes.headers.forEach((v, k) => {
|
|
730
|
+
if (k === 'set-cookie') return;
|
|
731
|
+
headers[k] = v;
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Negotiate compression.
|
|
735
|
+
let compressor = null;
|
|
736
|
+
if (opts?.compress && req && webRes.body && isCompressible(headers['content-type'])) {
|
|
737
|
+
const accept = String(req.headers['accept-encoding'] || '');
|
|
738
|
+
if (/(?:^|,\s*)br(?:;|,|$)/.test(accept)) {
|
|
739
|
+
compressor = createBrotliCompress({
|
|
740
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
|
|
741
|
+
});
|
|
742
|
+
headers['content-encoding'] = 'br';
|
|
743
|
+
} else if (/(?:^|,\s*)gzip(?:;|,|$)/.test(accept)) {
|
|
744
|
+
compressor = createGzip({ level: 6 });
|
|
745
|
+
headers['content-encoding'] = 'gzip';
|
|
746
|
+
}
|
|
747
|
+
if (compressor) {
|
|
748
|
+
headers['vary'] = 'Accept-Encoding';
|
|
749
|
+
delete headers['content-length'];
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
res.writeHead(webRes.status, headers);
|
|
754
|
+
if (!webRes.body) { res.end(); return; }
|
|
755
|
+
|
|
756
|
+
if (compressor) {
|
|
757
|
+
compressor.pipe(res);
|
|
758
|
+
const reader = webRes.body.getReader();
|
|
759
|
+
try {
|
|
760
|
+
while (true) {
|
|
761
|
+
const { done, value } = await reader.read();
|
|
762
|
+
if (done) break;
|
|
763
|
+
compressor.write(value);
|
|
764
|
+
}
|
|
765
|
+
} finally {
|
|
766
|
+
compressor.end();
|
|
767
|
+
}
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const reader = webRes.body.getReader();
|
|
772
|
+
while (true) {
|
|
773
|
+
const { done, value } = await reader.read();
|
|
774
|
+
if (done) break;
|
|
775
|
+
res.write(value);
|
|
776
|
+
}
|
|
777
|
+
res.end();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** @param {string | string[] | undefined} contentType */
|
|
781
|
+
function isCompressible(contentType) {
|
|
782
|
+
if (!contentType) return false;
|
|
783
|
+
const ct = Array.isArray(contentType) ? contentType[0] : contentType;
|
|
784
|
+
return /^(?:text\/|application\/(?:javascript|json|xml|wasm|manifest)|image\/svg\+xml)/i.test(ct);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Read a file and return a Response with appropriate caching.
|
|
789
|
+
* Dev: no-cache (always revalidate).
|
|
790
|
+
* Prod: ETag + ~1h max-age for user files; `immutable` bumps to 1 year.
|
|
791
|
+
*
|
|
792
|
+
* @param {string} abs
|
|
793
|
+
* @param {{ dev: boolean, immutable: boolean }} opts
|
|
794
|
+
*/
|
|
795
|
+
async function fileResponse(abs, opts) {
|
|
796
|
+
try {
|
|
797
|
+
const data = await readFile(abs);
|
|
798
|
+
const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
|
|
799
|
+
const headers = { 'content-type': type };
|
|
800
|
+
if (opts.dev) {
|
|
801
|
+
headers['cache-control'] = 'no-cache';
|
|
802
|
+
} else {
|
|
803
|
+
const etag = `"${createHash('sha1').update(data).digest('hex').slice(0, 16)}"`;
|
|
804
|
+
headers['etag'] = etag;
|
|
805
|
+
headers['cache-control'] = opts.immutable
|
|
806
|
+
? 'public, max-age=31536000, immutable'
|
|
807
|
+
: 'public, max-age=3600';
|
|
808
|
+
}
|
|
809
|
+
return new Response(data, { status: 200, headers });
|
|
810
|
+
} catch {
|
|
811
|
+
return new Response('Not found', { status: 404 });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function exists(p) {
|
|
816
|
+
try { await stat(p); return true; } catch { return false; }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Strip TypeScript types from `source`, using Node's built-in
|
|
821
|
+
* `module.stripTypeScriptTypes` first (whitespace replacement,
|
|
822
|
+
* position-preserving, no sourcemap needed) and falling back to
|
|
823
|
+
* esbuild for files using non-erasable syntax (`enum`, `namespace`,
|
|
824
|
+
* parameter properties, legacy decorators).
|
|
825
|
+
*
|
|
826
|
+
* The framework's own code and the user's app code are kept on
|
|
827
|
+
* erasable TS by the `erasable-typescript-only` convention check.
|
|
828
|
+
* The fallback exists for third-party `.ts` files that the runtime
|
|
829
|
+
* occasionally needs to serve.
|
|
830
|
+
*
|
|
831
|
+
* @param {string} source
|
|
832
|
+
* @param {string} abs
|
|
833
|
+
* @returns {Promise<string>}
|
|
834
|
+
*/
|
|
835
|
+
async function stripTs(source, abs) {
|
|
836
|
+
try {
|
|
837
|
+
return stripTypeScriptTypes(source);
|
|
838
|
+
} catch (err) {
|
|
839
|
+
if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
|
|
840
|
+
const { transform: esbuild } = await loadEsbuild();
|
|
841
|
+
const r = await esbuild(source, {
|
|
842
|
+
loader: 'ts',
|
|
843
|
+
format: 'esm',
|
|
844
|
+
target: 'es2022',
|
|
845
|
+
sourcemap: 'inline',
|
|
846
|
+
sourcefile: abs,
|
|
847
|
+
});
|
|
848
|
+
return r.code;
|
|
849
|
+
}
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
|
|
856
|
+
* Result is cached by mtime so subsequent requests are instant; a
|
|
857
|
+
* file edit invalidates naturally.
|
|
858
|
+
*
|
|
859
|
+
* @param {string} abs
|
|
860
|
+
* @param {boolean} dev
|
|
861
|
+
*/
|
|
862
|
+
async function tsResponse(abs, dev) {
|
|
863
|
+
const st = await stat(abs);
|
|
864
|
+
const cached = TS_CACHE.get(abs);
|
|
865
|
+
if (cached && cached.mtimeMs === st.mtimeMs) {
|
|
866
|
+
return new Response(cached.code, {
|
|
867
|
+
headers: {
|
|
868
|
+
'content-type': 'application/javascript; charset=utf-8',
|
|
869
|
+
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
const source = await readFile(abs, 'utf8');
|
|
874
|
+
const code = await stripTs(source, abs);
|
|
875
|
+
// Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
|
|
876
|
+
if (TS_CACHE.size >= TS_CACHE_MAX) {
|
|
877
|
+
const oldest = TS_CACHE.keys().next().value;
|
|
878
|
+
TS_CACHE.delete(oldest);
|
|
879
|
+
}
|
|
880
|
+
TS_CACHE.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
|
|
881
|
+
return new Response(code, {
|
|
882
|
+
headers: {
|
|
883
|
+
'content-type': 'application/javascript; charset=utf-8',
|
|
884
|
+
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function debounce(fn, ms) {
|
|
890
|
+
let t;
|
|
891
|
+
return (...args) => {
|
|
892
|
+
clearTimeout(t);
|
|
893
|
+
t = setTimeout(() => fn(...args), ms);
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Find the absolute directory of the `@webjsdev/core` package, regardless of
|
|
899
|
+
* whether we're running from the monorepo or an installed copy.
|
|
900
|
+
* @param {string} appDir
|
|
901
|
+
*/
|
|
902
|
+
function locateCoreDir(appDir) {
|
|
903
|
+
try {
|
|
904
|
+
const require = createRequire(join(appDir, 'package.json'));
|
|
905
|
+
const pkgPath = require.resolve('@webjsdev/core/package.json');
|
|
906
|
+
return dirname(pkgPath);
|
|
907
|
+
} catch {}
|
|
908
|
+
const here = fileURLToPath(import.meta.url);
|
|
909
|
+
return resolve(here, '..', '..', '..', 'core');
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Find an npm package's installed root folder in the app's node_modules graph.
|
|
914
|
+
* @param {string} appDir
|
|
915
|
+
* @param {string} pkgName
|
|
916
|
+
* @returns {string | null}
|
|
917
|
+
*/
|
|
918
|
+
function locatePackageDir(appDir, pkgName) {
|
|
919
|
+
// Many packages lock down `./package.json` in their exports field, so we
|
|
920
|
+
// resolve the bare specifier (always exported) and trim back to the
|
|
921
|
+
// folder named pkgName.
|
|
922
|
+
const match = '/node_modules/' + pkgName + '/';
|
|
923
|
+
const tryFrom = (from) => {
|
|
924
|
+
const require = createRequire(from);
|
|
925
|
+
const entry = require.resolve(pkgName).split(sep).join('/');
|
|
926
|
+
const at = entry.lastIndexOf(match);
|
|
927
|
+
if (at < 0) return null;
|
|
928
|
+
return entry.slice(0, at + match.length - 1).split('/').join(sep);
|
|
929
|
+
};
|
|
930
|
+
try { const d = tryFrom(join(appDir, 'package.json')); if (d) return d; } catch {}
|
|
931
|
+
try { const d = tryFrom(fileURLToPath(import.meta.url)); if (d) return d; } catch {}
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Load esbuild. Resolved as a real dependency of `@webjsdev/server`,
|
|
937
|
+
* so the bare specifier always resolves regardless of where the cli is
|
|
938
|
+
* installed (global, local, workspace-linked).
|
|
939
|
+
*
|
|
940
|
+
* @returns {Promise<typeof import('esbuild')>}
|
|
941
|
+
*/
|
|
942
|
+
let _esbuild = null;
|
|
943
|
+
async function loadEsbuild() {
|
|
944
|
+
if (_esbuild) return _esbuild;
|
|
945
|
+
_esbuild = await import('esbuild');
|
|
946
|
+
return _esbuild;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const RELOAD_CLIENT_JS = `// webjs dev reload client
|
|
950
|
+
const es = new EventSource('/__webjs/events');
|
|
951
|
+
es.addEventListener('reload', () => location.reload());
|
|
952
|
+
`;
|