first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
/**
* Cloudflare Sandbox Runner - RUNTIME ENTRY
*
* This module is loaded at runtime when plugins need to be sandboxed.
* It imports cloudflare:workers and should NOT be imported at config time.
*
* For config-time usage, import { sandbox } from "@emdashcms/cloudflare" instead.
*
*/
export { CloudflareSandboxRunner, createSandboxRunner, type PluginBridgeProps } from "./runner.js";
export { PluginBridge, setEmailSendCallback, type PluginBridgeEnv } from "./bridge.js";
export { generatePluginWrapper } from "./wrapper.js";

View File

@@ -0,0 +1,357 @@
/**
* Cloudflare Sandbox Runner
*
* Uses Worker Loader to run plugins in isolated V8 isolates.
* Plugins communicate with the host via a BRIDGE service binding
* that enforces capabilities and scopes operations.
*
* This module imports directly from cloudflare:workers to access
* the LOADER binding and PluginBridge export. It's only loaded
* when the user configures `sandboxRunner: "@emdashcms/cloudflare/sandbox"`.
*
*/
import { env, exports } from "cloudflare:workers";
import type {
SandboxRunner,
SandboxedPlugin,
SandboxEmailSendCallback,
SandboxOptions,
SandboxRunnerFactory,
SerializedRequest,
PluginManifest,
} from "emdash";
import { setEmailSendCallback } from "./bridge.js";
import type { WorkerLoader, WorkerStub, PluginBridgeBinding, WorkerLoaderLimits } from "./types.js";
import { generatePluginWrapper } from "./wrapper.js";
/**
* Default resource limits for sandboxed plugins.
*
* cpuMs and subrequests are enforced by Worker Loader at the V8 isolate level.
* wallTimeMs is enforced by the runner via Promise.race.
* memoryMb is declared for API compatibility but NOT currently enforced —
* Worker Loader doesn't expose a memory limit option. V8 isolates have a
* platform-level memory ceiling (~128MB) but it's not configurable per-worker.
*/
const DEFAULT_LIMITS = {
cpuMs: 50,
memoryMb: 128,
subrequests: 10,
wallTimeMs: 30_000,
} as const;
export interface PluginBridgeProps {
pluginId: string;
pluginVersion: string;
capabilities: string[];
allowedHosts: string[];
storageCollections: string[];
}
/**
* Get the Worker Loader binding from env
*/
function getLoader(): WorkerLoader | null {
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker Loader binding accessed from untyped env object
return (env as Record<string, unknown>).LOADER as WorkerLoader | null;
}
/**
* Get the PluginBridge from exports (loopback binding)
*/
function getPluginBridge(): ((opts: { props: PluginBridgeProps }) => PluginBridgeBinding) | null {
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- PluginBridge accessed from untyped cloudflare:workers exports
return (exports as Record<string, unknown>).PluginBridge as
| ((opts: { props: PluginBridgeProps }) => PluginBridgeBinding)
| null;
}
/**
* Resolved resource limits with defaults applied.
*/
interface ResolvedLimits {
cpuMs: number;
memoryMb: number;
subrequests: number;
wallTimeMs: number;
}
/**
* Resolve resource limits by merging user-provided overrides with defaults.
*/
function resolveLimits(limits?: SandboxOptions["limits"]): ResolvedLimits {
return {
cpuMs: limits?.cpuMs ?? DEFAULT_LIMITS.cpuMs,
memoryMb: limits?.memoryMb ?? DEFAULT_LIMITS.memoryMb,
subrequests: limits?.subrequests ?? DEFAULT_LIMITS.subrequests,
wallTimeMs: limits?.wallTimeMs ?? DEFAULT_LIMITS.wallTimeMs,
};
}
/**
* Cloudflare sandbox runner using Worker Loader.
*/
export class CloudflareSandboxRunner implements SandboxRunner {
private plugins = new Map<string, CloudflareSandboxedPlugin>();
private options: SandboxOptions;
private resolvedLimits: ResolvedLimits;
private siteInfo?: { name: string; url: string; locale: string };
constructor(options: SandboxOptions) {
this.options = options;
this.resolvedLimits = resolveLimits(options.limits);
this.siteInfo = options.siteInfo;
// Wire email send callback if provided at construction time
setEmailSendCallback(options.emailSend ?? null);
}
/**
* Set the email send callback for sandboxed plugins.
* Called after the EmailPipeline is created, since the pipeline
* doesn't exist when the sandbox runner is constructed.
*/
setEmailSend(callback: SandboxEmailSendCallback | null): void {
setEmailSendCallback(callback);
}
/**
* Check if Worker Loader is available.
*/
isAvailable(): boolean {
return !!getLoader() && !!getPluginBridge();
}
/**
* Load a sandboxed plugin.
*
* @param manifest - Plugin manifest with capabilities and storage declarations
* @param code - The bundled plugin JavaScript code
*/
async load(manifest: PluginManifest, code: string): Promise<SandboxedPlugin> {
const pluginId = `${manifest.id}:${manifest.version}`;
// Return cached plugin if available
const existing = this.plugins.get(pluginId);
if (existing) return existing;
const loader = getLoader();
const pluginBridge = getPluginBridge();
if (!loader) {
throw new Error(
"Worker Loader not available. Add worker_loaders binding to wrangler config.",
);
}
if (!pluginBridge) {
throw new Error(
"PluginBridge not available. Export PluginBridge from your worker entrypoint.",
);
}
const plugin = new CloudflareSandboxedPlugin(
manifest,
code,
loader,
pluginBridge,
this.resolvedLimits,
this.siteInfo,
);
this.plugins.set(pluginId, plugin);
return plugin;
}
/**
* Terminate all loaded plugins.
*/
async terminateAll(): Promise<void> {
for (const plugin of this.plugins.values()) {
await plugin.terminate();
}
this.plugins.clear();
}
}
/**
* A plugin running in a Worker Loader isolate.
*
* IMPORTANT: Worker stubs and bridge bindings are tied to request context.
* We must create fresh stubs for each invocation to avoid I/O isolation errors:
* "Cannot perform I/O on behalf of a different request"
*/
class CloudflareSandboxedPlugin implements SandboxedPlugin {
readonly id: string;
readonly manifest: PluginManifest;
private loader: WorkerLoader;
private createBridge: (opts: { props: PluginBridgeProps }) => PluginBridgeBinding;
private code: string;
private wrapperCode: string | null = null;
private limits: ResolvedLimits;
private siteInfo?: { name: string; url: string; locale: string };
constructor(
manifest: PluginManifest,
code: string,
loader: WorkerLoader,
createBridge: (opts: { props: PluginBridgeProps }) => PluginBridgeBinding,
limits: ResolvedLimits,
siteInfo?: { name: string; url: string; locale: string },
) {
this.id = `${manifest.id}:${manifest.version}`;
this.manifest = manifest;
this.code = code;
this.loader = loader;
this.createBridge = createBridge;
this.limits = limits;
this.siteInfo = siteInfo;
}
/**
* Create a fresh worker stub for the current request.
*
* Worker Loader stubs contain bindings (like BRIDGE) that are tied to the
* request context in which they were created. Reusing stubs across requests
* causes "Cannot perform I/O on behalf of a different request" errors.
*
* The Worker Loader internally caches the V8 isolate, so we only pay the
* cost of creating the bridge binding and stub wrapper per request.
*/
private createWorker(): WorkerStub {
// Cache the wrapper code (CPU-bound, no I/O context issues)
if (!this.wrapperCode) {
this.wrapperCode = generatePluginWrapper(this.manifest, {
site: this.siteInfo,
});
}
// Create fresh bridge binding for THIS request
const bridgeBinding = this.createBridge({
props: {
pluginId: this.manifest.id,
pluginVersion: this.manifest.version || "0.0.0",
capabilities: this.manifest.capabilities || [],
allowedHosts: this.manifest.allowedHosts || [],
storageCollections: Object.keys(this.manifest.storage || {}),
},
});
// Build Worker Loader limits from resolved resource limits
const loaderLimits: WorkerLoaderLimits = {
cpuMs: this.limits.cpuMs,
subRequests: this.limits.subrequests,
};
// Get a fresh stub with the new bridge binding.
// Worker Loader caches the isolate but the stub/bindings are per-call.
return this.loader.get(this.id, () => ({
compatibilityDate: "2025-01-01",
mainModule: "plugin.js",
modules: {
"plugin.js": { js: this.wrapperCode! },
"sandbox-plugin.js": { js: this.code },
},
// Block direct network access - plugins must use ctx.http via bridge
globalOutbound: null,
// Enforce resource limits at the V8 isolate level
limits: loaderLimits,
env: {
// Plugin metadata
PLUGIN_ID: this.manifest.id,
PLUGIN_VERSION: this.manifest.version || "0.0.0",
// Bridge binding for all host operations
BRIDGE: bridgeBinding,
},
}));
}
/**
* Run a function with wall-time enforcement.
*
* CPU limits and subrequest limits are enforced by the Worker Loader
* at the V8 isolate level. Wall-time is enforced here because Worker
* Loader doesn't expose a wall-time limit — a plugin could stall
* indefinitely waiting on network I/O.
*/
private async withWallTimeLimit<T>(operation: string, fn: () => Promise<T>): Promise<T> {
const wallTimeMs = this.limits.wallTimeMs;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new Error(
`Plugin ${this.manifest.id} exceeded wall-time limit of ${wallTimeMs}ms during ${operation}`,
),
);
}, wallTimeMs);
});
try {
return await Promise.race([fn(), timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
/**
* Invoke a hook in the sandboxed plugin.
*
* CPU and subrequest limits are enforced by Worker Loader.
* Wall-time is enforced here.
*/
async invokeHook(hookName: string, event: unknown): Promise<unknown> {
return this.withWallTimeLimit(`hook:${hookName}`, () => {
const worker = this.createWorker();
const entrypoint = worker.getEntrypoint<PluginEntrypoint>("default");
return entrypoint.invokeHook(hookName, event);
});
}
/**
* Invoke an API route in the sandboxed plugin.
*
* CPU and subrequest limits are enforced by Worker Loader.
* Wall-time is enforced here.
*/
async invokeRoute(
routeName: string,
input: unknown,
request: SerializedRequest,
): Promise<unknown> {
return this.withWallTimeLimit(`route:${routeName}`, () => {
const worker = this.createWorker();
const entrypoint = worker.getEntrypoint<PluginEntrypoint>("default");
return entrypoint.invokeRoute(routeName, input, request);
});
}
/**
* Terminate the sandboxed plugin.
*/
async terminate(): Promise<void> {
// Worker Loader manages isolate lifecycle - nothing to do here
this.wrapperCode = null;
}
}
/**
* The RPC interface exposed by the plugin wrapper.
*/
interface PluginEntrypoint {
invokeHook(hookName: string, event: unknown): Promise<unknown>;
invokeRoute(routeName: string, input: unknown, request: SerializedRequest): Promise<unknown>;
}
/**
* Factory function for creating the Cloudflare sandbox runner.
*
* Matches the SandboxRunnerFactory signature. The LOADER and PluginBridge
* are obtained internally from cloudflare:workers imports.
*/
export const createSandboxRunner: SandboxRunnerFactory = (options) => {
return new CloudflareSandboxRunner(options);
};

View File

@@ -0,0 +1,181 @@
/**
* Cloudflare-specific types for sandbox runner
*/
import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
/**
* Environment bindings required for sandbox runner.
* These must be configured in wrangler.jsonc.
*/
export interface CloudflareSandboxEnv {
/** Worker Loader binding for spawning plugin isolates */
LOADER?: WorkerLoader;
/** D1 database for plugin storage and bridge operations */
DB: D1Database;
/** R2 bucket for plugin code storage (optional if loading from config) */
PLUGINS?: R2Bucket;
}
/**
* Worker Loader binding type.
* This is the API provided by Cloudflare's Worker Loader feature.
*/
export interface WorkerLoader {
/**
* Get or create a dynamic worker instance.
*
* @param name - Unique identifier for this worker instance
* @param config - Configuration function returning worker setup
* @returns A stub to interact with the dynamic worker
*/
get(name: string, config: () => WorkerLoaderConfig | Promise<WorkerLoaderConfig>): WorkerStub;
}
/**
* Configuration for a dynamically loaded worker.
*/
export interface WorkerLoaderConfig {
/** Compatibility date for the worker */
compatibilityDate?: string;
/** Name of the main module (must be in modules) */
mainModule: string;
/** Map of module names to their code */
modules: Record<string, string | { js: string }>;
/** Environment bindings to pass to the worker */
env?: Record<string, unknown>;
/**
* Outbound fetch handler.
* Set to null to block all network access.
* Set to a service binding to intercept/proxy requests.
*/
globalOutbound?: null | object;
/**
* Resource limits enforced at the V8 isolate level.
* Analogous to Workers for Platforms custom limits.
*/
limits?: WorkerLoaderLimits;
}
/**
* Resource limits for a dynamically loaded worker.
* Enforced by the Worker Loader runtime at the V8 isolate level.
*/
export interface WorkerLoaderLimits {
/** Maximum CPU time in milliseconds per invocation */
cpuMs?: number;
/** Maximum number of subrequests (fetch/service-binding calls) per invocation */
subRequests?: number;
}
/**
* Stub returned by Worker Loader for interacting with dynamic workers.
*/
export interface WorkerStub {
/**
* Get the default entrypoint (fetch handler).
*/
fetch(request: Request): Promise<Response>;
/**
* Get a named entrypoint class instance for RPC.
*/
getEntrypoint<T = unknown>(name?: string): T;
}
/**
* Plugin manifest - loaded from manifest.json in plugin bundle.
*/
export interface LoadedPluginManifest {
id: string;
version: string;
capabilities: string[];
allowedHosts: string[];
storage: Record<string, { indexes: Array<string | string[]> }>;
hooks: string[];
routes: string[];
}
/**
* Content item shape returned by bridge content operations.
* Matches core's ContentItem from plugins/types.ts.
*/
interface BridgeContentItem {
id: string;
type: string;
data: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
/**
* Media item shape returned by bridge media operations.
* Matches core's MediaItem from plugins/types.ts.
*/
interface BridgeMediaItem {
id: string;
filename: string;
mimeType: string;
size: number | null;
url: string;
createdAt: string;
}
/**
* Type for the PluginBridge binding passed to sandboxed workers.
* This is the RPC interface exposed by PluginBridge WorkerEntrypoint.
*/
export interface PluginBridgeBinding {
// KV
kvGet(key: string): Promise<unknown>;
kvSet(key: string, value: unknown): Promise<void>;
kvDelete(key: string): Promise<boolean>;
kvList(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
// Storage
storageGet(collection: string, id: string): Promise<unknown>;
storagePut(collection: string, id: string, data: unknown): Promise<void>;
storageDelete(collection: string, id: string): Promise<boolean>;
storageQuery(
collection: string,
opts?: { limit?: number; cursor?: string },
): Promise<{ items: Array<{ id: string; data: unknown }>; hasMore: boolean; cursor?: string }>;
storageCount(collection: string): Promise<number>;
storageGetMany(collection: string, ids: string[]): Promise<Map<string, unknown>>;
storagePutMany(collection: string, items: Array<{ id: string; data: unknown }>): Promise<void>;
storageDeleteMany(collection: string, ids: string[]): Promise<number>;
// Content
contentGet(collection: string, id: string): Promise<BridgeContentItem | null>;
contentList(
collection: string,
opts?: { limit?: number; cursor?: string },
): Promise<{ items: BridgeContentItem[]; cursor?: string; hasMore: boolean }>;
contentCreate(collection: string, data: Record<string, unknown>): Promise<BridgeContentItem>;
contentUpdate(
collection: string,
id: string,
data: Record<string, unknown>,
): Promise<BridgeContentItem>;
contentDelete(collection: string, id: string): Promise<boolean>;
// Media
mediaGet(id: string): Promise<BridgeMediaItem | null>;
mediaList(opts?: {
limit?: number;
cursor?: string;
mimeType?: string;
}): Promise<{ items: BridgeMediaItem[]; cursor?: string; hasMore: boolean }>;
mediaUpload(
filename: string,
contentType: string,
bytes: ArrayBuffer,
): Promise<{ mediaId: string; storageKey: string; url: string }>;
mediaDelete(id: string): Promise<boolean>;
// Network
httpFetch(
url: string,
init?: RequestInit,
): Promise<{ status: number; headers: Record<string, string>; text: string }>;
// Email
emailSend(message: { to: string; subject: string; text: string; html?: string }): Promise<void>;
// Logging
log(level: "debug" | "info" | "warn" | "error", msg: string, data?: unknown): void;
}

View File

@@ -0,0 +1,238 @@
/**
* Plugin Wrapper Generator
*
* Generates the code that wraps a plugin to run in a Worker Loader isolate.
* The wrapper:
* - Imports plugin hooks and routes from a separate module ("sandbox-plugin.js")
* - Creates plugin context that proxies to BRIDGE service binding
* - Exposes hooks and routes via RPC through WorkerEntrypoint
*
* Plugin code runs in its own module scope, isolated from the wrapper template.
*
*/
import type { PluginManifest } from "emdash";
const TRAILING_SLASH_RE = /\/$/;
const NEWLINE_RE = /[\n\r]/g;
const COMMENT_CLOSE_RE = /\*\//g;
/**
* Options for wrapper generation
*
* **Known limitation:** `site` info is baked into the generated wrapper code
* at load time. If site settings change (e.g., admin updates site name/URL),
* sandboxed plugins will see stale values until the worker restarts.
* Trusted-mode plugins always read fresh values from the database.
*/
export interface WrapperOptions {
/** Site info to inject into the context (no RPC needed) */
site?: { name: string; url: string; locale: string };
}
export function generatePluginWrapper(manifest: PluginManifest, options?: WrapperOptions): string {
const storageCollections = Object.keys(manifest.storage || {});
const site = options?.site ?? { name: "", url: "", locale: "en" };
const hasReadUsers = manifest.capabilities.includes("read:users");
const hasEmailSend = manifest.capabilities.includes("email:send");
return `
// =============================================================================
// Sandboxed Plugin Wrapper
// Generated by @emdashcms/cloudflare
// Plugin: ${sanitizeComment(manifest.id)}@${sanitizeComment(manifest.version)}
// =============================================================================
import { WorkerEntrypoint } from "cloudflare:workers";
// Plugin code lives in a separate module for scope isolation
import pluginModule from "sandbox-plugin.js";
// Extract hooks and routes from the plugin module
const hooks = pluginModule?.hooks || pluginModule?.default?.hooks || {};
const routes = pluginModule?.routes || pluginModule?.default?.routes || {};
// -----------------------------------------------------------------------------
// Context Factory - creates ctx that proxies to BRIDGE
// -----------------------------------------------------------------------------
function createContext(env) {
const bridge = env.BRIDGE;
const storageCollections = ${JSON.stringify(storageCollections)};
// KV - proxies to bridge.kvGet/Set/Delete/List
const kv = {
get: (key) => bridge.kvGet(key),
set: (key, value) => bridge.kvSet(key, value),
delete: (key) => bridge.kvDelete(key),
list: (prefix) => bridge.kvList(prefix)
};
// Storage collection factory
function createStorageCollection(collectionName) {
return {
get: (id) => bridge.storageGet(collectionName, id),
put: (id, data) => bridge.storagePut(collectionName, id, data),
delete: (id) => bridge.storageDelete(collectionName, id),
exists: async (id) => (await bridge.storageGet(collectionName, id)) !== null,
query: (opts) => bridge.storageQuery(collectionName, opts),
count: (where) => bridge.storageCount(collectionName, where),
getMany: (ids) => bridge.storageGetMany(collectionName, ids),
putMany: (items) => bridge.storagePutMany(collectionName, items),
deleteMany: (ids) => bridge.storageDeleteMany(collectionName, ids)
};
}
// Storage proxy that creates collections on access
const storage = new Proxy({}, {
get(_, collectionName) {
if (typeof collectionName !== "string") return undefined;
return createStorageCollection(collectionName);
}
});
// Content access - proxies to bridge (capability enforced by bridge)
const content = {
get: (collection, id) => bridge.contentGet(collection, id),
list: (collection, opts) => bridge.contentList(collection, opts),
create: (collection, data) => bridge.contentCreate(collection, data),
update: (collection, id, data) => bridge.contentUpdate(collection, id, data),
delete: (collection, id) => bridge.contentDelete(collection, id)
};
// Media access - proxies to bridge (capability enforced by bridge)
const media = {
get: (id) => bridge.mediaGet(id),
list: (opts) => bridge.mediaList(opts),
upload: (filename, contentType, bytes) => bridge.mediaUpload(filename, contentType, bytes),
getUploadUrl: () => { throw new Error("getUploadUrl is not available in sandbox mode. Use media.upload(filename, contentType, bytes) instead."); },
delete: (id) => bridge.mediaDelete(id)
};
// HTTP access - proxies to bridge (capability + host enforced by bridge)
const http = {
fetch: async (url, init) => {
const result = await bridge.httpFetch(url, init);
// Bridge returns serialized response, reconstruct Response-like object
return {
status: result.status,
ok: result.status >= 200 && result.status < 300,
headers: new Headers(result.headers),
text: async () => result.text,
json: async () => JSON.parse(result.text)
};
}
};
// Logger - proxies to bridge
const log = {
debug: (msg, data) => bridge.log("debug", msg, data),
info: (msg, data) => bridge.log("info", msg, data),
warn: (msg, data) => bridge.log("warn", msg, data),
error: (msg, data) => bridge.log("error", msg, data)
};
// Site info - injected at wrapper generation time, no RPC needed
const site = ${JSON.stringify(site)};
// URL helper - generates absolute URLs from paths
const siteBaseUrl = ${JSON.stringify(site.url.replace(TRAILING_SLASH_RE, ""))};
function url(path) {
if (!path.startsWith("/")) {
throw new Error('URL path must start with "/", got: "' + path + '"');
}
if (path.startsWith("//")) {
throw new Error('URL path must not be protocol-relative, got: "' + path + '"');
}
return siteBaseUrl + path;
}
// User access - proxies to bridge (capability enforced by bridge)
const users = ${hasReadUsers} ? {
get: (id) => bridge.userGet(id),
getByEmail: (email) => bridge.userGetByEmail(email),
list: (opts) => bridge.userList(opts)
} : undefined;
// Email access - proxies to bridge (capability enforced by bridge)
const email = ${hasEmailSend} ? {
send: (message) => bridge.emailSend(message)
} : undefined;
return {
plugin: {
id: env.PLUGIN_ID,
version: env.PLUGIN_VERSION
},
storage,
kv,
content,
media,
http,
log,
site,
url,
users,
email
};
}
// -----------------------------------------------------------------------------
// Worker Entrypoint (RPC interface)
// -----------------------------------------------------------------------------
export default class PluginEntrypoint extends WorkerEntrypoint {
async invokeHook(hookName, event) {
const ctx = createContext(this.env);
// Find the hook handler
const hookDef = hooks[hookName];
if (!hookDef) {
// No handler for this hook - that's ok, return undefined
return undefined;
}
// Get the handler (might be wrapped in config object)
const handler = typeof hookDef === "function" ? hookDef : hookDef.handler;
if (typeof handler !== "function") {
throw new Error(\`Hook \${hookName} handler is not a function\`);
}
// Execute the hook
return handler(event, ctx);
}
async invokeRoute(routeName, input, serializedRequest) {
const ctx = createContext(this.env);
// Find the route handler
const route = routes[routeName];
if (!route) {
throw new Error(\`Route not found: \${routeName}\`);
}
// Get handler (might be direct function or object with handler)
const handler = typeof route === "function" ? route : route.handler;
if (typeof handler !== "function") {
throw new Error(\`Route \${routeName} handler is not a function\`);
}
// Execute the route handler with input, request metadata, and context
return handler({ input, request: serializedRequest, requestMeta: serializedRequest.meta }, ctx);
}
}
`;
}
/**
* Sanitize a string for inclusion in a JavaScript comment.
* Prevents comment injection via manifest.id or manifest.version containing
* newlines or comment-closing sequences.
*/
function sanitizeComment(s: string): string {
return s.replace(NEWLINE_RE, " ").replace(COMMENT_CLOSE_RE, "* /");
}