@hanzo/runtime 0.0.0-dev

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.
@@ -0,0 +1,245 @@
1
+ /*
2
+ * Copyright 2025 Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { CompletionList, LspSymbol, ToolboxApi } from '@daytonaio/api-client'
7
+ import { prefixRelativePath } from './utils/Path'
8
+
9
+ /**
10
+ * Supported language server types.
11
+ */
12
+ export enum LspLanguageId {
13
+ PYTHON = 'python',
14
+ TYPESCRIPT = 'typescript',
15
+ JAVASCRIPT = 'javascript',
16
+ }
17
+
18
+ /**
19
+ * Represents a zero-based position within a text document,
20
+ * specified by line number and character offset.
21
+ *
22
+ * @interface
23
+ * @property {number} line - Zero-based line number in the document
24
+ * @property {number} character - Zero-based character offset on the line
25
+ *
26
+ * @example
27
+ * const position: Position = {
28
+ * line: 10, // Line 11 (zero-based)
29
+ * character: 15 // Character 16 on the line (zero-based)
30
+ * };
31
+ */
32
+ export type Position = {
33
+ /** Zero-based line number */
34
+ line: number
35
+ /** Zero-based character offset */
36
+ character: number
37
+ }
38
+
39
+ /**
40
+ * Provides Language Server Protocol functionality for code intelligence to provide
41
+ * IDE-like features such as code completion, symbol search, and more.
42
+ *
43
+ * @property {LspLanguageId} languageId - The language server type (e.g., "typescript")
44
+ * @property {string} pathToProject - Absolute path to the project root directory
45
+ * @property {ToolboxApi} toolboxApi - API client for Sandbox operations
46
+ * @property {SandboxInstance} instance - The Sandbox instance this server belongs to
47
+ *
48
+ * @class
49
+ */
50
+ export class LspServer {
51
+ constructor(
52
+ private readonly languageId: LspLanguageId,
53
+ private readonly pathToProject: string,
54
+ private readonly toolboxApi: ToolboxApi,
55
+ private readonly sandboxId: string,
56
+ ) {
57
+ if (!Object.values(LspLanguageId).includes(this.languageId)) {
58
+ throw new Error(
59
+ `Invalid languageId: ${this.languageId}. Supported values are: ${Object.values(LspLanguageId).join(', ')}`,
60
+ )
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Starts the language server, must be called before using any other LSP functionality.
66
+ * It initializes the language server for the specified language and project.
67
+ *
68
+ * @returns {Promise<void>}
69
+ *
70
+ * @example
71
+ * const lsp = await sandbox.createLspServer('typescript', 'workspace/project');
72
+ * await lsp.start(); // Initialize the server
73
+ * // Now ready for LSP operations
74
+ */
75
+ public async start(): Promise<void> {
76
+ await this.toolboxApi.lspStart(this.sandboxId, {
77
+ languageId: this.languageId,
78
+ pathToProject: this.pathToProject,
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Stops the language server, should be called when the LSP server is no longer needed to
84
+ * free up system resources.
85
+ *
86
+ * @returns {Promise<void>}
87
+ *
88
+ * @example
89
+ * // When done with LSP features
90
+ * await lsp.stop(); // Clean up resources
91
+ */
92
+ public async stop(): Promise<void> {
93
+ await this.toolboxApi.lspStop(this.sandboxId, {
94
+ languageId: this.languageId,
95
+ pathToProject: this.pathToProject,
96
+ })
97
+ }
98
+
99
+ /**
100
+ * Notifies the language server that a file has been opened, enabling
101
+ * language features like diagnostics and completions for that file. The server
102
+ * will begin tracking the file's contents and providing language features.
103
+ *
104
+ * @param {string} path - Path to the opened file. Relative paths are resolved based on the user's
105
+ * root directory.
106
+ * @returns {Promise<void>}
107
+ *
108
+ * @example
109
+ * // When opening a file for editing
110
+ * await lsp.didOpen('workspace/project/src/index.ts');
111
+ * // Now can get completions, symbols, etc. for this file
112
+ */
113
+ public async didOpen(path: string): Promise<void> {
114
+ await this.toolboxApi.lspDidOpen(this.sandboxId, {
115
+ languageId: this.languageId,
116
+ pathToProject: this.pathToProject,
117
+ uri: 'file://' + prefixRelativePath(this.pathToProject, path),
118
+ })
119
+ }
120
+
121
+ /**
122
+ * Notifies the language server that a file has been closed, should be called when a file is closed
123
+ * in the editor to allow the language server to clean up any resources associated with that file.
124
+ *
125
+ * @param {string} path - Path to the closed file. Relative paths are resolved based on the project path
126
+ * set in the LSP server constructor.
127
+ * @returns {Promise<void>}
128
+ *
129
+ * @example
130
+ * // When done editing a file
131
+ * await lsp.didClose('workspace/project/src/index.ts');
132
+ */
133
+ public async didClose(path: string): Promise<void> {
134
+ await this.toolboxApi.lspDidClose(this.sandboxId, {
135
+ languageId: this.languageId,
136
+ pathToProject: this.pathToProject,
137
+ uri: 'file://' + prefixRelativePath(this.pathToProject, path),
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Get symbol information (functions, classes, variables, etc.) from a document.
143
+ *
144
+ * @param {string} path - Path to the file to get symbols from. Relative paths are resolved based on the project path
145
+ * set in the LSP server constructor.
146
+ * @returns {Promise<LspSymbol[]>} List of symbols in the document. Each symbol includes:
147
+ * - name: The symbol's name
148
+ * - kind: The symbol's kind (function, class, variable, etc.)
149
+ * - location: The location of the symbol in the file
150
+ *
151
+ * @example
152
+ * // Get all symbols in a file
153
+ * const symbols = await lsp.documentSymbols('workspace/project/src/index.ts');
154
+ * symbols.forEach(symbol => {
155
+ * console.log(`${symbol.kind} ${symbol.name}: ${symbol.location}`);
156
+ * });
157
+ */
158
+ public async documentSymbols(path: string): Promise<LspSymbol[]> {
159
+ const response = await this.toolboxApi.lspDocumentSymbols(
160
+ this.sandboxId,
161
+ this.languageId,
162
+ this.pathToProject,
163
+ 'file://' + prefixRelativePath(this.pathToProject, path),
164
+ )
165
+ return response.data
166
+ }
167
+
168
+ /**
169
+ * Searches for symbols matching the query string across the entire Sandbox.
170
+ *
171
+ * @param {string} query - Search query to match against symbol names
172
+ * @returns {Promise<LspSymbol[]>} List of matching symbols from all files. Each symbol includes:
173
+ * - name: The symbol's name
174
+ * - kind: The symbol's kind (function, class, variable, etc.)
175
+ * - location: The location of the symbol in the file
176
+ *
177
+ * @deprecated Use `sandboxSymbols` instead. This method will be removed in a future version.
178
+ */
179
+ public async workspaceSymbols(query: string): Promise<LspSymbol[]> {
180
+ return await this.sandboxSymbols(query)
181
+ }
182
+
183
+ /**
184
+ * Searches for symbols matching the query string across the entire Sandbox.
185
+ *
186
+ * @param {string} query - Search query to match against symbol names
187
+ * @returns {Promise<LspSymbol[]>} List of matching symbols from all files. Each symbol includes:
188
+ * - name: The symbol's name
189
+ * - kind: The symbol's kind (function, class, variable, etc.)
190
+ * - location: The location of the symbol in the file
191
+ *
192
+ * @example
193
+ * // Search for all symbols containing "User"
194
+ * const symbols = await lsp.sandboxSymbols('User');
195
+ * symbols.forEach(symbol => {
196
+ * console.log(`${symbol.name} (${symbol.kind}) in ${symbol.location}`);
197
+ * });
198
+ */
199
+ public async sandboxSymbols(query: string): Promise<LspSymbol[]> {
200
+ const response = await this.toolboxApi.lspWorkspaceSymbols(
201
+ this.sandboxId,
202
+ this.languageId,
203
+ this.pathToProject,
204
+ query,
205
+ )
206
+ return response.data
207
+ }
208
+
209
+ /**
210
+ * Gets completion suggestions at a position in a file.
211
+ *
212
+ * @param {string} path - Path to the file. Relative paths are resolved based on the project path
213
+ * set in the LSP server constructor.
214
+ * @param {Position} position - The position in the file where completion was requested
215
+ * @returns {Promise<CompletionList>} List of completion suggestions. The list includes:
216
+ * - isIncomplete: Whether more items might be available
217
+ * - items: List of completion items, each containing:
218
+ * - label: The text to insert
219
+ * - kind: The kind of completion
220
+ * - detail: Additional details about the item
221
+ * - documentation: Documentation for the item
222
+ * - sortText: Text used to sort the item in the list
223
+ * - filterText: Text used to filter the item
224
+ * - insertText: The actual text to insert (if different from label)
225
+ *
226
+ * @example
227
+ * // Get completions at a specific position
228
+ * const completions = await lsp.completions('workspace/project/src/index.ts', {
229
+ * line: 10,
230
+ * character: 15
231
+ * });
232
+ * completions.items.forEach(item => {
233
+ * console.log(`${item.label} (${item.kind}): ${item.detail}`);
234
+ * });
235
+ */
236
+ public async completions(path: string, position: Position): Promise<CompletionList> {
237
+ const response = await this.toolboxApi.lspCompletions(this.sandboxId, {
238
+ languageId: this.languageId,
239
+ pathToProject: this.pathToProject,
240
+ uri: 'file://' + prefixRelativePath(this.pathToProject, path),
241
+ position,
242
+ })
243
+ return response.data
244
+ }
245
+ }
@@ -0,0 +1,232 @@
1
+ /*
2
+ * Copyright 2025 Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
7
+ import { Upload } from '@aws-sdk/lib-storage'
8
+ import * as crypto from 'crypto'
9
+ import * as fs from 'fs'
10
+ import * as path from 'path'
11
+ import * as tar from 'tar'
12
+ import { PassThrough } from 'stream'
13
+ import { DaytonaError } from './errors/DaytonaError'
14
+
15
+ /**
16
+ * Configuration for the ObjectStorage class.
17
+ *
18
+ * @interface
19
+ * @property {string} endpointUrl - The endpoint URL for the object storage service.
20
+ * @property {string} accessKeyId - The access key ID for the object storage service.
21
+ * @property {string} secretAccessKey - The secret access key for the object storage service.
22
+ * @property {string} [sessionToken] - The session token for the object storage service. Used for temporary credentials.
23
+ * @property {string} [bucketName] - The name of the bucket to use.
24
+ */
25
+ export interface ObjectStorageConfig {
26
+ endpointUrl: string
27
+ accessKeyId: string
28
+ secretAccessKey: string
29
+ sessionToken?: string
30
+ bucketName?: string
31
+ }
32
+
33
+ /**
34
+ * ObjectStorage class for interacting with object storage services.
35
+ *
36
+ * @class
37
+ * @param {ObjectStorageConfig} config - The configuration for the object storage service.
38
+ */
39
+ export class ObjectStorage {
40
+ private bucketName: string
41
+ private s3Client: S3Client
42
+
43
+ constructor(config: ObjectStorageConfig) {
44
+ this.bucketName = config.bucketName || 'daytona-volume-builds'
45
+ this.s3Client = new S3Client({
46
+ region: this.extractAwsRegion(config.endpointUrl) || 'us-east-1',
47
+ endpoint: config.endpointUrl,
48
+ credentials: {
49
+ accessKeyId: config.accessKeyId,
50
+ secretAccessKey: config.secretAccessKey,
51
+ sessionToken: config.sessionToken,
52
+ },
53
+ forcePathStyle: true,
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Upload a file or directory to object storage.
59
+ *
60
+ * @param {string} path - The path to the file or directory to upload.
61
+ * @param {string} organizationId - The organization ID to use for the upload.
62
+ * @param {string} archiveBasePath - The base path to use for the archive.
63
+ * @returns {Promise<string>} The hash of the uploaded file or directory.
64
+ */
65
+ async upload(path: string, organizationId: string, archiveBasePath: string): Promise<string> {
66
+ if (!fs.existsSync(path)) {
67
+ const errMsg = `Path does not exist: ${path}`
68
+ throw new DaytonaError(errMsg)
69
+ }
70
+
71
+ // Compute hash for the path
72
+ const pathHash = await this.computeHashForPathMd5(path, archiveBasePath)
73
+
74
+ // Define the S3 prefix
75
+ const prefix = `${organizationId}/${pathHash}/`
76
+ const s3Key = `${prefix}context.tar`
77
+
78
+ // Check if it already exists in S3
79
+ if (await this.folderExistsInS3(prefix)) {
80
+ return pathHash
81
+ }
82
+
83
+ // Upload to S3
84
+ await this.uploadAsTar(s3Key, path, archiveBasePath)
85
+
86
+ return pathHash
87
+ }
88
+
89
+ /**
90
+ * Compute a hash for a file or directory.
91
+ *
92
+ * @param {string} pathStr - The path to the file or directory to hash.
93
+ * @param {string} archiveBasePath - The base path to use for the archive.
94
+ * @returns {Promise<string>} The hash of the file or directory.
95
+ */
96
+ private async computeHashForPathMd5(pathStr: string, archiveBasePath: string): Promise<string> {
97
+ const md5Hasher = crypto.createHash('md5')
98
+ const absPathStr = path.resolve(pathStr)
99
+
100
+ md5Hasher.update(archiveBasePath)
101
+
102
+ if (fs.statSync(absPathStr).isFile()) {
103
+ // For files, hash the content
104
+ await this.hashFile(absPathStr, md5Hasher)
105
+ } else {
106
+ // For directories, recursively hash all files and their paths
107
+ await this.hashDirectory(absPathStr, pathStr, md5Hasher)
108
+ }
109
+
110
+ return md5Hasher.digest('hex')
111
+ }
112
+
113
+ /**
114
+ * Recursively hash a directory and its contents.
115
+ *
116
+ * @param {string} dirPath - The path to the directory to hash.
117
+ * @param {string} basePath - The base path to use for the hash.
118
+ * @param {crypto.Hash} hasher - The hasher to use for the hash.
119
+ * @returns {Promise<void>} A promise that resolves when the directory has been hashed.
120
+ */
121
+ private async hashDirectory(dirPath: string, basePath: string, hasher: crypto.Hash): Promise<void> {
122
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
123
+
124
+ const hasSubdirs = entries.some((e) => e.isDirectory())
125
+ const hasFiles = entries.some((e) => e.isFile())
126
+
127
+ if (!hasSubdirs && !hasFiles) {
128
+ // Empty directory
129
+ const relDir = path.relative(basePath, dirPath)
130
+ hasher.update(relDir)
131
+ }
132
+
133
+ for (const entry of entries) {
134
+ const fullPath = path.join(dirPath, entry.name)
135
+
136
+ if (entry.isDirectory()) {
137
+ await this.hashDirectory(fullPath, basePath, hasher)
138
+ } else if (entry.isFile()) {
139
+ const relPath = path.relative(basePath, fullPath)
140
+ hasher.update(relPath)
141
+
142
+ await this.hashFile(fullPath, hasher)
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Hash a file.
149
+ *
150
+ * @param {string} filePath - The path to the file to hash.
151
+ * @param {crypto.Hash} hasher - The hasher to use for the hash.
152
+ * @returns {Promise<void>} A promise that resolves when the file has been hashed.
153
+ */
154
+ private async hashFile(filePath: string, hasher: crypto.Hash): Promise<void> {
155
+ await new Promise<void>((resolve, reject) => {
156
+ const stream = fs.createReadStream(filePath, { highWaterMark: 8192 })
157
+ stream.on('data', (chunk) => hasher.update(chunk))
158
+ stream.on('end', resolve)
159
+ stream.on('error', reject)
160
+ })
161
+ }
162
+
163
+ /**
164
+ * Check if a prefix (folder) exists in S3.
165
+ *
166
+ * @param {string} prefix - The prefix to check.
167
+ * @returns {Promise<boolean>} True if the prefix exists, false otherwise.
168
+ */
169
+ private async folderExistsInS3(prefix: string): Promise<boolean> {
170
+ const response = await this.s3Client.send(
171
+ new ListObjectsV2Command({
172
+ Bucket: this.bucketName,
173
+ Prefix: prefix,
174
+ MaxKeys: 1,
175
+ }),
176
+ )
177
+
178
+ return !!response.Contents && response.Contents.length > 0
179
+ }
180
+
181
+ /**
182
+ * Create a tar archive of the specified path and upload it to S3.
183
+ *
184
+ * @param {string} s3Key - The key to use for the uploaded file.
185
+ * @param {string} sourcePath - The path to the file or directory to upload.
186
+ * @param {string} archiveBasePath - The base path to use for the archive.
187
+ */
188
+ private async uploadAsTar(s3Key: string, sourcePath: string, archiveBasePath: string) {
189
+ sourcePath = path.resolve(sourcePath)
190
+
191
+ const normalizedSourcePath = path.normalize(sourcePath)
192
+ const normalizedArchiveBasePath = path.normalize(archiveBasePath)
193
+
194
+ let basePrefix: string
195
+
196
+ if (normalizedArchiveBasePath === '.') {
197
+ // When archiveBasePath is empty (normalized to '.'), use the normalizedSourcePath as cwd and the '.' as target
198
+ basePrefix = normalizedSourcePath
199
+ } else {
200
+ // Normal case: extract the base prefix by removing archiveBasePath from the end
201
+ basePrefix = normalizedSourcePath.slice(0, normalizedSourcePath.length - normalizedArchiveBasePath.length)
202
+ }
203
+
204
+ const tarStream = tar.create(
205
+ {
206
+ cwd: basePrefix,
207
+ portable: true,
208
+ gzip: false,
209
+ },
210
+ [normalizedArchiveBasePath],
211
+ )
212
+
213
+ const pass = new PassThrough()
214
+ tarStream.pipe(pass)
215
+
216
+ const uploader = new Upload({
217
+ client: this.s3Client,
218
+ params: {
219
+ Bucket: this.bucketName,
220
+ Key: s3Key,
221
+ Body: pass,
222
+ },
223
+ })
224
+
225
+ await uploader.done()
226
+ }
227
+
228
+ private extractAwsRegion(endpoint: string): string | undefined {
229
+ const match = endpoint.match(/s3[.-]([a-z0-9-]+)\.amazonaws\.com/)
230
+ return match?.[1]
231
+ }
232
+ }