From de42b22f7bbe4fb66269685555c5d15ec1b0a252 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 00:34:53 -0700 Subject: [PATCH] feat: warn at build time when the server build targets a worker runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated server code (the start-mode handler and the server-function handler module) imports `@solidjs/web/storage`, the one Solid module that needs `node:async_hooks`. A build with `ssr.target: 'webworker'` (what Shopify Oxygen's Vite plugin sets) only failed at deploy with a bare `No such module "node:async_hooks"` (solidjs/solid#3597). Name the requirement and both fixes at build time instead: a compatibility_date of 2026-08-04 or later, or the nodejs_compat flag. Detected in configResolved (build + start or serverFunctions + webworker target), emitted from the ssr environment's buildStart so it lands exactly once per server build — the default builder resolves the config once per environment on top of its own pass, so a configResolved warning printed three times. Never in dev, where the ssr environment runs in Node. Co-authored-by: Claude via Cursor --- .changeset/webworker-async-hooks-warning.md | 5 + examples/start-ssr/package.json | 2 +- examples/start-ssr/test/webworker-warning.mjs | 202 ++++++++++++++++++ src/index.ts | 41 +++- 4 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 .changeset/webworker-async-hooks-warning.md create mode 100644 examples/start-ssr/test/webworker-warning.mjs diff --git a/.changeset/webworker-async-hooks-warning.md b/.changeset/webworker-async-hooks-warning.md new file mode 100644 index 0000000..e0def6a --- /dev/null +++ b/.changeset/webworker-async-hooks-warning.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Warn at build time when the server build targets a worker runtime (`ssr.target: 'webworker'`) and the plugin generates server code — SSR or client start mode, or `serverFunctions` on its own. That code imports `@solidjs/web/storage`, the one Solid module that needs `node:async_hooks` (`AsyncLocalStorage` keeps the request event live across `await`s; there is no sync-scope fallback), so on a worker without Node compat the deploy failed with a bare `No such module "node:async_hooks"` (solidjs/solid#3597, Shopify Oxygen — whose Vite plugin sets the webworker target). The warning names the requirement and both fixes: a `compatibility_date` of 2026-08-04 or later (Cloudflare enables `nodejs_compat` by default from that date, and Oxygen accepts the same date), or the `nodejs_compat` flag. It is emitted once per server build, from the `ssr` environment's `buildStart` (the default builder resolves the config once per environment, so a config-time warning would repeat), and never in dev, where the ssr environment runs in Node. No new option; the condition is the resolved `ssr.target`. diff --git a/examples/start-ssr/package.json b/examples/start-ssr/package.json index 34f4a12..8fcb0bf 100644 --- a/examples/start-ssr/package.json +++ b/examples/start-ssr/package.json @@ -6,7 +6,7 @@ "dev": "vite", "build": "vite build", "serve": "NODE_ENV=production node server.js", - "test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs" + "test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs && node test/webworker-warning.mjs" }, "devDependencies": { "jsdom": "^26.1.0", diff --git a/examples/start-ssr/test/webworker-warning.mjs b/examples/start-ssr/test/webworker-warning.mjs new file mode 100644 index 0000000..9a3e2f1 --- /dev/null +++ b/examples/start-ssr/test/webworker-warning.mjs @@ -0,0 +1,202 @@ +// Build-level test for the worker-target warning (solidjs/solid#3597). The +// generated server code (the start-mode handler and the server-function +// handler module) imports `@solidjs/web/storage`, which needs +// `node:async_hooks`. On a worker runtime without Node compat that fails at +// deploy with a bare module-not-found, so a build whose ssr environment +// targets a worker (`ssr.target: 'webworker'`, what Shopify Oxygen's Vite +// plugin sets) must warn at build time naming the requirement and both fixes +// (compatibility_date >= 2026-08-04, or the nodejs_compat flag). Asserts: +// - a full SSR start-mode `vite build` (client + ssr environments) with +// `ssr.target: 'webworker'` warns EXACTLY once — the default builder +// resolves the config once per environment on top of its own pass, so a +// configResolved-time warning would print three times; the text names +// node:async_hooks, the compatibility date, the nodejs_compat flag, and +// links the issue, +// - the same build with the default node target is silent, +// - a client start-mode build with `serverFunctions` (dist/server kept for +// the endpoint) warns once too, +// - a `serverFunctions`-only ssr build (no `start`) warns — its handler +// module carries the same storage import, +// - a transform-only `ssr: true` build (no generated server code) is silent, +// - `vite dev` with the webworker target is silent — the dev server runs +// the ssr environment in Node, so the warning is build-only. +// +// Real in-process builds into the example's dist/ (no browser); dev via a +// non-listening createServer. Requires the plugin built (pnpm build at the +// repo root). Usage: node test/webworker-warning.mjs + +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { createBuilder, createServer, createLogger } from 'vite'; +import solidPlugin from '@solidjs/vite-plugin'; + +const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const NEEDLE = 'targets a worker runtime'; + +const results = []; +function record(name, ok, detail = '') { + results.push({ name, ok, detail }); + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${detail}` : ''}`); +} + +function capturingLogger(warnings) { + const logger = createLogger('warn', { allowClearScreen: false }); + const originalWarn = logger.warn; + logger.warn = (msg, opts) => { + warnings.push(String(msg)); + originalWarn(msg, opts); + }; + return logger; +} + +function inlineConfig(solidOptions, ssr, extra = {}) { + return { + root: exampleDir, + configFile: false, + logLevel: 'warn', + ssr, + plugins: [solidPlugin(solidOptions)], + ...extra, + }; +} + +/** One plain `vite build` — every environment, like the CLI. */ +async function buildAppWith(solidOptions, ssr) { + const warnings = []; + const builder = await createBuilder( + inlineConfig(solidOptions, ssr, { + customLogger: capturingLogger(warnings), + }), + ); + await builder.buildApp(); + return warnings.filter((w) => w.includes(NEEDLE)); +} + +/** + * The ssr environment alone, with an explicit input: the shape of a host + * that owns its server entry and only uses the plugin's transforms (and, + * optionally, its server-function handler module). + */ +async function buildSsrWith(solidOptions, ssr) { + const warnings = []; + const builder = await createBuilder( + inlineConfig(solidOptions, ssr, { + customLogger: capturingLogger(warnings), + environments: { + ssr: { + build: { + outDir: 'dist/webworker-warning-ssr', + rollupOptions: { input: path.join(exampleDir, 'src/api.ts') }, + }, + }, + }, + }), + ); + await builder.build(builder.environments.ssr); + return warnings.filter((w) => w.includes(NEEDLE)); +} + +async function devWith(solidOptions, ssr) { + const warnings = []; + const server = await createServer( + inlineConfig(solidOptions, ssr, { + customLogger: capturingLogger(warnings), + server: { middlewareMode: true, hmr: false, watch: null }, + }), + ); + try { + // Environments (and their plugin containers' buildStart) initialize in + // createServer; nothing has to listen or be requested. + await Promise.all(Object.values(server.environments).map((env) => env.init?.())); + } finally { + await server.close(); + } + return warnings.filter((w) => w.includes(NEEDLE)); +} + +const startSsr = { ssr: true, start: {}, serverFunctions: true }; + +// ---- SSR start-mode build, webworker target: warns once, full text -------- +{ + const warnings = await buildAppWith(startSsr, { target: 'webworker' }); + record( + 'start-mode build with ssr.target webworker warns exactly once', + warnings.length === 1, + `${warnings.length} warnings`, + ); + const text = warnings[0] ?? ''; + record('warning names ssr.target = "webworker"', text.includes('ssr.target = "webworker"'), text); + record( + 'warning names node:async_hooks / AsyncLocalStorage', + text.includes('node:async_hooks') && text.includes('AsyncLocalStorage'), + text, + ); + record( + 'warning gives the compatibility_date route (>= 2026-08-04)', + text.includes('compatibility_date') && text.includes('2026-08-04'), + text, + ); + record('warning gives the nodejs_compat flag route', text.includes('nodejs_compat'), text); + record('warning mentions Shopify Oxygen', text.includes('Shopify Oxygen'), text); + record( + 'warning links solidjs/solid#3597', + text.includes('https://github.com/solidjs/solid/issues/3597'), + text, + ); +} + +// ---- Same build, default node target: silent ------------------------------- +{ + const warnings = await buildAppWith(startSsr, {}); + record('start-mode build with the node target does not warn', warnings.length === 0, warnings); +} + +// ---- Client start mode + serverFunctions: dist/server handler, warns once -- +{ + const warnings = await buildAppWith( + { start: {}, serverFunctions: true }, + { target: 'webworker' }, + ); + record( + 'client start mode with serverFunctions and webworker target warns once', + warnings.length === 1, + `${warnings.length} warnings`, + ); +} + +// ---- serverFunctions only (no start): the handler module imports storage -- +{ + const warnings = await buildSsrWith( + { ssr: true, serverFunctions: true }, + { target: 'webworker' }, + ); + record( + 'serverFunctions-only ssr build with webworker target warns once', + warnings.length === 1, + `${warnings.length} warnings`, + ); +} + +// ---- Transform-only ssr: true (no generated server code): silent ---------- +{ + const warnings = await buildSsrWith({ ssr: true }, { target: 'webworker' }); + record( + 'transform-only ssr build with webworker target does not warn', + warnings.length === 0, + warnings, + ); +} + +// ---- Dev with the webworker target: build-only, so silent ----------------- +{ + const warnings = await devWith(startSsr, { target: 'webworker' }); + record('vite dev with ssr.target webworker does not warn', warnings.length === 0, warnings); +} + +const failed = results.filter((r) => !r.ok); +console.log(`\n${results.length - failed.length}/${results.length} assertions passed`); +if (failed.length) { + console.log('\nFailures:'); + for (const f of failed) console.log(` ${f.name} — ${f.detail}`); + process.exit(1); +} diff --git a/src/index.ts b/src/index.ts index f4d6687..386e82e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,7 @@ export type { } from './server-functions/index.js'; export type { StartOptions }; import path from 'path'; -import type { FilterPattern, Plugin, ViteDevServer } from 'vite'; +import type { FilterPattern, Logger, Plugin, ViteDevServer } from 'vite'; import { createFilter, defaultClientConditions, @@ -947,6 +947,10 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { let isBuild = false; let isSsrBuild = false; let base = '/'; + let logger: Logger | null = null; + // Set in configResolved when a worker-targeted server build would carry + // the plugin's `@solidjs/web/storage` import; emitted once from buildStart. + let workerTargetWarning = false; let clientOutDir: string | null = null; // The client environment's resolved build options, for the configured // entry input. Read off the resolved config so the SSR half of a @@ -1433,6 +1437,24 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { "`components: 'external'` to acknowledge it and silence this warning.", ); } + // The generated server code — the start-mode handler and the + // server-function handler module — imports `@solidjs/web/storage`, + // the one Solid module that needs `node:async_hooks` + // (AsyncLocalStorage keeps the request event live across `await`s; + // there is no sync-scope fallback, solidjs/solid#3597). A worker- + // targeted server build (`ssr.target: 'webworker'`, e.g. Shopify + // Oxygen's Vite plugin) only fails at deploy with a bare + // module-not-found, so name the requirement and the fix at build + // time instead. Detected here, emitted from the ssr environment's + // buildStart so it lands once per server build: the default builder + // resolves the config once per environment on top of its own pass, + // so a configResolved warning would print three times per build. + // Build-only — dev runs the ssr environment in Node. + workerTargetWarning = + isBuild && + !!(startOptions || options.serverFunctions) && + config.ssr?.target === 'webworker'; + logger = config.logger; needHmr = config.command === 'serve' && config.mode !== 'production' && @@ -1481,6 +1503,23 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { } as typeof hot.send; }, + buildStart() { + // `ssr.target` is a property of the `ssr` environment specifically + // (Vite reads it as `environment.name === 'ssr'` too), and that is the + // one environment whose bundle carries the storage import. Each + // environment gets its own plugin instance under the default builder, + // so this runs exactly once for the server build — and not at all for + // the client build or a `vite build --ssr`-less client-only build. + if (!workerTargetWarning || this.environment?.name !== 'ssr') return; + logger!.warn( + '[@solidjs/vite-plugin] The server build targets a worker runtime (ssr.target = "webworker"). ' + + "Solid's server runtime requires Node's async context (node:async_hooks / AsyncLocalStorage). " + + 'On Cloudflare Workers and compatible runtimes (e.g. Shopify Oxygen) set a compatibility_date ' + + 'of 2026-08-04 or later (nodejs_compat is on by default from that date), or enable the ' + + 'nodejs_compat flag. See https://github.com/solidjs/solid/issues/3597', + ); + }, + async hotUpdate({ file, modules, read }) { if (isTsrxModule(file) && this.environment.name === 'client') { updateTsrxCss(tsrxCss, file, await compileTsrxCss(await read(), file));