Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rc10-live-address-preview-passthrough-prebundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/vite-plugin': patch
---

Follow-ups to riding Solid 2.0.0-rc.10. The server-function dev middleware reads the id out of the runtime's third address shape, `<endpoint>/live/<id>` (a `live` loop's connection), the way it already did for `<endpoint>/data/<id>` — mirroring the runtime's `parseServerFunctionAddress`, one kind segment at most — so a live function whose module nothing has imported server-side yet is loaded on demand and the first call resolves instead of the runtime answering `404 Unknown server function`. `vite preview` passes `text/event-stream` responses and everything under the server-function mount through its compression middleware as `content-encoding: identity`, as it already did for `text/html`: the middleware compresses from a ≥1KiB first write and then holds small writes in zlib's buffer, which would leave a live event stream's events (and the streaming codec's chunks) undelivered while the connection stays open. Under `serve`, `solid-js/attribution` and `@solidjs/web/performance-tracks` join `optimizeDeps.include`, so a consumer the dependency scanner never crawls (a linked package, a `solid`-condition package vitefu excluded) importing one mid-session no longer triggers a late discovery → re-optimize → a second `@solidjs/signals` core beside the one `solid-js` was bundled with.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ A [picomatch](https://github.com/micromatch/picomatch) pattern, or array of patt
- Type: Boolean
- Default: true

This will inject `solid-js/dev` in place of `solid-js` in dev mode. Has no effect in prod.
If set to false, it won't inject it in dev.
This is useful for extra logs and debug.
Resolve Solid's development builds under `vite dev` — the `development` export condition of
`solid-js` and `@solidjs/web`, which carry the extra checks, warnings and diagnostics. Has no
effect on `vite build`. Set to `false` to serve the production builds in dev instead.

#### options.observe

Expand Down
25 changes: 25 additions & 0 deletions examples/start-ssr/test/components-warning.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,31 @@ async function resolveWith(solidOptions) {
componentsWarnings.length === 0 &&
!config.optimizeDeps.include.includes('@solidjs/web/frames'),
);
// Independent of server components: the attribution engine and the
// performance-tracks recorder are pre-bundled in the first optimizer pass
// under `serve`, so a consumer the scanner never crawls (a linked package,
// a `solid`-condition package vitefu excluded) importing one mid-session
// can't re-optimize a second @solidjs/signals core into the page.
record(
'serve: solid-js/attribution and @solidjs/web/performance-tracks pre-bundled',
config.optimizeDeps.include.includes('solid-js/attribution') &&
config.optimizeDeps.include.includes('@solidjs/web/performance-tracks'),
config.optimizeDeps.include.join(', '),
);
}

// ---- build: optimizeDeps is serve-only, the entries stay out --------------
{
const config = await resolveConfig(
{ root: exampleDir, configFile: false, plugins: [solidPlugin({ ssr: true })] },
'build',
);
record(
'build: attribution / performance-tracks not in optimizeDeps.include',
!config.optimizeDeps.include.includes('solid-js/attribution') &&
!config.optimizeDeps.include.includes('@solidjs/web/performance-tracks'),
config.optimizeDeps.include.join(', '),
);
}

// ---- full SSR start mode: `'external'` is redundant but harmless ---------
Expand Down
151 changes: 149 additions & 2 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,56 @@ async function runDevMode() {
record(mode, 'sf', 'dev middleware rejects unknown id', bogus.status === 404);
await runCsrfChecks(mode, origin, functionId);

// ---- Live address, cold: `<endpoint>/live/<id>` (@solidjs/web rc.10) --
// A `live` loop connects at the third address shape, answered as an
// event stream. The function lives in a module nothing has loaded
// server-side yet — src/frames/data.tsx is the frames page's; App.tsx
// never imports it, and the bare cold dispatch above loaded api.ts only
// — so the middleware has to read the id out of the live shape and
// import the module itself. Read as a `data/`-only shape the id parsed
// to null, the import was skipped, and the runtime 404'd the call.
const liveModule = await (await fetch(origin + '/src/frames/data.tsx')).text();
const liveId = extractFunctionId(liveModule, 'incrementCounter');
const live = liveId
? await fetch(`${origin}/_server/live/${encodeURIComponent(liveId)}`, { method: 'POST' })
: null;
// A live body would stay open on a source that keeps yielding; a plain
// value is one event and the end of the stream — bound the read so a
// regression into an open connection fails instead of hanging.
const liveBody = live
? await Promise.race([
live.text(),
new Promise((resolve) => setTimeout(() => resolve(null), 10000)),
])
: null;
record(
mode,
'sf',
'cold dispatch at the live address imports the module on demand (event stream)',
!!live &&
live.status === 200 &&
(live.headers.get('content-type') || '').startsWith('text/event-stream') &&
typeof liveBody === 'string' &&
/^data: /m.test(liveBody),
liveId
? `status ${live.status}, content-type ${JSON.stringify(live.headers.get('content-type'))}, body ${JSON.stringify(liveBody)}`
: 'could not extract function id',
);
// The runtime's address grammar, mirrored: one kind segment at most, and
// an id is exactly one segment — the middleware must not import a module
// for these either (the runtime answers 404 in every case).
for (const miss of ['/_server/live/', '/_server/live/a/b', '/_server/data/live/' + liveId]) {
const res = await fetch(origin + miss, { method: 'POST' });
await res.text();
record(
mode,
'sf',
`live address grammar: ${miss} is a miss`,
res.status === 404,
`status ${res.status}`,
);
}

const html = await runSsrChecks(mode, origin);
record(mode, 'dev', 'Vite client injected into <head>', html.includes('/@vite/client'));
// The patch script's own source references the selector; match it inside
Expand Down Expand Up @@ -3323,6 +3373,103 @@ async function runPreviewMode() {
const bogus = await fetch(origin + '/_server/bogus-0', { method: 'POST' });
record(mode, 'sf', 'preview dispatches /_server (unknown id rejected)', bogus.status === 404);

// ---- Streamed shapes pass preview's compression untouched -----------
// Preview's compression middleware sizes a response up on its first
// write and, at ≥1KiB, pipes the rest through zlib — which holds small
// writes until its buffer fills. A live event stream (@solidjs/web
// 2.0.0-rc.10, `<endpoint>/live/<id>`) stays open, so a held event is
// one never delivered; the plugin passes `text/event-stream` and
// everything under the server-function mount through as identity, like
// it already did for the streamed page. Answers big enough to trip the
// threshold, at both scripted shapes: without the exemption they come
// back gzip/br (fetch decodes transparently — the header is the tell).
// The registrations sit in the handler chunk — split out of server.js
// under `start.instrument` (this mode's env) — so scan the whole
// server build.
const serverDist = path.join(exampleDir, 'dist/server');
const serverAssetsDir = path.join(serverDist, 'assets');
const previewBundle = [
path.join(serverDist, 'server.js'),
...(existsSync(serverAssetsDir)
? readdirSync(serverAssetsDir)
.filter((f) => f.endsWith('.js'))
.map((f) => path.join(serverAssetsDir, f))
: []),
]
.map((file) => readFileSync(file, 'utf-8'))
.join('\n');
const registeredId = (name) =>
previewBundle.match(new RegExp(`registerServerReference\\w*\\("(${name}-[^"]+)"`))?.[1] ??
null;
// `greet` answers an object (via respond(), status 201): a value the
// live address frames as an event. A string result has a natural HTTP
// body and takes the ordinary road even there, so `getServerMessage`
// is the data-address probe below, not this one.
const greetId = registeredId('greet');
const messageId = registeredId('getServerMessage');
const bigName = 'x'.repeat(2000);
const bigArgs = `?args=${encodeURIComponent(JSON.stringify([bigName]))}`;
const compressible = { method: 'POST', headers: { 'accept-encoding': 'gzip, br' } };
const notCompressed = (res) =>
!/\b(gzip|br|deflate)\b/.test(res.headers.get('content-encoding') || '');
const liveRes = greetId
? await fetch(`${origin}/_server/live/${encodeURIComponent(greetId)}${bigArgs}`, compressible)
: null;
const liveText = liveRes
? await Promise.race([
liveRes.text(),
new Promise((resolve) => setTimeout(() => resolve(null), 10000)),
])
: null;
record(
mode,
'sf',
'live event stream passes preview compression as identity',
!!liveRes &&
liveRes.status === 201 &&
(liveRes.headers.get('content-type') || '').startsWith('text/event-stream') &&
notCompressed(liveRes) &&
typeof liveText === 'string' &&
/^data: /m.test(liveText) &&
liveText.includes(bigName),
greetId
? `status ${liveRes.status}, content-type ${JSON.stringify(liveRes.headers.get('content-type'))}, content-encoding ${JSON.stringify(liveRes.headers.get('content-encoding'))}, body ${liveText === null ? 'timed out' : `${liveText.length} chars`}`
: 'could not find greet in the server bundle',
);
// The data address answers a string result as a plain body — a shape
// the content type alone would not exempt; the mount rule does.
const dataRes = messageId
? await fetch(
`${origin}/_server/data/${encodeURIComponent(messageId)}${bigArgs}`,
compressible,
)
: null;
const dataText = dataRes ? await dataRes.text() : '';
record(
mode,
'sf',
'server-function endpoint answers pass preview compression as identity',
!!dataRes && dataRes.status === 200 && notCompressed(dataRes) && dataText.includes(bigName),
dataRes
? `status ${dataRes.status}, content-type ${JSON.stringify(dataRes.headers.get('content-type'))}, content-encoding ${JSON.stringify(dataRes.headers.get('content-encoding'))}`
: 'could not find getServerMessage in the server bundle',
);
// Static assets keep compressing — the exemption is scoped to the
// streamed shapes, not a preview-wide switch.
if (entryMatch) {
const compressedAsset = await fetch(origin + entryMatch[1], {
headers: { 'accept-encoding': 'gzip, br' },
});
await compressedAsset.arrayBuffer();
record(
mode,
'static',
'hashed client asset still compressed under preview',
/\b(gzip|br)\b/.test(compressedAsset.headers.get('content-encoding') || ''),
`content-encoding ${JSON.stringify(compressedAsset.headers.get('content-encoding'))}`,
);
}

// Middleware fronts preview exactly like dev and prod.
record(
mode,
Expand Down Expand Up @@ -4270,7 +4417,7 @@ async function runObserveMode() {
record(
mode,
'client',
'client components compile with their source labels (componentNames)',
'client components compile with their source labels (sourceNames)',
clientLabels.has('HmrTarget') && clientLabels.size >= 5,
`labels: ${[...clientLabels].join(', ') || 'none'}`,
);
Expand Down Expand Up @@ -4303,7 +4450,7 @@ async function runObserveMode() {
ssrLabelsExpected ? serverLabels.has('HmrTarget') : true,
ssrLabelsExpected
? `labels: ${[...serverLabels].join(', ') || 'none'}`
: `not asserted: @solidjs/compiler ${compiler.version} predates SSR componentNames`,
: `not asserted: @solidjs/compiler ${compiler.version} predates SSR sourceNames`,
);
const webObserveExpected = web.rc >= 9;
record(
Expand Down
2 changes: 1 addition & 1 deletion examples/start-ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import solidPlugin from '@solidjs/vite-plugin';
// graph loads — the APM/OpenTelemetry seam; middleware.ts reports what it
// saw at its own load in an `x-instrument` header.
// - SOLID_OBSERVE=1 (observe mode) turns on `observe`: the `observe` export
// condition everywhere and the compiler's `componentNames` for both
// condition everywhere and the compiler's `sourceNames` for both
// postures, so the built server AND client bundles carry component labels.
// - SSR_RENDER_MODE sets `start.renderMode` (render-mode mode): `module`
// wires src/render-mode.ts (the per-request policy: header / crawler UA /
Expand Down
36 changes: 32 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {
import { boundaryModules } from './boundary-modules.js';
import { solidDiagnostics } from './diagnostics/index.js';

import { serverFunctions, type ServerFunctionsOptions } from './server-functions/index.js';
import {
normalizeServerFunctionsEndpoint,
serverFunctions,
type ServerFunctionsOptions,
} from './server-functions/index.js';
import { SSR_HANDLER_ID, startServe, type StartOptions } from './ssr/index.js';
import { startEnv } from './start-env.js';
import {
Expand Down Expand Up @@ -258,9 +262,10 @@ export interface Options {
*/
exclude?: FilterPattern;
/**
* This will inject solid-js/dev in place of solid-js in dev mode. Has no
* effect in prod. If set to `false`, it won't inject it in dev. This is
* useful for extra logs and debugging.
* Resolve Solid's development builds under `vite dev` — the `development`
* export condition of `solid-js` and `@solidjs/web`, which carry the extra
* checks, warnings and diagnostics. Has no effect on `vite build`. Set to
* `false` to serve the production builds in dev instead.
*
* @default true
*/
Expand Down Expand Up @@ -1374,6 +1379,19 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
...(command === 'serve' && serverComponents
? ['@solidjs/web/frames', '@solidjs/web/server-functions']
: []),
// The attribution engine and the Chrome performance-tracks
// recorder are subpaths the scanner only sees when the app's own
// graph imports them; a consumer it never crawls (a linked
// package, a `solid`-condition package vitefu excluded from the
// scan) importing one mid-session would discover it late →
// re-optimize → a second `@solidjs/signals` core beside the one
// `solid-js` was bundled with. Pre-bundle both in the first pass
// so they share that core. Both subpaths exist for every version
// in the peer range (^2.0.0-rc.10: `solid-js/attribution` since
// rc.8, `@solidjs/web/performance-tracks` since rc.10).
...(command === 'serve'
? ['solid-js/attribution', '@solidjs/web/performance-tracks']
: []),
...solidPkgsConfig.optimizeDeps.include,
],
exclude: solidPkgsConfig.optimizeDeps.exclude,
Expand Down Expand Up @@ -2117,6 +2135,16 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
...startEnv(startOptions.env),
...startServe(startOptions, {
serverFunctions: !!options.serverFunctions,
// The mount the runtime is configured with (before `base`; the
// start plugin applies it): the preview middleware passes every
// response under it through uncompressed.
...(options.serverFunctions
? {
serverFunctionsEndpoint: normalizeServerFunctionsEndpoint(
options.serverFunctions === true ? undefined : options.serverFunctions.endpoint,
),
}
: {}),
serverComponents,
ssr: !!options.ssr,
styleFilter: filterDevStyles,
Expand Down
Loading
Loading