@webjsdev/server 0.7.3 → 0.8.0
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 +7 -2
- package/index.js +21 -3
- package/package.json +1 -3
- package/src/actions.js +6 -6
- package/src/cache.js +19 -2
- package/src/check.js +226 -95
- package/src/component-elision.js +797 -0
- package/src/component-scanner.js +8 -2
- package/src/context.js +36 -0
- package/src/crypto-utils.js +65 -0
- package/src/dev.js +478 -93
- package/src/importmap.js +282 -20
- package/src/js-scan.js +288 -0
- package/src/module-graph.js +150 -13
- package/src/rate-limit.js +100 -12
- package/src/script-tag-json.js +63 -0
- package/src/session.js +60 -14
- package/src/ssr.js +133 -17
- package/src/vendor.js +1231 -103
- package/src/websocket.js +3 -1
package/src/dev.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer as createHttp1Server } from 'node:http';
|
|
2
|
-
import { stat, readFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
2
|
+
import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { digestHex } from './crypto-utils.js';
|
|
4
5
|
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
5
6
|
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
|
|
6
7
|
import { createRequire, stripTypeScriptTypes } from 'node:module';
|
|
@@ -19,7 +20,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
19
20
|
// equivalent built-in, we will need to install `amaro` directly (or
|
|
20
21
|
// an equivalent: Sucrase preserves lines but not columns; SWC's
|
|
21
22
|
// strip-only also works). The fast-path `stripTs` helper would
|
|
22
|
-
// change one import line
|
|
23
|
+
// change one import line.
|
|
23
24
|
//
|
|
24
25
|
// Suppress the one-shot ExperimentalWarning that Node prints the
|
|
25
26
|
// first time `stripTypeScriptTypes` is called. The API is committed
|
|
@@ -57,15 +58,16 @@ import {
|
|
|
57
58
|
import { defaultLogger } from './logger.js';
|
|
58
59
|
import { withRequest } from './context.js';
|
|
59
60
|
import { attachWebSocket } from './websocket.js';
|
|
60
|
-
import { scanBareImports,
|
|
61
|
-
import { buildModuleGraph, transitiveDeps } from './module-graph.js';
|
|
62
|
-
import { primeComponentRegistry, findOrphanComponents } from './component-scanner.js';
|
|
61
|
+
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache } from './vendor.js';
|
|
62
|
+
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
|
|
63
|
+
import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
|
|
64
|
+
import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
63
65
|
|
|
64
66
|
/** PascalCase → kebab-case for a helpful diagnostic example tag name. */
|
|
65
67
|
function kebab(name) {
|
|
66
68
|
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
67
69
|
}
|
|
68
|
-
import { setVendorEntries } from './importmap.js';
|
|
70
|
+
import { setVendorEntries, setCoreInstall } from './importmap.js';
|
|
69
71
|
import { urlFromRequest } from './forwarded.js';
|
|
70
72
|
|
|
71
73
|
const MIME = {
|
|
@@ -92,23 +94,79 @@ const MIME = {
|
|
|
92
94
|
* Capped at 500 entries to prevent unbounded memory growth in
|
|
93
95
|
* long-running production servers.
|
|
94
96
|
*
|
|
95
|
-
*
|
|
97
|
+
* Stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
|
|
96
98
|
* Position-preserving whitespace replacement. No sourcemap is
|
|
97
99
|
* emitted because every (line, column) maps to itself in the source.
|
|
98
100
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* `webjs check`.
|
|
101
|
+
* Only erasable TypeScript is supported. Non-erasable syntax (`enum`,
|
|
102
|
+
* `namespace` with values, parameter properties, legacy decorators
|
|
103
|
+
* with `emitDecoratorMetadata`, `import = require`) throws at strip
|
|
104
|
+
* time. The `erasable-typescript-only` and `no-non-erasable-typescript`
|
|
105
|
+
* lint rules catch these at edit time. webjs is buildless end-to-end:
|
|
106
|
+
* there is no bundler fallback.
|
|
106
107
|
*
|
|
107
108
|
* @type {Map<string, { mtimeMs: number, code: string, map: string | null }>}
|
|
108
109
|
*/
|
|
109
110
|
const TS_CACHE_MAX = 500;
|
|
110
111
|
const TS_CACHE = new Map();
|
|
111
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
|
|
115
|
+
* what Rails / Next / Astro do out of the box: a scaffolded app with
|
|
116
|
+
* a committed `.env.example` and a developer-copied `.env` should
|
|
117
|
+
* "just work" without the user having to add a dotenv import or set
|
|
118
|
+
* the file path on the CLI.
|
|
119
|
+
*
|
|
120
|
+
* Uses Node 24+'s built-in `process.loadEnvFile`, which is dotenv-
|
|
121
|
+
* compatible and DOES NOT override pre-existing `process.env` values.
|
|
122
|
+
* Calls that hit a missing file or parse error are silenced; the
|
|
123
|
+
* server should still come up cleanly when there's no `.env`.
|
|
124
|
+
*
|
|
125
|
+
* Idempotent: re-running is a no-op for any env var the user already
|
|
126
|
+
* exported (e.g. via the host shell or a process manager). That
|
|
127
|
+
* keeps the "shell-set wins over file" precedence Rails users
|
|
128
|
+
* expect.
|
|
129
|
+
*
|
|
130
|
+
* Must run before any server-only module is loaded by
|
|
131
|
+
* buildActionIndex, since module-init code in `lib/*.server.ts`
|
|
132
|
+
* (e.g. `createAuth({ secret: process.env.AUTH_SECRET })`) reads
|
|
133
|
+
* process.env at import time. createRequestHandler is the
|
|
134
|
+
* single entry point where this is guaranteed.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} appDir
|
|
137
|
+
*/
|
|
138
|
+
function loadAppEnv(appDir) {
|
|
139
|
+
try {
|
|
140
|
+
if (typeof process.loadEnvFile === 'function') {
|
|
141
|
+
process.loadEnvFile(join(appDir, '.env'));
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// No .env file, malformed file, or Node version without
|
|
145
|
+
// loadEnvFile. Either way, fall through silently: the user
|
|
146
|
+
// may not need any env vars, or they may set them via shell.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read the project-level elision switch from `package.json`.
|
|
152
|
+
* `{ "webjs": { "elide": false } }` disables display-only and inert-route
|
|
153
|
+
* elision app-wide (everything ships, like before the feature existed).
|
|
154
|
+
* Any other value, or an absent key, leaves elision enabled (the default).
|
|
155
|
+
* Re-read on every rebuild so toggling the switch takes effect without a
|
|
156
|
+
* server restart.
|
|
157
|
+
* @param {string} appDir
|
|
158
|
+
* @returns {Promise<boolean>}
|
|
159
|
+
*/
|
|
160
|
+
async function readElideEnabled(appDir) {
|
|
161
|
+
try {
|
|
162
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
163
|
+
if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
|
|
164
|
+
} catch {
|
|
165
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
112
170
|
/**
|
|
113
171
|
* Create a reusable, framework-agnostic request handler for a webjs app.
|
|
114
172
|
* The returned `handle(req)` takes a standard `Request` and resolves to a
|
|
@@ -124,21 +182,72 @@ const TS_CACHE = new Map();
|
|
|
124
182
|
*/
|
|
125
183
|
export async function createRequestHandler(opts) {
|
|
126
184
|
const appDir = resolve(opts.appDir);
|
|
185
|
+
// Load <appDir>/.env into process.env BEFORE anything else.
|
|
186
|
+
// buildActionIndex below imports server-only files (lib/*.server.ts,
|
|
187
|
+
// modules/**/*.server.ts), some of which read process.env at module
|
|
188
|
+
// init (e.g. createAuth reads AUTH_SECRET). Without this call,
|
|
189
|
+
// scaffolded apps with a committed .env.example + .env would fail
|
|
190
|
+
// to boot until the user discovered the missing env-load. See
|
|
191
|
+
// tracker #37.
|
|
192
|
+
loadAppEnv(appDir);
|
|
127
193
|
const dev = !!opts.dev;
|
|
128
194
|
const logger = opts.logger || defaultLogger({ dev });
|
|
129
195
|
const coreDir = locateCoreDir(appDir);
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
196
|
+
// Switch the importmap between dist/ bundles and src/ per-file
|
|
197
|
+
// URLs depending on whether the resolved @webjsdev/core install
|
|
198
|
+
// has built bundles on disk. npm-installed copies always do;
|
|
199
|
+
// workspace dev does only after `npm run build:dist`. Without
|
|
200
|
+
// a built dist the server falls back to the historical per-file
|
|
201
|
+
// src/ URLs so dev iteration does not require a build step.
|
|
202
|
+
//
|
|
203
|
+
// Both required bundles must exist. An older @webjsdev/core
|
|
204
|
+
// install built BEFORE the browser-entry split (#119/#128) has
|
|
205
|
+
// `webjs-core.js` but no `webjs-core-browser.js`. Enabling dist
|
|
206
|
+
// mode in that case would route the bare `@webjsdev/core`
|
|
207
|
+
// specifier at a 404 on every page. Require both so a partial
|
|
208
|
+
// dist transparently degrades to src/ mode instead.
|
|
209
|
+
const distDir = join(coreDir, 'dist');
|
|
210
|
+
const distComplete =
|
|
211
|
+
existsSync(join(distDir, 'webjs-core.js')) &&
|
|
212
|
+
existsSync(join(distDir, 'webjs-core-browser.js'));
|
|
213
|
+
await setCoreInstall(coreDir, distComplete);
|
|
134
214
|
|
|
135
215
|
// Build module dependency graph for transitive preload hints.
|
|
136
216
|
const moduleGraph = await buildModuleGraph(appDir);
|
|
137
217
|
|
|
138
218
|
// Scan for component classes and prime their module URLs into the
|
|
139
219
|
// core registry. SSR uses this for modulepreload hints without
|
|
140
|
-
// requiring authors to pass `import.meta.url` themselves.
|
|
141
|
-
|
|
220
|
+
// requiring authors to pass `import.meta.url` themselves. The same
|
|
221
|
+
// scan result feeds the browser-bound graph computation below,
|
|
222
|
+
// avoiding a duplicate appDir walk at boot.
|
|
223
|
+
const components = await scanComponents(appDir);
|
|
224
|
+
await primeComponentRegistry(appDir, components);
|
|
225
|
+
|
|
226
|
+
const routeTable = await buildRouteTable(appDir);
|
|
227
|
+
|
|
228
|
+
// Determine which component modules are display-only and which page/layout
|
|
229
|
+
// route modules are inert, so both can be elided from the browser (no JS
|
|
230
|
+
// download). Static analysis only; the sets bias conservatively toward
|
|
231
|
+
// shipping. See component-elision.js. The project-level `webjs.elide: false`
|
|
232
|
+
// switch in package.json skips the analysis entirely (empty sets, so nothing
|
|
233
|
+
// is stripped and the importmap keeps every vendor dep).
|
|
234
|
+
const elideEnabled = await readElideEnabled(appDir);
|
|
235
|
+
const { elidableComponents, inertRouteModules } = elideEnabled
|
|
236
|
+
? await analyzeElision(
|
|
237
|
+
components,
|
|
238
|
+
collectRouteModules(routeTable),
|
|
239
|
+
moduleGraph,
|
|
240
|
+
(f) => readFile(f, 'utf8'),
|
|
241
|
+
appDir,
|
|
242
|
+
)
|
|
243
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set() };
|
|
244
|
+
|
|
245
|
+
// Scan for bare npm imports and register vendor import map entries.
|
|
246
|
+
// Runs AFTER elision so vendor deps reachable only through display-only
|
|
247
|
+
// components are excluded from the importmap.
|
|
248
|
+
const bareImports = await scanBareImports(appDir, new Set([...elidableComponents, ...inertRouteModules]));
|
|
249
|
+
const initialVendor = await resolveVendorImports(bareImports, appDir);
|
|
250
|
+
await setVendorEntries(initialVendor.imports, initialVendor.integrity);
|
|
142
251
|
|
|
143
252
|
// Dev-time guardrail: warn about any class extending WebComponent
|
|
144
253
|
// that isn't registered via customElements.define() in its own
|
|
@@ -156,25 +265,81 @@ export async function createRequestHandler(opts) {
|
|
|
156
265
|
}
|
|
157
266
|
|
|
158
267
|
const state = {
|
|
159
|
-
routeTable
|
|
268
|
+
routeTable,
|
|
160
269
|
actionIndex: await buildActionIndex(appDir, dev),
|
|
161
270
|
middleware: await loadMiddleware(appDir, dev, logger),
|
|
162
271
|
logger,
|
|
163
272
|
bareImports,
|
|
164
273
|
moduleGraph,
|
|
274
|
+
elidableComponents,
|
|
275
|
+
inertRouteModules,
|
|
276
|
+
browserBoundFiles: computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir),
|
|
165
277
|
};
|
|
166
278
|
|
|
279
|
+
// Rebuilds are serialized so a slow rebuild #1 (e.g. waiting on a
|
|
280
|
+
// jspm.io fetch) cannot overwrite a fresher rebuild #2's
|
|
281
|
+
// setVendorEntries / route table when it finally finishes. Without
|
|
282
|
+
// this, two file edits inside one fs.watch debounce window could
|
|
283
|
+
// produce a permanently-stale importmap until the next rebuild.
|
|
284
|
+
// Each rebuild also gets a monotonic token; setVendorEntries is only
|
|
285
|
+
// applied if its token still matches the latest scheduled rebuild.
|
|
286
|
+
let rebuildInFlight = Promise.resolve();
|
|
287
|
+
let latestRebuildToken = 0;
|
|
288
|
+
|
|
167
289
|
async function rebuild() {
|
|
290
|
+
const token = ++latestRebuildToken;
|
|
291
|
+
rebuildInFlight = rebuildInFlight.then(() => doRebuild(token)).catch((e) => {
|
|
292
|
+
logger.error?.(`[webjs] rebuild failed:`, e);
|
|
293
|
+
});
|
|
294
|
+
return rebuildInFlight;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function doRebuild(token) {
|
|
168
298
|
state.routeTable = await buildRouteTable(appDir);
|
|
169
299
|
state.actionIndex = await buildActionIndex(appDir, dev);
|
|
170
300
|
state.middleware = await loadMiddleware(appDir, dev, logger);
|
|
171
|
-
// Re-scan bare imports and module graph on rebuild
|
|
172
301
|
clearVendorCache();
|
|
173
|
-
state.bareImports = await scanBareImports(appDir);
|
|
174
|
-
setVendorEntries(vendorImportMapEntries(state.bareImports));
|
|
175
302
|
state.moduleGraph = await buildModuleGraph(appDir);
|
|
176
303
|
// Re-scan components in case a new file was added or a tag renamed.
|
|
177
|
-
|
|
304
|
+
// Share the scan with the browser-bound graph computation so we
|
|
305
|
+
// don't walk appDir twice per rebuild.
|
|
306
|
+
const components = await scanComponents(appDir);
|
|
307
|
+
await primeComponentRegistry(appDir, components);
|
|
308
|
+
// Recompute which components are elidable and which route modules are
|
|
309
|
+
// inert. A dependency's edit can flip a verdict WITHOUT changing an
|
|
310
|
+
// importer's mtime, so the TS transform cache (keyed by mtime) must be
|
|
311
|
+
// dropped or it would serve a stale strip decision for the unchanged
|
|
312
|
+
// importer.
|
|
313
|
+
{
|
|
314
|
+
const r = (await readElideEnabled(appDir))
|
|
315
|
+
? await analyzeElision(
|
|
316
|
+
components,
|
|
317
|
+
collectRouteModules(state.routeTable),
|
|
318
|
+
state.moduleGraph,
|
|
319
|
+
(f) => readFile(f, 'utf8'),
|
|
320
|
+
appDir,
|
|
321
|
+
)
|
|
322
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set() };
|
|
323
|
+
state.elidableComponents = r.elidableComponents;
|
|
324
|
+
state.inertRouteModules = r.inertRouteModules;
|
|
325
|
+
}
|
|
326
|
+
TS_CACHE.clear();
|
|
327
|
+
// Re-scan bare imports AFTER elision so the importmap drops vendor
|
|
328
|
+
// deps reachable only through display-only components.
|
|
329
|
+
state.bareImports = await scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules]));
|
|
330
|
+
const v = await resolveVendorImports(state.bareImports, appDir);
|
|
331
|
+
// Defensive: if a newer rebuild has been queued while we were
|
|
332
|
+
// awaiting resolveVendorImports, drop our result. The newer one
|
|
333
|
+
// will overwrite anyway, but checking the token here avoids a
|
|
334
|
+
// brief window of stale entries.
|
|
335
|
+
if (token === latestRebuildToken) {
|
|
336
|
+
await setVendorEntries(v.imports, v.integrity);
|
|
337
|
+
}
|
|
338
|
+
// Recompute the browser-bound file set: the page / layout / error /
|
|
339
|
+
// loading / not-found / component entries plus their transitive imports.
|
|
340
|
+
// This drives the dev server's "is this file allowed to be served as
|
|
341
|
+
// source?" gate at the file-extension catch-all branch below.
|
|
342
|
+
state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);
|
|
178
343
|
if (dev) {
|
|
179
344
|
const orphans = await findOrphanComponents(appDir);
|
|
180
345
|
for (const { className, file } of orphans) {
|
|
@@ -267,16 +432,41 @@ export async function startServer(opts) {
|
|
|
267
432
|
},
|
|
268
433
|
});
|
|
269
434
|
|
|
435
|
+
/** @type {AbortController | null} */
|
|
436
|
+
let watcherAbort = null;
|
|
270
437
|
if (dev) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
438
|
+
// Watch the app root recursively via Node's built-in
|
|
439
|
+
// `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
|
|
440
|
+
// Node 24. No external dep needed.
|
|
441
|
+
//
|
|
442
|
+
// fs.watch returns relative paths in event.filename. We apply
|
|
443
|
+
// the same ignore filter chokidar used before: skip
|
|
444
|
+
// node_modules, .git, and prisma's dev artefacts (dev.db,
|
|
445
|
+
// dev.db-journal, migrations/) which the dev server writes
|
|
446
|
+
// during db:migrate and would otherwise loop.
|
|
447
|
+
//
|
|
448
|
+
// The prisma branch uses prefix-only matching (no required
|
|
449
|
+
// trailing separator) so the SQLite sidecar files like
|
|
450
|
+
// `prisma/dev.db` and `prisma/dev.db-journal` are ignored too.
|
|
451
|
+
// node_modules / .git stay separator-anchored so unrelated
|
|
452
|
+
// names like `node_modules.bak/foo` don't get caught.
|
|
453
|
+
const IGNORE = /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/;
|
|
454
|
+
const rebuild = debounce(() => app.rebuild(), 80);
|
|
455
|
+
watcherAbort = new AbortController();
|
|
456
|
+
(async () => {
|
|
457
|
+
try {
|
|
458
|
+
const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
|
|
459
|
+
for await (const event of events) {
|
|
460
|
+
const filename = event.filename || '';
|
|
461
|
+
if (IGNORE.test(filename)) continue;
|
|
462
|
+
rebuild();
|
|
463
|
+
}
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (err && /** @type any */(err).name !== 'AbortError') {
|
|
466
|
+
logger.warn({ err }, 'file watcher exited');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
})();
|
|
280
470
|
}
|
|
281
471
|
|
|
282
472
|
// SSE keepalive: send a comment frame every 25s to defeat proxy idle timeouts.
|
|
@@ -354,7 +544,13 @@ export async function startServer(opts) {
|
|
|
354
544
|
// corrupted, so log + start an orderly shutdown rather than continuing.
|
|
355
545
|
installProcessHandlers(logger, () => shutdown('uncaughtException'));
|
|
356
546
|
|
|
357
|
-
return {
|
|
547
|
+
return {
|
|
548
|
+
server,
|
|
549
|
+
close: () => new Promise((r) => {
|
|
550
|
+
if (watcherAbort) watcherAbort.abort();
|
|
551
|
+
server.close(() => r());
|
|
552
|
+
}),
|
|
553
|
+
};
|
|
358
554
|
}
|
|
359
555
|
|
|
360
556
|
/**
|
|
@@ -408,12 +604,31 @@ async function handleCore(req, ctx) {
|
|
|
408
604
|
return fileResponse(abs, { dev, immutable: false });
|
|
409
605
|
}
|
|
410
606
|
|
|
411
|
-
// Vendor
|
|
412
|
-
//
|
|
413
|
-
//
|
|
607
|
+
// Vendor URL handler for `webjs vendor pin --download` mode only.
|
|
608
|
+
// In default pin mode (or no-pin mode) the importmap routes bare
|
|
609
|
+
// imports straight to ga.jspm.io URLs and the browser bypasses this
|
|
610
|
+
// server entirely. When the user ran `webjs vendor pin --download`,
|
|
611
|
+
// the importmap has local `/__webjs/vendor/<file>.js` URLs and this
|
|
612
|
+
// handler serves the committed bundle files from `.webjs/vendor/`.
|
|
414
613
|
if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
|
|
415
|
-
|
|
416
|
-
|
|
614
|
+
// Vendor bundles are read-only static content. Allow GET/HEAD for
|
|
615
|
+
// the normal fetch, OPTIONS for any cross-origin preflight (we
|
|
616
|
+
// return 204 with the same Allow header rather than 405, which
|
|
617
|
+
// some intermediaries treat as a hard failure even for a CORS
|
|
618
|
+
// probe), and 405 everything else.
|
|
619
|
+
if (method === 'OPTIONS') {
|
|
620
|
+
return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
|
|
621
|
+
}
|
|
622
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
623
|
+
return new Response(null, { status: 405, headers: { allow: 'GET, HEAD, OPTIONS' } });
|
|
624
|
+
}
|
|
625
|
+
const filename = path.slice('/__webjs/vendor/'.length);
|
|
626
|
+
const resp = await serveDownloadedBundle(filename, appDir, dev);
|
|
627
|
+
if (method === 'HEAD') {
|
|
628
|
+
// HEAD must return same headers as GET with no body.
|
|
629
|
+
return new Response(null, { status: resp.status, headers: resp.headers });
|
|
630
|
+
}
|
|
631
|
+
return resp;
|
|
417
632
|
}
|
|
418
633
|
|
|
419
634
|
// Internal server-action RPC endpoint
|
|
@@ -450,10 +665,32 @@ async function handleCore(req, ctx) {
|
|
|
450
665
|
if (path.startsWith('/public/') || path === '/favicon.ico') {
|
|
451
666
|
const p = path === '/favicon.ico' ? '/public/favicon.ico' : path;
|
|
452
667
|
const abs = join(appDir, p);
|
|
668
|
+
// Containment check. `join` normalises `..` segments, so a path
|
|
669
|
+
// like `/public/%2E%2E/secret/x.svg` decodes (after URL parsing,
|
|
670
|
+
// which doesn't touch `%2E`) to `/public/../secret/x.svg` and
|
|
671
|
+
// `join(appDir, ...)` resolves it to `appDir/secret/x.svg`. The
|
|
672
|
+
// resulting `abs` could be inside `appDir` but OUTSIDE `appDir/
|
|
673
|
+
// public/`, exposing files the user reasonably thought were
|
|
674
|
+
// private under their non-public directories. Reject anything
|
|
675
|
+
// that doesn't stay under `appDir/public/` (and the favicon
|
|
676
|
+
// exception, which is already validated above).
|
|
677
|
+
const publicRoot = join(appDir, 'public') + sep;
|
|
678
|
+
if (!abs.startsWith(publicRoot)) {
|
|
679
|
+
return new Response(null, { status: 404 });
|
|
680
|
+
}
|
|
453
681
|
if (await exists(abs)) return fileResponse(abs, { dev, immutable: false });
|
|
454
682
|
}
|
|
455
683
|
|
|
456
|
-
// User source modules (served as ES modules, with action-file rewriting)
|
|
684
|
+
// User source modules (served as ES modules, with action-file rewriting).
|
|
685
|
+
//
|
|
686
|
+
// Authorization gate: only files reachable from a browser-bound entry
|
|
687
|
+
// (page, layout, error, loading, not-found, component) via the module
|
|
688
|
+
// graph are servable. Same posture as Next.js, where the bundler's
|
|
689
|
+
// manifest is the source of truth for what the browser may fetch.
|
|
690
|
+
// Anything not in the set (node_modules/, top-level package.json,
|
|
691
|
+
// scripts/, etc.) 404s here regardless of whether the file exists on
|
|
692
|
+
// disk. The `.server.{js,ts}` stub guardrail runs below as a
|
|
693
|
+
// defense-in-depth layer.
|
|
457
694
|
if (method === 'GET' && /\.(js|mjs|ts|mts|css|svg|png|jpg|jpeg|gif|webp|json|ico|txt)$/.test(path)) {
|
|
458
695
|
let abs = join(appDir, path);
|
|
459
696
|
// When the browser asks for `.js`, allow falling through to a sibling
|
|
@@ -466,7 +703,12 @@ async function handleCore(req, ctx) {
|
|
|
466
703
|
if (await exists(mtsAbs)) abs = mtsAbs;
|
|
467
704
|
}
|
|
468
705
|
}
|
|
469
|
-
|
|
706
|
+
// Gate: must be in the browser-bound module graph. Server-action
|
|
707
|
+
// files (.server.{js,ts}) get a stub via the guardrail below; they
|
|
708
|
+
// ARE included in browserBoundFiles because client code imports
|
|
709
|
+
// them by path (the import rewrites to an RPC stub at request time).
|
|
710
|
+
const inGraph = state.browserBoundFiles && state.browserBoundFiles.has(abs);
|
|
711
|
+
if (abs.startsWith(appDir) && inGraph && (await exists(abs))) {
|
|
470
712
|
// Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
|
|
471
713
|
// MUST NEVER be served as source to the browser. The extension is
|
|
472
714
|
// the path-level boundary; we re-verify it on every request (not
|
|
@@ -484,7 +726,7 @@ async function handleCore(req, ctx) {
|
|
|
484
726
|
// Lazily ensure the index knows about this file so serveActionStub
|
|
485
727
|
// can mint a stable hash and function list.
|
|
486
728
|
if (!state.actionIndex.fileToHash.has(abs)) {
|
|
487
|
-
const h = hashFile(abs);
|
|
729
|
+
const h = await hashFile(abs);
|
|
488
730
|
state.actionIndex.fileToHash.set(abs, h);
|
|
489
731
|
state.actionIndex.hashToFile.set(h, abs);
|
|
490
732
|
}
|
|
@@ -499,9 +741,19 @@ async function handleCore(req, ctx) {
|
|
|
499
741
|
headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
|
|
500
742
|
});
|
|
501
743
|
}
|
|
502
|
-
// TypeScript source:
|
|
744
|
+
// TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
|
|
745
|
+
// Both module paths also strip side-effect imports of display-only
|
|
746
|
+
// components so the browser never downloads their JS.
|
|
747
|
+
const elideOpts = {
|
|
748
|
+
moduleGraph: state.moduleGraph,
|
|
749
|
+
elidableComponents: state.elidableComponents,
|
|
750
|
+
appDir,
|
|
751
|
+
};
|
|
503
752
|
if (/\.m?ts$/.test(abs)) {
|
|
504
|
-
return tsResponse(abs, dev);
|
|
753
|
+
return tsResponse(abs, dev, elideOpts);
|
|
754
|
+
}
|
|
755
|
+
if (/\.m?js$/.test(abs)) {
|
|
756
|
+
return jsModuleResponse(abs, dev, elideOpts);
|
|
505
757
|
}
|
|
506
758
|
return fileResponse(abs, { dev, immutable: false });
|
|
507
759
|
}
|
|
@@ -547,6 +799,8 @@ async function handleCore(req, ctx) {
|
|
|
547
799
|
const handler = () => ssrPage(page.route, page.params, url, {
|
|
548
800
|
dev, appDir, req, moduleGraph: state.moduleGraph,
|
|
549
801
|
serverFiles: state.actionIndex.fileToHash,
|
|
802
|
+
elidableComponents: state.elidableComponents,
|
|
803
|
+
inertRouteModules: state.inertRouteModules,
|
|
550
804
|
});
|
|
551
805
|
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
552
806
|
}
|
|
@@ -693,12 +947,23 @@ function toWebRequest(req, url) {
|
|
|
693
947
|
/** @type {Record<string,string>} */
|
|
694
948
|
const headers = {};
|
|
695
949
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
696
|
-
// Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`)
|
|
697
|
-
//
|
|
950
|
+
// Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`).
|
|
951
|
+
// They're parsed separately into req.method / req.url and are rejected
|
|
698
952
|
// by the standard Headers class if we pass them through verbatim.
|
|
699
953
|
if (k.startsWith(':')) continue;
|
|
954
|
+
// Strip any inbound `x-webjs-remote-ip` header so clients cannot
|
|
955
|
+
// spoof the framework-stamped client IP that rate-limit's
|
|
956
|
+
// `clientIp(req, { trustProxy: false })` reads. We rewrite it
|
|
957
|
+
// below from the actual TCP socket. Node's IncomingMessage
|
|
958
|
+
// always lowercases header keys, so a literal compare is enough.
|
|
959
|
+
if (k === 'x-webjs-remote-ip') continue;
|
|
700
960
|
headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
|
|
701
961
|
}
|
|
962
|
+
// Stamp the framework-trusted remote IP from the socket. Read by
|
|
963
|
+
// `clientIp(req)` (rate-limit.js) as the bucket key when
|
|
964
|
+
// `trustProxy: false` (the safe default).
|
|
965
|
+
const remoteIp = req.socket?.remoteAddress;
|
|
966
|
+
if (remoteIp) headers['x-webjs-remote-ip'] = remoteIp;
|
|
702
967
|
let body;
|
|
703
968
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
704
969
|
body = new ReadableStream({
|
|
@@ -800,7 +1065,7 @@ async function fileResponse(abs, opts) {
|
|
|
800
1065
|
if (opts.dev) {
|
|
801
1066
|
headers['cache-control'] = 'no-cache';
|
|
802
1067
|
} else {
|
|
803
|
-
const etag = `"${
|
|
1068
|
+
const etag = `"${(await digestHex('SHA-1', data)).slice(0, 16)}"`;
|
|
804
1069
|
headers['etag'] = etag;
|
|
805
1070
|
headers['cache-control'] = opts.immutable
|
|
806
1071
|
? 'public, max-age=31536000, immutable'
|
|
@@ -812,54 +1077,72 @@ async function fileResponse(abs, opts) {
|
|
|
812
1077
|
}
|
|
813
1078
|
}
|
|
814
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* Serve a plain `.js` / `.mjs` browser module, stripping side-effect
|
|
1082
|
+
* imports of display-only components. Mirrors {@link fileResponse}'s
|
|
1083
|
+
* headers but reads as text so the source can be transformed. Used only
|
|
1084
|
+
* for files that exist as `.js` on disk (TS apps usually hit
|
|
1085
|
+
* {@link tsResponse} via the .js to .ts sibling rewrite instead).
|
|
1086
|
+
*
|
|
1087
|
+
* @param {string} abs
|
|
1088
|
+
* @param {boolean} dev
|
|
1089
|
+
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
|
|
1090
|
+
*/
|
|
1091
|
+
async function jsModuleResponse(abs, dev, elideOpts) {
|
|
1092
|
+
let source;
|
|
1093
|
+
try { source = await readFile(abs, 'utf8'); }
|
|
1094
|
+
catch { return new Response('Not found', { status: 404 }); }
|
|
1095
|
+
const code = elideImportsFromSource(
|
|
1096
|
+
source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
1097
|
+
);
|
|
1098
|
+
const headers = { 'content-type': 'application/javascript; charset=utf-8' };
|
|
1099
|
+
if (dev) {
|
|
1100
|
+
headers['cache-control'] = 'no-cache';
|
|
1101
|
+
} else {
|
|
1102
|
+
headers['etag'] = `"${(await digestHex('SHA-1', code)).slice(0, 16)}"`;
|
|
1103
|
+
headers['cache-control'] = 'public, max-age=3600';
|
|
1104
|
+
}
|
|
1105
|
+
return new Response(code, { status: 200, headers });
|
|
1106
|
+
}
|
|
1107
|
+
|
|
815
1108
|
async function exists(p) {
|
|
816
1109
|
try { await stat(p); return true; } catch { return false; }
|
|
817
1110
|
}
|
|
818
1111
|
|
|
819
1112
|
/**
|
|
820
|
-
* Strip TypeScript types from `source
|
|
821
|
-
* `module.stripTypeScriptTypes
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* parameter properties, legacy decorators).
|
|
1113
|
+
* Strip TypeScript types from `source` via Node's built-in
|
|
1114
|
+
* `module.stripTypeScriptTypes`. Position-preserving whitespace
|
|
1115
|
+
* replacement: no sourcemap is needed because every (line, column)
|
|
1116
|
+
* maps to itself in the source.
|
|
825
1117
|
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
1118
|
+
* Only erasable TypeScript is supported. Non-erasable syntax
|
|
1119
|
+
* (`enum`, `namespace` with values, parameter properties, legacy
|
|
1120
|
+
* decorators with `emitDecoratorMetadata`, `import = require`)
|
|
1121
|
+
* throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` from Node and the
|
|
1122
|
+
* dev server returns the error to the caller. The
|
|
1123
|
+
* `erasable-typescript-only` and `no-non-erasable-typescript` lint
|
|
1124
|
+
* rules catch these at edit time. There is no bundler fallback;
|
|
1125
|
+
* webjs is buildless end-to-end.
|
|
830
1126
|
*
|
|
831
1127
|
* @param {string} source
|
|
832
|
-
* @param {string}
|
|
1128
|
+
* @param {string} _abs (unused; preserved for symmetry with prior signature)
|
|
833
1129
|
* @returns {Promise<string>}
|
|
834
1130
|
*/
|
|
835
|
-
async function stripTs(source,
|
|
836
|
-
|
|
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
|
-
}
|
|
1131
|
+
async function stripTs(source, _abs) {
|
|
1132
|
+
return stripTypeScriptTypes(source);
|
|
852
1133
|
}
|
|
853
1134
|
|
|
854
1135
|
/**
|
|
855
1136
|
* Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
|
|
856
1137
|
* Result is cached by mtime so subsequent requests are instant; a
|
|
857
|
-
* file edit invalidates naturally.
|
|
1138
|
+
* file edit invalidates naturally. `elideOpts` additionally strips
|
|
1139
|
+
* side-effect imports of display-only components from the served code.
|
|
858
1140
|
*
|
|
859
1141
|
* @param {string} abs
|
|
860
1142
|
* @param {boolean} dev
|
|
1143
|
+
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
|
|
861
1144
|
*/
|
|
862
|
-
async function tsResponse(abs, dev) {
|
|
1145
|
+
async function tsResponse(abs, dev, elideOpts) {
|
|
863
1146
|
const st = await stat(abs);
|
|
864
1147
|
const cached = TS_CACHE.get(abs);
|
|
865
1148
|
if (cached && cached.mtimeMs === st.mtimeMs) {
|
|
@@ -871,7 +1154,48 @@ async function tsResponse(abs, dev) {
|
|
|
871
1154
|
});
|
|
872
1155
|
}
|
|
873
1156
|
const source = await readFile(abs, 'utf8');
|
|
874
|
-
|
|
1157
|
+
let code;
|
|
1158
|
+
try {
|
|
1159
|
+
code = await stripTs(source, abs);
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
// Node's stripTypeScriptTypes throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
|
|
1162
|
+
// for enum, namespace with values, parameter properties, legacy
|
|
1163
|
+
// decorators with emitDecoratorMetadata, and import = require.
|
|
1164
|
+
// Return a clean 500 with the file path and a pointer at the
|
|
1165
|
+
// erasable-typescript-only lint rule rather than letting the
|
|
1166
|
+
// error bubble up unstyled.
|
|
1167
|
+
if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
|
|
1168
|
+
// Log full detail server-side regardless of mode so operators
|
|
1169
|
+
// see what went wrong in their logs.
|
|
1170
|
+
// eslint-disable-next-line no-console
|
|
1171
|
+
console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
|
|
1172
|
+
const msg = dev
|
|
1173
|
+
// Dev: include the file path and Node's error message so the
|
|
1174
|
+
// developer's browser tooling can point them at the offending
|
|
1175
|
+
// construct. Replace `*` + `/` with `*\\/` so a path or
|
|
1176
|
+
// message containing the comment-close sequence cannot
|
|
1177
|
+
// terminate the wrapper comment early.
|
|
1178
|
+
? `[webjs] non-erasable TypeScript in ${abs}: ${err.message}\n\n` +
|
|
1179
|
+
`webjs is buildless: only erasable TS syntax is supported. ` +
|
|
1180
|
+
`Replace enum / namespace / parameter-property / legacy-decorator / ` +
|
|
1181
|
+
`import = require constructs with their erasable equivalents. ` +
|
|
1182
|
+
`Run \`webjs check\` for guidance (no-non-erasable-typescript rule).`
|
|
1183
|
+
// Prod: terse, no path leak, no Node-message leak (Node's
|
|
1184
|
+
// message can include source snippets). Operators get the
|
|
1185
|
+
// detail in server logs above.
|
|
1186
|
+
: `[webjs] server error transforming a .ts response. Check server logs.`;
|
|
1187
|
+
return new Response(`/* ${msg.replace(/\*\//g, '*\\/')} */`, {
|
|
1188
|
+
status: 500,
|
|
1189
|
+
headers: { 'content-type': 'application/javascript; charset=utf-8' },
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
throw err;
|
|
1193
|
+
}
|
|
1194
|
+
if (elideOpts) {
|
|
1195
|
+
code = elideImportsFromSource(
|
|
1196
|
+
code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
875
1199
|
// Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
|
|
876
1200
|
if (TS_CACHE.size >= TS_CACHE_MAX) {
|
|
877
1201
|
const oldest = TS_CACHE.keys().next().value;
|
|
@@ -894,6 +1218,81 @@ function debounce(fn, ms) {
|
|
|
894
1218
|
};
|
|
895
1219
|
}
|
|
896
1220
|
|
|
1221
|
+
/**
|
|
1222
|
+
* Walk the route table + component scanner to collect every file the
|
|
1223
|
+
* browser may legitimately fetch as an ES module, then expand via the
|
|
1224
|
+
* module graph into the full transitive closure.
|
|
1225
|
+
*
|
|
1226
|
+
* This is webjs's equivalent of Next.js's bundler-produced page
|
|
1227
|
+
* manifest, applied at boot time (and on every rebuild) instead of
|
|
1228
|
+
* compile time. The dev server's source-file branch uses the returned
|
|
1229
|
+
* Set as an authorization gate: in-set → served (subject to the
|
|
1230
|
+
* .server.{js,ts} stub guardrail); out-of-set → 404.
|
|
1231
|
+
*
|
|
1232
|
+
* Browser-bound entries:
|
|
1233
|
+
* - page.{js,ts,mjs,mts} (re-runs on client for hydration)
|
|
1234
|
+
* - layout.{js,ts,mjs,mts} (same)
|
|
1235
|
+
* - error.{js,ts,mjs,mts} (same)
|
|
1236
|
+
* - loading.{js,ts,mjs,mts} (same)
|
|
1237
|
+
* - not-found.{js,ts,mjs,mts} (same)
|
|
1238
|
+
* - component files discovered by the scanner (eager + lazy)
|
|
1239
|
+
*
|
|
1240
|
+
* Server-only entries (NOT in the set):
|
|
1241
|
+
* - route.{js,ts} (API handlers, never fetched as JS module)
|
|
1242
|
+
* - middleware.{js,ts}
|
|
1243
|
+
* - metadata routes (sitemap.js, robots.js, manifest.js, …)
|
|
1244
|
+
* - .server.{js,ts} files (browser gets a stub, not the source)
|
|
1245
|
+
*
|
|
1246
|
+
* Components are passed in (rather than rescanned) so the caller can
|
|
1247
|
+
* share one scan with `primeComponentRegistry`. Saves a full
|
|
1248
|
+
* appDir walk at boot and on every rebuild.
|
|
1249
|
+
*
|
|
1250
|
+
* @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
|
|
1251
|
+
* @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
|
|
1252
|
+
* @param {Awaited<ReturnType<typeof scanComponents>>} components
|
|
1253
|
+
* @param {string} appDir
|
|
1254
|
+
* @returns {Set<string>}
|
|
1255
|
+
*/
|
|
1256
|
+
/**
|
|
1257
|
+
* Collect every page + layout file across the route table. These are the
|
|
1258
|
+
* modules the client boot script imports, and thus the candidates for
|
|
1259
|
+
* inert-route elision (dropping a module that does no client work).
|
|
1260
|
+
* `route.{js,ts}` / middleware / metadata are excluded: they never ship.
|
|
1261
|
+
*
|
|
1262
|
+
* @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
|
|
1263
|
+
* @returns {string[]}
|
|
1264
|
+
*/
|
|
1265
|
+
function collectRouteModules(routeTable) {
|
|
1266
|
+
/** @type {Set<string>} */
|
|
1267
|
+
const mods = new Set();
|
|
1268
|
+
for (const page of routeTable.pages || []) {
|
|
1269
|
+
if (page.file) mods.add(page.file);
|
|
1270
|
+
for (const f of page.layouts || []) mods.add(f);
|
|
1271
|
+
}
|
|
1272
|
+
return [...mods];
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
|
|
1276
|
+
/** @type {Set<string>} */
|
|
1277
|
+
const entries = new Set();
|
|
1278
|
+
for (const page of routeTable.pages) {
|
|
1279
|
+
if (page.file) entries.add(page.file);
|
|
1280
|
+
for (const f of page.layouts || []) entries.add(f);
|
|
1281
|
+
for (const f of page.errors || []) entries.add(f);
|
|
1282
|
+
for (const f of page.loadings || []) entries.add(f);
|
|
1283
|
+
}
|
|
1284
|
+
if (routeTable.notFound) entries.add(routeTable.notFound);
|
|
1285
|
+
if (routeTable.notFounds) {
|
|
1286
|
+
for (const f of routeTable.notFounds.values()) entries.add(f);
|
|
1287
|
+
}
|
|
1288
|
+
// Lazy components live in the registry but no page imports their
|
|
1289
|
+
// class directly; the lazy-loader fetches their module URLs on
|
|
1290
|
+
// viewport entry. Add every discovered component file as an entry so
|
|
1291
|
+
// the graph walk covers both eager and lazy paths.
|
|
1292
|
+
for (const c of components) entries.add(c.file);
|
|
1293
|
+
return reachableFromEntries(moduleGraph, [...entries], appDir);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
897
1296
|
/**
|
|
898
1297
|
* Find the absolute directory of the `@webjsdev/core` package, regardless of
|
|
899
1298
|
* whether we're running from the monorepo or an installed copy.
|
|
@@ -932,20 +1331,6 @@ function locatePackageDir(appDir, pkgName) {
|
|
|
932
1331
|
return null;
|
|
933
1332
|
}
|
|
934
1333
|
|
|
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
1334
|
const RELOAD_CLIENT_JS = `// webjs dev reload client
|
|
950
1335
|
const es = new EventSource('/__webjs/events');
|
|
951
1336
|
es.addEventListener('reload', () => location.reload());
|