Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/gitpod-protocol/src/gitpod-service.ts
2498 views
1
/**
2
* Copyright (c) 2020 Gitpod GmbH. All rights reserved.
3
* Licensed under the GNU Affero General Public License (AGPL).
4
* See License.AGPL.txt in the project root for license information.
5
*/
6
7
import parse from "parse-duration";
8
import {
9
User,
10
WorkspaceInfo,
11
WorkspaceCreationResult,
12
WorkspaceInstanceUser,
13
WorkspaceImageBuild,
14
AuthProviderInfo,
15
Token,
16
UserEnvVarValue,
17
Configuration,
18
UserInfo,
19
GitpodTokenType,
20
GitpodToken,
21
AuthProviderEntry,
22
GuessGitTokenScopesParams,
23
GuessedGitTokenScopes,
24
ProjectEnvVar,
25
PrebuiltWorkspace,
26
UserSSHPublicKeyValue,
27
SSHPublicKeyValue,
28
IDESettings,
29
EnvVarWithValue,
30
WorkspaceTimeoutSetting,
31
WorkspaceContext,
32
LinkedInProfile,
33
SuggestedRepository,
34
} from "./protocol";
35
import {
36
Team,
37
TeamMemberInfo,
38
TeamMembershipInvite,
39
Project,
40
TeamMemberRole,
41
PrebuildWithStatus,
42
StartPrebuildResult,
43
PartialProject,
44
OrganizationSettings,
45
} from "./teams-projects-protocol";
46
import { JsonRpcProxy, JsonRpcServer } from "./messaging/proxy-factory";
47
import { Disposable, CancellationTokenSource, CancellationToken } from "vscode-jsonrpc";
48
import { HeadlessLogUrls } from "./headless-workspace-log";
49
import {
50
WorkspaceInstance,
51
WorkspaceInstancePort,
52
WorkspaceInstancePhase,
53
WorkspaceInstanceRepoStatus,
54
} from "./workspace-instance";
55
import { AdminServer } from "./admin-protocol";
56
import { Emitter } from "./util/event";
57
import { RemotePageMessage, RemoteTrackMessage, RemoteIdentifyMessage } from "./analytics";
58
import { IDEServer } from "./ide-protocol";
59
import { ListUsageRequest, ListUsageResponse, CostCenterJSON } from "./usage";
60
import { SupportedWorkspaceClass } from "./workspace-class";
61
import { BillingMode } from "./billing-mode";
62
import { WorkspaceRegion } from "./workspace-cluster";
63
64
export interface GitpodClient {
65
onInstanceUpdate(instance: WorkspaceInstance): void;
66
onWorkspaceImageBuildLogs: WorkspaceImageBuild.LogCallback;
67
68
onPrebuildUpdate(update: PrebuildWithStatus): void;
69
70
//#region propagating reconnection to iframe
71
notifyDidOpenConnection(): void;
72
notifyDidCloseConnection(): void;
73
//#endregion
74
}
75
76
export const GitpodServer = Symbol("GitpodServer");
77
export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer, IDEServer {
78
// User related API
79
getLoggedInUser(): Promise<User>;
80
updateLoggedInUser(user: Partial<User>): Promise<User>;
81
sendPhoneNumberVerificationToken(phoneNumber: string): Promise<{ verificationId: string }>;
82
verifyPhoneNumberVerificationToken(phoneNumber: string, token: string, verificationId: string): Promise<boolean>;
83
getConfiguration(): Promise<Configuration>;
84
getToken(query: GitpodServer.GetTokenSearchOptions): Promise<Token | undefined>;
85
getGitpodTokenScopes(tokenHash: string): Promise<string[]>;
86
deleteAccount(): Promise<void>;
87
getClientRegion(): Promise<string | undefined>;
88
89
// Auth Provider API
90
getAuthProviders(): Promise<AuthProviderInfo[]>;
91
// user-level
92
getOwnAuthProviders(): Promise<AuthProviderEntry[]>;
93
updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise<AuthProviderEntry>;
94
deleteOwnAuthProvider(params: GitpodServer.DeleteOwnAuthProviderParams): Promise<void>;
95
// org-level
96
createOrgAuthProvider(params: GitpodServer.CreateOrgAuthProviderParams): Promise<AuthProviderEntry>;
97
updateOrgAuthProvider(params: GitpodServer.UpdateOrgAuthProviderParams): Promise<AuthProviderEntry>;
98
getOrgAuthProviders(params: GitpodServer.GetOrgAuthProviderParams): Promise<AuthProviderEntry[]>;
99
deleteOrgAuthProvider(params: GitpodServer.DeleteOrgAuthProviderParams): Promise<void>;
100
// public-api compatibility
101
/** @deprecated used for public-api compatibility only */
102
getAuthProvider(id: string): Promise<AuthProviderEntry>;
103
/** @deprecated used for public-api compatibility only */
104
deleteAuthProvider(id: string): Promise<void>;
105
/** @deprecated used for public-api compatibility only */
106
updateAuthProvider(id: string, update: AuthProviderEntry.UpdateOAuth2Config): Promise<AuthProviderEntry>;
107
108
// Query/retrieve workspaces
109
getWorkspaces(options: GitpodServer.GetWorkspacesOptions): Promise<WorkspaceInfo[]>;
110
getWorkspaceOwner(workspaceId: string): Promise<UserInfo | undefined>;
111
getWorkspaceUsers(workspaceId: string): Promise<WorkspaceInstanceUser[]>;
112
getSuggestedRepositories(organizationId: string): Promise<SuggestedRepository[]>;
113
searchRepositories(params: SearchRepositoriesParams): Promise<SuggestedRepository[]>;
114
/**
115
* **Security:**
116
* Sensitive information like an owner token is erased, since it allows access for all team members.
117
* If you need to access an owner token use `getOwnerToken` instead.
118
*/
119
getWorkspace(id: string): Promise<WorkspaceInfo>;
120
isWorkspaceOwner(workspaceId: string): Promise<boolean>;
121
getOwnerToken(workspaceId: string): Promise<string>;
122
getIDECredentials(workspaceId: string): Promise<string>;
123
124
/**
125
* Creates and starts a workspace for the given context URL.
126
* @param options GitpodServer.CreateWorkspaceOptions
127
* @return WorkspaceCreationResult
128
*/
129
createWorkspace(options: GitpodServer.CreateWorkspaceOptions): Promise<WorkspaceCreationResult>;
130
startWorkspace(id: string, options: GitpodServer.StartWorkspaceOptions): Promise<StartWorkspaceResult>;
131
stopWorkspace(id: string): Promise<void>;
132
deleteWorkspace(id: string): Promise<void>;
133
setWorkspaceDescription(id: string, desc: string): Promise<void>;
134
controlAdmission(id: string, level: GitpodServer.AdmissionLevel): Promise<void>;
135
resolveContext(contextUrl: string): Promise<WorkspaceContext>;
136
137
updateWorkspaceUserPin(id: string, action: GitpodServer.PinAction): Promise<void>;
138
sendHeartBeat(options: GitpodServer.SendHeartBeatOptions): Promise<void>;
139
watchWorkspaceImageBuildLogs(workspaceId: string): Promise<void>;
140
isPrebuildDone(pwsid: string): Promise<boolean>;
141
getHeadlessLog(instanceId: string): Promise<HeadlessLogUrls>;
142
143
// Workspace timeout
144
setWorkspaceTimeout(workspaceId: string, duration: WorkspaceTimeoutDuration): Promise<SetWorkspaceTimeoutResult>;
145
getWorkspaceTimeout(workspaceId: string): Promise<GetWorkspaceTimeoutResult>;
146
147
// Port management
148
getOpenPorts(workspaceId: string): Promise<WorkspaceInstancePort[]>;
149
openPort(workspaceId: string, port: WorkspaceInstancePort): Promise<WorkspaceInstancePort | undefined>;
150
closePort(workspaceId: string, port: number): Promise<void>;
151
152
updateGitStatus(workspaceId: string, status: Required<WorkspaceInstanceRepoStatus> | undefined): Promise<void>;
153
154
// Workspace env vars
155
getWorkspaceEnvVars(workspaceId: string): Promise<EnvVarWithValue[]>;
156
157
// User env vars
158
getAllEnvVars(): Promise<UserEnvVarValue[]>;
159
setEnvVar(variable: UserEnvVarValue): Promise<void>;
160
deleteEnvVar(variable: UserEnvVarValue): Promise<void>;
161
162
// User SSH Keys
163
hasSSHPublicKey(): Promise<boolean>;
164
getSSHPublicKeys(): Promise<UserSSHPublicKeyValue[]>;
165
addSSHPublicKey(value: SSHPublicKeyValue): Promise<UserSSHPublicKeyValue>;
166
deleteSSHPublicKey(id: string): Promise<void>;
167
168
// Teams
169
getTeam(teamId: string): Promise<Team>;
170
updateTeam(
171
teamId: string,
172
team: Partial<Pick<Team, "name" | "maintenanceMode" | "maintenanceNotification">>,
173
): Promise<Team>;
174
getTeams(): Promise<Team[]>;
175
getTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
176
createTeam(name: string): Promise<Team>;
177
joinTeam(inviteId: string): Promise<Team>;
178
setTeamMemberRole(teamId: string, userId: string, role: TeamMemberRole): Promise<void>;
179
removeTeamMember(teamId: string, userId: string): Promise<void>;
180
getGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
181
resetGenericInvite(inviteId: string): Promise<TeamMembershipInvite>;
182
deleteTeam(teamId: string): Promise<void>;
183
getOrgSettings(orgId: string): Promise<OrganizationSettings>;
184
updateOrgSettings(teamId: string, settings: Partial<OrganizationSettings>): Promise<OrganizationSettings>;
185
getOrgWorkspaceClasses(orgId: string): Promise<SupportedWorkspaceClass[]>;
186
187
getDefaultWorkspaceImage(params: GetDefaultWorkspaceImageParams): Promise<GetDefaultWorkspaceImageResult>;
188
189
// Dedicated, Dedicated, Dedicated
190
getOnboardingState(): Promise<GitpodServer.OnboardingState>;
191
192
// Projects
193
/** @deprecated no-op */
194
getProviderRepositoriesForUser(
195
params: GetProviderRepositoriesParams,
196
cancellationToken?: CancellationToken,
197
): Promise<ProviderRepository[]>;
198
createProject(params: CreateProjectParams): Promise<Project>;
199
deleteProject(projectId: string): Promise<void>;
200
getTeamProjects(teamId: string): Promise<Project[]>;
201
getProjectOverview(projectId: string): Promise<Project.Overview | undefined>;
202
findPrebuilds(params: FindPrebuildsParams): Promise<PrebuildWithStatus[]>;
203
findPrebuildByWorkspaceID(workspaceId: string): Promise<PrebuiltWorkspace | undefined>;
204
getPrebuild(prebuildId: string): Promise<PrebuildWithStatus | undefined>;
205
triggerPrebuild(projectId: string, branchName: string | null): Promise<StartPrebuildResult>;
206
cancelPrebuild(projectId: string, prebuildId: string): Promise<void>;
207
updateProjectPartial(partialProject: PartialProject): Promise<void>;
208
setProjectEnvironmentVariable(
209
projectId: string,
210
name: string,
211
value: string,
212
censored: boolean,
213
id?: string,
214
): Promise<void>;
215
getProjectEnvironmentVariables(projectId: string): Promise<ProjectEnvVar[]>;
216
deleteProjectEnvironmentVariable(variableId: string): Promise<void>;
217
218
// Gitpod token
219
getGitpodTokens(): Promise<GitpodToken[]>;
220
generateNewGitpodToken(options: GitpodServer.GenerateNewGitpodTokenOptions): Promise<string>;
221
deleteGitpodToken(tokenHash: string): Promise<void>;
222
223
// misc
224
/** @deprecated always returns false */
225
isGitHubAppEnabled(): Promise<boolean>;
226
/** @deprecated this is a no-op */
227
registerGithubApp(installationId: string): Promise<void>;
228
229
/**
230
* Stores a new snapshot for the given workspace and bucketId. Returns _before_ the actual snapshot is done. To wait for that, use `waitForSnapshot`.
231
* @return the snapshot id
232
*/
233
takeSnapshot(options: GitpodServer.TakeSnapshotOptions): Promise<string>;
234
/**
235
*
236
* @param snapshotId
237
*/
238
waitForSnapshot(snapshotId: string): Promise<void>;
239
240
/**
241
* Returns the list of snapshots that exist for a workspace.
242
*/
243
getSnapshots(workspaceID: string): Promise<string[]>;
244
245
guessGitTokenScopes(params: GuessGitTokenScopesParams): Promise<GuessedGitTokenScopes>;
246
247
/**
248
* Stripe/Usage
249
*/
250
getStripePublishableKey(): Promise<string>;
251
findStripeSubscriptionId(attributionId: string): Promise<string | undefined>;
252
getPriceInformation(attributionId: string): Promise<string | undefined>;
253
createStripeCustomerIfNeeded(attributionId: string, currency: string): Promise<void>;
254
createHoldPaymentIntent(
255
attributionId: string,
256
): Promise<{ paymentIntentId: string; paymentIntentClientSecret: string }>;
257
subscribeToStripe(attributionId: string, paymentIntentId: string, usageLimit: number): Promise<number | undefined>;
258
getStripePortalUrl(attributionId: string): Promise<string>;
259
getCostCenter(attributionId: string): Promise<CostCenterJSON | undefined>;
260
setUsageLimit(attributionId: string, usageLimit: number): Promise<void>;
261
getUsageBalance(attributionId: string): Promise<number>;
262
isCustomerBillingAddressInvalid(attributionId: string): Promise<boolean>;
263
264
listUsage(req: ListUsageRequest): Promise<ListUsageResponse>;
265
266
getBillingModeForTeam(teamId: string): Promise<BillingMode>;
267
268
getLinkedInClientId(): Promise<string>;
269
connectWithLinkedIn(code: string): Promise<LinkedInProfile>;
270
271
/**
272
* Analytics
273
*/
274
trackEvent(event: RemoteTrackMessage): Promise<void>;
275
trackLocation(event: RemotePageMessage): Promise<void>;
276
identifyUser(event: RemoteIdentifyMessage): Promise<void>;
277
278
/**
279
* Frontend metrics
280
*/
281
reportErrorBoundary(url: string, message: string): Promise<void>;
282
283
getSupportedWorkspaceClasses(): Promise<SupportedWorkspaceClass[]>;
284
updateWorkspaceTimeoutSetting(setting: Partial<WorkspaceTimeoutSetting>): Promise<void>;
285
286
/**
287
* getIDToken - doesn't actually do anything, just used to authenticat/authorise
288
*/
289
getIDToken(): Promise<void>;
290
}
291
292
export interface RateLimiterError {
293
method?: string;
294
295
/**
296
* Retry after this many seconds, earliest.
297
* cmp.: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
298
*/
299
retryAfter: number;
300
}
301
302
export interface GetDefaultWorkspaceImageParams {
303
// filter with workspaceId (actually we will find with organizationId, and it's a real time finding)
304
workspaceId?: string;
305
}
306
307
export type DefaultImageSource =
308
| "installation" // Source installation means the image comes from Gitpod instance install config
309
| "organization"; // Source organization means the image comes from Organization settings
310
311
export interface GetDefaultWorkspaceImageResult {
312
image: string;
313
source: DefaultImageSource;
314
}
315
316
export interface CreateProjectParams {
317
name: string;
318
cloneUrl: string;
319
teamId: string;
320
appInstallationId: string;
321
}
322
export interface FindPrebuildsParams {
323
projectId: string;
324
branch?: string;
325
latest?: boolean;
326
prebuildId?: string;
327
// default: 30
328
limit?: number;
329
}
330
export interface GetProviderRepositoriesParams {
331
provider: string;
332
hints?: { installationId: string } | object;
333
searchString?: string;
334
limit?: number;
335
maxPages?: number;
336
}
337
export interface SearchRepositoriesParams {
338
/** @deprecated unused */
339
organizationId?: string;
340
searchString: string;
341
limit?: number; // defaults to 30
342
}
343
export interface ProviderRepository {
344
name: string;
345
path?: string;
346
account: string;
347
accountAvatarUrl: string;
348
cloneUrl: string;
349
updatedAt?: string;
350
installationId?: number;
351
installationUpdatedAt?: string;
352
}
353
354
const WORKSPACE_MAXIMUM_TIMEOUT_HOURS = 24;
355
356
export type WorkspaceTimeoutDuration = string;
357
export namespace WorkspaceTimeoutDuration {
358
export function validate(duration: string): WorkspaceTimeoutDuration {
359
duration = duration.toLowerCase();
360
361
try {
362
// Ensure the duration contains proper units (h, m, s, ms, us, ns)
363
// This prevents bare numbers like "1" from being accepted
364
if (!/[a-z]/.test(duration)) {
365
throw new Error("Invalid duration format");
366
}
367
368
// Use parse-duration library which supports Go duration format perfectly
369
// This handles mixed-unit durations like "1h30m", "2h15m", etc.
370
const milliseconds = parse(duration);
371
372
if (milliseconds === undefined || milliseconds === null) {
373
throw new Error("Invalid duration format");
374
}
375
376
// Validate the parsed duration is within limits
377
const maxMs = WORKSPACE_MAXIMUM_TIMEOUT_HOURS * 60 * 60 * 1000;
378
if (milliseconds > maxMs) {
379
throw new Error("Workspace inactivity timeout cannot exceed 24h");
380
}
381
382
if (milliseconds <= 0) {
383
throw new Error(`Invalid timeout value: ${duration}. Timeout must be greater than 0`);
384
}
385
386
// Return the original duration string - Go's time.ParseDuration will handle it correctly
387
return duration;
388
} catch (error) {
389
// If it's our validation error, re-throw it
390
if (error.message.includes("cannot exceed 24h") || error.message.includes("must be greater than 0")) {
391
throw error;
392
}
393
// Otherwise, it's a parsing error from the library
394
throw new Error(`Invalid timeout format: ${duration}. Use Go duration format (e.g., "30m", "1h30m", "2h")`);
395
}
396
}
397
}
398
399
export const WORKSPACE_TIMEOUT_DEFAULT_SHORT: WorkspaceTimeoutDuration = "30m";
400
export const WORKSPACE_TIMEOUT_DEFAULT_LONG: WorkspaceTimeoutDuration = "60m";
401
export const WORKSPACE_TIMEOUT_EXTENDED: WorkspaceTimeoutDuration = "180m";
402
export const WORKSPACE_LIFETIME_SHORT: WorkspaceTimeoutDuration = "8h";
403
export const WORKSPACE_LIFETIME_LONG: WorkspaceTimeoutDuration = "36h";
404
405
export const MAX_PARALLEL_WORKSPACES_FREE = 4;
406
export const MAX_PARALLEL_WORKSPACES_PAID = 16;
407
408
export const createServiceMock = function <C extends GitpodClient, S extends GitpodServer>(
409
methods: Partial<JsonRpcProxy<S>>,
410
): GitpodServiceImpl<C, S> {
411
return new GitpodServiceImpl<C, S>(createServerMock(methods));
412
};
413
414
export const createServerMock = function <S extends GitpodServer>(methods: Partial<JsonRpcProxy<S>>): JsonRpcProxy<S> {
415
methods.setClient = methods.setClient || (() => {});
416
methods.dispose = methods.dispose || (() => {});
417
return new Proxy<JsonRpcProxy<S>>(methods as any as JsonRpcProxy<S>, {
418
// @ts-ignore
419
get: (target: S, property: keyof S) => {
420
const result = target[property];
421
if (!result) {
422
throw new Error(`Method ${String(property)} not implemented`);
423
}
424
return result;
425
},
426
});
427
};
428
429
export interface SetWorkspaceTimeoutResult {
430
resetTimeoutOnWorkspaces: string[];
431
humanReadableDuration: string;
432
}
433
434
export interface GetWorkspaceTimeoutResult {
435
duration: WorkspaceTimeoutDuration;
436
canChange: boolean;
437
humanReadableDuration: string;
438
}
439
440
export interface StartWorkspaceResult {
441
instanceID: string;
442
workspaceURL?: string;
443
}
444
445
export namespace GitpodServer {
446
export interface GetWorkspacesOptions {
447
limit?: number;
448
searchString?: string;
449
pinnedOnly?: boolean;
450
projectId?: string | string[];
451
includeWithoutProject?: boolean;
452
organizationId?: string;
453
}
454
export interface GetAccountStatementOptions {
455
date?: string;
456
}
457
export interface CreateWorkspaceOptions extends StartWorkspaceOptions {
458
contextUrl: string;
459
organizationId: string;
460
projectId?: string;
461
462
// whether running workspaces on the same context should be ignored. If false (default) users will be asked.
463
//TODO(se) remove this option and let clients do that check if they like. The new create workspace page does it already
464
ignoreRunningWorkspaceOnSameCommit?: boolean;
465
forceDefaultConfig?: boolean;
466
}
467
468
export interface StartWorkspaceOptions {
469
//TODO(cw): none of these options can be changed for a workspace that's been created. Should be moved to CreateWorkspaceOptions.
470
forceDefaultImage?: boolean;
471
workspaceClass?: string;
472
ideSettings?: IDESettings;
473
region?: WorkspaceRegion;
474
}
475
export interface TakeSnapshotOptions {
476
workspaceId: string;
477
/* this is here to enable backwards-compatibility and untangling rollout between workspace, IDE and meta */
478
dontWait?: boolean;
479
}
480
export interface GetTokenSearchOptions {
481
readonly host: string;
482
}
483
export interface SendHeartBeatOptions {
484
readonly instanceId: string;
485
readonly wasClosed?: boolean;
486
readonly roundTripTime?: number;
487
}
488
export interface UpdateOwnAuthProviderParams {
489
readonly entry: AuthProviderEntry.UpdateEntry | AuthProviderEntry.NewEntry;
490
}
491
export interface DeleteOwnAuthProviderParams {
492
readonly id: string;
493
}
494
export interface CreateOrgAuthProviderParams {
495
// ownerId is automatically set to the authenticated user
496
readonly entry: Omit<AuthProviderEntry.NewOrgEntry, "ownerId">;
497
}
498
export interface UpdateOrgAuthProviderParams {
499
readonly entry: AuthProviderEntry.UpdateOrgEntry;
500
}
501
export interface GetOrgAuthProviderParams {
502
readonly organizationId: string;
503
}
504
export interface DeleteOrgAuthProviderParams {
505
readonly id: string;
506
readonly organizationId: string;
507
}
508
export type AdmissionLevel = "owner" | "everyone";
509
export type PinAction = "pin" | "unpin" | "toggle";
510
export interface GenerateNewGitpodTokenOptions {
511
name?: string;
512
type: GitpodTokenType;
513
scopes?: string[];
514
}
515
export interface OnboardingState {
516
/**
517
* Whether this Gitpod instance is already configured with SSO.
518
*/
519
readonly isCompleted: boolean;
520
/**
521
* Total number of organizations.
522
*/
523
readonly organizationCountTotal: number;
524
}
525
}
526
527
export const GitpodServerPath = "/gitpod";
528
529
export const GitpodServerProxy = Symbol("GitpodServerProxy");
530
export type GitpodServerProxy<S extends GitpodServer> = JsonRpcProxy<S>;
531
532
export class GitpodCompositeClient<Client extends GitpodClient> implements GitpodClient {
533
protected clients: Partial<Client>[] = [];
534
535
public registerClient(client: Partial<Client>): Disposable {
536
this.clients.push(client);
537
return {
538
dispose: () => {
539
const index = this.clients.indexOf(client);
540
if (index > -1) {
541
this.clients.splice(index, 1);
542
}
543
},
544
};
545
}
546
547
onInstanceUpdate(instance: WorkspaceInstance): void {
548
for (const client of this.clients) {
549
if (client.onInstanceUpdate) {
550
try {
551
client.onInstanceUpdate(instance);
552
} catch (error) {
553
console.error(error);
554
}
555
}
556
}
557
}
558
559
onPrebuildUpdate(update: PrebuildWithStatus): void {
560
for (const client of this.clients) {
561
if (client.onPrebuildUpdate) {
562
try {
563
client.onPrebuildUpdate(update);
564
} catch (error) {
565
console.error(error);
566
}
567
}
568
}
569
}
570
571
onWorkspaceImageBuildLogs(
572
info: WorkspaceImageBuild.StateInfo,
573
content: WorkspaceImageBuild.LogContent | undefined,
574
): void {
575
for (const client of this.clients) {
576
if (client.onWorkspaceImageBuildLogs) {
577
try {
578
client.onWorkspaceImageBuildLogs(info, content);
579
} catch (error) {
580
console.error(error);
581
}
582
}
583
}
584
}
585
586
notifyDidOpenConnection(): void {
587
for (const client of this.clients) {
588
if (client.notifyDidOpenConnection) {
589
try {
590
client.notifyDidOpenConnection();
591
} catch (error) {
592
console.error(error);
593
}
594
}
595
}
596
}
597
598
notifyDidCloseConnection(): void {
599
for (const client of this.clients) {
600
if (client.notifyDidCloseConnection) {
601
try {
602
client.notifyDidCloseConnection();
603
} catch (error) {
604
console.error(error);
605
}
606
}
607
}
608
}
609
}
610
611
export type GitpodService = GitpodServiceImpl<GitpodClient, GitpodServer>;
612
613
const hasWindow = typeof window !== "undefined";
614
const phasesOrder: Record<WorkspaceInstancePhase, number> = {
615
unknown: 0,
616
preparing: 1,
617
building: 2,
618
pending: 3,
619
creating: 4,
620
initializing: 5,
621
running: 6,
622
interrupted: 7,
623
stopping: 8,
624
stopped: 9,
625
};
626
export class WorkspaceInstanceUpdateListener {
627
private readonly onDidChangeEmitter = new Emitter<void>();
628
readonly onDidChange = this.onDidChangeEmitter.event;
629
630
private source: "sync" | "update" = "sync";
631
632
get info(): WorkspaceInfo {
633
return this._info;
634
}
635
636
constructor(private readonly service: GitpodService, private _info: WorkspaceInfo) {
637
service.registerClient({
638
onInstanceUpdate: (instance) => {
639
if (this.isOutOfOrder(instance)) {
640
return;
641
}
642
this.cancelSync();
643
this._info.latestInstance = instance;
644
this.source = "update";
645
this.onDidChangeEmitter.fire(undefined);
646
},
647
notifyDidOpenConnection: () => {
648
this.sync();
649
},
650
});
651
if (hasWindow) {
652
// learn about page lifecycle here: https://developers.google.com/web/updates/2018/07/page-lifecycle-api
653
window.document.addEventListener("visibilitychange", async () => {
654
if (window.document.visibilityState === "visible") {
655
this.sync();
656
}
657
});
658
window.addEventListener("pageshow", (e) => {
659
if (e.persisted) {
660
this.sync();
661
}
662
});
663
}
664
}
665
666
private syncQueue = Promise.resolve();
667
private syncTokenSource: CancellationTokenSource | undefined;
668
/**
669
* Only one sync can be performed at the same time.
670
* Any new sync request or instance update cancels all previously scheduled sync requests.
671
*/
672
private sync(): void {
673
this.cancelSync();
674
this.syncTokenSource = new CancellationTokenSource();
675
const token = this.syncTokenSource.token;
676
this.syncQueue = this.syncQueue.then(async () => {
677
if (token.isCancellationRequested) {
678
return;
679
}
680
try {
681
const info = await this.service.server.getWorkspace(this._info.workspace.id);
682
if (token.isCancellationRequested) {
683
return;
684
}
685
this._info = info;
686
this.source = "sync";
687
this.onDidChangeEmitter.fire(undefined);
688
} catch (e) {
689
console.error("failed to sync workspace instance:", e);
690
}
691
});
692
}
693
private cancelSync(): void {
694
if (this.syncTokenSource) {
695
this.syncTokenSource.cancel();
696
this.syncTokenSource = undefined;
697
}
698
}
699
700
/**
701
* If sync seen more recent update then ignore all updates with previous phases.
702
* Within the same phase still the race can occur but which should be eventually consistent.
703
*/
704
private isOutOfOrder(instance: WorkspaceInstance): boolean {
705
if (instance.workspaceId !== this._info.workspace.id) {
706
return true;
707
}
708
if (this.source === "update") {
709
return false;
710
}
711
if (instance.id !== this.info.latestInstance?.id) {
712
return false;
713
}
714
return phasesOrder[instance.status.phase] < phasesOrder[this.info.latestInstance.status.phase];
715
}
716
}
717
718
export interface GitpodServiceOptions {
719
onReconnect?: () => void | Promise<void>;
720
}
721
722
export class GitpodServiceImpl<Client extends GitpodClient, Server extends GitpodServer> {
723
private readonly compositeClient = new GitpodCompositeClient<Client>();
724
725
constructor(public readonly server: JsonRpcProxy<Server>, private options?: GitpodServiceOptions) {
726
server.setClient(this.compositeClient);
727
server.onDidOpenConnection(() => this.compositeClient.notifyDidOpenConnection());
728
server.onDidCloseConnection(() => this.compositeClient.notifyDidCloseConnection());
729
}
730
731
public registerClient(client: Partial<Client>): Disposable {
732
return this.compositeClient.registerClient(client);
733
}
734
735
private readonly instanceListeners = new Map<string, Promise<WorkspaceInstanceUpdateListener>>();
736
listenToInstance(workspaceId: string): Promise<WorkspaceInstanceUpdateListener> {
737
const listener =
738
this.instanceListeners.get(workspaceId) ||
739
(async () => {
740
const info = await this.server.getWorkspace(workspaceId);
741
return new WorkspaceInstanceUpdateListener(this, info);
742
})();
743
this.instanceListeners.set(workspaceId, listener);
744
return listener;
745
}
746
747
async reconnect(): Promise<void> {
748
if (this.options?.onReconnect) {
749
await this.options.onReconnect();
750
}
751
}
752
}
753
754