Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add asset support to :play #19

Merged
merged 16 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,088 changes: 943 additions & 145 deletions package-lock.json

Large diffs are not rendered by default.

21 changes: 13 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,25 @@
"@codemirror/lint": "6.5.0",
"@codemirror/state": "6.4.1",
"@codemirror/view": "6.26.3",
"@devvit/previews": "0.10.20",
"@devvit/protos": "0.10.20",
"@devvit/public-api": "0.10.20",
"@devvit/runtime-lite": "0.10.20",
"@devvit/shared-types": "0.10.20",
"@devvit/ui-renderer": "0.10.20",
"@devvit/previews": "0.10.21-next-2024-05-13-b48ce196f.0",
"@devvit/protos": "0.10.21-next-2024-05-13-b48ce196f.0",
"@devvit/public-api": "0.10.21-next-2024-05-13-b48ce196f.0",
"@devvit/runtime-lite": "0.10.21-next-2024-05-13-b48ce196f.0",
"@devvit/shared-types": "0.10.21-next-2024-05-13-b48ce196f.0",
"@devvit/ui-renderer": "0.10.21-next-2024-05-13-b48ce196f.0",
"@esm-bundle/chai": "4.3.4-fix.0",
"@types/jsdom": "21.1.6",
"@types/mocha": "10.0.6",
"@typescript/vfs": "1.5.0",
"@web/dev-server-esbuild": "1.0.2",
"@web/test-runner": "0.18.1",
"@zenfs/core": "0.9.7",
"@zenfs/dom": "0.2.6",
"@zenfs/zip": "0.3.1",
"bundlesize": "0.18.2",
ObsidianSnoo marked this conversation as resolved.
Show resolved Hide resolved
"codemirror": "6.0.1",
"esbuild": "0.20.2",
"idb-keyval": "6.2.1",
"jsdom": "24.0.0",
"lit": "3.1.3",
"lit-analyzer": "2.0.3",
Expand Down Expand Up @@ -56,8 +61,8 @@
"gzip": "3.5 KB"
},
"dist/play-pen.js": {
"none": "35000 KB",
"gzip": "5200 KB"
"none": "37000 KB",
"gzip": "5400 KB"
}
},
"typesVersions": {
Expand Down
323 changes: 323 additions & 0 deletions src/assets/asset-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
import type {AssetMap} from '@devvit/shared-types/Assets.js'
ObsidianSnoo marked this conversation as resolved.
Show resolved Hide resolved
import * as ZenFS from '@zenfs/core'
import {FileSystem, fs} from '@zenfs/core'
import {WebAccess, WebStorage} from '@zenfs/dom'
import {Zip} from '@zenfs/zip'
import * as IDB from 'idb-keyval'
import {hasFileAccessAPI, tryQueryPermission} from '../utils/file-access-api.js'

export type AssetFilesystemType = 'virtual' | 'local'

const LOG_TAG = '[AssetManager]'

const CACHE_LAST_DIR = 'lastMountedDirectory'
const CACHE_LAST_ZIP = 'lastMountedArchive'

const MIME: {[ext: string]: string} = {
html: 'text/html',
htm: 'text/html',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif'
}
const DEFAULT_MIME = 'application/octet-stream'

export class AssetManager {
ObsidianSnoo marked this conversation as resolved.
Show resolved Hide resolved
private static _waitForInit: Promise<void> = new Promise(resolve => {
this._initComplete = resolve
})
private static _initComplete: () => void
private static _filesystemType: AssetFilesystemType = 'virtual'
private static _assetMap: AssetMap = {}
private static _assetCount: number = 0
private static _eventTarget: EventTarget = new EventTarget()
private static _directoryHandle: FileSystemDirectoryHandle | undefined
private static _archiveHandle: FileSystemFileHandle | File | undefined

static get filesystemType(): AssetFilesystemType {
return this._filesystemType
}

static set filesystemType(type: AssetFilesystemType) {
if (type !== this._filesystemType) {
void this.initialize(type)
}
}

static get isDirectoryMounted(): boolean {
return this._directoryHandle !== undefined
}

static get isArchiveMounted(): boolean {
return this._archiveHandle !== undefined
}

static get directoryName(): string {
return this._directoryHandle?.name ?? ''
}

static get archiveFilename(): string {
return this._archiveHandle?.name ?? ''
}

static get fileCount(): number {
return this._assetCount
}

static get hasFileAccessAPI(): boolean {
return hasFileAccessAPI
}

ObsidianSnoo marked this conversation as resolved.
Show resolved Hide resolved
static get assetMap(): Promise<AssetMap> {
const resolvedMap = () => this._waitForInit.then(() => this._assetMap)
if (this.isDirectoryMounted) {
return this._updateMap().then(resolvedMap)
}
return resolvedMap()
}

static async initialize(filesystemType: AssetFilesystemType): Promise<void> {
this._filesystemType = filesystemType || 'virtual'
if (filesystemType === 'virtual') {
await this.mountVirtualFs()
} else {
const [lastMountedDirectory, lastMountedArchive] = await IDB.getMany([
CACHE_LAST_DIR,
CACHE_LAST_ZIP
])

if (lastMountedDirectory) {
if (await this._verifyPermissions(lastMountedDirectory)) {
await this.mountLocalDirectory(lastMountedDirectory)
} else {
await IDB.del(CACHE_LAST_DIR)
console.debug(
LOG_TAG,
'Failed to remount the previously used directory'
)
}
} else if (lastMountedArchive) {
if (await this._verifyPermissions(lastMountedArchive)) {
await this.mountLocalArchive(lastMountedArchive)
} else {
await IDB.del(CACHE_LAST_ZIP)
console.debug(
LOG_TAG,
'Failed to remount the previously used ZIP file'
)
}
} else {
this._emitChangeEvent()
}
}
this._initComplete()
}

static addEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions | undefined
): void {
this._eventTarget.addEventListener(type, callback, options)
}

static dispatchEvent(event: Event): boolean {
return this._eventTarget.dispatchEvent(event)
}

static removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: boolean | EventListenerOptions | undefined
): void {
this._eventTarget.removeEventListener(type, callback, options)
}

static async mountVirtualFs(): Promise<void> {
await this._mountRoot(WebStorage.create({}))
this._emitChangeEvent()
}

static async mountLocalDirectory(
directoryHandle: FileSystemDirectoryHandle
): Promise<void> {
await this._mountRoot(WebAccess.create({handle: directoryHandle}))
await this._updateCache({directory: directoryHandle})
this._directoryHandle = directoryHandle
this._archiveHandle = undefined
this._emitChangeEvent()
}

static async mountLocalArchive(
fileHandle: FileSystemFileHandle | File
): Promise<void> {
let file
if ('getFile' in fileHandle) {
file = await fileHandle.getFile()
} else {
file = fileHandle as File
}
await this._mountRoot(Zip.create({zipData: await file.arrayBuffer()}))
await this._updateCache({file: fileHandle})
this._archiveHandle = fileHandle
this._directoryHandle = undefined
this._emitChangeEvent()
}

static async remountLocalArchive(): Promise<void> {
if (this._archiveHandle) {
await this.mountLocalArchive(this._archiveHandle)
}
}

static async unmount(): Promise<void> {
this._unmountRoot()
await this._updateMap()
await this._updateCache({})
this._emitChangeEvent()
}

static async addVirtualAsset(
handle: File | FileSystemFileHandle
): Promise<void> {
let file: File
if ('getFile' in handle) {
file = await handle.getFile()
} else {
file = handle
}

await fs.promises.writeFile(
file.name,
new Uint8Array(await file.arrayBuffer()),
{flush: true}
)

await this._updateMap()
this._emitChangeEvent()
}

static async renameVirtualAsset(
oldName: string,
newName: string
): Promise<void> {
if (this.filesystemType !== 'virtual') {
return
}

await fs.promises.rename(oldName, newName)

await this._updateMap()
this._emitChangeEvent()
}

static async deleteVirtualAsset(name: string): Promise<void> {
if (this.filesystemType !== 'virtual') {
return
}

await fs.promises.unlink(name)

await this._updateMap()
this._emitChangeEvent()
}

static async clearVirtualAssets(): Promise<void> {
if (this.filesystemType !== 'virtual' || !this._assetMap) {
return
}

const removals = []
for (const name of Object.keys(this._assetMap)) {
removals.push(fs.promises.unlink(name))
}

await Promise.all(removals)

await this._updateMap()
this._emitChangeEvent()
}

private static async _mountRoot(fs: FileSystem): Promise<void> {
this._unmountRoot()
ZenFS.mount('/', fs)
await this._updateMap()
}

private static _unmountRoot(): void {
if (ZenFS.mounts.has('/')) {
ZenFS.umount('/')
this._archiveHandle = undefined
this._directoryHandle = undefined
}
}

private static async _updateMap(): Promise<void> {
this._clearAssetMap()
if (!ZenFS.mounts.has('/')) {
console.debug(LOG_TAG, 'Root filesystem unmounted, skipping _updateMap')
return
}
const entries: string[] = []
entries.push(...(await fs.promises.readdir('/')))
while (entries.length > 0) {
const entry = entries.shift()!
// remove the leading / present in some backends
const name = entry.replace(/^\//, '')
const stat = await fs.promises.stat(entry)
if (stat.isDirectory()) {
entries.unshift(
...(await fs.promises.readdir(entry)).map(child => {
if (child.startsWith('/')) return child
return `${entry}/${child}`
})
)
} else {
const file = await fs.promises.open(entry)
const buffer = await file.readFile()
await file.close()
const basename = entry.split('/').at(-1)!
const extension = basename.split('.').at(-1)!
const mimetype = MIME[extension] ?? DEFAULT_MIME
this._assetMap![name] = window.URL.createObjectURL(
new Blob([buffer], {type: mimetype})
)
this._assetCount++
}
}
console.debug(LOG_TAG, `Found ${this._assetCount} files`, this._assetMap)
ObsidianSnoo marked this conversation as resolved.
Show resolved Hide resolved
}

private static _clearAssetMap() {
const assetMap = this._assetMap!
for (const entry of Object.keys(assetMap)) {
const url = assetMap[entry] ?? undefined
if (url) {
URL.revokeObjectURL(url)
}
}
this._assetMap = {}
this._assetCount = 0
}

private static async _updateCache(handles: {
directory?: FileSystemDirectoryHandle | undefined
file?: FileSystemFileHandle | File | undefined
}): Promise<void> {
await IDB.del(CACHE_LAST_DIR)
await IDB.del(CACHE_LAST_ZIP)
await IDB.set(CACHE_LAST_DIR, handles.directory)
await IDB.set(CACHE_LAST_ZIP, handles.file)
}

private static _emitChangeEvent(): void {
this._eventTarget.dispatchEvent(new CustomEvent('change'))
}

private static async _verifyPermissions(
handle: FileSystemHandle
): Promise<boolean> {
return (await tryQueryPermission(handle, {mode: 'read'})) === 'granted'
}
}
10 changes: 8 additions & 2 deletions src/bundler/linker.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import type {LinkedBundle, SerializableServiceDefinition} from '@devvit/protos'
import type {AssetMap} from '@devvit/shared-types/Assets.js'

/**
* @arg es JavaScript
* @arg hostname Arbitrary but something unique to the window like
* hello-world.local may allow concurrent sessions with the
* remote.
* @arg assets AssetMap describing how to map project assets to URLs
*/
export function link(es: string, hostname: string): LinkedBundle {
export function link(
es: string,
hostname: string,
assets?: AssetMap
ObsidianSnoo marked this conversation as resolved.
Show resolved Hide resolved
): LinkedBundle {
return {
actor: {name: 'pen', owner: 'play', version: '0.0.0.0'},
assets: {},
assets: assets ?? {},
code: es,
hostname,
provides: provides(),
Expand Down
7 changes: 7 additions & 0 deletions src/elements/play-assets-dialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {assert} from '@esm-bundle/chai'
import {PlayAssetsDialog} from './play-assets-dialog'

test('tag is defined', () => {
const el = document.createElement('play-assets-dialog')
assert.instanceOf(el, PlayAssetsDialog)
})
Loading