@spfn/core 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,10 +78,9 @@ interface CallOptions<TContract extends RouteContract> {
78
78
  */
79
79
  declare class ApiClientError extends Error {
80
80
  readonly status: number;
81
- readonly statusText: string;
82
81
  readonly url: string;
83
82
  readonly response?: unknown | undefined;
84
- constructor(message: string, status: number, statusText: string, url: string, response?: unknown | undefined);
83
+ constructor(message: string, status: number, url: string, response?: unknown | undefined);
85
84
  }
86
85
  /**
87
86
  * Contract-based API Client
@@ -1,9 +1,8 @@
1
1
  // src/client/contract-client.ts
2
2
  var ApiClientError = class extends Error {
3
- constructor(message, status, statusText, url, response) {
3
+ constructor(message, status, url, response) {
4
4
  super(message);
5
5
  this.status = status;
6
- this.statusText = statusText;
7
6
  this.url = url;
8
7
  this.response = response;
9
8
  this.name = "ApiClientError";
@@ -140,7 +139,6 @@ var ContractClient = class _ContractClient {
140
139
  throw new ApiClientError(
141
140
  `${method} ${urlPath} failed: ${response.status} ${response.statusText}`,
142
141
  response.status,
143
- response.statusText,
144
142
  url,
145
143
  errorBody
146
144
  );
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/client/contract-client.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Contract-Based API Client\n *\n * Type-safe HTTP client that works with RouteContract for full end-to-end type safety\n *\n * @example\n * ```ts\n * import { createClient } from '@spfn/core/client';\n * import { getUserContract } from './contracts';\n *\n * const client = createClient({ baseUrl: 'http://localhost:4000' });\n * const user = await client.call(getUserContract, { params: { id: '123' } });\n * // ✅ user is fully typed based on contract.response\n * ```\n */\n\nimport type { RouteContract, InferContract } from '../route';\n\n/**\n * Request interceptor function\n *\n * Allows modifying request before it's sent\n */\nexport type RequestInterceptor = (\n url: string,\n init: RequestInit\n) => Promise<RequestInit> | RequestInit;\n\n/**\n * Client configuration\n */\nexport interface ClientConfig\n{\n /**\n * API base URL (e.g., http://localhost:4000)\n * Can be overridden per request\n */\n baseUrl?: string;\n\n /**\n * Default headers to include in all requests\n */\n headers?: Record<string, string>;\n\n /**\n * Request timeout in milliseconds\n */\n timeout?: number;\n\n /**\n * Custom fetch implementation (for testing or custom behavior)\n */\n fetch?: typeof fetch;\n}\n\n/**\n * Request options for API calls\n */\nexport interface CallOptions<TContract extends RouteContract>\n{\n /**\n * Path parameters (for dynamic routes like /users/:id)\n */\n params?: InferContract<TContract>['params'];\n\n /**\n * Query parameters (for URL query strings)\n */\n query?: InferContract<TContract>['query'];\n\n /**\n * Request body (for POST, PUT, PATCH)\n */\n body?: InferContract<TContract>['body'];\n\n /**\n * Additional headers for this specific request\n */\n headers?: Record<string, string>;\n\n /**\n * Override base URL for this request\n */\n baseUrl?: string;\n}\n\n/**\n * API Client Error\n */\nexport class ApiClientError extends Error\n{\n constructor(\n message: string,\n public readonly status: number,\n public readonly statusText: string,\n public readonly url: string,\n public readonly response?: unknown\n )\n {\n super(message);\n this.name = 'ApiClientError';\n }\n}\n\n/**\n * Build URL with path parameters replaced\n *\n * @example\n * buildUrl('/users/:id', { id: '123' }) → '/users/123'\n * buildUrl('/posts/:postId/comments/:id', { postId: '1', id: '2' }) → '/posts/1/comments/2'\n */\nfunction buildUrl(path: string, params?: Record<string, string | number>): string\n{\n if (!params) return path;\n\n let url = path;\n for (const [key, value] of Object.entries(params))\n {\n url = url.replace(`:${key}`, String(value));\n }\n\n return url;\n}\n\n/**\n * Build query string from object\n *\n * @example\n * buildQuery({ page: '1', limit: '10' }) → '?page=1&limit=10'\n */\nfunction buildQuery(query?: Record<string, string | string[] | number | boolean>): string\n{\n if (!query || Object.keys(query).length === 0) return '';\n\n const params = new URLSearchParams();\n for (const [key, value] of Object.entries(query))\n {\n if (Array.isArray(value))\n {\n value.forEach((v) => params.append(key, String(v)));\n }\n else if (value !== undefined && value !== null)\n {\n params.append(key, String(value));\n }\n }\n\n const queryString = params.toString();\n return queryString ? `?${queryString}` : '';\n}\n\n/**\n * Extract HTTP method from contract or infer from request type\n */\nfunction getHttpMethod<TContract extends RouteContract>(\n contract: TContract,\n options?: CallOptions<TContract>\n): string\n{\n // If contract has explicit method, use it\n if ('method' in contract && typeof contract.method === 'string')\n {\n return contract.method.toUpperCase();\n }\n\n // Infer from presence of body\n if (options?.body !== undefined)\n {\n return 'POST';\n }\n\n // Default to GET\n return 'GET';\n}\n\n/**\n * Contract-based API Client\n */\nexport class ContractClient\n{\n private readonly config: Required<ClientConfig>;\n private readonly interceptors: RequestInterceptor[] = [];\n\n constructor(config: ClientConfig = {})\n {\n this.config = {\n baseUrl: config.baseUrl || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',\n headers: config.headers || {},\n timeout: config.timeout || 30000,\n fetch: config.fetch || globalThis.fetch,\n };\n }\n\n /**\n * Add request interceptor\n *\n * Interceptors are executed in the order they are added\n *\n * @example\n * ```ts\n * client.use(async (url, init) => {\n * // Add auth header\n * return {\n * ...init,\n * headers: {\n * ...init.headers,\n * Authorization: `Bearer ${token}`\n * }\n * };\n * });\n * ```\n */\n use(interceptor: RequestInterceptor): void\n {\n this.interceptors.push(interceptor);\n }\n\n /**\n * Make a type-safe API call using a contract\n *\n * @example\n * ```ts\n * const getUserContract = {\n * params: Type.Object({ id: Type.String() }),\n * response: Type.Object({ id: Type.Number(), name: Type.String() })\n * } as const satisfies RouteContract;\n *\n * const user = await client.call('/users/:id', getUserContract, {\n * params: { id: '123' }\n * });\n * // ✅ user.name is typed as string\n * ```\n */\n async call<TContract extends RouteContract>(\n path: string,\n contract: TContract,\n options?: CallOptions<TContract>\n ): Promise<InferContract<TContract>['response']>\n {\n // Build URL\n const baseUrl = options?.baseUrl || this.config.baseUrl;\n const urlPath = buildUrl(path, options?.params as Record<string, string | number>);\n const queryString = buildQuery(options?.query as Record<string, string | string[] | number | boolean>);\n const url = `${baseUrl}${urlPath}${queryString}`;\n\n // Determine HTTP method\n const method = getHttpMethod(contract, options);\n\n // Build headers\n const headers: Record<string, string> = {\n ...this.config.headers,\n ...options?.headers,\n };\n\n // Add Content-Type for requests with body\n if (options?.body !== undefined && !headers['Content-Type'])\n {\n headers['Content-Type'] = 'application/json';\n }\n\n // Build request init\n let init: RequestInit = {\n method,\n headers,\n };\n\n // Add body for POST/PUT/PATCH\n if (options?.body !== undefined)\n {\n init.body = JSON.stringify(options.body);\n }\n\n // Create abort controller for timeout\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);\n init.signal = controller.signal;\n\n // Execute interceptors\n for (const interceptor of this.interceptors)\n {\n init = await interceptor(url, init);\n }\n\n // Make request\n const response = await this.config.fetch(url, init).catch((error) =>\n {\n clearTimeout(timeoutId);\n\n // Handle abort (timeout)\n if (error instanceof Error && error.name === 'AbortError')\n {\n throw new ApiClientError(\n `${method} ${urlPath} timed out after ${this.config.timeout}ms`,\n 0,\n 'Timeout',\n url\n );\n }\n\n // Handle network errors\n if (error instanceof Error)\n {\n throw new ApiClientError(\n `${method} ${urlPath} network error: ${error.message}`,\n 0,\n 'Network Error',\n url\n );\n }\n\n // Unknown error\n throw error;\n });\n\n // Clear timeout\n clearTimeout(timeoutId);\n\n // Handle non-OK responses\n if (!response.ok)\n {\n const errorBody = await response.json().catch(() => null);\n throw new ApiClientError(\n `${method} ${urlPath} failed: ${response.status} ${response.statusText}`,\n response.status,\n response.statusText,\n url,\n errorBody\n );\n }\n\n // Parse and return response\n const data = await response.json();\n return data as InferContract<TContract>['response'];\n }\n\n /**\n * Create a new client with merged configuration\n *\n * Useful for creating clients with specific auth tokens or custom headers\n *\n * @example\n * ```ts\n * const authClient = client.withConfig({\n * headers: { Authorization: `Bearer ${token}` }\n * });\n * ```\n */\n withConfig(config: Partial<ClientConfig>): ContractClient\n {\n return new ContractClient({\n baseUrl: config.baseUrl || this.config.baseUrl,\n headers: { ...this.config.headers, ...config.headers },\n timeout: config.timeout || this.config.timeout,\n fetch: config.fetch || this.config.fetch,\n });\n }\n}\n\n/**\n * Create a new contract-based API client\n *\n * @example\n * ```ts\n * const client = createClient({\n * baseUrl: 'http://localhost:4000',\n * headers: { 'X-Custom': 'header' }\n * });\n *\n * const user = await client.call('/users/:id', getUserContract, {\n * params: { id: '123' }\n * });\n * ```\n */\nexport function createClient(config?: ClientConfig): ContractClient\n{\n return new ContractClient(config);\n}\n\n/**\n * Default client instance\n *\n * @example\n * ```ts\n * import { client } from '@spfn/core/client';\n *\n * const user = await client.call('/users/:id', getUserContract, {\n * params: { id: '123' }\n * });\n * ```\n */\nexport const client = createClient();"]}
1
+ {"version":3,"sources":["../../src/client/contract-client.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Contract-Based API Client\n *\n * Type-safe HTTP client that works with RouteContract for full end-to-end type safety\n *\n * @example\n * ```ts\n * import { createClient } from '@spfn/core/client';\n * import { getUserContract } from './contracts';\n *\n * const client = createClient({ baseUrl: 'http://localhost:4000' });\n * const user = await client.call(getUserContract, { params: { id: '123' } });\n * // ✅ user is fully typed based on contract.response\n * ```\n */\n\nimport type { RouteContract, InferContract } from '../route';\n\n/**\n * Request interceptor function\n *\n * Allows modifying request before it's sent\n */\nexport type RequestInterceptor = (\n url: string,\n init: RequestInit\n) => Promise<RequestInit> | RequestInit;\n\n/**\n * Client configuration\n */\nexport interface ClientConfig\n{\n /**\n * API base URL (e.g., http://localhost:4000)\n * Can be overridden per request\n */\n baseUrl?: string;\n\n /**\n * Default headers to include in all requests\n */\n headers?: Record<string, string>;\n\n /**\n * Request timeout in milliseconds\n */\n timeout?: number;\n\n /**\n * Custom fetch implementation (for testing or custom behavior)\n */\n fetch?: typeof fetch;\n}\n\n/**\n * Request options for API calls\n */\nexport interface CallOptions<TContract extends RouteContract>\n{\n /**\n * Path parameters (for dynamic routes like /users/:id)\n */\n params?: InferContract<TContract>['params'];\n\n /**\n * Query parameters (for URL query strings)\n */\n query?: InferContract<TContract>['query'];\n\n /**\n * Request body (for POST, PUT, PATCH)\n */\n body?: InferContract<TContract>['body'];\n\n /**\n * Additional headers for this specific request\n */\n headers?: Record<string, string>;\n\n /**\n * Override base URL for this request\n */\n baseUrl?: string;\n}\n\n/**\n * API Client Error\n */\nexport class ApiClientError extends Error\n{\n constructor(\n message: string,\n public readonly status: number,\n public readonly url: string,\n public readonly response?: unknown\n )\n {\n super(message);\n this.name = 'ApiClientError';\n }\n}\n\n/**\n * Build URL with path parameters replaced\n *\n * @example\n * buildUrl('/users/:id', { id: '123' }) → '/users/123'\n * buildUrl('/posts/:postId/comments/:id', { postId: '1', id: '2' }) → '/posts/1/comments/2'\n */\nfunction buildUrl(path: string, params?: Record<string, string | number>): string\n{\n if (!params) return path;\n\n let url = path;\n for (const [key, value] of Object.entries(params))\n {\n url = url.replace(`:${key}`, String(value));\n }\n\n return url;\n}\n\n/**\n * Build query string from object\n *\n * @example\n * buildQuery({ page: '1', limit: '10' }) → '?page=1&limit=10'\n */\nfunction buildQuery(query?: Record<string, string | string[] | number | boolean>): string\n{\n if (!query || Object.keys(query).length === 0) return '';\n\n const params = new URLSearchParams();\n for (const [key, value] of Object.entries(query))\n {\n if (Array.isArray(value))\n {\n value.forEach((v) => params.append(key, String(v)));\n }\n else if (value !== undefined && value !== null)\n {\n params.append(key, String(value));\n }\n }\n\n const queryString = params.toString();\n return queryString ? `?${queryString}` : '';\n}\n\n/**\n * Extract HTTP method from contract or infer from request type\n */\nfunction getHttpMethod<TContract extends RouteContract>(\n contract: TContract,\n options?: CallOptions<TContract>\n): string\n{\n // If contract has explicit method, use it\n if ('method' in contract && typeof contract.method === 'string')\n {\n return contract.method.toUpperCase();\n }\n\n // Infer from presence of body\n if (options?.body !== undefined)\n {\n return 'POST';\n }\n\n // Default to GET\n return 'GET';\n}\n\n/**\n * Contract-based API Client\n */\nexport class ContractClient\n{\n private readonly config: Required<ClientConfig>;\n private readonly interceptors: RequestInterceptor[] = [];\n\n constructor(config: ClientConfig = {})\n {\n this.config = {\n baseUrl: config.baseUrl || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',\n headers: config.headers || {},\n timeout: config.timeout || 30000,\n fetch: config.fetch || globalThis.fetch,\n };\n }\n\n /**\n * Add request interceptor\n *\n * Interceptors are executed in the order they are added\n *\n * @example\n * ```ts\n * client.use(async (url, init) => {\n * // Add auth header\n * return {\n * ...init,\n * headers: {\n * ...init.headers,\n * Authorization: `Bearer ${token}`\n * }\n * };\n * });\n * ```\n */\n use(interceptor: RequestInterceptor): void\n {\n this.interceptors.push(interceptor);\n }\n\n /**\n * Make a type-safe API call using a contract\n *\n * @example\n * ```ts\n * const getUserContract = {\n * params: Type.Object({ id: Type.String() }),\n * response: Type.Object({ id: Type.Number(), name: Type.String() })\n * } as const satisfies RouteContract;\n *\n * const user = await client.call('/users/:id', getUserContract, {\n * params: { id: '123' }\n * });\n * // ✅ user.name is typed as string\n * ```\n */\n async call<TContract extends RouteContract>(\n path: string,\n contract: TContract,\n options?: CallOptions<TContract>\n ): Promise<InferContract<TContract>['response']>\n {\n // Build URL\n const baseUrl = options?.baseUrl || this.config.baseUrl;\n const urlPath = buildUrl(path, options?.params as Record<string, string | number>);\n const queryString = buildQuery(options?.query as Record<string, string | string[] | number | boolean>);\n const url = `${baseUrl}${urlPath}${queryString}`;\n\n // Determine HTTP method\n const method = getHttpMethod(contract, options);\n\n // Build headers\n const headers: Record<string, string> = {\n ...this.config.headers,\n ...options?.headers,\n };\n\n // Add Content-Type for requests with body\n if (options?.body !== undefined && !headers['Content-Type'])\n {\n headers['Content-Type'] = 'application/json';\n }\n\n // Build request init\n let init: RequestInit = {\n method,\n headers,\n };\n\n // Add body for POST/PUT/PATCH\n if (options?.body !== undefined)\n {\n init.body = JSON.stringify(options.body);\n }\n\n // Create abort controller for timeout\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);\n init.signal = controller.signal;\n\n // Execute interceptors\n for (const interceptor of this.interceptors)\n {\n init = await interceptor(url, init);\n }\n\n // Make request\n const response = await this.config.fetch(url, init).catch((error) =>\n {\n clearTimeout(timeoutId);\n\n // Handle abort (timeout)\n if (error instanceof Error && error.name === 'AbortError')\n {\n throw new ApiClientError(\n `${method} ${urlPath} timed out after ${this.config.timeout}ms`,\n 0,\n 'Timeout',\n url\n );\n }\n\n // Handle network errors\n if (error instanceof Error)\n {\n throw new ApiClientError(\n `${method} ${urlPath} network error: ${error.message}`,\n 0,\n 'Network Error',\n url\n );\n }\n\n // Unknown error\n throw error;\n });\n\n // Clear timeout\n clearTimeout(timeoutId);\n\n // Handle non-OK responses\n if (!response.ok)\n {\n const errorBody = await response.json().catch(() => null);\n throw new ApiClientError(\n `${method} ${urlPath} failed: ${response.status} ${response.statusText}`,\n response.status,\n url,\n errorBody\n );\n }\n\n // Parse and return response\n const data = await response.json();\n return data as InferContract<TContract>['response'];\n }\n\n /**\n * Create a new client with merged configuration\n *\n * Useful for creating clients with specific auth tokens or custom headers\n *\n * @example\n * ```ts\n * const authClient = client.withConfig({\n * headers: { Authorization: `Bearer ${token}` }\n * });\n * ```\n */\n withConfig(config: Partial<ClientConfig>): ContractClient\n {\n return new ContractClient({\n baseUrl: config.baseUrl || this.config.baseUrl,\n headers: { ...this.config.headers, ...config.headers },\n timeout: config.timeout || this.config.timeout,\n fetch: config.fetch || this.config.fetch,\n });\n }\n}\n\n/**\n * Create a new contract-based API client\n *\n * @example\n * ```ts\n * const client = createClient({\n * baseUrl: 'http://localhost:4000',\n * headers: { 'X-Custom': 'header' }\n * });\n *\n * const user = await client.call('/users/:id', getUserContract, {\n * params: { id: '123' }\n * });\n * ```\n */\nexport function createClient(config?: ClientConfig): ContractClient\n{\n return new ContractClient(config);\n}\n\n/**\n * Default client instance\n *\n * @example\n * ```ts\n * import { client } from '@spfn/core/client';\n *\n * const user = await client.call('/users/:id', getUserContract, {\n * params: { id: '123' }\n * });\n * ```\n */\nexport const client = createClient();"]}
@@ -109,17 +109,17 @@ declare function generateClient(mappings: RouteContractMapping[], options: Clien
109
109
  interface WatchGenerateOptions {
110
110
  /** Routes directory (default: src/server/routes) */
111
111
  routesDir?: string;
112
- /** Output path for generated client (default: src/lib/api/client.ts) */
112
+ /** Output path for generated client (default: src/lib/api.ts) */
113
113
  outputPath?: string;
114
114
  /** Base URL for API client */
115
115
  baseUrl?: string;
116
116
  /** Enable debug logging */
117
117
  debug?: boolean;
118
+ /** Watch mode (default: true) */
119
+ watch?: boolean;
118
120
  }
119
121
  /**
120
122
  * Watch contracts and generate client code
121
- *
122
- * This file is meant to be run with tsx --watch
123
123
  */
124
124
  declare function watchAndGenerate(options?: WatchGenerateOptions): Promise<void>;
125
125
 
@@ -1,7 +1,8 @@
1
1
  import { mkdir, writeFile, readdir, stat } from 'fs/promises';
2
2
  import { join, dirname } from 'path';
3
3
  import * as ts from 'typescript';
4
- import { readFileSync, existsSync, mkdirSync, createWriteStream } from 'fs';
4
+ import { existsSync, mkdirSync, createWriteStream, readFileSync } from 'fs';
5
+ import { watch } from 'chokidar';
5
6
  import pino from 'pino';
6
7
 
7
8
  // src/codegen/contract-scanner.ts
@@ -925,40 +926,84 @@ var logger = createAdapter(getAdapterType());
925
926
 
926
927
  // src/codegen/watch-generate.ts
927
928
  var codegenLogger = logger.child("codegen");
928
- async function watchAndGenerate(options = {}) {
929
+ async function generateOnce(options) {
929
930
  const cwd = process.cwd();
930
931
  const routesDir = options.routesDir ?? join(cwd, "src", "server", "routes");
931
- const outputPath = options.outputPath ?? join(cwd, "src", "lib", "api", "client.ts");
932
- const debug = options.debug ?? false;
933
- if (debug) {
934
- codegenLogger.info("Contract Watcher Started", { routesDir, outputPath });
935
- }
932
+ const outputPath = options.outputPath ?? join(cwd, "src", "lib", "api.ts");
936
933
  try {
937
934
  const contracts = await scanContracts(routesDir);
938
935
  if (contracts.length === 0) {
939
- if (debug) {
936
+ if (options.debug) {
940
937
  codegenLogger.warn("No contracts found");
941
938
  }
942
- return;
939
+ return null;
943
940
  }
944
941
  const stats = await generateClient(contracts, {
945
942
  outputPath,
946
943
  includeTypes: true,
947
944
  includeJsDoc: true
948
945
  });
949
- if (debug) {
946
+ if (options.debug) {
950
947
  codegenLogger.info("Client generated", {
951
948
  endpoints: stats.methodsGenerated,
952
949
  resources: stats.resourcesGenerated,
953
950
  duration: stats.duration
954
951
  });
955
952
  }
953
+ return stats;
956
954
  } catch (error) {
957
955
  codegenLogger.error(
958
956
  "Generation failed",
959
957
  error instanceof Error ? error : new Error(String(error))
960
958
  );
961
- throw error;
959
+ return null;
960
+ }
961
+ }
962
+ async function watchAndGenerate(options = {}) {
963
+ const cwd = process.cwd();
964
+ const routesDir = options.routesDir ?? join(cwd, "src", "server", "routes");
965
+ const outputPath = options.outputPath ?? join(cwd, "src", "lib", "api.ts");
966
+ const watchMode = options.watch !== false;
967
+ if (options.debug) {
968
+ codegenLogger.info("Contract Watcher Started", { routesDir, outputPath, watch: watchMode });
969
+ }
970
+ await generateOnce(options);
971
+ if (watchMode) {
972
+ let isGenerating = false;
973
+ let pendingRegeneration = false;
974
+ const watcher = watch(routesDir, {
975
+ ignored: /(^|[\/\\])\../,
976
+ // ignore dotfiles
977
+ persistent: true,
978
+ ignoreInitial: true,
979
+ awaitWriteFinish: {
980
+ stabilityThreshold: 100,
981
+ pollInterval: 50
982
+ }
983
+ });
984
+ const regenerate = async () => {
985
+ if (isGenerating) {
986
+ pendingRegeneration = true;
987
+ return;
988
+ }
989
+ isGenerating = true;
990
+ pendingRegeneration = false;
991
+ if (options.debug) {
992
+ codegenLogger.info("Contracts changed, regenerating...");
993
+ }
994
+ await generateOnce(options);
995
+ isGenerating = false;
996
+ if (pendingRegeneration) {
997
+ await regenerate();
998
+ }
999
+ };
1000
+ watcher.on("add", regenerate).on("change", regenerate).on("unlink", regenerate);
1001
+ process.on("SIGINT", () => {
1002
+ watcher.close();
1003
+ process.exit(0);
1004
+ });
1005
+ await new Promise(() => {
1006
+ });
962
1007
  }
963
1008
  }
964
1009
  if (import.meta.url === `file://${process.argv[1]}`) {