@webjsdev/server 0.8.91 → 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 +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -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 +112 -15
- 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/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- 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 +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -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/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -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 +191 -133
- package/src/dev/server.js +109 -37
- 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 +322 -78
- 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-styles.js +64 -6
- 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 +150 -69
- 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 +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -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 +3 -8
- package/src/component-elision.js +0 -1852
- 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,54 +157,46 @@ 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) {
|
|
204
|
-
const { state, appDir, coreDir, dev, reportError, reportDevError, hasOnError, logger, cspEnabled, allowedOrigins } = ctx;
|
|
199
|
+
const { state, appDir, coreDir, dev, reportError, reportDevError, recordDevStatus, hasOnError, logger, cspEnabled, allowedOrigins } = ctx;
|
|
205
200
|
const url = new URL(req.url);
|
|
206
201
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
207
202
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -240,15 +235,9 @@ export async function handleCore(req, ctx) {
|
|
|
240
235
|
// Pass the onError sink (issue #239): a server action that throws
|
|
241
236
|
// unexpectedly is reported to the APM hook before the sanitized 500.
|
|
242
237
|
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
243
|
-
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins);
|
|
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)
|
|
@@ -387,7 +393,19 @@ export async function handleCore(req, ctx) {
|
|
|
387
393
|
// API route (route.js handler)
|
|
388
394
|
const api = matchApi(state.routeTable, path);
|
|
389
395
|
if (api) {
|
|
390
|
-
|
|
396
|
+
// A handler that throws is recorded in the dev status as a `route` error
|
|
397
|
+
// HERE, before a root middleware wrapping `next()` reports the same error
|
|
398
|
+
// as its own (#1664). The rethrow keeps the response path unchanged.
|
|
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;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
391
409
|
return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
|
|
392
410
|
}
|
|
393
411
|
|
|
@@ -419,10 +437,8 @@ export async function handleCore(req, ctx) {
|
|
|
419
437
|
const ssrOpts = {
|
|
420
438
|
dev, appDir, moduleGraph: state.moduleGraph,
|
|
421
439
|
serverFiles: state.actionIndex.fileToHash,
|
|
422
|
-
elidableComponents: state.elidableComponents,
|
|
423
440
|
lazyComponentFiles: state.lazyComponentFiles,
|
|
424
|
-
|
|
425
|
-
importOnlyRouteModules: state.importOnlyRouteModules,
|
|
441
|
+
routeFrontiers: state.routeFrontiers,
|
|
426
442
|
notFoundFile: state.routeTable.notFound,
|
|
427
443
|
// Root-only boundaries (#848): the app-wide catch-all error page and the
|
|
428
444
|
// unmatched-anywhere 404, rendered by ssr.js when a nested boundary is
|
|
@@ -439,7 +455,7 @@ export async function handleCore(req, ctx) {
|
|
|
439
455
|
cspEnabled,
|
|
440
456
|
// onError sink (issue #239): a page render error that becomes a 500 is
|
|
441
457
|
// reported to the APM hook with the active request's correlation id.
|
|
442
|
-
onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
|
|
458
|
+
onError: reportError ? (e) => reportError(e, req, 'ssr', routePathOf(appDir, page.route.file)) : undefined,
|
|
443
459
|
// Dev error overlay (#264): a render crash pushes a frame to the open
|
|
444
460
|
// tab. Dev-only (reportDevError early-returns in prod), so no source
|
|
445
461
|
// leaks. Distinct from onError (the APM sink), which always fires.
|
|
@@ -456,10 +472,11 @@ export async function handleCore(req, ctx) {
|
|
|
456
472
|
// to turn any page into an error.
|
|
457
473
|
reportFormSubmittedAsGet(
|
|
458
474
|
url, req,
|
|
459
|
-
// Gated on
|
|
460
|
-
//
|
|
461
|
-
// dedupe slot
|
|
462
|
-
|
|
475
|
+
// Gated on a sink OR dev, not on `reportError` alone, which is
|
|
476
|
+
// always a function: with neither, nobody receives the report and it
|
|
477
|
+
// would only spend a dedupe slot. In dev the dev status receives it
|
|
478
|
+
// (#1664), the same as the POST variant below.
|
|
479
|
+
hasOnError || dev ? (e) => reportError(e, req, 'action') : undefined,
|
|
463
480
|
logger, dev, page.route,
|
|
464
481
|
);
|
|
465
482
|
// A successful render of URL U supersedes a RETAINED render error for
|
|
@@ -470,14 +487,27 @@ export async function handleCore(req, ctx) {
|
|
|
470
487
|
// still current for the page the user is looking at (the #893 gap the
|
|
471
488
|
// retention exists to close). Dev-only, and skipped for a prefetch,
|
|
472
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).
|
|
473
496
|
const handler = dev && !isPrefetch
|
|
474
497
|
? async () => {
|
|
475
498
|
const before = state.lastDevError;
|
|
476
|
-
|
|
499
|
+
let resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
477
500
|
if (
|
|
478
501
|
before && state.lastDevError === before
|
|
479
502
|
&& before.kind === 'render' && before.url === devErrorUrl
|
|
480
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
|
+
}
|
|
481
511
|
return resp;
|
|
482
512
|
}
|
|
483
513
|
: () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
@@ -490,7 +520,9 @@ export async function handleCore(req, ctx) {
|
|
|
490
520
|
const deps = {
|
|
491
521
|
actionIndex: state.actionIndex,
|
|
492
522
|
allowedOrigins,
|
|
493
|
-
|
|
523
|
+
// Also passed in dev with no sink, so a failing form action reaches the
|
|
524
|
+
// dev status error list (#1664); `reportError` records it first.
|
|
525
|
+
onError: hasOnError || dev ? (e) => reportError(e, req, 'action') : undefined,
|
|
494
526
|
logger,
|
|
495
527
|
};
|
|
496
528
|
const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);
|
|
@@ -559,6 +591,17 @@ export async function handleCore(req, ctx) {
|
|
|
559
591
|
return resp;
|
|
560
592
|
}
|
|
561
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
|
+
|
|
562
605
|
/** @param {Request} req @param {string} path */
|
|
563
606
|
export function wantsJson(req, path) {
|
|
564
607
|
const accept = req.headers.get('accept') || '';
|
|
@@ -581,10 +624,15 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
|
|
|
581
624
|
const handlers = [];
|
|
582
625
|
for (const f of files) {
|
|
583
626
|
try {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
//
|
|
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
|
+
});
|
|
588
636
|
}
|
|
589
637
|
}
|
|
590
638
|
let i = 0;
|
|
@@ -596,43 +644,6 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
|
|
|
596
644
|
return next();
|
|
597
645
|
}
|
|
598
646
|
|
|
599
|
-
/**
|
|
600
|
-
* Root-middleware filename candidates, in resolution order.
|
|
601
|
-
*
|
|
602
|
-
* Every other routing convention accepts all four extensions (the router
|
|
603
|
-
* matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
|
|
604
|
-
* and both the scaffold and the dev supervisor write / watch `middleware.ts`.
|
|
605
|
-
* This lookup used to be the single literal `middleware.js`, so a root
|
|
606
|
-
* `middleware.ts` was silently never loaded: no error, no warning, the app
|
|
607
|
-
* just ran with no global middleware. TypeScript is the documented default
|
|
608
|
-
* for an app, so that was the common case, and it went unnoticed because the
|
|
609
|
-
* failure is invisible (a missing middleware looks exactly like an app that
|
|
610
|
-
* has none). `.ts` is tried first to match the dev supervisor's order.
|
|
611
|
-
*/
|
|
612
|
-
const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* Load the optional top-level `middleware.{ts,js,mts,mjs}`.
|
|
616
|
-
* @param {string} appDir
|
|
617
|
-
* @param {boolean} dev
|
|
618
|
-
* @param {import('../logger.js').Logger} logger
|
|
619
|
-
*/
|
|
620
|
-
export async function loadMiddleware(appDir, dev, logger) {
|
|
621
|
-
let file = null;
|
|
622
|
-
for (const name of ROOT_MIDDLEWARE_FILES) {
|
|
623
|
-
const candidate = join(appDir, name);
|
|
624
|
-
if (await exists(candidate)) { file = candidate; break; }
|
|
625
|
-
}
|
|
626
|
-
if (!file) return null;
|
|
627
|
-
try {
|
|
628
|
-
const mod = await devImport(file, dev);
|
|
629
|
-
return typeof mod.default === 'function' ? mod.default : null;
|
|
630
|
-
} catch (e) {
|
|
631
|
-
logger.error('failed to load root middleware', { file, err: String(e) });
|
|
632
|
-
return null;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
647
|
/**
|
|
637
648
|
* Read a file and return a Response with appropriate caching.
|
|
638
649
|
* Dev: no-cache (always revalidate).
|
|
@@ -707,18 +718,19 @@ export async function fileResponse(abs, opts) {
|
|
|
707
718
|
*
|
|
708
719
|
* @param {string} abs
|
|
709
720
|
* @param {boolean} dev
|
|
710
|
-
* @param {{ moduleGraph: any,
|
|
721
|
+
* @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} serveOpts
|
|
711
722
|
* @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
|
|
712
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
|
|
713
725
|
*/
|
|
714
|
-
export async function jsModuleResponse(abs, dev,
|
|
726
|
+
export async function jsModuleResponse(abs, dev, serveOpts, immutable, reportDevError) {
|
|
715
727
|
let source;
|
|
716
728
|
try { source = await readFile(abs, 'utf8'); }
|
|
717
729
|
catch { return new Response('Not found', { status: 404 }); }
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
);
|
|
721
|
-
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);
|
|
722
734
|
// Version same-origin relative import specifiers so the URL the browser
|
|
723
735
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
724
736
|
// A no-op in dev (fingerprinting disabled).
|
|
@@ -733,6 +745,44 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
733
745
|
return new Response(code, { status: 200, headers });
|
|
734
746
|
}
|
|
735
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
|
+
|
|
736
786
|
/**
|
|
737
787
|
* Serve a side-effect import of a `static lazy` component as a lazy-loader
|
|
738
788
|
* registration (#1524). The URL the loader imports is the one the boot script
|
|
@@ -743,14 +793,19 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
743
793
|
*
|
|
744
794
|
* @param {string} code
|
|
745
795
|
* @param {string} abs
|
|
746
|
-
* @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
|
|
747
797
|
* @returns {string}
|
|
748
798
|
*/
|
|
749
799
|
function deferLazyImports(code, abs, opts) {
|
|
750
|
-
if (
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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;
|
|
754
809
|
}
|
|
755
810
|
|
|
756
811
|
/**
|
|
@@ -801,21 +856,21 @@ export async function stripTs(source, _abs) {
|
|
|
801
856
|
/**
|
|
802
857
|
* Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
|
|
803
858
|
* Result is cached by mtime in the handler's own `cache` so subsequent
|
|
804
|
-
* requests are instant; a file edit invalidates naturally. `
|
|
805
|
-
* additionally
|
|
806
|
-
*
|
|
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
|
|
807
862
|
* `state.tsCache` and not a module-global: the cached bytes bake in this
|
|
808
|
-
* handler's
|
|
863
|
+
* handler's lazy set.
|
|
809
864
|
*
|
|
810
865
|
* @param {string} abs
|
|
811
866
|
* @param {boolean} dev
|
|
812
|
-
* @param {{ moduleGraph: any,
|
|
867
|
+
* @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} [serveOpts]
|
|
813
868
|
* @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
|
|
814
869
|
* @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
|
|
815
870
|
* serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
|
|
816
871
|
* the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
|
|
817
872
|
*/
|
|
818
|
-
export async function tsResponse(abs, dev,
|
|
873
|
+
export async function tsResponse(abs, dev, serveOpts, cache, immutable, reportDevError) {
|
|
819
874
|
// The body bytes are identical with or without `?v`; only the cache header
|
|
820
875
|
// changes, so the per-mtime cache stays a single entry.
|
|
821
876
|
const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
|
|
@@ -878,16 +933,19 @@ export async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDe
|
|
|
878
933
|
}
|
|
879
934
|
throw err;
|
|
880
935
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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);
|
|
887
945
|
}
|
|
888
946
|
// Version same-origin relative import specifiers so the URL the browser
|
|
889
947
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
890
|
-
// A no-op in dev (fingerprinting disabled). Cached with the
|
|
948
|
+
// A no-op in dev (fingerprinting disabled). Cached with the lazy rewrite:
|
|
891
949
|
// PROD files are static within a deploy, so the baked `?v` stays correct.
|
|
892
950
|
code = versionModuleImports(code, abs);
|
|
893
951
|
// Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
|