@webjsdev/server 0.9.0 → 0.10.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 +4 -1
- package/index.d.ts +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +96 -14
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +140 -65
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/dev/serve.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Serving a request once the dev/prod handler has decided what it is: the
|
|
3
3
|
* framework's own static files, an app source module (with TypeScript stripped
|
|
4
|
-
* and
|
|
4
|
+
* and lazy imports deferred), and the per-segment middleware chain around a route.
|
|
5
5
|
*
|
|
6
6
|
* Split off handler.js, which was 1460 lines. The seam is real rather than
|
|
7
7
|
* arithmetic: nothing here builds or configures the handler, and the handler
|
|
8
8
|
* half calls into it at exactly three points.
|
|
9
9
|
*/
|
|
10
10
|
import { stat, readFile } from 'node:fs/promises';
|
|
11
|
-
import { join, extname, resolve, relative, sep } from 'node:path';
|
|
11
|
+
import { join, extname, resolve, relative, sep, dirname } from 'node:path';
|
|
12
12
|
|
|
13
13
|
import { matchPage, matchApi } from '../router.js';
|
|
14
14
|
import { ssrPage, ssrNotFound } from '../ssr.js';
|
|
@@ -26,7 +26,7 @@ import { maybeRegenerate } from '../dev-regenerate.js';
|
|
|
26
26
|
import { stripTypeScript } from '../ts-strip.js';
|
|
27
27
|
import { serveDownloadedBundle } from '../vendor.js';
|
|
28
28
|
import { resolveImport } from '../module-graph.js';
|
|
29
|
-
import {
|
|
29
|
+
import { deferLazyImportsFromSource } from '../lazy-components.js';
|
|
30
30
|
import { annotateSourceLocations, isSourceLocationCandidate, sourceLocationFile } from '../dev-source-locations.js';
|
|
31
31
|
import { basePath } from '../importmap.js';
|
|
32
32
|
import { withBasePath } from '../base-path.js';
|
|
@@ -35,7 +35,12 @@ import { toUrlPath } from '../ssr/preloads.js';
|
|
|
35
35
|
import { BUFFERED_MARKER } from '../conditional-get.js';
|
|
36
36
|
import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js';
|
|
37
37
|
import { devImport } from '../dev-import.js';
|
|
38
|
+
import { versionImportSpecifiers } from './specifier-versions.js';
|
|
39
|
+
import { devServiceWorkerResponse } from './dev-service-worker.js';
|
|
38
40
|
import { runMetadataRoute } from '../metadata-routes.js';
|
|
41
|
+
import { findDecorator, decoratorReport } from '../lit-hints.js';
|
|
42
|
+
import { loadMiddlewareFile } from '../middleware-load.js';
|
|
43
|
+
import { publicFilePath, resolvePublicFile } from '../public-files.js';
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
46
|
* Serve framework-internal static assets that depend on NEITHER the whole-app
|
|
@@ -140,11 +145,9 @@ export async function tryServeFrameworkStatic(path, method, { coreDir, appDir, d
|
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
/**
|
|
143
|
-
* Serve
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* and so must its offline fallback. Same remap shape as the /favicon.ico
|
|
147
|
-
* special-case. (#830)
|
|
148
|
+
* Serve a file from `public/` at the URL root (public-files.js): `public/a.css`
|
|
149
|
+
* is `/a.css`, `public/sw.js` is `/sw.js` (so a service worker scopes to the
|
|
150
|
+
* origin root, #830), `public/favicon.ico` is `/favicon.ico`.
|
|
148
151
|
*
|
|
149
152
|
* Extracted (#1397) so the dev pre-`ensureReady()` call site and the
|
|
150
153
|
* `handleCore` one share ONE implementation, and the containment guard below
|
|
@@ -154,50 +157,42 @@ export async function tryServeFrameworkStatic(path, method, { coreDir, appDir, d
|
|
|
154
157
|
* @param {{ appDir: string, dev: boolean, versioned?: boolean, regenerateRules: any[] }} ctx
|
|
155
158
|
* @returns {Promise<Response|null>} a Response, or null when the path is not a
|
|
156
159
|
* public asset OR the file does not exist, in both of which cases the caller
|
|
157
|
-
* must fall through to the rest of the pipeline (a missing `/
|
|
158
|
-
* 404s through normal routing
|
|
159
|
-
*
|
|
160
|
+
* must fall through to the rest of the pipeline (a missing `/x.png`
|
|
161
|
+
* 404s through normal routing). A path that escapes `public/` is simply not
|
|
162
|
+
* a public file and falls through the same way.
|
|
160
163
|
*/
|
|
161
164
|
export async function tryServePublicAsset(path, ctx) {
|
|
162
|
-
const { appDir, dev, versioned, regenerateRules } = ctx;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// x.svg`
|
|
170
|
-
//
|
|
171
|
-
// `
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
// exception, which is already validated above).
|
|
175
|
-
// The live vector encodes the SLASH, not the dots: the WHATWG URL
|
|
176
|
-
// parser decodes `%2E%2E` and normalises the dot segment away, so a
|
|
177
|
-
// `/public/%2E%2E/x` request arrives here as plain `/x` and never
|
|
178
|
-
// enters this branch. `..%2F` survives parsing intact and does.
|
|
179
|
-
const publicRoot = join(appDir, 'public') + sep;
|
|
180
|
-
if (!abs.startsWith(publicRoot)) {
|
|
181
|
-
return new Response(null, { status: 404 });
|
|
182
|
-
}
|
|
165
|
+
const { appDir, dev, versioned, regenerateRules, publicFiles } = ctx;
|
|
166
|
+
// Under `webjs dev` the root worker is a kill switch (#1689), whether or not
|
|
167
|
+
// the app ships one: a caching worker on a dev origin serves the edit before
|
|
168
|
+
// last, and one a `webjs start` on the same port left behind is cleared too.
|
|
169
|
+
if (dev && path === '/sw.js') return devServiceWorkerResponse();
|
|
170
|
+
// `public/` is served at the URL ROOT (Next.js parity): `public/logo.png`
|
|
171
|
+
// is `/logo.png`. `publicFilePath` keeps the containment guard: `join`
|
|
172
|
+
// normalises `..`, so a `/..%2Fsecret/x.svg` that survived URL parsing
|
|
173
|
+
// (the WHATWG parser normalises a decoded `%2E%2E` away, but not `..%2F`)
|
|
174
|
+
// would otherwise resolve outside `public/`. A path escaping it is not a
|
|
175
|
+
// public file and falls through to the routes.
|
|
176
|
+
if (!publicFilePath(appDir, path)) return null;
|
|
183
177
|
// On-request regeneration (#967): in dev, if a `webjs.dev.regenerate` rule
|
|
184
178
|
// matches this output and it is stale (a source is newer, or it is missing),
|
|
185
179
|
// rebuild it to completion BEFORE serving, so a newly added utility class is
|
|
186
180
|
// never served stale. No-op when no rule matches or the output is fresh, and
|
|
187
181
|
// never runs in prod (rules are empty there). This replaces the fragile
|
|
188
182
|
// `tailwindcss --watch` that could die mid-session and serve stale CSS.
|
|
189
|
-
if (dev && regenerateRules.length) {
|
|
190
|
-
await maybeRegenerate(appDir,
|
|
183
|
+
if (dev && regenerateRules && regenerateRules.length) {
|
|
184
|
+
await maybeRegenerate(appDir, `public${path}`, regenerateRules);
|
|
191
185
|
}
|
|
186
|
+
const abs = resolvePublicFile(appDir, path, { dev, index: publicFiles });
|
|
187
|
+
if (!abs) return null;
|
|
192
188
|
// A `?v=<hash>` public asset is content-addressed -> immutable (#243).
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return null;
|
|
189
|
+
const res = await fileResponse(abs, { dev, immutable: versioned });
|
|
190
|
+
// A worker served below its registration path only controls that subtree
|
|
191
|
+
// unless the response opts it up to the root scope. Served at the root
|
|
192
|
+
// already, so this only matters to a worker under a subdirectory, but it is
|
|
193
|
+
// harmless and keeps a registration with `scope: '/'` working. (#830)
|
|
194
|
+
if (path === '/sw.js') res.headers.set('Service-Worker-Allowed', '/');
|
|
195
|
+
return res;
|
|
201
196
|
}
|
|
202
197
|
|
|
203
198
|
export async function handleCore(req, ctx) {
|
|
@@ -243,12 +238,6 @@ export async function handleCore(req, ctx) {
|
|
|
243
238
|
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins, logger);
|
|
244
239
|
}
|
|
245
240
|
|
|
246
|
-
// Static: /public/*, the #830 root remaps, and /favicon.ico. In dev this
|
|
247
|
-
// already ran before ensureReady() (#1397); this stays the LIVE path in prod
|
|
248
|
-
// and the fallback in dev, and both call sites share one implementation.
|
|
249
|
-
const publicResp = await tryServePublicAsset(path, { appDir, dev, versioned, regenerateRules: state.regenerateRules });
|
|
250
|
-
if (publicResp) return publicResp;
|
|
251
|
-
|
|
252
241
|
// User source modules (served as ES modules, with action-file rewriting).
|
|
253
242
|
//
|
|
254
243
|
// Authorization gate: only files reachable from a browser-bound entry
|
|
@@ -277,7 +266,11 @@ export async function handleCore(req, ctx) {
|
|
|
277
266
|
// them by path (the import rewrites to an RPC stub at request time).
|
|
278
267
|
// In test mode any app file is servable (see the `state.testMode` note
|
|
279
268
|
// above); otherwise the file must be in the browser-bound module graph.
|
|
280
|
-
|
|
269
|
+
// A route-table file (page, layout, boundary) is refused by PATH, test mode
|
|
270
|
+
// included: it runs only on the server (#1686), so its source never leaves.
|
|
271
|
+
const isRouteModule = !!(state.routeModuleFiles && state.routeModuleFiles.has(abs));
|
|
272
|
+
const inGraph = !isRouteModule
|
|
273
|
+
&& (state.testMode || (state.browserBoundFiles && state.browserBoundFiles.has(abs)));
|
|
281
274
|
// Containment: `abs` must be appDir itself or genuinely UNDER it. The
|
|
282
275
|
// trailing `sep` (matching the public-asset branch) stops a `..` path that
|
|
283
276
|
// resolves to a sibling sharing the appDir name-prefix (`/x/app` ->
|
|
@@ -319,11 +312,10 @@ export async function handleCore(req, ctx) {
|
|
|
319
312
|
});
|
|
320
313
|
}
|
|
321
314
|
// TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
|
|
322
|
-
// Both module paths also
|
|
323
|
-
// components
|
|
324
|
-
const
|
|
315
|
+
// Both module paths also defer side-effect imports of `static lazy`
|
|
316
|
+
// components to the lazy loader.
|
|
317
|
+
const serveOpts = {
|
|
325
318
|
moduleGraph: state.moduleGraph,
|
|
326
|
-
elidableComponents: state.elidableComponents,
|
|
327
319
|
// A browser test imports the component it tests and expects it defined
|
|
328
320
|
// when the import resolves, so the test handler serves every import as
|
|
329
321
|
// written (#1524).
|
|
@@ -332,14 +324,17 @@ export async function handleCore(req, ctx) {
|
|
|
332
324
|
// Dev source locations (#1499): annotate the served module's `html`
|
|
333
325
|
// templates exactly as the SSR load hook annotates the server's copy.
|
|
334
326
|
sourceLocations: state.sourceLocations === true,
|
|
327
|
+
// Browser module versions (#1689): an import of a module a dev edit
|
|
328
|
+
// bumped is served with `?t=<n>`, so a hot re-import loads it fresh.
|
|
329
|
+
versionOf: dev && state.browserVersions ? (f) => state.browserVersions.versionOf(f) : undefined,
|
|
335
330
|
};
|
|
336
331
|
// A `?v=<hash>` app-module request is content-addressed -> immutable
|
|
337
332
|
// (#243); an un-fingerprinted request keeps the 1h fallback.
|
|
338
333
|
if (/\.m?ts$/.test(abs)) {
|
|
339
|
-
return tsResponse(abs, dev,
|
|
334
|
+
return tsResponse(abs, dev, serveOpts, state.tsCache, versioned, reportDevError);
|
|
340
335
|
}
|
|
341
336
|
if (/\.m?js$/.test(abs)) {
|
|
342
|
-
return jsModuleResponse(abs, dev,
|
|
337
|
+
return jsModuleResponse(abs, dev, serveOpts, versioned, reportDevError);
|
|
343
338
|
}
|
|
344
339
|
return fileResponse(abs, { dev, immutable: versioned });
|
|
345
340
|
}
|
|
@@ -359,6 +354,17 @@ export async function handleCore(req, ctx) {
|
|
|
359
354
|
}
|
|
360
355
|
}
|
|
361
356
|
|
|
357
|
+
// Static files from `public/`, served at the URL root (Next.js parity), after
|
|
358
|
+
// the app's own source modules and before every route, so a public file
|
|
359
|
+
// beats a dynamic route at the same url. One at the url of a static page, a
|
|
360
|
+
// route.ts or a metadata route is refused at boot (no-public-route-conflict).
|
|
361
|
+
// In dev this already ran before ensureReady() (#1397); this stays the LIVE
|
|
362
|
+
// path in prod and the fallback in dev, both sharing one implementation.
|
|
363
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
364
|
+
const publicResp = await tryServePublicAsset(path, { appDir, dev, versioned, regenerateRules: state.regenerateRules, publicFiles: state.publicFiles });
|
|
365
|
+
if (publicResp) return method === 'HEAD' ? new Response(null, { status: publicResp.status, headers: publicResp.headers }) : publicResp;
|
|
366
|
+
}
|
|
367
|
+
|
|
362
368
|
// Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
|
|
363
369
|
if (method === 'GET' && state.routeTable.metadataRoutes) {
|
|
364
370
|
const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path)
|
|
@@ -390,11 +396,16 @@ export async function handleCore(req, ctx) {
|
|
|
390
396
|
// A handler that throws is recorded in the dev status as a `route` error
|
|
391
397
|
// HERE, before a root middleware wrapping `next()` reports the same error
|
|
392
398
|
// as its own (#1664). The rethrow keeps the response path unchanged.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
399
|
+
// The same throw is reported to onError / onRequestError as a `route`
|
|
400
|
+
// error (reportError dedupes, so the top-level catch does not report it
|
|
401
|
+
// again under a vaguer phase).
|
|
402
|
+
const handler = async () => {
|
|
403
|
+
try { return await handleApi(api.route, api.params, req, dev); } catch (e) {
|
|
404
|
+
if (recordDevStatus) recordDevStatus(e, req, 'route');
|
|
405
|
+
if (reportError) reportError(e, req, 'route', routePathOf(appDir, api.route.file));
|
|
406
|
+
throw e;
|
|
396
407
|
}
|
|
397
|
-
|
|
408
|
+
};
|
|
398
409
|
return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
|
|
399
410
|
}
|
|
400
411
|
|
|
@@ -426,10 +437,8 @@ export async function handleCore(req, ctx) {
|
|
|
426
437
|
const ssrOpts = {
|
|
427
438
|
dev, appDir, moduleGraph: state.moduleGraph,
|
|
428
439
|
serverFiles: state.actionIndex.fileToHash,
|
|
429
|
-
elidableComponents: state.elidableComponents,
|
|
430
440
|
lazyComponentFiles: state.lazyComponentFiles,
|
|
431
|
-
|
|
432
|
-
importOnlyRouteModules: state.importOnlyRouteModules,
|
|
441
|
+
routeFrontiers: state.routeFrontiers,
|
|
433
442
|
notFoundFile: state.routeTable.notFound,
|
|
434
443
|
// Root-only boundaries (#848): the app-wide catch-all error page and the
|
|
435
444
|
// unmatched-anywhere 404, rendered by ssr.js when a nested boundary is
|
|
@@ -446,7 +455,7 @@ export async function handleCore(req, ctx) {
|
|
|
446
455
|
cspEnabled,
|
|
447
456
|
// onError sink (issue #239): a page render error that becomes a 500 is
|
|
448
457
|
// reported to the APM hook with the active request's correlation id.
|
|
449
|
-
onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
|
|
458
|
+
onError: reportError ? (e) => reportError(e, req, 'ssr', routePathOf(appDir, page.route.file)) : undefined,
|
|
450
459
|
// Dev error overlay (#264): a render crash pushes a frame to the open
|
|
451
460
|
// tab. Dev-only (reportDevError early-returns in prod), so no source
|
|
452
461
|
// leaks. Distinct from onError (the APM sink), which always fires.
|
|
@@ -478,14 +487,27 @@ export async function handleCore(req, ctx) {
|
|
|
478
487
|
// still current for the page the user is looking at (the #893 gap the
|
|
479
488
|
// retention exists to close). Dev-only, and skipped for a prefetch,
|
|
480
489
|
// which is not the user's view of anything.
|
|
490
|
+
//
|
|
491
|
+
// A render that reported a NEW error for this url is marked
|
|
492
|
+
// `x-webjs-dev-error: 1` (#1689), so the client router's in-place
|
|
493
|
+
// refresh leaves the last good page on screen under the overlay
|
|
494
|
+
// instead of applying the error page, and the fixing edit's refresh
|
|
495
|
+
// applies in place (Next's posture).
|
|
481
496
|
const handler = dev && !isPrefetch
|
|
482
497
|
? async () => {
|
|
483
498
|
const before = state.lastDevError;
|
|
484
|
-
|
|
499
|
+
let resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
485
500
|
if (
|
|
486
501
|
before && state.lastDevError === before
|
|
487
502
|
&& before.kind === 'render' && before.url === devErrorUrl
|
|
488
503
|
) state.lastDevError = null;
|
|
504
|
+
const after = state.lastDevError;
|
|
505
|
+
if (after && after !== before && after.kind === 'render' && after.url === devErrorUrl) {
|
|
506
|
+
try { resp.headers.set('x-webjs-dev-error', '1'); } catch {
|
|
507
|
+
resp = new Response(resp.body, resp);
|
|
508
|
+
resp.headers.set('x-webjs-dev-error', '1');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
489
511
|
return resp;
|
|
490
512
|
}
|
|
491
513
|
: () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
@@ -569,6 +591,17 @@ export async function handleCore(req, ctx) {
|
|
|
569
591
|
return resp;
|
|
570
592
|
}
|
|
571
593
|
|
|
594
|
+
/**
|
|
595
|
+
* The route file's directory as Next's `onRequestError` context names it,
|
|
596
|
+
* `/app/blog/[slug]` for `app/blog/[slug]/page.ts`.
|
|
597
|
+
* @param {string} appDir
|
|
598
|
+
* @param {string} file
|
|
599
|
+
*/
|
|
600
|
+
export function routePathOf(appDir, file) {
|
|
601
|
+
const rel = relative(join(appDir, 'app'), dirname(file)).split(sep).join('/');
|
|
602
|
+
return rel ? `/app/${rel}` : '/app';
|
|
603
|
+
}
|
|
604
|
+
|
|
572
605
|
/** @param {Request} req @param {string} path */
|
|
573
606
|
export function wantsJson(req, path) {
|
|
574
607
|
const accept = req.headers.get('accept') || '';
|
|
@@ -591,10 +624,15 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
|
|
|
591
624
|
const handlers = [];
|
|
592
625
|
for (const f of files) {
|
|
593
626
|
try {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
//
|
|
627
|
+
handlers.push(await loadMiddlewareFile(f, dev));
|
|
628
|
+
} catch (e) {
|
|
629
|
+
// FAIL CLOSED: a segment middleware usually guards its subtree (auth,
|
|
630
|
+
// a paywall), so one that cannot load must never let the request
|
|
631
|
+
// through unguarded. Log the cause and answer 500.
|
|
632
|
+
console.error(`[webjs] segment middleware ${f} failed to load; answering 500 rather than skipping the guard:`, e);
|
|
633
|
+
return new Response(dev ? `[webjs] middleware ${f} failed to load: ${/** @type {Error} */ (e).message}\n` : 'Server error', {
|
|
634
|
+
status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
635
|
+
});
|
|
598
636
|
}
|
|
599
637
|
}
|
|
600
638
|
let i = 0;
|
|
@@ -606,43 +644,6 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
|
|
|
606
644
|
return next();
|
|
607
645
|
}
|
|
608
646
|
|
|
609
|
-
/**
|
|
610
|
-
* Root-middleware filename candidates, in resolution order.
|
|
611
|
-
*
|
|
612
|
-
* Every other routing convention accepts all four extensions (the router
|
|
613
|
-
* matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
|
|
614
|
-
* and both the scaffold and the dev supervisor write / watch `middleware.ts`.
|
|
615
|
-
* This lookup used to be the single literal `middleware.js`, so a root
|
|
616
|
-
* `middleware.ts` was silently never loaded: no error, no warning, the app
|
|
617
|
-
* just ran with no global middleware. TypeScript is the documented default
|
|
618
|
-
* for an app, so that was the common case, and it went unnoticed because the
|
|
619
|
-
* failure is invisible (a missing middleware looks exactly like an app that
|
|
620
|
-
* has none). `.ts` is tried first to match the dev supervisor's order.
|
|
621
|
-
*/
|
|
622
|
-
const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Load the optional top-level `middleware.{ts,js,mts,mjs}`.
|
|
626
|
-
* @param {string} appDir
|
|
627
|
-
* @param {boolean} dev
|
|
628
|
-
* @param {import('../logger.js').Logger} logger
|
|
629
|
-
*/
|
|
630
|
-
export async function loadMiddleware(appDir, dev, logger) {
|
|
631
|
-
let file = null;
|
|
632
|
-
for (const name of ROOT_MIDDLEWARE_FILES) {
|
|
633
|
-
const candidate = join(appDir, name);
|
|
634
|
-
if (await exists(candidate)) { file = candidate; break; }
|
|
635
|
-
}
|
|
636
|
-
if (!file) return null;
|
|
637
|
-
try {
|
|
638
|
-
const mod = await devImport(file, dev);
|
|
639
|
-
return typeof mod.default === 'function' ? mod.default : null;
|
|
640
|
-
} catch (e) {
|
|
641
|
-
logger.error('failed to load root middleware', { file, err: String(e) });
|
|
642
|
-
return null;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
647
|
/**
|
|
647
648
|
* Read a file and return a Response with appropriate caching.
|
|
648
649
|
* Dev: no-cache (always revalidate).
|
|
@@ -717,18 +718,19 @@ export async function fileResponse(abs, opts) {
|
|
|
717
718
|
*
|
|
718
719
|
* @param {string} abs
|
|
719
720
|
* @param {boolean} dev
|
|
720
|
-
* @param {{ moduleGraph: any,
|
|
721
|
+
* @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} serveOpts
|
|
721
722
|
* @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
|
|
722
723
|
* serve `immutable` (1 year) instead of the 1h fallback. Dev stays `no-cache`.
|
|
724
|
+
* @param {(error: unknown, info?: object) => void} [reportDevError] dev overlay hook
|
|
723
725
|
*/
|
|
724
|
-
export async function jsModuleResponse(abs, dev,
|
|
726
|
+
export async function jsModuleResponse(abs, dev, serveOpts, immutable, reportDevError) {
|
|
725
727
|
let source;
|
|
726
728
|
try { source = await readFile(abs, 'utf8'); }
|
|
727
729
|
catch { return new Response('Not found', { status: 404 }); }
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
);
|
|
731
|
-
code = deferLazyImports(code, abs,
|
|
730
|
+
const refused = decoratorRefusal(source, abs, dev, serveOpts && serveOpts.appDir, reportDevError);
|
|
731
|
+
if (refused) return refused;
|
|
732
|
+
let code = annotateServedModule(source, abs, serveOpts);
|
|
733
|
+
code = deferLazyImports(code, abs, serveOpts);
|
|
732
734
|
// Version same-origin relative import specifiers so the URL the browser
|
|
733
735
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
734
736
|
// A no-op in dev (fingerprinting disabled).
|
|
@@ -743,6 +745,44 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
743
745
|
return new Response(code, { status: 200, headers });
|
|
744
746
|
}
|
|
745
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Refuse an app module that carries a decorator (`@customElement`,
|
|
750
|
+
* `@property`, `@state`, `@query`, ...) with a 500 naming the decorator and
|
|
751
|
+
* its WebJs spelling, the same shape a TypeScript strip failure answers with.
|
|
752
|
+
* Neither Node's type stripper nor the browser runs decorators, so serving the
|
|
753
|
+
* module would only move the failure to a bare `SyntaxError` in the browser
|
|
754
|
+
* console. Scoped to the app's own files outside node_modules; the scan runs
|
|
755
|
+
* only when the source contains an `@` at all.
|
|
756
|
+
*
|
|
757
|
+
* @param {string} code the served source (stripped for TypeScript)
|
|
758
|
+
* @param {string} abs
|
|
759
|
+
* @param {boolean} dev
|
|
760
|
+
* @param {string | undefined} appDir
|
|
761
|
+
* @param {((error: unknown, info?: object) => void) | undefined} reportDevError
|
|
762
|
+
* @returns {Response | null}
|
|
763
|
+
*/
|
|
764
|
+
function decoratorRefusal(code, abs, dev, appDir, reportDevError) {
|
|
765
|
+
if (!code.includes('@')) return null;
|
|
766
|
+
if (appDir && !abs.startsWith(appDir + sep)) return null;
|
|
767
|
+
if (abs.includes(sep + 'node_modules' + sep)) return null;
|
|
768
|
+
const hit = findDecorator(code);
|
|
769
|
+
if (!hit) return null;
|
|
770
|
+
const { message, fix } = decoratorReport(hit);
|
|
771
|
+
const where = abs + ':' + hit.line + ':' + hit.column;
|
|
772
|
+
const err = new SyntaxError('Decorator @' + hit.name + ' in ' + where);
|
|
773
|
+
// eslint-disable-next-line no-console
|
|
774
|
+
console.error('[webjs] ' + err.message + '. ' + fix);
|
|
775
|
+
reportDevError?.(err, { kind: 'ts-strip', file: abs, line: hit.line, column: hit.column, hint: message + ' ' + fix });
|
|
776
|
+
const msg = dev
|
|
777
|
+
? '[webjs] ' + err.message + '\n\n' + message + '\n' + fix +
|
|
778
|
+
'\nThe no-non-erasable-typescript check rule reports this at edit time. Docs: https://webjs.dev/docs/troubleshooting#decorators'
|
|
779
|
+
: '[webjs] server error transforming a module response. Check server logs.';
|
|
780
|
+
return new Response('/* ' + msg.replace(/\*\//g, '*\\/') + ' */', {
|
|
781
|
+
status: 500,
|
|
782
|
+
headers: { 'content-type': 'application/javascript; charset=utf-8' },
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
746
786
|
/**
|
|
747
787
|
* Serve a side-effect import of a `static lazy` component as a lazy-loader
|
|
748
788
|
* registration (#1524). The URL the loader imports is the one the boot script
|
|
@@ -753,14 +793,19 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
753
793
|
*
|
|
754
794
|
* @param {string} code
|
|
755
795
|
* @param {string} abs
|
|
756
|
-
* @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} opts
|
|
796
|
+
* @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string, versionOf?: (abs: string) => number }} opts
|
|
757
797
|
* @returns {string}
|
|
758
798
|
*/
|
|
759
799
|
function deferLazyImports(code, abs, opts) {
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
800
|
+
if (opts.lazyComponentFiles && opts.lazyComponentFiles.size > 0) {
|
|
801
|
+
const bp = basePath();
|
|
802
|
+
const urlFor = (file) => withAssetHash(withBasePath(toUrlPath(file, opts.appDir), bp), bp);
|
|
803
|
+
code = deferLazyImportsFromSource(code, abs, opts.moduleGraph, opts.lazyComponentFiles, resolveImport, opts.appDir, urlFor);
|
|
804
|
+
}
|
|
805
|
+
// Dev browser module versions (#1689), LAST: the lazy deferral matches
|
|
806
|
+
// the specifiers as written, so it must see them unversioned.
|
|
807
|
+
if (opts.versionOf) code = versionImportSpecifiers(code, abs, opts.appDir, opts.versionOf);
|
|
808
|
+
return code;
|
|
764
809
|
}
|
|
765
810
|
|
|
766
811
|
/**
|
|
@@ -811,21 +856,21 @@ export async function stripTs(source, _abs) {
|
|
|
811
856
|
/**
|
|
812
857
|
* Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
|
|
813
858
|
* Result is cached by mtime in the handler's own `cache` so subsequent
|
|
814
|
-
* requests are instant; a file edit invalidates naturally. `
|
|
815
|
-
* additionally
|
|
816
|
-
*
|
|
859
|
+
* requests are instant; a file edit invalidates naturally. `serveOpts`
|
|
860
|
+
* additionally defers side-effect imports of `static lazy` components in the
|
|
861
|
+
* served code, which is exactly why `cache` is the per-handler
|
|
817
862
|
* `state.tsCache` and not a module-global: the cached bytes bake in this
|
|
818
|
-
* handler's
|
|
863
|
+
* handler's lazy set.
|
|
819
864
|
*
|
|
820
865
|
* @param {string} abs
|
|
821
866
|
* @param {boolean} dev
|
|
822
|
-
* @param {{ moduleGraph: any,
|
|
867
|
+
* @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} [serveOpts]
|
|
823
868
|
* @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
|
|
824
869
|
* @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
|
|
825
870
|
* serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
|
|
826
871
|
* the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
|
|
827
872
|
*/
|
|
828
|
-
export async function tsResponse(abs, dev,
|
|
873
|
+
export async function tsResponse(abs, dev, serveOpts, cache, immutable, reportDevError) {
|
|
829
874
|
// The body bytes are identical with or without `?v`; only the cache header
|
|
830
875
|
// changes, so the per-mtime cache stays a single entry.
|
|
831
876
|
const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
|
|
@@ -888,16 +933,19 @@ export async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDe
|
|
|
888
933
|
}
|
|
889
934
|
throw err;
|
|
890
935
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
936
|
+
// The stripper passes a decorator through untouched, so without this the
|
|
937
|
+
// browser receives the module and fails it with a bare SyntaxError that
|
|
938
|
+
// names neither the decorator nor the fix. Not cached, so the next request
|
|
939
|
+
// after the edit re-strips and re-checks.
|
|
940
|
+
const refused = decoratorRefusal(code, abs, dev, serveOpts && serveOpts.appDir, reportDevError);
|
|
941
|
+
if (refused) return refused;
|
|
942
|
+
if (serveOpts) {
|
|
943
|
+
code = annotateServedModule(code, abs, serveOpts);
|
|
944
|
+
code = deferLazyImports(code, abs, serveOpts);
|
|
897
945
|
}
|
|
898
946
|
// Version same-origin relative import specifiers so the URL the browser
|
|
899
947
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
900
|
-
// A no-op in dev (fingerprinting disabled). Cached with the
|
|
948
|
+
// A no-op in dev (fingerprinting disabled). Cached with the lazy rewrite:
|
|
901
949
|
// PROD files are static within a deploy, so the baked `?v` stays correct.
|
|
902
950
|
code = versionModuleImports(code, abs);
|
|
903
951
|
// Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
|