Skip to content
Draft
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
459 changes: 252 additions & 207 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"packageManager": "bun@1.3.14",
"catalog": {
"@typescript/native-preview": "7.0.0-dev.20260509.2",
"oxc-parser": "0.137.0",
"typescript": "6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2",
"vitest": "4.1.9",
Expand Down
3 changes: 3 additions & 0 deletions packages/sideffect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"check": "vp check",
"prepublishOnly": "vp run build"
},
"dependencies": {
"oxc-parser": "catalog:"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.40.0",
"@cloudflare/workers-types": "4.20260605.1",
Expand Down
44 changes: 41 additions & 3 deletions packages/sideffect/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
writeWorkflowEnvTypes,
type CapturedWorkflow,
} from "./vite/generated-entry.ts";
import { collectWorkflowEntries, type WorkflowDiscoveryPaths } from "./vite/workflow-discovery.ts";
import {
collectWorkflowEntriesForVite,
formatParserSelection,
type WorkflowDiscoveryParserSelection,
type WorkflowDiscoveryPaths,
} from "./vite/workflow-discovery.ts";
import type { CloudflarePluginConfig, WorkerConfig } from "./vite/cloudflare-options.ts";
import type { WorkflowConfigEntry } from "./types.ts";

Expand Down Expand Up @@ -49,11 +54,17 @@ export interface Plugin {
readonly enforce?: "pre" | "post";
readonly sharedDuringBuild?: boolean;
config?(config: { readonly root?: string }): void;
configResolved?(config: { readonly root: string }): void;
configResolved?(config: { readonly root: string; readonly logger?: ViteLogger }): void;
resolveId?(source: string): string | void;
load?(this: ResolveContext, id: string): Promise<string | void> | string | void;
}

/** Minimal Vite logger shape used for parser-selection diagnostics. */
interface ViteLogger {
readonly info?: (message: string) => void;
readonly warn?: (message: string) => void;
}

/** Sideffect Vite plugin plus the Cloudflare config object it forwards. */
export interface SideffectWorkflowsPlugin extends Plugin {
/** Config object passed to `@cloudflare/vite-plugin` by `withCloudflareWorkflows()`. */
Expand Down Expand Up @@ -122,6 +133,8 @@ export function createSideffectWorkflowsPlugin(
const captured: { value?: CapturedWorkflowConfig } = {};
let configRoot: string | undefined;
let resolvedConfig: { readonly root: string } | undefined;
let logger: ViteLogger | undefined;
let pendingParserSelection: WorkflowDiscoveryParserSelection | undefined;

const plugin: SideffectWorkflowsPlugin = {
name: "sideffect:cloudflare-workflows",
Expand Down Expand Up @@ -153,7 +166,15 @@ export function createSideffectWorkflowsPlugin(
typeof plugin.cloudflare.configPath === "string"
? dirname(resolve(root, plugin.cloudflare.configPath))
: root;
const discoveredWorkflows = collectWorkflowEntries(workflowPaths, baseDirectory);
const discoveredWorkflows = collectWorkflowEntriesForVite(workflowPaths, baseDirectory, {
onParserSelected(selection) {
if (logParserSelection(logger, selection)) {
pendingParserSelection = undefined;
} else {
pendingParserSelection = selection;
}
},
});
const discoveredByClassName = new Map(
discoveredWorkflows.map((workflow) => [workflow.config.class_name, workflow]),
);
Expand Down Expand Up @@ -200,6 +221,12 @@ export function createSideffectWorkflowsPlugin(
},
configResolved(config) {
resolvedConfig = config;
logger = config.logger;
if (pendingParserSelection) {
if (logParserSelection(logger, pendingParserSelection)) {
pendingParserSelection = undefined;
}
}
},
resolveId(source) {
if (source !== virtualEntry) {
Expand Down Expand Up @@ -259,3 +286,14 @@ export function createSideffectWorkflowsPlugin(

return plugin;
}

function logParserSelection(
logger: ViteLogger | undefined,
selection: WorkflowDiscoveryParserSelection,
): boolean {
if (!logger?.info) {
return false;
}
logger.info(`[sideffect] workflow discovery parser: ${formatParserSelection(selection)}`);
return true;
}
171 changes: 171 additions & 0 deletions packages/sideffect/src/vite/oxc-parser-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { extname } from "node:path";

import { Effect } from "effect";
import type { EcmaScriptModule, Program as OxcProgram } from "oxc-parser";

import {
WorkflowParserUnavailable,
WorkflowSourceParseFailed,
WorkflowSourceReadFailed,
} from "./workflow-discovery-errors.ts";

const require = createRequire(import.meta.url);

type OxcParserModule = typeof import("oxc-parser");
type OxcParserOptions = NonNullable<Parameters<OxcParserModule["parseSync"]>[2]> & {
readonly experimentalRawTransfer?: boolean;
};

/** @internal JS runtime executing workflow discovery during Vite config/build. */
export type JsRuntime = "bun" | "deno" | "node" | "node<22" | "unknown";

/** @internal Selected Oxc parser mode for one workflow discovery run. */
export type WorkflowDiscoveryParserSelection =
| { readonly _tag: "RawTransfer"; readonly runtime: JsRuntime }
| {
readonly _tag: "Standard";
readonly reason: "RawTransferUnsupported";
readonly runtime: JsRuntime;
};

/** @internal Parsed Oxc source file before conversion to Sideffect's local AST. */
export interface ParsedOxcSourceFile {
readonly filePath: string;
readonly program: OxcProgram;
readonly module: EcmaScriptModule;
}

/** @internal Oxc parser dependency and mode selected for workflow discovery. */
export interface OxcWorkflowParser {
readonly parser: OxcParserModule;
readonly selection: WorkflowDiscoveryParserSelection;
}

let loadedOxcParser: OxcParserModule | undefined;

/** @internal Creates the Oxc parser adapter used for one workflow discovery run. */
export const makeOxcWorkflowParser = Effect.fnUntraced(function* () {
const parser = yield* loadOxcParser();
const selection = selectParserMode(parser.rawTransferSupported(), currentJsRuntimeLabel());

return { parser, selection } satisfies OxcWorkflowParser;
});

/** @internal Reads and parses one workflow source file with Oxc. */
export const parseOxcSourceFile = Effect.fnUntraced(function* (
workflowParser: OxcWorkflowParser,
filePath: string,
) {
const source = yield* Effect.try({
try: () => readFileSync(filePath, "utf8"),
catch: (cause) => new WorkflowSourceReadFailed({ filePath, cause }),
});
const parseOptions: OxcParserOptions = {
astType: "ts",
...(workflowParser.selection._tag === "RawTransfer" ? { experimentalRawTransfer: true } : {}),
lang: oxcLangForFile(filePath),
preserveParens: false,
range: false,
showSemanticErrors: false,
sourceType: "module",
};
const result = workflowParser.parser.parseSync(filePath, source, parseOptions);

if (result.errors.length > 0) {
return yield* new WorkflowSourceParseFailed({
filePath,
message: result.errors[0]?.message ?? "unknown parser error",
});
}

return { filePath, program: result.program, module: result.module } satisfies ParsedOxcSourceFile;
});

/** @internal Formats parser mode selection for Vite logs. */
export function formatParserSelection(selection: WorkflowDiscoveryParserSelection): string {
switch (selection._tag) {
case "RawTransfer":
return "oxc raw-transfer";
case "Standard":
return `oxc standard (raw transfer unsupported by JS runtime: ${selection.runtime})`;
}
}

function loadOxcParser() {
if (loadedOxcParser) {
return Effect.succeed(loadedOxcParser);
}

return Effect.try({
try: () => {
// SAFETY: The following runtime checks prove the parser surface this adapter uses.
const parser = require("oxc-parser") as Partial<OxcParserModule>;
if (typeof parser.parseSync !== "function") {
throw new Error('Resolved "oxc-parser", but it does not expose parseSync.');
}
if (typeof parser.rawTransferSupported !== "function") {
throw new Error('Resolved "oxc-parser", but it does not expose rawTransferSupported.');
}

// SAFETY: parseSync and rawTransferSupported were checked before caching the module.
loadedOxcParser = parser as OxcParserModule;
return loadedOxcParser;
},
catch: (cause) =>
new WorkflowParserUnavailable({
cause,
message: "Could not load oxc-parser for workflow discovery.",
}),
});
}

function selectParserMode(
rawTransferSupported: boolean,
runtime: JsRuntime,
): WorkflowDiscoveryParserSelection {
return rawTransferSupported
? { _tag: "RawTransfer", runtime }
: { _tag: "Standard", reason: "RawTransferUnsupported", runtime };
}

function currentJsRuntimeLabel(): JsRuntime {
// SAFETY: Runtime detection reads optional global fields and never trusts their payloads.
const global = globalThis as typeof globalThis & {
readonly Bun?: unknown;
readonly Deno?: { readonly version?: { readonly deno?: string } };
};
const processLike = global.process as
| { readonly versions?: { readonly bun?: string; readonly node?: string } }
| undefined;
if (global.Bun || processLike?.versions?.bun) {
return "bun";
}
if (global.Deno) {
return "deno";
}

const nodeVersion = processLike?.versions?.node;
const major = nodeVersion ? Number(nodeVersion.split(".")[0]) : Number.NaN;
if (Number.isInteger(major)) {
return major < 22 ? "node<22" : "node";
}

return "unknown";
}

function oxcLangForFile(path: string): "js" | "jsx" | "ts" | "tsx" {
switch (extname(path)) {
case ".tsx":
return "tsx";
case ".jsx":
return "jsx";
case ".js":
case ".mjs":
case ".cjs":
return "js";
default:
return "ts";
}
}
Loading
Loading