diff --git a/docs/docs/env-vars.md b/docs/docs/env-vars.md index 58561f6c..ac04c7f8 100644 --- a/docs/docs/env-vars.md +++ b/docs/docs/env-vars.md @@ -20,6 +20,8 @@ CodeRoad has a number of configurations: - `CODEROAD_WEBHOOK_TOKEN` - an optional token for authenticating/authorizing webhook endpoints. Passed to the webhook endpoint in a `CodeRoad-User-Token` header. +- `CODEROAD_SESSION_STORAGE_PATH` - the path to a directory for writing session storage to files. Helps preserves state across containers. Example: `../tmp`. + ## How to Use Variables ### Local diff --git a/src/actions/onStartup.ts b/src/actions/onStartup.ts index 09cc97a3..98235536 100644 --- a/src/actions/onStartup.ts +++ b/src/actions/onStartup.ts @@ -35,8 +35,8 @@ const onStartup = async (context: Context): Promise => { // NEW: no stored tutorial, must start new tutorial if (!tutorial || !tutorial.id) { - if (!!TUTORIAL_URL) { - // NEW_FROM_URL + if (TUTORIAL_URL) { + // if a tutorial URL is added, launch on startup try { const tutorialRes = await fetch(TUTORIAL_URL) const tutorial = await tutorialRes.json() diff --git a/src/environment.ts b/src/environment.ts index 59077d61..8073609b 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -46,3 +46,6 @@ export const CONTENT_SECURITY_POLICY_EXEMPTIONS: string | null = // optional token for authorization/authentication of webhook calls export const WEBHOOK_TOKEN = process.env.CODEROAD_WEBHOOK_TOKEN || null + +// a path to write session state to a file. Useful for maintaining session across containers +export const SESSION_STORAGE_PATH = process.env.CODEROAD_SESSION_STORAGE_PATH || null diff --git a/src/services/context/state/Position.ts b/src/services/context/state/Position.ts index 50c649f0..fe90d2da 100644 --- a/src/services/context/state/Position.ts +++ b/src/services/context/state/Position.ts @@ -19,6 +19,7 @@ class Position { setTutorial(workspaceState: vscode.Memento, tutorial: TT.Tutorial): void { this.storage = new Storage({ key: `coderoad:position:${tutorial.id}:${tutorial.version}`, + filePath: 'coderoad_position', storage: workspaceState, defaultValue, }) diff --git a/src/services/context/state/Tutorial.ts b/src/services/context/state/Tutorial.ts index 1195e7bf..40ecc48c 100644 --- a/src/services/context/state/Tutorial.ts +++ b/src/services/context/state/Tutorial.ts @@ -9,6 +9,7 @@ class Tutorial { constructor(workspaceState: vscode.Memento) { this.storage = new Storage({ key: 'coderoad:currentTutorial', + filePath: 'coderoad_tutorial', storage: workspaceState, defaultValue: null, }) diff --git a/src/services/node/index.ts b/src/services/node/index.ts index a90dd208..78bfac20 100644 --- a/src/services/node/index.ts +++ b/src/services/node/index.ts @@ -7,25 +7,41 @@ import { WORKSPACE_ROOT } from '../../environment' const asyncExec = promisify(cpExec) const asyncRemoveFile = promisify(fs.unlink) const asyncReadFile = promisify(fs.readFile) +const asyncWriteFile = promisify(fs.writeFile) interface ExecParams { command: string dir?: string } +// correct paths to be from workspace root rather than extension folder +const getWorkspacePath = (...paths: string[]) => { + return join(WORKSPACE_ROOT, ...paths) +} + export const exec = (params: ExecParams): Promise<{ stdout: string; stderr: string }> | never => { const cwd = join(WORKSPACE_ROOT, params.dir || '') return asyncExec(params.command, { cwd }) } export const exists = (...paths: string[]): boolean | never => { - return fs.existsSync(join(WORKSPACE_ROOT, ...paths)) + return fs.existsSync(getWorkspacePath(...paths)) } export const removeFile = (...paths: string[]) => { - return asyncRemoveFile(join(WORKSPACE_ROOT, ...paths)) + return asyncRemoveFile(getWorkspacePath(...paths)) +} + +export const readFile = (...paths: string[]): Promise => { + const filePath = getWorkspacePath(...paths) + return asyncReadFile(getWorkspacePath(...paths), 'utf8').catch((err) => { + console.warn(`Failed to read from ${filePath}: ${err.message}`) + }) } -export const readFile = (...paths: string[]) => { - return asyncReadFile(join(...paths)) +export const writeFile = (data: any, ...paths: string[]): Promise => { + const filePath = getWorkspacePath(...paths) + return asyncWriteFile(filePath, data).catch((err) => { + console.warn(`Failed to write to ${filePath}: ${err.message}`) + }) } diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 8a64cceb..db7ebe34 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -1,4 +1,6 @@ import * as vscode from 'vscode' +import { readFile, writeFile } from '../node' +import { SESSION_STORAGE_PATH } from '../../environment' // NOTE: localStorage is not available on client // and must be stored in editor @@ -8,23 +10,55 @@ import * as vscode from 'vscode' // forcing it to be passed in through activation and down to other tools class Storage { private key: string + private filePath: string private storage: vscode.Memento private defaultValue: T - constructor({ key, storage, defaultValue }: { key: string; storage: vscode.Memento; defaultValue: T }) { + constructor({ + key, + filePath, + storage, + defaultValue, + }: { + key: string + filePath: string + storage: vscode.Memento + defaultValue: T + }) { this.storage = storage this.key = key + this.filePath = filePath this.defaultValue = defaultValue } public get = async (): Promise => { const value: string | undefined = await this.storage.get(this.key) if (value) { return JSON.parse(value) + } else if (SESSION_STORAGE_PATH) { + try { + // optionally read from file as a fallback to local storage + const sessionFile = await readFile(SESSION_STORAGE_PATH, `${this.filePath}.json`) + if (!sessionFile) { + throw new Error('No session file found') + } + const data: T = JSON.parse(sessionFile) + + if (data) { + // validate session + const keys = Object.keys(data) + if (keys.length) { + return data + } + } + } catch (err) { + console.warn(`Failed to read or parse session file: ${SESSION_STORAGE_PATH}/${this.filePath}.json`) + } } return this.defaultValue } public set = (value: T): void => { const stringValue = JSON.stringify(value) this.storage.update(this.key, stringValue) + this.writeToSessionFile(stringValue) } public update = async (value: T): Promise => { const current = await this.get() @@ -32,7 +66,19 @@ class Storage { ...current, ...value, }) - this.storage.update(this.key, next) + await this.storage.update(this.key, next) + + this.writeToSessionFile(next) + } + public writeToSessionFile(data: string) { + // optionally write state to file, useful when state cannot be controlled across containers + if (SESSION_STORAGE_PATH) { + try { + writeFile(data, SESSION_STORAGE_PATH, `${this.filePath}.json`) + } catch (err: any) { + console.warn(`Failed to write coderoad session to path: ${SESSION_STORAGE_PATH}/${this.filePath}.json`) + } + } } public reset = () => { this.set(this.defaultValue)