Skip to content
Merged
8 changes: 6 additions & 2 deletions src/html/styles-builder/plugin-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @module html/styles-builder/plugin-loader
*/

import { encodeBase64, serverLogger } from "#veryfront/utils";
import { encodeBase64Bytes, serverLogger } from "#veryfront/utils";
import {
type ErrorSlug,
getErrorBySlug,
Expand Down Expand Up @@ -99,7 +99,11 @@ export function rewriteEsmShRootRelativeImports(code: string): string {

async function importBundledModule(code: string): Promise<unknown> {
if (!isDeno) {
const dataUrl = `data:text/javascript;base64,${encodeBase64(code)}`;
// Encode as UTF-8 bytes: the data: URL importer decodes UTF-8, and btoa on
// the raw string would emit Latin-1 bytes for chars in [0x80, 0xFF].
const dataUrl = `data:text/javascript;base64,${
encodeBase64Bytes(new TextEncoder().encode(code))
}`;
return await import(dataUrl);
}

Expand Down
25 changes: 25 additions & 0 deletions src/modules/server/fs-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Minimal stat surface shared by the secure and platform filesystems. */
interface StatCapableFs {
stat(path: string): Promise<{ isFile: boolean }>;
}

/**
* Resolve the first path in `paths` order that exists as a file, or null.
* All candidates are stat-probed in parallel; order of `paths` decides the
* winner, not which probe resolves first.
*/
export async function findFirstExistingFile(
Comment thread
kojiwakayama marked this conversation as resolved.
fs: StatCapableFs,
paths: string[],
): Promise<string | null> {
const results = await Promise.all(paths.map(async (path) => {
try {
const stat = await fs.stat(path);
return stat.isFile ? path : null;
} catch {
return null;
}
}));

return results.find((path): path is string => path !== null) ?? null;
}
37 changes: 3 additions & 34 deletions src/modules/server/module-batch-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
hasSourceMiss,
rememberSourceMiss,
} from "./module-source-resolution-cache.ts";
import { findFirstExistingFile } from "./fs-probe.ts";

const logger = serverLogger.component("module-batch");

Expand Down Expand Up @@ -83,38 +84,6 @@ const FRAMEWORK_EXTENSIONS = [
".js", // Regular sources for dev mode
] as const;

async function findFirstSecureFile(
secureFs: ReturnType<typeof createSecureFs>,
paths: string[],
): Promise<string | null> {
const results = await Promise.all(paths.map(async (path) => {
try {
const stat = await secureFs.stat(path);
return stat.isFile ? path : null;
} catch {
return null;
}
}));

return results.find((path): path is string => path !== null) ?? null;
}

async function findFirstPlatformFile(
platformFs: ReturnType<typeof createFileSystem>,
paths: string[],
): Promise<string | null> {
const results = await Promise.all(paths.map(async (path) => {
try {
const stat = await platformFs.stat(path);
return stat.isFile ? path : null;
} catch {
return null;
}
}));

return results.find((path): path is string => path !== null) ?? null;
}

export interface BatchHandlerOptions {
projectDir: string;
adapter: RuntimeAdapter;
Expand Down Expand Up @@ -341,7 +310,7 @@ async function loadAndTransformModule(
});
if (hasSourceMiss(missCacheKey)) return null;

const sourcePath = await findFirstSecureFile(
const sourcePath = await findFirstExistingFile(
secureFs,
EXTENSIONS.map((ext) => join(projectDir, basePath + ext)),
);
Expand All @@ -359,7 +328,7 @@ async function loadAndTransformModule(

const platformFs = createFileSystem();
for (const lookupDir of frameworkLookupDirs) {
const frameworkPath = await findFirstPlatformFile(
const frameworkPath = await findFirstExistingFile(
platformFs,
FRAMEWORK_EXTENSIONS.map((ext) => join(lookupDir, basePath + ext)),
);
Expand Down
43 changes: 6 additions & 37 deletions src/modules/server/module-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
getReleaseModuleResponse,
rememberReleaseModuleResponse,
} from "./module-response-cache.ts";
import { findFirstExistingFile } from "./fs-probe.ts";
import { ensureFilenameDefaultExport } from "#veryfront/modules/loader-shared/filename-default-export.ts";

const logger = serverLogger.component("module-server");
Expand Down Expand Up @@ -791,44 +792,12 @@ async function findFrameworkPackageAssetFile(
): Promise<string | null> {
if (hasUnsafePackageAssetPath(basePathWithoutExt)) return null;

return await findFirstPlatformFile(
return await findFirstExistingFile(
fs,
extensions.map((ext) => join(FRAMEWORK_ROOT, basePathWithoutExt + ext)),
);
}

async function findFirstPlatformFile(
fs: ReturnType<typeof createFileSystem>,
paths: string[],
): Promise<string | null> {
const results = await Promise.all(paths.map(async (path) => {
try {
const stat = await fs.stat(path);
return stat.isFile ? path : null;
} catch {
return null;
}
}));

return results.find((path): path is string => path !== null) ?? null;
}

async function findFirstSecureFile(
secureFs: ReturnType<typeof createSecureFs>,
paths: string[],
): Promise<string | null> {
const results = await Promise.all(paths.map(async (path) => {
try {
const stat = await secureFs.stat(path);
return stat.isFile ? path : null;
} catch {
return null;
}
}));

return results.find((path): path is string => path !== null) ?? null;
}

async function findSourceFile(
secureFs: ReturnType<typeof createSecureFs>,
projectDir: string,
Expand Down Expand Up @@ -970,7 +939,7 @@ async function findSourceFile(
: extensions;

// Project file lookups (using secureFs which may go through FSAdapter in proxy mode)
const projectFilePath = await findFirstSecureFile(
const projectFilePath = await findFirstExistingFile(
secureFs,
projectLookupExtensions.map((ext) => join(projectDir, basePathWithoutExt + ext)),
);
Expand All @@ -984,7 +953,7 @@ async function findSourceFile(
if (!basePathWithoutExt.startsWith(prefix)) continue;

const strippedPath = basePathWithoutExt.slice(prefix.length);
const strippedFilePath = await findFirstSecureFile(
const strippedFilePath = await findFirstExistingFile(
secureFs,
projectLookupExtensions.map((ext) => join(projectDir, strippedPath + ext)),
);
Expand All @@ -998,7 +967,7 @@ async function findSourceFile(
}
}

const indexFilePath = await findFirstSecureFile(
const indexFilePath = await findFirstExistingFile(
secureFs,
projectLookupExtensions.map((ext) => join(projectDir, basePathWithoutExt, `index${ext}`)),
);
Expand All @@ -1013,7 +982,7 @@ async function findSourceFile(
// Try looking in common project directories
const commonDirs = ["components", "app", "pages", "lib", "src"];
for (const dir of commonDirs) {
const commonDirFilePath = await findFirstSecureFile(
const commonDirFilePath = await findFirstExistingFile(
secureFs,
projectLookupExtensions.map((ext) => join(projectDir, dir, basePathWithoutExt + ext)),
);
Expand Down
14 changes: 4 additions & 10 deletions src/observability/error-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
**************************/

import { type ErrorCategory, INVALID_ARGUMENT } from "#veryfront/errors";
import { createSubscriberSet } from "#veryfront/utils/subscriber-set.ts";

/** Public API contract for error type. */
export type ErrorType = "compile" | "runtime" | "bundle" | "hmr" | "module";
Expand Down Expand Up @@ -65,7 +66,7 @@ export type ErrorSubscriber = (error: DevError) => void;
/** Implement error collector. */
export class ErrorCollector {
private errors = new Map<string, DevError>();
private subscribers = new Set<ErrorSubscriber>();
private subscribers = createSubscriberSet<[DevError]>();
private idCounter = 0;
private maxErrors: number;

Expand Down Expand Up @@ -99,13 +100,7 @@ export class ErrorCollector {

this.errors.set(fullError.id, fullError);

for (const subscriber of this.subscribers) {
try {
subscriber(fullError);
} catch (_) {
/* expected: subscriber errors must not break error collection */
}
}
this.subscribers.notify(fullError);

return fullError;
}
Expand Down Expand Up @@ -320,8 +315,7 @@ export class ErrorCollector {
}

subscribe(callback: ErrorSubscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
return this.subscribers.subscribe(callback);
}

toJSON(): DevError[] {
Expand Down
14 changes: 4 additions & 10 deletions src/observability/log-buffer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { redactSensitive } from "#veryfront/utils/logger/redact.ts";
import { createSubscriberSet } from "#veryfront/utils/subscriber-set.ts";

/** Public API contract for log level. */
export type LogLevel = "debug" | "info" | "warn" | "error";
Expand Down Expand Up @@ -27,7 +28,7 @@ export type LogSubscriber = (entry: LogEntry) => void;
/** Implement log buffer. */
export class LogBuffer {
private entries: LogEntry[] = [];
private subscribers = new Set<LogSubscriber>();
private subscribers = createSubscriberSet<[LogEntry]>();
private idCounter = 0;
private maxSize: number;

Expand Down Expand Up @@ -55,13 +56,7 @@ export class LogBuffer {
this.entries.shift();
}

for (const subscriber of this.subscribers) {
try {
subscriber(fullEntry);
} catch (_) {
/* expected: subscriber errors must not break log buffering */
}
}
this.subscribers.notify(fullEntry);

return fullEntry;
}
Expand Down Expand Up @@ -146,8 +141,7 @@ export class LogBuffer {
}

subscribe(callback: LogSubscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
return this.subscribers.subscribe(callback);
}

toJSON(): LogEntry[] {
Expand Down
24 changes: 18 additions & 6 deletions src/observability/request-profiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ const records: RequestProfileRecord[] = [];
const MAX_RECORDS = 200;
let sequence = 0;

function roundMs(value: number): number {
/** Round to 2 decimal places (Server-Timing millisecond precision). */
export function roundMs(value: number): number {
return Math.round(value * 100) / 100;
}

Expand Down Expand Up @@ -171,16 +172,27 @@ function formatDuration(value: number): string {
return Math.max(0, roundMs(value)).toFixed(2);
}

export function buildServerTimingHeader(record: RequestProfileRecord): string {
const metrics = [`total;dur=${formatDuration(record.totalMs)}`];

for (const [name, duration] of Object.entries(record.phases).slice(0, 20)) {
/** Build a Server-Timing header value from a total plus named phase durations. */
export function buildServerTimingValue(
totalLabel: string,
totalMs: number,
phases: Iterable<[string, number]>,
): string {
const metrics = [`${sanitizeMetricName(totalLabel)};dur=${formatDuration(totalMs)}`];
for (const [name, duration] of phases) {
metrics.push(`${sanitizeMetricName(name)};dur=${formatDuration(duration)}`);
}

return metrics.join(", ");
}

export function buildServerTimingHeader(record: RequestProfileRecord): string {
return buildServerTimingValue(
"total",
record.totalMs,
Object.entries(record.phases).slice(0, 20),
);
}

export function withServerTimingHeader(
response: Response,
record: RequestProfileRecord | null,
Expand Down
20 changes: 2 additions & 18 deletions src/proxy/server-timing.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,12 @@
import { getEnv } from "#veryfront/platform/compat/process.ts";
import { buildServerTimingValue, roundMs } from "#veryfront/observability/request-profiler.ts";

export interface ProxyServerTiming {
enabled: boolean;
startedAt: number;
phases: Map<string, number>;
}

function roundMs(value: number): number {
return Math.round(value * 100) / 100;
}

function formatDuration(value: number): string {
return Math.max(0, roundMs(value)).toFixed(2);
}

function sanitizeMetricName(name: string): string {
return name.replace(/[^A-Za-z0-9!#$%&'*+\-.^_`|~]/g, "_");
}

export function shouldEnableProxyServerTiming(): boolean {
return getEnv("VERYFRONT_ENABLE_PROXY_SERVER_TIMING") === "1" ||
getEnv("VERYFRONT_ENABLE_SERVER_TIMING") === "1";
Expand Down Expand Up @@ -64,12 +53,7 @@ export function withProxyServerTimingHeader(
): Response {
if (!timing.enabled) return response;

const metrics = [`proxy.total;dur=${formatDuration(totalMs)}`];
for (const [name, duration] of timing.phases.entries()) {
metrics.push(`${sanitizeMetricName(name)};dur=${formatDuration(duration)}`);
}

const value = metrics.join(", ");
const value = buildServerTimingValue("proxy.total", totalMs, timing.phases.entries());

try {
const existing = response.headers.get("Server-Timing");
Expand Down
Loading