apigrip 0.1.0

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.
Files changed (41) hide show
  1. package/README.md +240 -0
  2. package/cli/commands/curl.js +11 -0
  3. package/cli/commands/env.js +174 -0
  4. package/cli/commands/last.js +63 -0
  5. package/cli/commands/list.js +35 -0
  6. package/cli/commands/mcp.js +25 -0
  7. package/cli/commands/projects.js +50 -0
  8. package/cli/commands/send.js +189 -0
  9. package/cli/commands/serve.js +46 -0
  10. package/cli/index.js +109 -0
  11. package/cli/output.js +168 -0
  12. package/cli/resolve-project.js +43 -0
  13. package/client/dist/assets/index-CtHBIuEv.js +75 -0
  14. package/client/dist/assets/index-kzeRjfI8.css +1 -0
  15. package/client/dist/index.html +19 -0
  16. package/core/curl-builder.js +218 -0
  17. package/core/curl-executor.js +370 -0
  18. package/core/env-resolver.js +244 -0
  19. package/core/git-info.js +41 -0
  20. package/core/params-store.js +94 -0
  21. package/core/preferences-store.js +150 -0
  22. package/core/projects-store.js +173 -0
  23. package/core/response-store.js +121 -0
  24. package/core/schema-validator.js +196 -0
  25. package/core/spec-discovery.js +109 -0
  26. package/core/spec-parser.js +172 -0
  27. package/lib/index.cjs +16 -0
  28. package/lib/index.js +294 -0
  29. package/mcp/server.js +257 -0
  30. package/package.json +70 -0
  31. package/server/index.js +53 -0
  32. package/server/routes/browse.js +61 -0
  33. package/server/routes/environments.js +92 -0
  34. package/server/routes/events.js +40 -0
  35. package/server/routes/params.js +38 -0
  36. package/server/routes/preferences.js +27 -0
  37. package/server/routes/project.js +94 -0
  38. package/server/routes/projects.js +51 -0
  39. package/server/routes/requests.js +192 -0
  40. package/server/routes/spec.js +92 -0
  41. package/server/spec-watcher.js +236 -0
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getConfigDir, getProjectHash } from './env-resolver.js';
4
+
5
+ /**
6
+ * Get the responses file path for a project.
7
+ */
8
+ function getResponsesFilePath(projectDir) {
9
+ const configDir = getConfigDir();
10
+ const hash = getProjectHash(projectDir);
11
+ return path.join(configDir, 'responses', `${hash}.json`);
12
+ }
13
+
14
+ /**
15
+ * Read and parse the responses file.
16
+ * Returns an empty object if the file doesn't exist or is corrupt.
17
+ */
18
+ function readResponsesFile(filePath) {
19
+ try {
20
+ if (!fs.existsSync(filePath)) {
21
+ return {};
22
+ }
23
+ const raw = fs.readFileSync(filePath, 'utf-8');
24
+ const parsed = JSON.parse(raw);
25
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
26
+ console.warn(`[response-store] Corrupt responses file at ${filePath}, returning defaults`);
27
+ return {};
28
+ }
29
+ return parsed;
30
+ } catch (err) {
31
+ console.warn(`[response-store] Failed to read responses file at ${filePath}: ${err.message}`);
32
+ return {};
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Write the full responses object to the responses file.
38
+ */
39
+ function writeResponsesFile(filePath, data) {
40
+ const dir = path.dirname(filePath);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
43
+ }
44
+
45
+ /**
46
+ * Save the last response for a specific endpoint.
47
+ * @param {string} projectDir - Project directory path
48
+ * @param {string} method - HTTP method (e.g., "GET")
49
+ * @param {string} endpointPath - Endpoint path (e.g., "/users/{id}")
50
+ * @param {object} response - Response object to save
51
+ * @param {number} response.status - HTTP status code
52
+ * @param {string} [response.status_text] - HTTP status text
53
+ * @param {object} [response.headers] - Response headers
54
+ * @param {string} [response.body] - Response body
55
+ * @param {number} [response.size_bytes] - Response size
56
+ * @param {object} [response.timing] - Timing info (dns_ms, tcp_ms, tls_ms, ttfb_ms, total_ms)
57
+ * @param {string} [response.curl_command] - curl command used
58
+ * @param {object} [response.validation] - Schema validation result
59
+ */
60
+ export function saveLastResponse(projectDir, method, endpointPath, response) {
61
+ const filePath = getResponsesFilePath(projectDir);
62
+ const all = readResponsesFile(filePath);
63
+ const key = `${method.toUpperCase()} ${endpointPath}`;
64
+ all[key] = {
65
+ ...response,
66
+ saved_at: new Date().toISOString(),
67
+ };
68
+ writeResponsesFile(filePath, all);
69
+ }
70
+
71
+ /**
72
+ * Load the last response for a specific endpoint.
73
+ * @param {string} projectDir - Project directory path
74
+ * @param {string} method - HTTP method (e.g., "GET")
75
+ * @param {string} endpointPath - Endpoint path (e.g., "/users/{id}")
76
+ * @returns {object|null} The saved response or null if none exists
77
+ */
78
+ export function loadLastResponse(projectDir, method, endpointPath) {
79
+ const filePath = getResponsesFilePath(projectDir);
80
+ const all = readResponsesFile(filePath);
81
+ const key = `${method.toUpperCase()} ${endpointPath}`;
82
+ return all[key] || null;
83
+ }
84
+
85
+ /**
86
+ * Load all saved responses for a project.
87
+ * @param {string} projectDir - Project directory path
88
+ * @returns {object} Full responses object keyed by "METHOD /path"
89
+ */
90
+ export function loadAllResponses(projectDir) {
91
+ const filePath = getResponsesFilePath(projectDir);
92
+ return readResponsesFile(filePath);
93
+ }
94
+
95
+ /**
96
+ * Clear the saved response for a specific endpoint.
97
+ * @param {string} projectDir - Project directory path
98
+ * @param {string} method - HTTP method
99
+ * @param {string} endpointPath - Endpoint path
100
+ * @returns {boolean} True if a response was cleared, false if none existed
101
+ */
102
+ export function clearLastResponse(projectDir, method, endpointPath) {
103
+ const filePath = getResponsesFilePath(projectDir);
104
+ const all = readResponsesFile(filePath);
105
+ const key = `${method.toUpperCase()} ${endpointPath}`;
106
+ if (key in all) {
107
+ delete all[key];
108
+ writeResponsesFile(filePath, all);
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /**
115
+ * Clear all saved responses for a project.
116
+ * @param {string} projectDir - Project directory path
117
+ */
118
+ export function clearAllResponses(projectDir) {
119
+ const filePath = getResponsesFilePath(projectDir);
120
+ writeResponsesFile(filePath, {});
121
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * schema-validator.js - Validate JSON against JSON Schema using ajv.
3
+ */
4
+
5
+ import Ajv from 'ajv';
6
+
7
+ /**
8
+ * Create a configured Ajv instance.
9
+ */
10
+ function createAjv() {
11
+ return new Ajv({
12
+ allErrors: true,
13
+ strict: false,
14
+ validateFormats: false,
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Convert Ajv errors to our standard error format.
20
+ *
21
+ * @param {Array} ajvErrors - Errors from ajv.errors
22
+ * @returns {Array<{path: string, message: string, expected: string, actual: string}>}
23
+ */
24
+ function formatErrors(ajvErrors) {
25
+ if (!ajvErrors || ajvErrors.length === 0) return [];
26
+
27
+ return ajvErrors.map(err => {
28
+ const path = err.instancePath || '/';
29
+ let expected = '';
30
+ let actual = '';
31
+
32
+ if (err.keyword === 'type') {
33
+ expected = String(err.params.type);
34
+ actual = err.data != null ? typeof err.data : 'undefined';
35
+ } else if (err.keyword === 'required') {
36
+ expected = 'required';
37
+ actual = 'missing';
38
+ } else if (err.keyword === 'enum') {
39
+ expected = `one of: ${(err.params.allowedValues || []).join(', ')}`;
40
+ actual = String(err.data);
41
+ } else if (err.keyword === 'format') {
42
+ expected = `${err.params.format} format`;
43
+ actual = String(err.data);
44
+ } else if (err.keyword === 'minimum' || err.keyword === 'maximum') {
45
+ expected = `${err.keyword}: ${err.params.limit}`;
46
+ actual = String(err.data);
47
+ } else if (err.keyword === 'minLength' || err.keyword === 'maxLength') {
48
+ expected = `${err.keyword}: ${err.params.limit}`;
49
+ actual = `length: ${String(err.data).length}`;
50
+ } else if (err.keyword === 'additionalProperties') {
51
+ expected = 'no additional properties';
52
+ actual = `property: ${err.params.additionalProperty}`;
53
+ } else if (err.keyword === 'pattern') {
54
+ expected = `pattern: ${err.params.pattern}`;
55
+ actual = String(err.data);
56
+ } else {
57
+ expected = err.keyword || '';
58
+ actual = err.data != null ? String(err.data) : '';
59
+ }
60
+
61
+ return {
62
+ path,
63
+ message: err.message || 'Validation error',
64
+ expected,
65
+ actual,
66
+ };
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Validate a request body against a JSON schema.
72
+ *
73
+ * @param {object} schema - JSON Schema to validate against
74
+ * @param {string} body - Body string to validate
75
+ * @param {string} contentType - Content type of the body
76
+ * @returns {{ valid: boolean|null, errors: Array }}
77
+ */
78
+ export function validateBody(schema, body, contentType) {
79
+ // For non-JSON content types, skip validation
80
+ if (!contentType || !contentType.includes('json')) {
81
+ return { valid: null, errors: [] };
82
+ }
83
+
84
+ if (!schema) {
85
+ return { valid: null, errors: [] };
86
+ }
87
+
88
+ // Parse body as JSON
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(body);
92
+ } catch (err) {
93
+ return {
94
+ valid: false,
95
+ errors: [{
96
+ path: '/',
97
+ message: `Invalid JSON: ${err.message}`,
98
+ expected: 'valid JSON',
99
+ actual: 'parse error',
100
+ }],
101
+ };
102
+ }
103
+
104
+ const ajv = createAjv();
105
+ let validate;
106
+ try {
107
+ validate = ajv.compile(schema);
108
+ } catch (err) {
109
+ return {
110
+ valid: null,
111
+ errors: [{
112
+ path: '/',
113
+ message: `Schema compilation error: ${err.message}`,
114
+ expected: 'valid schema',
115
+ actual: 'invalid schema',
116
+ }],
117
+ };
118
+ }
119
+
120
+ const valid = validate(parsed);
121
+ if (valid) {
122
+ return { valid: true, errors: [] };
123
+ }
124
+
125
+ return {
126
+ valid: false,
127
+ errors: formatErrors(validate.errors),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Validate a response body against the spec's schema for the given status code and content type.
133
+ *
134
+ * @param {object} spec - Parsed OpenAPI spec
135
+ * @param {string} method - HTTP method (case-insensitive)
136
+ * @param {string} path - OpenAPI path template
137
+ * @param {number|string} statusCode - HTTP status code
138
+ * @param {string} responseBody - Response body string
139
+ * @param {string} responseContentType - Response content type
140
+ * @returns {{ valid: boolean|null, errors: Array }}
141
+ */
142
+ export function validateResponse(spec, method, path, statusCode, responseBody, responseContentType) {
143
+ if (!spec || !spec.paths || !spec.paths[path]) {
144
+ return { valid: null, errors: [] };
145
+ }
146
+
147
+ const pathItem = spec.paths[path];
148
+ const operation = pathItem[method.toLowerCase()];
149
+ if (!operation || !operation.responses) {
150
+ return { valid: null, errors: [] };
151
+ }
152
+
153
+ const statusStr = String(statusCode);
154
+
155
+ // Try the exact status code first, then fallback to default
156
+ let responseDef = operation.responses[statusStr] || operation.responses.default;
157
+ if (!responseDef) {
158
+ return { valid: null, errors: [] };
159
+ }
160
+
161
+ // OpenAPI 3.x: responses have content map
162
+ if (responseDef.content) {
163
+ const schema = findSchemaForContentType(responseDef.content, responseContentType);
164
+ if (!schema) {
165
+ return { valid: null, errors: [] };
166
+ }
167
+ return validateBody(schema, responseBody, responseContentType);
168
+ }
169
+
170
+ // Swagger 2.0: responses have a direct schema property
171
+ if (responseDef.schema) {
172
+ return validateBody(responseDef.schema, responseBody, responseContentType || 'application/json');
173
+ }
174
+
175
+ return { valid: null, errors: [] };
176
+ }
177
+
178
+ /**
179
+ * Find a schema in a content map for a given content type.
180
+ * Handles exact match and partial match (e.g., 'application/json' matches 'application/json; charset=utf-8').
181
+ */
182
+ function findSchemaForContentType(content, responseContentType) {
183
+ if (!content || !responseContentType) return null;
184
+
185
+ // Normalize the content type: take just the media type part (before semicolon)
186
+ const normalizedType = responseContentType.split(';')[0].trim().toLowerCase();
187
+
188
+ for (const [ct, def] of Object.entries(content)) {
189
+ const normalizedCt = ct.split(';')[0].trim().toLowerCase();
190
+ if (normalizedCt === normalizedType) {
191
+ return def.schema || null;
192
+ }
193
+ }
194
+
195
+ return null;
196
+ }
@@ -0,0 +1,109 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, extname } from 'node:path';
3
+ import yaml from 'js-yaml';
4
+
5
+ const EXCLUDED_DIRS = new Set([
6
+ 'node_modules', '.git', 'vendor', 'dist', 'build', '__pycache__', '.venv', 'target'
7
+ ]);
8
+
9
+ const ROOT_PRIORITY = ['openapi.yaml', 'openapi.yml', 'openapi.json'];
10
+
11
+ /**
12
+ * Check if a file is a valid OpenAPI/Swagger spec by examining its top-level keys.
13
+ */
14
+ async function isSpecFile(filePath) {
15
+ try {
16
+ const ext = extname(filePath).toLowerCase();
17
+ if (!['.yaml', '.yml', '.json'].includes(ext)) {
18
+ return false;
19
+ }
20
+
21
+ const content = await readFile(filePath, 'utf-8');
22
+ let parsed;
23
+
24
+ if (ext === '.json') {
25
+ parsed = JSON.parse(content);
26
+ } else {
27
+ parsed = yaml.load(content);
28
+ }
29
+
30
+ if (parsed && typeof parsed === 'object') {
31
+ return ('openapi' in parsed) || ('swagger' in parsed);
32
+ }
33
+
34
+ return false;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Check if a directory name should be excluded from recursive search.
42
+ */
43
+ function isExcludedDir(name) {
44
+ if (EXCLUDED_DIRS.has(name)) return true;
45
+ if (name.startsWith('.')) return true;
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Discover an OpenAPI spec file in a directory.
51
+ *
52
+ * Priority:
53
+ * 1. openapi.yaml > openapi.yml > openapi.json in the project root
54
+ * 2. Recursive BFS, alphabetical order, excluding certain directories
55
+ *
56
+ * @param {string} projectDir - The project directory to search in
57
+ * @returns {Promise<{specPath: string, specFile: string} | null>}
58
+ */
59
+ export async function discoverSpec(projectDir) {
60
+ // Phase 1: Check root priority files
61
+ for (const fileName of ROOT_PRIORITY) {
62
+ const filePath = join(projectDir, fileName);
63
+ if (await isSpecFile(filePath)) {
64
+ return { specPath: filePath, specFile: fileName };
65
+ }
66
+ }
67
+
68
+ // Phase 2: Recursive BFS
69
+ const queue = [projectDir];
70
+
71
+ while (queue.length > 0) {
72
+ const currentDir = queue.shift();
73
+ let entries;
74
+
75
+ try {
76
+ entries = await readdir(currentDir, { withFileTypes: true });
77
+ } catch {
78
+ continue;
79
+ }
80
+
81
+ // Sort entries alphabetically for deterministic order
82
+ entries.sort((a, b) => a.name.localeCompare(b.name));
83
+
84
+ const subdirs = [];
85
+
86
+ for (const entry of entries) {
87
+ const fullPath = join(currentDir, entry.name);
88
+
89
+ if (entry.isFile()) {
90
+ // Skip root priority files if we're in the root dir (already checked)
91
+ if (currentDir === projectDir && ROOT_PRIORITY.includes(entry.name)) {
92
+ continue;
93
+ }
94
+
95
+ if (await isSpecFile(fullPath)) {
96
+ const relative = fullPath.slice(projectDir.length + 1);
97
+ return { specPath: fullPath, specFile: relative };
98
+ }
99
+ } else if (entry.isDirectory() && !isExcludedDir(entry.name)) {
100
+ subdirs.push(fullPath);
101
+ }
102
+ }
103
+
104
+ // Add subdirs to queue (they're already sorted)
105
+ queue.push(...subdirs);
106
+ }
107
+
108
+ return null;
109
+ }
@@ -0,0 +1,172 @@
1
+ import SwaggerParser from '@apidevtools/swagger-parser';
2
+
3
+ const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
4
+
5
+ /**
6
+ * Parse and dereference an OpenAPI spec file.
7
+ *
8
+ * @param {string} specPath - Absolute path to the spec file
9
+ * @returns {Promise<object>} - Parsed and dereferenced spec object
10
+ */
11
+ export async function parseSpec(specPath) {
12
+ const spec = await SwaggerParser.dereference(specPath);
13
+ return spec;
14
+ }
15
+
16
+ /**
17
+ * Extract a lean endpoint list from a parsed spec.
18
+ *
19
+ * @param {object} spec - Parsed OpenAPI spec
20
+ * @returns {Array<{method: string, path: string, summary: string, tags: string[], deprecated: boolean}>}
21
+ */
22
+ export function extractEndpoints(spec) {
23
+ const endpoints = [];
24
+
25
+ if (!spec || !spec.paths) {
26
+ return endpoints;
27
+ }
28
+
29
+ for (const [path, pathItem] of Object.entries(spec.paths)) {
30
+ if (!pathItem || typeof pathItem !== 'object') continue;
31
+
32
+ for (const method of HTTP_METHODS) {
33
+ const operation = pathItem[method];
34
+ if (!operation) continue;
35
+
36
+ endpoints.push({
37
+ method: method.toUpperCase(),
38
+ path,
39
+ summary: operation.summary || '',
40
+ tags: operation.tags || [],
41
+ deprecated: operation.deprecated || false,
42
+ });
43
+ }
44
+ }
45
+
46
+ return endpoints;
47
+ }
48
+
49
+ /**
50
+ * Get full details for a single endpoint.
51
+ * Merges path-level and operation-level parameters (operation params override
52
+ * path params with the same name+in combination).
53
+ *
54
+ * @param {object} spec - Parsed OpenAPI spec
55
+ * @param {string} method - HTTP method (case-insensitive)
56
+ * @param {string} path - OpenAPI path template
57
+ * @returns {object|null} - Full endpoint details or null if not found
58
+ */
59
+ export function getEndpointDetails(spec, method, path) {
60
+ if (!spec || !spec.paths || !spec.paths[path]) {
61
+ return null;
62
+ }
63
+
64
+ const pathItem = spec.paths[path];
65
+ const lowerMethod = method.toLowerCase();
66
+ const operation = pathItem[lowerMethod];
67
+
68
+ if (!operation) {
69
+ return null;
70
+ }
71
+
72
+ // Merge parameters: path-level first, then operation-level overrides
73
+ const pathParams = pathItem.parameters || [];
74
+ const opParams = operation.parameters || [];
75
+
76
+ // Build a map keyed by name+in, path-level first, operation overrides
77
+ const paramMap = new Map();
78
+ for (const param of pathParams) {
79
+ const key = `${param.name}:${param.in}`;
80
+ paramMap.set(key, { ...param });
81
+ }
82
+ for (const param of opParams) {
83
+ const key = `${param.name}:${param.in}`;
84
+ paramMap.set(key, { ...param });
85
+ }
86
+
87
+ const mergedParams = Array.from(paramMap.values());
88
+
89
+ // Build request_body
90
+ let requestBody = null;
91
+ if (operation.requestBody) {
92
+ requestBody = {
93
+ required: operation.requestBody.required || false,
94
+ content: operation.requestBody.content || {},
95
+ };
96
+ }
97
+
98
+ // Build responses
99
+ const responses = {};
100
+ if (operation.responses) {
101
+ for (const [statusCode, responseDef] of Object.entries(operation.responses)) {
102
+ responses[statusCode] = {
103
+ description: responseDef.description || '',
104
+ };
105
+ if (responseDef.content) {
106
+ responses[statusCode].content = responseDef.content;
107
+ }
108
+ }
109
+ }
110
+
111
+ // Resolve servers: operation-level > path-level > spec-level
112
+ let servers = [];
113
+ if (operation.servers && operation.servers.length > 0) {
114
+ servers = operation.servers;
115
+ } else if (pathItem.servers && pathItem.servers.length > 0) {
116
+ servers = pathItem.servers;
117
+ } else if (spec.servers && spec.servers.length > 0) {
118
+ servers = spec.servers;
119
+ }
120
+
121
+ // For Swagger 2.0, construct servers from host + basePath + schemes
122
+ if (servers.length === 0 && spec.swagger && spec.host) {
123
+ const scheme = (spec.schemes && spec.schemes[0]) || 'https';
124
+ const basePath = spec.basePath || '';
125
+ servers = [{
126
+ url: `${scheme}://${spec.host}${basePath}`,
127
+ description: 'Default server',
128
+ }];
129
+ }
130
+
131
+ return {
132
+ method: method.toUpperCase(),
133
+ path,
134
+ summary: operation.summary || '',
135
+ description: operation.description || '',
136
+ tags: operation.tags || [],
137
+ deprecated: operation.deprecated || false,
138
+ parameters: mergedParams,
139
+ request_body: requestBody,
140
+ responses,
141
+ servers,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Get all external file paths referenced via $ref in the spec.
147
+ *
148
+ * @param {string} specPath - Absolute path to the spec file
149
+ * @returns {Promise<string[]>} - Array of external file paths
150
+ */
151
+ export async function getRefDeps(specPath) {
152
+ const parser = new SwaggerParser();
153
+ await parser.resolve(specPath);
154
+ const allPaths = parser.$refs.paths();
155
+
156
+ // Filter out the spec file itself and return only external refs.
157
+ // $refs.paths() returns file URLs or absolute paths depending on platform.
158
+ return allPaths.filter(p => {
159
+ let normalized = p;
160
+ let specNormalized = specPath;
161
+
162
+ // Handle file:// URLs
163
+ if (p.startsWith('file://')) {
164
+ try { normalized = new URL(p).pathname; } catch { /* keep as-is */ }
165
+ }
166
+ if (specPath.startsWith('file://')) {
167
+ try { specNormalized = new URL(specPath).pathname; } catch { /* keep as-is */ }
168
+ }
169
+
170
+ return normalized !== specNormalized;
171
+ });
172
+ }
package/lib/index.cjs ADDED
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ // CJS compatibility wrapper for ESM-only core modules.
4
+ //
5
+ // Usage in CommonJS projects:
6
+ //
7
+ // const apigrip = await require('apigrip');
8
+ // const { spec, endpoints } = await apigrip.loadSpec('./my-api/');
9
+ //
10
+ // Or with .then():
11
+ //
12
+ // require('apigrip').then(({ loadSpec, send }) => {
13
+ // // ...
14
+ // });
15
+
16
+ module.exports = import('./index.js');