@vesk/adapter 0.2.68 → 0.2.70
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/dist/client-bundle.d.ts.map +1 -1
- package/dist/client-bundle.js +68 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +7 -0
- package/dist/prod-server.d.ts.map +1 -1
- package/dist/prod-server.js +47 -24
- package/dist/server-only-plugin.d.ts +77 -0
- package/dist/server-only-plugin.d.ts.map +1 -0
- package/dist/server-only-plugin.js +233 -0
- package/dist/server-only.d.ts +99 -0
- package/dist/server-only.d.ts.map +1 -0
- package/dist/server-only.js +283 -0
- package/dist/ssr-function.d.ts +20 -0
- package/dist/ssr-function.d.ts.map +1 -1
- package/dist/ssr-function.js +99 -20
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client-bundle.d.ts","sourceRoot":"","sources":["../src/client-bundle.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"client-bundle.d.ts","sourceRoot":"","sources":["../src/client-bundle.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,SAAS,EAAE,mBAAmB,EAAE,kBAAkB,EAAmF,MAAM,yBAAyB,CAAC;AAuEnL;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BhE;AAED;;;;;GAKG;AACH,UAAU,YAAY;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAC7B,EAAE,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvB,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,IAAI,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE;gBAAE,IAAI,CAAC,EAAE,MAAM,CAAC;gBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,CAAA;KAAE,CAAC,CAAC;IACtH,WAAW,CAAC,EAAE;QACZ,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,MAAM,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAC1C,YAAY,CAAC,EAAE,KAAK,CAAC;YAAE,IAAI,CAAC,EAAE;gBAAE,IAAI,CAAC,EAAE,MAAM,CAAC;gBAAC,MAAM,CAAC,EAAE;oBAAE,IAAI,CAAC,EAAE,MAAM,CAAC;oBAAC,IAAI,CAAC,EAAE,MAAM,CAAA;iBAAE,CAAA;aAAE,CAAA;SAAE,CAAC,CAAC;KAC/F,CAAC;IACF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAmDD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAKzD;AAkCD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA+B7D;AAiCD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAyBlD;AAuQD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA+B5D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CASxD;AA4CD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,OAAO,GAAG,MAAM,CAoBnG;AAED,yEAAyE;AACzE,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,MAAM,CAKtE;AAED,wBAAsB,oBAAoB,CACxC,SAAS,EAAE,SAAS,EAAE,EACtB,MAAM,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,kBAAkB,CAAC,CAysB7B;AAMD,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CA+B3D;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAUlE;AAoCD;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAI/F"}
|
package/dist/client-bundle.js
CHANGED
|
@@ -12,6 +12,7 @@ import { resolveComponentName } from '@vesk/compiler/src/server-codegen';
|
|
|
12
12
|
import { collectVskImportPaths, vskImportLines } from '@vesk/compiler/src/vsk-imports';
|
|
13
13
|
import { inlineMdContentAttrs, guessProjectRoots } from '@vesk/compiler/src/md-inline';
|
|
14
14
|
import { resolveImportPath, findTsconfigPath } from '@vesk/compiler/src/module-imports';
|
|
15
|
+
import { serverOnlyPlugin } from '@vesk/adapter/src/server-only-plugin';
|
|
15
16
|
import { availableParallelism } from 'node:os';
|
|
16
17
|
/**
|
|
17
18
|
* Run `fn` over `items` with at most `limit` in flight, preserving input
|
|
@@ -256,6 +257,28 @@ export function nativeLibraryImportStubs(code) {
|
|
|
256
257
|
});
|
|
257
258
|
return lines.join('\n') + '\n\n';
|
|
258
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Report, and under `--strict` fail on, a server-only package that reached a
|
|
262
|
+
* client chunk.
|
|
263
|
+
*
|
|
264
|
+
* Not an error by default: importing `../db.ts` from a page is the normal way
|
|
265
|
+
* to write a server-rendered page, and the import is harmless as long as the
|
|
266
|
+
* binding is only touched during SSR — which is the whole reason the bundler
|
|
267
|
+
* substitutes a throwing stub instead of breaking the module. `--strict` is the
|
|
268
|
+
* gate for authors who want the build to refuse it outright, the same bargain
|
|
269
|
+
* `--strict` already makes for unresolvable chunk imports.
|
|
270
|
+
*/
|
|
271
|
+
function reportServerOnlyImports(chunkName, hits, strict) {
|
|
272
|
+
const detail = [...new Set(hits.map(h => `"${h.binding}" from "${h.specifier}"`))].join(', ');
|
|
273
|
+
if (strict) {
|
|
274
|
+
throw new Error(`[vesk] ${chunkName} imports a server-only package in client code: ${detail}. ` +
|
|
275
|
+
'Move that work into an API route (app/api/**/route.ts) or a server action. ' +
|
|
276
|
+
'Pass without --strict to allow it (the binding is replaced by a throwing stub).');
|
|
277
|
+
}
|
|
278
|
+
console.error(`[vesk] ${chunkName}: removed server-only import from client code (${detail}). ` +
|
|
279
|
+
'It is replaced by a stub that throws if the component calls it at runtime — ' +
|
|
280
|
+
'server-only work belongs in an API route or a server action.');
|
|
281
|
+
}
|
|
259
282
|
/**
|
|
260
283
|
* Demotes surviving value exports to plain declarations (`export const x`
|
|
261
284
|
* becomes `const x`) and drops bare re-export lists (`export { x }`,
|
|
@@ -1008,7 +1031,7 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
|
|
|
1008
1031
|
console.error('[vesk] chunk import merge aborted for', chunkName + ':', chunkImports.reason);
|
|
1009
1032
|
const head = chunkImports.render();
|
|
1010
1033
|
const code = (head ? head + '\n\n' : '') + chunkCode.join('\n\n');
|
|
1011
|
-
chunkEntries.push({ name: chunkName, code, node, importsTable: chunkImports, imports: chunkHasImports(head.length, !chunkImports.ok) });
|
|
1034
|
+
chunkEntries.push({ name: chunkName, code, node, importsTable: chunkImports, imports: chunkHasImports(head.length, !chunkImports.ok), });
|
|
1012
1035
|
}
|
|
1013
1036
|
}
|
|
1014
1037
|
walkSplit(node.children || [], [..._chain, node]);
|
|
@@ -1027,7 +1050,7 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
|
|
|
1027
1050
|
console.error('[vesk] chunk import merge aborted for shared.js:', sharedImports.reason);
|
|
1028
1051
|
const head = sharedImports.render();
|
|
1029
1052
|
const code = (head ? head + '\n\n' : '') + sharedCode.join('\n\n');
|
|
1030
|
-
chunkEntries.push({ name: SHARED_CHUNK_NAME, code, node: null, importsTable: sharedImports, imports: chunkHasImports(head.length, !sharedImports.ok) });
|
|
1053
|
+
chunkEntries.push({ name: SHARED_CHUNK_NAME, code, node: null, importsTable: sharedImports, imports: chunkHasImports(head.length, !sharedImports.ok), });
|
|
1031
1054
|
}
|
|
1032
1055
|
const chunkIIFE = (body) => `(()=>{\nconst __components = globalThis.__components || (globalThis.__components = {});\nconst __hydrators = globalThis.__hydrators || (globalThis.__hydrators = {});\n${body}\n})();\n`;
|
|
1033
1056
|
/**
|
|
@@ -1187,6 +1210,19 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
|
|
|
1187
1210
|
const tmpFile = join(tmpBase, 'entry.js');
|
|
1188
1211
|
const toBundle = `const __components = globalThis.__components || (globalThis.__components = {});\nconst __hydrators = globalThis.__hydrators || (globalThis.__hydrators = {});\n${entry.code}\n`;
|
|
1189
1212
|
writeFileSync(tmpFile, toBundle);
|
|
1213
|
+
// Keeps a database driver out of a browser bundle. The interception has
|
|
1214
|
+
// to be here, inside the bundler, because that is the only place the
|
|
1215
|
+
// transitive import `db.ts → slintorm → pg` is ever visible: by now the
|
|
1216
|
+
// chunk's own import list is merged, and the offender is almost never
|
|
1217
|
+
// in it.
|
|
1218
|
+
const serverOnlyHits = [];
|
|
1219
|
+
const driverHits = [];
|
|
1220
|
+
const eagerHits = [];
|
|
1221
|
+
const serverOnlyGuard = serverOnlyPlugin({
|
|
1222
|
+
onStub: hit => serverOnlyHits.push(hit),
|
|
1223
|
+
onDriver: hit => driverHits.push(hit),
|
|
1224
|
+
onEager: hit => eagerHits.push(hit),
|
|
1225
|
+
});
|
|
1190
1226
|
try {
|
|
1191
1227
|
// Pass the app tsconfig so esbuild resolves tsconfig `paths` aliases
|
|
1192
1228
|
// (`@/x`, `@app/x`) inside transitively bundled modules too.
|
|
@@ -1212,7 +1248,7 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
|
|
|
1212
1248
|
// shared-modules.js serves every route chunk. (esbuild's `alias`
|
|
1213
1249
|
// option only accepts bare package names, so this is a resolver
|
|
1214
1250
|
// rather than a map.)
|
|
1215
|
-
...(sharedRedirectPlugin ? { plugins: [sharedRedirectPlugin] } : {}),
|
|
1251
|
+
...(sharedRedirectPlugin ? { plugins: [sharedRedirectPlugin, serverOnlyGuard] } : { plugins: [serverOnlyGuard] }),
|
|
1216
1252
|
...(appTsconfig ? { tsconfig: appTsconfig } : {}),
|
|
1217
1253
|
});
|
|
1218
1254
|
finalCode = result.outputFiles[0].text;
|
|
@@ -1247,6 +1283,35 @@ export async function generateClientBundle(routeTree, appDir, componentMap, opti
|
|
|
1247
1283
|
}
|
|
1248
1284
|
catch { }
|
|
1249
1285
|
}
|
|
1286
|
+
// Checked OUTSIDE the try above, deliberately. A TCP driver cannot be
|
|
1287
|
+
// stubbed into a browser bundle — a page that looked fine and failed on
|
|
1288
|
+
// its first query would be worse than a build error — and the catch
|
|
1289
|
+
// block degrades every failure into "strip the imports and carry on",
|
|
1290
|
+
// which is exactly the wrong outcome for this one.
|
|
1291
|
+
if (eagerHits.length > 0) {
|
|
1292
|
+
// A stub would throw while the chunk is still evaluating, so the page
|
|
1293
|
+
// would die before a component existed. That is the worst place to
|
|
1294
|
+
// learn about it: refuse the build instead.
|
|
1295
|
+
const detail = eagerHits
|
|
1296
|
+
.map(h => `${h.importer} uses ${h.bindings.map(b => `"${b}"`).join(', ')} from "${h.specifier}" at module scope`)
|
|
1297
|
+
.join('; ');
|
|
1298
|
+
console.error('[vesk] server-only package used at module scope in client code', entry.name + ':', detail);
|
|
1299
|
+
throw new Error(`[vesk] ${entry.name}: ${detail}.\n` +
|
|
1300
|
+
'A module-scope statement runs the moment the browser evaluates the chunk, so a ' +
|
|
1301
|
+
'server-only package cannot be substituted there. Move that work into an API route ' +
|
|
1302
|
+
'(app/api/**/route.ts) or a server action — if the binding is only used from inside ' +
|
|
1303
|
+
'a function, this build already handles it.');
|
|
1304
|
+
}
|
|
1305
|
+
if (driverHits.length > 0) {
|
|
1306
|
+
const detail = [...new Set(driverHits.map(h => `${h.specifier} (from ${h.importer})`))].join(', ');
|
|
1307
|
+
console.error('[vesk] chunk bundle contains a TCP-only driver', entry.name + ':', detail);
|
|
1308
|
+
throw new Error(`[vesk] ${entry.name} pulls a TCP-only driver into the browser bundle: ${detail}. ` +
|
|
1309
|
+
"A browser cannot open a socket. Import the package's edge/browser-safe entry if it " +
|
|
1310
|
+
'ships one, or keep database access in an API route (app/api/**/route.ts) or a server action.');
|
|
1311
|
+
}
|
|
1312
|
+
if (serverOnlyHits.length > 0) {
|
|
1313
|
+
reportServerOnlyImports(entry.name, serverOnlyHits, options?.strict);
|
|
1314
|
+
}
|
|
1250
1315
|
}
|
|
1251
1316
|
else {
|
|
1252
1317
|
// No-import chunk: wrap the compiled source in the registry IIFE. The
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EACe,YAAY,EAAE,WAAW,EAC7B,UAAU,EAChC,MAAM,yBAAyB,CAAC;AAoDjC;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;CACjB;AACD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,iBAAiB,EAAE,CAAC;CAC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,UAAU,EAAE,EACrB,OAAO,EAAE,iBAAiB,EAAE,GAAG,IAAI,GAAG,SAAS,GAC9C,UAAU,EAAE,CAQd;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,UAAU,EAAE,EACrB,KAAK,EAAE,eAAe,GAAG,IAAI,GAAG,SAAS,GACxC,UAAU,EAAE,CAGd;AAkDD,wBAAsB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CA0XpG;AAED,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAIhE,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,EACzB,eAAe,GAChB,MAAM,2BAA2B,CAAC;AACnC,YAAY,EACV,mBAAmB,EACnB,YAAY,EACZ,kBAAkB,EAClB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,2BAA2B,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { compileMiddleware, compileMiddlewareCode } from '@vesk/adapter/src/midd
|
|
|
15
15
|
import { compileEvents } from '@vesk/adapter/src/events';
|
|
16
16
|
import { generateClientBundle } from '@vesk/adapter/src/client-bundle';
|
|
17
17
|
import { generateManifest } from '@vesk/adapter/src/manifest';
|
|
18
|
+
import { isSocketlessTarget, tcpDriverViolations, edgeDriverBuildError } from '@vesk/adapter/src/server-only';
|
|
18
19
|
import { copyStaticAssets } from '@vesk/adapter/src/static';
|
|
19
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
async function resolveCompilerApi(name) {
|
|
@@ -204,6 +205,15 @@ export async function build(appDir, options) {
|
|
|
204
205
|
if (pluginHeadExtra)
|
|
205
206
|
console.error('vesk build: baked plugin head → config.json + SSR functions');
|
|
206
207
|
console.error(`vesk build: output → ${outDir}`);
|
|
208
|
+
// Socketless-target gate, before anything is compiled or bundled. An edge
|
|
209
|
+
// runtime has no TCP socket, so a build that imports a socket driver produces
|
|
210
|
+
// a function that fails on its first query — in production, from a log. Fail
|
|
211
|
+
// here instead, naming the file, the line and the driver.
|
|
212
|
+
if (isSocketlessTarget(options?.platform, options?.target)) {
|
|
213
|
+
const driverHits = tcpDriverViolations(appDir);
|
|
214
|
+
if (driverHits.length > 0)
|
|
215
|
+
throw edgeDriverBuildError(driverHits);
|
|
216
|
+
}
|
|
207
217
|
const dirs = [
|
|
208
218
|
resolve(outDir, 'server', 'functions'),
|
|
209
219
|
resolve(outDir, 'server', 'api'),
|
package/dist/manifest.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAqE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAiCpK,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,EAAE,EACnB,SAAS,EAAE,SAAS,EAAE,EACtB,SAAS,EAAE,YAAY,EAAE,EACzB,YAAY,EAAE,cAAc,EAAE,EAC9B,iBAAiB,EAAE,OAAO,EAC1B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,SAAS,CAAC,EAAE,MAAM,EAClB,aAAa,UAAQ,GACpB,QAAQ,
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAqE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAiCpK,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,EAAE,EACnB,SAAS,EAAE,SAAS,EAAE,EACtB,SAAS,EAAE,YAAY,EAAE,EACzB,YAAY,EAAE,cAAc,EAAE,EAC9B,iBAAiB,EAAE,OAAO,EAC1B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,SAAS,CAAC,EAAE,MAAM,EAClB,aAAa,UAAQ,GACpB,QAAQ,CA8EV"}
|
package/dist/manifest.js
CHANGED
|
@@ -44,6 +44,13 @@ export function generateManifest(routes, ssrRoutes, apiRoutes, staticRoutes, mid
|
|
|
44
44
|
entry.tags = r._isrTags;
|
|
45
45
|
routeEntries.push(entry);
|
|
46
46
|
}
|
|
47
|
+
// Same precedence rule as the API entries below, and for the same reason:
|
|
48
|
+
// every consumer takes the FIRST exact-length match, so directory-scan order
|
|
49
|
+
// decides. `app/docs/[slug]/page.vsk` scanned before `app/docs/boom/page.vsk`
|
|
50
|
+
// left `/docs/boom` permanently served by the dynamic route — a static page
|
|
51
|
+
// that 404s or, worse, renders the wrong page's content. The API half of this
|
|
52
|
+
// fix landed earlier; SSR entries had the identical defect.
|
|
53
|
+
routeEntries.sort((a, b) => compareSpecificity(a.path, b.path));
|
|
47
54
|
const apiEntries = [];
|
|
48
55
|
for (const r of apiRoutes) {
|
|
49
56
|
const urlParts = r.fullPath.split('/').filter(Boolean);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prod-server.d.ts","sourceRoot":"","sources":["../src/prod-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"prod-server.d.ts","sourceRoot":"","sources":["../src/prod-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAcjG,OAAO,KAAK,EAAkB,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAuFpG,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,KAAK,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAIzE;AA0FD,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG;IAAE,WAAW,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA6mBvK"}
|
package/dist/prod-server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { resolveCssUrls, hasBuiltGlobalCss } from '@vesk/adapter/src/css';
|
|
|
12
12
|
import { loadVeskConfig } from '@vesk/adapter/src/load-config';
|
|
13
13
|
import { renderDefaultNotFoundPage, renderDefaultErrorPage } from '@vesk/adapter/src/default-pages';
|
|
14
14
|
import { installResponsePipeline } from '@vesk/adapter/src/response-pipeline';
|
|
15
|
+
import { resolveBoundaryFileForUrl } from '@vesk/adapter/src/ssr-function';
|
|
15
16
|
import { mimeFor } from '@vesk/adapter/src/platform-output';
|
|
16
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
async function readBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) {
|
|
@@ -600,17 +601,42 @@ export async function startProdServer(outDir, options) {
|
|
|
600
601
|
const pageCssUrls = resolveCssUrls({
|
|
601
602
|
enabled: hasBuiltGlobalCss(outDir),
|
|
602
603
|
});
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
604
|
+
/**
|
|
605
|
+
* Render the boundary that owns `url.pathname`.
|
|
606
|
+
*
|
|
607
|
+
* Deepest-match over the URL's directories, not a hard read of the root
|
|
608
|
+
* file: `app/admin/not-found.vsk` is the boundary for `/admin/*`, and the
|
|
609
|
+
* root one is the fallback. The generated SSR function does the same walk at
|
|
610
|
+
* build time (`resolveNotFoundFile`) and renders the boundary itself inside
|
|
611
|
+
* the layout stack; this path is what remains for an unmatched URL, for a
|
|
612
|
+
* route with no layout (which streams, so its status is already committed),
|
|
613
|
+
* and for an error thrown outside a page's own try block.
|
|
614
|
+
*/
|
|
615
|
+
const boundaryCache = new Map();
|
|
616
|
+
async function renderBoundaryFile(file, pathname, props) {
|
|
617
|
+
const boundaryPath = resolveBoundaryFileForUrl(pathname, appDir, file);
|
|
618
|
+
if (!boundaryPath)
|
|
619
|
+
return null;
|
|
620
|
+
const key = `${file}:${boundaryPath}`;
|
|
621
|
+
let html = boundaryCache.has(key) ? boundaryCache.get(key) : null;
|
|
622
|
+
if (html === null) {
|
|
623
|
+
try {
|
|
624
|
+
const runtimePath = resolve(outDir, 'server', 'runtime.js');
|
|
625
|
+
const { renderFullPage, storeDataScriptGlobal } = await import(runtimePath);
|
|
626
|
+
const src = readFileSync(boundaryPath, 'utf-8');
|
|
627
|
+
const compName = resolveComponentName(src) || (file === 'error.vsk' ? 'Error' : 'NotFound');
|
|
628
|
+
html = await renderFullPage(src, compName, props, new Map(), { hydrate: true, cssUrls: pageCssUrls, clientScriptUrl: buildConfig?.assets?.client?.url || '/_vesk/static/client.js', clientScriptIntegrity: buildConfig?.assets?.client?.integrity, security: securityConfig?.security || {}, externalDataScript: storeDataScriptGlobal, sourcePath: boundaryPath, headExtra: bakedHeadExtra });
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
612
633
|
}
|
|
613
|
-
|
|
634
|
+
return html;
|
|
635
|
+
}
|
|
636
|
+
let notFoundHtml = null;
|
|
637
|
+
{
|
|
638
|
+
const rendered = await renderBoundaryFile('not-found.vsk', url.pathname, { params: {}, url: url.pathname });
|
|
639
|
+
notFoundHtml = rendered;
|
|
614
640
|
}
|
|
615
641
|
for (const route of buildConfig.routes) {
|
|
616
642
|
if (route.type === 'ssr') {
|
|
@@ -686,20 +712,17 @@ export async function startProdServer(outDir, options) {
|
|
|
686
712
|
if (eventsMod) {
|
|
687
713
|
await executeError(err, { url: url.href, routePath: route.path, target: 'node', status: 500 }, eventsMod);
|
|
688
714
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
}
|
|
701
|
-
catch { }
|
|
702
|
-
}
|
|
715
|
+
// Same nearest-boundary walk as not-found above. This is the last resort for a
|
|
716
|
+
// throw the generated function could not absorb — a layout render
|
|
717
|
+
// failure, or the error boundary itself failing — and it must pick
|
|
718
|
+
// the boundary that owns the URL, not the root one.
|
|
719
|
+
const expose = process.env.NODE_ENV !== 'production';
|
|
720
|
+
const errorHtml = await renderBoundaryFile('error.vsk', url.pathname, {
|
|
721
|
+
error: expose ? err.message : 'Internal Server Error',
|
|
722
|
+
stack: expose ? err.stack : '',
|
|
723
|
+
statusCode: 500,
|
|
724
|
+
url: url.pathname,
|
|
725
|
+
});
|
|
703
726
|
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
704
727
|
res.end(errorHtml || renderDefaultErrorPage({ message: process.env.NODE_ENV !== 'production' ? err.message : undefined, url: url.pathname, headExtra: bakedHeadExtra }));
|
|
705
728
|
return;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface ServerOnlyHit {
|
|
2
|
+
specifier: string;
|
|
3
|
+
binding: string;
|
|
4
|
+
importer: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ServerOnlyPluginOptions {
|
|
7
|
+
/** One call per (specifier, binding) replaced by a throwing stub. */
|
|
8
|
+
onStub?: (hit: ServerOnlyHit) => void;
|
|
9
|
+
/** One call per TCP driver seen while bundling. Never stubbed. */
|
|
10
|
+
onDriver?: (hit: {
|
|
11
|
+
specifier: string;
|
|
12
|
+
importer: string;
|
|
13
|
+
}) => void;
|
|
14
|
+
/**
|
|
15
|
+
* One call per binding used at module-evaluation position, where a stub would
|
|
16
|
+
* throw while the chunk loads rather than at a call site. Reported, never
|
|
17
|
+
* stubbed — this is a build error.
|
|
18
|
+
*/
|
|
19
|
+
onEager?: (hit: {
|
|
20
|
+
specifier: string;
|
|
21
|
+
bindings: string[];
|
|
22
|
+
importer: string;
|
|
23
|
+
}) => void;
|
|
24
|
+
}
|
|
25
|
+
/** The slice of esbuild's plugin API this uses, declared so the module needs no esbuild types. */
|
|
26
|
+
export interface EsbuildPluginLike {
|
|
27
|
+
name: string;
|
|
28
|
+
setup: (build: {
|
|
29
|
+
onResolve: (opts: {
|
|
30
|
+
filter: RegExp;
|
|
31
|
+
namespace?: string;
|
|
32
|
+
}, cb: (args: {
|
|
33
|
+
path: string;
|
|
34
|
+
importer: string;
|
|
35
|
+
}) => unknown) => void;
|
|
36
|
+
onLoad: (opts: {
|
|
37
|
+
filter: RegExp;
|
|
38
|
+
namespace: string;
|
|
39
|
+
}, cb: (args: {
|
|
40
|
+
path: string;
|
|
41
|
+
}) => {
|
|
42
|
+
contents: string;
|
|
43
|
+
loader: string;
|
|
44
|
+
}) => void;
|
|
45
|
+
}) => void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The export names one file asks for from `specifier`.
|
|
49
|
+
*
|
|
50
|
+
* Read from the importer's own source rather than guessed: a stub declaring the
|
|
51
|
+
* wrong set of names is either an unresolvable-import build failure or, worse,
|
|
52
|
+
* a silently undefined binding.
|
|
53
|
+
*/
|
|
54
|
+
export declare function importedNamesFor(code: string, specifier: string): string[];
|
|
55
|
+
/** The shared throwing-proxy factory, emitted once per stubbed package. */
|
|
56
|
+
export declare function stubFactorySource(specifier: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Is `name` used at module-evaluation position — i.e. NOT inside a function?
|
|
59
|
+
*
|
|
60
|
+
* This is the difference between a stub that is fine and one that is a trap.
|
|
61
|
+
*
|
|
62
|
+
* export function allRows() { return orm.getAll(); } // never runs in the
|
|
63
|
+
* // browser → stub is fine
|
|
64
|
+
* export const orm = createORM({}); // runs the moment the
|
|
65
|
+
* // chunk evaluates
|
|
66
|
+
*
|
|
67
|
+
* The second form cannot be rescued by any amount of stubbing: the browser
|
|
68
|
+
* evaluates it while loading the chunk, so the page dies before a single
|
|
69
|
+
* component exists. A build error is the honest answer there, and it is
|
|
70
|
+
* decidable from the source alone — no types, no dataflow.
|
|
71
|
+
*
|
|
72
|
+
* Non-computed member keys (`obj.orm`) and object/class property keys are not
|
|
73
|
+
* references, for the same reason the reactivity diagnostics skip them.
|
|
74
|
+
*/
|
|
75
|
+
export declare function referencedAtTopLevel(code: string, name: string): boolean;
|
|
76
|
+
export declare function serverOnlyPlugin(options?: ServerOnlyPluginOptions): EsbuildPluginLike;
|
|
77
|
+
//# sourceMappingURL=server-only-plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-only-plugin.d.ts","sourceRoot":"","sources":["../src/server-only-plugin.ts"],"names":[],"mappings":"AA0BA,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,uBAAuB;IACtC,qEAAqE;IACrE,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,IAAI,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAClE;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACtF;AAED,kGAAkG;AAClG,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,KAAK,EAAE;QACb,SAAS,EAAE,CACT,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,SAAS,CAAC,EAAE,MAAM,CAAA;SAAE,EAC5C,EAAE,EAAE,CAAC,IAAI,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,KACtD,IAAI,CAAC;QACV,MAAM,EAAE,CACN,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAA;SAAE,EAC3C,EAAE,EAAE,CAAC,IAAI,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,KAAK;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAA;SAAE,KACjE,IAAI,CAAC;KACX,KAAK,IAAI,CAAC;CACZ;AAMD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CA+C1E;AAED,2EAA2E;AAC3E,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAmB3D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAoDxE;AAED,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,iBAAiB,CAoDzF"}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* esbuild plugin that keeps server-only packages out of a browser bundle.
|
|
3
|
+
*
|
|
4
|
+
* Why a plugin and not a filter on the chunk's own import list: by the time the
|
|
5
|
+
* bundler runs, the chunk's direct imports have already been merged, and the
|
|
6
|
+
* offender is almost never one of them. The shape that actually leaks is
|
|
7
|
+
* `page.vsk → app/db.ts → slintorm → pg`, and only the bundler's transitive
|
|
8
|
+
* resolution sees `pg` at all. Filtering the import list would have caught
|
|
9
|
+
* nothing.
|
|
10
|
+
*
|
|
11
|
+
* A resolved server-only package is replaced with a generated module whose
|
|
12
|
+
* every export is a Proxy that throws. A stub and not a deletion, because the
|
|
13
|
+
* binding is referenced by code that survives into the chunk: dropping it would
|
|
14
|
+
* produce `X is not defined` at hydration, pointing nowhere near the import that
|
|
15
|
+
* caused it. The export names come from the importing statement, read off the
|
|
16
|
+
* importer's own source, so the generated module declares exactly what the
|
|
17
|
+
* importer asked for and nothing is silently undefined.
|
|
18
|
+
*
|
|
19
|
+
* TCP drivers are NOT stubbed. A browser bundle cannot contain one under any
|
|
20
|
+
* circumstances, so a stub would ship a page that looks fine and fails at the
|
|
21
|
+
* first query; they are reported and left for esbuild to reject.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { parse } from '@vesk/compiler/src/parser';
|
|
25
|
+
import { classifySpecifier } from '@vesk/adapter/src/server-only';
|
|
26
|
+
const STUB_NAMESPACE = 'vesk-server-only';
|
|
27
|
+
let stubCounter = 0;
|
|
28
|
+
/**
|
|
29
|
+
* The export names one file asks for from `specifier`.
|
|
30
|
+
*
|
|
31
|
+
* Read from the importer's own source rather than guessed: a stub declaring the
|
|
32
|
+
* wrong set of names is either an unresolvable-import build failure or, worse,
|
|
33
|
+
* a silently undefined binding.
|
|
34
|
+
*/
|
|
35
|
+
export function importedNamesFor(code, specifier) {
|
|
36
|
+
let ast;
|
|
37
|
+
try {
|
|
38
|
+
ast = parse(code);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const names = [];
|
|
44
|
+
// The vsk parser flattens a module specifier to a bare string, while the TS
|
|
45
|
+
// parser keeps it as `{ value }`. Both shapes occur in practice.
|
|
46
|
+
const sourceOf = (node) => {
|
|
47
|
+
const src = node.source;
|
|
48
|
+
if (typeof src === 'string')
|
|
49
|
+
return src;
|
|
50
|
+
const v = src?.value;
|
|
51
|
+
return typeof v === 'string' ? v : null;
|
|
52
|
+
};
|
|
53
|
+
for (const node of ast.body ?? []) {
|
|
54
|
+
if (node?.type === 'ImportDeclaration') {
|
|
55
|
+
if (sourceOf(node) !== specifier)
|
|
56
|
+
continue;
|
|
57
|
+
for (const spec of node.specifiers ?? []) {
|
|
58
|
+
// `import * as x` has no names to declare — the namespace is made of
|
|
59
|
+
// whatever the importer reaches for, and each of those reads is what
|
|
60
|
+
// throws. Declaring guesses would be worse than declaring none.
|
|
61
|
+
if (spec.type === 'ImportNamespaceSpecifier')
|
|
62
|
+
continue;
|
|
63
|
+
const local = spec.local?.name;
|
|
64
|
+
if (typeof local === 'string' && !names.includes(local))
|
|
65
|
+
names.push(local);
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (node?.type === 'ExportAllDeclaration' || node?.type === 'ExportNamedDeclaration') {
|
|
70
|
+
if (sourceOf(node) !== specifier)
|
|
71
|
+
continue;
|
|
72
|
+
// `export * from 'pg'` names nothing at all, and `export { Client } from
|
|
73
|
+
// 'pg'` names only what this barrel happens to forward. Either way the
|
|
74
|
+
// honest stub declares what is written here.
|
|
75
|
+
for (const spec of node.specifiers ?? []) {
|
|
76
|
+
const imported = spec.imported?.name ?? spec.imported?.value ?? spec.local?.name;
|
|
77
|
+
if (typeof imported === 'string' && !names.includes(imported))
|
|
78
|
+
names.push(imported);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return names;
|
|
83
|
+
}
|
|
84
|
+
/** The shared throwing-proxy factory, emitted once per stubbed package. */
|
|
85
|
+
export function stubFactorySource(specifier) {
|
|
86
|
+
const template = '"$1" comes from "$0", which is server-only. It reached the browser bundle because a ' +
|
|
87
|
+
'component (or a module one imports) imported it — component code runs in the browser, ' +
|
|
88
|
+
'where this module cannot work. Move the server-only work into an API route ' +
|
|
89
|
+
'(app/api/**/route.ts) or a server action and call it over HTTP, or import the ' +
|
|
90
|
+
'edge/browser-safe entry if the package ships one.';
|
|
91
|
+
return [
|
|
92
|
+
'function __vesk_stub(binding) {',
|
|
93
|
+
' var message = ' + JSON.stringify(template) + ';',
|
|
94
|
+
' message = message.replace("$0", ' + JSON.stringify(specifier) + ').replace("$1", String(binding));',
|
|
95
|
+
' var thrower = function () { throw Object.assign(new Error(message), { __veskServerOnly: ' + JSON.stringify(specifier) + ' }); };',
|
|
96
|
+
' return new Proxy(function () {}, {',
|
|
97
|
+
' get: thrower,',
|
|
98
|
+
' apply: thrower,',
|
|
99
|
+
' construct: thrower',
|
|
100
|
+
' });',
|
|
101
|
+
'}',
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Is `name` used at module-evaluation position — i.e. NOT inside a function?
|
|
106
|
+
*
|
|
107
|
+
* This is the difference between a stub that is fine and one that is a trap.
|
|
108
|
+
*
|
|
109
|
+
* export function allRows() { return orm.getAll(); } // never runs in the
|
|
110
|
+
* // browser → stub is fine
|
|
111
|
+
* export const orm = createORM({}); // runs the moment the
|
|
112
|
+
* // chunk evaluates
|
|
113
|
+
*
|
|
114
|
+
* The second form cannot be rescued by any amount of stubbing: the browser
|
|
115
|
+
* evaluates it while loading the chunk, so the page dies before a single
|
|
116
|
+
* component exists. A build error is the honest answer there, and it is
|
|
117
|
+
* decidable from the source alone — no types, no dataflow.
|
|
118
|
+
*
|
|
119
|
+
* Non-computed member keys (`obj.orm`) and object/class property keys are not
|
|
120
|
+
* references, for the same reason the reactivity diagnostics skip them.
|
|
121
|
+
*/
|
|
122
|
+
export function referencedAtTopLevel(code, name) {
|
|
123
|
+
let ast;
|
|
124
|
+
try {
|
|
125
|
+
ast = parse(code);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Cannot prove it either way; assume the dangerous case so the build is
|
|
129
|
+
// never softened by an unparseable file.
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
let found = false;
|
|
133
|
+
const FUNCTION_TYPES = new Set([
|
|
134
|
+
'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression',
|
|
135
|
+
'ClassMethod', 'ObjectMethod', 'ClassDeclaration', 'ClassExpression',
|
|
136
|
+
]);
|
|
137
|
+
/** Never carries a reference: AST bookkeeping and source offsets. */
|
|
138
|
+
const SKIP_KEYS = new Set(['type', 'start', 'end', 'loc', 'range', 'raw', 'regex']);
|
|
139
|
+
/** A re-export's module specifier and specifier list name bindings, not use them. */
|
|
140
|
+
const SKIP_EXPORT_NAMED_KEYS = new Set([...SKIP_KEYS, 'source', 'specifiers', 'exportKind', 'attributes']);
|
|
141
|
+
const visit = (node, parentKey, fnDepth) => {
|
|
142
|
+
if (found || node === null || typeof node !== 'object')
|
|
143
|
+
return;
|
|
144
|
+
if (Array.isArray(node)) {
|
|
145
|
+
for (const child of node)
|
|
146
|
+
visit(child, parentKey, fnDepth);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const n = node;
|
|
150
|
+
if (n.type === 'MemberExpression' || n.type === 'Property' || n.type === 'PropertyDefinition' || n.type === 'MethodDefinition') {
|
|
151
|
+
const prop = (n.property ?? n.key);
|
|
152
|
+
// `obj.orm` and `{ orm: 1 }` name a field, not the binding. `[orm]` does.
|
|
153
|
+
if (prop && typeof prop === 'object' && prop.name === name && n.computed !== true)
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// An import NAMES the binding without USING it. Walking into the specifiers
|
|
157
|
+
// made every imported name look top-level-referenced, which would have
|
|
158
|
+
// turned every server-only import into a build error.
|
|
159
|
+
if (n.type === 'ImportDeclaration' || n.type === 'ExportAllDeclaration' || n.type === 'ImportExpression')
|
|
160
|
+
return;
|
|
161
|
+
if (n.type === 'Identifier' && n.name === name) {
|
|
162
|
+
if (parentKey === 'local' || parentKey === 'id' || parentKey === 'params' || parentKey === 'key')
|
|
163
|
+
return;
|
|
164
|
+
if (fnDepth === 0)
|
|
165
|
+
found = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// `export const orm = createORM({})` carries its USE inside the export
|
|
169
|
+
// declaration, so only the specifier list and module specifier are skipped.
|
|
170
|
+
const skip = n.type === 'ExportNamedDeclaration' ? SKIP_EXPORT_NAMED_KEYS : SKIP_KEYS;
|
|
171
|
+
const nextDepth = FUNCTION_TYPES.has(String(n.type)) ? fnDepth + 1 : fnDepth;
|
|
172
|
+
for (const [key, value] of Object.entries(n)) {
|
|
173
|
+
if (skip.has(key))
|
|
174
|
+
continue;
|
|
175
|
+
visit(value, key, nextDepth);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
visit(ast.body, undefined, 0);
|
|
179
|
+
return found;
|
|
180
|
+
}
|
|
181
|
+
export function serverOnlyPlugin(options = {}) {
|
|
182
|
+
/** Generated stub id -> module source. */
|
|
183
|
+
const stubs = new Map();
|
|
184
|
+
return {
|
|
185
|
+
name: 'vesk-server-only',
|
|
186
|
+
setup(build) {
|
|
187
|
+
build.onResolve({ filter: /.*/ }, args => {
|
|
188
|
+
const kind = classifySpecifier(args.path);
|
|
189
|
+
if (!kind)
|
|
190
|
+
return null;
|
|
191
|
+
if (kind === 'tcp-driver') {
|
|
192
|
+
options.onDriver?.({ specifier: args.path, importer: args.importer });
|
|
193
|
+
// Returning null lets esbuild resolve it normally, which in a browser
|
|
194
|
+
// bundle fails with its own "Could not resolve" error. Substituting a
|
|
195
|
+
// stub here would ship a page that looks fine and dies on first use.
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
let names = [];
|
|
199
|
+
let importerCode = '';
|
|
200
|
+
try {
|
|
201
|
+
importerCode = readFileSync(args.importer, 'utf-8');
|
|
202
|
+
names = importedNamesFor(importerCode, args.path);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* importer unreadable — emit a default-only stub rather than crash */
|
|
206
|
+
}
|
|
207
|
+
// A stub only works if the browser never reaches the binding. A
|
|
208
|
+
// module-evaluation-position use means the browser reaches it while
|
|
209
|
+
// loading the chunk, before any component exists, so no stub can help
|
|
210
|
+
// and the build has to say so rather than ship a page that dies on load.
|
|
211
|
+
const eager = names.filter(n => referencedAtTopLevel(importerCode, n));
|
|
212
|
+
if (eager.length > 0) {
|
|
213
|
+
options.onEager?.({ specifier: args.path, bindings: eager, importer: args.importer });
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const id = `vesk-stub-${stubCounter++}`;
|
|
217
|
+
stubs.set(id, [
|
|
218
|
+
stubFactorySource(args.path),
|
|
219
|
+
'export default __vesk_stub("default");',
|
|
220
|
+
...names.map(n => `export const ${n} = __vesk_stub(${JSON.stringify(n)});`),
|
|
221
|
+
].join('\n'));
|
|
222
|
+
for (const binding of names) {
|
|
223
|
+
options.onStub?.({ specifier: args.path, binding, importer: args.importer });
|
|
224
|
+
}
|
|
225
|
+
return { path: id, namespace: STUB_NAMESPACE };
|
|
226
|
+
});
|
|
227
|
+
build.onLoad({ filter: /.*/, namespace: STUB_NAMESPACE }, args => ({
|
|
228
|
+
contents: stubs.get(args.path) ?? stubFactorySource('unknown'),
|
|
229
|
+
loader: 'js',
|
|
230
|
+
}));
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What may not cross the server/client line, and what may not be deployed to a
|
|
3
|
+
* socketless runtime.
|
|
4
|
+
*
|
|
5
|
+
* Vesk splits a `.vsk` file into two graphs without being told which side a
|
|
6
|
+
* given import belongs to: a page is *also* a client component, so the module
|
|
7
|
+
* a page imports for its `load()` ends up in the browser chunk closure. When
|
|
8
|
+
* that module reaches for a database driver, the browser bundler dutifully
|
|
9
|
+
* inlines the driver's Node implementation — `node:net`, `node:tls`,
|
|
10
|
+
* `node:stream` — and the client bundle grows by hundreds of kilobytes of code
|
|
11
|
+
* that cannot run and is never called.
|
|
12
|
+
*
|
|
13
|
+
* So the separation has to be enforced by the build rather than inferred from
|
|
14
|
+
* usage. Two distinct rules, because the failure modes are different:
|
|
15
|
+
*
|
|
16
|
+
* - **Server-only packages** (`slintorm`, `@vesk/adapter`) may legitimately be
|
|
17
|
+
* imported by a page that only touches them during SSR. The import is removed
|
|
18
|
+
* from the browser chunk and every binding it declared becomes a throwing
|
|
19
|
+
* stub, so a build stays possible but a real client-side use fails loudly and
|
|
20
|
+
* names itself. `--strict` turns the warning into a build failure.
|
|
21
|
+
* - **TCP-only drivers** (`pg`, `mysql2`, `mongodb`, `node:sqlite`) cannot run
|
|
22
|
+
* anywhere a socket cannot be opened — not in a browser, and not on any edge
|
|
23
|
+
* runtime. In an edge-targeted build that is not a degradation, it is a
|
|
24
|
+
* guaranteed crash at the first query, so it is a hard build error with no
|
|
25
|
+
* opt-out: the alternative is discovering it in a deployment log.
|
|
26
|
+
*
|
|
27
|
+
* AST-only throughout. No text scanning — see the module rules.
|
|
28
|
+
*/
|
|
29
|
+
export type ServerOnlyKind = 'server-only-package' | 'tcp-driver';
|
|
30
|
+
/**
|
|
31
|
+
* Classify a bare import specifier. Returns null for anything that is fine on
|
|
32
|
+
* the client — including a subpath of a listed package that is itself a
|
|
33
|
+
* browser entry.
|
|
34
|
+
*/
|
|
35
|
+
export declare function classifySpecifier(spec: string): ServerOnlyKind | null;
|
|
36
|
+
export declare function isServerOnlySpecifier(spec: string): boolean;
|
|
37
|
+
export declare function isTcpDriverSpecifier(spec: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Throwing stand-ins for the bindings a server-only import declared.
|
|
40
|
+
*
|
|
41
|
+
* A stub, not a removal: the binding is referenced by code that survives into
|
|
42
|
+
* the chunk, so dropping it would produce `X is not defined` at hydration — a
|
|
43
|
+
* ReferenceError pointing nowhere near the import that caused it. A Proxy that
|
|
44
|
+
* throws on any use keeps `typeof` truthful (so a feature check does not blow
|
|
45
|
+
* up during module evaluation) while failing at the exact call site with a
|
|
46
|
+
* message that names the package, the binding and the fix.
|
|
47
|
+
*/
|
|
48
|
+
export declare function serverOnlyStubs(notes: Array<{
|
|
49
|
+
specifier: string;
|
|
50
|
+
binding: string;
|
|
51
|
+
}>): string;
|
|
52
|
+
/**
|
|
53
|
+
* Same stubs, discovered by scanning source rather than from a recorded list —
|
|
54
|
+
* used by the edge gate, which sees `.vsk` sources before anything is compiled.
|
|
55
|
+
*/
|
|
56
|
+
export declare function serverOnlyImportStubs(code: string, classify?: (spec: string) => ServerOnlyKind | null): string;
|
|
57
|
+
export interface ServerOnlyViolation {
|
|
58
|
+
kind: ServerOnlyKind;
|
|
59
|
+
specifier: string;
|
|
60
|
+
/** Absolute path of the file the import was written in. */
|
|
61
|
+
file: string;
|
|
62
|
+
/** 1-based line number when it could be determined. */
|
|
63
|
+
line: number | null;
|
|
64
|
+
/** What to do about it. */
|
|
65
|
+
remedy: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Every server-only / TCP-only import written across a set of files.
|
|
69
|
+
*
|
|
70
|
+
* Walks the whole set rather than a module graph, because the file that names
|
|
71
|
+
* the driver is rarely the file that matters: `app/db.ts` importing `slintorm`
|
|
72
|
+
* is the normal shape, and it is the *component* that transitively pulls it in.
|
|
73
|
+
* Callers pair this with their own reachability information.
|
|
74
|
+
*/
|
|
75
|
+
export declare function scanServerOnlyImports(files: Array<{
|
|
76
|
+
path: string;
|
|
77
|
+
code: string;
|
|
78
|
+
}>): ServerOnlyViolation[];
|
|
79
|
+
/**
|
|
80
|
+
* The build-time error for an edge-targeted build that imports a TCP driver.
|
|
81
|
+
*
|
|
82
|
+
* No opt-out by design: there is no configuration of an edge target under which
|
|
83
|
+
* a TCP driver works, so the only question is whether the author finds out here
|
|
84
|
+
* or from a production log.
|
|
85
|
+
*/
|
|
86
|
+
export declare function edgeDriverBuildError(violations: ServerOnlyViolation[]): Error;
|
|
87
|
+
export declare function isSocketlessTarget(platform: string | null | undefined, target: string | null | undefined): boolean;
|
|
88
|
+
/** Every app source file under `dir`, recursively. */
|
|
89
|
+
export declare function collectAppSources(dir: string, out?: string[]): string[];
|
|
90
|
+
/**
|
|
91
|
+
* TCP-driver imports anywhere under the app directory.
|
|
92
|
+
*
|
|
93
|
+
* Scans the whole tree rather than the client chunk closure: the driver is named
|
|
94
|
+
* in `app/db.ts`, which is correct, and what is wrong is that anything under it
|
|
95
|
+
* is reachable from a socketless runtime. Either way the build has to stop, and
|
|
96
|
+
* it has to stop before minutes of bundling have been spent.
|
|
97
|
+
*/
|
|
98
|
+
export declare function tcpDriverViolations(appDir: string): ServerOnlyViolation[];
|
|
99
|
+
//# sourceMappingURL=server-only.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-only.d.ts","sourceRoot":"","sources":["../src/server-only.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAsCH,MAAM,MAAM,cAAc,GAAG,qBAAqB,GAAG,YAAY,CAAC;AAElE;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAMrE;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,KAAK,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,MAAM,CAkB5F;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,cAAc,GAAG,IAAwB,GAAG,MAAM,CAoBjI;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,cAAc,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,2BAA2B;IAC3B,MAAM,EAAE,MAAM,CAAC;CAChB;AAWD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,GAC3C,mBAAmB,EAAE,CA8BvB;AAYD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,mBAAmB,EAAE,GAAG,KAAK,CAU7E;AAYD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAGlH;AAMD,sDAAsD;AACtD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CAwB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAUzE"}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What may not cross the server/client line, and what may not be deployed to a
|
|
3
|
+
* socketless runtime.
|
|
4
|
+
*
|
|
5
|
+
* Vesk splits a `.vsk` file into two graphs without being told which side a
|
|
6
|
+
* given import belongs to: a page is *also* a client component, so the module
|
|
7
|
+
* a page imports for its `load()` ends up in the browser chunk closure. When
|
|
8
|
+
* that module reaches for a database driver, the browser bundler dutifully
|
|
9
|
+
* inlines the driver's Node implementation — `node:net`, `node:tls`,
|
|
10
|
+
* `node:stream` — and the client bundle grows by hundreds of kilobytes of code
|
|
11
|
+
* that cannot run and is never called.
|
|
12
|
+
*
|
|
13
|
+
* So the separation has to be enforced by the build rather than inferred from
|
|
14
|
+
* usage. Two distinct rules, because the failure modes are different:
|
|
15
|
+
*
|
|
16
|
+
* - **Server-only packages** (`slintorm`, `@vesk/adapter`) may legitimately be
|
|
17
|
+
* imported by a page that only touches them during SSR. The import is removed
|
|
18
|
+
* from the browser chunk and every binding it declared becomes a throwing
|
|
19
|
+
* stub, so a build stays possible but a real client-side use fails loudly and
|
|
20
|
+
* names itself. `--strict` turns the warning into a build failure.
|
|
21
|
+
* - **TCP-only drivers** (`pg`, `mysql2`, `mongodb`, `node:sqlite`) cannot run
|
|
22
|
+
* anywhere a socket cannot be opened — not in a browser, and not on any edge
|
|
23
|
+
* runtime. In an edge-targeted build that is not a degradation, it is a
|
|
24
|
+
* guaranteed crash at the first query, so it is a hard build error with no
|
|
25
|
+
* opt-out: the alternative is discovering it in a deployment log.
|
|
26
|
+
*
|
|
27
|
+
* AST-only throughout. No text scanning — see the module rules.
|
|
28
|
+
*/
|
|
29
|
+
import { parse } from '@vesk/compiler/src/parser';
|
|
30
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
/**
|
|
33
|
+
* Packages that are server-side by construction.
|
|
34
|
+
*
|
|
35
|
+
* `slintorm` is here for a specific reason, and it is not a slur on the ORM:
|
|
36
|
+
* `slintorm` ships an edge-safe entry (`slintorm/edge` and `slintorm/browser`,
|
|
37
|
+
* which resolve to a bundle with no Node built-ins anywhere in its graph), and
|
|
38
|
+
* that entry is deliberately NOT matched. Only the bare `slintorm` specifier is
|
|
39
|
+
* server-only, because only that one pulls in the driver dispatch and the
|
|
40
|
+
* `node:fs` schema generator.
|
|
41
|
+
*/
|
|
42
|
+
const SERVER_ONLY_EXACT = new Set([
|
|
43
|
+
'slintorm',
|
|
44
|
+
'@vesk/adapter',
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Drivers that need a real socket. Anything here is a hard error in an
|
|
48
|
+
* edge-targeted build.
|
|
49
|
+
*
|
|
50
|
+
* `node:sqlite` is a Node built-in that appears only as an import specifier, so
|
|
51
|
+
* it is listed here alongside the packages for uniformity; it cannot appear in
|
|
52
|
+
* a browser chunk because the bundler already fails on it.
|
|
53
|
+
*/
|
|
54
|
+
const TCP_DRIVERS = new Set([
|
|
55
|
+
'pg',
|
|
56
|
+
'mysql2',
|
|
57
|
+
'mongodb',
|
|
58
|
+
'node:sqlite',
|
|
59
|
+
'node:net',
|
|
60
|
+
'node:tls',
|
|
61
|
+
]);
|
|
62
|
+
/**
|
|
63
|
+
* Classify a bare import specifier. Returns null for anything that is fine on
|
|
64
|
+
* the client — including a subpath of a listed package that is itself a
|
|
65
|
+
* browser entry.
|
|
66
|
+
*/
|
|
67
|
+
export function classifySpecifier(spec) {
|
|
68
|
+
if (typeof spec !== 'string' || spec.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
if (TCP_DRIVERS.has(spec))
|
|
71
|
+
return 'tcp-driver';
|
|
72
|
+
if (spec.startsWith('node:'))
|
|
73
|
+
return 'tcp-driver';
|
|
74
|
+
if (SERVER_ONLY_EXACT.has(spec))
|
|
75
|
+
return 'server-only-package';
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
export function isServerOnlySpecifier(spec) {
|
|
79
|
+
return classifySpecifier(spec) === 'server-only-package';
|
|
80
|
+
}
|
|
81
|
+
export function isTcpDriverSpecifier(spec) {
|
|
82
|
+
return classifySpecifier(spec) === 'tcp-driver';
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Throwing stand-ins for the bindings a server-only import declared.
|
|
86
|
+
*
|
|
87
|
+
* A stub, not a removal: the binding is referenced by code that survives into
|
|
88
|
+
* the chunk, so dropping it would produce `X is not defined` at hydration — a
|
|
89
|
+
* ReferenceError pointing nowhere near the import that caused it. A Proxy that
|
|
90
|
+
* throws on any use keeps `typeof` truthful (so a feature check does not blow
|
|
91
|
+
* up during module evaluation) while failing at the exact call site with a
|
|
92
|
+
* message that names the package, the binding and the fix.
|
|
93
|
+
*/
|
|
94
|
+
export function serverOnlyStubs(notes) {
|
|
95
|
+
if (notes.length === 0)
|
|
96
|
+
return '';
|
|
97
|
+
const lines = notes.map(({ binding, specifier }) => {
|
|
98
|
+
const message = `"${binding}" comes from "${specifier}", which is server-only. It reached the client bundle ` +
|
|
99
|
+
'because a component (or a module one imports) imported it — component code runs in the ' +
|
|
100
|
+
'browser, where this module cannot work. Move the server-only work into an API route ' +
|
|
101
|
+
'(app/api/**/route.ts) or a server action and call it over HTTP, or import the ' +
|
|
102
|
+
'edge/browser-safe entry if the package ships one.';
|
|
103
|
+
const thrower = `throw Object.assign(new Error(${JSON.stringify(message)}), { __veskServerOnly: ${JSON.stringify(specifier)} })`;
|
|
104
|
+
return (`const ${binding} = new Proxy(function () {}, { ` +
|
|
105
|
+
`get: function () { ${thrower}; }, ` +
|
|
106
|
+
`apply: function () { ${thrower}; }, ` +
|
|
107
|
+
`construct: function () { ${thrower}; } });`);
|
|
108
|
+
});
|
|
109
|
+
return lines.join('\n') + '\n\n';
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Same stubs, discovered by scanning source rather than from a recorded list —
|
|
113
|
+
* used by the edge gate, which sees `.vsk` sources before anything is compiled.
|
|
114
|
+
*/
|
|
115
|
+
export function serverOnlyImportStubs(code, classify = classifySpecifier) {
|
|
116
|
+
let ast;
|
|
117
|
+
try {
|
|
118
|
+
ast = parse(code);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
const notes = [];
|
|
124
|
+
for (const node of ast.body ?? []) {
|
|
125
|
+
// Only ImportDeclaration here, unlike the scanner above: a re-export binds
|
|
126
|
+
// no local name, so there is nothing to replace — the scan still reports it.
|
|
127
|
+
if (node?.type !== 'ImportDeclaration')
|
|
128
|
+
continue;
|
|
129
|
+
const src = node.source?.value;
|
|
130
|
+
if (typeof src !== 'string' || classify(src) !== 'server-only-package')
|
|
131
|
+
continue;
|
|
132
|
+
for (const spec of node.specifiers ?? []) {
|
|
133
|
+
const local = spec?.local?.name;
|
|
134
|
+
if (typeof local === 'string')
|
|
135
|
+
notes.push({ specifier: src, binding: local });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return serverOnlyStubs(notes);
|
|
139
|
+
}
|
|
140
|
+
const REMEDY = {
|
|
141
|
+
'server-only-package': 'import it from an API route (app/api/**/route.ts) or a server action instead of a component, ' +
|
|
142
|
+
'or use the package\'s browser/edge entry point if it ships one.',
|
|
143
|
+
'tcp-driver': 'this driver opens a TCP socket, which no edge runtime provides. Use the driver\'s HTTP/HTTPS ' +
|
|
144
|
+
'transport (for SlintORM: `driver: "postgrest"`), or build for a Node target.',
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Every server-only / TCP-only import written across a set of files.
|
|
148
|
+
*
|
|
149
|
+
* Walks the whole set rather than a module graph, because the file that names
|
|
150
|
+
* the driver is rarely the file that matters: `app/db.ts` importing `slintorm`
|
|
151
|
+
* is the normal shape, and it is the *component* that transitively pulls it in.
|
|
152
|
+
* Callers pair this with their own reachability information.
|
|
153
|
+
*/
|
|
154
|
+
export function scanServerOnlyImports(files) {
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
let ast;
|
|
158
|
+
try {
|
|
159
|
+
ast = parse(file.code);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
for (const node of ast.body ?? []) {
|
|
165
|
+
// Re-exports count. `export { Client } from 'pg'` is how a barrel file
|
|
166
|
+
// forwards a driver, and it is exactly the shape that slips past a scan
|
|
167
|
+
// looking only at imports.
|
|
168
|
+
if (node?.type !== 'ImportDeclaration'
|
|
169
|
+
&& node?.type !== 'ExportNamedDeclaration'
|
|
170
|
+
&& node?.type !== 'ExportAllDeclaration')
|
|
171
|
+
continue;
|
|
172
|
+
const spec = node.source?.value;
|
|
173
|
+
if (typeof spec !== 'string')
|
|
174
|
+
continue;
|
|
175
|
+
const kind = classifySpecifier(spec);
|
|
176
|
+
if (!kind)
|
|
177
|
+
continue;
|
|
178
|
+
out.push({
|
|
179
|
+
kind,
|
|
180
|
+
specifier: spec,
|
|
181
|
+
file: file.path,
|
|
182
|
+
line: lineOf(file.code, node.start),
|
|
183
|
+
remedy: REMEDY[kind],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/** 1-based line of a character offset. */
|
|
190
|
+
function lineOf(code, offset) {
|
|
191
|
+
if (typeof offset !== 'number' || offset < 0 || offset > code.length)
|
|
192
|
+
return null;
|
|
193
|
+
let line = 1;
|
|
194
|
+
for (let i = 0; i < offset; i++) {
|
|
195
|
+
if (code.charCodeAt(i) === 10)
|
|
196
|
+
line++;
|
|
197
|
+
}
|
|
198
|
+
return line;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The build-time error for an edge-targeted build that imports a TCP driver.
|
|
202
|
+
*
|
|
203
|
+
* No opt-out by design: there is no configuration of an edge target under which
|
|
204
|
+
* a TCP driver works, so the only question is whether the author finds out here
|
|
205
|
+
* or from a production log.
|
|
206
|
+
*/
|
|
207
|
+
export function edgeDriverBuildError(violations) {
|
|
208
|
+
const detail = violations
|
|
209
|
+
.map(v => ` ${v.file}${v.line ? `:${v.line}` : ''} — import "${v.specifier}"\n ${v.remedy}`)
|
|
210
|
+
.join('\n');
|
|
211
|
+
return new Error(`[vesk] edge-targeted build cannot include a TCP-only driver (${violations.length} found).\n` +
|
|
212
|
+
`${detail}\n\n` +
|
|
213
|
+
'This was a build error rather than a runtime one because an edge runtime has no socket: ' +
|
|
214
|
+
'the deployed function would fail on its first query.');
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Platforms whose deployed functions have no socket.
|
|
218
|
+
*
|
|
219
|
+
* Derived from `platform-deploy.ts`'s `nodeBuiltins: false` shells — those are
|
|
220
|
+
* exactly the ones bundled with empty-Node-builtin stubs, which is the same
|
|
221
|
+
* reason they cannot open a TCP connection. `vercel`/`netlify`/`aws` run Node
|
|
222
|
+
* functions and are deliberately absent.
|
|
223
|
+
*/
|
|
224
|
+
const SOCKETLESS_PLATFORMS = new Set(['edge', 'cloudflare', 'deno', 'coxmos']);
|
|
225
|
+
export function isSocketlessTarget(platform, target) {
|
|
226
|
+
if (target === 'edge')
|
|
227
|
+
return true;
|
|
228
|
+
return typeof platform === 'string' && SOCKETLESS_PLATFORMS.has(platform);
|
|
229
|
+
}
|
|
230
|
+
const SOURCE_EXTENSIONS = ['.vsk', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
|
|
231
|
+
const SKIPPED_DIRS = new Set(['node_modules', '.vesk', '.git', 'dist', '.next', '.output', 'coverage']);
|
|
232
|
+
/** Every app source file under `dir`, recursively. */
|
|
233
|
+
export function collectAppSources(dir, out = []) {
|
|
234
|
+
let entries;
|
|
235
|
+
try {
|
|
236
|
+
entries = readdirSync(dir);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (SKIPPED_DIRS.has(entry) || entry.startsWith('.'))
|
|
243
|
+
continue;
|
|
244
|
+
const full = join(dir, entry);
|
|
245
|
+
let st;
|
|
246
|
+
try {
|
|
247
|
+
st = statSync(full);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (st.isDirectory())
|
|
253
|
+
collectAppSources(full, out);
|
|
254
|
+
else if (st.isFile()) {
|
|
255
|
+
const dot = entry.lastIndexOf('.');
|
|
256
|
+
if (dot === -1)
|
|
257
|
+
continue;
|
|
258
|
+
if (SOURCE_EXTENSIONS.includes(entry.slice(dot).toLowerCase()))
|
|
259
|
+
out.push(full);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* TCP-driver imports anywhere under the app directory.
|
|
266
|
+
*
|
|
267
|
+
* Scans the whole tree rather than the client chunk closure: the driver is named
|
|
268
|
+
* in `app/db.ts`, which is correct, and what is wrong is that anything under it
|
|
269
|
+
* is reachable from a socketless runtime. Either way the build has to stop, and
|
|
270
|
+
* it has to stop before minutes of bundling have been spent.
|
|
271
|
+
*/
|
|
272
|
+
export function tcpDriverViolations(appDir) {
|
|
273
|
+
const files = [];
|
|
274
|
+
for (const path of collectAppSources(appDir)) {
|
|
275
|
+
try {
|
|
276
|
+
files.push({ path, code: readFileSync(path, 'utf-8') });
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* unreadable file — not a build blocker */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return scanServerOnlyImports(files).filter(v => v.kind === 'tcp-driver');
|
|
283
|
+
}
|
package/dist/ssr-function.d.ts
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import type { RouteNode, SsrFunctionOptions } from '@vesk/adapter/src/types';
|
|
2
|
+
/**
|
|
3
|
+
* The nearest `<file>` walking up from a route's own directory to the app root.
|
|
4
|
+
*
|
|
5
|
+
* This is the server-side twin of the client router's `findErrorComponent` /
|
|
6
|
+
* `findNotFoundComponent`, which walk the matched chain deepest-first. Same rule,
|
|
7
|
+
* same reason: an `error.vsk` (or `not-found.vsk`) inside `app/admin/` is the
|
|
8
|
+
* boundary for `/admin/*` and nothing else — the root file is the fallback, not
|
|
9
|
+
* the implementation.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveBoundaryFile(sourceDir: string, appDir: string, file: string): string | null;
|
|
2
12
|
export declare function resolveErrorFile(sourceDir: string, appDir: string): string | null;
|
|
13
|
+
export declare function resolveNotFoundFile(sourceDir: string, appDir: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* The nearest `<file>` for a REQUESTED URL, used by the server when no SSR
|
|
16
|
+
* function ran (an unmatched path) or when one gave up.
|
|
17
|
+
*
|
|
18
|
+
* The walk is over URL segments, so a dynamic segment cannot be mapped back to a
|
|
19
|
+
* directory — `/users/42/edit` stops at `/users`. Static paths, which is what a
|
|
20
|
+
* `not-found.vsk` is written for, resolve exactly.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveBoundaryFileForUrl(pathname: string, appDir: string, file: string): string | null;
|
|
3
23
|
/** Shared persisted-store SSR helper block emitted into every generated SSR
|
|
4
24
|
* function (prod via ssr-function.ts, dev via hmr.ts — keep the two callers
|
|
5
25
|
* in sync by calling this). Emit it AFTER the function declarations so the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssr-function.d.ts","sourceRoot":"","sources":["../src/ssr-function.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAkB,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"ssr-function.d.ts","sourceRoot":"","sources":["../src/ssr-function.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAkB,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7F;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQlG;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEjF;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEpF;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQvG;AAgED;;;2EAG2E;AAC3E,wBAAgB,qBAAqB,IAAI,MAAM,CA4C9C;AAED,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,OAAO,CAAC,EAAE,kBAAkB,GAC3B;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CA6etD"}
|
package/dist/ssr-function.js
CHANGED
|
@@ -3,13 +3,44 @@ import { resolve, relative, join } from 'node:path';
|
|
|
3
3
|
import { resolveComponentName, precompileFile } from '@vesk/compiler/src/server-codegen';
|
|
4
4
|
import { resolveCssUrls, hasBuiltGlobalCss } from '@vesk/adapter/src/css';
|
|
5
5
|
import { renderDefaultErrorFragment } from '@vesk/adapter/src/default-pages';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The nearest `<file>` walking up from a route's own directory to the app root.
|
|
8
|
+
*
|
|
9
|
+
* This is the server-side twin of the client router's `findErrorComponent` /
|
|
10
|
+
* `findNotFoundComponent`, which walk the matched chain deepest-first. Same rule,
|
|
11
|
+
* same reason: an `error.vsk` (or `not-found.vsk`) inside `app/admin/` is the
|
|
12
|
+
* boundary for `/admin/*` and nothing else — the root file is the fallback, not
|
|
13
|
+
* the implementation.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveBoundaryFile(sourceDir, appDir, file) {
|
|
9
16
|
const rel = relative(appDir, sourceDir).split('/').filter(Boolean);
|
|
10
17
|
for (let depth = rel.length; depth >= 0; depth--) {
|
|
11
18
|
const dir = depth === 0 ? appDir : join(appDir, ...rel.slice(0, depth));
|
|
12
|
-
const p = join(dir,
|
|
19
|
+
const p = join(dir, file);
|
|
20
|
+
if (existsSync(p))
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function resolveErrorFile(sourceDir, appDir) {
|
|
26
|
+
return resolveBoundaryFile(sourceDir, appDir, 'error.vsk');
|
|
27
|
+
}
|
|
28
|
+
export function resolveNotFoundFile(sourceDir, appDir) {
|
|
29
|
+
return resolveBoundaryFile(sourceDir, appDir, 'not-found.vsk');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The nearest `<file>` for a REQUESTED URL, used by the server when no SSR
|
|
33
|
+
* function ran (an unmatched path) or when one gave up.
|
|
34
|
+
*
|
|
35
|
+
* The walk is over URL segments, so a dynamic segment cannot be mapped back to a
|
|
36
|
+
* directory — `/users/42/edit` stops at `/users`. Static paths, which is what a
|
|
37
|
+
* `not-found.vsk` is written for, resolve exactly.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveBoundaryFileForUrl(pathname, appDir, file) {
|
|
40
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
41
|
+
for (let depth = segments.length; depth >= 0; depth--) {
|
|
42
|
+
const dir = join(appDir, ...segments.slice(0, depth));
|
|
43
|
+
const p = join(dir, file);
|
|
13
44
|
if (existsSync(p))
|
|
14
45
|
return p;
|
|
15
46
|
}
|
|
@@ -168,18 +199,30 @@ export function generateSsrFunction(routeNode, appDir, outDir, componentMap, opt
|
|
|
168
199
|
const errorVars = errorPath
|
|
169
200
|
? `const _errorComp = ${JSON.stringify(errorComp)};\nconst _errorCompiled = ${hydrateInvoke(precompilePlan(errorPath))};\n`
|
|
170
201
|
: 'const _errorComp = null;\nconst _errorCompiled = null;\n';
|
|
202
|
+
// Not-found used to have no server-side resolution at all: a NotFoundError was
|
|
203
|
+
// re-thrown out of the function and the prod server answered with the ROOT
|
|
204
|
+
// not-found.vsk. A nested `app/admin/not-found.vsk` therefore did nothing on a
|
|
205
|
+
// hard load, while the client router — which walks the matched chain
|
|
206
|
+
// deepest-first — honoured it on SPA navigation. Same URL, two different
|
|
207
|
+
// pages depending on how you arrived.
|
|
208
|
+
const notFoundPath = resolveNotFoundFile(routeNode.sourceDir, appDir);
|
|
209
|
+
const notFoundSrc = notFoundPath ? readFileSync(notFoundPath, 'utf-8') : null;
|
|
210
|
+
const notFoundComp = notFoundPath ? (extractCompName(notFoundSrc) || 'NotFound') : null;
|
|
211
|
+
const notFoundVars = notFoundPath
|
|
212
|
+
? `const _notFoundComp = ${JSON.stringify(notFoundComp)};\nconst _notFoundCompiled = ${hydrateInvoke(precompilePlan(notFoundPath))};\n`
|
|
213
|
+
: 'const _notFoundComp = null;\nconst _notFoundCompiled = null;\n';
|
|
171
214
|
let src = '';
|
|
172
215
|
if (layoutDecls.length > 0) {
|
|
173
216
|
src = `const _pageComp = ${JSON.stringify(pageComp)};\n`;
|
|
174
217
|
src += `const _pageCompiled = (() => { try { return hydratePrecompile(${pagePlan}); } catch { return null; } })();\n`;
|
|
175
218
|
src += `const _layoutCompList = [${layoutDecls.map((d) => d.comp).join(', ')}];\n`;
|
|
176
219
|
src += `const _layoutCompiledList = [\n${layoutDecls.map((d) => ' ' + d.compiled + ',').join('\n')}\n];\n`;
|
|
177
|
-
src += errorVars;
|
|
220
|
+
src += errorVars + notFoundVars;
|
|
178
221
|
}
|
|
179
222
|
else {
|
|
180
223
|
src = `const _comp = ${JSON.stringify(pageComp)};\n`;
|
|
181
224
|
src += `const _compiled = (() => { try { return hydratePrecompile(${pagePlan}); } catch { return null; } })();\n`;
|
|
182
|
-
src += errorVars;
|
|
225
|
+
src += errorVars + notFoundVars;
|
|
183
226
|
}
|
|
184
227
|
const urlParts = routeNode.fullPath.split('/').filter(Boolean);
|
|
185
228
|
const paramExprs = buildParamExtraction(routeNode, urlParts);
|
|
@@ -215,30 +258,66 @@ export function generateSsrFunction(routeNode, appDir, outDir, componentMap, opt
|
|
|
215
258
|
' }',
|
|
216
259
|
'}',
|
|
217
260
|
'',
|
|
261
|
+
// Returns null rather than throwing when there is no boundary, so the
|
|
262
|
+
// caller can re-throw the original NotFoundError and let the server answer
|
|
263
|
+
// — a page with no not-found.vsk anywhere must still get a 404.
|
|
264
|
+
'async function __renderNotFoundBody(props) {',
|
|
265
|
+
' if (!_notFoundComp) return null;',
|
|
266
|
+
' try {',
|
|
267
|
+
' const result = await renderPage(\'\', _notFoundComp, props, __componentRegistry, { hydrate: true, cached: _notFoundCompiled });',
|
|
268
|
+
' return result.body;',
|
|
269
|
+
' } catch {',
|
|
270
|
+
' return null;',
|
|
271
|
+
' }',
|
|
272
|
+
'}',
|
|
273
|
+
'',
|
|
274
|
+
// The layout wrap, shared by the page / error / not-found paths. Extracted
|
|
275
|
+
// because three call sites that each re-implemented it is three chances to
|
|
276
|
+
// forget the head merge.
|
|
277
|
+
'async function __wrapLayouts(params, body, head) {',
|
|
278
|
+
' let _b = body;',
|
|
279
|
+
' let _h = head || "";',
|
|
280
|
+
' for (let _i = _layoutCompList.length - 1; _i > 0; _i--) {',
|
|
281
|
+
" const _inner = await renderPage('', _layoutCompList[_i], { params, children: _b }, __componentRegistry, { hydrate: true, cached: _layoutCompiledList[_i] });",
|
|
282
|
+
' _b = _inner.body;',
|
|
283
|
+
' if (_inner.head) _h = _inner.head + _h;',
|
|
284
|
+
' }',
|
|
285
|
+
" return renderFullPage('', _layoutCompList[0], { params, children: _b }, __componentRegistry, { hydrate: true, cached: _layoutCompiledList[0]" + bakedOptions + clientScriptOption + dataScriptOption + ", pageHead: _h });",
|
|
286
|
+
'}',
|
|
287
|
+
'',
|
|
218
288
|
'async function __renderHtml(params, requestUrl) {',
|
|
219
289
|
' return withSsrStore(async () => {',
|
|
220
290
|
' resetPersistedStoreKeys();',
|
|
221
291
|
' let page;',
|
|
222
292
|
' let caughtError = null;',
|
|
293
|
+
' let notFound = false;',
|
|
223
294
|
" const __expose = process.env.NODE_ENV !== 'production';",
|
|
224
295
|
' try {',
|
|
225
296
|
' page = await renderPage(\'\', _pageComp, { params }, __componentRegistry, { hydrate: true, cached: _pageCompiled });',
|
|
226
297
|
' } catch (err) {',
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
'
|
|
233
|
-
'
|
|
234
|
-
'
|
|
235
|
-
'
|
|
236
|
-
|
|
237
|
-
'
|
|
238
|
-
'
|
|
298
|
+
" if (err && err.name === 'Redirect') throw err;",
|
|
299
|
+
" if (err && err.name === 'NotFoundError') {",
|
|
300
|
+
// The boundary is chosen per-route at build time (resolveNotFoundFile walks
|
|
301
|
+
// this route's own directory up to the app root), so a nested
|
|
302
|
+
// app/admin/not-found.vsk answers /admin/* and the root one answers the
|
|
303
|
+
// rest — matching the client router's deepest-first chain walk.
|
|
304
|
+
' const nfBody = await __renderNotFoundBody({ params, url: requestUrl || \'\' });',
|
|
305
|
+
' if (nfBody !== null) {',
|
|
306
|
+
' notFound = true;',
|
|
307
|
+
' page = { body: nfBody, head: \'\' };',
|
|
308
|
+
' } else {',
|
|
309
|
+
' throw err;',
|
|
310
|
+
' }',
|
|
311
|
+
' } else {',
|
|
312
|
+
' caughtError = err;',
|
|
313
|
+
" const message = err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err);",
|
|
314
|
+
" const stack = err && typeof err === 'object' && 'stack' in err ? String(err.stack) : '';",
|
|
315
|
+
" page = { body: await __renderErrorBody({ params, statusCode: 500, error: __expose ? message : 'Internal Server Error', stack: __expose ? stack : '', url: requestUrl || '' }), head: '' };",
|
|
316
|
+
' }',
|
|
239
317
|
' }',
|
|
240
|
-
"
|
|
241
|
-
|
|
318
|
+
" let _body = (caughtError ? '<!--vesk-ssr-error:' + (caughtError && typeof caughtError === 'object' && 'message' in caughtError ? encodeURIComponent(__expose ? String(caughtError.message) : 'Internal Server Error') : '') + '-->' : '') + page.body;",
|
|
319
|
+
' const html = await __wrapLayouts(params, _body, page.head || \'\');',
|
|
320
|
+
" return new Response(__injectStoreInit(html), { headers: { 'Content-Type': 'text/html' }, status: notFound ? 404 : (caughtError ? 500 : 200) });",
|
|
242
321
|
' });',
|
|
243
322
|
'}',
|
|
244
323
|
'',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vesk/adapter",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.70",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Vesk adapter — builds deployable .vesk/ output for Deno and Node serverless",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"sharp": "^0.35.3"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@vesk/compiler": "^0.2.
|
|
27
|
-
"@vesk/runtime": "^0.2.
|
|
28
|
-
"@vesk/types": "^0.2.
|
|
26
|
+
"@vesk/compiler": "^0.2.70",
|
|
27
|
+
"@vesk/runtime": "^0.2.70",
|
|
28
|
+
"@vesk/types": "^0.2.70"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/ws": "^8.18.1",
|