forked from iazzam-bornan/docker-session
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathworkspace.ts
More file actions
239 lines (216 loc) · 8.17 KB
/
Copy pathworkspace.ts
File metadata and controls
239 lines (216 loc) · 8.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// ─────────────────────────────────────────────────────────────────────────
// per-user workspace + port pool
//
// every session needs:
// • a private *home directory* on disk so the user can navigate a
// real filesystem (not just the project). the layout looks like:
//
// <workspaceRoot>/<sessionId>/home/
// ├── Desktop/
// ├── Documents/
// │ └── welcome.md
// ├── Downloads/
// └── projects/
// └── greeter-api/ ← cloned from apps/server/templates
//
// the user thinks they're in /home/<them>/, the server maps that to
// <workspaceRoot>/<sessionId>/home/.
// • a private slice of the host's port range so multiple users running
// `docker run -p 3000:3000` don't collide
// ─────────────────────────────────────────────────────────────────────────
import {
cpSync,
existsSync,
mkdirSync,
rmSync,
writeFileSync,
} from "node:fs"
import { resolve } from "node:path"
const TEMPLATES_ROOT = resolve(import.meta.dirname, "..", "templates")
const GREETER_TEMPLATE = resolve(TEMPLATES_ROOT, "greeter-api")
const TASKBOARD_TEMPLATE = resolve(TEMPLATES_ROOT, "taskboard")
/**
* Subpath inside the home dir where the *first* project lives. Used by
* the session type to cache the "default project dir" for docker commands.
* The second project (taskboard) also lives under ~/projects/.
*/
export const PROJECT_SUBPATH = "projects/greeter-api"
/** Absolute path of the home directory for this session. */
export function homeDirFor(workspaceRoot: string, sessionId: string): string {
return resolve(workspaceRoot, sessionId, "home")
}
/** Absolute path of the project (greeter-api) inside the user's home. */
export function projectDirFor(workspaceRoot: string, sessionId: string): string {
return resolve(homeDirFor(workspaceRoot, sessionId), PROJECT_SUBPATH)
}
/**
* Build the user's home dir on first hello. Subsequent calls are no-ops so
* any edits the user made are preserved across reconnects within the
* cleanup grace period.
*/
export function ensureWorkspace(workspaceRoot: string, sessionId: string): {
homeDir: string
projectDir: string
} {
const home = homeDirFor(workspaceRoot, sessionId)
const project = projectDirFor(workspaceRoot, sessionId)
if (existsSync(home)) {
return { homeDir: home, projectDir: project }
}
if (!existsSync(GREETER_TEMPLATE)) {
throw new Error(
`template directory missing: ${GREETER_TEMPLATE}. did you delete apps/server/templates/?`
)
}
// Create the standard top-level home folders so the file manager has
// something to show beyond just the project. Empty by design — users
// can create their own files via the terminal.
mkdirSync(resolve(home, "Desktop"), { recursive: true })
mkdirSync(resolve(home, "Documents"), { recursive: true })
mkdirSync(resolve(home, "Downloads"), { recursive: true })
mkdirSync(resolve(home, "projects"), { recursive: true })
// Seed Documents with a friendly welcome note so the file manager
// doesn't open empty.
writeFileSync(
resolve(home, "Documents", "welcome.md"),
[
"# welcome to dockerlab",
"",
"this is your private home directory.",
"",
"## projects",
"",
"you have two projects under ~/projects/:",
"",
"### greeter-api (start here)",
"a tiny express API — one endpoint, one file.",
"open the Dockerfile and fill in the `???` placeholders to",
"learn the basics: FROM, WORKDIR, COPY, RUN, EXPOSE, CMD.",
"",
"### taskboard (full-stack challenge)",
"a React + Express + MongoDB + Redis task board.",
"write TWO Dockerfiles: one for the backend (TypeScript compile step),",
"one for the frontend (multi-stage build: Node → nginx).",
"then run everything with `docker compose up --build`.",
"",
"## solutions",
"",
"each project has a `solutions/` folder with the working Dockerfiles.",
"try to write them yourself first! if you're stuck, peek at the answer.",
"",
"## getting started",
"",
"```",
"cd projects/greeter-api",
"code . # open in the editor",
"cat TUTORIAL.md # follow the step-by-step guide",
"```",
"",
"each project has:",
"- `README.md` — quick overview + reference",
"- `TUTORIAL.md` — full step-by-step walkthrough",
"- `Dockerfile` — your task (fill in the ???)",
"- `solutions/` — working answers (try yourself first!)",
"",
"everything you create here lives only inside your session.",
"when you disconnect, it gets cleaned up after a 90-second grace period.",
"",
].join("\n"),
"utf-8"
)
// Clone both project templates into projects/
cpSync(GREETER_TEMPLATE, project, { recursive: true })
if (existsSync(TASKBOARD_TEMPLATE)) {
cpSync(
TASKBOARD_TEMPLATE,
resolve(home, "projects", "taskboard"),
{ recursive: true }
)
}
return { homeDir: home, projectDir: project }
}
/**
* Wipe a session's home directory and any docker artifacts. Called by the
* cleanup sweep after the disconnect grace period expires.
*/
export function destroyWorkspace(workspaceRoot: string, sessionId: string): void {
const dir = resolve(workspaceRoot, sessionId)
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true })
}
}
// ─── port allocation ─────────────────────────────────────────────────────
//
// we hand out a 10-port slice per session starting at 30000. the user types
// `-p 3000:3000` but the host actually binds e.g. 30040:3000. we map the
// requested "guest port" to the next free slot in the user's slice.
//
// when the user reuses the same guest port across runs (the common case),
// they get the same host port back, so the browser preview keeps working
// without changing the URL.
const POOL_START = 30000
const SLICE_SIZE = 10
const sessionIndex = new Map<string, number>() // sessionId → slice index
const reservations = new Map<
string, // sessionId
Map<number, number> // guestPort → hostPort
>()
let nextSliceIndex = 0
function sliceFor(sessionId: string): number {
let idx = sessionIndex.get(sessionId)
if (idx === undefined) {
idx = nextSliceIndex++
sessionIndex.set(sessionId, idx)
}
return idx
}
/**
* Allocate (or look up) the host port that backs `guestPort` for this
* session. Returns null if the slice is full.
*/
export function reservePort(
sessionId: string,
guestPort: number
): number | null {
let perSession = reservations.get(sessionId)
if (!perSession) {
perSession = new Map()
reservations.set(sessionId, perSession)
}
const existing = perSession.get(guestPort)
if (existing !== undefined) return existing
const slice = sliceFor(sessionId)
const sliceBase = POOL_START + slice * SLICE_SIZE
for (let i = 0; i < SLICE_SIZE; i++) {
const candidate = sliceBase + i
// is anyone in this session already using `candidate` as a host port?
let taken = false
for (const [, host] of perSession) {
if (host === candidate) {
taken = true
break
}
}
if (!taken) {
perSession.set(guestPort, candidate)
return candidate
}
}
return null
}
/** Drop a single port reservation (e.g. on `docker rm`). */
export function releasePort(sessionId: string, guestPort: number): void {
reservations.get(sessionId)?.delete(guestPort)
}
/** Drop everything for a session (e.g. on disconnect cleanup). */
export function releaseAllPorts(sessionId: string): void {
reservations.delete(sessionId)
sessionIndex.delete(sessionId)
}
/** Look up an existing reservation without creating one. */
export function lookupHostPort(
sessionId: string,
guestPort: number
): number | undefined {
return reservations.get(sessionId)?.get(guestPort)
}